本章摘要:这一章是全书从「看懂」转向「做出东西」的分水岭,核心技能只有一句话——先问「我要加的行为挂在哪个扩展点」,再问「怎么写」。全章包含六个可直接照做的实操:写一个工具、写一个权限门禁、写一个模型适配器、让界面显示实时输出、写一个协议驱动、委派给子 agent。每个实操都按「它挂在哪 → 最小可用代码 → 跑起来看什么 → 容易踩的坑」的节奏展开,最后用一张「加新行为的完整清单」和三句话收尾。读完这一章,第九章之前提到的每一个扩展点你都会亲手碰到一遍。
10.1 第一步:学会问「挂在哪」
这是全部开发工作的核心技能。不要一开始就想「我要写什么代码」,先问:「我要加的这个行为,应该挂在哪个扩展点上?」
官方在架构文档里给了一张「新行为的归属位置」表,在实操手册里给了另一张更贴近产品的「功能→机制」映射表。两者合起来,就是一张完整的地图。下面按「你想做什么」重组一次,方便检索。
这张图请记住。它回答的是这本书一开始提出的那个问题。
10.2 零基础环境搭建:先把东西跑起来
在写任何插件之前,先让它跑起来。官方给了两条路。
路线一:直接用 npm(最快)
npx @deepseek-ai/dsh web这会默认在http://127.0.0.1:3080启动 Web UI,本机启动时还会用默认浏览器打开页面。
两个实用参数:
| 参数 | 作用 |
|---|---|
--no-open | 只运行服务器,不打开浏览器 |
--patch <路径> | 临时叠加一层 patch。开发插件时最常用它,因为你不想每次都改 profile |
官方还提到一个 SSH 场景的细节:通过 SSH 启动时只打印宿主机 URL,因为本地转发地址由 SSH 客户端或编辑器持有。
路线二:从源码运行(要读源码、写插件时必须)
gitclone https://github.com/deepseek-ai/deepseek-harness.gitcddeepseek-harnesspnpminstallpnpmrun buildpnpmdsh web两个命令的差别值得注意:pnpm run build准备仓库产物;pnpm dsh web直接使用这些已构建产物,不会重新构建。开发时你大概想要的是pnpm run dev:web——它在源码修改时重建客户端 bundle。
陷阱
官方在 README 里用加粗写了一条:**运行本项目前,请阅读安全说明。**另外项目处于「开发者预览」阶段并且明确写着「未来将出现破坏兼容性的变更」。所以:不要在关键生产环境上直接依赖它,读到的一切请以你手上那份代码为准。
学 Cordis 之后再动 harness
官方给了一条很明确的路径建议:Cordis 有一份七章的动手教程(第一个插件 → 生命周期与 effect → 服务 → 事件 → 配置 → 组合与 HMR → 进入 harness),每一章都是一个可以运行的示例,而且全程不需要 API 密钥。如果你想认真做插件,把这七章过一遍的收益远高于直接读架构文档。教程的第一章从
tmp/cordis-tutorial目录里跑node --import tsx ../../vendor/cordis/bin.js开始。
10.3 实操一:写一个工具(最完整的例子)
这是最常做的开发。第 6 章给了骨架,这里给一个完整、带解释的版本。
import{readFile}from'node:fs/promises'importtype{Context}from'@deepseek-ai/cordis'import{defineTool}from'@deepseek-ai/dsh-tools'exportconstname='my-tools'exportconstinject=['tools']exportfunctionapply(ctx:Context){ctx.tools.register(defineTool({name:'read_file',description:'Read a UTF-8 text file from disk. '+'Use this before editing a file. '+'Returns the file content as text.',parameters:{path:{type:'string',required:true,description:'Absolute path'},limit:{type:'number',description:'Max lines to return'},},output:{schema:{type:'string'},render:(_args,value)=>[{type:'text',text:value}],},timeoutMs:10_000,asyncexecute(args,exec){consttext=awaitreadFile(args.path,{encoding:'utf8',signal:exec.signal})constlines=text.split('\n')returnargs.limit?lines.slice(0,args.limit).join('\n'):text},}))}逐段解释这份代码「为什么这么写」:
| 片段 | 为什么 |
|---|---|
inject = ['tools'] | 让 Cordis 等工具注册表就绪。少了它,你的ctx.tools可能是 undefined |
description写了三句话 | **这是模型理解这个工具的唯一依据。**第一句说做什么,第二句说什么时候用(很重要,能显著减少误用),第三句说返回什么 |
parameters里path标了required | defineTool会在执行前校验。execute收到的args已经是强类型且合法的 |
output.schema是{ type: 'string' } | 函数体返回一个字符串。注册表会按这个 schema 校验返回值——返回不对的类型会被转成isError |
render把字符串包成一个 text 块 | 规范值 → 模型可见内容的投影。这个分离让 PTC 里的代码能拿到裸字符串,而不是一段包装过的内容块 |
timeoutMs: 10_000 | 声明超时预算。注意:它同时是一份承诺——你声明了它,就意味着这个工具会把exec.signal转给一个能收敛的实现。readFile支持 signal,所以这里成立 |
signal: exec.signal | 把取消信号传下去。用户按停止时,这个读操作会被真正取消 |
怎么跑起来
官方教程给的方式是:把插件写在一个目录里,用--patch指过去:
pnpmdsh web--patch./scratch-plugin/cordis.yml然后在界面里对模型说:Use the greet tool to greet Ada.(这是官方教程里的原话,把 greet 换成你的工具名即可)。
进阶要点速查
| 需求 | 怎么做 |
|---|---|
| 参数校验更复杂(非空、正数、跨字段) | schema DSL 表达不了的,在execute里手动检查 |
| 让模型看到结构化结果 | 把output.schema设计成实用的程序化 API:直接返回句柄与字段 |
| 给界面做漂亮卡片 | 用presentCall/presentResult返回渲染意图。必须是纯函数 |
| 让卡片在回放时也能还原 | 用output.presentationMeta(args, value)投影出可回放的 JSON |
| 跑很久的任务 | 用ctx.jobs.start({ kind, label, owner: exec.agent, run }) |
| 工具执行后给模型补一段说明 | exec.deferContext(...)——会在tool/result之后追加一条 user/message |
| 让这个工具结束整个轮次 | exec.concludeTurn()(只在成功结果上有效) |
| 异步通知模型(不唤醒) | exec.agent.inject({ content, source: { kind: 'plugin', plugin: '你的名字' } }),并 try/catch 防已销毁的 agent |
10.4 实操二:写一个权限门禁(钩子插件)
第 1 章已经给过这个例子。这里补充「完整版」要考虑的几件事。
importtype{Context}from'@deepseek-ai/cordis'importtype{PreToolDecision,ToolExecution}from'@deepseek-ai/dsh-tools'// 危险命令清单(示意)constDANGEROUS=[/rm\s+-rf\s+\//,/mkfs/,/:\(\)\{:\|:&\};:/]functioninspect(exec:ToolExecution):{deny?:string;ask?:boolean}{if(exec.name!=='bash')return{}constcmd=String((exec.argumentsas{command?:string})?.command??'')if(DANGEROUS.some(re=>re.test(cmd))){return{deny:'This command matches a destructive pattern and is blocked.'}}if(/\bgit\s+push\b/.test(cmd)){return{ask:true}// 让它走人工确认}return{}}exportconstname='my-permission-gate'exportconstinject=['tools']exportfunctionapply(ctx:Context){ctx.on('tools/pre-execute',async(exec,next):Promise<PreToolDecision>=>{constverdict=inspect(exec)if(verdict.deny){return{kind:'deny',reason:verdict.deny}}if(verdict.ask){return{kind:'ask',reason:'push-to-remote',// 审计用的原因displayReason:{en:'Push to remote?',zh:'要推送到远端吗?'},}}returnnext()// 没意见,交给下游})// 一条不可撤销的底线:无论谁放行,都不允许在根目录做破坏性写操作ctx.tools.guard((exec)=>{if(exec.name==='bash'&&/\brm\s+-rf\s+\/\s*$/.test(String((exec.argumentsasany)?.command??''))){return'Refusing to remove the filesystem root.'}returnundefined})}这个版本比第 1 章多了三个要点:
| 要点 | 为什么 |
|---|---|
deny和ask分开用 | deny是「绝对不行」,ask是「让用户决定」。ask会走ctx.approval,只有allowed-once才继续,而且没有回答方时直接拒绝 |
displayReason做了本地化 | 它是给用户看的文案;而reason是审计用的稳定标识。两者的读者不同,不要混用 |
最后挂了一条guard | **守卫没有 allow,所以这条底线无法被任何后续监听器翻案。**这就是「单调」的价值 |
10.5 实操三:写一个模型适配器
接入一家新的模型厂商,需要实现LlmAdapter。它的接口设计得很精简——只有一个抽象方法必须实现。
import{LlmAdapter}from'@deepseek-ai/dsh-llm'importtype{Context}from'@deepseek-ai/cordis'classMyProviderAdapterextendsLlmAdapter{/** 唯一的必填方法:把一次调用变成原始分片流 */async*stream(options){constres=awaitfetch(this.baseURL,{method:'POST',headers:{...this.headers,'User-Agent':attributionHeaders().UserAgent},body:JSON.stringify(toProviderPayload(options)),signal:options.signal,// 必须遵守})// 把提供方的 SSE 逐条翻译成 StreamChunkforawait(constevtofparseSSE(res.body)){yieldtoStreamChunk(evt)// block-start / *-delta / block-end / usage / finish}}/** 展示元信息 */providerInfo(provider){return{id:provider,name:'My Provider'}}/** 可发现模型列表(给界面用的参考目录,不是请求白名单) */asynclistModels(){return[{provider:this.id,id:'my-model',name:'My Model'}]}/** 精确模型能力:上下文容量、默认输出上限、推理档位、更新模式 */asyncresolveModel(provider,model){return{provider,id:model,name:model,context:{contextWindow:128_000}}}}exportconstname='my-llm-provider'exportconstinject=['llm']exportfunctionapply(ctx:Context){ctx.llm.registerAdapter(['my-provider'],newMyProviderAdapter())}写适配器时要记住的约定(第 7 章那张表的浓缩版):
必须做
usage在finish之前发,finish之后什么都不发
工具调用的arguments全程保持原始 JSON 字符串
每个 HTTP 请求带上attributionHeaders()
把上下文溢出归一化为CONTEXT_WINDOW_EXCEEDED
把无内容块的终止性 stop 映射为EMPTY_RESPONSE错误
遵守options.signal
不要做
不要在适配器里实现重试——那是 agent 层的职责
不要自己拼装块——用共享的BlockAssembler
不要用提供方文本做错误路由——按 code
不要把 catalog 当请求白名单——它只是参考目录,适配器才是权威
不要把私有元数据当共享词汇——它是不透明的,只有「切分方式」是共享的
官方还给了一个很实用的提醒:**一次适配器调用就是一次提供方尝试。**agent 层的恢复会打开另一个持久、带编号的轮次;直接调ctx.llm.stream()的调用方仍然只尝试一次。所以如果你在写一个直接调用层的东西,要知道重试不是自动的。
10.6 实操四:让界面显示实时输出
如果你在写 UI 或编辑器集成,模式是这样的:
import{brandString}from'@deepseek-ai/dsh-brand'import{createUserMessage}from'@deepseek-ai/dsh-llm'exportconstname='my-ui'exportconstinject=['agents']exportfunctionapply(ctx){// 1) 实时 token 流:给「打字机效果」用ctx.on('agent/assistant-stream',({frame})=>{if(frame.type==='chunk'&&frame.chunk.type==='text-delta'){render(frame.chunk.text)}})// 2) 持久事实:给「历史记录」「工具卡片」「审计」用ctx.on('session/event',(session,event)=>{if(event.type==='tool/call')showToolCard(event.data)if(event.type==='tool/result')finishToolCard(event.data)if(event.type==='turn/end')markTurnDone(event.data.reason)})// 3) 把输入送回去onUserInput(text=>ctx.agents.get(brandString('client-session'))?.followup(createUserMessage({content:[{type:'text',text}],source:{kind:'user'},})))}关键
官方给了两条不同用途的通道,别用错:
·实时 token 呈现→
agent/assistant-stream(瞬态,不写日志,唯一的远程消费方是 Web Session-follow 适配器)·可回放的持久数据→
session/event(持久,可以重放、可以审计、可以做 trace)官方原话:「需要可回放 transcript 数据的 SDK 用户应当消费
session/event;agent/*是用于队列与状态、提示词拦截、请求构造、steering、继续执行和错误处理的实时协调接口。」
还有一个专门针对 Web Client 的细节:如果要往里加「业务行」,你要注册ConversationNodeDefinition加一个 keyed renderer。而且官方有一条要求:**如果同一个插件事件族里的多条事件要组装成一个 Conversation Node,那么这个族里的每条 start/update/result/resource/interruption 事件,都必须携带或独立推导出同一个稳定的业务 id。**理由很直接——不想让客户端靠「相邻关系」去猜归属,也不想让它去扫历史。
10.7 实操五:写一个协议驱动
「协议驱动」是把一个外部协议的对端接到ctx.agents上——它可能服务于界面,也可能服务于自动化客户端。官方在实操手册里给了标准做法:
- stdio 驱动拥有 stdout,通过工厂创建或恢复 agent,把协议请求映射成
followup()或cancel()。 - 底层提示词请求返回的是「持久的入队回执」,它不会通过把
MessageId和turn/end关联起来获得结果。 - 整个 agent 的状态要单独发布。
- 拆卸要用
AgentHandle.dispose(),这样 dispose 才能达到完全停稳。
官方点名了一个完整的参考实现:packages/acp/acp——它通过 ACP(Agent Client Protocol)的 JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。
其中有一段关于「等一下还是持续观察」的说明很有价值:
自动化方法可以从回执等待到下一次 idle,并概括这一显式拥有的区间;UI 通常则会持续观察开放式事件流。
10.8 实操六:委派给子 agent
「让主 agent 把一部分工作交给子 agent」是一个常见需求。它挂在一个独立的 seam 上。
官方在归属位置表里的说法是:子 agent 委派用ctx.subagents提供方注册表,然后用dsh-tool-subagent向模型暴露一个已配置的提供方。
可选的提供方包括:
| 提供方 | 它做什么 |
|---|---|
subagent-spawn-in-process | 在同一进程里新建一个子 agent |
subagent-fork-in-process | 在同一进程里从当前会话 fork 出一个子 agent |
subagent-acp | 通过 ACP 协议委派给另一个产品 |
subagent-codex | 委派给 Codex |
subagent-claude-code | 委派给 Claude Code |
subagent-dsh-sdk | 通过 dsh 自己的 SDK 委派 |
官方对ctx.subagents的职责描述是:**提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排。**而且消费方的分工也很清楚:
tool-subagent选择「一次性」或「可延续」委派。tool-subagent-control传递后续消息。tool-ralph要求一条全新的结构化输出路由。
反直觉
子 agent 和主 agent 的关系,在运行时的所有权和持久会话的血缘上是两件独立的事。官方在注册表里专门说明:
isOwnedBy(id, owner)判断的是「运行时所有权」,它「与持久会话血缘无关,并且在不相关的提供方复用一个 id 时依然无歧义」。所以恢复出来的一个 fork,在运行时可能仍然是一个 root。
10.9 加新行为的完整清单
把这一章的内容压缩成一张可以照着走的清单:
- **先确定归属。**用 10.1 的图,找到「能力 → ctx 键」或「策略 → 事件」。
- **决定是否需要持久化。**这个事实要在重启后还在吗?在 → 扩展
SessionEventMap;不在 → 用 Agent 事件。 - 写插件骨架。
name+inject+apply(ctx)。名字带前缀。 - **注册行为。**用
ctx.xxx.register()或ctx.on(...)。不要忘记注册都是副作用,会自动撤销。 - **写好 description。**如果你的能力是面向模型的,description 就是提示词,认真写。
- **遵守 signal。**所有异步工作都要响应
exec.signal或轮次的 signal。 - 用
--patch挂上去跑一次。dsh web --patch ./你的/cordis.yml。 - **用
--dump-config确认它真的加载了。**如果没生效,先看它有没有出现在列表里。 - **验证撤销。**把 patch 拿掉,确认行为完全消失、没有残留。
10.10 这一章要带走的三句话
这一章要带走的三句话
- **先问「挂在哪」,再问「怎么写」。**90% 的迷路都发生在第一步。
- **用
--patch开发,用--dump-config验证。**这两个命令能省你大量时间。- **注册与撤销是一对。**写完注册,立刻想撤销会发生什么。
这一章之后
六个实操覆盖了本书提到的全部主要扩展点。做完之后建议回到第 1 章重新读一遍 Cordis 的五种分发模式——
你会发现那些当时抽象的概念,现在都对应着你刚写过的某行代码。
如果你只想留一张纸在桌上,那就是 10.9 节的「加新行为的完整清单」:
先问挂在哪,再查该扩展点用什么分发模式,最后确认副作用要不要可逆。
内容整理自 DeepSeek Harness 官方仓库
docs/architecture.zh.md及其引用的 Cookbook、开发文档。
官方项目处于开发者预览阶段,具体命令、包名与字段请以你手上的代码为准。