从 Agent Loop 到 Harness:DeepSeek Harness 的一套大仓 AI 工程化实战解读
从 Agent Loop 到 Harness:DeepSeek Harness 的一套大仓 AI 工程化实战解读
基于 DeepSeek Harness 当前源码快照的阅读笔记。
本文不讨论“模型能力有多强”,只讨论一个 agent 产品在真实工程里怎样把可替换、可审计、可恢复和可验证做进运行时。代码位置均对应本仓库源码。
写在前面
如果你只把 AI 当成“能调用几个工具的聊天机器人”,一个循环就够了:收用户消息,调用模型,解析 tool call,执行工具,再把结果塞回模型。
但当它开始处理真实的软件工程任务,问题很快就不在“能不能调模型”上。一次会话能不能暂停后恢复?工具执行一半取消了,日志还能不能回放?一个 agent 能不能拥有不同于另一个 agent 的工具集合?一个新的沙箱实现接进来,会不会逼着你改 Agent Loop?一个 Web UI 如何准确地还原模型流式输出、工具待执行态和最终结果?
DeepSeek Harness(下文简称 dsh)面对的就是这类问题。它不是一个将 prompt、模型和工具简单粘起来的 SDK,而是一个围绕长期运行 agent 构建的插件运行时。项目在架构文档里给出了很鲜明的原则:everything is a plugin。
这句话真正的含义不是“插件很多”,而是没有一块功能天然拥有特权。模型适配器、工具注册表、会话日志、审批策略,甚至驱动模型请求的 agent loop,都是可以由配置组装、可以由生命周期卸载的插件。
本文按照“先讲问题 → 再讲运行时骨架 → 再讲几个最值得看的工程细节 → 最后讲可以带走的判断标准”的顺序展开。不是官方设计文档的改写,而是一份面向工程实践的源码导读。
全文阅读约 20 分钟。若时间有限,建议直接看第四章的会话日志、第五章的工具流水线和第七章的几个取舍。
第一章:它究竟要解决什么问题
1.1 不是“调用模型”,而是管理一个会话中的工作
一个普通 demo 往往把对话抽象成一问一答:用户输入,模型输出,结束。
真实 agent 却更像一个持续演进的工作单元。一条用户消息可能触发多轮模型请求;一轮模型请求可能调用多个工具;工具结果可能再给模型补充上下文;用户还可能在执行过程中插入新指令、取消任务,或者在稍后恢复此前的会话。
dsh 将这里的最小执行单位拆成了两层:
| 单位 | 定义 | 例子 |
|---|---|---|
| Turn | 一次被唤醒后的完整工作轮次,可包含零到多个步骤 | 用户说“修复这个测试”后开始的一次连续处理 |
| Step | 一次模型请求及其引起的工具调用 | 模型先读文件、再调用 bash、将结果交给下一次请求 |
这不是纯概念划分。它决定了取消写在哪里、日志何时落盘、上下文在哪个时点进入模型,以及一个失败到底该回退到哪里。默认驱动器的实现位于 packages/core/agent-loop/src/agent.ts,其中 ReactLoopAgent 明确维护 idle、maintenance、running 三种状态,而不是把所有异步工作混进一个 Promise 链。
1.2 一个 Harness 的复杂度来自四类事实
阅读源码后,可以把 dsh 的主要复杂度归为下面四类:
| 事实 | 如果没有工程化设计,常见结果 | dsh 的处理 |
|---|---|---|
| 能力会变 | 新模型、新工具、新沙箱都要改核心循环 | 用插件和 capability seam 隔离替换 |
| 会话会中断 | 内存数组丢失,无法解释历史上下文 | 用 append-only SessionEvent 日志作为事实来源 |
| 工具有副作用 | 权限、超时、策略散在每个工具里 | 统一进入工具执行流水线 |
| agent 需要分层权限 | 全局工具集互相污染,子 agent 难以隔离 | 用 scope 管理注册可见性和事件路由 |
注意,这四件事本质上都不是模型推理问题。模型只是运行时中的一个外部能力。真正难的是:把模型的不确定输出放进一个确定、可观察、可治理的工程系统。
1.3 最有价值的边界:模型可见即已记录
dsh 有一条非常硬的内部约束:任何抵达模型请求的内容,都必须能从 session log 重建。
这条约束看上去很保守,实际解决的问题很多。它意味着 Web UI、终端 transcript、会话恢复、fork、遥测和离线排查不需要各自维护一份“对话真相”;它们消费同一串事件即可。
反过来看,很多 agent 应用后期难维护,往往正是因为“模型看到什么”藏在临时内存对象里,“用户界面显示什么”藏在另一个状态机里,“持久化内容”又是第三份数据。三份数据开始时都很合理,出错后却很难对账。
第二章:先看整体,dsh 是怎样组装起来的
2.1 Profile、Bundle、Patch:把产品形态变成配置组合
dsh 不把 Web、命令行、headless runner 写成三条大分支,而是把运行时看成由插件条目组成的树。
profile
├─ base bundle 模型、会话、工具、权限、持久化等公共能力
├─ web-app bundle Web 应用和服务端装配
├─ headless bundle 一次性运行入口
├─ profile patch 用户配置的当前 profile 覆盖
├─ home patch Harness home 的全局覆盖
└─ --patch overlay 本次启动的临时覆盖
基础层的真实装配清单在 packages/bundle/base/cordis.patch.yml。里面不是抽象示例,而是系统实际启动时挂载的服务:dsh-llm、dsh-session、dsh-agent、dsh-subprocess-local、dsh-sandbox-local、文件系统工具、技能系统、审批系统等。
这里有一个值得学习的细节:patch 不是深度合并某个插件的 config,而是通过行 ID 定位后替换整条配置。这样做的代价是覆盖者必须写完整配置;收益是最终运行的配置没有“某个字段到底继承自哪里”的隐式行为。
对于 agent 基础设施,显式通常比灵活的合并规则更便宜。因为配置错误的排查频率,远高于少写几行 YAML 的收益。
2.2 Cordis:让注册有生命周期
项目底层使用 vendored Cordis。Cordis 的关键不只是依赖注入,而是把服务、事件监听和注册操作都放入插件生命周期。
在 dsh 的约定里,注册不是“向全局 Map 塞一个函数”,而是一种 effect:插件挂载时注册,插件卸载时自动撤销。这在热更新、会话预设、临时能力以及测试清理时非常重要。
比如工具注册表 ctx.tools.register() 会返回 disposer;作用域对象也维护自己的 disposal 语义。你不需要靠“记得在 finally 中手工删掉”来维持干净状态。
这个设计带来的真正变化是:扩展不是修改中心代码,而是在同一个 Context 上追加可逆贡献。架构文档把它概括为“没有需要打补丁的特权核心”。
2.3 Capability seam:不要只抽接口,要把替换关系做完整
dsh 将可替换能力拆成三种角色:
| 角色 | 负责什么 |
|---|---|
| Service Definition | 声明能力接口和稳定语义 |
| Service Provider | 实现该接口,例如本地、远程或 sandbox 版本 |
| Consumer | 消费能力,通常是面向模型暴露的工具 |
项目把这组完整关系称为 capability seam。以 shell 为例,模型工具不直接 spawn 本地 bash;它消费 ctx.shell,本地实现再经由 subprocess 和 sandbox 等更低层能力工作。
这个拆分的判断标准并不是“所有东西都要拆成三个包”。只有当定义、实现和消费会独立演化或独立替换时,才值得形成 seam。否则过度拆分会把一个小功能变成一串没有实际替换价值的抽象。
第三章:Agent Loop 不是 while 循环,而是一台可恢复的状态机
3.1 一次 Turn 的真实路径
架构文档给出的执行路径很清楚:
turn/start
-> 领取 inbox 中的输入
-> agent/pre-step
-> step/start
-> user/message 落日志
-> 由日志派生模型历史
-> agent/request -> llm/stream
-> assistant/chunk* -> assistant/message
-> tool/call* -> 工具流水线 -> tool/result*
-> step/end
-> 需要时进入下一 step
turn/end
这里最容易被忽略的是:先写 assistant/chunk,再写完整的 assistant/message。前者保留流式输出和 UI 时序,后者成为模型下一步真正要消费的稳定消息。它不是简单地把 chunk 拼起来丢掉,而是同时服务实时体验和离线回放。
对应实现可以从 ReactLoopAgent.step() 开始看。它用 BlockAssembler 组装流,同时把每个 chunk 写成事件;流结束后生成完整 assistant message,并从内容中抽取 tool call。
3.2 Inbox 是持久的,不只是数组
用户的 followup、steer 和注入上下文并不是直接塞进当前请求。它们进入 Inbox,并被区分为 next-turn 与 next-step 两类队列。
这两个队列的每次修改都会写成 agent/inbox/spliced 事件。也就是说,哪条消息排队、被领取、被取消,都可从会话日志中恢复。实现见 packages/core/agent/src/inbox.ts。
这是一种很典型但常被忽略的工程选择:只要一个内存队列影响未来模型输入,它就不是“内部实现细节”,而是需要持久化的业务状态。
3.3 取消不是 throw 一下就结束
异步 agent 里最棘手的一类 bug,通常是取消到达时,已有工作正在执行。
如果直接停止等待,已启动的工具可能继续运行,日志却没有结果;如果立刻清空任务,又会让恢复时无法知道哪些调用没有发生。dsh 的策略更严格:取消会停止新的工具调度,同时等待已开始的调用排空;未开始的调用会补写合成失败结果,确保日志仍是可重放的完整记录。
这会让实现复杂一些,但换来一个关键性质:一次已进入日志的模型工具调用,不会在历史上留下“看起来发起了,却永远没有结局”的空洞。
第四章:事件溯源不是为了“优雅”,是为了让所有投影对得上
4.1 SessionEvent 是唯一事实流
packages/core/session/src/index.ts 中的 Session 是整个运行时最值得细读的类之一。它维护 append-only log,每个事件都分配连续的 seq,并在进入日志前做 JSON 可序列化校验、冻结和 surface 约束检查。
从实现上看,它刻意拒绝很多“方便但不可持久化”的值:函数、BigInt、循环引用、稀疏数组、Map、Set、Date、类实例等。这样做并不是讨厌 JavaScript 的灵活性,而是在日志写入处解决边界问题:一旦事件接受,任何持久化后端都应当能保存,任何回放端都应当能理解。
4.2 日志不是直接等于模型上下文
一个容易误解的点是:并非所有日志事件都会进入模型历史。
例如 turn/start、step/end、assistant/chunk 都是重要的运行记录,但模型不需要逐条读到它们。dsh 通过 surfaceOp 和 sourceEventSeqs 管理哪些事件构成对话 surface,再由 deriveMessages() 增量投影为模型历史。
SessionEvent log
├─ 运行事实:turn/start、step/end、inbox splice
├─ 流式事实:assistant/chunk
├─ 对话事实:user/message、assistant/message、tool/result
└─ surface 投影
└─ deriveMessages()
└─ LLM request.messages
这种二段式设计尤其适合 compaction。压缩上下文时,不必篡改旧日志,而是通过 surface replacement 改变“下一次模型应该看到哪些消息”。历史仍在,当前上下文可以被重新组织。
4.3 事件日志为什么要 deep freeze
日志一旦被其他插件、UI 或持久化层观察,就不能再允许调用方把原对象改掉。
Session.append() 会对 data 做无损快照并深度冻结。它保护的不是类型安全,而是时间上的一致性:某个事件在 10:00 被记录的内容,不能因为调用方在 10:01 修改了一个嵌套对象而悄悄变成另一条历史。
在小应用里,这似乎是额外成本;在支持回放、fork、远程 SDK 和遥测的系统里,它是很划算的防线。
第五章:工具执行流水线,才是 Agent 的治理中心
5.1 工具不是一个 execute 函数
很多 agent 框架的工具模型是:定义参数 schema,写一个 execute,然后交给模型调用。
dsh 的工具注册表则把一次调用拆成多个可插拔阶段:
tool/call 已落日志
-> tools/pre-execute 允许、拒绝或请求人工审批
-> 单调 guard 只能拒绝,不能越权放行
-> tools/execute 环绕主体,可实现超时、重试、指标
-> tool.execute() 工具主体
-> tools/post-execute 改写结果、阻止结果、追加上下文
-> finalizeContent 工具自身的最后内容约束
-> tools/result 只读观察最终冻结结果
-> tool/result 落日志
完整实现位于 packages/core/tools/src/index.ts,图形化的官方说明见 docs/tool-execution-pipeline.zh.md。
这个分层最重要的价值是解耦。审批服务不需要知道 bash 工具如何执行;超时策略不需要复制到每个工具里;文件系统策略也不需要侵入所有调用方。它们都挂在正确的阶段上。
5.2 为什么 guard 要“只能拒绝”
dsh 对 guard 的设计很克制:guard 返回拒绝原因,或什么也不做;它不能强制放行。
这是一个看似微小、实际很关键的权限模型。可重排的策略插件可以参与 pre-execute waterfall,而资源所有者的限制则以单调 guard 形式存在。只要有一个拥有方说“不行”,后面的插件就不能以“我觉得可以”为由绕过它。
在多插件系统里,这种单调性比“每个插件都能写一个最终决定”更安全。否则插件的加载顺序会意外变成权限优先级。
5.3 并发执行,但按模型顺序提交
模型可能一次发出多个工具调用。全部串行会慢;全部并行又会让结果顺序、上下文注入、UI 显示和日志回放都变得不稳定。
dsh 的解决方式是一个有界滚动池:被声明为 concurrency-safe 的连续调用可以并行 dispatch;独占调用是屏障;无论实际谁先结束,tool/result 仍按模型调用顺序提交。
这段逻辑在 packages/core/agent-loop/src/tool-calls.ts。它体现的是很实用的一条原则:
可以并行执行,不要并行提交事实。
副作用的执行顺序可以为了性能放宽;进入持久历史、进入模型上下文和进入用户界面的顺序,最好保持可预测。
5.4 Code Mode 的真实含义:把多工具协作收进一个程序
dsh 还提供 code / both 两种工具呈现方式。在 code 模式下,模型不直接面对所有原生工具,而是面对一个保留的 run_code 传输和根据当前工具集生成的 SDK。
模型写一段程序,在程序内部调用 tools.read_file()、tools.bash() 等绑定。内部每个调用仍会回到同一条完整工具流水线,不是绕开策略的捷径。
这个功能的工程价值不只是“模型会写代码”。它把多步、依赖明确的工具编排压缩进一个可控程序,同时仍保留权限、超时、并发分类和可审计日志。
第六章:Scope 解决的不是命名冲突,而是 agent 的能力隔离
6.1 为什么全局工具注册会成为问题
假设一个进程里同时运行三个 agent:主 agent、只读代码审查 agent、受限的子 agent。
如果工具注册全部是全局的,你很快会遇到两个问题:子 agent 不该看到的工具仍在 prompt 中;为一个子 agent 临时添加的工具可能污染其他 agent。简单的 if 判断可以补一阵,但很快会把每个注册表和每个事件都写成“如果 agentId 等于……”。
dsh 的做法是在 Context 上创建带不透明 scope key 的子上下文。工具、prompt section、策略等注册可以在 scope 内发生;子 scope 继承祖先能力,局部注册可遮蔽上层注册;事件则只向匹配 scope 或祖先 scope 的监听者路由。
实现位于 packages/core/scope/src/index.ts。
6.2 Scope 的一个细节:能力继承和事件传播方向相反
这段源码特别值得看。
能力可见性沿着祖先链向下继承:子 agent 可以看到父 scope 的工具。事件路由则让父 scope 观察子 scope 的事件:一个预设层可以观察它所管理的全部子 agent,但子 agent 不会反过来观察父层的私有事件。
这两个方向恰好对应了组合系统的常见需求:上层提供能力和政策,下层执行具体任务;上层需要审计下层,下层不应越权窥探上层。
第七章:三个看起来更简单、实际上会踩坑的方案
7.1 “把所有逻辑都放进 Agent Loop”
直觉上最方便的做法,是在 loop 里直接写权限判断、文件系统限制、重试、Telemetry、工具特判。
短期它确实快,长期每一个新能力都要改最核心、最难测、最容易引入回归的地方。dsh 反过来要求“新行为优先成为插件,而不是 loop 改动”。Agent Loop 只拥有时序和状态;能力细节进入服务或事件扩展点。
判断标准很简单:如果你希望某个行为可以按部署、用户、agent preset 或运行时环境变化,就不要把它固化在循环里。
7.2 “工具并行了,结果也按完成顺序返回”
这会让性能看起来很好,却会制造很难复现的问题。
假设模型先请求读 A 文件,再请求读 B 文件;B 先完成。如果 B 的结果先进入上下文,下一轮模型观察到的历史顺序就不再等于自己的调用顺序。对模型、UI 和回放来说,这都是不必要的非确定性。
dsh 的实现选择了执行层并发、提交层有序。它牺牲一点最短延迟,换取稳定的因果叙事。对于能够被用户重放、被测试快照和被日志审计的系统,这是正确取舍。
7.3 “持久化只在会话结束时做一次快照”
快照方案实现简单,但无法自然处理流式输出、进行中的工具调用、断电恢复和历史 fork。更糟的是,快照里很难分辨“模型已经看到的上下文”和“正在准备的临时状态”。
dsh 用事件流取代终态快照,并且让 append 热路径不阻塞 I/O:持久化插件订阅 session/event,在 flush 或 dispose 时完成耐久化。这样运行时保持响应性,事实流也保持完整。
事件溯源不是所有业务系统的默认答案;但当系统的价值包含重放、审计、恢复和多投影时,它往往比“保存最后一个 JSON”更贴近问题本身。
第八章:从 dsh 可以带走的工程判断标准
读完源码,最值得带走的不是某个类名,而是以下几条判断标准。
8.1 先问“这是能力、策略,还是时序”
能力适合抽为 seam,例如 LLM、filesystem、shell、subagent;策略适合挂在事件流水线,例如审批、超时、限制和审计;时序才属于 agent loop,例如 turn、step、取消和队列。
把这三类事情混在同一个模块里,是 agent 系统后期失控的高频起点。
8.2 对模型可见的数据,按可审计数据对待
不要把模型 prompt 当作临时字符串拼接。只要内容影响了模型决定,就应当能回答:它来自哪里?何时生效?能否在重放时重建?是否受权限或用户确认约束?
dsh 的“model-visible equals logged”把这些问题变成一条架构不变量。
8.3 并发优化要明确谁拥有顺序
并发并不等于无序。执行、持久化、模型上下文和用户界面可以拥有不同的顺序要求。先写清哪些阶段可重叠、哪些事实必须有序,再开始写 Promise pool。
8.4 插件化不等于到处可插拔
插件系统只有在每个扩展点有明确时机、数据和权限时才有价值。dsh 不只是暴露大量 hook;它还给出了事件域、waterfall 语义、scope 路由、最终结果冻结和日志不变量。
没有这些限制的“插件化”,最后通常会退化成一堆依赖加载顺序的回调。
8.5 用机器验证你真正关心的工程规则
仓库本身把不少设计要求变成了可执行检查:类型检查、工具 schema、文档图生成、包不变量、构建产物闭包、快照测试和依赖约束。
这条经验和 Harness 本身高度一致:能由机器判断的事实,不应只留在 Agent 的自然语言承诺里。
结语:Harness 的重点不是“让 AI 更能干”
DeepSeek Harness 最有价值的地方,不是提供了多少工具,也不是把 agent loop 写得多复杂。
它真正解决的是:当 AI 进入真实工程后,如何让一次任务有明确的能力边界、稳定的执行时序、可追溯的历史、可替换的基础设施,以及能被测试和治理的结果。
模型会继续变强,但这些工程问题不会自动消失。反而模型能力越强、可调用的系统越多,运行时越需要把“不确定的推理”包在“确定的工程秩序”里。
这也许就是从一个 Agent demo 走向 Harness 时,最重要的一步。
附:源码阅读路线
docs/architecture.zh.md:先建立 Profile、Bundle、事件和能力 seam 的全局地图。packages/core/agent-loop/src/agent.ts:理解 turn/step、流式模型响应和下一步调度。packages/core/session/src/index.ts:理解事件日志、surface 和消息投影。packages/core/tools/src/index.ts:理解工具注册、策略流水线和结果规范化。packages/core/agent-loop/src/tool-calls.ts:理解并发调度与有序提交。packages/core/scope/src/index.ts:理解多 agent 的能力隔离和事件路由。packages/bundle/base/cordis.patch.yml:最后回到真实装配清单,理解“everything is a plugin”如何落地。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)