数据截至 (上游 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:413 的 composeEntries。两个 shipped 模板 web / headless 各自的 bundle 列表在 packages/boot/app-boot/src/profile.ts:114 的 PROFILE_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:332 的 ReactLoopAgent.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.sessions | append-only 事件日志 + 内存会话表 | packages/core/session/src/index.ts |
ctx.agentLoop | 默认驱动器:开 turn、跑 step、发请求、调工具 | packages/core/agent-loop/src/agent.ts |
ctx.agents | Agent 接口与活跃 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 主线走一遍(高层,不进代码)
- 组装。
dsh --profile web先把 profile 的四层 patch 叠成一份 entry 列表,Cordis 按列表挂起整棵插件树(apps/cli/src/profile-boot.ts:142)。 - 开会话。 某个前端(Web / headless / ACP)向
ctx.agentLoop要一个 Agent;工厂在发布前先prepare()出驱动器和它自己的 scope,再进注册表(packages/core/agent-loop/src/index.ts:459)。 - 收输入。 用户的话进 Inbox。
followup排在 next-turn、steer排在 next-step 且唤醒、inject排在 next-step 但不唤醒(packages/core/agent-loop/src/agent.ts:122-132)——注入的上下文会一直躺着,等下一条真消息把它带进去。 - 一个 turn。 循环先 append
turn/start,claim 输入,装配提示词,过agent/pre-step瀑布(插件可以改写甚至拒绝这批消息),然后进 step(agent.ts:246)。 - 一个 step。 从日志投影出历史 → 组一份冻结的请求并把请求头 append 进日志 → 调适配器流式取回 → 每个 chunk 都 append → 装配成 assistant/message。
- 工具。 模型要调工具就交给调度器:标注了并发安全的工具进有界并行池,其余串行(
packages/core/agent-loop/src/tool-calls.ts:59);每个调用走tools/pre-execute → tools/execute → tools/post-execute三段瀑布,结果 append 成tool/result,再进下一个 step。 - 收尾。 没有工具再欠请求、Inbox 也没有 next-step 输入,turn 就 append
turn/end关闭。
3. 阅读地图
建议按顺序读:1 → 2 → 3 是主干(组装 → 数据 → 控制),4 → 5 → 6 是展开(手脚 → 可替换性 → 产品化)。
| 顺序 | 章节 | 读完你会知道 |
|---|---|---|
| 0 | DeepSeek 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/message、assistant/message、tool/result(packages/core/session/src/surface.ts:15),而且这三种在 append 时必须带 surfaceOp 标记,类型系统会强制这一点;其他类型带了标记反而报错(packages/core/session/src/index.ts:604 的 append 签名)。于是「新增一种模型能看到的输入」这件事,在编译期就被逼成「新增一个会话事件」。
③ 压缩上下文不是删历史,是往日志里追加一条「遮蔽」记录。 因为日志只追加,压缩(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.fs 和 ctx.subprocess 两个接缝一旦指向 E2B 远程沙箱,dsh-bash-local、dsh-terminal-bash、dsh-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.md、packages/preset/agent-presets/src/mount.ts:332)。
⑦ 注册即效果,卸载即回滚。 所有贡献都走 ctx.effect() / ctx.on(),注册函数返回 disposer;插件卸载时它注册过的工具、提示词分节、事件监听自动消失。Agent 的生命周期把这条推到极致:teardown 在发布之前就注册好并被记忆化,所以「装到一半被卸载」也能完整回滚(packages/core/agent-loop/src/index.ts:494-520)。
⑧ 配置能改的东西,和配置只能看的东西,分得很清楚。 agent-loop 的 maxParallelToolCalls 做成 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:371 | loadProfile |
| 组装:把 patch 层合成 entry 列表 | packages/boot/app-boot/src/profile.ts:413 | composeEntries |
| 组装:四层叠加顺序 | apps/cli/src/profile-boot.ts:142 | composeProfile |
| 组装:shipped 模板的 bundle 列表 | packages/boot/app-boot/src/profile.ts:114 | PROFILE_TEMPLATES |
| 组装:base 层实际插入的行 | packages/bundle/base/cordis.patch.yml:15 | insert: |
| 日志:会话对象与只追加日志 | packages/core/session/src/index.ts:425 | Session |
| 日志:写入契约(JSON 校验 + surface 标记) | packages/core/session/src/index.ts:604 | Session.append |
| 日志:派生模型历史 | packages/core/session/src/index.ts:726 | Session.deriveMessages |
| 日志:事件类型总表 | packages/core/session/src/types.ts:236 | SessionEventMap |
| 日志:哪些事件能进 surface | packages/core/session/src/surface.ts:15 | SURFACE_EVENT_TYPES |
| 日志:单事件 → 单消息的投影规则 | packages/core/session/src/surface.ts:83 | deriveEventMessage |
| 主循环:驱动器本体 | packages/core/agent-loop/src/agent.ts:64 | ReactLoopAgent |
| 主循环:一个 turn | packages/core/agent-loop/src/agent.ts:246 | ReactLoopAgent.turn |
| 主循环:一个 step | packages/core/agent-loop/src/agent.ts:332 | ReactLoopAgent.step |
| 主循环:组一次模型请求 | packages/core/agent-loop/src/agent.ts:426 | ReactLoopAgent.buildRequest |
| 主循环:输入队列的两条通道 | packages/core/agent/src/inbox.ts:25 | Inbox、Inbox.claim |
| 主循环:工厂、生命周期与回滚 | packages/core/agent-loop/src/index.ts:296 | AgentLoop、AgentLoop.prepare |
| 工具:注册表服务 | packages/core/tools/src/index.ts:787 | ToolRuntime |
| 工具:注册(全局或 agent 作用域) | packages/core/tools/src/index.ts:1037 | ToolRuntime.register |
| 工具:执行入口 | packages/core/tools/src/index.ts:1342 | ToolRuntime.execute |
| 工具:三段式扩展点 | packages/core/tools/src/index.ts:152 | tools/pre-execute、tools/execute、tools/post-execute |
| 工具:模型可见的 schema 白名单 | packages/core/tools/src/index.ts:1234 | ToolRuntime.schemas |
| 工具:并行/串行调度 | packages/core/agent-loop/src/tool-calls.ts:59 | executeToolCalls |
| 工具:Code Mode 传输 | packages/core/tools/src/code-mode.ts:20 | RUN_CODE_NAME |
| 接缝:模型适配器注册表 | packages/llm/llm/src/index.ts:311 | LlmRuntime、LlmRuntime.registerAdapter |
| 接缝:请求前解析适配器默认值 | packages/llm/llm/src/index.ts:824 | LlmRuntime.prepareCall |
| 接缝:远程执行世界(fs + subprocess) | packages/e2b/README.md | dsh-fs-e2b、dsh-subprocess-e2b |
| 每会话组装:agent 作用域 | packages/core/scope/src/index.ts:137 | createScope、scopeParentOf |
| 每会话组装:挂载一个 preset | packages/preset/agent-presets/src/mount.ts:332 | mountPreset |
| 提示词:分节装配 | packages/core/system-prompt/src/index.ts:467 | SystemPrompt.assemble |
| 外壳:CLI 启动 | apps/cli/src/profile-boot.ts:207 | runProfile |
| 外壳:模式 bundle 的差异 | packages/bundle/web-app/cordis.patch.yml、packages/bundle/headless/cordis.patch.yml | insert: / 行覆盖 |
上游自带的参考文档(作为事实来源,不作为写作规范)
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 入门)。