一、引言
DeepSeek Harness 没有把模型调用、工具执行、上下文压缩和持久化都塞进一个不断膨胀的循环。它保留一条稳定的 Agent Loop,再把容易变化的部分交给插件。用户输入 ↓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 在什么时刻调用插件来完成模型请求和工具执行。想查 Agent、Session、LLM、Tools 的函数与事件,看第三章;想理解一次 Turn 怎样调用插件,看第四章和主流程图;
二、启动链路先建立 Host 基础插件树
2.1 npx @deepseek-ai/dsh web 如何进入 web Profile
按照官方文档,启动 Web UI 只需要一条命令: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 树
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,也没有注册任何工具或事件。Entry 中的插件包名 → Loader 加载 npm 模块 → 等待它依赖的服务可用 → 执行插件 → 插件把能力注册进 Cordis Context
| |
|---|
dsh-llm | |
dsh-session | |
dsh-agent | |
dsh-tools | |
dsh-system-prompt | |
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 类型,而是通过它注册和消费的能力参与协作。type: agenttype: sessiontype: llm
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() | |
create() | |
resume() | |
register() | 把已创建的 Agent 发布到运行时 Registry |
dsh-agent-loop 注册为 Agent Factory,并负责创建 ReactLoopAgent。 | | |
|---|
agent/created | | |
agent/disposed | | |
agent/session-start | | |
agent/pre-step | | 决定当前 Step 是否进入,以及模型看到哪些输入 |
agent/request | | |
agent/request-error | | |
agent/turn-stopping | | |
agent/status | | |
3.3 Session 事件日志是模型上下文的事实来源
dsh-session 创建 ctx.sessions,同时定义 SessionStore 和单个 Session。 | | |
|---|
SessionStore | create() | |
SessionStore | prepare() | |
SessionStore | flush() | |
SessionStore | fork() | |
Session | append() | |
Session | deriveMessages() | |
| | |
|---|
session/created | | |
session/disposed | | |
session/event | | |
session/flush | | |
Session 日志是模型上下文的来源。凡是进入模型请求的数据,都必须能够从 Session 日志重建。3.4 LLM Runtime 把请求路由到具体 Adapter
| |
|---|
registerAdapter() | 注册 Provider 到 Adapter 的路由 |
prepareCall() | 解析 Provider、Model 和 Adapter 默认参数 |
stream() | 进入 LLM Waterfall 并调用最终 Adapter |
LlmAdapter.stream(options)
llm-deepseek 插件 → ctx.llm.registerAdapter(['deepseek'], adapter) → LlmRuntime Adapter Registry → prepareCall({ provider: 'deepseek', model }) → preparedCall.stream(request) → llm/stream Waterfall → DeepSeekAdapter.stream()
| | |
|---|
llm/stream | | |
llm/adapters-updated | | |
模型请求失败后,Agent Loop 发布 agent/request-error。llm-retry 和 compaction-basic 都通过这个事件参与错误恢复。3.5 Tools 把一次调用拆成 prepare、dispatch 和 finalize
| |
|---|
register() | |
schemas() | |
execute() | |
prepare() | |
dispatch() | |
finalize() | |
ToolDefinition.execute(args, context)
| | |
|---|
tools/pre-execute | | |
tools/execute | | |
tools/post-execute | | |
tools/result | | |
tools/change | | |
tools/code-dispatch-log | | |
3.6 System Prompt 动态汇总多个插件的贡献
dsh-system-prompt 创建 ctx.systemPrompt。 | |
|---|
section() | |
context() | |
variable() | |
tools() | |
assemble() | |
| | |
|---|
system-prompt/assemble | | |
system-prompt/change | | |
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-basic | agent/pre-step | |
compaction-basic | agent/request-error | |
llm-retry | agent/request-error | |
session-checkpoint-policy | agent/pre-step | |
session-checkpoint-policy | llm/stream | |
session-checkpoint-policy | tools/execute | |
timeout-policy | tools/execute | |
spill-policy | tools/post-execute | |
repeat-tool-reminder | tools/post-execute | |
session-persistence | session/event | |
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
一个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,以及这种“自我改造”离真正可用还有多远。