DeepSeek Harness 源码解读(六):Provider、Consumer 与能力接缝
本文讨论 DeepSeek Harness 最容易被低估的一项设计:模型、Shell、Web、子代理等能力为什么可以被替换,而调用它们的工具和 Agent Loop 不需要跟着重写。答案不在某个抽象基类,而在一条由 Service Definition、Provider、Consumer 共同组成的能力接缝。
项目地址:https://github.com/deepseek-ai/deepseek-harness
源码基线:仓库版本 0.1.0-rc.5,重点是 packages/llm、packages/shell、packages/web、packages/subagent 及其测试。
一、本章要回答的问题
"可替换"常被简化成"定义一个 interface,然后写两个实现"。在 Agent 运行时,这远远不够。替换一个模型后,路由、重试、请求上下文和模型发现是否仍然一致?把本地 Bash 换成沙箱 Bash 后,工具是否还会暴露同一 schema?Web 搜索有多个供应商时,注册顺序是否会偷偷决定行为?子代理在进程内、fork 进程和 ACP 之间切换时,父侧是否必须了解子侧生命周期?
DeepSeek Harness 的做法是把每项能力拆成三个角色:Service Definition 定义调用者依赖的接口和稳定词汇;Service Provider 把具体后端注册到这个接口;Consumer 只使用 Definition,不导入 Provider。Cordis 上下文是组装点,配置决定加载哪些 Provider,Consumer 看到的始终是能力,而不是实现。
二、核心结论
能力接缝不是"抽象类越多越好",而是一次明确的职责划分:
- Definition 规定输入、输出、错误、取消和选择语义,拥有
ctx.<capability>的服务键。 - Provider 注册一个可用实现,负责外部协议、进程、凭据或线程细节,并返回 disposer。
- Consumer 面向模型或 Agent,调用
ctx.<capability>,把结果投影成工具、提示词或会话事件。
接缝的质量由两点决定。第一,Definition 要足够窄,能让多个实现都自然满足;第二,Provider 选择必须在拥有者处明确完成,不能让 Consumer 从注册顺序、模块副作用或"第一个可用项"推断。当前实现里,LLM 允许一个适配器服务多个 provider route,Shell 每个上下文只允许一个 executor,Web 在每类操作下允许多个 provider 但无配置时要求恰好一个可用,Subagent 则允许命名 Provider 并存,由调用方选择。
三、相关包与源码入口
| 能力 | Definition | Provider 示例 | Consumer 示例 |
|---|---|---|---|
| LLM | packages/llm/llm/src/index.ts 的 LlmRuntime、LlmAdapter |
llm-deepseek、llm-pi-ai |
Agent Loop 的 ctx.llm.prepareCall()/stream() |
| Shell | packages/shell/shell/src/index.ts 的抽象 ShellExecutor |
bash-local、bash-sandbox、pwsh-local |
packages/shell/tool-bash/src/index.ts |
| Web | packages/web/web/src/index.ts 的 WebRuntime |
web-search-exa、web-search-perplexity、web-fetch-http |
packages/web/tool-web/src/search.ts、fetch.ts |
| Subagent | packages/subagent/subagent/src/index.ts 的 SubagentRuntime |
subagent-spawn-in-process、subagent-fork-in-process、subagent-acp |
tool-subagent、tool-subagent-control |
相关 README 也把三类角色分开描述:packages/web/web/README.md 说明本包是 Definition;packages/web/tool-web/README.md 明确"不导入具体 provider";packages/subagent/subagent/README.md 说明 Provider 与模型侧 Consumer 是独立包。测试则验证注册冲突、选择错误和 disposer 生命周期,例如 packages/web/web/tests/web.spec.ts 覆盖多 provider 歧义,packages/llm/llm/tests/topology.spec.ts 覆盖适配器拓扑变更。
四、LLM:同一个请求面对的是 route,不是实现类
4.1 Definition 拥有稳定的流协议
LlmAdapter 的唯一必需方法是 stream(options),输入使用 DeepSeek Harness 自己的 GenerateOptions 和 StreamChunk,不把某个 SDK 的 response 类型泄漏给 Agent Loop。适配器还可以提供 providerInfo、providerRetryPolicy、listModels 和 resolveModel,但这些是能力元数据,不改变消费者调用方式。
LlmRuntime 通过 ctx.llm.registerAdapter(providers, adapter) 注册 route。注册是全量校验、全量提交:provider 名称不能为空,重复 route 抛 DUPLICATE_ADAPTER,元数据 id 必须与 route 一致。注册返回的 handle 既能 dispose,也能 replace(nextProviders),替换前先验证候选集合,切换时一次同步提交,观察者不会看到"先清空再注册"的空窗。
ts
const handle = ctx.llm.registerAdapter(['deepseek-official'], adapter)
handle.replace(['deepseek-official', 'compatible-gateway'])
这个接口的关键不是 Map<string, adapter>,而是 route 归属和原子替换。Agent Loop 只把 provider、model 和 sampling 字段写入 LlmCallConfig,再由 runtime 根据当前 route 做 prepareCall()。准备阶段会捕获 exact adapter registration、重试策略、模型上下文和适配器补全的默认字段,防止热重载在同一请求中把元数据和 stream 拼成两代实现。
4.2 两个 Provider 可以完全不同
llm-deepseek 直接使用 fetch 和 SSE parser;llm-pi-ai 通过 @earendil-works/pi-ai 动态解析 provider/model。它们的网络库、认证和流解析不同,但都把原始结果转成 StreamChunk,最终由 BlockAssembler 组装。消费者不需要为"官方 API"和"兼容网关"各写一条主循环。
流式 API 还规定了失败语义:适配器抛异常或返回 failure finish,runtime 都归一成一个终止分片;llm/stream waterfall 可以做重放、路由、日志和包装。模型目录是 advisory,适配器可以接受未列出的动态 model,Consumer 不能把"目录里没有"当成拒绝理由。
五、Shell:一个服务键,两套执行世界
ShellExecutor 是一个抽象 Service,不是 bash-local 的接口别名。它定义 resolve(request)、run(spec)、start(spec) 和 sandboxMode。请求和执行 spec 被明确拆成两步:Consumer 可以只提供 command 和可选 override,Provider 在 resolve() 中补工作目录、超时、输出上限和 sandbox policy;run() 只接受完整 spec。
当前约束是一个上下文只能有一个 ctx.shell Provider,加载第二个服务会触发 Cordis 的重复服务错误。这个约束使 tool-bash 不必决定"本地还是沙箱",但也意味着平台组合要在配置层选择正确的一行。bash-local 和 bash-sandbox 对同一 ShellRunResult 负责,区别在执行边界和返回的 sandbox facts;非零退出、超时和 caller abort 都是 resolved result,只有基础设施错误 reject。
这种单实现策略并不等于不可替换:Profile 在 POSIX 与 Windows 上分别装配 bash-local/bash-sandbox 或 pwsh-local/pwsh-sandbox,而工具包始终只读取 ctx.shell。替换发生在插件树,不发生在工具代码。
六、Web:多 Provider 共存,但选择不能靠顺序
Web Definition 展示了另一种接缝。WebRuntime 同时拥有 search 和 fetch 两类注册表,每类都允许多个 provider。每个 Provider 必须有唯一 id 和 available();Consumer 调用 ctx.web.search(request, signal) 或 fetch(request, signal)。
选择规则被写在 Definition 内部:配置了 id 时,未注册抛 WEB_PROVIDER_CONFIGURED_MISSING,已注册但不可用抛 WEB_PROVIDER_CONFIGURED_UNAVAILABLE;未配置时,恰好一个可用 Provider 才能自动选择,零个抛 WEB_PROVIDER_UNAVAILABLE,多个抛 WEB_PROVIDER_AMBIGUOUS。因此 Exa 和 Perplexity 的加载顺序不会改变生产结果。
tool-web 只关注模型侧的工具名、JSON Schema、结果格式和 UI presentation。它不 import web-search-exa,而是把 exec.signal 转交给 ctx.web。Provider 不可用时,seam 抛结构化 WebError,Tool Runtime 再把它转成模型可读的错误结果;工具 schema 可以保持稳定,不需要因为 credentials 尚未配置就从 prompt 中消失。

七、Subagent:把生命周期也藏在接缝之后
子代理比 Shell 和 Web 更能说明"能力接口必须包含生命周期"。SubagentRuntime 允许多个命名 Provider 并存,调用方用 ctx.subagents.start(name, request) 选择一个。Provider 返回已发布的 SubagentRun;runtime 负责生命周期事件 subagent/start、subagent/end、深度限制和观察。Provider 不需要暴露 AgentHandle 给 Consumer。
当前 Provider 包括同进程 spawn、fork、ACP 等不同执行方式。startContinuable() 还将"创建 durable child"和"发送初始 prompt"定义成一次发布边界;后续 followup() 通过 child inbox 排序,冷恢复时读取 Session,不要求 Provider 常驻。Consumer 因此只需处理 child id、结果和错误,不需要知道子代理是在 worker、子进程还是远端 ACP。
子代理接缝的代价是契约更严格:Provider 必须在 fulfillment 前建立 child ownership;调用者取消、最大深度和 delegated policy 必须在 Definition 中表达;一个已经返回的 run 不能因为 Provider 被卸载就被强行撤销。packages/subagent/subagent/tests/continuation.spec.ts 的大量用例正是在锁定这些生命周期性质,而不是测试某个具体 transport。
八、亮点、约束与代价
8.1 亮点:替换点位于配置和注册表
Provider 的切换不会复制 Agent Loop,也不会改模型可见的工具 schema。配置可以让同一 Consumer 在无网络的 snapshot 测试中连接 replay adapter,在真实运行时连接 DeepSeek adapter;Shell 工具测试可以接 local executor,集成测试再挂 sandbox executor。
8.2 约束:选择语义必须在 Definition 内可判定
"恰好一个可用"是 Web 的正确规则;"一个上下文只有一个"是 Shell 的正确规则;"命名 route 独占且原子替换"是 LLM 的正确规则;"命名 Provider 并存"是 Subagent 的正确规则。不能为了形式统一,把所有能力都改成"注册数组 + 第一个获胜"。一致的不是容器,而是可解释、稳定的选择结果。
8.3 代价:接缝增加装配和错误分类
Provider、Consumer、Definition 分包后,开发者需要理解加载顺序、disposer 和可用性检查;服务缺失、重复注册、配置歧义都必须有明确错误。这个成本换来的是 blast radius 可控:替换 provider 只影响执行世界,模型历史、工具调用链和 UI 投影仍使用同一套稳定词汇。
九、扩展方式:添加一个新的 Provider 或 Consumer
新增 Provider 时,先读取 Definition 的 README 和类型;实现所有取消、错误和返回值要求;通过 ctx.effect() 注册,使用结构化错误码;用注册/卸载、重复 id、不可用和取消测试固定选择行为。不要在 Provider 中偷偷添加第二套配置优先级,环境变量必须映射到 Definition 已有的 config 字段。
新增 Consumer 时,只依赖 @deepseek-ai/dsh-<capability> Definition;不要 import 具体 Provider;在调用前完成明确的 resolve(request): Spec,不要在 run() 内用隐藏 ?? default;把模型可见 schema、持久化事件和 UI projection 与 Provider 的运行细节分开。若新能力需要多种运行方式,先决定是一个 Definition 下的多 Provider,还是多个独立能力,避免把本来独立的职责塞进一个万能 service。
十、小结与下一篇衔接
DeepSeek Harness 的可替换性来自"能力接缝",而不是抽象层数量:Definition 拥有稳定协议和选择规则,Provider 负责具体世界,Consumer 只使用能力。LLM 通过 route 捕获适配器,Shell 通过唯一 service key 互换执行器,Web 通过确定性选择管理多供应商,Subagent 把发布、取消和冷恢复也纳入契约。
替换底层能力后,什么东西必须保持不变?答案是会话日志中的可重建事实。下一篇将分析 Session 的追加事件、消息投影、分叉与持久化,解释为什么"模型可见内容必须已经被记录"是这些接缝能够长期演进的前提。