DeepSeek 终于把触角从模型伸向了 Agent 运行时。DeepSeek Harness(dsh) 是 DeepSeek AI 官方开源的一个 Agent Harness,承载 agent loop、工具执行、会话持久化、沙箱和 Web 客户端。文档目前以"技术预览"状态公开,但里面体现的架构思路比预览版认真得多。它正面回答了 Agent 工程里最麻烦的一类问题:一个产品同时有几十个可替换能力、多个模型适配器、多种前端形态时,架构该怎么搭。
dsh 的答案很干脆:不存在特权内核,产品的一切都是插件。模型适配器是插件,工具注册表是插件,会话日志是插件,连 agent loop 本身也是插件。想扩展 dsh 不用打补丁,把新插件挂到旧插件旁边就行;所有注册都是可逆副作用,插件卸载时自动撤销。
这篇文章基于官方参考文档(架构 / Cordis 入门 / 能力 Seam / Agent 生命周期 / Tool 执行 / 会话 / 沙箱 / 压缩等页面),逐层拆开这套设计,最后给一点我自己的工程判断。
一、总体架构:一棵由 Profile 与组合包叠出来的插件树
dsh 底层的插件框架是 Cordis(vendor 引入)。Cordis 提供四个核心机制:
- 插件是实现 Service 的对象:可以是函数,也可以是生命周期由 Cordis 管理的 Service 子类;
- 上下文(Context)是服务的容器:每个服务占据一个稳定的
ctx.<key>(如ctx.tools、ctx.llm、ctx.sessions),其他插件通过 key 查找服务,而不是 import 具体实现; - 通过
inject声明服务依赖:加载顺序由依赖关系表达,不用手写启动序列; - 类型化事件通信 + 可逆副作用注册:
ctx.effect()/ctx.on()安装的一切,在 reload 和 teardown 时按预期撤销。
在这之上,运行中的 dsh 是一棵插件树,由启动时按序叠加的层组合而成。两个关键概念:
- Profile(装配档) :存放在 Harness home 里的具名组装,列出自己叠放的组合包、存放树外插件、保存用户自己的
cordis.patch.yml。发行版随附web(带浏览器应用)和headless(一次性运行器、完全无服务器)两个模板。 - 组合包(Bundle):Cordis 配置项及其挂载代码的分发格式,插入的内容永远可以被上层各层 patch 掉。
各层按固定顺序应用:
text
空条目列表
→ profile 列出的每个组合包(按序)
→ profile 的 cordis.patch.yml
→ home 级的 cordis.patch.yml
→ 任意 --patch overlay
dsh-base 是每个 profile 的第一层(模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测);dsh-web-app 往上加浏览器应用;dsh-headless 加一次性运行器。想看自己机器上实际启动的配置树:
bash
dsh --profile web --dump-config
打印出的任何条目都可以被你的 patch 替换。这套"配置可观测、层层可覆盖"的机制,让"没有需要打补丁的特权内核"这句话有了落点,而不是一句口号。
核心包主干
| 包 | 职责 | ctx 键 |
|---|---|---|
core/session |
仅追加的 SessionEvent 日志 + 内存存储(唯一真源) |
ctx.sessions |
core/system-prompt |
提示词片段与工具 schema 的组装 | ctx.systemPrompt |
core/tools |
作用域化的工具注册表 + 带把关的执行流水线 | ctx.tools |
core/agent |
Agent 接口、活跃 agent 注册表、agent/* 事件 |
ctx.agents |
core/agent-loop |
实现该接口的默认驱动器(driver) | ctx.agentLoop |
core/scope |
按 agent 划分作用域的注册原语(纯库,无 ctx 键) | --- |
llm/llm |
消息与流式词汇表 + 适配器 seam | ctx.llm |
有个分层纪律值得记住:扩展插件依赖 agent 包,绝不直接依赖 agent-loop。agent-loop 只是公开 Agent 约定的一个具体实现。想换循环,换掉这个 driver 就行,消费方代码不用动。
二、事件系统:三个域,四种分发模式
"事件就是扩展点":选对事件域,是大多数改动的第一个决定。dsh 把事件分成三个域:
| 事件域 | 特征 | 用途 |
|---|---|---|
会话事件 (turn/*、step/*、user/message、assistant/*、tool/*) |
追加进日志并通过 session/event 广播的持久事实 |
需要跨 reload 存活的事实 |
Agent 事件 (agent/*) |
携带活跃 Agent 的实时协调 |
观察/拦截进行中的工作:inbox、步骤、状态、请求、验证、续跑 |
能力事件 (fs/*、tools/*、telemetry/*) |
无导入循环地向 seam 附加策略和适配器 | 策略与适配器挂载 |
Cordis 的四种分发模式是事件公开约定的一部分(文档用 @mode 标签交叉校验声明与调用点):
| 模式 | 是否 await | 分发顺序 | 返回值 |
|---|---|---|---|
emit |
否 | 按注册顺序观察 | 无 |
waterfall |
否 | 按注册顺序观察(环绕中间件) | 有 |
parallel |
是 | 并行观察 | 无 |
serial |
是 | 按注册顺序观察 | 有 |
Waterfall 语义是这套系统最值得单独讲的一块:监听器接收 (...args, next),调用 next() 委托给下游;不调用直接返回就是短路。对于"单决策"事件,短路是设计意图:拥有决策权的策略监听器可以不调 next() 直接返回;只做标注或观察的监听器必须委托。协作式监听器通常修改共享的请求/决策对象后委托下去。
在 dsh 里,agent/pre-step、agent/request、llm/stream 和三个 tools/* 事件都是 waterfall;agent/turn-stopping 是 serial 事件(没有 next())。
三、轮次流程:step 与 turn 的两次抽象
dsh 把循环拆成两级单位:
- step(步骤):一次模型请求 + 它调用的工具;
- turn(轮次):零个或多个 step,在领取首条输入时打开,在不再欠下任何工作时关闭。
官方时序图可以浓缩成一条链:
text
turn/start
→ 领取 next-step 输入 + 一条排队消息
→ 组装提示词片段 + 工具 schema
→ agent/pre-step(可 reject / 可 enter)
├─ reject 或被改写为空 → 关闭一个"无步骤"的轮次(日志仍记录这次尝试)
└─ step/start
→ 追加进入的消息为 user/message
→ 从日志派生模型历史
→ agent/request → llm/stream → assistant/chunk* → assistant/message
→ tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
→ step/end
→ 工具还欠请求?或新输入到达?→ 领取 → 下一个 step
→ agent/turn-stopping(serial,无 next())
turn/end
有几个细节我觉得值得停下来看:
agent/pre-step决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝。首次领取被拒绝或被改写为空时,仍会关闭一个不含 step 的持久轮次,日志会记录这次尝试。这比"静默丢弃输入"更可审计。- 输入走同一个 inbox 到达驱动器。有些消息立即唤醒它;注入的上下文(
agent.inject())留在 inbox 里,直到另一条消息将其唤醒。 agent/turn-stopping是轮次的最后一道闸:在轮次没有工具或 steering 后续时运行,先于最后一次 steering 排空。- 取消原因(
user/parent/hook/disposed)由 TypeScript 强制约束为同进程输入;持久turn/end只保留粗粒度{ kind: 'aborted' }结果。想记录"是谁取消了",得用单独的持久事件,不能往终态结果里塞含义。
四、会话日志:事件溯源,且"模型可见即已记录"
dsh 的会话是一个类型化 SessionEvent 的仅追加日志,也是唯一的真源。模型历史不是单独存储的,而是从日志派生(deriveMessages());原始 assistant/chunk 事件保证回放与 UI 保真。fork、恢复、transcript、遥测、持久化,全部派生自同一条事件流。
核心不变式是一句话:"模型可见即已记录。"(Model-visible is already recorded)抵达模型请求的一切都必须能从日志重建,并由一项运行时不变式(ctx.invariants)断言。所以新增一项模型可见输入,就必须新增一个会话事件:扩展 SessionEventMap 并从日志渲染。
SessionEventMap 目前有十二种变体(可以用声明合并扩展,比如压缩 seam 追加 compaction/start / compaction/summary / compaction/end):
turn/start · turn/end · step/start · step/end · user/message · assistant/chunk · assistant/message · tool/call · tool/result · steering/message · todo/write · request/header
每个条目携带单调的 seq、time 与按 type 判别的 data payload。assistant/message 携带该 step 的 usage(Token 计量跟着输出走,没有独立的 usage 记录);tool/call 保留模型产出的原始 JSON 字符串(未解析);todo/write 和 request/header 是仅日志的 UI/请求状态(最新一条覆盖前者,永不进入派生历史)。
dsh 还有一个全仓通用的类型模式,值得单独讲:...Map → derived-union 模式 。几乎所有可扩展的和类型都遵循同一范式:以判别标签为 key 的接口(...Map),联合类型由 keyof 派生;插件通过 declaration merging 添加变体,不用修改拥有该类型的包:
ts
// 模式示意
interface ThingMap {
'a': { kind: 'a'; /* ... */ }
'b': { kind: 'b'; /* ... */ }
}
type ThingKind = keyof ThingMap // 'a' | 'b'
type Thing = ThingMap[keyof ThingMap] // 判别联合
// 插件零侵入扩展:
declare module '@deepseek-ai/dsh-llm' {
interface ThingMap {
'c': { kind: 'c'; /* ... */ }
}
}
六个规范 map 使用此模式:ContentBlockMap、MessageSourceMap、FinishReasonMap、TurnTriggerMap、TurnEndReasonMap、SessionEventMap。配套的还有品牌化 ID(Branded<B>:结构上是字符串,但类型层面不可互换,SessionId 不能传给需要 CallId 的位置)。CallId、SessionId、JobId 都是这样。
五、能力 Seam:换一个提供方,就换整个产品
**Seam(接缝)**是 dsh 对"可替换能力"的建模,包含三种角色:
- Service Definition:声明接口;
- Service Provider:实现它;
- Consumer:使用它(通常是面向模型的工具)。
一个包可以合并承担多个角色,但单一角色本身不是 seam:添加一项能力,意味着把三者一并设计。
seam 的杠杆在于:替换一个提供方就能改变整个产品的行为。最典型的例子:文件系统与进程提供方共享同一个执行世界,把它们指向远程沙箱,Bash、PTY、LSP 就一并搬了过去,不需要为每个提供方做专用 fork。subagent 提供方在同一个接口后面也千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品(ACP、Codex、Claude Code、dsh SDK)。
文档用一张大表枚举了所有能力与服务(capability-seams 页),我挑几个看角色分工:
| ctx 键 | 角色 | 实现示例 |
|---|---|---|
ctx.llm |
seam | llm-deepseek、llm-pi-ai、llm-replay(测试用回放) |
ctx.sessionPersistence |
seam | session-persistence-jsonl、session-persistence-sqlite |
ctx.fs |
seam | fs-local、fs-sandbox、fs-e2b |
ctx.shell |
seam | bash-local、bash-sandbox、pwsh-local |
ctx.subprocess |
seam | subprocess-local、subprocess-e2b |
ctx.sandbox |
seam | sandbox-local(bwrap/Landlock/Seatbelt/Windows ACL) |
ctx.web |
seam | web-search-exa、web-search-perplexity、web-search-deepseek、web-fetch-http |
ctx.skills |
seam | skill-badge、skill-filesystem |
ctx.subagents |
seam | 进程内 spawn/fork、ACP、Codex、Claude Code、dsh-sdk |
ctx.compaction |
seam | compaction-basic |
ctx.credentials |
seam | credentials-local(配置持有引用,提供方持有真实值,轮换后下一次请求即生效) |
ctx.sessions / ctx.tools / ctx.agents / ctx.systemPrompt |
core | 主干服务 |
"新增行为归属"文档给了一张"目标 → 机制"的映射表,是理解这套架构最快的入口(我摘录高频项):
| 目标 | 机制 |
|---|---|
| 添加模型提供方 | 在 ctx.llm 上注册适配器 |
| 添加面向模型的能力 | 在 ctx.tools 上注册;schema 自动进入提示词组装 |
| 让某会话拥有不同能力集合 | 组装 agent preset;服务行需要 isolate realm |
| 添加 shell 执行 | 注册 ctx.shell 后端 |
| 添加持久化终端执行 | 注册 ctx.terminals 后端 + dsh-tool-terminal |
| 添加用户命令 | ctx.commands(无需模型轮次即可分派) |
| 添加后台工作 | ctx.jobs |
| 限制所启动的进程 | ctx.sandbox 后端(消费方在 spawn 前包装 argv) |
| 拦截请求/工具/轮次 | agent/* 或 tools/* 事件 |
| 添加模型可见上下文 | agent.inject()(落到下一次获准的请求) |
| 添加持久会话状态 | 扩展 SessionEventMap,从日志渲染 |
| fork 活跃会话 | ctx.sessions.fork(source, boundary?, childSessionId?) |
| 把注册项限定到单个 agent | 用该 agent 的 agent.ctx |
注意那个 isolate realm:preset 通过它把服务发布到组外不可见的作用域里。浏览器 RPC 之类的宿主查询方要用 ctx.agentPresets.serviceFor(agent, name) 才能读到 agent 组合内部的服务。
六、Tool 执行:可扩展 waterfall + 单调策略 + 冻结结果
工具子系统(ctx.tools)是 dsh 里安全和可扩展做得最平衡的地方。执行流水线是:
text
tools/pre-execute(可重排的 allow/deny/ask waterfall)
→ 已注册的单调 guard
→ tools/execute(环绕分派包装层,可做超时/重试/指标)
→ tools/post-execute(检查/替换结果)
→ 可选 finalizeContent(定义所有)
→ tools/result(不可变的权威结果,观察者只读)
几个设计上的选择:
-
身份不可变。
ToolExecutionToken是不透明的运行时 Symbol,只用于身份比较。参数在策略执行前被物化、深冻结;调用身份保留在不可变的ToolExecution上,伴随结果经过每个钩子,并出现在持久化的tool/call/tool/result事件上,包装层没法造出第二个相互矛盾的身份。tools/execute包装层可以替换 signal 但不能移除,注册表在调用工具函数体前重新融合调用方 signal。 -
单调策略(monotonic guard)。
ToolGuard的返回类型有意不包含 allow 结果:返回undefined保留 waterfall 的决策,返回 reason 只能缩减权限,因此后续监听器永远无法撤销一次拒绝。"监听器顺序不可能把 denial 变回 permission"是刻意设计的防呆。 -
决策类型化。每个拦截 waterfall 返回类型化 Decision:
ts
type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string } // 只有 approval 服务返回 allowed-once 才放行
type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: UserMessage[] }
| { kind: 'accept'; value: JsonValue; ... }
| { kind: 'block'; feedback: ContentBlock[]; ... }
参数不可被改写:历史记录、审计、UI 与执行必须保持一致。后置策略可以替换内容或值(二选一),替换值会重新校验并重新计算;block 移除值并转为包含纠正反馈的 isError。
-
规范值只活在执行期。循环只持久化
content、error、meta;执行期的规范value不落日志,回放能重现展示,却无法重建规范的中间值。这是"日志是唯一真源"原则在工具层的贯彻。 -
调度模式 fail-closed。每个待处理调用按
parallel(可与兄弟并发)或exclusive(独占,形成排序屏障)调度;只有精确返回true才算 parallel,未知/隐藏/未声明/无效/抛异常的分类器一律归为 exclusive。 -
展示 UI 词汇与提供方无关。工具通过
presentCall/presentResult返回 card 渲染意图(判别联合):generic/terminal/diff/search/read/web。UI 桥接层据此分发:编辑器工具调用卡片、CLI 日志行都能消费同一套中性词汇,工具不需要知道客户端协议。locations({ path, line? }[])让编辑器可以跟随调用读取/修改的文件;truncated/total保证 UI 永不把部分结果当作完整结果。 -
强制执行的 JSON Schema 子集。原始 schema 经过
assertSupportedJsonSchema()校验,不支持的关键字会被拒绝,而不是"不强制执行地放行"。defineToolDSL 的类型推导精确到 16 层容器,之后回退JsonValue以避免耗尽 TS 类型实例化栈;运行时校验仍走完整 schema。
七、沙箱:三档模式 + 逐调用策略 + 方言感知的错误分类
进程沙箱是 dsh 对"Agent 跑代码"这道题的答案。SandboxMode 只管控文件系统效果:
read-only:只允许必需 sink(如/dev/null);workspace-write:允许写工作区根 + 后端承诺的临时区;danger-full-access:绕过隔离。
注意:只有前两种会发给提供方;danger-full-access 的消费方直接 spawn 原始 argv,根本不调用 ctx.sandbox。网络与进程可见性不在这个词汇表内,沙箱只管文件。
强制执行完整性是后端报告的事实:full 表示管控了所有承诺的文件效果;partial 表示(如较旧 Landlock ABI、Windows ACL 的 Everyone/硬链接边界)只管控了子集,要求绝对保证的消费方必须拒绝或向上暴露这一区别。
真正有意思的是逐调用策略(per-call policy,而不是提供方固定状态):完整策略按每次能力调用解析并携带,包含 danger-full-access,消费方可以只解析一次策略,再决定是否绕过。workspaceRoot 先按文件系统语义规范化再做词法规范化,因此包含 symlink/.. 的 cwd 会正确标识 spawn 出的进程实际运行的目录。两个并发会话可以在同一时刻请求不同边界:bash 跑 read-only,而受限子 agent 需要写自己的状态目录。
沙箱后端还返回两种正交的 stderr 分类器:
denialSignatures:识别"沙箱正常工作、受限命令被阻止"的情形,且按后端方言区分(bwrap 的 EROFS、Landlock 的 EACCES、Seatbelt 的 EPERM),消费方只匹配当前后端真实会产生的签名,而不是跨后端联合;runnerFailureRules:识别"沙箱 runner 在执行命令前失败"的情形,消费方先检查这个(作为基础设施故障上报,而非普通任务失败),再检查 denial。
最后还有一条硬规则:对受限策略,静默的无隔离透传永远不合法。没有可用后端时 confine() 抛出 SandboxUnavailableError(SANDBOX_UNAVAILABLE),fail-closed。
八、上下文压缩:一个锁驱动的可选能力
压缩(compaction)是"可选能力"的一个样板:ctx.compaction(CompactionEngine)是 seam,compaction-basic 是默认提供方,command-compact 是面向用户的消费者。它不是 agent loop 主干的一部分,因此不依赖核心包,但它必然依赖 dsh-session 和 dsh-llm(其动词作用于 session,持久摘要事件使用 ContentBlock 词汇)。
压缩通过声明合并扩展三个仅写日志的会话事件,三者都不进入 surface:
| 事件 | 载荷 | 作用 |
|---|---|---|
compaction/start |
{ turn } |
获取日志记录的锁(数字=未结束的自动轮次,null=手动尝试) |
compaction/summary |
{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, ... } |
摘要 + 被遮蔽范围/seq/Token 数 |
compaction/end |
{ turn, error? } |
释放锁 |
锁括住整个操作:先追加 start → 生成摘要 → 写 summary + 替换 user/message → 最后追加 end。崩溃会表现为"有 start 无 end"的可检测遗留锁,而不是虚假声称压缩完成的 end。
surface 变更只有一处:摘要本身通过一条带 surfaceOp: { op: 'replace', start, end } 的 user/message 落地,这是摘要压缩执行的唯一 surface 变更。shadowedRange 是 surface 位置跨度而非数值区间:一次替换把新摘要节点放到旧范围的更早位置后,start 可以大于 end,所以 shadowedSeqs 才是权威的被遮蔽节点集合。
触发策略分两种:pressure(压力)和 context-overflow(上下文溢出)。压力压缩在串行 agent/pre-step 中运行,先于请求推导;溢出恢复在失败步骤关闭后通过 agent/request-error 运行,仅当剪枝或摘要推进了 surface replacement generation 时才开启全新重试轮次,否则仍以原始请求错误为准。压缩前还可以调用可选的 ctx.toolResultPruner 把过大的工具结果先剪掉(报告每次替换的 Unicode code point 前后大小与总减少量)。
九、工程判断:它做对了什么,我保留什么
做得很扎实的地方
- "无特权内核"是认真兑现的架构承诺。不搞"核心 + 插件扩展点"的折中,而是连 agent loop、会话日志、模型适配器都做成可替换插件。
agent-loop只是公开Agent约定的一个实现,扩展包依赖agent而非agent-loop,循环可整体替换。 - 事件溯源 + "模型可见即已记录"不变式,把可审计性做成了系统属性。fork、恢复、transcript、遥测、持久化全部派生自一条仅追加事件流,回放保真由原始 chunk 事件保证。这对 Agent 产品是极强的调试和合规底座。
- 单调策略 + 不可变执行身份。Guard 只能缩减权限、参数不可改写、身份不可伪造、最终结果冻结,安全语义不会因为监听器注册顺序而松动。
- 能力 Seam 三角色建模。把"接口/实现/消费方"显式拆开,让"换一个提供方 = 换整个产品"成为现实(本地 ↔ 远程沙箱、进程内 ↔ 外部 subagent)。这是目前开源 Agent 框架里少见的成熟抽象。
- 对边缘情况近乎偏执的严谨:沙箱的
partial强制执行要显式暴露;压缩锁崩溃可检测;shadowedRange的位置语义;失败分类的方言隔离;fail-closed 的静默透传禁令。文档里的每个"刻意设计"都能追溯到具体的 Agent 事故场景。
我保留意见的地方
- 学习曲线陡峭,且依赖 Cordis 词汇。waterfall/serial/parallel、scope、isolate realm、branded ID、declaration merging......扩展 dsh 需要先掌握一整层框架语言。文档(生成式目录 + 双语配对)质量很高,但这本身说明心智负担不轻。
- TypeScript 类型体操是双刃剑。
Map → derived-union模式、16 层推导上限、InferValue回退JsonValue:类型安全做到了极致,代价是包间依赖和编译期复杂度。文档专门为"避免耗尽类型实例化栈"写回退逻辑,侧面说明这套类型层已经触到了 TS 的极限。 - "文档即契约"的维护成本。参考文档大量由脚本从源码生成(
gen-cordis-catalog.ts、verify-type-equiv、双语配对),并有完整性守卫。这是好事,但也意味着文档系统本身成了一个需要维护的子系统。 - 技术预览的现实:当前还处于预览阶段,生态(插件市场、稳定 API 承诺)尚未成型。选择押注 dsh 的团队要有跟随 breaking change 的心理准备。
结语
说回开头那个问题。DeepSeek Harness 不是又多了一个"Agent 框架",它示范的是把 Agent 运行时彻底插件化的一条路线:事件溯源做真源、能力做 seam、策略做单调、配置层层覆盖、连循环本身都可替换。我自己在折腾 agentic 系统时,至少会抄这三条:
- 会话状态用仅追加事件流做真源,模型可见即记录,可回放、可审计、可恢复;
- 可替换能力按 Definition / Provider / Consumer 三角色建模,替换提供方不动消费方;
- 策略一律单调、执行身份一律不可变、失败一律 fail-closed,安全不依赖监听器顺序。
模型是 DeepSeek 的强项,harness 拼的是工程。这份技术预览文档至少让我确认了一件事:DeepSeek 在 Agent 基础设施上的投入,不是顺手做做。
参考文档: deepseek-harness.github.io/deepseek-ha...
源码:github.com/deepseek-ai...
原创技术博客 · 开源项目架构深潜 · idao.fun