DeepSeek Harness 架构拆解

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 的三把钥匙:

  1. Cordis 插件树 ------所有能力都是挂在一个共享 ctx(context)上的服务,通过 key 互相发现。
  2. 能力接缝(Capability Seam)------每个可替换能力被拆成"定义 / 实现 / 消费"三个角色,换一个实现就能整块换掉产品行为。
  3. 事件溯源(Event-sourced Session) ------agent 交互的唯一真相是一条 append-only 的事件日志,模型看到的对话历史是从日志推导出来的,而不是单独存的。

三、Cordis:五个核心概念

在读 dsh 之前必须先懂 Cordis。它只有五个概念(源自 docs/cordis-primer.md):

概念 一句话
Plugin(插件) 一个实现了 Service 的对象:可以是带 inject/apply(ctx) 的函数,也可以是 Service 子类
Context(上下文) 服务的仓库。服务用稳定的 ctx.<key>(如 ctx.toolsctx.llm)占位,别人靠 key 找它,而不是 import 具体实现
inject(依赖声明) 插件声明它需要哪些服务,Cordis 等这些服务就绪后才激活它------加载顺序由依赖表达,而非手写 boot 序列
Typed Event(类型化事件) 服务通过 TS 声明合并注册事件名,再按 emit / waterfall / parallel / serial 四种模式分发
Effect(可逆副作用) 所有注册(prompt 段、工具、适配器、监听器)都通过 ctx.effect() / ctx.on() 完成,卸载时按序回滚

3.1 插件的两种写法

dsh 里两种写法并存。函数式插件 (导出 nameinjectapply):

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.jsondsh.profile.bundles 列出它要叠的 bundle 顺序,外加用户自己的 cordis.patch.yml

启动时,dsh 从一个空的 entry 列表 开始,按顺序叠加:profile 列出的每个 bundle 的 patchprofile 的 cordis.patch.ymlhome 级 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-localbash-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 的四种投递语义

驱动器只有一个 inboxpackages/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 完整时序

sequenceDiagram participant User participant Agent participant Driver participant Hooks as hook 监听器 participant LLM as ctx.llm participant Tools as ctx.tools participant Session User->>Agent: followup(content) Agent->>Driver: 排队的活儿唤醒驱动器 Driver->>Session: turn/start Note over Driver: 认领 next-step 输入 + 一条排队消息 Driver->>Hooks: agent/pre-step (瀑布) → reject | enter(messages) Driver->>Session: step/start + user/message* Driver->>Hooks: agent/request (瀑布,可改模型路由) Driver->>LLM: llm/stream (瀑布) LLM-->>Driver: StreamChunk* Driver->>Session: assistant/chunk* → assistant/message loop 每个工具调用 Driver->>Session: tool/call Driver->>Tools: pre-execute → execute → post-execute Driver->>Session: tool/result end Driver->>Session: step/end opt 自然停止且 inbox 空 Driver->>Hooks: agent/turn-stopping (serial 终止检查点) end Driver->>Session: turn/end

图里 turn/*step/*user/messageassistant/*tool/* 都是持久会话事件;agent/pre-stepagent/requestllm/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

  1. 没有特权核心。 连 agent loop、模型适配、工具注册表都是插件,注册即可逆 effect。这让"扩展"永远是"加一个插件",而不是"改一处核心",也让热重载成为自然结果。

  2. 能力接缝三角色。 把每个可替换能力拆成"抽象定义 / 具体实现 / 消费者",消费者只依赖抽象 key。于是"把 shell/fs 指向远程沙箱"这种大改动,退化成"换一个 provider",Bash/PTY/LSP 整体跟着走。

  3. 事件溯源做真相源。 循环几乎不持有状态,模型历史当场从 append-only 日志 deriveMessages() 派生。fork、resume、回放、遥测、压缩全部统一在一条事件流上;"模型可见即已记录"这条不变式,让整个系统天然可审计、可回放。

  4. 瀑布式扩展点。 agent/pre-stepagent/requesttools/pre|execute|post 这组环绕中间件,把"拦截 / 改写 / 策略"统一成一种模式------权限、审批、沙箱、压缩、超时、重试都是挂在同一组瀑布上的独立插件,互不耦合。

参考

  • 仓库:github.com/deepseek-ai...
  • 架构文档:docs/architecture.mddocs/cordis-primer.mddocs/glossary.mddocs/capability-seams.mddocs/tool-execution-pipeline.mddocs/agent-lifecycle.mddocs/api-gateway.md
  • 底层框架 Cordis:github.com/cordiverse/...
相关推荐
智购科技自动售卖机厂家1 小时前
2026自动售货机OTA升级系统设计:从全量升级到差分升级的带宽优化工程实践~YH
大数据·人工智能·numpy·pyqt·fastapi
武子康1 小时前
登录不是授权:高能力 AI 的连续身份保证链
人工智能
安逸sgr1 小时前
AI 应用怎么评测?离线评测、人工评估和线上反馈如何结合?
人工智能·ai·大模型·agent·智能体
代码青铜2 小时前
1 万用户的图片短视频交易市场,829 元/月能跑起来?Zion+AI让开发看得懂、改得动
人工智能
JJJennie7772 小时前
Anthropic 发布 Claude Academy,AI 教学开始告别提示词速成班
人工智能
一杯仙妖气2 小时前
Grok 4.6 正式发布!性能直接飙升,xAI 这次真的开始冲击第一梯队! 可免费试用 2026年8月20日 admin AI xAI
人工智能
白露与泡影2 小时前
解密 Pi 的 Harness 工程:Agent 会话如何实现持久化与恢复
java·人工智能·算法
DogDaoDao2 小时前
Magma:微软如何用一个模型打通数字与物理世界的 AI Agent
人工智能·微软·机器人·大模型·机器人模型·智能体·magma
DS随心转插件2 小时前
Grok生成的html怎么导出——AI导出鸭:大模型结构化输出的“最后一公里”工程化解构
前端·人工智能·ai·html·豆包·deepseek·ai导出鸭