数据STUDIO

DeepSeek Harness 进阶篇:由浅入深看懂「一切皆插件」与进阶玩法

Image

上一篇我们已经把最基础的一条链路跑通了:装、配、跑、写第一个插件、加载,再到 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 浓缩成五条:

  1. 一个插件是一片 service:插件可以是一个带可选 inject 和 apply(ctx) 的函数模块,也可以是一个被挂载进 context 的 Service 子类。
  2. context 是 service 的仓库:service 认领一个稳定的 ctx.<key>(ctx.tools、ctx.llm、ctx.sessions…),其它插件按 key 找服务,而不是 import 一个具体实现。
  3. 用 inject 声明依赖:插件声明它需要的 service,框架等到这些 service 就绪才加载它——加载顺序由依赖关系算出来,不是手写 boot 顺序。
  4. typed events 通信:service 通过 TypeScript 声明合并定义事件名,再按需要派发。
  5. 注册是可逆 effect:prompt section、tool schema、适配器、provider、listener 都经 ctx.effect() / ctx.on() 安装,卸载时可预测地 unwind。

五种派发模式

模式
是否等待
派发顺序
有没有返回值
emit
否
按注册顺序观察
无
waterfall
否
按注册顺序观察
有
parallel
是
全部并行
无
serial
是
按注册顺序
有
bail
否
按顺序直到有人 bail
有

这里最值得吃透的是 waterfall,因为 agent loop 里很多关键钩子都靠它。

它更像一层 around-middleware:监听器拿到 (...args, next),只有调用 next(),结果才会继续交给下一个监听器;不调 next(),当前监听器就可以直接短路。

这意味着两类插件的边界很清楚:真正拥有决策权的策略插件,可以在这里直接截断;只做注解、观察或补充信息的插件,就应该继续委托给 next()。

所以 dsh 里的“拦截点”不是后面硬加的一层 patch,而是框架原生就给你留好的结构。

03seam:能力的「缝」

工具其实只是最容易理解的一种插件。

再往上一层,dsh 真正想让你替换的单位叫 seam(能力缝)。一个完整 seam 不是“再写一个插件”这么简单,而是把一项能力拆成三个角色:

角色
职责
Service Definition
声明接口(能力的契约)
Service Provider
实现它并挂到 ctx 的一个 key
Consumer
按 key 取用,不 import 具体实现

官方原话很硬:只写一个角色不算 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 给了从创建到验证的清单,我摘三条对你最有用的原则:

  1. 可替换能力按 Definition/Provider/Consumer 拆包。当你判断三个角色会独立演化时,拆成三个包;shell trio(tool-bash → shell / shell-env / sandbox-policy)就是官方模板。单一职责插件可以留在一个包里。
  2. package.json 有大量 invariant,由 pnpm run constraints 强制:private: true、版本对齐根包、@deepseek-ai/cordis 同时进 peerDependencies 和 devDependencies 等等。不是「想怎么写就怎么写」。
  3. 命名的单复数有规矩:单数 ctx key 给「一个 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 能怎么玩基本已经展开了。

但越往进阶走,越需要把边界一起看清楚。现在这个版本,有三件事比“还能扩展什么功能”更重要:

  1. 官方自标未安全审计。SAFETY 文档原话:尚未接受安全审计,不得视为安全或可用于生产环境的软件;沙箱、审批与权限控制不保证隔离。你越往进阶走(自定义 provider、远程沙箱、headless 批处理),越要放在一次性/可回滚环境里跑。
  2. 开发者预览 + 破坏兼容性变更。所有字段(seam、profile、preset、adapter)都以 0.1.2-alpha.1 和当前 config-catalog 为准;版本迭代很快,投产前一定对照官方最新源码复核。
  3. 凭据与遥测默认值。key 存 $DSH_HOME/.credentials.yaml,write-only 不回显;遥测默认按反馈门控关闭,DSH_TELEMETRY_MODE 才改变行为。别在没有想清楚凭据暴露范围的情况下跑高级实验。

所以走到最后,我对 dsh 的判断和上一篇其实是一致的,只是理由更清楚了。

它真正有意思的地方,不是“插件很多”,而是把 Agent 里原本容易写死的部分都做成了可以替换的能力边界。

模型可以换,工具可以换,会话可以换,执行环境可以换,连 agent loop 也可以换。

这对自己做 Agent Framework、Coding Agent、长任务 Harness 的人来说,确实很值得研究。

但另一面也不能忽略:现在还是 0.1.2-alpha.1,开发者预览,安全审计没完成,接口也还在快速变化。

所以最合适的姿势仍然是:拿它搭积木、做实验、读架构,不要急着把生产任务压上去。

Image

如果还要继续深挖,官方仓库的 docs/architecture.md、docs/cordis-primer.md、docs/agent-lifecycle.md 和 docs/cookbook/,就是下一站最值得看的几份材料。

Image