DeepSeek Harness 概念篇
01. DSH 是如何实现、基于哪些工具组成的?
在我准备写 DeepSeek Harness 动手篇之前,我需要先把一些必要的概念讲清楚。
其实这算是我的学习笔记,我只是想试着通过讲解者、教学者的角色方式,讲明白我学到的知识,消化好通过输出的方式,进行巩固,也希望对 DSH 感兴趣的朋友,有所帮助。
DeepSeek Harness(DSH)是 DeepSeek 官方开源的 Agent harness(智能体框架/运行时),把「模型调用 + 工具 + 会话记忆 + Agent 循环」做成一个一切皆插件的可复用框架,底层由 Cordis 插件框架驱动。
这里“一切皆插件”的思想,在后续的讲解中,会慢慢地显像出来。Cordis 是什么,DSH 是如何运用、使用它的,你在课程最后,自然也会搞懂。
先说我的参照系
我读 DSH 的时候,是带着一套 Java 后端的脑子去读的——满脑子都是 Spring。如果你也写过 Spring,那这篇会很好读:我会一路拿 Spring 当对照物,帮你标出哪些地方“似曾相识”、哪些地方“完全不是一回事”;没写过 Spring 也没关系,类比只是梯子,讲的重点永远是 DSH 本身。
一、“一切皆插件”,到底在说什么
“一切皆插件”这句话,太容易被当成“模块化”的同义词,然后被轻轻放过。我第一次也是这么想的,结果错得挺彻底。
写 Spring 的朋友都熟悉它那句口号——“一切皆 Bean”。业务代码、配置、切面,统统是 Bean,靠 IoC 容器装配。听起来 DSH 的“一切皆插件”是同一个意思,对吧?
对了一半。两者确实共享同一套思想:把“要组装的东西”和“组装这件事”分开。但 DSH 走得更远,远在两个字上——特权。
Spring 里,Bean 是“一切”,但 Spring 框架本身不是 Bean。DispatcherServlet、ApplicationContext 这些核心是框架内置的、写死的,你只能用它,不能换它。换句话说,Spring 的“一切皆 Bean”是打了折扣的:框架的核心是特权区,插件只能在外围打补丁。
DSH 把这个特权区拆掉了。它的“一切皆插件”是真的“一切”:连 agent 循环本身、会话日志、工具注册表,全都是插件。没有哪块是“框架亲儿子”动不得。你不满意默认的循环算法?配置文件里换一行,运行时装配出来的就是另一套东西,不用改源码、不用重新编译。
这就是“模块化”和“插件化”的分水岭:模块化只是代码层面的拆分,编译完还是那一坨;插件化是运行时的组合,改个 yml,装出来的产品就变了。
二、harness 到底在解决什么
在往下钻之前,先退一步问一句:这东西为什么要存在?
答案是四个坑——你一旦裸调过 LLM API,就会挨个撞上:
- 多轮记忆:模型无状态,上一句说过啥它记不住,历史得你自己存、自己塞回下一次请求;
- 工具调用:让 AI 去改代码、跑命令,这是有真实副作用的操作,得安全地接进模型;
- 上下文管理:对话越来越长,token 迟早爆,得知道什么时候压缩、怎么压;
- 循环控制:AI 干活不是一问一答,是“想→做→看结果→再想”的循环,谁来驱动、怎么停、出错怎么办。
这四个坑,harness 全兜住了。这也是我第一次真正分清一件事:harness 不是又一个 LLM SDK。SDK 解决的是“怎么调模型”,harness 解决的是“模型 + 工具 + 记忆 + 循环怎么组织成一个 Agent”。它站在 SDK 更上面一层。
三、Cordis:地基上的五块砖
“一切皆插件”不是喊出来的,得有一套底层机制撑着。这套机制,就是 Cordis。
我把 Cordis 概括成五个思想、两把钥匙。
五个思想:
- 插件是 Service 对象——不是随便一个函数,是带生命周期、能挂到容器上的对象;
- context 是服务仓库——所有服务通过
ctx.<key>这种稳定的键暴露,谁用谁按 key 去拿; - inject 声明依赖——你要用哪个服务,就在
inject: ['shell']里声明,框架等服务就绪了再挂载你; - 类型化事件通信——插件之间靠事件解耦,而且事件有四种派发模式;
- 注册是可逆效应——每注册一个东西都返回一个 disposer,卸载时逆序收回。
两把钥匙,是依赖注入和事件分发。
依赖注入:inject 不是 import
先说 inject。写 Java 的对依赖注入不陌生,但你得注意它和 @Autowired 的差别——不是实现上的差别,是“边界”上的差别。
const plugin = {
inject: ['shell'], // 我要一个叫 shell 的服务
apply(ctx) {
ctx.shell.run('ls -la') // 用的时候,框架已经把它塞进 ctx 了
}
}
对照 Java 里熟悉的写法:
@Autowired
private ShellExecutor shell; // 面向接口,从不知道具体实现
两者本质一样:依赖倒置 + 运行时装配。你对着一串抽象的键/接口写代码,从来不知道背后塞进来的是本地 shell 还是远程沙箱。差别在于,Cordis 的注入键是字符串 shell,而且插件自己用 inject 数组声明“我需要什么”,框架等服务就绪了再挂载你——加载顺序由依赖关系自动推导,不用手动排。
事件分发:四种模式,重点记 waterfall
插件之间怎么通信?靠类型化事件。四种派发模式,区别就两条:等不等、有没有返回值。
| 模式 | 等不等 | 有没有返回值 | 怎么记 |
|---|---|---|---|
| emit | 不等 | 无 | 广播,喊一嗓子就走 |
| waterfall | 不等 | 有 | 拦截器链,能改结果 |
| parallel | 等 | 无 | 并发 fan-out |
| serial | 等 | 有 | 责任链,逐级处理 |
waterfall 是最容易踩坑的,单独拎出来讲。它的语义是:每个监听器收到一个 next,你想把流程往下传,就调 next(args);不调、直接 return,整条链就在你这儿短路,下游全不执行。
写 Spring 的同学可以拿 HandlerInterceptor 对照:Spring 里 preHandle 返回 true 表示放行;而 Cordis 的 waterfall 没有“返回 true”这回事,不调 next() 就等于拦截。想参与但不拦截?必须老老实实调 next()。我还真手滑漏写过一次,agent 行为诡异,debug 半天才想起这茬。
四、profile / bundle / patch:三层组装
单个插件搞懂了,接着的问题是:一堆插件怎么组装成一个产品?
答案是分层叠加,三层:
- bundle(分发包):一包“配置行 + 挂载的代码”。
dsh-base就是每套东西的第一层,装着模型、工具、持久化、沙箱、凭证这些标配。写 Spring 的可以把它想成 starter——自动装配一批东西; - profile(命名组合):一份“套餐”,列出要叠哪些 bundle,再加你自己的 patch。
web和headless是两个官方套餐; - patch(补丁层):
cordis.patch.yml,按 id 定位到某一行,整行替换它的 config。
拿餐厅打个比方:bundle 是“标准做法”,profile 是“套餐”,patch 是你在套餐上划掉的那条备注——“不要香菜”。
两条铁律:
- patch 是整行替换,不是合并——你写一个 config,是把原来那一整行的 config 换掉;
- last write wins——多层都动了同一行,最后写的生效。
(严格说叠加链条更长:bundle 列表 → profile patch → home 级 patch → --patch 命令行覆盖,一层压一层,同样 last write wins。)
还有一个反直觉的点,值得单独记:cordis.yml 里的行顺序,完全不影响加载顺序。源码注释原文写着 Row order carries no load semantics。真正的加载顺序,由上一节说的 inject 依赖声明自动推导;yml 里的顺序只是排版给读的人看的。顺带一个小坑:配置里写 JS 表达式,标签是 !!js,两个感叹号,!js 是错的。
五、能力接缝:这一节是整个设计的精髓
如果前面几节是“看懂它在做什么”,这一节是“看懂它为什么漂亮”——能力接缝(capability seam)。
一个接缝由三个角色组成:
- Service Definition:定义接口,比如“执行命令”有哪些方法,只定形不定实;
- Service Provider:真正干活的那个,比如本地 shell 是一个 provider,远程沙箱是另一个;
- Consumer:使用这个能力的人,比如把 shell 能力暴露给模型的工具插件。
三个角色齐全,才叫一个完整接缝。这听起来挺像 Java 的“接口 + 多实现”——List<String> list = new ArrayList<>(),业务只对着 List 写,换 LinkedList 一行不改。我第一次也这么以为。但它比这更彻底,彻底在两点。
第一,Definition 不是 TypeScript 的 interface,而是 Cordis 的 Service(抽象类)。因为它要挂到 ctx 上、有生命周期,得是个有状态的东西,光一个 interface 撑不起来。
第二,也是真正让人拍大腿的一点——共享底层执行世界。架构文档里有句话,我反复读了好几遍:
Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks.
意思是:文件系统、Shell、LSP 这几个能力的 provider,底下共享同一个“执行世界”。你把底层从“本地”指向“远程沙箱”,Bash、PTY、LSP 一起跟着迁过去,不用 fork 任何一个 provider。
这跟“每个能力各配一个独立 provider、解耦越彻底越好”的直觉正好相反。DSH 反着来:故意让 provider 共享底层,换取“一次替换、全家搬迁”。替换发生在运行时组合层,而不是编译期多态——这就是它比“接口 + 多实现”强的地方。
六、会话日志:把“状态”换成“事实”
接下来是会话记忆,也是我觉得最有“工程味”的一节。
朴素的做法是:拿一个数组,把要发给模型的消息一条条 append 进去。DSH 不这么干。它把会话存成一条 append-only 的 SessionEvent 日志,记录的是“这一路发生的每一件事”(事实),而不是“当前该发什么”(状态)。
拿 Git 打个比方:数组是工作区当前的快照,日志是 commit 历史。快照只有最新一版,回不到过去;commit 历史里任意一个点都能重建。
所以模型看到的对话历史,根本不是直接存的,是日志派生出来的一个视图——通过 deriveMessages() 从日志里投影出来。同一个日志,还能派生 transcript、telemetry、fork/resume、持久化……全从这一条日志来,永远不会“各说各话”。
支撑这一切的,是一条核心不变量,原文特别漂亮——“模型可见 ⟺ 已记录”:任何到达模型请求里的内容,都必须能从日志重建出来。违反了呢?fork/resume 时 deriveMessages() 重建的上下文会缺块,模型在新会话里看到“被偷工减料的过去”;审计时也查不出模型当时为什么那么回答。所以 DSH 用运行时断言强制检查这条不变量,违反了直接报错。
我学到这里最大的感触是:好的设计不是加了多少功能,而是立了一条原则,然后让一堆功能自动从这条原则里长出来。 一条“模型可见即已记录”,fork、resume、审计、持久化全被它串起来了。
七、turn 和 step:循环的节拍
最后一节是循环的最小单位,概念不难,但特别容易混:
- step:一次模型请求 + 它这次调用的工具;
- turn:从接到你的输入,到最终回答完的整段,一个 turn 里可以有零到多个 step。
最容易错的是:step 按“模型请求次数”算,不是按“工具调用次数”算。一次请求里模型连调三个工具,这仍然是 1 个 step,不是 3 个。光数工具调用,是数不出 step 数的。
循环怎么停?分两层:
- 正常停:模型自己觉得“活干完了”,不再调工具,直接给最终回答,循环自然结束——这是主力;
- 兜底停:模型抽风无限循环时,策略插件(超时、token 上限、用户取消)通过
agent/turn-stopping事件强制掐断。
我一开始脑补的是“肯定有个审查 Agent 在盯着”,结果不是——主力是模型自己停,策略层只是兜底。这个纠正挺重要。
顺带把事件分个类:turn/*、step/*、assistant/*、tool/* 是 durable 会话事件,会落盘、能重建;agent/*、tools/* 是 live 挂载点,不落盘,给插件运行时挂东西用。
收尾:把这七节串成一条线
讲到这里,回头看那句最唬人的“一切皆插件”,它就不再是一句口号,而是有骨有肉的一套东西:
“一切皆插件”是世界观 → Cordis 是承载它的底层机制(inject 注入依赖、事件分发通信)→ profile/bundle/patch 是组装方式 → capability seam 是能力被替换的“接缝” → append-only 日志是记忆的真相来源 → turn/step 是运行的节拍。
这七节全是概念,一行代码没写。但它们是动手篇的“必读前情”:下一篇去跑 npx @deepseek-ai/dsh web、读第一个最小插件时,你会不断回头印证这些词——inject、waterfall、profile、seam、deriveMessages、turn。
把地基打牢,动手才有底气。下一篇见。
引用本文