DeepSeek Harness 架构拆解:一切皆插件的 Agent 框架是怎么实现的
本文基于 deepseek-ai/deepseek-harness 公开源码(developer preview 阶段)拆解,代码引用均标注了包路径与关键位置。文中所有
packages/...路径均相对仓库根目录。
一、为什么这套代码值得读
市面上绝大多数 agent 框架,都是"一个主循环 + 一堆写死的能力(工具、模型适配、记忆)"。想加个能力,往往要去改主循环、改 prompt 拼接、改工具分发------核心是有"特权"的,扩展是打补丁的,而且这个改动成本很大,如果想要修改一个多依赖的步骤往往很困难。
这里对于工程设计来说,plugin的设计思路不是一个新东西,在Android早期app设计中我们都会把一个个activity放入一个module中,这样业务之间不仅耦合严重,而且后期治理成本极高。随着组件化思想的扩散,开发者们开始以路由的方式解决这个问题,这个思想正是与DeepSeek的一致

DeepSeek Harness(下称 dsh)反过来做了一件事:把主循环本身也做成插件 。它的架构信条只有一句话------Everything is a Plugin(一切皆插件)。模型适配器是插件、工具注册表是插件、会话日志是插件、连 agent loop 都是插件。没有一个"必须去 patch 的核心",你扩展 dsh 的方式永远是"在旁边再挂一个插件",而不是"改动已有的东西"。
这套能力建立在一个叫 Cordis 的插件框架之上(dsh 把它 vendor 进了 vendor/ 目录)。整个仓库是一个 pnpm monorepo,packages/ 下有 200+ 个 workspace 包,全部以 @deepseek-ai/dsh-<name> 命名。
从 Cordis 的五个核心概念讲到 agent loop 的 turn/step 状态机,再到能力接缝、事件溯源、工具管线、Typert RPC,最后回到它的工程化设计。读完你会明白:一个"完全可替换"的 agent 框架,在代码层面到底长什么样。
二、一句话架构
先给一张全局图,后面的章节都是在给这张图的各个部分填细节。

三个关键词,构成理解 dsh 的三把钥匙:
- Cordis 插件树 ------所有能力都是挂在一个共享
ctx(context)上的服务,通过 key 互相发现。 - 能力接缝(Capability Seam)------每个可替换能力被拆成"定义 / 实现 / 消费"三个角色,换一个实现就能整块换掉产品行为。
- 事件溯源(Event-sourced Session) ------agent 交互的唯一真相是一条 append-only 的事件日志,模型看到的对话历史是从日志推导出来的,而不是单独存的。
三、Cordis:五个核心概念
在读 dsh 之前必须先懂 Cordis。它只有五个概念(源自 docs/cordis-primer.md):
| 概念 | 一句话 |
|---|---|
| Plugin(插件) | 一个实现了 Service 的对象:可以是带 inject/apply(ctx) 的函数,也可以是 Service 子类 |
| Context(上下文) | 服务的仓库。服务用稳定的 ctx.<key>(如 ctx.tools、ctx.llm)占位,别人靠 key 找它,而不是 import 具体实现 |
| inject(依赖声明) | 插件声明它需要哪些服务,Cordis 等这些服务就绪后才激活它------加载顺序由依赖表达,而非手写 boot 序列 |
| Typed Event(类型化事件) | 服务通过 TS 声明合并注册事件名,再按 emit / waterfall / parallel / serial 四种模式分发 |
| Effect(可逆副作用) | 所有注册(prompt 段、工具、适配器、监听器)都通过 ctx.effect() / ctx.on() 完成,卸载时按序回滚 |
3.1 插件的两种写法
dsh 里两种写法并存。函数式插件 (导出 name、inject、apply):
ts
// packages/todo/tool-todo/src/index.ts
export const name = 'tool-todo'
export const inject = ['tools']
export function apply(ctx: Context, config: Config): void {
// 可选接缝的"按需激活":只有 sessionProjections 被组装了才跑这段
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register({ key: 'todos', init, apply, ... })
})
ctx.tools.register(defineTool({ name: 'todo_write', description, parameters, output, execute }))
}
Service 子类 (靠 static inject 声明硬依赖):
ts
// packages/goal/goal/src/index.ts
export class GoalService extends TypertRemoteService {
static inject = ['agents']
static Config = z.object({ defaultMaxGoalRounds: z.number().default(256) })
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'goals') // 占用 ctx.goals
ctx.on('agent/session-start', ({ agent }) => { ... }) // 订阅事件
}
@Remote('edit') /* ... */ // 暴露为 RPC 的方法
}
这里有一个贯穿全仓的重要约定:ctx.inject(['x'], cb) 表示"可选 依赖"------x 存在才激活这个子 fiber;static inject = ['x'] 表示"硬 依赖"------x 不存在这个插件根本不构造。能力的可选性,是用依赖声明的方式表达的。
3.2 四种事件分发模式
事件的分发模式是它公共契约的一部分,新事件要用 @mode 标注:
| Mode | 是否 await | 顺序 | 有返回值 |
|---|---|---|---|
emit |
否 | 注册序,纯观察 | 无 |
waterfall |
否 | 注册序 | 有(环绕式中间件) |
parallel |
是 | 并行 | 无 |
serial |
是 | 注册序 | 有 |
其中 waterfall(瀑布)是最关键的扩展机制 。它是"环绕式中间件":监听器签名是 (...args, next),调 next() 把(可能被改写的)结果交给下一个服务,不调 next() 就短路。这就是 dsh 里"拦截 / 改写 / 策略决策"的统一模式------比如拦截模型请求、否决工具调用、注入 prompt,全走 waterfall。
3.3 一切注册都是可逆 Effect
ts
// 典型:注册一个工具,返回一个 disposer
return this.layers.effect(this.ctx, layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' })
因为注册即 effect、effect 自带 disposer,dsh 才能支持热重载(HMR):改一个插件的代码,它连同它注册的工具/prompt/监听器一起被干净卸载再装回,不会泄漏。
四、Profile 与 Bundle:启动期怎么把插件树拼出来
既然一切皆插件,那"一个能跑的 dsh"本质就是一棵启动期组装出来的插件树 。dsh 用两级结构描述这个组装(packages/boot/app-boot/src/profile.ts):
- Bundle(捆绑包) :一个 npm 包,在自己的
package.json里声明"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }。它是"一批 Cordis 配置行 + 挂载它们的代码"的分发格式。 - Profile(配置档) :存在
$DSH_HOME/profiles/<name>/,package.json的dsh.profile.bundles列出它要叠的 bundle 顺序,外加用户自己的cordis.patch.yml。
启动时,dsh 从一个空的 entry 列表 开始,按顺序叠加:profile 列出的每个 bundle 的 patch → profile 的 cordis.patch.yml → home 级 patch → --patch 命令行覆盖层。

一个 patch 通过 id 定位某一行,整体替换 它的 config(不是合并),或者 insert 新行。"最后写入的赢"。
dsh-base 是每个 profile 的第一层(模型适配、工具、持久化、沙箱与审批策略、设置、凭证、遥测)。web profile = [dsh-base, dsh-web-app],headless profile = [dsh-base, dsh-headless]。
看一段真实的 base bundle patch(packages/bundle/base/cordis.patch.yml):
yaml
- insert:
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: session
name: '@deepseek-ai/dsh-session'
- id: agent-default-model
name: '@deepseek-ai/dsh-agent-default-model'
config: { provider: deepseek-official, model: deepseek-v4-flash }
- id: settings
name: '@deepseek-ai/dsh-settings-file'
再看 headless bundle 怎么覆盖 base 行 + 关闭某行 + 追加自己的行 (packages/bundle/headless/cordis.patch.yml):
yaml
- id: system-prompt # 覆盖 base 里同名行的 config
config:
persona: >-
You are a coding agent powered by the {{model}} model.
- id: hmr
disabled: true # 关闭一行
- insert:
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.headlessStartup.task # patch 里允许 !!js 表达式
注意 !!js:Cordis 的 loader 会把它解析成表达式节点,在依赖就绪后按插件上下文求值。这让"环境驱动的条件组装"成为可能(比如 !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash')。
想看你机器实际启动的那棵树?
dsh --profile web --dump-config。每一行都能被你自己的 patch 替换掉------这就是"没有特权核心"在使用层面的样子。
五、能力接缝(Capability Seam):三角色模型
这是 dsh 最核心的可组合性机制,也是理解"一切皆插件"如何落到实处的关键。
一个 seam(接缝) 是一个"可替换的能力",由三个角色构成(docs/glossary.md):
- Service Definition(服务定义) :声明接口的抽象类,占据某个
ctx.<key>与相关词汇类型。它必须是抽象类或具体注册表。 - Service Provider(服务提供者):实现它的具体类,可以有多个。
- Consumer(消费者):注入并使用这个服务的一方,通常是一个面向模型的工具。
一个角色不构成 seam,三个角色齐全才是。 下面用最经典的 packages/shell 三件套说明。
定义(Service Definition)
ts
// packages/shell/shell/src/index.ts
declare module '@deepseek-ai/cordis' {
interface Context { shell: ShellExecutor } // 声明合并:把 ctx.shell 挂上类型
}
export abstract class ShellExecutor extends Service {
constructor(ctx: Context) { super(ctx, 'shell') }
get sandboxMode(): SandboxMode | undefined { return undefined }
abstract resolve(request: ShellExecRequest): ShellExecSpec // 请求 → 规格
abstract run(spec: ShellExecSpec): Promise<ShellRunResult> // 一次性执行
abstract start(spec: ShellExecSpec): ShellProcess // 后台进程
}
实现(Service Provider)
ts
// packages/shell/bash-local/src/index.ts
export class LocalBashExecutor extends ShellExecutor {
static inject = ['subprocess']
static Config = z.object({ timeoutMs: z.number().default(120_000) })
resolve(request) { /* 补全 workdir、钳制超时、原样带上 sandboxPolicy */ }
async run(spec) { return this.runArgv(spec, ['bash', '-c', spec.command]) }
start(spec) { return this.startArgv(spec, ['bash', '-c', spec.command]) }
}
消费(Consumer)
ts
// packages/shell/tool-bash/src/index.ts
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
export function apply(ctx: Context): void {
const defaultMode = ctx.shell.sandboxMode // 只依赖抽象 key
ctx.tools.register(defineTool({ name: 'bash',
async execute(args, exec) {
return await ctx.shell.run(ctx.shell.resolve({ ...request }))
}}))
}
关键在于 :tool-bash 只依赖抽象的 ctx.shell,永远不知道背后挂的是 bash-local、bash-sandbox 还是 pwsh-local。
官方文档原话:文件系统和子进程 provider 共享同一个"执行世界",所以把它们指向一个远程沙箱,Bash、PTY、LSP 就整体跟着搬过去了,一个 provider 都不用 fork。 换一个实现,整个产品的能力边界就变了------这就是"seam 为什么能让一次 provider 替换改变整个产品"。
dsh 里到处是 seam:ctx.fs(文件系统)、ctx.web(搜索/抓取)、ctx.lsp(语言服务)、ctx.sandbox(进程沙箱)、ctx.compaction(上下文压缩)、ctx.subagents(子 agent)。有的 seam 只允许一个 provider(如 shell),有的允许多个 provider 按名共存(如 subagent、llm 适配器)。
六、Session Event Log:事件溯源是整个系统的地基
在讲 agent loop 之前,必须先讲会话日志,因为循环本身几乎不持有状态,一切都从日志里推导。
一个 Session 是一条 append-only 的 SessionEvent 日志 (packages/core/session),是 agent 全部交互历史的唯一真相。SessionEventMap 是一个可合并扩展的接口(插件用声明合并往里加事件),核心成员:
ts
// packages/core/session/src/types.ts
interface SessionEventMap {
'turn/start': { turn: number }
'turn/end': { turn: number; reason: TurnEndReason }
'step/start': { turn: number; step: number }
'step/end': { turn: number; step: number }
'user/message': UserMessage
'assistant/chunk': { turn; step; chunk: StreamChunk } // 原始流式分片
'assistant/message':{ turn; step; message: AssistantMessage; usage?; interrupted?: true }
'tool/call': { turn; step; callId; name; arguments: string } // 原始未解析 JSON
'tool/result': { turn; step; message: ToolResultMessage; error?; meta? }
'todo/write': { todos: TodoItem[] }
// compaction/* 、hook/* 等由各自插件通过声明合并追加
}
这里有一条贯穿全仓、写进 AGENTS.md 的铁律:
Model-visible ⟺ logged(模型可见即已记录):任何到达模型请求的东西,都必须能从日志重建。一个新的"模型可见输入"必然要求一个新的会话事件。
七、Agent Loop:turn / step 状态机拆解
现在到主循环。先厘清三个层级的词汇(docs/glossary.md):
- step(步):一次模型请求 + 它引发的工具执行。
- turn(轮):一次对"已接纳输入"的排空,包含 0 个或多个 step。输入被认领前开启,欠债还清后关闭。
- round(回合):更外层的策略迭代(如 goal round、Ralph round)。
agent loop 由两个文件承载(packages/core/agent-loop/src/):index.ts 是工厂插件 (AgentLoop extends Service),agent.ts 是每会话的驱动器 (ReactLoopAgent)。
工厂把自己注册成 agent 工厂:
ts
// packages/core/agent-loop/src/index.ts
export class AgentLoop extends Service {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
// ...
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
}
注意这行 static inject :agent loop 自己也只是一个声明了 5 个依赖的普通插件。想换掉整个循环?实现 Agent 接口、ctx.agents.setFactory 换成你的工厂就行------这就是"连 agent loop 都是插件"的字面意思。
7.1 loop 驱动
ts
// packages/core/agent-loop/src/agent.ts ------ Phase = idle | maintenance | running
private async kick(): Promise<void> {
try { while (await this.turn()) {} } // 一直驱动 turn 直到返回 false
catch (_error) { /* 在驱动器边界兜住 */ }
finally { /* 落回 idle;若 inbox 还有待处理则再唤醒 */ }
}
turn() 的骨架:
arduino
append turn/start
loop:
preStep() // 认领输入 + 组装 prompt + agent/pre-step 瀑布
append step/start
append 每条认领的 user/message
step() // 请求模型 + 分发工具
append step/end
若 completed/max-tokens 且 next-step inbox 为空 → break
append turn/end
7.2 preStep:认领输入 + 组装 prompt
ts
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
const sections = renderContextSections(assembly)
const decision = await this.dispatch.waterfall('agent/pre-step', {...}, ...) // ★扩展点
agent/pre-step 是一个瀑布,决定模型将看到什么 :监听器可以改写认领的消息,或直接否决它们。压缩插件 dsh-compaction-basic 就挂在这里,在请求派生前判断上下文压力,必要时触发压缩。一个被否决 / 空的首次认领,仍会关闭一个"没花任何 step"的持久 turn------日志会记录这次尝试。
7.3 step:请求模型 + 流式落盘 + 分发工具
ts
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal) // ★从日志派生历史
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
for await (const chunk of stream) {
this.session.append('assistant/chunk', { turn, step, chunk }) // 每个分片都落盘(回放保真)
assembler.push(chunk)
}
// 组装成 assistant/message 落盘
const toolCalls = message.content.filter(b => b.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
await executeToolCalls(this.loopCtx, turn, step, toolCalls, signal, ...) // 分发工具
buildRequest() 跑 agent/request 瀑布(让插件重新指定模型路由)、经 llm.prepareCall 解析适配器、在请求头变化时记一条 request/header 事件,最后冻结 GenerateOptions。
7.4 输入怎么进来:Inbox 的四种投递语义
驱动器只有一个 inbox (packages/core/agent/src/inbox.ts),内部是两个队列 next-turn / next-step,且能从持久化的 agent/inbox/spliced 事件里重建。agent 对外暴露四种投递(agent.ts):send / followup(排队等下一轮)、steer(插队进当前步)、inject(注入上下文,安静地等下次认领)。有的消息立刻唤醒驱动器,注入的上下文则在 inbox 里等到别的消息把它一起带走。
7.5 工具调度:模型序提交,有界并发执行
executeToolCalls()(tool-calls.ts)按模型给出的顺序 遍历调用,逐个问注册表 ctx.tools.executionMode(exec):parallel 的调用进一个有界滚动池并发跑,exclusive 的形成屏障。但结果和上下文严格按模型序提交 (commitReady()),保证回放确定性。maxParallelToolCalls 从配置读取;中止时给被跳过的调用补一个合成的 error 结果,让日志回放依然合法。
7.6 完整时序
图里 turn/*、step/*、user/message、assistant/*、tool/* 都是持久会话事件;agent/pre-step、agent/request、llm/stream 和三个 tools/* 是瀑布扩展点(监听器必须调 next() 才继续链);agent/turn-stopping 是 serial,没有 next()。
八、工具注册表与守护执行管线
工具是模型的手,dsh 把"注册"和"守护执行"彻底分离(packages/core/tools)。
8.1 注册:作用域化 + 可逆
ts
// register() 校验定义(要求 output {schema, render},拒绝保留名 run_code),返回 disposer
return this.layers.effect(this.ctx, layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' })
工具是作用域化 的:有全局工具,也有挂在某个 agent scope 上的工具。restrict() 能对某个 agent 屏蔽全局工具集------被过滤掉的工具在 prompt 里看不到、执行时也拒绝,和"不存在"无法区分。这就是"每 agent 人格 / 工具变体"的底层机制(most-specific-wins 的 shadowing)。
8.2 守护pipeline
一次工具调用不是直接跑 execute,而是穿过一条管线:

tools/pre-execute(瀑布):hooks、权限、沙箱策略在此。之后是ctx.approval审批解析,再之后是单调守卫------守卫只能"拒绝或弃权",永远不能"强制放行",这是安全不变式。tools/execute(环绕瀑布):超时、重试、指标这类"环绕关注点"包在这里,里面才是工具本体execute(args, exec)(会把调用方和包装器的 abort 信号融合)。tools/post-execute(瀑布):可以接受、阻断、替换结果,或追加上下文。之后是定义自带的finalizeContent(最后的内容不变式),最后tools/result观察那个冻结的、无损 JSON 的权威结果。
这套设计的价值在于:hooks 能横跨整个工具家族(bash、fs、web......)加策略,而不用把任何一个工具耦合到某个具体策略服务上。文件系统的"读前写"检查是挂在 fs/* 事件上的独立插件(dsh-fs-observation-policy),不改任何工具的 schema。
8.3 内置工具
从生成的工具目录看,dsh 内置的模型可见工具覆盖面相当完整:
| 类别 | 工具 | 背后的 seam |
|---|---|---|
| Shell | bash / pwsh(一次性 + 持久 PTY 版) |
ctx.shell / ctx.terminals |
| 文件 | read / write / edit / read_image / str_replace_editor |
ctx.fs |
| 检索 | glob / grep(打包的 ripgrep 走 ctx.subprocess) |
ctx.subprocess |
| Web | web_search / web_fetch |
ctx.web |
| 代码智能 | lsp |
ctx.lsp |
| 委派 | subagent / subagent_fork / send_message / list_agents |
ctx.subagents |
| 编排 | workflow / ralph(fresh-agent 循环) |
ctx.workflowEngine |
| 后台 | job_list / job_output / job_kill |
ctx.jobs |
| 会话状态 | todo_write / create_goal / schedule_create |
各自 seam |
| 交互 | ask_user_question / exit_plan_mode |
ctx.userQuestions |
| Code Mode | run_code(保留传输) |
ctx.codeRuntime |
总结:这套架构好在哪,可借鉴什么
Everything is a Plugin。
-
没有特权核心。 连 agent loop、模型适配、工具注册表都是插件,注册即可逆 effect。这让"扩展"永远是"加一个插件",而不是"改一处核心",也让热重载成为自然结果。
-
能力接缝三角色。 把每个可替换能力拆成"抽象定义 / 具体实现 / 消费者",消费者只依赖抽象 key。于是"把 shell/fs 指向远程沙箱"这种大改动,退化成"换一个 provider",Bash/PTY/LSP 整体跟着走。
-
事件溯源做真相源。 循环几乎不持有状态,模型历史当场从 append-only 日志
deriveMessages()派生。fork、resume、回放、遥测、压缩全部统一在一条事件流上;"模型可见即已记录"这条不变式,让整个系统天然可审计、可回放。 -
瀑布式扩展点。
agent/pre-step、agent/request、tools/pre|execute|post这组环绕中间件,把"拦截 / 改写 / 策略"统一成一种模式------权限、审批、沙箱、压缩、超时、重试都是挂在同一组瀑布上的独立插件,互不耦合。
参考
- 仓库:github.com/deepseek-ai...
- 架构文档:
docs/architecture.md、docs/cordis-primer.md、docs/glossary.md、docs/capability-seams.md、docs/tool-execution-pipeline.md、docs/agent-lifecycle.md、docs/api-gateway.md - 底层框架 Cordis:github.com/cordiverse/...