先说结论
Agent harness 是包在模型外面的那一层工程:模型只负责每一步的推理,其余的循环、上下文、工具、权限、持久化,都归 harness。
DeepSeek 开源的 DeepSeek Harness(命令行名 dsh)是个不错的拆解样本。它没有把这些能力写成一个大循环,而是拆成几十个独立的包,每个包只管一件事。读它的包目录,基本就等于读一份 harness 模块清单。
本文以 dsh 的文档和包结构为依据(读的是 2026-10-15 的仓库快照),回答一个问题:一个 harness 到底有哪些模块,它们之间怎么配合。dsh 目前是官方标注的开发者预览,会有破坏兼容性的变更,下面的包名和事件名以后可能变。
一切皆插件
dsh 的底座是 Cordis 插件框架。可以把它想成一个大家共用的工作台:每个插件上台,能做三件事。
- 提供服务:把自己的能力挂在工作台的固定位置上,比如压缩挂在
ctx.compaction。别人要用就按位置取,不用关心背后是谁实现的。 - 收发事件:插件之间不直接互相调用,靠带类型的事件通信,谁关心哪个环节,就在那里挂个监听器。
- 随时收回:注册工具、提示词片段、监听器,每一笔都记着对应的撤销办法。插件卸载或热重载时,框架按记录一笔笔撤掉,不留残渣。
官方架构文档的说法是,产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志,以及 agent loop 本身,所以每个都能通过配置替换。
这带来两个直接后果:
- 没有特权内核。想扩展,就把插件挂在别的插件旁边;注册产生的副作用,会在插件卸载时一起撤销。
- 依赖只指向接口。扩展插件依赖“服务定义”,不依赖具体实现。比如 agent loop 可以整个换掉,UI、钩子、工具插件都不受影响。
文档里反复出现一个模式,叫能力 seam,拆成三个角色:
- Service Definition:定义接口和词汇,如
ctx.compaction。 - Service Provider:具体实现,如本地文件系统、不同平台的沙箱。
- Consumer:面向用户或模型的入口,如压缩命令、
skill工具。
压缩、沙箱、文件系统、spill 存储、skill、子 Agent,基本都按这个套路拆。这是 dsh 最值得借鉴的设计:模块边界先切在“能力”上,而不是切在“功能”上。
Profile 与组合包
插件怎么组装成一个可运行的 agent?靠 profile。profile 是一份具名装配,里面叠放若干“组合包”,组合包是配置加挂载代码的分发单元。随发行版提供 web、headless、sdk、sdk-minimal、acp 五个 profile,共享的第一层是 dsh-base:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。
各层按顺序叠加,后面的 patch 可以按 id 替换或插入某个条目。想看自己机器上最终的插件树:
# 打印 web profile 展开后的完整配置树,里面每个条目都能被自己的 patch 替换
dsh --profile web --dump-config
先跑起来看看:
# 默认在 http://127.0.0.1:3080 启动 Web UI;加 --no-open 只起服务不开浏览器
npx @deepseek-ai/dsh web
模块全景
packages/ 下有几十个包,按能力分成十来个组。我按职责归并成下图。
flowchart TD
UI[接入层 web / sdk / acp] --> Loop[Agent Loop]
Loop --> Ctx[上下文]
Loop --> Tools[工具]
Loop --> Sess[会话日志]
Loop --> Sub[子 Agent]
Ctx --> Prompt[提示词组装]
Ctx --> Comp[压缩]
Ctx --> Spill[结果 spill]
Tools --> Gate[审批与沙箱]
Tools --> Guard[循环守卫]
Sub --> Jobs[后台任务]
Tools --> Skill[Skill 与 MCP]
后面几章按这张图展开。
轮次与步骤
dsh 对循环的定义很清楚:
- 步骤:一次模型请求,加上它触发的所有工具调用。
- 轮次:零个或多个步骤。在领取第一条输入时打开,不再欠任何工作时关闭。
一个轮次的主干流程是:
- 领取排队的输入,组装系统提示词和工具 schema。
- 触发
agent/pre-step钩子,钩子可以拒绝这一步,或改写要进入的消息。 - 发起模型请求,流式接收,记录
assistant/message。 - 执行工具调用,记录
tool/result。 - 步骤结束;如果工具欠一次新请求,或又来了新输入,就进入下一步,否则触发
agent/turn-stopping后关闭轮次。
这里有几个设计值得注意。
- 每个阶段都有钩子。步骤开始前、请求构造时、请求出错时、轮次将结束时,都是可拦截的扩展点,循环本身不需要为新功能改代码。
- 请求出错也走钩子。
agent/request-error的返回值决定是重试还是保留原错误。比如压缩插件就挂在这里处理上下文溢出。 - 取消是原子的。文档明确写了:请求准备阶段被取消时,系统消息和用户消息都不会提交。
事件分三个域,选对域是写扩展时的第一个决定:
| 域 | 性质 | 用途 |
|---|---|---|
| 会话事件 | 追加到日志的持久事实 | 必须在重新加载后仍然存在的内容 |
agent/* 事件 |
活跃 Agent 的实时事件 | 观察或拦截进行中的工作 |
| 能力事件 | 挂在某个 seam 上 | 给 fs/*、tools/* 这类能力加策略 |
上下文管理
这是 harness 里最费功夫的部分。dsh 把它拆成三个互相独立的包。
提示词组装
system-prompt 包管理“提示词贡献者”和一次组装之间交换的数据。各插件按作用域注册自己的提示词片段,组装时按顺序渲染,同时产出当前对模型可见的工具 schema 集合。工作区指令、时间上下文、引用这类内容,则由 context 包负责注入。
压缩
压缩是一个可选能力,不在 agent loop 主干里。默认实现 compaction-basic 在每步请求之前,通过 agent/pre-step 处理上下文压力;agent/request-error 只处理规范的上下文溢出。触发后的顺序是:
- 先做工具结果剪枝(可选)。
- 剪枝不够,再生成摘要。
- 摘要以一条替换范围的消息写入,原始事件仍留在日志里,只是对模型不可见。
整个压缩被一对 compaction/start 和 compaction/end 事件括起来当锁用。进程在中途崩溃,日志里会留下有 start 没有 end 的痕迹,可以被检测到,而不是留下一条“压缩已完成”的假记录。
结果溢出
工具返回的大文本和图片,会被 spill-policy 限制在一个共享的估算 token 预算内:模型收到首尾两段,外加完整结果文件的路径,需要时再自己去读。图片存进附件存储。如果没法保存可恢复的内容,就保留原结果,不做截断。
这个思路是“限制进入上下文的量,但不丢信息”,和压缩正好互补:一个管单次结果,一个管累计历史。
工具执行流水线
dsh 的工具调用不是“调函数”,而是过一条带关卡的流水线。顺序如下:
flowchart TD
A[tool/call 写入日志] --> B[pre-execute 钩子 权限 沙箱]
B -->|拒绝| X[跳过执行 返回拒绝]
B -->|询问| C[用户审批]
C -->|拒绝或无应答| X
B -->|允许| D[守卫]
C -->|允许一次| D
D --> E[execute 超时 重试 指标]
E --> F[工具本体]
F --> G[post-execute 改写 追加上下文]
G --> H[tool/result 写入日志]
X --> H
几个要点:
- 调用先落日志,再执行。
tool/call在执行前就被记录,崩溃后能知道执行到了哪里。 - 前置、环绕、后置三个 waterfall。钩子、权限、沙箱挂在前置;超时、重试、指标环绕分发;后置可以接受、拦截、替换结果或追加上下文。这些都不用改循环。
- 审批失败即拒绝。没有应答者、应答者抛异常、请求被撤回,结果都是不通过,而不是放行。
- 单一的模型可见结果。无论走哪条路径,最终只有一条
tool/result进入模型历史。
工具本身分成很多包,每个包是一个能力家族:文件读写与编辑(read、edit、write)、检索(glob、grep)、bash、web_fetch、web_search、lsp、todo_write、ask_user_question 等。还有 run_code:让模型写一段代码批量调用其它工具,调用同样要过上面的流水线。外部工具通过 MCP 包接入,当作原生工具使用。
权限与沙箱
权限在 dsh 里是两个互相独立的旋钮:
- 沙箱模式:
read-only、workspace-write、danger-full-access,只管文件系统副作用。 - 审批策略:
ask(交给应答者链)或never(确定性拒绝,不再询问任何人)。
默认预设把它们捆成两档:workspace-write 加 ask,和 danger-full-access 加 never。客户端把这些预设显示成一个“权限”选择器。
沙箱由平台后端实现:Linux 用 bwrap 或 Landlock,macOS 用 Seatbelt,Windows 用 ACL 受限令牌。有两点文档说得很坦率:
- 沙箱模式只定义文件效果,网络和进程可见性不在它的词汇里。
- 后端会报告强制程度是
full还是partial,比如旧内核的 Landlock 版本只能管住一部分,要求绝对边界的调用方必须自己处理这个差异。
这种“把能力的缺口如实暴露”的做法,比声称“已隔离”可靠得多。
会话日志
会话是一份只追加的事件日志。轮次、步骤、系统消息、用户消息、助手消息、工具调用与结果,都是日志里的事件;压缩摘要、图片省略等也是事件,只是它们用“替换范围”的方式改变模型看到的视图,而不是改写历史。
模型每一步实际看到的历史,是从日志派生并冻结出来的。这意味着:
- 回放和恢复有了单一事实来源。想要可回放的 transcript,消费
session/event就行。 - 分叉会话很自然:子会话继承种子日志。
- 失败的请求尝试也能留痕(
assistant/attempt),便于排查。
另有 session-query 提供按语料检索、有界读取、血缘追踪和全文搜索,让 agent 能回头查自己的历史。
子 Agent 与技能
子 Agent 用统一的 activation API 管理,后端分两类:本地会话(有自己的 Agent 和日志,可继续执行)和外部执行(一次性)。模型通过 subagent 工具委托任务,也有 list_agents、send_message、interrupt_agent 做控制。后台任务由 jobs 包提供,对应 job_list、job_output、job_kill。
Skill 是可选的指令集,不是会话事件。ctx.skills 汇总多个提供方(本地目录、随包内置、远程),在宿主层和各作用域层之间合并,同名 skill 就近优先。模型通过 skill 工具按需加载,所以不占固定的提示词篇幅。
循环守卫
guard 组只有两个小插件,针对两种最常见的失败:
repeat-tool-reminder:模型反复发出完全相同的工具调用时,提醒它换方法或收尾。这只是建议,不强制。timeout-policy:为声明了时限的工具调用设上限,超时返回清晰错误,不让一个挂起的调用拖住整个会话。
两者在 dsh-base 里默认启用。把它们独立成包的好处是,不想要就能直接移除或调参。
模块对照表
把 dsh 的包放回开头的问题,一个 harness 的模块大致是这样:
| 通用模块 | dsh 里的对应 |
|---|---|
| 主循环 | agent-loop,可整体替换 |
| 上下文 | system-prompt、context、compaction、spill |
| 工具 | tools 注册表与流水线,tool-* 家族,mcp |
| 权限与安全 | user-approval、permission-presets、sandbox |
| 记忆与状态 | session 日志、session-query、todo、goal |
| 编排 | subagent、jobs、workflow、schedule |
| 扩展 | 钩子事件、skill、extensions、profile 与 patch |
| 守卫 | guard |
| 接入层 | web、sdk、acp、desktop |
小结
读完 dsh,我最大的感受是:harness 的难点不在循环,而在边界。
- 循环只负责推进轮次和步骤,其余的事都通过事件交给别的模块。
- 上下文问题靠三个互补的机制解决:提示词组装管输入,spill 管单次结果,压缩管累计历史。
- 工具调用是一条可钩挂的流水线,权限、沙箱、超时、重试都挂在流水线上,而不是写进每个工具。
- 失败默认关闭:审批无应答就拒绝,强制不完整就如实报告。
- 日志是事实来源,模型看到的视图是派生的。
如果你要自己做 harness,建议先把“会话日志 + 工具流水线 + 上下文三件套”做扎实,再谈子 Agent 和编排。最后提醒一句:dsh 在快速迭代,细节请以官方文档为准。
延伸阅读:仓库内的 docs/architecture.md、docs/agent-lifecycle.md、docs/tool-execution-pipeline.md,以及 docs/subsystems/ 下各模块页。