乐于分享
好东西不私藏

DeepSeek Harness 源码拆解:连 Agent Loop 都是插件,它到底怎么跑起来?

DeepSeek Harness 源码拆解:连 Agent Loop 都是插件,它到底怎么跑起来?

一、引言

DeepSeek Harness 没有把模型调用、工具执行、上下文压缩和持久化都塞进一个不断膨胀的循环。它保留一条稳定的 Agent Loop,再把容易变化的部分交给插件。
最常见的 Agent 循环如下:
用户输入  ↓LLM  ├─ 无 tool_calls → 最终答案  └─ 有 tool_calls → Tool → Tool Result                            ↓                         下一次 LLM
这张图解释了 Agent 在做什么,却没有回答工程实现中更麻烦的问题:谁选择模型,谁组装上下文,工具执行前如何做权限检查,请求失败后在哪里重试,消息又怎样同时送往持久化和 UI。
DeepSeek Harness,简称 dsh,给出的答案是:everything is a plugin。LLM、Session、Agent、Tools、System Prompt 和 Agent Loop 本身都是 Cordis 插件。
DeepSeek Harness 把这部分复杂性拆成了两层:一条只负责推进 Turn 和 Step 的稳定 Loop,以及一组负责模型、工具、上下文、压缩、权限和持久化的插件。
Agent 的复杂性没有消失;它只是从中央循环移到了具有明确调用位置和生命周期的扩展点。
源码调用关系分为两条线:CLI 如何把插件装入运行时,以及一次 Turn 在什么时刻调用插件来完成模型请求和工具执行。
全文可以按需跳读:
只想理解 dsh 怎样启动和装配插件,看第二章;
想查 Agent、Session、LLM、Tools 的函数与事件,看第三章;
想理解一次 Turn 怎样调用插件,看第四章和主流程图;
想理解插件化的设计取舍与多模式能力,看第五章。

二、启动链路先建立 Host 基础插件树

2.1 npx @deepseek-ai/dsh web 如何进入 web Profile

按照官方文档,启动 Web UI 只需要一条命令:
npx @deepseek-ai/dsh web
npx 会先读取 package.json 中的 bin 声明,找到真正要执行的 lib/bin.js。它是发布到 npm 的构建产物,对应源码里的apps/cli/src/bin.ts。CLI 看到参数web后,又会把它转换成 profile: "web"。
也就是说,web不是一套独立的启动逻辑,而是--profile web的快捷写法。
从 CLI 到 Agent Loop 的主干如下:
从启动入口、Host 插件装配到 Agent 运行的完整链路。
参数解析完成后,npm 包和源码启动都会进入同一套runProfile()逻辑。它不会直接创建 Agent 或监听 HTTP 端口,而是先读取 web 这套启动配置,收集需要依次应用的配置层,再把根配置和这些修改交给boot()。
boot() 创建 Cordis 运行环境,由 Include 应用这些修改并生成 Host 插件清单,Loader 再启动清单中的插件。web Profile 最终会装入 Web Server、API 和浏览器前端,所以可以通过 http://127.0.0.1:3080 访问;换成 Headless Profile,同一套启动逻辑装入的就是命令行 Runner。
Profile 决定进程启动时的 Host 基础组合,但它不是运行期不可变化的静态清单;Loader 可以继续响应配置更新,每个 Agent 还会叠加自己的 Preset。

2.2 Profile 只选择 Bundle,具体插件写在 Patch 里

runProfile() 拿到 web 之后,不会立即创建 Agent。它先读取 web Profile 的配置文件:$DSH_HOME/profiles/web/package.json。未设置 DSH_HOME 时,默认路径是 ~/.dsh/profiles/web/package.json。
文件中的 dsh.profile.bundles 字段声明了两个插件装配包(Bundle):
{  ”dsh”: {    ”profile”: {      ”bundles”: [        ”@deepseek-ai/dsh-base”,        ”@deepseek-ai/dsh-web-app”      ]    }  }}
Profile 只记录采用哪些 Bundle,不需要逐个列出插件。真正把 Agent、Session、LLM、Tools 等插件写进清单的,是每个 Bundle 携带的cordis.patch.yml。
web Profile  ├─ dsh-base  │   └─ packages/bundle/base/cordis.patch.yml  │       └─ Agent / Session / LLM / Tools / System Prompt / Agent Loop  │  └─ dsh-web-app      └─ packages/bundle/web-app/cordis.patch.yml          └─ Web Server / API / Browser UI两份 Patch 依次应用  → Profile、Home 和命令行 Patch 继续修改  → 最终 Host Entry 树
名称
含义
Bundle
携带一组插件装配规则的 npm 包
Patch
对插件清单执行插入、替换或禁用的配置补丁
Entry
插件清单中的一条记录,包含插件包名及其配置
dsh-base 的配置补丁包含以下核心 Entry(查看源码):
- insert:    - id: llm      name: '@deepseek-ai/dsh-llm'    - id: session      name: '@deepseek-ai/dsh-session'    - id: agent      name: '@deepseek-ai/dsh-agent'    - id: tools      name: '@deepseek-ai/dsh-tools'    - id: system-prompt      name: '@deepseek-ai/dsh-system-prompt'    - id: agent-loop      name: '@deepseek-ai/dsh-agent-loop'
dsh-web-app 的配置补丁随后修改部分基础 Entry,并插入 Web Server、API 和浏览器 UI 等插件(查看源码)。后面的 Profile Patch、Home Patch 和命令行 --patch 还可以继续修改结果;如果按相同 id 替换配置,新的 config 会整体替换旧值,而不是自动合并。
Bundle 解决“默认装哪些插件”,Patch 解决“怎样修改这套默认组合”,Entry 则回答“最终准备启动哪个插件”。
这棵 Host Entry 树只是进程级基础组合。Agent Preset 不进入这棵树;Web 创建 Agent 时才挂接 Preset,为该 Agent 增加 Tool、Prompt、Skill 和策略。

2.3 Loader 把 Entry 变成运行中的插件

Patch 合成结束后,每个 Entry 只有 name、config 等配置,还没有创建 ctx.llm,也没有注册任何工具或事件。
Cordis Loader 接手后,流程很短:
Entry 中的插件包名  → Loader 加载 npm 模块  → 等待它依赖的服务可用  → 执行插件  → 插件把能力注册进 Cordis Context
这些 Entry 激活后的运行时结果如下:
Entry 对应的插件
插件执行后的结果
dsh-llm
创建 ctx.llm
dsh-session
创建 ctx.sessions
dsh-agent
创建 ctx.agents
dsh-tools
创建 ctx.tools
dsh-system-prompt
创建 ctx.systemPrompt
dsh-agent-loop
注册 AgentFactory,由它创建 ReactLoopAgent
compaction-basic
订阅 agent/pre-step 等事件,在请求前执行压缩策略
“注册”分为两个阶段:cordis.patch.yml 先把 npm 包写成 Entry,表示它应该进入运行时;Loader 执行插件后,插件代码才真正创建 Service、注册实现或者订阅事件。
插件的激活时机由依赖是否可用决定,不由 Patch 中的书写顺序决定。Profile 或 Home 的 Patch 发生变化时,Loader 还可以卸载受影响的旧插件,再按新 Entry 激活它们。
配置决定哪些插件进入运行时,插件代码决定它们进入以后提供什么能力。
Agent、Session、LLM、Tools 和 System Prompt 都先由 dsh-base 的 Patch 写成 Entry,再由 Loader 执行并注册到 Context 中。

三、插件由角色和能力域共同分类

3.1 Agent、Session 和 LLM 不是插件类型枚举

插件进入运行时后,不会被固定标记为 Agent、Session 或 LLM 类型,而是通过它注册和消费的能力参与协作。
dsh 不会要求一个插件声明:
type: agenttype: sessiontype: llm
更准确的分类方式有两个维度:插件角色和能力域。
第一个维度是插件角色:
角色
作用
Service Definition
定义 ctx.xxx 服务和公共 API
Service Provider
为服务注册具体实现
Consumer
调用服务,向模型暴露能力
Middleware
订阅事件或 Waterfall,修改运行过程
Bundle 不属于运行时角色。它只是启动前复用插件装配规则的发布形式。
第二个维度是能力域,例如 Agent、Session、LLM、Tools 和 System Prompt。
一个插件可以同时扮演多个角色。例如 tool-bash 既向 ctx.tools 注册工具,也向 System Prompt 注册 Bash 使用说明。

3.2 Agent Registry 分离 Agent 身份与 Loop 实现

dsh-agent 创建 ctx.agents,核心对象是 AgentRegistry。
主要函数:
函数
作用
setFactory()
注册 Agent 的创建和恢复实现
create()
创建新 Agent
resume()
从已有 Session 恢复 Agent
register()
把已创建的 Agent 发布到运行时 Registry
dsh-agent-loop 注册为 Agent Factory,并负责创建 ReactLoopAgent。
关键事件包括:
事件
模式
作用
agent/created
emit
Agent 发布成功
agent/disposed
emit
Agent 离开 Registry
agent/session-start
emit
Agent 开始使用 Session
agent/pre-step
waterfall
决定当前 Step 是否进入,以及模型看到哪些输入
agent/request
waterfall
调整模型请求配置
agent/request-error
waterfall
处理模型错误、重试或压缩
agent/turn-stopping
serial
Turn 即将停止
agent/status
emit
Agent 状态发生变化

3.3 Session 事件日志是模型上下文的事实来源

dsh-session 创建 ctx.sessions,同时定义 SessionStore 和单个 Session。
主要函数:
对象
函数
作用
SessionStorecreate()
创建并发布 Session
SessionStoreprepare()
准备一个尚未发布的 Session
SessionStoreflush()
等待持久化插件完成检查点
SessionStorefork()
从现有事件前缀创建子 Session
Sessionappend()
追加一个不可变 Session 事件
SessionderiveMessages()
从事件日志重建模型消息
关键事件:
事件
模式
作用
session/created
emit
Session 发布
session/disposed
emit
Session 被移除
session/event
emit
日志追加了新事件
session/flush
parallel
要求所有持久化 Listener 完成检查点
Session 日志是模型上下文的来源。凡是进入模型请求的数据,都必须能够从 Session 日志重建。

3.4 LLM Runtime 把请求路由到具体 Adapter

dsh-llm 创建 ctx.llm。
主要函数:
函数
作用
registerAdapter()
注册 Provider 到 Adapter 的路由
prepareCall()
解析 Provider、Model 和 Adapter 默认参数
stream()
进入 LLM Waterfall 并调用最终 Adapter
LLM Provider 需要实现:
LlmAdapter.stream(options)
例如 DeepSeek 插件的注册链路是:
llm-deepseek 插件  → ctx.llm.registerAdapter(['deepseek'], adapter)  → LlmRuntime Adapter Registry  → prepareCall({ provider: 'deepseek', model })  → preparedCall.stream(request)  → llm/stream Waterfall  → DeepSeekAdapter.stream()
关键事件:
事件
模式
作用
llm/stream
waterfall
在 Adapter 前增加持久化、重放或代理逻辑
llm/adapters-updated
emit
Adapter Registry 发生变化
模型请求失败后,Agent Loop 发布 agent/request-error。llm-retry 和 compaction-basic 都通过这个事件参与错误恢复。

3.5 Tools 把一次调用拆成 prepare、dispatch 和 finalize

dsh-tools 创建 ctx.tools。
主要函数:
函数
作用
register()
注册具体 ToolDefinition
schemas()
返回模型可见的 Tool Schema
execute()
执行完整工具管线
prepare()
解析参数并执行前置策略
dispatch()
执行工具 Body
finalize()
执行后处理并物化最终结果
每个具体工具至少提供:
ToolDefinition.execute(args, context)
关键事件:
事件
模式
作用
tools/pre-execute
waterfall
allow、deny 或 ask
tools/execute
waterfall
包裹工具 Body,实现超时、检查点等能力
tools/post-execute
waterfall
接受、替换、增强或阻止工具结果
tools/result
emit
观察最终工具结果
tools/change
emit
工具集合发生变化
tools/code-dispatch-log
waterfall
修改 run_code 子调用的日志副本

3.6 System Prompt 动态汇总多个插件的贡献

dsh-system-prompt 创建 ctx.systemPrompt。
主要函数:
函数
作用
section()
注册稳定的 Prompt Section
context()
注册运行时上下文
variable()
注册动态变量
tools()
注册 Tool Schema Provider
assemble()
合并所有 Prompt 贡献
关键事件:
事件
模式
作用
system-prompt/assemble
waterfall
对组装结果进行最终变换
system-prompt/change
emit
Prompt Provider 发生变化
assemble() 的核心顺序是:
variable Providers → 解析变量  → section Providers  → context Providers  → tool Schema Providers  → 合并 Prompt 贡献  → system-prompt/assemble Waterfall  → PromptAssembly { sections, tools }
动态组装不等于无法重放。assemble() 产生的 System Prompt 和 Tool Schema 会在请求前写入 request/header,模型路由和上下文窗口写入 request/context;进入 Step 的消息也会追加为 Session 事件。因此,历史请求可以从日志还原,不必重新执行当时的 Prompt Provider;恢复后的新 Step 仍会按当前插件集合重新组装 Prompt。

3.7 策略插件通过 Waterfall 修改执行过程

Compaction、Retry、Timeout 和 Persistence 通常不创建模型可见能力,而是订阅现有事件。
插件
订阅事件
作用
compaction-basicagent/pre-step
根据 Token 压力执行压缩
compaction-basicagent/request-error
上下文超限后压缩并重试
llm-retryagent/request-error
按错误码和退避策略重试
session-checkpoint-policyagent/pre-step
Step 前执行 flush()
session-checkpoint-policyllm/stream
模型请求前执行 flush()
session-checkpoint-policytools/execute
顶层工具执行前执行 flush()
timeout-policytools/execute
为 Tool Body 增加 deadline
spill-policytools/post-execute
把过大结果保存到 Spill Artifact
repeat-tool-remindertools/post-execute
检测重复工具调用
session-persistencesession/event
session/flush
持久化事件日志

3.8 插件化把复杂性移出了 Loop,但没有消除复杂性

插件机制减少了 Agent Loop 中的条件分支,代价是完整调用关系不再集中在一个文件里。一次行为可能同时跨过 Profile Entry、服务注册、Provider Registry 和 Waterfall Listener,单看 agent-loop 无法还原全部过程。
Waterfall 的 Listener 顺序属于运行行为。
Listener 按注册顺序组成调用链,并且任何一个 Listener 都可以通过不调用 next() 提前结束链路。新增策略插件时,不仅要确认它订阅了哪个事件,还要确认它位于哪些 Listener 之前或之后,以及短路时会跳过什么。
完整调用图必须同时保留两侧:左侧只看 Loop 会漏掉插件策略,右侧只看注册函数又看不到触发时机。两边要在具体函数和事件上连起来。
插件化减少了 Loop 里的条件分支,却把正确性压力转移到了注册顺序、作用域和生命周期上。

四、一次 Turn 如何把插件串成完整模型循环

4.1 工具结果会驱动 Turn 进入下一个 Step

在ReactLoopAgent.turn()中:
一个Step是一次模型请求,以及这次请求产生的工具调用;
一个Turn由零个或多个 Step 构成;
工具结果通常会触发下一个 Step;
如果没有工具调用,也没有待处理的 next-step 输入,Turn 结束。
Agent 的 Inbox 分为 next-turn 和 next-step。普通 Follow-up 进入 next-turn;运行中的 steer()、inject() 以及工具返回的 additionalContexts 进入 next-step。一个 Turn 的第一个 preStep() 会领取全部 next-step 输入和一个 next-turn 输入,后续 preStep() 只领取新的 next-step 输入。仍在排队的next-turn不会延长当前 Turn,而是在当前 Turn 结束后启动另一个 Turn。
工具结果本身直接写入 Session,step() 的返回值负责驱动 Loop 继续,而不是把 Tool Result 再复制到 Inbox。

4.2 主流程在直接调用与 Waterfall 的交点上推进

下图把一次 Turn 放回完整运行时中。左侧是 Loop 推进 Turn 和 Step 的顺序,右侧是被调用的服务函数、Provider 和 Listener。连线落到具体函数,表示插件在哪个时刻获得控制权。
一次 Turn 中,Loop 的直接调用与插件 Waterfall 在具体函数处交汇。
主图包含五个需要跨插件理解的节点。

4.3 AgentFactory 把 Registry 与 ReactLoopAgent 接起来

ctx.agents.create()  → AgentRegistry.create()  → 已注册的 AgentFactory  → AgentLoop.createAgent()  → sessions.prepare()  → ReactLoopAgent
dsh-agent 提供 Registry 和公共创建入口,dsh-agent-loop 通过 setFactory() 注册具体实现。调用 ctx.agents.create() 时,Registry 找到已注册的 Factory;Factory 准备 Session,最后返回 ReactLoopAgent。

4.4 preStep 先组装 Prompt,再执行策略 Waterfall

inbox.claim()  → systemPrompt.assemble()  → agent/pre-step Waterfall      ├─ session-checkpoint-policy.flush() → next()      ├─ compaction-basic.compactIfNeeded() → next()      └─ instructions / skill / plan context → next()         (以上是 Listener 示例,实际顺序由注册顺序决定)  → PreStepDecision      ├─ reject → Turn blocked      └─ enter  → append(user/message) → Step
session-checkpoint-policy 在这里执行持久化检查点,compaction-basic 检查 Token 压力,agent-instructions、tool-skill 和 plan-mode 可以补充上下文。
preStep()先从 Inbox 领取消息并调用systemPrompt.assemble(),随后才进入agent/pre-stepWaterfall。
事件收到已领取的消息,以及当前 Turn、Step 和取消信号。链路末端生成 PreStepDecision:enter 会把运行时 Context 合入消息,reject 则阻止 Step 开始,将 Turn 记为 blocked。已领取的 Inbox 消息保持已消费状态,Loop 不会自动重新入队。
Listener 可以在调用 next() 前后调整这个决定。只有 enter 中的消息才会追加为 user/message,并进入后续模型请求。
这些 Listener 的实际顺序由注册顺序决定。图中连线表达调用关系,不把某组 Listener 描述为永久固定的业务顺序。

4.5 Session 派生消息,LLM Runtime 再选择 Adapter

session.deriveMessages()  → agent/request Waterfall  → llm.prepareCall()  → append(request/header + request/context)  → preparedCall.stream()  → llm/stream Waterfall  → Adapter.stream()  → append(assistant/chunk + assistant/message)
从 Step 边界到模型的完整顺序是:Inbox 领取消息,systemPrompt.assemble() 组装 Prompt,agent/pre-step 决定是否进入,消息写入 Session,deriveMessages() 重建历史,agent/request 调整 Provider 和 Model,prepareCall() 绑定 Adapter,buildRequest() 记录请求,最后进入 llm/stream 和 Adapter.stream()。
正常路径使用 preparedCall.stream():它固定 prepareCall() 已解析的 Adapter,避免热更新把一次请求的能力解析与发送落到不同 Adapter,但内部仍然进入同一个 llm/stream Waterfall。只有 prepareCall() 无法绑定 Adapter、而 Middleware 可能接管该路由时,Loop 才退回通用的 llm.stream()。
模型返回的 Chunk 和最终 AssistantMessage 也追加到 Session,因此恢复、重放、UI 和遥测可以从同一个事件流重建状态。
如果模型请求失败,Loop 不会立即退出 Step。它把错误交给 agent/request-error Waterfall:
模型返回 finish(error)  → agent/request-error Waterfall(按 Listener 注册顺序)      ├─ llm-retry:可重试 → backoff → { kind: 'retry' }      │             不处理 → next()      └─ compaction-basic:上下文超限 → compact → { kind: 'retry' }                         不处理     → next()         (两者的相对位置由注册顺序决定)  → retry,或抛出原始 LlmError
llm-retry 只处理 Provider 重试策略允许的错误,其余错误调用 next();compaction-basic 只处理上下文超限,其余错误同样向后传递。
任一 Listener 返回{ kind: 'retry' },Loop 都会重新构造请求。
两者谁先获得错误取决于注册顺序,Loop 不需要知道退避策略或压缩算法。

4.6 工具执行结果决定是否进入下一个 Step

工具执行的准确顺序是:
tool_calls(模型顺序)  → append(tool/call)  → prepare()  [tools/pre-execute]  → dispatch() [tools/execute → Tool Body,可并发]  → finalize() [tools/post-execute]  → append(tool/result,仍按模型顺序)  → 是否有 Result 声明 concludesTurn?      ├─ 否 → 下一 Step      └─ 是 → 先处理 next-step Inbox;为空时结束 Turn
只有 Tool Body 可以并发运行,工具调用和结果仍按模型原始顺序提交到 Session。
工具管线会为成功、失败或被策略拒绝的调用生成有序结果。正常完成的工具组没有 Result 声明 concludesTurn 时,step() 返回空结束原因,Loop 进入下一 Step。任一结果声明 concludesTurn 时,step() 标记完成;但已经进入 next-step Inbox 的附加上下文仍会先被处理,只有完成原因存在且 next-step Inbox 为空时,当前 Turn 才真正停止。模型本身不再产生 Tool Call 时,也会得到完成原因并走同一停止判断。

4.7 session/event 把同一条日志送往持久化和 UI

每次 Session.append() 都会触发:
Session.append(event)  → session/event      ├─ session-persistence → 持久化      ├─ session-projection  → 状态投影 / UI      ├─ token-meter         → Token 计量      └─ session-telemetry   → 遥测
Session 不是模型循环结束后的附属存储,而是 Loop、持久化、上下文恢复和 UI 共享的数据主干。
Loop 的输入、模型输出和工具结果都先成为 Session 事件,再由这些能力消费同一条事件流。
一次 Turn 能否被恢复和重放,不取决于 Loop 还记得什么,而取决于 Session 日志记录了什么。

五、总结:骨架已经搭好,生态尚未到来

拆到这里,DeepSeek Harness还不是成熟的 Agent 平台,更像一副骨架。Loop 只保留 Turn 和 Step,模型、工具、Prompt 与策略从插件接入。
它没有消灭复杂度,只是把改 Loop 的风险,换成装配、作用域和生命周期的管理成本。
它最大的价值,不是标准模式已经能做多少事,而是同一个 Loop 可以被重新组合成多少种 Agent。
标准、PTC、极简和创造模式共享 Host,却拥有不同的 Tool、Prompt、Skill 与策略。切换的不是一句 Prompt,而是一整套运行时能力。这种组合空间,就是它最吸引人的可玩性。
但骨架不等于生态。项目仍在早期,接口、调试、文档和第三方插件仍不成熟。如果社区没有持续补上 Provider、Tool、Preset 和场景方案,“everything is a plugin”就可能只剩一套漂亮但昂贵的抽象。
框架搭出了上限,生态才决定它能否长成产品。
下一篇,我会继续拆“创造模式”:Agent 如何检查自己的运行时、实验插件、生成 Preset,以及这种“自我改造”离真正可用还有多远。