ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DeepSeek Harness 架构拆解:没有“核心“的 Agent 运行时,“万物皆插件“是怎么做到的

DeepSeek Harness 架构拆解:没有“核心“的 Agent 运行时,“万物皆插件“是怎么做到的 DeepSeek 在 2026-08-13 开源了 DeepSeek Harnessdsh两天拿到 95K starMITTypeScriptv0.1.0-rc.5。它值得读的原因不是 star 数而是架构上的一个激进选择整个运行时没有 privileged core。模型适配器、工具注册表、会话日志、甚至 agent 循环本身全都是插件——每个部分都可以从配置层面替换。底层是一个 vendored 的插件框架 Cordis出自 cordiverse设计文档是一篇叫A Programming Paradigm for Spatiotemporal Composability的论文。这篇文章从它的源码文档拆四件事插件模型、事件系统、turn 执行流、capability seams外加一个工具插件的完整写法和它自己的测试设施。Cordis 的五条核心思想Cordis 的插件模型可以用五句话概括理解了这五句话整个 dsh 的代码结构就通了插件是一个实现 Service 的对象。可以是带可选inject/apply(ctx)字段的函数也可以是Service子类生命周期由 Cordis 挂载到当前 context。context 是服务的仓库。一个服务在 context 上占一个稳定的键ctx.tools、ctx.llm、ctx.sessions别的插件通过键找服务而不是 import 具体实现——这是可替换的地基。用inject声明服务依赖。插件声明需要的服务后要等这些服务存在了才会被激活加载顺序由服务依赖推导而不是手写 boot 序列。类型化事件做通信。服务通过 TypeScript declaration merging 声明事件名再按四种分发模式派发见下表。注册都是可逆的 effect。prompt 段、工具 schema、适配器、provider、监听器全部通过ctx.effect()/ctx.on()安装插件卸载时按注册顺序自动回滚。其中第 4 点值得展开。事件的分发模式是事件公共契约的一部分新事件要用mode标签标注生成的目录会校验声明和派发点是否一致模式是否 await分发顺序有返回值emit否按注册顺序观察无waterfall否按注册顺序可包裹/改写有parallel是全部并行观察无serial是按注册顺序有ctx.waterfall本质是 around-middleware监听器收到(...args, next)调用next()把可能被改写过的结果传给下一个服务不调next()直接 return 就是短路。策略类监听器比如权限门用短路来拍板只做注解/观察的监听器必须委托。dsh 里agent/pre-step、agent/request、llm/stream、三个tools/*事件都是 waterfall监听器不调next()就会断链。Profile 与 Bundle启动时的分层组合一个跑起来的dsh是一棵在启动时按有序分层组合出来的插件树。profile是命名组合存在 Harness home 目录里列出它叠了哪些 bundle、装了哪些树外插件、以及用户自己的cordis.patch.yml。web和headless是自带模板。bundle是 Cordis 配置行 它们挂载的代码的分发格式它插入的任何东西都能被上层 patch 覆盖。dsh-base模型适配器、工具、持久化、sandbox、审批策略、设置、凭据、遥测是每个 profile 的第一层dsh-web-app加浏览器应用dsh-headless加无 server 的一次性 runner。层叠顺序是profile 里列出的每个 bundle按列出顺序→ profile 的cordis.patch.yml→ home 级 patch → 命令行--patch覆盖。patch 按行 id 定位整行替换配置或插入新行。想看你机器上真正 boot 出来的树dsh --profile web --dump-config打印出来的每一行都可以用你自己的 patch 替换——这就是没有核心要打补丁的含义扩展 dsh 的方式是往插件树里再挂一个插件。Turn/Step 执行流一次模型交互的完整时序dsh 对执行流有两个精确定义step 一次模型请求 它调用的工具turn 零个或多个 step。turn 在第一个输入被认领前打开在所有欠账还清后关闭。官方文档给的时序turn/start claim next-step input plus one queued message assemble prompt sections tool schemas - agent/pre-step reject | enter(messages) step/start append entered messages as user/message derive model history from the log agent/request - llm/stream - assistant/chunk* - assistant/message tool/call* - tools/pre-execute - tools/execute - tools/post-execute - tool/result* step/end tools owe another request, or next-step input arrived - claim - next step - agent/turn-stopping turn/end几个关键设计turn/*、step/*、user/message、assistant/*、tool/*是持久化 session 事件进日志其余是三个域里的活扩展点。agent/pre-step决定模型能看到什么。监听器可以改写被认领的消息或直接 reject被 reject 或首个 claim 被改写成空仍然会关闭一个没花任何 step的持久 turn——日志里会留下这次尝试。输入从一个 inbox到达 driver。部分消息会立刻唤醒它injected context 只在有别的消息到达时才被消费agent.inject()追加的是下一条模型请求可见的持久上下文不是唤醒信号。Session log模型可见即日志可重建会话日志是模型上下文的唯一来源。deriveMessages()从日志投影出模型历史原始assistant/chunk事件保真地保留 replay 和 UI 呈现。fork、resume、transcript、telemetry、持久化全部从这条流派生。这里有一条运行时不变式是理解 dsh 数据流的关键Model-visible means logged——任何到达模型请求的内容都必须能从日志重建运行时有一个断言在强制它。所以给模型加一种新输入这件事的落点是扩展SessionEventMap加一个新 session 事件类型从日志渲染它。想绕过日志直接塞数据给模型在架构上就是错的。Capability seams换一个 provider换掉整个产品dsh 里可替换能力是一个叫seam的结构化概念包含三个角色Service Definition声明接口Service Provider实现接口Consumer使用接口通常是模型面工具一个角色单独不构成 seam加一项能力意味着三个角色一起设计。seam 的价值在于一次 provider 切换改变整个产品filesystem 和 subprocess provider 共享同一个执行世界把它们指向远程 sandboxBash、PTY、LSP 会跟着一起迁移不需要为每个工具 fork provider。subagent provider 也一样——一个接口背后可以是全新子 agent也可以是另一个产品里的委托 turn。写一个工具插件最小可用形态官方 cookbook 给出的最小工具插件import { readFile } from node:fs/promises import type { Context } from deepseek-ai/cordis import { defineTool } from deepseek-ai/dsh-tools export const name my-tool export const inject [tools] export function apply(ctx: Context) { ctx.tools.register(defineTool({ name: read_file, description: Read a file from disk., // what the model sees parameters: { path: { type: string, required: true, description: Absolute path }, limit: { type: number }, // optional by default }, output: { schema: { type: string }, render: (_args, value) [{ type: text, text: value }], }, async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity token; signal is the operational field return readFile(args.path, { encoding: utf8, signal: exec.signal }) }, })) }注册走 effect 机制插件 fiber 销毁时工具自动注销schema 自动汇入 system-prompt 组装。execute()契约里有几条硬规则踩过坑的会知道这些边界多重要args 自动校验。defineTool在execute跑之前用统一的ParameterSchemaSpec校验模型生成的 arguments类型、必填键、字面量约束、exact-one union、嵌套值所以 execute 里的 args 类型和 schema 严格一致。schema DSL 表达不了的约束非空字符串、正数、跨字段规则才需要自己手检。执行身份受保护。注册表把 arguments 物化为 detached lossless JSON 并 freeze分配不透明的exec.tokencallId/name/arguments/agent/token/signal在派发全程不可变。只有 around-dispatch 包装器能拿到可变视图而且它只能替换exec.signal比如加 deadline不能删掉它。返回值是唯一的 canonical JSON 值。output.schema定义根类型execute只返回推断出的值注册表快照、校验、freeze 后交给output.render(args, value)。别在 body 里 return content blocks也别让调用方去解析散文里的 id。抛异常或返回非法值 isError。基础设施故障用 throw业务的非理想状态比如进程非零退出要放进 canonical value 表达。presentCall/presentResult必须是纯函数。它们在直播流和 session-log replay 上都会跑不能有 I/O、不能读 session 状态、不能用时钟/随机数——UI 卡片generic/terminal/diff/search/web的渲染意图要和模型可见内容彻底分离格式化只服务 UI 的东西不许进 canonical value。另外一个对测试开发者很友好的点Code Mode 下每个注册的可见工具直接变成await tools.name(args)参数/返回类型从同一套 schema 推导调用重新走完整执行管线含 policy。这意味着工具的注入点、参数契约、返回值契约都是类型化、可编程的——给 agent 写测试时的 mock/stub 边界非常干净。这个仓库自己怎么测vitest 矩阵dsh 的测试设施本身就是一篇工程实践教材。根目录有一排 vitest 配置vitest.config.ts、vitest.e2e.config.ts、vitest.snapshot.config.ts、vitest.web.config.ts、vitest.web.perf.config.ts、vitest.web-stress.config.ts对应脚本脚本覆盖test/test:coverage单元 覆盖率test:e2e真 API 端到端无DEEPSEEK_API_KEY时自动跳过test:snapshot/test:snapshot:record快照与录制test:web/test:web:perf/test:web:stressWeb 层功能/性能/压测test:guiGUI 测试两个值得抄走的做法CI 的 Node 兼容矩阵CI 覆盖 Node 22.19 / 24 / 26engines声明^22.19.0 || 24.0.0pnpm 锁11.7.0。e2e 用真 API 但无 key 自跳过普通提交不需要真 key真 key 只进专门的 real-API workflow——把需要外部依赖的测试和纯本地测试在 CI 层面切干净。Host/Client 双 tsconfig aggregate仓库把包分成 Host 和 Client 两个 aggregate 编译程序原因是两边都在同一个 CordisContext接口上做 declaration merging但注册的服务不同——合进一个ts.Program会直接类型冲突。这个冲突只存在于ts.Program内部模块解析不会触发所以 root solution 可以引用两个 aggregate一个 paths facade 可以跨两侧。pre-push跑pnpm run typecheck保证契约生成完备。给双端共享接口 各自扩展的 monorepo 提供了一个可复制的类型方案。上手与踩坑当前是developer preview官方明说THERE WILL BE COMPATIBILITY-BREAKING CHANGES。生产项目别急着锁版本。快速体验npx deepseek-ai/dsh webWeb UI 默认http://127.0.0.1:3080。跑 headless 一次性任务pnpm dsh --profile headless summarize this workspace需要DEEPSEEK_API_KEY。本地跑源码要求 Node 22.19/24、corepack 启用的 pnpm锁 11.7.0。装完先pnpm run typecheck验证环境。写了插件想被生态发现仓库打dsh-plugintopic。调试配置树用dsh --profile web --dump-config比读文档准——打印的就是你机器上真实 boot 的树。总结与进阶方向dsh 的架构贡献是把可扩展从口头承诺变成结构性事实插件树 分层 patch 消灭了 privileged coretyped events 的四种分发模式把观察/包裹/扇出/串行决策统一成一张表append-only session log 用一条不变式模型可见即日志可重建管住了数据流capability seams 让一次 provider 切换波及整个产品。对测试开发者来说它同时是两样东西一个 agent 测试底座harness 的本义和一个大规模 vitest 矩阵 双端类型隔离的工程范本。进阶方向读 Cordis 的Spatiotemporal Composability论文理解插件树的形式化基础跟一遍docs/cookbook/adding-a-tool.md和 extension cookbook 里的 permission-gate 示例研究agent/pre-step拦截改写——那是给 agent 注入测试上下文/录制输入的最干净钩子。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进