正文
deepseek harness · 架构图解
dsh 架构图解
219 个包,19.9 万行 TypeScript。而真正的 agent 循环只有 1,643 行。本文用七张图说明这个比例是怎么来的,以及它换到了什么。
面向懂 agent 的工程师 图在前 · 结论在后 数据实测自源码树
1整体形状
图 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。
:::card 一个直接推论
因为循环是空的,所以 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它是怎么被拼起来的
图 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 的完整时序
图 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工具流水线:灵活段与棘轮段
图 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 |
:::card 为什么这个拆分重要 大多数框架只给你一种钩子。结果只有两个方向:要么钩子太弱不够用,要么任何一个插件都能把安全策略解除掉 —— 因为它跟安全策略用的是同一种注册方式,谁后跑谁说了算。 dsh 的答案是给两种,并且在执行顺序上焊死:waterfall 先跑完,guard 后跑,guard 的拒绝没有回头路。想放行的插件根本没有一个能表达"放行"的位置。 :::
5模型可见 = 必须落日志(且当场校验)
图 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 })三个细节说明这不是装饰:
prepend: true,并且注释直接写明理由 —— 防止某个会短路的 replay 监听器把检查静音掉。它假设自己会被别的插件绕过,并主动防了这一手。- 不只比消息,还比折叠出来的请求头:model、system、temperature、maxTokens、stop、tools 六项逐一对(
invariant.ts:44-49)。 - 请求对象和 messages 数组都必须
Object.isFrozen。不是"约定不要改",是改不了。
:::card 它防的是哪一类 bug agent 系统最难查的一类问题是:模型看到了日志里没有的东西。某个插件在请求路径上偷塞了一段上下文,而回放、审计、fork、压缩全部基于日志 —— 于是线上那次行为你永远复现不出来,而且怎么查都查不到。 dsh 的做法是把"日志 = 唯一真相"从约定升级成运行时断言。谁偷塞,谁当场炸。 :::
配套:每个包都必须自带一份 invariant
这条检查不是孤例,它是一套制度的一部分。包级宪法的最后一条规定:每个包都必须发布一个 ./invariant 伴生插件,注册自己的 npm 包名;能写出运行时检查就写,写不出来必须留一条以 No runtime invariant: 开头、且针对本包的具体理由。
"凑数的生成物、没解释的空实现、被忽略的报告"—— 一律让 scripts/verify-package-invariants.ts 挂掉。而 invariants 服务本身刻意不含任何产品逻辑:检查归各包所有,注册表只管调度和生命周期,每个启用的检查跑在自己的子 Cordis fiber 里,失败时原子回滚。
6scope:注册向下继承,事件向上冒泡
图 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、各自有各自能力集"的底层原语。两个方向是反的,而且反得有道理:
- 注册向下 —— 所以一个 preset 的工具和提示词章节在整个进程里只需要挂一次,底下所有加入它的 session 自动都有。不是每个 session 复制一份。
- 事件向上 —— 所以祖先能观察后代,而后代碰不到祖先,兄弟之间完全隔离。一个 session 的事件不会漏进另一个 session 的监听器。
基于这一条,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能力缝:为什么换一处能动全身
图 7 · 能力缝的三角色,与一次替换的传导范围。出处:docs/architecture.md:100, 102。全树共 55 个这样的服务(docs/capability-seams.md 生成图谱实测)。
dsh 把"可替换能力"形式化成了三角色结构,并规定:一个包可以兼任多个角色,但只占一个角色不叫缝;新增一项能力意味着把三个角色都设计出来。
图右下是这套东西的实际收益。Bash 工具、PTY 终端、LSP 导航共用同一个执行世界(ctx.fs + ctx.subprocess),所以把这两个 provider 指向远端沙箱,三样工具自动跟着搬过去 —— 不需要给每个工具各写一个"远程版本"。
同一条设计还有一句反向约束,防止缝被某一个消费者绑架:
:::card 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 不让过。
这些看起来过度的纪律,前提是这一条立场:
:::card 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 测试。
栈很深。 一次工具调用穿六段流水线,可插拔的另一面是出问题时不好定位。
:::card 结论 dsh 不是"一个更好的 coding agent"。它是一个押注 agent 产品最终由部署方而不是模型定义的平台。 它所有看起来过度的设计 —— 单调 guard、日志重推断言、每包 invariant、KV 缓存影响声明 —— 服务的是同一件事:让"谁能改什么"这个问题在任何一层都有确定答案。 单人单机跑 agent,这套东西是纯负担。多个团队往同一个平台塞能力、且不能互相破坏 —— 它解决的正是那个问题。 :::
原文:dsh 架构图解
来源与依据
- 旧 Wiki 原文 · DeepSeek Harness 不是 agent 循环,而是循环周围的缝
收录日期:2026-10-11
- 原文引用 · mastra-report.vyibc.com
收录日期:2026-10-11