AI 工程
剖析 DeepSeek Harness:它如何用“万物皆插件”颠覆传统 AI Agent 架构?
“万物皆插件”很容易写成一句口号:模型是插件,工具是插件,界面也是插件。到这里为止,它和许多框架的 README 没有区别。DeepSeek Harness 值得拆开看,不在于插件数量多,而在于它把通常藏在产品内部的 Agent loop、会话日志、权限策略和 UI 都放进了同一套生命周期与依赖系统。
这会改变开发者能改什么,也会把一批原本由厂商消化的问题推回给开发者。配置树可以替换一切,意味着依赖关系、兼容性和安全责任也会从“一套产品”变成“一组会变化的组合”。标题里的“颠覆”是否成立,不能听口号,要看这些边界到底落在代码的什么位置。
dsh 把产品边界推开了
Claude Code、Codex 这类 Coding Agent 交付的是经过调校的完整产品:模型接入、提示词、工具协议、循环、权限和交互界面由产品方一起演进。它们并非完全不可扩展——MCP、Hooks、Skills、项目指令都在扩大可定制范围——但用户通常扩展的是产品公开的接口,不会把默认 loop 换成自己的实现,再要求原界面照常工作。
DeepSeek Harness 选择了另一条边界。它将自己定义为 agent harness,发布 MIT 许可的完整代码,并把运行实例组装成一棵 Cordis 插件树。默认实现仍然存在,只是默认实现不享有“只有核心才能做”的特权。
dsh 仍然有内核。Cordis 的上下文、插件生命周期、Loader 和事件分发构成了基础设施;会话事件也有严格词汇表。官方所说的“没有特权内核”,指的是模型适配器、工具注册表、默认循环、持久化和界面都通过插件挂载,没有一块闭合的 Agent 业务逻辑只能靠修改核心源码替换。
Memory 也需要说准。当前发行版没有一个名为 Memory 的通用核心服务:短期会话由 append-only Session 日志承担,跨会话长期记忆则通过默认关闭的 MCP 示例接入 Memorix、Engram 或 MCP Reference Memory。架构允许安装记忆能力,不代表默认系统已经替用户决定了记什么、何时召回。
Cordis 不只是依赖注入容器
把 Cordis 简写成 IoC 或 DI 容器,会漏掉它对 dsh 最关键的部分:时间上的可组合性。一个插件声明自己依赖哪些服务,等服务就绪后启动;它注册的工具、事件监听器和提示词片段都有对应的 disposer。配置热更新或插件卸载时,这些副作用一起撤销。
例如,工具插件不必导入某个全局 ToolRegistry 单例。它声明 inject = ['tools'],运行时从 ctx.tools 取得服务。服务提供方换了,消费方代码不动。加载顺序也不靠 YAML 里的先后位置碰运气,而由依赖关系决定。
官方入门文档中的工具插件可以压缩成下面这段:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true }
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }]
},
async execute(args) {
return `Hello, ${args.name}!`
}
}))
}
短短一段代码里有三层约束:inject 表达运行依赖,parameters 校验模型输入,output.schema 与 render 分开处理程序值和模型可见文本。插件卸载后,注册项随生命周期撤销,不需要作者手动从一堆全局表里清理。
Cordis 的事件分发比 EventEmitter 多了控制语义。dsh 使用 emit、parallel、serial 和 waterfall 四种模式。waterfall 里的监听器可以调用 next() 把控制权交给下游,也可以改写请求、包装返回值,或者直接短路。权限检查、请求重写、重试和工具结果处理由此成为可插拔的中间件链。
一行导出,击穿了 178 个测试
dsh 仓库自己的第一份 postmortem 比任何架构口号都更能说明 Cordis 的脾气。ACP 插件同时导出了 name、inject、Config 和 apply,末尾又顺手加了一行 export default apply。Loader 优先取 default export,拿到的只剩一个裸函数;挂在函数同级的 inject 随之消失。Zed 第一次调用 session/new,服务器当场报错:
Zed ACP bridge Cordis Loader
| session/new | |
|------------------>| import plugin module |
| |------------------------>|
| | default = apply |
| |<------------------------|
| | ctx.agents ✗ no inject |
|<------------------| Internal error |
cannot get property "agents" without inject
当时有 178 个绿色测试,行覆盖率 100%。测试却是手工挂载插件对象,没有经过真实 Loader,刚好绕开了出错入口。这个事故给“万物皆插件”补上了后半句:加载方式本身就是产品行为。单测证明 apply() 能跑,不等于打包后的插件能被 Loader 正确识别。
Agent Loop 到底怎样被拆开
默认 loop 仍然是大家熟悉的模型—工具循环,但它没有把提示词组装、模型传输和工具执行写进一个大函数。一次 step 的关键路径大致如下:
一个 step 是一次模型请求以及紧随其后的工具调用;一个 turn 可以包含多个 step。模型返回工具请求,执行结果写进会话日志,loop 再发起下一次请求。没有工具债务、没有新输入,且停止钩子不要求续跑时,turn 才结束。
“Loop Plugin 可以替换 ReAct 或 Plan-Execute”这句话方向没错,但说得太轻巧。默认循环通过 ctx.agents.setFactory() 向注册表提供创建与恢复能力,UI、SDK 和子 Agent 服务都依赖这套契约。替换 loop 要实现 Agent 接口,维持 turn/step 日志不变量,还得处理取消、错误恢复、inbox、工具并发和持久化。这条 seam 公开得很彻底,接手成本也很高。
Host plane
进程级注册表和跨会话能力:会话持久化、模型路由、沙箱、审批、子 Agent provider、工具注册表。
这类服务通常只能挂载一次。把它误放进每个 preset,会造成重复注册或让其他宿主组件找不到服务。
Agent plane
单个 Agent 可见的组合:persona、工具集合、Skills、计划模式、压缩策略、工作流工具。
需要私有状态的服务放进 isolate realm;普通注册则按 Agent scope 限定可见范围。
Profile、Bundle 与 Patch:配置不是一份大 YAML
dsh 的 Profile 是一套具名安装:它保存依赖、列出 Bundle,并拥有用户自己的 cordis.patch.yml。Bundle 是一组可分发的插件配置层。启动时从空树开始,依次叠加 Profile 声明的 Bundle、Profile patch、Home 级 patch,最后才是命令行传入的 --patch。
# 查看 web profile 最终会启动的完整插件树
dsh --profile web --dump-config
# 临时叠加一个配置层,不修改已有 profile
dsh web --patch ./extra.cordis.yml
# 安装会贡献配置层的第三方 bundle
dsh plugin --profile web add <package-or-git-spec>
后应用的层优先,但 patch 并不是对 config 做深度合并:它按 id 定位条目,替换该条目的整个 config,也可以插入新条目。这个细节很重要。只想改端口却复制了一份不完整的 config,可能顺手删掉运行时表达式和其他默认值。
Profile 解决“这台机器准备了哪些插件”,Agent preset 解决“某个会话挂载哪些面向模型的能力”。目前随发行版提供四个 preset:
| Preset | 实际用途 | 需要注意的地方 |
|---|---|---|
| 标准模式 | 完整编码 Agent:文件、Shell、搜索、Skills、计划、目标、子 Agent 与工作流。 | 它是能力组合,不代表最高权限。 |
| PTC 模式 | 沿用标准能力,把工具呈现为一个 TypeScript SDK,由 run_code 在单次程序中组合调用。 | 减少模型往返,但程序执行和子调用仍受工具流水线约束。 |
| 极简模式 | 固定系统提示,只保留持久 Bash 与 str_replace_editor。 | 默认文件提供方与标准模式不同,不能把“工具少”理解为“权限更低”。 |
| 创造模式 | 在标准能力上增加 Cordis 运行时检查、临时挂载和 preset 创作工具。 | 官方配置直接提醒:应把它视作 Shell 访问,不是安全实验区。 |
工具、MCP 与 Code Mode 分别处在哪一层
原生工具最终都进入 ctx.tools。默认循环负责把 schema 放进模型请求,再把工具调用送进 pre-execute → execute → post-execute 流水线。权限策略、后台任务、结果裁剪和 UI 卡片都可以在这里介入。
MCP Client 插件做的事很具体:连接外部 MCP Server,读取工具列表,并把每个工具注册成 mcp__<server>__<name>。从 loop 视角看,MCP 工具与本地工具使用同一注册表和执行路径。当前实现只桥接 MCP Tools,Resources 与 Prompts 还没有 Harness 消费方。
- id: mcp-internal
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: internal
transport: streamable-http
url: http://127.0.0.1:3000/mcp
headers:
Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'
failOnStartupError: true
有个不太显眼却很要命的边界:stdio MCP Server 的命令运行在 Agent 沙箱之外,是宿主信任的可执行代码。因此 dsh 虽然随 CLI 带有 MCP Client 依赖,却不会默认启用任何 Server。把 MCP 写进配置,相当于允许那个包在宿主进程侧启动,不是仅仅“给模型多一个受限工具”。
MCP 断线时还有一段容易把排障人员绕进去的“灰色时间”。dsh 不会立刻从工具表中删掉最后一次同步成功的 schema;模型仍能看到工具名,真正调用时却会失败。后台按指数退避重新连接,成功后整代替换工具;连续失败达到上限,才注销这台 Server 的全部工具。也就是说,“工具还在列表里”只能证明它曾经发现成功,不能证明远端此刻可用。
tools/list 成功 → mcp__internal__search 已注册
连接中断 → schema 暂时保留,调用返回失败
重连成功 → 重新发现并整代替换
重试预算耗尽 → 注销该 Server 的全部工具
PTC(Programmatic Tool Calling)处理的是另一件事。在 Native 模式下,模型每做一步都发出一次工具调用;在 PTC 模式下,模型只看到 run_code 和生成的 TypeScript SDK,可以在一个程序里调用多个已有工具。工具没有绕开注册表,只是模型与注册表之间多了一层可编程调度。
// 概念示意:一个模型回合里完成并发搜索,再读取命中的文件
const hits = await Promise.all([
tools.grep({ pattern: 'SessionEvent', path: 'packages' }),
tools.grep({ pattern: 'agent/pre-step', path: 'packages' })
])
const files = unique(hits.flatMap(x => x.files)).slice(0, 6)
return await Promise.all(files.map(path => tools.read({ path })))
收益是少几次模型往返,大块中间值也不必全部进入模型上下文;代价是调试对象从“哪次工具调用错了”扩展为“生成程序、SDK 桥接或某个子调用哪里错了”。打开 PTC 之前,最好先确认模型确实把时间耗在往返,而不是把错误藏进更长的生成代码里。
这里有几个很具体的坑。当前 TypeScript runtime 只做 type stripping,不做类型检查;enum 和 namespace 这类不可擦除语法会在 worker 启动前被拒绝。每次 run_code 又是全新的 worker,上一次定义的变量不会保留。更麻烦的是副作用不回滚:程序先改了三个文件,第四个工具调用失败,前三次修改仍然留在工作区。模型若把整段程序当成事务反复重跑,很快就会从“节省往返”变成“重复执行副作用”。
run_code #17
├─ tools.read(...) ok
├─ tools.edit(fileA, ...) ok # 已落盘
├─ tools.edit(fileB, ...) ok # 已落盘
└─ tools.test(...) failed
retry run_code #18
└─ 如果不先检查 fileA/fileB,前两次编辑可能被重复应用
worker 有 compute、wall-clock、heap 和输出上限,但它只提供 containment,不是安全边界。模型代码启动的 OS 子进程甚至可能在 worker 被终止后继续存活。生产环境若开放 PTC,外层进程隔离和幂等工具设计比那张漂亮的 SDK 类型表更重要。
“Agent 的 Agent”已经能做,但没有宣传稿那么圆满
dsh 的子 Agent seam 把委托提供方放在统一接口后面。内置的 spawn 可以创建新的本地 Agent,fork 可以带着父会话已完成的历史前缀创建分支。仓库还提供两个可选 provider:Claude Code 与 Codex。
它们确实能成为 dsh 的子节点。Codex provider 会启动 codex app-server --stdio,创建临时 thread 并提交一个 turn;Claude Code provider 通过官方 Agent SDK 发起一次 query。两者都继承父会话的工作目录,但不会继承父会话历史、persona、工具过滤或深度策略。
回传边界更窄:父 Agent 只得到子产品的最终文本或错误。子 Agent 的推理、工具轨迹、Token 用量、stderr 和文件 diff 不会合并进父会话日志。任务可以后台执行,但每次都是新进程、新会话、一次性请求,没有继续对话、池化或跨任务恢复。
# Host plane:安装并挂载 Codex provider
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
# Agent preset:暴露给模型的调用入口
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
backgroundMode: one-shot
maxDepth: provider-managed
正式发行默认既不安装 provider,也不启用 preset 中预留的工具行。还要由宿主提供可用的 codex 或 claude、本机配置与登录状态。这里的“跨平台异构调度”成立,但更像受控的单次任务网关,而不是把三个 Agent 的内部状态统一进一个分布式运行时。
无人值守也是硬边界。Codex 子进程遇到命令或文件审批时,provider 会优先选择 cancel;遇到用户问题则提交空答案,MCP elicitation 直接拒绝。Claude Code 侧同样没有交互回调。于是一个在原生 CLI 里会弹框等人的任务,挂成子 Agent 后可能迅速失败。父 Agent 看不到孩子刚才卡在哪个审批框,只拿到最后错误。这种任务应该在委托前把权限和输入准备完整,或者干脆留在有人值守的产品里执行。
Append-only 日志带来了什么,又没有带来什么
dsh 把 SessionEvent 作为会话事实源。用户消息、模型原始 chunk、组装后的 assistant message、工具调用与结果、turn/step 边界、请求头以及权限切换都按顺序追加。模型下一次看到的历史由日志投影出来,UI 的 Trajectory 视图也消费同一事件流。
turn/start一次用户意图开始step/start准备第一次模型请求request/header模型、系统提示与工具 schema 快照assistant/chunk保留流式输出以便 UI 复现tool/call工具名与参数进入持久事实tool/result结果或稳定错误码step/end工具欠下一次请求,继续下一 stepResume 会加载持久日志、修复崩溃留下的未闭合边界,然后创建新的运行实例。Fork 会复制一个结束于稳定边界的事件前缀,并记录父会话与 seed 长度。它们都不是把 JavaScript 堆栈冻结后恢复;崩溃在工具调用之后、结果写入之前发生时,系统只能把结果标为未知,提醒模型先验证副作用。
“Replay”也有两种容易混淆的含义。会话事件可以重放,以重建 UI 和派生状态;测试用的 llm-replay 还能用已录制模型流复现实验。但在真实生产会话里重新调用一个非确定性模型,并不保证得到相同输出。日志提供可审计输入和已发生事实,不提供时间倒流。
Web UI 默认运行在 http://127.0.0.1:3080。除了 Chat,Trajectory 可以查看 turn、step、请求、工具和耗时;统计区显示 TTFT、生成速度、输入/输出 Token、缓存读写 Token 与缓存命中率,并有上下文占用仪表。把“缓存命中率高”当成性能证据前仍要看供应商计费口径:不同适配器报告的 usage 字段并不完全等价。
下面是按 Trajectory 字段整理的终端示意,不是产品截图:
DSH / session 8f2a context 41% cache 86%
────────────────────────────────────────────────────────────────
TURN 12 · STEP 1
00:00.000 user/message "修复登录回调的竞态"
00:00.014 request/header deepseek-v4-pro · 27 tools
00:00.812 assistant/chunk first token
00:02.438 tool/call grep { pattern: "callback" }
00:02.492 tool/result 14 matches
TURN 12 · STEP 2
00:02.506 request/header unchanged
00:03.177 tool/call read { path: "src/auth.ts" }
00:03.181 tool/result 218 lines
────────────────────────────────────────────────────────────────
TTFT 0.7s · 42.3 tok/s · input 18.4k · output 1.1k
这张账本很适合回答“模型为什么又发了一次请求”“哪个工具拖了两秒”。它回答不了“模型为什么相信了错误线索”,也不能证明外部副作用与日志一致。Trajectory 是调试入口,不是自动生成的事故结论。
四种 Agent preset,不是四级安全沙箱
标准、PTC、极简、创造描述的是 Agent 能力组合。文件沙箱另有三种模式:
| Sandbox mode | 文件效果边界 | 容易误解的地方 |
|---|---|---|
read-only | 要求后端拒绝写入,只保留 Shell 必需的少量 sink。 | 不等于看不到宿主文件,也不禁止网络与进程可见性。 |
workspace-write | 允许写工作区和后端承诺的临时区域。 | 工作区之外的外部服务仍需各自权限控制。 |
danger-full-access | 绕过文件沙箱,直接执行原 argv。 | 它不是某个 provider 实现出的“更宽沙箱”,而是明确不隔离。 |
本地后端按平台选择 Linux 的 bubblewrap/Landlock、macOS Seatbelt 或 Windows ACL 受限令牌,并报告 full 或 partial enforcement。没有可用后端时,受限模式必须 fail closed,不能静默退回裸执行。
这套错误分类也栽过跟头。旧版 Landlock 在能力不完整的内核上会输出一条提示,dsh 一度把这条提示与“子进程非零退出”拼在一起,当成沙箱启动失败。于是 ripgrep 正常用退出码 1 表示“没有匹配项”,上层收到的却是 SANDBOX_UNAVAILABLE。问题不在隔离失效,而在两条互不相干的事实被错误归因。后来修复同时收紧了信息行排除和退出状态判定,并补上旧 ABI 的组合测试。安全系统最怕这种诊断漂移:错误码看起来足够吓人,调查方向却完全错了。
这仍不是完整生产隔离。沙箱 seam 只承诺文件效果;容器、microVM 或远程执行需要一起替换 Shell、文件系统等能力提供方,确保它们处于同一个执行世界。只把 Bash 放进容器,而文件编辑工具仍写宿主目录,会得到一个边界互相打架的系统。
第三方插件还有更高一层风险:插件本身在宿主侧执行。pnpm 安装脚本、MCP stdio 命令、Cordis 插件的 apply() 都不受 Agent 文件沙箱保护。生产部署至少需要锁定版本或 commit、审查插件源代码、隔离宿主凭据,并把不可信任务放进外部容器或 microVM。
Harness 架构与成品型 Coding Agent 怎么比较
把一边写成“全黑盒”、另一边写成“完全透明”,没有分析价值。更准确的区别是:成品型 Agent 由厂商维护一套主张明确的默认闭环;dsh 把闭环拆成可替换服务,并要求使用方承担组合后的系统行为。
| 维度 | DeepSeek Harness | 成品型 Coding Agent |
|---|---|---|
| 模型绑定 | 原生 DeepSeek 适配器,也可通过 llm-pi-ai 配置多供应商或 OpenAI-compatible 私有端点。 | 通常围绕厂商模型优化;有些产品支持第三方模型或网关,但由产品接口决定范围。 |
| Loop | 默认 loop 是插件 seam,可替换,但必须实现完整 Agent 契约。 | 核心 loop 通常由产品方控制,用户通过 hooks、instructions、skills 等扩展。 |
| 工具与 MCP | 统一进入 ctx.tools,工具流水线、呈现模式和 MCP bridge 都可组合。 | 工具体验更统一,公开扩展点之外的调度细节通常不可替换。 |
| 多 Agent | 本地 spawn/fork 与可选 Claude Code、Codex provider 共用委托接口。 | 产品自带的子 Agent 通常有更完整的内部状态与 UI;跨产品协作需要外部编排。 |
| 调试 | 事件日志、Trajectory、配置 dump 与源码可查,适合研究请求和状态演进。 | 日志和诊断更产品化,但内部实现可见度取决于开源程度与官方接口。 |
| 升级成本 | RC 阶段接口会破坏;插件、patch 和 preset 的组合需要自己验证。 | 厂商吸收大部分内部迁移,用户主要处理公开配置与扩展 API 的变化。 |
场景一:切到本地 OpenAI-compatible 模型
llm-pi-ai 允许声明一个仓库内置目录没有的 provider route,指定协议、端点与模型。Ollama 提供 OpenAI-compatible 接口,因此可以按私有网关方式接入。整个切换走的是通用适配层,dsh 没有为 Ollama 维护专用分支。
- id: llm-local
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
local-ollama:
displayName: Local Ollama
api: openai-completions
baseURL: http://127.0.0.1:11434/v1
apiKeyEnv: OLLAMA_API_KEY
models:
- id: qwen3-coder
contextWindow: 131072
maxTokens: 16384
本地端点如果不校验 Bearer token,也可能仍要求放一个占位 key,因为通用 OpenAI 客户端会检查凭据。模型的 context window、输出上限和图像能力也要按实际部署填写;配置错了,Harness 无法替本地服务猜出来。
场景二:私有工具链加自定义 Loop
先把私有能力做成 Tool 或 Service Provider,保留默认 loop,通常已经足够。只有任务停止条件、调度语义或多轮状态机确实不同,才值得替换 ctx.agentLoop。比如合规审查必须固定经过“证据收集—规则判定—人工签字”三段,外层 Workflow 往往比自由探索的新 loop 更好审计。
直接改 loop 最容易破坏的是错误恢复:工具已经产生副作用、日志还没写完时进程崩溃,新的驱动器必须知道下一次是重试、验证还是停止。把 happy path 跑通,只完成了这项工作的前半截。
Developer Preview 的成本,远不止 API 改名
第一,组合空间会迅速膨胀。模型适配器、preset、工具呈现、沙箱后端和 UI 都能替换后,测试矩阵不再是一条版本线。某个插件单独通过测试,不代表它与另一个 realm、patch 顺序或 HMR 生命周期组合后仍然正确。
第二,可追溯不等于可复现。事件日志能够解释当时发出了什么请求、调用了什么工具;外部网页变了、模型版本更新了、命令读取了当前时间,结果仍然会漂移。严格复现实验还需要固定模型、工具输入、环境镜像和外部依赖。
第三,生态治理还没经受规模压力。“万物皆插件”走到规模化之后,难题会从写插件转向签名、权限清单、依赖冲突、恶意升级、兼容区间和维护者失联。现在的 dsh-plugin topic 只能帮助发现项目,尚不是带审查和权限模型的插件市场。
第四,如果你只想找个开箱即用的编码工具,先别碰这套 Harness。Profile、Bundle、Patch、Host plane、Agent plane、scope、realm、service seam 任何一层放错位置,表现都可能像“插件没有加载”。省下来的厂商限制,会在插件冲突、版本断层和组合测试里找你结账。对平台团队这是控制权;对普通用户,大多只是额外维护。
我的判断:DeepSeek Harness 还称不上 Agent 的“安卓时刻”。Android 的成立不仅靠可替换组件,还靠稳定 API、硬件抽象、应用分发、安全模型和足够大的开发者市场。dsh 现在更像一颗设计得很认真的 Agent 微内核:它证明了 loop、工具、日志、沙箱和 UI 可以在同一插件模型下协作,也把过去很难触碰的控制面交给了开发者。
这已经足够重要。若后续 seam 保持稳定,第三方 provider 与 preset 能跨版本存活,安全边界经得住生产部署,它可能成为领域 Agent 的底座。若生态只剩不断复制整份 YAML、追逐 RC 接口和修复插件冲突,“万物皆插件”也可能退化成“万物都要自己维护”。分水岭不在开源当天,而在两年后旧插件还能不能装、新版本敢不敢升。
参考资料
- DeepSeek Harness 官方仓库与 README
- DeepSeek Harness 架构说明
- Cordis 入门:服务、事件与可逆副作用
- Postmortem 0001:default export 丢失 inject
- Postmortem 0004:Landlock 提示导致错误归因
- PTC Worker Thread runtime 的执行边界与已知限制
- A Programming Paradigm for Spatiotemporal Composability
- dsh 进程沙箱设计
- MCP Client 插件实现与限制
- Codex 子 Agent provider
- Claude Code 子 Agent provider
- Ollama 的 OpenAI compatibility 文档
Share