DeepSeek Harness 源码解读(十):六条设计纪律如何约束可替换运行时
DeepSeek Harness 的价值不只在插件数量,而在一组持续约束所有插件的规则:注册必须可撤销,模型可见事实必须可重建,能力必须按角色拆分,控制流必须显式,错误配置必须尽早失败。本文以当前仓库的 effect、Session、事件、能力接口和 runtime invariant 为证据,收束整个源码解读系列。
项目地址:https://github.com/deepseek-ai/deepseek-harness
源码基线:仓库版本 0.1.0-rc.5。重点入口是根目录 AGENTS.md、docs/architecture.md、packages/core、packages/runtime-diagnostics/invariants 及各能力 Service Definition。
一、本章要回答的问题
插件化系统很容易停留在形式上:代码拆成很多包,每个包都有注册函数,但状态仍由中心模块拥有,卸载后监听器仍然存活,模型看到的上下文无法恢复,配置错误直到执行中途才暴露。这样的系统只是"文件很多",并没有获得可替换性。
DeepSeek Harness 的关键不是允许插件做任意事情,而是规定插件必须怎样贡献行为、怎样留下持久事实、怎样调用下一层、怎样表达失败。本文把这些要求归纳为六条设计纪律,并说明它们分别解决什么工程风险。
二、核心结论:自由来自可执行的约束

六条纪律最终指向同一个结果:插件可以替换,但不能留下无法回收的状态;请求可以被改写,但不能脱离日志;能力可以增加,但不能绕开服务语义;类型可以扩展,但不能让未知分支悄悄落入错误路径。
这些规则并非只写在文档里。部分由 TypeScript 类型系统约束,部分由 Cordis effect 管理,部分由配置 schema 和运行时断言执行。代码评审仍然重要,但它不是唯一防线。
三、纪律一:每个注册都必须有生命周期所有者
根目录开发约定把它写成一句话:Registrations are effects。服务、工具、事件 listener、Provider 和后台资源都必须属于某个 Cordis Fiber,并在拥有者卸载时撤销。
默认 Agent Loop 注册工厂时不是直接修改全局变量,而是把注册放进 effect:
ts
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
AgentRegistry.setFactory() 自身也返回 disposer。工具注册、模型 Adapter 注册和许多作用域层同样返回撤销函数。这样形成两层保证:注册 API 明确告诉调用方"这项贡献需要回收",Cordis 再把回收动作绑定到插件生命周期。
这条纪律解决三个问题:热重载不会累积重复 listener;Agent 级注册不会泄漏到其他 Agent;Provider 被替换后不会留下仍可被路由到的旧实例。
需要注意,effect 不是普通的"退出时清理"。它还表达所有权顺序。父 Fiber 卸载会触发子资源释放,异步 disposer 可以被等待,注册失败也能回滚已经建立的局部状态。一个没有明确拥有者的连接、定时器或注册表项,在插件系统中就是潜在泄漏。
四、纪律二:模型可见事实必须从日志重建
docs/architecture.md 给出的规则是 Model-visible means logged。任何进入模型请求的消息、工具结果或请求配置,都必须能从 Session 日志重新推导;新增模型可见输入时,需要扩展 SessionEventMap 并实现对应投影。
这条纪律把"当前内存里看起来正确"提升为"进程重启后仍能解释"。packages/core/session 使用追加事件保存事实,再由 deriveMessages()、请求头折叠和 surface 投影得到模型历史。工具执行、压缩、上下文注入和分叉会话不能私自维护另一份消息真相。
仓库还为这条规则提供了可执行检查。packages/core/agent-loop/src/invariant.ts 在 llm/stream 前识别由默认循环构造的请求,确认请求冻结、携带 live Session 标识,并把请求消息与日志在派发时的派生结果比较;不一致时抛出带包名的 invariant 错误。
因此,插件若想给模型增加一句长期有效的上下文,正确做法不是在最终 messages 数组上临时 push。它应该通过 agent.inject() 进入被接纳的输入流程,或定义新的持久 Session 事件及投影。否则第一次调用也许成功,恢复、分叉和重放都会失去同一事实。
五、纪律三:新增行为优先挂插件,不侵入默认循环
packages/core/agent-loop 负责默认 Turn/Step 驱动,但它不是所有功能的收纳箱。审批、超时、重复调用提醒、上下文、压缩、子 Agent 和工具策略都通过服务或事件接入,而不是不断增加循环分支。
这条纪律要求先判断行为属于哪个领域:
- 需要跨重启保留的事实进入 Session 事件;
- 当前运行中的请求、工具或回合策略进入
agent/*、tools/*等 live 事件; - 可被多个 Consumer 使用的操作进入服务;
- 部署差异进入 Profile、Bundle 或配置,而不是代码常量。
这样做的直接收益是默认循环只承担驱动语义。删除一个 guard 不会改动循环;替换文件 Provider 不会改动文件工具;增加 Web 界面也不需要让 Agent Loop 认识 HTTP。
这条纪律也有边界。如果需求确实改变 Turn、Step、取消或事件顺序,继续在外围叠 listener 反而会让语义更隐蔽。此时应该明确替换 AgentFactory,并更新架构中的生命周期图,而不是伪装成普通插件策略。
六、纪律四:可替换能力必须包含三个角色
DeepSeek Harness 把完整能力拆为 Service Definition、Service Provider 和 Consumer。三者不是目录命名习惯,而是替换成立的条件。
以 Shell 为例:
packages/shell/shell定义ctx.shell的请求、解析后规格、结果和错误语义;packages/shell/bash-local或其他实现负责真正启动命令;packages/shell/tool-bash把能力暴露给模型,并负责工具参数与结果呈现。
Consumer 只依赖 Definition,Provider 只承诺 Definition,Profile 决定装载哪一个 Provider。这样,本地执行切换到远端执行时,模型工具不需要出现一套 E2B 分叉;审批和工具事件也可以继续工作。
三个角色缺一不可。只有接口没有真实 Provider,无法证明语义足够;只有 Provider 没有稳定 Definition,Consumer 会绑定实现;只有工具没有服务层,其他插件就只能绕过工具协议或复制执行逻辑。
这项拆分会增加包数量,但换来依赖方向稳定。包多不是目标,角色能独立演进才是目标。若一个能力只有单一实现、单一消费者且看不到替换需求,强行拆成三包同样属于过度设计。
七、纪律五:控制流和类型分支必须显式
Cordis waterfall listener 收到 next()。调用它表示把控制权交给下一层,直接返回表示短路。两种行为都合法,因此代码必须明确选择,不能把遗漏 next() 当作默认继续。
工具流水线中的 tools/pre-execute、tools/execute 和 tools/post-execute 都依赖这一语义。一个只想观察的 listener 必须调用 next();审批或策略插件若决定阻止,才返回拒绝结果。显式控制权让每个 listener 的责任可审查,也让包装超时、替换信号和清洗结果成为同一种组合方式。
类型分支采用相同原则。闭合判别联合的 switch 以 assertNever() 结束:
ts
export function assertNever(value: never, context?: string): never {
const rendered = (JSON.stringify(value) as string | undefined) ?? String(value)
throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`)
}
新增联合成员而忘记处理时,TypeScript 会在调用点报错;若不可信值逃过静态类型,运行时仍会给出明确诊断。相反,允许插件声明新成员的开放联合必须保留有说明的默认分支,不能假装世界是闭合的。
同样的显式性还体现在包边界默认值。ctx.shell 先用 resolve(request) 产生完整 ShellExecSpec,再交给 run() 或 start();默认值不隐藏在执行中途的 ?? 表达式里。调用者和测试都能看到"请求如何变成最终执行规格"。
八、纪律六:错误配置和被破坏的关系必须尽早失败
插件系统最危险的失败不是报错,而是配置不完整时悄悄跳过。DeepSeek Harness 要求自包含错误在加载时失败,依赖关系则在最早能够确定时失败。
具体机制包括:
- Cordis 服务重复注册会失败,避免两个 Provider 同时声称拥有
ctx.shell; - 配置通过 schema 和额外语义校验,非法并发数、超时或未知字段不会留到第一次请求;
- 跨包标识使用
Branded<B>,例如SessionId与普通字符串在类型层分开; - Session header 的版本、绝对路径和 lineage 字段在读取时验证;
ctx.invariants允许每个包注册自己拥有的运行关系检查,失败时给出INVARIANT错误和包名。
packages/runtime-diagnostics/invariants 不替每个包猜测正确性。每个 ./invariant companion 只检查该包真正拥有、并能从权威事件或可变数据观察到的关系。默认循环检查请求是否可由日志重建,Session 检查 Turn、Step 和工具事件顺序,其他包没有可信关系可检查时应明确为空,而不是制造一个永远成功的样例断言。
"尽早失败"会让启动和开发阶段更严格,却能避免系统带着半套能力进入真实会话。对于会执行命令、写文件和持久化历史的 Agent,清晰拒绝比带着错误假设继续运行更安全。
九、六条纪律怎样互相支撑
单独看,每条规则都像局部编码规范;放在一起,它们形成一条完整链路:
- Profile 装载插件,配置错误立即失败;
- 插件通过 effect 注册服务、Provider、工具或 listener;
- Consumer 只依赖稳定 Definition,不绑定具体实现;
- 请求经过显式 waterfall,观察者和拦截者不会混淆;
- 模型可见结果追加为 Session 事件,再从日志投影;
- runtime invariant 检查关键关系没有在组合过程中被破坏;
- 插件卸载时,所有注册按所有权顺序撤销。
这条链解释了为什么"插件自由建立在硬约束上"。如果没有生命周期,替换会泄漏;没有日志纪律,恢复会分叉;没有角色拆分,Provider 无法替换;没有显式控制流,listener 会意外吞掉请求;没有早失败,错误组合会进入生产运行。
十、开发者预览意味着什么
仓库当前版本是 0.1.0-rc.5,README 明确标注开发者预览并提示会出现破坏兼容性的变更。根目录开发政策进一步选择"地基优先":在首个正式标签前,宁可统一重命名、重组包和拒绝旧磁盘格式,也不为尚未承诺的外部兼容性增加 shim。
这项选择与六条纪律并不矛盾。内部协议要求严格,外部兼容承诺仍然可以处于预发布状态。前者保证当前组合自洽,后者允许团队在正式稳定前修正抽象。
使用者应据此做三件事:锁定版本;把 Profile、Session 数据和插件 API 的升级纳入发布流程;不要把 RC 阶段的包路径或持久格式当作长期 ABI。评估项目时可以关注设计和能力,生产升级则必须按版本验证。
十一、把这些纪律带到自己的 Agent 项目
即使不采用 DeepSeek Harness,也可以用下面的问题审视自己的运行时:
- 一个工具、listener 或连接由谁拥有,模块卸载时谁负责回收?
- 模型下一次看到的每条内容,能否从持久事实重新生成?
- 新策略是否必须修改中心循环,还是存在稳定事件或服务入口?
- 可替换能力是否真的分开了接口、实现和消费方?
- 中间件继续、短路和替换结果是否在代码中显式可见?
- 错误配置、未知联合成员和跨组件关系破坏会在什么时候失败?
如果其中三四个问题没有明确答案,系统可能仍能演示,但随着 Provider、工具和入口增加,行为会越来越难恢复、替换和诊断。
十二、十篇源码解读的最终地图
本系列可以压缩为一条从组合到约束的阅读路径:
- 第一篇确定 Harness 不是单次模型调用器,而是 Agent 运行时;
- 第二篇解释 Cordis Context、Service、事件和 effect;
- 第三篇建立核心服务与事件域的整体结构;
- 第四篇跟踪一次 Turn 和 Step;
- 第五篇展开工具从参数到结果的执行链;
- 第六篇解释 Definition、Provider、Consumer 如何实现替换;
- 第七篇说明 Session 日志怎样成为模型历史的来源;
- 第八篇拆解文件、命令、审批、沙箱和 Guard 的安全分层;
- 第九篇把架构选择落到适用场景与扩展入口;
- 本篇用六条纪律解释这些机制为何能够长期共存。
第一次读仓库时,不必记住所有包名。先记住三条定位原则即可:运行组合看 Profile,临时行为看服务与事件,持久事实看 Session。遇到可替换能力,再检查 Definition、Provider、Consumer 是否完整。
十三、结语
DeepSeek Harness 展示的不是一种最短的 Agent 写法,而是一种面向长期演进的组织方式:把实现放进插件,把能力放在稳定服务后面,把过程交给明确事件,把模型历史交给追加日志,再用类型、配置校验和 runtime invariant 守住这些关系。
它因此比简单脚本更重,也比固定内核更难在几分钟内读懂。但当一个 Agent 产品需要多入口、长会话、可替换执行环境和持续扩展时,这些约束会把复杂度留在可定位、可卸载、可重放的位置。约束不是插件化的对立面,而是插件真正可以自由组合的前提。