deepseek harness · 架构图解

dsh 架构图解

219 个包,19.9 万行 TypeScript。而真正的 agent 循环只有 1,643 行。本文用七张图说明这个比例是怎么来的,以及它换到了什么。

面向懂 agent 的工程师 图在前 · 结论在后 数据实测自源码树

1整体形状

交付层 · bundle Web App Headless ACP / SDK API Gateway 能力缝层 · 55 个 ctx.* 服务(图示 12 个) ctx.llm ctx.tools ctx.sessions ctx.fs ctx.subprocess ctx.sandbox ctx.subagents ctx.compaction ctx.approval ctx.goals ctx.workflowEngine ctx.spillStore agent-loop 1,643 行 packages/core/agent-loop/src/ 六个文件 —— 这是全部的"内核" 占全树 0.8% Cordis 依赖注入容器 —— 插件声明 inject,容器负责激活时机与卸载回滚 注册是可逆 effect:插件卸载,它注册过的一切自动 unwind
图 1 · 分层架构。实测:ls -d packages/*/*/ | wc -l = 219;agent-loop/src/*.ts 合计 1,643 行;全树非测试 TS 共 198,836 行。

这张图要说明的是一个比例问题。19.9 万行代码里,负责"跟模型一来一回"的只占 0.8%。剩下的 99.2% 不是循环的实现细节,是循环周围的可替换层。

这是 dsh 最核心的判断:agent 的难点不在循环里。 ReAct 那一圈,任何人两天就能写对。难的是循环之外 —— 谁能改提示词、工具凭什么被允许执行、上下文压缩后还能不能重放、子 agent 该继承什么、模型看到的东西能不能审计。dsh 把这些全部做成了有名字的缝,而不是循环里的 if。

一个直接推论

因为循环是空的,所以 agent 循环本身也只是一个插件。AgentLoop extends Service(core/agent-loop/src/index.ts:296),挂在 ctx.agentLoop 上,身份是"具体驱动"(Concrete loop driver),而接口方是另一个包的 ctx.agents。换驱动不用碰接口。

官方文档的说法是:"There is no privileged core to patch"—— 没有一个特权内核可供打补丁,你扩展 dsh 的方式是在旁边挂一个插件。

2它是怎么被拼起来的

应用顺序:从下往上,后者压过前者 ① dsh-base 模型适配 / 工具 / 持久化 / 沙箱与审批策略 / 凭据 ② 其余 bundle(web-app · headless …) 按 profile 的 dsh.profile.bundles 顺序 ③ profile 的 cordis.patch.yml $DSH_HOME/profiles/<name>/ ④ home 级 cordis.patch.yml 机器级偏好,压过 profile 级 ⑤ --patch 命令行覆盖 最高优先级 composeEntries() 最终启动的那一棵插件树 root context llm-deepseek tool-bash tool-ralph · maxRounds: 64 session-persistence-jsonl sandbox-local · mode: … compaction-basic agent-loop … 按 id 替换整段 config
图 2 · 启动组装。补丁按行 id 定位并替换那一行的整个 config(未改字段要重述),或用 insert 插新行。出处:packages/boot/app-boot/README.md:38-45、docs/architecture.md:27。

dsh 没有主程序入口。你跑起来的那个进程,是五层配置叠出来的一棵 Cordis 插件树。想看你这台机器实际启动的树:

dsh --profile web --dump-config

打出来的每一行都能被你自己的补丁替换掉。这里有三个工程细节,决定了这套东西是"能用"还是"能长期用":

防漂移 dump 和真实启动共用同一个函数

组装、flag 推导、配置 dump 全走 composeEntries。README 明写目的是让三者 "cannot drift from what boots"。

所以 --dump-config 打出来的树,和真跑的树,在实现上不可能不一致。

防误配 空补丁文件直接抛错

点名一个不存在的 id 只是 stderr 警告;但空文件或只有注释的文件直接抛 —— 它解析出来是"空",不是"空列表"。

想关掉这一层,必须显式写 []。这是在强行区分"你没配"和"你配了个寂寞"。

防打断 补丁文件是热的,且失败不伤在跑的会话

watchUserPatches 全程盯着。读失败 / 解析失败 / Loader 拒绝时保留上一棵好树继续跑,只广播 hmr/config-update-failed。

改配置改错了,不会把正在跑的 agent 打死。

3一次 turn 的完整时序

调用方 Driver 插件监听器 会话日志 LLM 工具 followup() turn/start agent/pre-step waterfall reject | enter(messages) 驳回 → 仍然关掉一个"花了 0 个 step"的 turn,记录在案 step/start user/message deriveMessages() —— 从日志推出模型历史 agent/request → llm/stream StreamChunk* assistant/chunk* assistant/message tools/pre → guard → execute → post tool/result* step/end agent/turn-stopping serial · 无 next() turn/end
图 3 · 一次 turn。绿 = 落日志的持久事件,可重放 · 蓝 = waterfall,监听器必须调 next() 才继续 · 红 = serial,没有 next()。事件在源码中的位置:agent.ts 的 92/255(turn/start)、235(pre-step)、279(step/start)、292(step/end)、296(turn-stopping)、319(turn/end)、356(request-error)。

先把词定死:一个 step = 一次模型请求 + 它调的那批工具。一个 turn = 零个或多个 step —— 它在第一份输入被认领之前就已经打开,直到"没有任何欠账"才关闭。

时序本身没有惊喜,ReAct 那一圈都在。有价值的是三个设计动作:

入口 一个 inbox,两种醒法

所有输入进同一个收件箱。有的消息立刻唤醒驱动;注入的上下文只会躺着等,直到别的消息把它捎带进去。

后果:agent.inject() 永远不会自己触发一次模型调用。注入上下文这件事不烧 token。

闸门 被驳回的尝试也进日志

agent/pre-step 的监听器能改写、也能整批驳回。关键是后半句:驳回后仍然会关掉一个花了零个 step 的持久 turn。

所以"这次被拦下了"是一条可查的记录,不是静默消失。审计的时候这很重要。

出口 收尾那一步不给委托

前面全是可重排、可互相委托的 waterfall。只有 agent/turn-stopping 是 serial —— 没有 next()。

goal 的"这轮该不该继续"就挂在这个位置。它是终点检查站,不是又一个可以被别人插队的钩子。

4工具流水线:灵活段与棘轮段

assistant 消息里出现 tool-call tool/call —— 先落日志,后执行 tools/pre-execute waterfall · 可重排 · allow / deny / ask ask ctx.approval 允许一次 deny 跳过工具体 拒绝也要走完 post registered guards —— 单调 返回理由 = 拒绝 · 返回 undefined = 弃权 · 拒绝不可逆转 tools/execute around 包裹真正的 execute():超时 / 重试 / 埋点 tools/post-execute waterfall · accept / block / replace / 追加上下文 finalizeContent → tools/result(冻结) → tool/result 落日志,成为模型唯一可见的结果
图 4 · 工具执行流水线。出处:packages/core/tools/README.md:5, 25, 51, 57 与 docs/tool-execution-pipeline.md:6。注意 ask 分支:审批不可用时按拒绝处理,不是按放行。

这条链上最值得抄的是第三段和第二段的关系。dsh 把"灵活"和"不可撤销"拆成了两种物理上不同的注册方式:

tools/pre-execute(waterfall)registered guard(单调)
语义可重排、可互相委托、可以改掉别人的决定只能收紧,不能放松
返回值allow / deny / ask,并可调 next() 交给下一位字符串 = 最终拒绝理由;undefined = 弃权
能否被后续推翻能不能。"Later waterfall listeners cannot turn a guard denial back into permission."
适合放什么hook、实验性拦截、可协商的策略所有者的硬底线
作用域全局或按上下文普通 ctx = 全局;agent.ctx = 只管那一个 agent
为什么这个拆分重要

大多数框架只给你一种钩子。结果只有两个方向:要么钩子太弱不够用,要么任何一个插件都能把安全策略解除掉 —— 因为它跟安全策略用的是同一种注册方式,谁后跑谁说了算。

dsh 的答案是给两种,并且在执行顺序上焊死:waterfall 先跑完,guard 后跑,guard 的拒绝没有回头路。想放行的插件根本没有一个能表达"放行"的位置。

5模型可见 = 必须落日志(且当场校验)

Driver 组装出的请求 途中可能被任何插件动过手脚 会话日志(append-only) turn/start step/start user/message assistant/message tool/result … 实际要发出的那一份 options.messages(已冻结) 当场重推一遍 session.deriveMessages() 逐字节比对 外加请求头 6 个字段 model / system / temp / … 不一致 → 抛异常 log-reconstruction desync
图 5 · 请求重推断言。实现在 packages/core/agent-loop/src/invariant.ts:20-52,挂在 llm/stream 上。

文档里那句 "Model-visible means logged"(architecture.md:96)如果只是一句话,那就是空话。dsh 把它做成了一个真的会抛异常的运行时检查:每一次 loop 发出的 LLM 请求,都被用会话日志重新推导一遍,然后逐字节比对。

// Prepend prevents a short-circuiting replay listener from silencing the check.
ctx.on('llm/stream', (options, next) => {
  if (!isAgentLoopRequest(options)) return next()
  if (!Object.isFrozen(options))          fail('a loop-built request must be frozen')
  if (!Object.isFrozen(options.messages)) fail('...must carry a frozen messages array')

  const expected = session.deriveMessages()
  if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
    fail(`... diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
  }
  ...
}, { global: true, prepend: true })

三个细节说明这不是装饰:

它防的是哪一类 bug

agent 系统最难查的一类问题是:模型看到了日志里没有的东西。某个插件在请求路径上偷塞了一段上下文,而回放、审计、fork、压缩全部基于日志 —— 于是线上那次行为你永远复现不出来,而且怎么查都查不到。

dsh 的做法是把"日志 = 唯一真相"从约定升级成运行时断言。谁偷塞,谁当场炸。

配套:每个包都必须自带一份 invariant

这条检查不是孤例,它是一套制度的一部分。包级宪法的最后一条规定:每个包都必须发布一个 ./invariant 伴生插件,注册自己的 npm 包名;能写出运行时检查就写,写不出来必须留一条以 No runtime invariant: 开头、且针对本包的具体理由。

"凑数的生成物、没解释的空实现、被忽略的报告"—— 一律让 scripts/verify-package-invariants.ts 挂掉。而 invariants 服务本身刻意不含任何产品逻辑:检查归各包所有,注册表只管调度和生命周期,每个启用的检查跑在自己的子 Cordis fiber 里,失败时原子回滚。

6scope:注册向下继承,事件向上冒泡

global — 全局注册 preset A(挂载一次) preset B agent 1 agent 2 agent 3 注册视图向下继承 子 scope 看得见祖先注册的 工具与提示词章节,近的遮远的 事件准入向上冒泡 祖先的监听器收得到后代的事件, 反过来绝不行 兄弟 preset 之间互相是聋的
图 6 · scope 的双向语义。原文:"registration views inherit DOWN it … and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse."(packages/core/scope/README.md:5)

这是支撑"一个进程里跑多个 agent、各自有各自能力集"的底层原语。两个方向是反的,而且反得有道理:

基于这一条,agent-presets 做到的效果是:preset 的工具、提示词章节、投影单元只存在一份,却覆盖所有加入它的 session —— 靠的是把每个 agent 的 scope key 认作它的子。插件自己按 Session/Agent 分状态,所以共用一份实例也不串。

README 里还标了这套机制的硬边界,很实在:如果一个 preset 真要拥有自己的服务,必须放进 isolate realm。否则第二个 preset 注册同名服务会直接撞车,"a host reader would resolve one preset's instance for every session"(agent-presets/README.md:123)。

7能力缝:为什么换一处能动全身

Service Definition 只说 WHAT,不说 HOW Service Provider 实现并注册进 ctx Consumer 通常是面向模型的工具 三个角色齐了才叫缝 只占一个角色不是 Bash 工具 PTY 终端 LSP 导航 同一个执行世界 ctx.fs ctx.subprocess 两个 provider 把这两个 provider 指向远端沙箱 Bash 跟着搬 PTY 跟着搬 LSP 跟着搬 三样工具一行都不用改 —— "with no provider forks"
图 7 · 能力缝的三角色,与一次替换的传导范围。出处:docs/architecture.md:100, 102。全树共 55 个这样的服务(docs/capability-seams.md 生成图谱实测)。

dsh 把"可替换能力"形式化成了三角色结构,并规定:一个包可以兼任多个角色,但只占一个角色不叫缝;新增一项能力意味着把三个角色都设计出来。

图右下是这套东西的实际收益。Bash 工具、PTY 终端、LSP 导航共用同一个执行世界(ctx.fs + ctx.subprocess),所以把这两个 provider 指向远端沙箱,三样工具自动跟着搬过去 —— 不需要给每个工具各写一个"远程版本"。

同一条设计还有一句反向约束,防止缝被某一个消费者绑架:

packages/AGENTS.md

"Design Service Definitions for all current Consumers. Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract."

"Inverse smell: a public service method with one internal caller — pass a private capability closure instead."

一个只有一个内部调用方的公开服务方法,是设计错误的信号:它本该是个私有闭包,而不是接口的一部分。

8把文档做成 CI 卡口

这是我读完最意外的地方。dsh 把几类文档变成了会让构建挂掉的硬约束:

约束要求实测覆盖
Model Experience每个包 README 必须写清它对模型、token、KV 缓存的影响,格式规定死215 / 219 个 README 含 #### KV Cache effect
Known Limitations必须有 ## Known Limitations and Deferred Work 记录长期缺口;没有的走 allowlist 并说明理由verify-package-readme-limitations.ts
Invariant 伴生见第 5 节verify-package-invariants.ts
Postmortem事故独立编号归档,并被规矩条目反向引用docs/postmortem/0001–0004

"KV 缓存影响"这一项最能说明问题。看 ralph 工具 README 里那句:

packages/workflow/tool-ralph/README.md:56

"Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section."

这个插件的挂载或卸载,会改变系统提示词前缀,从而作废 KV 缓存复用。

在一个允许任意插件改系统提示词的架构里,提示词前缀稳定性是一个全局共享、且极易被单个插件破坏的资源。dsh 的应对不是禁止改,而是强制每个包声明自己的破坏范围 —— 写不清楚,CI 不让过。

这些看起来过度的纪律,前提是这一条立场:

AGENTS.md:5-7

"Pre-release stance: foundation over blast radius. … With no external consumers, prefer the correct foundation over compatibility shims: rename or repackage freely and update every reference together."

而且这条豁免自带期限 —— 同一节第一句写着 "Remove this section at the first tagged release."

9取舍(这一节是判断,不是源码事实)

买到了 三样东西

策略与实现彻底分离。 部署方通过带 schema 的 config 拥有全部策略面,模型和插件都越不过去。

可验证性。 日志是唯一真相且有运行时断言守着,回放、审计、fork、压缩建立在同一个可信来源上。

换一处动全身(好的那种)。 55 个缝意味着换 provider,而不是改分支。

付出了 三样代价

认知门槛极高。 219 个包、55 个服务、waterfall / serial / scoped 三种事件语义 —— 改一行得先搞清它属于哪条缝、该动哪个角色。

纪律成本转嫁给贡献者。 加一个包要同时交:invariant 伴生、Model Experience 段、Known Limitations 段、HMR 卸载测试、REAL-composition 测试。

栈很深。 一次工具调用穿六段流水线,可插拔的另一面是出问题时不好定位。

结论

dsh 不是"一个更好的 coding agent"。它是一个押注 agent 产品最终由部署方而不是模型定义的平台。

它所有看起来过度的设计 —— 单调 guard、日志重推断言、每包 invariant、KV 缓存影响声明 —— 服务的是同一件事:让"谁能改什么"这个问题在任何一层都有确定答案。

单人单机跑 agent,这套东西是纯负担。多个团队往同一个平台塞能力、且不能互相破坏 —— 它解决的正是那个问题。