
1. 为什么插件系统才是 DeepSeek Harness 的真正分水岭很多人第一次接触 DeepSeek Harness后面我统一简称 dsh注意力都放在怎么装怎么启动怎么连本地模型这些环节上。装完之后跑通一个对话觉得也就那样——不就是个命令行里的 AI 助手吗但真正把 dsh 用出生产力的人几乎都会在同一个地方停下来琢磨很久插件。原因很直接。dsh 本体提供的是一个智能体运行时它负责调度模型、管理会话、编排多个 Agent 之间的协作。但这个智能体能干什么具体的事——读 PDF、查数据库、跑代码诊断、操作浏览器、接入某个垂直工具——这些能力全部由插件来定义。换句话说dsh 本体是骨架插件才是肌肉。你装十个插件和装一个插件dsh 能解决的问题完全不是一个量级。这一篇是DeepSeek Harness 入门很简单系列的第四篇前三篇我们把安装、配置、本地模型连接这些基础打完了。这一篇专门讲插件接入。我会从插件的加载机制讲起然后手把手带你写一个能跑起来的最小插件再讲清楚 Cordis 这套依赖注入框架在插件里扮演什么角色最后把插件市场、配置读取、常见报错这些实战里绕不开的东西一次性说透。适合谁看如果你已经能把 dsh 跑起来能正常对话但不知道插件从哪来、怎么写、怎么调那这篇就是给你准备的。如果你还没装好 dsh建议先回去看前三篇否则插件部分你会缺少很多上下文。提示本篇涉及的所有代码和配置都是基于 dsh 当前稳定版本的插件规范。不同小版本之间 API 可能有细微差异遇到对不上的地方优先以你本地dsh --version对应的文档为准。2. dsh 插件的加载机制它到底在什么时候被唤醒2.1 插件不是启动即加载而是按需激活这是新手最容易误解的一点。很多人以为插件跟浏览器扩展一样装完重启就一直在后台跑。dsh 不是这个逻辑。dsh 的插件采用的是声明式注册 运行时激活的模式。插件在安装后dsh 只会在启动阶段读取它的清单文件manifest把插件声明的能力、依赖、触发条件登记到一张内部的能力表里。真正调用插件里的逻辑是在某个 Agent 明确需要这项能力的时候。举个具体场景。你装了一个读取 PDF的插件它声明自己能处理.pdf后缀的文件。当你跟 dsh 说帮我总结一下这份 report.pdfdsh 的调度层会去能力表里查谁声明了 PDF 处理能力查到你的插件然后才把这份文件路径传进去激活插件的执行逻辑。如果你这一整轮对话都没碰 PDF这个插件从头到尾就是一行注册信息不占运行资源。这个设计的好处是插件可以装很多不会互相拖慢。坏处是——插件没被触发你很难判断它到底装好没有。这也是后面插件装了没反应这类问题的根源。2.2 清单文件里到底写了什么每个 dsh 插件目录下都有一个清单文件通常叫dsh-plugin.json或者写在package.json的特定字段里。它的核心字段大致是这几类字段作用是否必填name插件唯一标识不能和已装的重复必填version版本号用于依赖解析和升级判断必填capabilities声明这个插件提供哪些能力是调度的依据必填triggers触发条件比如文件类型、命令前缀、关键词选填dependencies依赖的其他插件或运行时版本选填config插件自己的配置项 schema选填entry入口文件路径必填capabilities和triggers是最关键的两个。前者告诉 dsh我能干什么后者告诉 dsh什么时候该叫我。很多插件装了不生效八成是这两个字段写得含糊导致调度层根本匹配不上。2.3 加载顺序与依赖解析dsh 启动时会扫描插件目录构建一张依赖图。如果插件 A 依赖插件 BB 会先加载。这里有个坑循环依赖会导致启动直接失败而且报错信息往往只告诉你依赖解析失败不告诉你是哪两个插件互相依赖。排查的时候要自己顺着dependencies字段一个个看。另外同名插件不同版本共存时dsh 默认只保留版本号最高的那个。如果你确实需要两个版本并存比如某个老插件只兼容旧 API得在配置里显式声明隔离这个后面配置章节会讲。3. 从零写一个最小可用插件把读文件这件事跑通光讲机制太虚我们直接动手。目标写一个插件让 dsh 能读取指定路径的文本文件并返回内容。这个插件足够小但把注册、触发、执行、返回这条链路全走了一遍。3.1 目录结构与入口约定先在 dsh 的插件目录下建一个文件夹名字随意但建议和插件name保持一致方便管理dsh-plugins/ file-reader/ dsh-plugin.json index.jsindex.js就是入口文件entry字段指向它。dsh 加载插件时会require这个文件拿到它导出的对象。3.2 清单文件怎么写{ name: file-reader, version: 0.1.0, entry: index.js, capabilities: [file.read.text], triggers: [ { type: keyword, pattern: 读取文件|read file } ], config: { maxSize: { type: number, default: 1048576, description: 单次读取的最大字节数 } } }这里capabilities声明了一个能力file.read.texttriggers声明当用户输入里出现读取文件或read file时调度层可以考虑激活这个插件。config里定义了一个maxSize默认 1MB防止有人让它读一个几百兆的日志把内存撑爆。3.3 入口逻辑注册与执行分离module.exports { // 注册阶段dsh 启动时调用只做登记不做重活 register(ctx) { ctx.registerCapability(file.read.text, async (params, config) { const fs require(fs).promises; const path require(path); const target path.resolve(params.path); const stat await fs.stat(target); if (stat.size config.maxSize) { throw new Error( 文件大小 ${stat.size} 字节超过配置上限 ${config.maxSize} ); } const content await fs.readFile(target, utf-8); return { path: target, size: stat.size, content }; }); }, // 可选插件卸载时的清理逻辑 unregister(ctx) { ctx.unregisterCapability(file.read.text); } };注意register里我们只做了一件事把能力注册进去。真正的文件读取逻辑是放在回调里的等被调用时才执行。这就是前面说的按需激活在代码层面的体现。ctx这个参数是 dsh 注入进来的上下文对象它提供了registerCapability、unregisterCapability、读取配置、访问日志等一系列方法。这个ctx就是 Cordis 框架的产物下一节详细讲。3.4 验证插件是否真的被加载写完别急着用先确认 dsh 认没认这个插件。执行dsh plugin list正常的话你应该能在列表里看到file-reader0.1.0状态是loaded。如果状态是error说明加载阶段就出问题了通常是清单文件格式错误或者入口文件路径不对。确认加载成功后再跑一句dsh plugin inspect file-reader这个命令会打印出插件声明的所有能力和触发条件。如果capabilities是空的那说明你的清单文件没被正确解析回去检查 JSON 语法。注意dsh plugin list显示loaded只代表注册成功不代表功能可用。功能可用性要实际触发一次才知道。这两件事一定要分开看否则排查问题时会走很多弯路。4. Cordis 在插件体系里到底管什么4.1 先搞清楚 Cordis 解决的是什么问题如果你之前没接触过 Cordis可以把它理解成一套依赖注入 生命周期管理的框架。dsh 的插件系统建立在它之上。为什么需要这么一层想象一下你有 20 个插件其中 5 个都需要读取配置这个能力3 个都需要写日志这个能力。如果每个插件都自己实现一遍代码重复不说配置来源、日志格式还会各不相同。Cordis 的做法是把这些公共能力做成服务插件通过ctx声明自己需要哪些服务框架负责在合适的时机把服务实例注入进来。4.2 ctx 上你能拿到什么在插件的register(ctx)里ctx暴露的东西大致分几类能力注册类registerCapability、unregisterCapability前面用过。服务获取类ctx.get(logger)、ctx.get(config)这类拿到框架或其他插件提供的服务。配置读取类ctx.config直接读当前插件自己的配置已经按 schema 校验和填默认值了。生命周期钩子ctx.on(ready, fn)、ctx.on(dispose, fn)在特定时机执行逻辑。事件总线ctx.emit、ctx.on插件之间可以互相通信。这套东西的价值在于解耦。你的插件不需要知道日志服务是谁实现的只需要ctx.get(logger)拿到一个符合接口的对象就能用。将来 dsh 换了日志实现你的插件一行都不用改。4.3 依赖声明与注入时机插件如果依赖某个服务最好在清单里显式声明{ dependencies: { services: [logger, config] } }声明之后Cordis 会保证在调用你的register之前这两个服务已经就绪。如果你没声明却直接ctx.get(logger)在某些加载顺序下可能拿到undefined然后就是经典的Cannot read property info of undefined。我踩过这个坑早期写插件时图省事不声明依赖本地测试没问题一放到插件多的环境里就随机报错。后来老老实实把依赖写全问题再没出现过。依赖声明不是可选项是稳定性的保险。4.4 服务之间的通信别用全局变量插件之间要协作怎么办比如插件 A 处理完文件想让插件 B 接着做分析。正确做法是通过 Cordis 的事件总线// 插件 A处理完后发事件 ctx.emit(file.processed, { path, content }); // 插件 B监听事件 ctx.on(file.processed, (payload) { // 拿到 A 的产出继续处理 });绝对不要用模块级的全局变量来传递数据。dsh 支持多会话并发全局变量在并发场景下会串数据而且极难排查。事件总线是框架层面提供的、带会话隔离的通信方式用它。5. 插件市场与安装从哪拿、怎么装、装完怎么管5.1 插件市场的定位dsh 有一个官方的插件市场里面收录了社区贡献的各类插件。你可以通过命令行直接搜索和安装dsh plugin search pdf dsh plugin install pdf-reader市场里的插件质量参差不齐装之前建议看一眼它的capabilities声明和最近更新时间。一个两年没更新、能力声明又特别宽泛的插件谨慎使用——它可能申请了远超实际需要的权限。5.2 本地插件与市场插件的关系本地开发的插件和市场安装的插件在 dsh 眼里没有本质区别都是插件目录下的一个文件夹。区别只在于来源和升级方式。本地插件你自己维护市场插件可以用dsh plugin update统一升级。如果你在本地改了一个市场插件想覆盖原版直接把改好的文件夹放到插件目录dsh 会优先加载本地版本。这个机制方便调试但记得调试完要么提交上游要么在配置里锁定版本否则下次update会把你的改动冲掉。5.3 插件的启用、禁用与版本锁定不是所有装了的插件都要一直开着。禁用某个插件dsh plugin disable pdf-reader禁用后插件还在只是不参与能力注册。想彻底移除用dsh plugin remove。版本锁定在配置文件的插件段里做plugins: pdf-reader: version: 1.2.3 enabled: true config: maxPages: 50锁定版本的意义在于避免某次自动升级引入不兼容的 API 变更把你原本跑得好好的流程搞崩。生产环境里我强烈建议所有插件都锁版本。5.4 配置读取插件怎么拿到自己的配置前面清单里定义的configschema最终会体现在 dsh 的主配置文件里。插件在运行时通过ctx.config读取register(ctx) { const maxSize ctx.config.maxSize; // 已经填好默认值并校验过类型 // ... }配置的优先级是用户配置文件 插件清单里的 default。如果用户没配就用 default配了但类型不对dsh 启动时会直接报错不会让你带着错误配置跑起来。这个校验机制省了很多运行时排查的功夫。6. 实战中最容易踩的五个坑6.1 插件装了但完全没反应这是最高频的问题。排查顺序建议这样走dsh plugin list确认状态是loaded而不是error。dsh plugin inspect name确认capabilities和triggers不为空。检查你的触发词是否真的出现在输入里。触发是精确匹配还是模糊匹配取决于triggers的type。看 dsh 的运行日志搜索插件名看调度层有没有尝试激活它。大部分没反应卡在第 3 步——用户以为说了句相关的话就能触发实际上触发条件写得太死没匹配上。6.2 插件之间的能力冲突两个插件声明了同一个capabilities标识dsh 默认会用加载顺序靠后的那个覆盖前面的。这种覆盖是静默的不会报错。结果就是你以为在用 A实际跑的是 B。避免方法给自己的能力标识加命名空间前缀比如myorg.file.read别用file.read这种大众名。装第三方插件时也留意一下有没有能力标识撞车。6.3 配置文件路径写错导致读不到插件里读文件路径一定要用绝对路径或者基于明确的基准目录解析。相对路径的基准在不同启动方式下可能不一样——dsh web启动和dsh直接启动工作目录可能不同。我见过有人本地测试好好的一换成 web 模式就找不到文件就是这个问题。统一用path.resolve(__dirname, ...)或者从配置里读绝对路径别依赖当前工作目录。6.4 插件里的异步操作没处理好dsh 的调度层是异步的。如果你的插件回调里启动了异步任务却没await调度层会认为你已经返回了直接拿一个undefined去往下走。表现就是插件好像执行了但结果不对。规则很简单回调函数里所有异步操作都要 await或者显式返回 Promise。别用setTimeout糊弄时序问题那只会让 bug 更隐蔽。6.5 升级 dsh 后插件集体失效dsh 大版本升级时插件 API 可能有破坏性变更。升级前先看 changelog 里有没有标注 breaking change有的话把所有插件过一遍。稳妥做法是升级前备份插件目录和配置出问题能快速回滚。7. 把插件用出体系几个值得投入的方向单个插件解决单点问题但 dsh 真正的威力在于多个插件 多 Agent 编排组合起来。举几个我实际在用的方向。第一个方向是文档处理链路。一个插件负责识别文件类型一个负责抽取文本一个负责结构化最后一个负责摘要。四个插件串起来丢进去一份 PDF出来的就是一份带要点的摘要。每个插件都很小但组合起来覆盖了完整的文档处理流程。第二个方向是代码诊断。接入代码诊断类插件后dsh 可以在你贴出代码片段时自动跑一遍静态检查把问题标出来。配合 Agent Teams 的多智能体编排一个 Agent 负责诊断一个负责给修复建议效率比单打独斗高不少。第三个方向是配置驱动的能力开关。把插件的启用状态、参数都放到配置文件里不同项目用不同的配置组合。这样同一套 dsh 环境切个项目就换一套能力不用反复装卸插件。这三个方向的共同点是插件要小而专组合靠编排。别指望写一个包打天下的大插件那种插件维护成本高、复用性差最后往往变成谁都不敢动的祖传代码。8. 关于调试怎么快速定位插件问题调试插件日志是第一手资料。dsh 的日志分级里插件相关的信息通常在 debug 级别。启动时加上详细日志参数dsh --log-level debug然后在输出里搜你的插件名能看到注册、激活、执行、返回的完整链路。哪一步断了问题就在哪。如果日志不够可以在插件代码里临时打点register(ctx) { const logger ctx.get(logger); logger.debug([file-reader] register called); ctx.registerCapability(file.read.text, async (params, config) { logger.debug([file-reader] invoked with, params); // ... }); }用框架提供的 logger别用console.log。前者会带上插件名和会话标识后者在并发场景下你根本分不清是哪次调用打的。还有一个技巧写一个最小的测试输入专门用来触发你的插件。比如触发词是读取文件那就准备一句读取文件 /tmp/test.txt每次改完插件先跑这一句确认链路通了再测复杂场景。这样能把插件本身的问题和输入太复杂导致的问题分开。9. 我个人的一点使用体会插件这块我最大的体会是先跑通最小闭环再谈功能丰富。很多人一上来就想写个功能齐全的插件结果卡在注册没成功、触发没匹配这些基础问题上折腾半天连Hello World都没跑出来信心就没了。正确的节奏是先写一个只声明能力、回调里直接返回固定字符串的插件确认能被触发、能返回结果。这一步通了再往里填真实逻辑。基础链路是骨架功能是肉骨架没搭好肉往哪挂另外插件写多了之后一定要做能力清单管理。我会维护一个表格记录每个插件提供什么能力、依赖什么服务、配置项有哪些。插件超过十个之后没有这张表你自己都记不清哪个能力是谁提供的。这个习惯帮我省了无数次这个功能到底哪个插件在管的困惑。最后别怕读别人的插件源码。市场里那些下载量高的插件代码结构、错误处理、配置设计都有值得抄的地方。看三个高质量插件的源码比自己闷头写十个学到的都多。