引言:一个"没有内核"的 Agent 框架
如果让我用一个词概括 DeepSeek Harness(dsh),我会选可组合性。它是 DeepSeek AI 开源的 agent harness(MIT 协议,目前处于 developer preview,版本 0.1.0-rc.5),与 Claude Code、Codex 这类"产品级"编码 Agent 不同,dsh 的定位更接近框架:模型适配器、工具注册表、会话日志、审批策略、甚至 agent loop 本身,全部是插件。
这种"一切皆插件"不是口号,而是架构约束——dsh 建立在 Cordis 之上,其设计哲学来自论文 A Programming Paradigm for Spatiotemporal Composability(时空可组合性编程范式)。整棵树里不存在需要打补丁的特权内核:扩展 dsh 的方式就是把插件挂到其他插件旁边,而每项注册都是副作用,插件卸载时自动撤销。
本文基于源码仓库(/tmp/deepseek-harness,pnpm monorepo,56 个子包、3700+ 文件)逐层拆解:插件树怎么叠起来、会话怎么做到"模型可见即已记录"、沙箱为什么 fail-closed。
本文导读
- 先建立全局认知:第二节用"分层叠加 + 共享上下文 + 三类事件域"总览图看清插件树,再用时序图讲透轮次(turn)与步骤(step)循环;
- 再抓核心模块:第三节逐包拆 session / system-prompt / tools / agent-loop / 上下文管理,配"日志投影"与"压缩流程"两张数据流图;
- 最后看设计决策:第四节提炼全仓 6 个可复用的设计模式,第五节给出部署与运行方式。
项目概貌
| 维度 | 数据 |
|---|---|
| 架构范式 | 一切皆插件(Cordis),无特权内核,注册即副作用、卸载即撤销 |
| 语言 / 构建 | TypeScript 6.0,pnpm 11.7 monorepo(packages/*/* 二级分组,56 个子包),Node ≥ 22.19 |
| 打包 | tsdown 双面构建(host / client),前端 React 18 + Vite 6 |
| 核心抽象 | 会话日志(事件溯源)· seam(服务定义/提供方/消费方)· profile + patch 分层 |
| 安全 | fail-closed 沙箱(Linux bwrap→Landlock / macOS Seatbelt / Windows ACL)+ 审批 + 凭据引用 |
| 测试 | vitest 多配置,单元测试 100% 覆盖门禁,snapshot 支持 record/refresh/replay 三态 |
| 发布形态 | npx @deepseek-ai/dsh web,Web UI 默认 127.0.0.1:3080 |
一、项目背景与目标
dsh 要解决的核心问题是:一个 Agent 产品里有太多东西在变化。模型厂商在变、工具集在变、权限策略在变、UI 形态在变。传统的做法是把这些写死在应用里,靠配置开关切换;dsh 的做法是把它们全部变成可以独立替换的插件,再靠一套分层叠加机制组装成最终产品。
这一点从它的启动方式就能看出来。dsh CLI 只硬编码了两个子命令:web(等价于 --profile web)和 plugin。headless、run 都不是子命令,而是 profile 名——dsh --profile headless "fix the failing test" 里,--profile 之后的一切参数原样交给启动后的插件树,由注入的 app 插件自行解析。换句话说,CLI 自己不干活,它只负责把一棵插件树点起来。
目标拆解下来有三条:
- 可替换性:换模型、换沙箱、换审批策略,不需要 fork 或打补丁;
- 可观察性:模型看到的每一条上下文都能从日志重建,这是运行时的不变量而非约定;
- 可分发性:第三方插件以 npm 包发布,通过
cordis.patch.yml声明式挂载。
二、技术架构概述:插件树如何叠起来
先看总览图,建立心智模型:
看图重点:自上而下三层——组合包(bundle)按序叠加成 profile,用户 patch 在最顶层可覆盖任意条目;中间是 Cordis 共享上下文(五个核心服务槽);底部三类事件域是全部扩展点的入口。改动前先选对事件域,是 dsh 开发的第一决策。
2.1 无特权内核:Cordis
Cordis 是 dsh 的底座。插件向共享上下文贡献三样东西:服务(如 ctx.sessions、ctx.tools)、类型化事件(如 agent/pre-step)和可逆副作用(如注册一个工具)。核心包在 packages/core/ 下,各自通过 TypeScript 的模块声明扩展上下文:
// packages/core/agent-loop 中的依赖注入声明(简化)
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
}
依赖由 Cordis 按名字注入,不存在任何"内核代码路径"——agent loop 也只是众多插件之一。
2.2 profile 与组合包:分层叠加
运行中的 dsh 是一棵插件树,由启动时按序叠加的各层组合而成:
- profile 是存放在 harness home 中的具名组装,列出自己叠放的组合包,并保存用户自己的
cordis.patch.yml; - 组合包(bundle) 是 Cordis 配置项及其挂载代码的分发格式——它插入的内容始终可被其上各层 patch 覆盖。
三者的声明都在各自 package.json 的 dsh 字段里:dsh.profile 列出一个 profile 的组合包,dsh.bundle 指向组合包的 patch 文件。dsh-base 是每个 profile 的第一层(模型适配器、工具、持久化、沙箱与审批、凭据、遥测,约 90 个插件),dsh-web-app 在其上叠加浏览器应用,dsh-headless 叠加一个完全不带服务器的一次性 runner。
各层的应用顺序是:profile 列出的每个组合包 → profile 级 cordis.patch.yml → home 级 → 任意 --patch overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。想看你机器上实际启动的配置树,一条命令即可:
dsh --profile web --dump-config
它打印出的任何条目,都可以被你的 patch 替换——这就是"一切皆插件"落到可操作性上的样子。
2.3 三类事件域:选对域是改动的第一步
dsh 官方架构文档把事件明确分成三类,每类的语义边界非常清晰:
| 事件域 | 生命周期 | 用途 | 例子 |
|---|---|---|---|
会话事件 session/event |
持久,追加进日志并广播 | 必须重载后仍存在的事实 | turn/start、user/message、tool/result |
Agent 事件 agent/* |
实时,内存派发 | 观察或拦截进行中的工作 | agent/pre-step、agent/request、agent/turn-stopping |
| 能力事件 | 实时,seam 挂载点 | 无需导入环附着策略与适配器 | tools/*、fs/*、telemetry/* |
监听器也分三种派发模式。前两类是 waterfall(洋葱中间件,监听器必须调用 next() 才能委托下去,用于可改写结果的把关点);agent/turn-stopping 是 serial(顺序执行、无 next());通知类事件走自定义 emit 循环——逐个 catch 异常并 Promise.resolve().catch() 兜底,保证一个监听器抛错不会饿死后续监听器。这个细节很见功力:Cordis 原生 emit 用 Array.map,一个同步 throw 就会中断整条链。
2.4 轮次与步骤:一次模型请求加上它调用的工具
dsh 的最小执行单元定义得非常精确:一个步骤(step)= 一次模型请求 + 它调用的工具;一个轮次(turn)包含零个或多个步骤,在领取首条输入时打开,不再欠下任何工作时关闭。
看图重点:实线框是持久会话事件(写进日志),虚线框是 waterfall 实时扩展点(可拦截改写)。右侧回边是循环的关键——只要工具欠一个请求或新输入到达,就领取下一步;
agent/pre-step可以改写甚至拒绝已领取的消息。
驱动器实现在 packages/core/agent-loop/src/agent.ts 的 ReactLoopAgent 中,结构非常直白:
// ReactLoopAgent 的主循环(简化)
private async kick() {
while (await this.turn()) {}
}
private async turn(): Promise<boolean> {
this.session.append('turn/start', { turn })
while (true) {
const decision = await this.preStep(target, { turn, step }) // agent/pre-step 瀑布
this.session.append('step/start', { turn, step })
await this.step(decision.assembly) // 模型调用 + 工具执行
this.session.append('step/end', { turn, step })
// 工具欠请求或新输入到达 → 继续;否则 break
}
this.session.append('turn/end', { turn, reason })
}
注意循环的每一拍都会先写日志再执行——这个顺序是后面"模型可见即已记录"不变量的根基。
三、核心模块功能详解
3.1 session:仅追加的会话日志
会话日志是整个系统的唯一事实来源(single source of truth),实现上是一个纯内存的 append-only 事件数组。每个事件是判别联合类型,seq 严格等于数组下标:
// packages/core/session/src/types.ts(简化)
export type SessionEvent = {
type: SessionEventType
seq: number
time: number
data: SessionEventMap[typeof type]
ignorable?: true
}
append() 只追加、不修改:每个事件写入时经 lossless-JSON 快照校验并 deepFreeze,持久化由插件订阅 session/event 完成。模型历史不是另外存的一份数据,而是从日志投影出来的:
// deriveMessages():增量投影(简化)
for (const seq of nodes.slice(this.derivedNodes)) {
const msg = this.deriveEventMessage(this.log[seq]!)
if (msg) this.derived.push(msg)
}
投影只认 surface 上的三类消息事件(user/message、assistant/message、tool/result),带缓存增量重建;而 assistant/chunk 这类流式事实留作回放与 UI 保真,不进入模型历史。
看图重点:左侧日志里只有蓝框(surface 消息事件)能投影进右侧的模型历史;chunk 事件用于回放保真但不进上下文。底部的"不变量"条是本节最核心的一句话——新增任何模型可见输入,必须先扩展
SessionEventMap并从日志渲染。
3.2 system-prompt:分层组装的提示词
系统提示词同样不是一段写死的字符串。ctx.systemPrompt 接受三类贡献:带 order 的提示词片段(PromptSection)、运行时动态上下文、以及工具 schema。组装时按 order 稳定排序(约定 -100 为 harness 身份、0 为 persona),scoped 片段可遮蔽全局同名片段,变量插值后再拼接。工具 schema 则从所有 provider 收集,structuredClone 后按 toolOrder(含 <unlisted-tools> 占位)排序注入。
这意味着"某个会话有不同能力集"这类需求不需要改循环代码——组装一个 agent preset,把服务行标成 isolate realm 即可。
3.3 tools:作用域注册表 + 三阶段把关流水线
工具注册表是 scope 隔离的绝佳示范。ToolRuntime 内部持有 ScopedLayers<ToolLayer>,注册时按 scopeOf(ctx) 决定工具归属哪一层:
// packages/core/tools 注册逻辑(简化)
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
// register():
return this.layers.effect(this.ctx, layer => layer.tools.insert(name, definition))
查看工具时按"global → 祖先链 → 自身层"合并;restrict() 则强制要求当前上下文在某个 agent scope 内——把注册项限定到单个 agent,只需在该 agent 的 agent.ctx 里注册。
执行端是经典的三阶段流水线:tools/pre-execute(waterfall 把关,审批服务在此询问、guard 在此拒绝)→ execute(真正执行,带 fuse 信号)→ tools/post-execute(结果后处理,spill 策略在此把超大结果替换成 locator)。
3.4 agent-loop:默认驱动器
ReactLoopAgent 的职责只有一件:驱动 turn/step 循环。每步先跑 agent/pre-step 瀑布(监听器可改写或拒绝消息——首次领取被拒绝时仍会关闭一个不含步骤的持久轮次,因此日志记录了这次尝试),再 buildRequest(agent/request 瀑布)→ llm.stream → 组装 assistant/message → 有 tool call 则走工具流水线。驱动器自身不含任何模型或工具逻辑,全部经由上下文服务注入——这就是它可被整体替换的原因。
3.5 上下文管理三件套:context / spill / compaction
长任务跑得久,上下文管理是 harness 的硬功夫。dsh 拆成三个各司其职的包:
- context:负责"注入什么"——工作区指令(AGENTS.md)、跨会话引用(带字节预算截断)、时间上下文等;
- spill:负责"超大结果挪出去"——工具结果超过
maxInlineBytes时,存全文、只留 head/tail 预览 + locator 进上下文; - compaction:负责"历史收缩"——这是最有意思的一个,展开说。
压缩引擎抽象在 packages/compaction/compaction,策略分两种:summarize(LLM 摘要替换)与 prune(无模型参与的删减)。触发条件也有两条:pressure(token 超过阈值)与 context-overflow(服务端返回窗口溢出错误)。
看图重点:五步流水线——先做免费的 prune 收缩,再决定是否动用 LLM 摘要;最后的"替换"不是改内存,而是以
surfaceOp: replace追加新事件,让投影自然收缩。压缩前后对照展示 n 个事件如何变成 1 个摘要节点。
关键点在于:压缩没有绕过日志。compaction/start、summary、end 以 log-only 事件记录锁与计量,摘要本身以 surfaceOp: 'replace' 的 user/message 事件落地。日志仍完整可回放,只是 surface 投影变了——这保证了"可审计"与"可收缩"同时成立。
四、关键设计决策与亮点
4.1 模型可见即已记录:不变量而非约定
这是全仓最值得抄的设计。dsh 要求:抵达模型请求的一切都必须能从日志重建,并由一条运行时不变量断言。实现是双保险:
- 编译期:
append()对 message-producing 事件强制要求surfaceOp标记——类型系统在调用点就拦下"忘了记录"的代码; - 运行时:surface 投影遇到缺失标记的事件直接报错。
新增模型可见输入的唯一合法路径是:扩展 SessionEventMap → 从日志渲染 → 完成。一个看似"限制开发效率"的约束,换来的是 fork、恢复、transcript、遥测、持久化全部零成本派生自同一条事件流。
4.2 事件溯源贯穿始终
不止会话日志,目标(goal)、计划(plan)、**子代理(subagent)**全部采用"事件溯源 + fold 投影"模式。goal 是 CAS 式状态机:goal/change 事件 fold 出当前目标,mutation 必须 expectCurrent 校验 revision,杜绝 stale 写入。子代理 fork 会话更巧妙:
// subagent fork:取父会话"已完成的 turn 前缀"作为不可变 seed(简化)
const seed = parent.events.slice(0, boundary + 1)
const child = ctx.agents.create({ seed, meta: { parentSession, seedLength } })
// 子代理的结果 = 边界后追加的自身事件流
child.session.events.slice(activationBoundary)
seedLength 持久化"继承前缀长度"边界,子会话以父前缀为不可变基座,之后事件只追加。同一机制同时服务 fork 与崩溃恢复——恢复不过是"从持久化重建 seed 前缀"。
4.3 scope:每个 agent 一份能力视图
scope 原语(packages/core/scope)把"作用域"做成了库:createScope 建立层级注册表,工具、提示词片段、事件载体都能按 scope 分层。一个父 agent 派生子 agent 时,子 agent 的 agent.ctx 天然是其 scope 链的新节点——给子代理收窄工具集,就是在其 scope 层注册更少的工具,无需任何 if/else。
4.4 fail-closed 安全:绝不裸跑
安全设计统一遵循一条原则:默认拒绝,拿不到可用后端就直接失败。沙箱是典型:
SandboxProvider.confine(argv, policy)返回"包装后的 argv"由调用方 spawn;无后端可用时抛SANDBOX_UNAVAILABLE,绝不裸跑;- 平台链路:Linux
bwrap → landlock、macOSseatbelt(sandbox-exec)、Windows ACL restricted-token; - 其中最硬核的是
native/landlock-run——约 300 行 C11 直接对接 Linux Landlock 内核 UAPI、静态链接 musl 的"自限后 exec"启动器,规则集跨execve继承,未授予的路径一律拒绝。
配套的还有:凭据只存 env 变量名引用(CredentialRef),实际值按次从环境或 owner-only 文件解析,配置界面永远见不到值;审批(user-approval)的 outcome 是闭集(allowed-once / rejected / cancelled / unavailable),默认 ask、never 策略在 CI 场景确定性拒绝;沙箱升级必须 approveEscalation 严格 widen 检查 + 人工审批。Web 服务端甚至直接拒绝 --host 0.0.0.0——注释写得很直白:“would expose remote code execution”。
4.5 工程化:把"仓库健康"做成 DAG
最后一个亮点在工程体系。tsdown 按 DSH_BUILD_FACE 分 host/client 双面构建——同一套源码出 Node loader 与浏览器 bundle,两侧在 Cordis Context 上以相同 key 合并不同 service,因此类型检查拆成两个聚合项目(“one program cannot see both”)。测试分五层:单元(100% 覆盖率门禁)→ e2e(真实 API,无凭据自动 skip)→ snapshot(record 调真实 API 录制 / refresh 重放脚本 / replay 无 key 并行 diff)→ 浏览器 → perf/stress。
最有特色的是 gen/verify 生成器闭环:gen-module-graph 从 peerDependencies 生成依赖图文档、gen-tool-catalog 运行时引导每个工具插件采集 JSON Schema、gen-cordis-catalog 从 Typert catalog 投影 service/event——每个 gen-* 都配一个 verify-*(即 --check),CI 里产物与源码不一致直接失败。文档不是人写的,是代码生成的;依赖治理上,Cordis 等上游框架直接 vendor 进仓库用 link: 强制指向 pinned 源码,补丁只有 1 个(node-pty)。
设计模式总结
| 模式 | 落地位置 | 解决的问题 |
|---|---|---|
| 分层叠加(layered patch) | profile / bundle / cordis.patch.yml |
组合优于继承,任意条目可被上层覆盖 |
| 事件溯源 + 投影 | session 日志 / goal / plan / fork | 单一事实来源,派生视图零成本 |
| 不变量双保险 | surfaceOp 标记 |
编译期 + 运行时共同守护关键约束 |
| seam 三段式 | Service Definition / Provider / Consumer | 换一个提供方即换整个产品行为 |
| fail-closed | 沙箱 / 凭据 / 审批 | 拿不到安全后端就失败,不降级裸跑 |
| 生成器闭环 | gen-* / verify-* | 文档与产物始终与源码同步 |
五、部署与运行说明
快速开始
安装 Node.js 后,一行命令启动 Web UI:
npx @deepseek-ai/dsh web
服务默认监听 127.0.0.1:3080(仅回环,拒绝对外暴露)。从源码运行:
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web
headless:一次性 runner
不需要 UI 时用 headless profile——它完全不带服务器,启动后创建一个 Agent、跑完任务、打印最后一条 assistant 文本、按完成状态退出:
export DEEPSEEK_API_KEY=...
pnpm dsh --profile headless "fix the failing test"
流程是:解析位置参数作为任务 → agent.followup(task) → whenIdle() → flush 会话 → io.exit(completed ? 0 : 1)。CI 里做自动修 bug、批处理任务,这个形态最合适。
发布与打包
发布物是一组 npm 包(核心 @deepseek-ai/dsh 提供 dsh bin,前端 dsh-web-frontend 单独发包)。发布流程刻意拆成两阶段:release:pack 用 pnpm pack 把整个 family 的 tarball 打进 dist/npm 并记录拓扑顺序(这步无需凭据),release:publish 才接触 registry,按序发布、同版本同 integrity 跳过、冲突报错要求 bump,瞬态错误自动重试。
安全提示
- Web UI 仅监听回环地址,这是刻意的安全决策,不要试图改成
0.0.0.0; - 工具执行默认受审批策略约束;CI / 无头场景用
never策略确定性拒绝交互式授权; - 沙箱升级与敏感工具调用都会触发人工审批,窄化授权(
allowed-once)是唯一放行形态。
总结
读完整个仓库,我最深的感受是:dsh 把"框架"两个字做到了语义层面。它没有在代码里留任何特权路径——agent loop 是插件、日志是插件、UI 是插件;它用"模型可见即已记录"这样的不变量代替了文档约定,用 gen/verify 生成器代替了"文档靠人维护",用 fail-closed 代替了"默认放行"。
对 Agent 框架开发者来说,这份源码是极好的研究对象:Cordis 的插件模型回答了"怎么拆",事件溯源回答了"怎么存",seam 回答了"怎么换",scope 回答了"怎么隔离"。而对使用者来说,npx @deepseek-ai/dsh web 一行命令背后,是一棵可以逐层 patch 的插件树——你几乎可以为任何条目写一个覆盖层,这就是"一切皆插件"最终交到手里的东西。
项目目前处于 developer preview,官方明确声明会有破坏兼容性变更。但架构本身已经足够清晰、足够有说服力——它值得每一个做 Agent 基础设施的人读一遍。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付