DeepSeek 把 deepseek-harness 仓库公开了(最近一次合入 PR:release: dsh@0.1.0-rc.5,整套 dsh 包同时发到 npm)。这是一个开源 agent harness,采用「一切皆插件」的架构,底下由 Cordis 框架驱动,目前还在 developer preview,未来会有破坏性变更。本文拆解它的包结构、agent 循环、工具调用管线和工程约束。

快速上手

npx @deepseek-ai/dsh web

默认启动 Web UI,监听 127.0.0.1:3080;另一种形态是 headless,一次性执行完任务就退出,不起 server。从源码跑是常规四步:pnpm install、pnpm run build、pnpm dsh web。仓库要求 Node ^22.19 || >=24,全量 ESM,包管理用 pnpm workspaces。

README 说的「一切皆插件」不是修辞:工具注册表、提示词组装、agent 循环全在插件层里,和 tool-bash 同级,没有一个特权内核可以打补丁。四种入口(headless / Web UI 等)共用同一套插件层,差别只是叠了不同的 bundle。

仓库切分:按能力切,不按技术分层

这是一个 TypeScript monorepo。packages/ 下有 49 个分组目录,加上 apps/、vendor/、native/、python/ 一共 242 个 package.json,其中命名为 @deepseek-ai/dsh-* 的产品包有 219 个。src 目录下 TypeScript 约 22.8 万行,测试文件 882 个。

分组目录的粒度:core/(session、system-prompt、tools、agent、agent-loop)、llm/、fs/、shell/、terminal/、lsp/、sandbox/、subagent/、compaction/、skill/、workflow/、mcp/、acp/、client/。

  • client/ 下有 40 多个包,全是浏览器侧的 @deepseek-ai/dsh-client-*(ui-conversation、ui-tool、ui-sidebar、ui-settings-models 等)——前端同样按插件切,不是一个整块 SPA。
  • vendor/ 放的是 Cordis 框架及其依赖的源码副本,共 9 个包,全部重命名到 @deepseek-ai scope 下(cordis → @deepseek-ai/cordis)。理由:完全掌控框架层,可审计、可打补丁、可锁版本;重命名避免发布时占用上游 npm 包名。

分组不是按前端/后端/工具这样的技术分层切的,而是按能力切:一个能力的接口、实现和消费方各占一个独立包,所以 shell 和 subprocess 是两个分组,fs 和 sandbox 也是。这个切法决定了后面 capability seam 能替换到什么粒度。

profile 和 bundle 的分层组装

一个运行中的 dsh,是启动时按顺序叠出来的一棵插件树。

  • profile:一份具名组合,存在 Harness home 里,列出要叠哪些 bundle。
  • bundle:配置行和对应代码的分发单元。

两者都在自己的 package.json 里用 dsh 字段声明。dsh-base 是每个 profile 的第一层,packages/bundle/base/cordis.patch.yml 里是 78 行插件条目:

- insert:
  - id: llm
    name: '@deepseek-ai/dsh-llm'
  - id: agent-default-model
    name: '@deepseek-ai/dsh-agent-default-model'
    config:
      provider: deepseek-official
      model: deepseek-v4-flash

清单末尾几行是 tools、system-prompt、agent-loop,和 tool-bash、tool-web 平级——工具注册表、提示词组装、agent 循环本身都是可以被上层 patch 掉的普通配置行。

叠加顺序固定:profile 列出的各个 bundle 按序 → profile 自己的 cordis.patch.yml → home 级 → 最后命令行 --patch。patch 按 id 定位一行,整体替换它的 config,或插入新行。想看自己机器上实际启动的是哪棵树:dsh --profile web --dump-config,打印出的任意一行都能被自己的 patch 换掉。

agent-loop 的 turn 与 step

packages/core/agent-loop 是整个仓库里唯一含有循环逻辑的包,其余全是抽象服务或扩展点上的插件。

两级单位:

  • step:一次模型请求加上它调起的工具。
  • turn:包含零个或多个 step,在第一份输入被认领前打开,在没有待办时关闭。

事件分两类:落盘类(turn/*、step/*、user/message、assistant/*、tool/*)和扩展点类。agent/pre-step、agent/request、llm/stream 和三个 tools/* 是 waterfall 模式,监听器必须调 next() 才往下传,不调就短路;agent/turn-stopping 是 serial 模式,没有 next()。

preStep 的代码顺序:

const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
const decision = await this.dispatch.waterfall(
  'agent/pre-step', { messages: claimed, ...position, signal },
  () => Promise.resolve<PreStepDecision>({ kind: 'enter', messages: [...claimed, context] }),
)

先从 inbox 认领消息,再组装系统提示词和工具 schema,最后把「这一步模型会看到什么」交给 waterfall 决定。插件在这里可以改写消息,也可以直接 reject;被 reject 或首次认领被改写成空时,turn 依然以「没有 step」的形态落盘关闭,日志里留下尝试记录。

工具调用的执行管线:九道关

模型吐出一个 tool call 之后不是直接进 execute(),中间有九道:

  1. tools/pre-execute — 承载可扩展的 allow/deny/ask 策略;
  2. ctx.tools.guard() — 单调拒绝,不允许被后续监听器翻案;
  3. tools/execute — 环绕 dispatch 做超时和重试;
  4. tools/post-execute — 才动结果内容。

deny 是短路路径:被拒绝的调用跳过工具本体,但仍然走完 post-execute 和落盘,日志里不会缺一条 tool/result。另外 tool/call 在执行之前就写进日志,tool/result 在管线跑完之后写。

工具本身注册很短:

export const inject = ['tools']
export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'read_file',
    description: 'Read a file from disk.',
    parameters: { path: { type: 'string', required: true } },
    async execute(args, exec) {
      return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
    },
  }))
}

注册走 effect 机制,插件 fiber 一销毁,工具自动注销,schema 也自动退出提示词组装。仓库生成的工具目录收 24 个工具包、52 个模型可见的工具名(bash、read、edit、glob、grep、web_search、subagent、todo_write 等)。

capability seam:三个角色

工具只是消费方,能力本身另有一套约定,叫 capability seam,由三个角色组成:

  • Service Definition:声明接口;
  • Service Provider:实现接口;
  • Consumer:使用它(通常就是面向模型的工具)。

文档写得很硬:一个 seam 必须三个角色齐备,缺一个就不算。

好处在替换时体现:bash 工具是 ctx.shell 这个 seam 的 Consumer,ctx.shell 的本地实现又通过 ctx.subprocess 起进程。文件系统和子进程 provider 共享同一个执行世界——把它们指向远端沙箱,Bash、PTY、LSP 会一起跟着过去,不需要给每个工具单独 fork 一份远端实现。模型面的 bash 工具只认 ctx.shell 接口,底下换成远端沙箱实现时工具侧一行不用改。

沙箱这一层同样是 seam。dsh-sandbox-local 按平台挑 runner:Linux 优先 bwrap,其次 Landlock;macOS 用 Seatbelt;Windows 用 ACL 受限令牌。不支持的平台和探测失败的 runner 一律 fail closed,报 SANDBOX_UNAVAILABLE,不会静默地以无约束方式执行。Landlock 那条路还专门维护了 native 包 @deepseek-ai/node-addon-landlock-run。

Code Mode 的 run_code

工具注册表还有第二种呈现方式:把 mode 配成 code,模型看到的就不再是一堆 tool schema,而只有一个保留工具 run_code:

Execute a TypeScript program against the available tools. Takes two required arguments: code, the BODY of an async function … Call tools as await tools.name(args) per the declarations in the system prompt. Only what you print or return comes back — curate it.

其余能力改为在系统提示词里以一段生成的 SDK 声明呈现,语言跟着 ctx.codeRuntime.language 走(目前 TypeScript 和 Python 两套 flavor),落地的后端只有 worker-thread 一个。程序里每次 tools.xxx() 调用会重新进入完整工具管线,带着父级 token,记一条 tool/code-dispatch 事件;但只有外层 run_code 的结果进入模型历史。

对比 native 模式:后者每条 tool/result 都进模型历史,N 次调用就是 N 轮往返加 N 份中间产物;Code Mode 下这些调用仍然逐条落 tool/code-dispatch,日志完整可重建,但只有外层结果进历史,一轮里多次调用的中间产物不再逐条回灌上下文。代价是模型得先写对这段程序。

session 日志与请求重建校验

session 日志是仓库的中枢。SessionEventMap 是一个可合并扩展的 map,当前构建认识 44 种事件类型(从 turn/start、assistant/chunk 到 compaction/summary、plan/mode、sandbox/mode)。模型历史不单独存,靠 deriveMessages() 从日志投影出来。

围绕这点有一条硬规则:模型能看到的,必须能从日志里重建出来。新增一种模型可见的输入,就必须新增一种 session 事件。

这条规则不只写在文档里。agent-loop 的 invariant 伴生插件在 llm/stream 上挂了一个 prepend 监听器,在请求真正发出之前重算一遍:

const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
  fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation`)
}

把「即将发给模型的 messages」和「此刻从日志推导出来的 messages」做全量比对,对不上直接抛 InvariantError。同一个监听器还会核对 model、system、temperature、maxTokens、tools 是否与折叠出来的 request header 一致。用 prepend 是为了防止某个短路的 replay 监听器把这道检查绕过去。assistant/chunk、request/header 这类只服务重放和 UI,落盘但不进历史。

仓库自己的工程约束

机器可检的规则写在根 AGENTS.md 和 docs/AGENTS.md,由 scripts/ 下的 gate 脚本执行:

  • 每包一个 invariant 伴生插件:219 个 dsh-* 包对应 219 个 src/invariant.ts。没有可检查的运行时关系也不能省,得写一个空 installer 并注明理由;生成式的伴生插件、没写理由的空实现都会被 verify-package-invariants 卡住。
  • per-file 100% 覆盖率:CI 门禁是 packages/*/*/src 下按文件(而非整体)语句、分支、函数、行全部 100%。文档说法:未覆盖的行往往是该删的死代码,而不是需要补测试的地方。
  • 真 API 测试:We are DeepSeek — do not ration real-API tests. 无 key 的测试只能证明管道通了,只有带 key 跑真模型才能证明 agent 真的能干活;CI 无 key 时自动跳过。
  • 文档字数上限:每个事实只有一个归属层,根 AGENTS.md ≤ 1600 词、architecture.md ≤ 1800,pnpm run verify-doc-budgets 拒绝超标。
  • slop checklist:docs/AGENTS.md 列了要清理的写法——同一条规则出现在两个地方、叙述历史变更(“previously”/“now”/“no longer”)、在文档里手抄本该由生成器产出的目录、把推理过程当文档写。
  • Model Experience 小节:268 份包 README 里 215 份带 ## Model Experience,固定回答三个问题:模型看到了什么、token 影响是什么、KV Cache 影响是什么。
  • Agent Notes:决策记录放 .agents/notes/,按状态分目录(implemented 507 / archived 143 / proposed 25 / rejected 11,共 684 篇),每个非平凡改动都要在同一个 PR 里带至少一篇。

这些门禁的共同点:把约定变成会红的检查——覆盖率不达标、invariant 伴生插件缺失、文档超字数、生成文件过期,都会在 CI 上直接挡住,而不是靠 review 时有人想起来。

总结

dsh 的核心机制是 Cordis 的服务 + 类型化事件 + 可回滚 effect,落到产品上就是一棵启动时叠出来的插件树,连 agent 循环和工具注册表本身都是树上可替换的一行。扩展点集中在 session 事件、agent/* 事件和 capability seam 三处——新增行为进插件,不进循环。真正撑住这套结构的,是它给自己加的那几道机器可检的门:请求与日志推导的运行时比对、按文件 100% 的覆盖率、文档字数上限和 slop 清单、每包一个 invariant 伴生插件。

原文:公众号「脑子进煎鱼了」《DeepSeek Harness 里面长什么样?》;仓库:deepseek-ai/deepseek-harness