跳到主要内容

数据截至 (上游 commit 99f6f02fecdb)

DeepSeek Harness — 架构与原理

30 秒导读: DeepSeek Harness(命令 dsh)是 DeepSeek AI 开源的一套「agent 骨架」——你可以拿它当编码 agent 用(有 Web UI、有 bash/文件/LSP 工具),但它真正值得读的是架构:产品的每一层都是插件,包括主循环自己。换掉模型适配器、换掉工具集、换掉「代码跑在哪台机器上」,都不需要改核心代码,只需要往配置里加一层 patch。而模型每次看到的上下文,一律从一份只追加的会话事件日志投影出来——日志里没有的东西,模型就不可能看到。


1. 这是什么(零基础也能懂)

一句话定义: DeepSeek Harness 是一个用 TypeScript 写的、装配式的 AI agent 运行时——它自己不是一个固定的产品,而是一堆插件加一套组装规则,组装出来才是产品。

解决什么问题 / 给谁用: 假设你要做一个「会写代码的 AI 助手」。你很快会发现:模型调用只占一小块,剩下全是脚手架——工具怎么注册、权限怎么审批、上下文怎么攒、会话怎么落盘、崩了怎么恢复、命令行和网页两个前端怎么共用一套逻辑。多数项目把这些硬编码进一个主循环里,于是想换掉任何一层都要动核心。Harness 给的答案是:先把这些全部拆成可插拔的插件,再把「怎么拼」交给一份 YAML。 它面向的是要自己做 agent 产品、而不只是调 API 的工程师。

它能做什么(功能):

  • 一个浏览器里的编码 agent(Web UI,默认 http://127.0.0.1:3080)和一个无界面的一次性任务模式(headless)。
  • 一套模型能调用的工具:bash / 持久终端 / 文件读写 / LSP / 网页搜索抓取 / 后台任务 / todo / 子 agent 委派 / 技能加载。
  • Code Mode:让模型不再一次调一个工具,而是写一段程序去批量调工具(run_code)。
  • 会话持久化(JSONL / SQLite)、fork、恢复、全文检索、遥测、压缩(compaction)。
  • 沙箱化执行:把文件系统和子进程指向 Landlock 限制或 E2B 远程沙箱。
  • 对外协议:JSON-RPC SDK、ACP(Agent Client Protocol)自动化服务、Python SDK。

用起来什么样:

# 装个 Node 就能跑,起 Web UI
npx @deepseek-ai/dsh web

# 无界面跑一个任务,结果直接打到 stdout
pnpm dsh --profile headless "把 src/ 下的 var 全换成 const"

# 想知道你这台机器实际启动了哪些插件行:
dsh --profile web --dump-config

最后那条命令是理解这个项目的钥匙:它打印出的不是日志,而是这次启动实际组装出的整棵插件树。树里的任何一行,你都能用自己的一层 patch 换掉。

一句话直觉/类比: 把它想成 Linux 的 init + 包管理:没有「内核补丁」这回事,你要加功能就装一个包(插件),要改行为就叠一层配置(patch);而会话日志相当于一份 append-only 的账本,模型每次的上下文都是从账本上现算出来的,不是谁在内存里攒的。

诚实的边界: 项目自述处于 developer preview 阶段,明确声明会有破坏性变更(README.md);会话日志格式版本 SESSION_FORMAT_VERSION 仍为 0,不承诺兼容(packages/core/session/src/types.ts:56)。读它是为了学架构,不是为了当稳定依赖。


2. 顶层全景(它大概怎么转)

这一节回答两件事:启动时这堆插件是怎么拼起来的,以及跑起来以后一句话是怎么变成模型请求和工具调用的。两件事分别一张图。

2.1 启动:配置怎么变成产品

怎么读这张图:从上往下是 patch 的叠加顺序,后面的层按行 id 覆盖前面的层;最终合成一份扁平的 entry 列表,交给 Cordis 挂成插件树。

profiles/<name>/cordis.yml ← 永远是一个空列表 [](只用来锚定目录)

│ 依次叠加四类 patch 层(后写覆盖同 id 的行)

① bundle 层 dsh-base ──► dsh-web-app 或 dsh-headless
② profile 层 ~/.dsh/profiles/<name>/cordis.patch.yml
③ home 层 ~/.dsh/cordis.patch.yml
④ 命令行层 --patch <file> / DSH_TELEMETRY_DISABLED 开关

▼ composeEntries() 合成一份 entry 列表
┌──────────────────────────────────┐
│ Cordis 插件树(每行 = 一个插件) │
│ llm / session / tools / agent / │
│ agent-loop / 各种 provider …… │
└──────────────────────────────────┘

叠加顺序写在 apps/cli/src/profile-boot.ts:151(composeEntries([bundlePatches, profile.patches, homePatches, overlays])),合成算法在 packages/boot/app-boot/src/profile.ts:413composeEntries。两个 shipped 模板 web / headless 各自的 bundle 列表在 packages/boot/app-boot/src/profile.ts:114PROFILE_TEMPLATES

关键的一条规则:一次 patch 是「整行 config 替换」,不是深合并——这句话写在 packages/bundle/base/cordis.patch.yml 的开头注释里,也是为什么「按模式取值不同的行」不放在 base 层,而由每个模式 bundle 自己完整重述。

2.2 运行:一句话怎么走完一圈

怎么读这张图:实线是数据流向,左边那根回环是全篇最重要的一条——模型历史不是内存里攒的,而是每一步从日志现投影出来的。

用户输入 / 注入的上下文


Inbox(next-step、next-turn 两条队列)
│ claim

┌────────────────────────────┐ append ┌──────────────────────┐
│ 主循环 ReactLoopAgent │ ─────────► │ 会话日志 Session │
│ turn ⊃ step 状态机 │ │ (append-only) │
└────────────────────────────┘ ◄───────── └──────────────────────┘
│ deriveMessages()(surface 投影出模型历史)

ctx.llm 适配器 ──stream──► assistant/chunk* ──► assistant/message


ctx.tools 三段式流水线 ──► tool/result ──► 写回日志,进入下一 step

一次 step 的真实代码就是这张图:packages/core/agent-loop/src/agent.ts:332ReactLoopAgent.step() 依次做「组请求 → 流式收 chunk 并逐条 append → 装配成 assistant/message → 有 tool-call 就执行工具」。其中喂给模型的历史是 this.session.deriveMessages()(同文件 :341)。

2.3 部件一句话职责

部件干什么在哪个文件
Cordis(vendored)插件框架:服务、类型化事件、可回滚的注册效果vendor/cordis/
profile / bundle把「装哪些插件、怎么配」做成可叠加的 patch 层packages/boot/app-boot/src/profile.ts
ctx.sessionsappend-only 事件日志 + 内存会话表packages/core/session/src/index.ts
ctx.agentLoop默认驱动器:开 turn、跑 step、发请求、调工具packages/core/agent-loop/src/agent.ts
ctx.agentsAgent 接口与活跃 Agent 注册表、agent/* 事件packages/core/agent/src/index.ts
ctx.tools作用域化工具注册表 + 三段式执行流水线packages/core/tools/src/index.ts
ctx.systemPrompt提示词分节与工具 schema 的装配packages/core/system-prompt/src/index.ts
ctx.llm模型消息/流式词汇表 + 适配器接缝packages/llm/llm/src/index.ts

2.4 主线走一遍(高层,不进代码)

  1. 组装。 dsh --profile web 先把 profile 的四层 patch 叠成一份 entry 列表,Cordis 按列表挂起整棵插件树(apps/cli/src/profile-boot.ts:142)。
  2. 开会话。 某个前端(Web / headless / ACP)向 ctx.agentLoop 要一个 Agent;工厂在发布前先 prepare() 出驱动器和它自己的 scope,再进注册表(packages/core/agent-loop/src/index.ts:459)。
  3. 收输入。 用户的话进 Inbox。followup 排在 next-turn、steer 排在 next-step 且唤醒、inject 排在 next-step 但不唤醒(packages/core/agent-loop/src/agent.ts:122-132)——注入的上下文会一直躺着,等下一条真消息把它带进去。
  4. 一个 turn。 循环先 append turn/start,claim 输入,装配提示词,过 agent/pre-step 瀑布(插件可以改写甚至拒绝这批消息),然后进 step(agent.ts:246)。
  5. 一个 step。 从日志投影出历史 → 组一份冻结的请求并把请求头 append 进日志 → 调适配器流式取回 → 每个 chunk 都 append → 装配成 assistant/message。
  6. 工具。 模型要调工具就交给调度器:标注了并发安全的工具进有界并行池,其余串行(packages/core/agent-loop/src/tool-calls.ts:59);每个调用走 tools/pre-execute → tools/execute → tools/post-execute 三段瀑布,结果 append 成 tool/result,再进下一个 step。
  7. 收尾。 没有工具再欠请求、Inbox 也没有 next-step 输入,turn 就 append turn/end 关闭。

3. 阅读地图

建议按顺序读:1 → 2 → 3 是主干(组装 → 数据 → 控制),4 → 5 → 6 是展开(手脚 → 可替换性 → 产品化)。

顺序章节读完你会知道
0DeepSeek Harness — 架构与原理全景与主线(本页)
1第 1 章 · 底座:Cordis 插件树与「配置即产品」的分层组合profile / bundle / patch 三级怎么叠;为什么「没有特权核心」
2第 2 章 · 唯一事实源:append-only 会话事件日志与 surface 投影事件日志的写入契约、surface 标记、deriveMessages 投影、压缩为什么不是删日志
3第 3 章 · 主循环:turn / step 状态机、Inbox 与一次模型请求的生死turn/step 边界、Inbox 三种投递语义、请求头 epoch、取消与错误恢复
4第 4 章 · 手脚:工具注册表、三段式执行流水线与 Code Mode工具怎么注册与作用域化、审批/超时/重试挂在哪、并行调度、run_code 怎么把工具变成 SDK
5第 5 章 · 能力接缝:换一个 Provider,整个产品换一个执行世界seam 的三个角色、fs+subprocess 如何整体搬去远程沙箱、模型适配器接缝
6第 6 章 · 每会话独立组装与产品外壳:presets、系统提示词、RPC 与多前端preset 挂载 + scope 父链、提示词装配、Typert RPC 网关、Web/headless/ACP 三个外壳

如果你只有 20 分钟:读第 2 章和第 3 章。这两章是这个项目的「心脏」,其余都是围着它们长出来的可替换件。


4. 巧妙之处(可借鉴的技术)

① 主循环自己也是插件,所以「换掉主循环」不需要 fork。 AgentLoop 只是一个普通的 Cordis Service,它靠一句 ctx.effect(() => ctx.agents.setFactory(this)) 把自己塞进 Agent 工厂位(packages/core/agent-loop/src/index.ts:350)。Service 定义(ctx.agents,接口 AgentFactory)和默认实现(ctx.agentLoop)是两个包——想要另一种驱动方式,写一个新插件占住工厂位就行。

② 「模型可见 ⟺ 已记录」被做成了机制,而不是纪律。 全日志里只有三种事件能进入模型历史——user/messageassistant/messagetool/result(packages/core/session/src/surface.ts:15),而且这三种在 append 时必须surfaceOp 标记,类型系统会强制这一点;其他类型带了标记反而报错(packages/core/session/src/index.ts:604append 签名)。于是「新增一种模型能看到的输入」这件事,在编译期就被逼成「新增一个会话事件」。

③ 压缩上下文不是删历史,是往日志里追加一条「遮蔽」记录。 因为日志只追加,压缩(compaction)通过一条带 replace 语义 surfaceOp 的新事件,把旧的一段 surface 节点遮蔽掉;派生历史因此变短,而人类看的逐字记录仍然完整——项目专门提供了 isAppendSurfaceEvent 来区分「进过 surface 的原始事件」和「替换副本」,并注释说明后者只给模型看、不给人看(packages/core/session/src/surface.ts:51)。这是我在同类项目里见过最干净的「上下文压缩」做法。

④ 派生历史是增量缓存的,不是每步重算。 deriveMessages() 只投影没见过的 surface 节点,一次调用的成本是 O(新节点数);只有发生 replace(replaceGeneration 变了)才整体重建(packages/core/session/src/index.ts:726)。请求头(requestHeader())和路由元数据同样是增量折叠。

⑤ 一个 provider 换掉整个执行世界。 ctx.fsctx.subprocess 两个接缝一旦指向 E2B 远程沙箱,dsh-bash-localdsh-terminal-bashdsh-lsp-stdio 这些消费者一个都不用 fork——它们把所有执行世界操作都委托给这两个接缝(packages/e2b/README.md)。「Bash、PTY、LSP 一起搬家」是这个设计最有说服力的证据。

⑥ 每个会话可以装一套自己的能力。 preset(一个含 agent.cordis.yml 的目录)在进程里只挂载一次,每个选它的会话通过 dsh-scope 的父链 agent → preset → global 加入;工具和提示词分节因此只存在一份,却能覆盖所有加入的 agent,而兄弟 preset 的监听器对它「装聋」(packages/preset/agent-presets/README.mdpackages/preset/agent-presets/src/mount.ts:332)。

⑦ 注册即效果,卸载即回滚。 所有贡献都走 ctx.effect() / ctx.on(),注册函数返回 disposer;插件卸载时它注册过的工具、提示词分节、事件监听自动消失。Agent 的生命周期把这条推到极致:teardown 在发布之前就注册好并被记忆化,所以「装到一半被卸载」也能完整回滚(packages/core/agent-loop/src/index.ts:494-520)。

⑧ 配置能改的东西,和配置只能看的东西,分得很清楚。 agent-loopmaxParallelToolCalls 做成 getter,每次调度决策现读,改了立刻对下一组生效;而 agents(启动时组装的数组)明确进用户设置,因为「存了也只是看起来有效果」(packages/core/agent-loop/src/index.ts:239-252)。这种对「哪些是运行时可调、哪些是启动时事实」的自觉,值得抄。


5. 代码地图(导航索引)

按符号名 grep 比按行号可靠;行号 as-of sourceCommit

主题文件路径(:行)符号名
组装:加载 profile 与其 bundle 层packages/boot/app-boot/src/profile.ts:371loadProfile
组装:把 patch 层合成 entry 列表packages/boot/app-boot/src/profile.ts:413composeEntries
组装:四层叠加顺序apps/cli/src/profile-boot.ts:142composeProfile
组装:shipped 模板的 bundle 列表packages/boot/app-boot/src/profile.ts:114PROFILE_TEMPLATES
组装:base 层实际插入的行packages/bundle/base/cordis.patch.yml:15insert:
日志:会话对象与只追加日志packages/core/session/src/index.ts:425Session
日志:写入契约(JSON 校验 + surface 标记)packages/core/session/src/index.ts:604Session.append
日志:派生模型历史packages/core/session/src/index.ts:726Session.deriveMessages
日志:事件类型总表packages/core/session/src/types.ts:236SessionEventMap
日志:哪些事件能进 surfacepackages/core/session/src/surface.ts:15SURFACE_EVENT_TYPES
日志:单事件 → 单消息的投影规则packages/core/session/src/surface.ts:83deriveEventMessage
主循环:驱动器本体packages/core/agent-loop/src/agent.ts:64ReactLoopAgent
主循环:一个 turnpackages/core/agent-loop/src/agent.ts:246ReactLoopAgent.turn
主循环:一个 steppackages/core/agent-loop/src/agent.ts:332ReactLoopAgent.step
主循环:组一次模型请求packages/core/agent-loop/src/agent.ts:426ReactLoopAgent.buildRequest
主循环:输入队列的两条通道packages/core/agent/src/inbox.ts:25InboxInbox.claim
主循环:工厂、生命周期与回滚packages/core/agent-loop/src/index.ts:296AgentLoopAgentLoop.prepare
工具:注册表服务packages/core/tools/src/index.ts:787ToolRuntime
工具:注册(全局或 agent 作用域)packages/core/tools/src/index.ts:1037ToolRuntime.register
工具:执行入口packages/core/tools/src/index.ts:1342ToolRuntime.execute
工具:三段式扩展点packages/core/tools/src/index.ts:152tools/pre-executetools/executetools/post-execute
工具:模型可见的 schema 白名单packages/core/tools/src/index.ts:1234ToolRuntime.schemas
工具:并行/串行调度packages/core/agent-loop/src/tool-calls.ts:59executeToolCalls
工具:Code Mode 传输packages/core/tools/src/code-mode.ts:20RUN_CODE_NAME
接缝:模型适配器注册表packages/llm/llm/src/index.ts:311LlmRuntimeLlmRuntime.registerAdapter
接缝:请求前解析适配器默认值packages/llm/llm/src/index.ts:824LlmRuntime.prepareCall
接缝:远程执行世界(fs + subprocess)packages/e2b/README.mddsh-fs-e2bdsh-subprocess-e2b
每会话组装:agent 作用域packages/core/scope/src/index.ts:137createScopescopeParentOf
每会话组装:挂载一个 presetpackages/preset/agent-presets/src/mount.ts:332mountPreset
提示词:分节装配packages/core/system-prompt/src/index.ts:467SystemPrompt.assemble
外壳:CLI 启动apps/cli/src/profile-boot.ts:207runProfile
外壳:模式 bundle 的差异packages/bundle/web-app/cordis.patch.ymlpackages/bundle/headless/cordis.patch.ymlinsert: / 行覆盖

上游自带的参考文档(作为事实来源,不作为写作规范)

docs/architecture.md(架构总览)、docs/agent-lifecycle.md(时序图)、docs/tool-execution-pipeline.md(工具流水线)、docs/capability-seams.md(能力接缝图,由脚本生成)、docs/config-catalog.md(配置字段总表)、docs/cordis-primer.md(Cordis 入门)。