Xiaojun 的技术笔记

DeepSeek Harness 模块拆解

AI Agent约 8 分钟读完
目录

先说结论

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 对循环的定义很清楚:

  • 步骤:一次模型请求,加上它触发的所有工具调用。
  • 轮次:零个或多个步骤。在领取第一条输入时打开,不再欠任何工作时关闭。

一个轮次的主干流程是:

  1. 领取排队的输入,组装系统提示词和工具 schema。
  2. 触发 agent/pre-step 钩子,钩子可以拒绝这一步,或改写要进入的消息。
  3. 发起模型请求,流式接收,记录 assistant/message。
  4. 执行工具调用,记录 tool/result。
  5. 步骤结束;如果工具欠一次新请求,或又来了新输入,就进入下一步,否则触发 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 只处理规范的上下文溢出。触发后的顺序是:

  1. 先做工具结果剪枝(可选)。
  2. 剪枝不够,再生成摘要。
  3. 摘要以一条替换范围的消息写入,原始事件仍留在日志里,只是对模型不可见。

整个压缩被一对 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/ 下各模块页。