DeepSeek Harness 进阶篇:由浅入深看懂「一切皆插件」与进阶玩法
上一篇我们已经把最基础的一条链路跑通了:装、配、跑、写第一个插件、加载,再到 headless 和 Python SDK。
这一篇不再停在“会用”这一层,而是继续往里拆。
真正的问题是:DeepSeek Harness 为什么说「一切皆插件」?这套结构到底怎么搭起来的?如果你要自己接一个 LLM、换一个 agent loop、挂一套远程能力,应该从哪里下手?
版本仍然基于官方源码 0.1.2-alpha.1(开发者预览)。这里的名词会明显多一些,但我会尽量把每个概念都落到具体机制和代码上。看完之后,你应该不只是“知道 dsh 有插件”,而是能大概看懂它为什么能这么拆。
01「一切皆插件」的机制落点
上一篇已经验证过,“Everything is a plugin” 不是一句宣传话。
再往里看,它真正落地靠的是两层结构:
没有特权内核(no privileged core)。模型适配器、工具注册表、会话日志、agent 循环本身——在 dsh里全是插件。你想扩展它,不是去 fork 源码改核心,而是「在旁边挂一个插件」。底座是 Cordis。Cordis 是一个插件框架,负责提供 services(服务)、typed events(类型化事件)和 reversible effects(可逆 effect),全部挂在一个共享的 ctx(context)上。
所以上一篇里看到的 ctx.tools、ctx.effect、inject=['tools'],都不是 dsh 临时拼出来的接口,而是 Cordis 的通用机制。
把 Cordis 看懂之后,dsh 很多看起来“神奇”的地方,其实就顺了。
02Cordis 五条思想与事件派发
官方 primer 把 Cordis 浓缩成五条:
一个插件是一片 service:插件可以是一个带可选 inject和apply(ctx)的函数模块,也可以是一个被挂载进 context 的Service子类。context 是 service 的仓库:service 认领一个稳定的 ctx.<key>(ctx.tools、ctx.llm、ctx.sessions…),其它插件按 key 找服务,而不是 import 一个具体实现。用 inject声明依赖:插件声明它需要的 service,框架等到这些 service 就绪才加载它——加载顺序由依赖关系算出来,不是手写 boot 顺序。typed events 通信:service 通过 TypeScript 声明合并定义事件名,再按需要派发。 注册是可逆 effect:prompt section、tool schema、适配器、provider、listener 都经 ctx.effect()/ctx.on()安装,卸载时可预测地 unwind。
五种派发模式
emit | |||
waterfall | |||
parallel | |||
serial | |||
bail |
这里最值得吃透的是 waterfall,因为 agent loop 里很多关键钩子都靠它。
它更像一层 around-middleware:监听器拿到 (...args, next),只有调用 next(),结果才会继续交给下一个监听器;不调 next(),当前监听器就可以直接短路。
这意味着两类插件的边界很清楚:真正拥有决策权的策略插件,可以在这里直接截断;只做注解、观察或补充信息的插件,就应该继续委托给 next()。
所以 dsh 里的“拦截点”不是后面硬加的一层 patch,而是框架原生就给你留好的结构。
03seam:能力的「缝」
工具其实只是最容易理解的一种插件。
再往上一层,dsh 真正想让你替换的单位叫 seam(能力缝)。一个完整 seam 不是“再写一个插件”这么简单,而是把一项能力拆成三个角色:
| Service Definition | |
| Service Provider | ctx 的一个 key |
| Consumer |
官方原话很硬:只写一个角色不算 seam,加一个能力要把三个都设计全。("one role alone is not a seam; adding a capability means designing all three.")
官方仓库里的 docs/capability-seams.md 生成了一张能力缝全景图,你会看到一整排 ctx. 服务:ctx.llm(LLM 适配器注册表)、ctx.sessions(会话存储)、ctx.sessionPersistence(持久化)、ctx.shell、ctx.sandbox、ctx.subagent、ctx.commands(人类命令)、ctx.skills(skill provider)、ctx.tools(工具注册表)……每个都是一个可以替换的能力缝。这就是「按 key 找实现」的全景。
共享执行世界
这也是 seam 相对“一堆独立工具”最实际的优势。
文件系统和子进程 provider 可以共享同一个执行世界。
比如你把沙箱 provider 切到远程环境,Bash、PTY、LSP 可以一起跟着迁移,不需要再分别维护 Remote Bash、Remote PTY、Remote LSP 三套分叉。
做本地 demo 时,这个差异不明显;一旦开始接远程沙箱、容器或集群,价值就会出来。
Consumer 侧的写法值得记住——它把「硬依赖 vs 可选依赖」讲得很清楚:
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
// 已 inject 的 service,直接属性访问,缺了就起不来
const defaultMode = ctx.shell.sandboxMode
// 未 inject 的可选 service,可能返回 undefined,要自己判空
const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
inject 声明 + 直接属性访问 = 硬依赖;ctx.get(...) = 可选依赖。拦截与策略用事件(ctx.on / waterfall),直接能力调用用 service 方法,别混。
04agent 循环:step、turn 与事件瀑布
如果前面的模型、工具、Shell 都还能理解成“外围能力”,那 agent loop 本身也被做成插件,就更能说明 dsh 的态度了。
@deepseek-ai/dsh-agent-loop 本身就是一个插件。也就是说,“Agent 到底怎么循环”这件事,都不是框架里写死的。
它先定义两个基本单位:
step:一次模型请求 + 它调用的那些工具。 turn:零个或多个 step。在第一个输入被 claim 之前打开,在「不再欠任何东西」时关闭。
一个 turn 的完整事件瀑布(官方时序,简化):
turn/start → claim input
→ agent/pre-step(waterfall,可拒绝/改写/放行)
→ step/start → user/message → system-prompt/assemble
→ agent/request(waterfall)→ llm/stream(waterfall)
→ assistant/chunk* → assistant/message
→ tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
→ step/end → ... → agent/turn-stopping(serial,无 next)→ turn/end
几个关键判断:
agent/pre-step、agent/request、llm/stream、三个tools/*是 waterfall——监听器要调next()委托给下一个。agent/turn-stopping是 serial,没有next(),是「这一 turn 该停了」的最终检查点。agent/ 事件是 live 协调 API*(队列/状态/prompt 拦截/请求构造/steering/continuation/error);session/event 是 replayable 的持久数据。SDK/UI 要重放转录,消费 session/event;要实时控制、拦截 prompt、构造请求,消费agent/*。
还有一个挺反直觉的细节:即使第一次 claim 被拒绝,或者拿到的是空输入,也会形成一个“没有消耗 step 的持久 turn”。
所以 turn 的边界并不是“有没有真正干活”,而是“这一轮有没有完整打开、完整关闭”。这也说明 dsh 的日志更像在记录完整运行事实,而不是只保留“成功做了什么”。
05agent preset 与 isolate realm
要给某个 session 组合一套不同的能力集,就 compose 一个 agent preset。架构文档的原话:「给一个 session 一套不同能力集——compose 一个 agent preset;那里的服务行需要一个 isolate realm」——isolate 意味着这套能力不污染其它 session 的作用域。
官方在 packages/preset/agent-presets/presets/ 内置了四个 agent preset:standard(标准模式,完整编码 agent)、ptc(PTC 模式,脚本优先)、minimal(极简模式,仅 bash/pwsh + str_replace_editor)、cordis(创造模式,用于创作自定义 preset)。注意目录名是 cordis,「创造模式」是它的显示名。一个 agent preset 本质上是一个目录,内含 agent.cordis.yml(服务行组合)与 preset.yml(名称/描述/顺序)。PTC 和 Minimal 值得进阶读者留意:
PTC mode:模型先出脚本再执行,工具可被脚本直调,参数/返回类型从同一个 schema 推出来,走正常执行管线(含权限策略)。进程级开关是环境变量 DSH_TOOLS_MODE,取native/ptc/both,其它值会导致启动失败。Minimal preset:只组合持久 bash(win 上是pwsh)和str_replace_editor,完整系统提示词固定为You are a helpful software engineer assistant.——这是跑 benchmark 或极简沙盒实验的干净基底。
06接一个自定义 LLM 适配器
前面一直说“模型也是插件”。真正落到代码里,就是接一个新的 LlmAdapter。
如果你要把自己的模型服务、私有网关,或者一个新的 provider 接进 dsh,官方推荐的形状大概是这样:
class MyAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { /* ... */ }
}
export const name = 'llm-myprovider'
export const inject = ['llm']
export const Config: z<Config> = z.object({
apiKey: z.string(),
// ...
})
export function apply(ctx: Context, config: Config) {
// effect-based 注册:HMR 安全;同一 provider 重复注册会抛错
ctx.llm.registerAdapter(['my-provider'], new MyAdapter(/* ... */))
}
代码本身不复杂,真正容易踩坑的是协议。
官方把几条义务写得很明确,写适配器之前最好先看清:
usage必须在finish之前 emit,finish之后不能再 emit 任何东西;工具调用的 arguments端到端是 RAW JSON 字符串,流式片段用argumentsDelta;错误的两个合法出口:从 stream()throw(传输/协议级失败),或在finish {kind: 'error' | 'aborted'}里结束(provider 带内失败);必须尊重 options.signal(透传给 fetch / 你的 SDK);无法支持的选项要明文 throw UNSUPPORTED_OPTION,而不是悄悄丢弃。
密钥走 Cordis-native 的 schemastery Config + 环境变量 fallback(例如 cordis.yml 里 !!js process.env.MY_KEY),不要在代码里读临时 key 文件。
07按规范新增一个包
真正的「在 dsh 上造东西」,往往不只是写一个工具,而是新增一个 @deepseek-ai/dsh-* 包。官方 adding-a-package cookbook 给了从创建到验证的清单,我摘三条对你最有用的原则:
可替换能力按 Definition/Provider/Consumer 拆包。当你判断三个角色会独立演化时,拆成三个包;shell trio( tool-bash→ shell / shell-env / sandbox-policy)就是官方模板。单一职责插件可以留在一个包里。package.json 有大量 invariant,由 pnpm run constraints强制:private: true、版本对齐根包、@deepseek-ai/cordis同时进 peerDependencies 和 devDependencies 等等。不是「想怎么写就怎么写」。命名的单复数有规矩:单数 ctxkey 给「一个 engine/runtime/policy/controller/resolver/store/配置」;复数 key 给「一个 registry 或拥有多个命名成员的 service」。
验证命令是:pnpm install && pnpm run doc-sync && pnpm run constraints && pnpm run typecheck && pnpm run lint && pnpm run build && pnpm run hygiene。
08profiles / bundles / patch 的进阶组合
上一篇我们已经用过 --patch,但当时只把它当成“临时挂一个插件”的入口。
往进阶走,需要把 dsh 的整套配置叠加关系看清楚。一台真正跑起来的 dsh,本质上是一棵按层叠加出来的插件树:
profile 中的每个 bundle(按 dsh.profile.bundles 顺序)
→ profile 自己的 cordis.patch.yml
→ home 级 $DSH_HOME/cordis.patch.yml
→ 命令行 --patch 覆盖层
bundle 名字先从 dsh 安装目录解析( @deepseek-ai/dsh-base、@deepseek-ai/dsh-web-app等内置包),再从 profile 自己的node_modules解析(那里是 pnpm 装的 out-of-tree 插件);内置 profile web/headless/sdk/sdk-minimal/acp首次使用自动从模板初始化;自定义 profile 才需要dsh plugin --profile <name> add <package>;dsh --profile web --dump-config/--dump-default-config能看组合后的插件树,不用启动即可检查。
web profile 默认 patchReload: live(改 patch 实时重载);headless/sdk 这类一次性/stdio 应用是 startup(只启动时应用一次)。
09headless 与 Python SDK 的进阶玩法
headless:把 dsh 塞进脚本/CI
dsh --profile headless "run the tests"
输出契约(官方文档明确写死):
每个非空推理增量写 stderr( dsh: reasoning:前缀);最终答案写 stdout; 正常完成 exit 0,中止或出错 exit 1(错误在 stderr 打 dsh: <code>: <message>);一次运行只有一个任务,没有交互式 follow-up。
它非常适合 CI step 和单向批处理:进程不监听端口、跑完自己退、退出码即结果。但正因为无人值守,官方尚未安全审计,别让它去做不可逆的批量写操作。
Python SDK:给批处理一个确定入口
from deepseek_harness import DeepSeekHarness
with DeepSeekHarness(
dsh_home="/absolute/path/to/isolated-dsh-home",
cwd="/absolute/path/to/workspace",
provider="deepseek-official",
model="deepseek-v4-flash",
) as harness:
result = harness.run(
"Migrate fetch() calls across src/api.",
session_id="batch-001",
)
print(result.final_response)
几个进阶要点:
dsh_home必须显式传(或设DSH_HOME),SDK 故意不读~/.dsh,方便每个批处理任务用独立 home 隔离凭据/会话/插件;DeepSeekHarness懒启动并复用 runtime 直到close()/ 退出 with 块;profile="sdk-minimal"给一个只含 bash + str_replace_editor 的干净 agent(适合可复现批处理);持久插件走 dsh plugin --profile <name> add file:/.../my-plugin-bundle,本 run 临时变更走patches=("/path/to.patch.yml",);harness.run()返回RunResult(session_id, final_response, finish_reason, events, notifications)。
10会话日志:model-visible means logged
前面讲了很多插件、seam、preset,但如果让我只挑一条最值得记住的架构约束,还是这一句:
模型看到的一切,必须能从会话日志重建。
官方叫它 Model-visible means logged。
日志是 append-only 的。Prompt 片段、每一次工具调用的输入输出、原始响应都会进去;fork、resume、回放、转录、telemetry、持久化,也都从这条日志流继续派生,而且运行时会用不变式去约束它。
这件事真正有价值的地方,是可观测性不再是“出了问题以后再补一个仪表盘”。
它直接变成了系统结构的一部分。
以后排查 Agent 为什么做了一个奇怪决定时,至少不会先卡在“当时模型到底看到了什么、为什么日志里没有”这种最基础的问题上。
11什么时候该收手
看到这里,dsh 能怎么玩基本已经展开了。
但越往进阶走,越需要把边界一起看清楚。现在这个版本,有三件事比“还能扩展什么功能”更重要:
官方自标未安全审计。SAFETY 文档原话:尚未接受安全审计,不得视为安全或可用于生产环境的软件;沙箱、审批与权限控制不保证隔离。你越往进阶走(自定义 provider、远程沙箱、headless 批处理),越要放在一次性/可回滚环境里跑。 开发者预览 + 破坏兼容性变更。所有字段(seam、profile、preset、adapter)都以 0.1.2-alpha.1和当前config-catalog为准;版本迭代很快,投产前一定对照官方最新源码复核。凭据与遥测默认值。key 存 $DSH_HOME/.credentials.yaml,write-only 不回显;遥测默认按反馈门控关闭,DSH_TELEMETRY_MODE才改变行为。别在没有想清楚凭据暴露范围的情况下跑高级实验。
所以走到最后,我对 dsh 的判断和上一篇其实是一致的,只是理由更清楚了。
它真正有意思的地方,不是“插件很多”,而是把 Agent 里原本容易写死的部分都做成了可以替换的能力边界。
模型可以换,工具可以换,会话可以换,执行环境可以换,连 agent loop 也可以换。
这对自己做 Agent Framework、Coding Agent、长任务 Harness 的人来说,确实很值得研究。
但另一面也不能忽略:现在还是 0.1.2-alpha.1,开发者预览,安全审计没完成,接口也还在快速变化。
所以最合适的姿势仍然是:拿它搭积木、做实验、读架构,不要急着把生产任务压上去。
如果还要继续深挖,官方仓库的 docs/architecture.md、docs/cordis-primer.md、docs/agent-lifecycle.md 和 docs/cookbook/,就是下一站最值得看的几份材料。