P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了。

前言

最近深扒了DeepSeek Harness的架构,给我最大的感触就是:这哥们儿是真敢玩。

之前拆解Codex的时候,人家好歹还留了个特权内核当“定海神针”;到Harness这儿倒好,直接把“一切皆插件”卷到了天花板——连Agent主循环都能当插件随便换。

今天就给大家盘盘这套架构到底是怎么搭的,里面有哪些神设计值得直接抄作业,又有哪些坑得提前避开。

1. 先扒底座:Cordis的五个核心概念

先透个底,Harness的底座不是从零自研的,是基于Cordis插件框架做的二次开发。

所以想看懂它的骚操作,得先把Cordis的五个核心概念整明白,不然看代码就像看天书,每个字都认识,放一起不知道啥意思。

1.1 插件:本质就是实现Service的对象

第一个概念,插件本质上就是个实现Service的东西。

它可以是个带injectapply的函数,也可以是个Service子类,由Cordis把生命周期挂载到上下文里。

这意味着什么?意味着Harness里从模型适配器、工具注册表,到会话日志,甚至Agent主循环,全都是一模一样的插件形态。

没有谁是“亲儿子”,没有谁有特权,想换哪个换哪个。

搁别的项目里,主循环那都是核心中的核心,改一行都怕牵一发而动全身;这儿倒好,直接跟其他插件平起平坐。

1.2 上下文:装Service的仓库

第二个概念,上下文就是Service的仓库。

每个Service在ctx上占一个固定的键,比如ctx.toolsctx.llm。别的插件要用的时候,按键找就行,不用import具体的实现代码。

这才是解耦的精髓啊兄弟们。你换个LLM实现,调用方的代码一行都不用改,因为人家依赖的是ctx.llm这个键,不是某个具体的类。

搁很多项目里,改个底层实现,上游调用方得跟着改半天依赖,对比一下就知道差距了。

1.3 依赖靠inject声明,加载顺序自动推

第三个概念,依赖写在inject里,加载顺序自动算出来。

你插件需要什么服务,写进inject字段,Cordis就会等这些服务都就绪了再启动你的插件。不用你手写一大串启动顺序,还生怕排错了。

做过大型项目启动逻辑的都懂,启动顺序的隐式依赖就是个天坑。加个新功能,初始化顺序没排对,直接给你报个空指针,查都查半天。

1.4 通信靠类型化事件,分派模式写进契约

第四个概念,事件通信,而且分派模式是契约的一部分,不是口头约定。

总共四种分派方式,什么时候用、什么表现,明明白白:

分派模式是否await分派顺序有返回值
emit 散发按注册顺序观察
waterfall 瀑布按注册顺序观察
parallel 并行所有监听器并行观察
serial 串行按注册顺序观察

新增事件还得用@mode标签写进文档,生成工具会自动校验声明和实际对不对得上。

搁很多项目里,这玩意儿全靠老员工口口相传:“这个事件能不能改返回值啊?”“好像不行吧,上次我改就崩了。”传承全靠踩坑。

1.5 插件注册是可逆的

第五个,也是我觉得最该抄的设计:注册是可逆的。

所有的注册,不管是提示词片段、工具schema还是监听器,全都是通过ctx.effect()或者ctx.on()来装,卸载的时候能按原路撤销。注册方法必须返回一个disposer。

说真的,多数项目的插件机制就是个貔貅——只往里装,不往外吐。热重载?运行时关个功能?想都别想。

就这一条,看似简单,收益是整个扩展体系直接就能双向操作了。

2. 插件长啥样?契约就五个字段

讲完概念看实际代码。Harness的插件就是原生的Cordis插件,没搞自己的基类、装饰器那些花里胡哨的东西。

基础接口就五个可选字段,给你们看代码:

export interface<T = any> {
  /** 用于 fiber 诊断与 logger 名的显示名 */
  name?: string
  /** 插件启动前用于校验 config 的 standard-schema 校验器 */
  Config<any, T>
  /** 插件所需的服务,仅当全部可用时才加载 */
  inject?: Inject
  /** 插件提供的服务名 */
  provide?: string | string[]
  /** 插件声明自己消费哪些服务的 intercept 配置 */
 <boolean>
}

这里提个坑,Cordis 4里已经没有reusable字段了,看旧文档写插件很容易踩这雷。

2.1 条件注入:依赖写在哪一层是个学问

给大家看个很讲究的小插件,功能就是声明工具集的展示形态。代码很短,门道不少。

export const name = 'tool-presentation'
/** 所需服务。注意 codeRuntime 不在这里。 */
export const inject = ['tools']
export interface Config {
  mode: ToolPresentationMode
}
export const<Config> = z.object({
  mode: z.union(['native', 'ptc', 'both'] as const).required(),
})
export function apply(ctx: Context, config: Config): void {
  if (config.mode === 'native') {
    ctx.tools.presentAs('native')
    return
  }
  // 这个等待本身就是那次响亮的失败
  ctx.inject(['codeRuntime'], (runtimeCtx: Context) => {
    runtimeCtx.tools.presentAs(config.mode)
  })
}

看出门道没?codeRuntime没写在顶层的inject里,而是在apply内部按条件注入。

为啥?因为native模式根本不需要代码运行时啊。你要是写在顶层,那只用native的部署,就因为缺个运行时,这插件直接挂了。

这就好比你点个素面,店家非要给你加牛肉,说套餐里都有——你说你烦不烦?

当然,这么做也有前提,就是得有激活审计机制,让没满足的等待能被看见,不能悄无声息就没了。

2.2 配置也是契约的一部分

还有个纪律我特别认同:插件里不许有硬编码的可调参数。

凡是随部署变的选择,都必须是Config字段,能从cordis.yml里改;协议常量、安全参数这些就必须写成常量。

很多项目就分不清这个边界。部署该改的参数写死在代码里,安全该固定的反而做成可配置。哪天不小心把安全阈值改了,出问题都不知道为啥。

3. 能力接缝:全是插件为啥不散架

有人肯定要问了,全是插件,没有特权内核,那系统不会散成一盘沙吗?

人家用“接缝”这个概念给串起来了。一个正经的接缝必须包含三个角色:声明接口的Service Definition、实现接口的Service Provider、用接口的Consumer。

缺一个都不算真正的扩展点。

这就直接排除了那种假扩展点——就写个空接口,既没第二个实现,也没明确的调用方。看着像能扩展,实际上换个实现根本跑不起来。

3.1 几个典型的接缝例子

给大家举几个例子,直观感受下:

  • ctx.llm:接缝,实现有DeepSeek官方版、PI AI版、还有回放专用版
  • ctx.fs:接缝,本地、沙箱、E2B远程三种实现随便切
  • ctx.approval:接缝,没装审批模块的时候默认拒绝,天生安全

最绝的是ctx.agentLoop,它的角色是bundle,也就是个组合包。没有任何扩展依赖它本身,大家依赖的是dsh-agent的事件和服务。

这才是“连主循环都能换”的真正含义——不是它写得有多灵活,是没人把它当爹供着。

4. Agent循环:全是可拦截的扩展点

接下来讲Agent的运行流程。Codex是分线程、轮次、步三层;Harness就轮次和步两层,但中间插了一堆可拦截的扩展点。

4.1 先搞懂两个基础定义

先明确两个概念,不然后面说的都对不上号:

一个步骤 = 一次模型请求 + 它调用的所有工具。

一个轮次 = 零个或多个步骤。领输入的时候打开,没活干了就关闭。

注意“零个步骤”是合法的。也就是说,请求被拒绝了,也会生成一个空轮次记在日志里。

这点真的很细节。搁别的系统里,请求被拦了就拦了,啥痕迹没有;排查问题的时候,你都不知道是用户没发请求,还是系统直接给吞了。

4.2 三个事件域,别用错地方

Harness把事件分成了三个域,做扩展的时候第一件事就是选对域。

会话事件:会落日志,重启还在。比如轮次开始结束、消息、工具调用这些。

Agent事件:运行时的活跃状态,观察拦截用。比如 inbox、步骤状态这些。

能力事件:具体能力的事件,比如fs/、tools/。不用import循环就能加策略。

简单说:需要重启还能看到的,走会话事件;就运行时用一下的,走另外俩。

别等写完了才发现状态丢了,再补持久化,那成本就高了。

4.3 瀑布式事件:可以直接短路

有一批事件是瀑布式的,比如agent/pre-step、agent/request这些。监听器必须调用next()才能往下传,不调用直接返回,整条链就短路了。

这设计太实用了。想拦截个请求?直接不调用next()就完事了。想改点东西?改完再调用next()传下去。

当然也有坑,漏写next()的话,后面的逻辑全静默失效,连个报错都没有。所以人家直接写成硬性纪律了。

5. 工具执行流水线:十个环节,细节拉满

这部分是我觉得打磨得最好的,也是最值得直接抄走的。工具执行整条流水线分十个环节,每个环节能干啥、不能干啥,边界清清楚楚。

5.1 第1环:参数落盘了就不许改

第一步tool/call,先把调用参数写进日志,UI也同步渲染出待执行卡片。

从这之后,前置策略就不能改写参数了。

为啥?因为已经记录下来、展示给用户了。你偷偷改了,日志和界面记的是一套,实际执行的是另一套,这不就造假了吗?

想改参数?可以,在落盘之前改。生成调用的那一侧负责,策略层别碰。

5.2 第3环:单调性守卫,只能拒绝不能放行

这个设计我愿称之为神来之笔。守卫的返回类型是这样的:

type ToolGuard =<ToolExecution>) => string | undefined

返回字符串就是拒绝,字符串是拒绝理由;返回undefined就是不表态。

发现了吗?没有“放行”这个返回值。

这意味着什么?后注册的守卫,永远没法把前一个守卫的拒绝给改成放行。决策只能越来越严,注册顺序不影响最终结果。

搁常见的实现里,每个钩子返回个布尔值,那可就乱套了——谁最后加载谁说了算,权限全靠加载顺序,离谱。

5.3 第2环:没审批?默认拒绝

审批环节也是默认安全逻辑。没有任何审批应答方的时候,直接判定为不可用,也就是拒绝。

也就是说,你装个基础版,没装交互前端,所有需要审批的操作全给你拒了。

反过来想,要是默认放行,那才叫可怕。忘了装审批模块,啥危险操作都能直接跑。

5.4 中间三环分工明确

tools/execute环:包裹真正的执行逻辑,超时、重试、度量都在这。也是唯一能替换取消信号的地方。

tools/post-execute环:可以改结果内容或者结果值,也能直接判定失败。

finalizeContent:内容唯一收口,失败路径也走这。保证所有结果都经过同一段代码定稿。

最后到tools/result就只能看了,结果已经深度冻结,改不了了。

总结一下:改写在指定位置,观察在冻结之后,中间有个定稿点绕不过去。

6. 会话日志:模型可见即已记录

持久化这块,Harness走得更远。它直接把日志定为模型上下文的唯一来源,还用一条运行时不变量给钉死了:模型可见即已记录。

也就是说,但凡能送到模型眼前的东西,必须能从日志里重建出来。加个新的输入类型?先加个会话事件再说。

这直接把一类疑难杂症给消灭了。就是那种:回放会话的时候,跟当初运行的结果不一样,查半天发现是有个提示词没写进日志。

这种bug最恶心了,正常跑的时候啥事儿没有,一恢复、一分叉就出问题。

6.1 只有三种事件能进模型历史

日志里有五十多种事件,但能投影给模型看的只有三种:用户消息、助手消息、工具结果。

而且还有个“表面”机制,事件可以追加,也可以遮蔽——注意是遮蔽,不是删除。被替换的事件还在日志里,只是不投影给模型了。

每次遮蔽都得交代清楚:你遮的是谁、依据是什么。全都是可审计的。

比如Code Mode里,模型写的程序发起的子调用,就不会重新进模型上下文。让程序自己消化中间结果,只把最终结论交回来,不然上下文早就炸了。

6.2 落盘:一个会话一个文件

存储格式是JSONL加zstd压缩,一个会话一个文件。路径设计也很讲究,不用进程当前目录当默认值。

这点太重要了。Agent会随便执行bash命令啊,工作目录说变就变,你默认用cwd存会话文件,回头给你撒的到处都是,找都找不到。

还有崩溃修复机制。进程崩了最后一帧可能是坏的,人家有明确的修复流程:先截掉坏的尾巴,把能解的事件补回去,再追加结束事件,把没走完的轮次和工具都补齐。

工具结局还分两种:未开始、结局未知。有没有副作用,一目了然。

宁可留下明确标异常的历史,也不留看起来正常但残缺的历史。这点真的很专业。

7. 上下文压缩:细节控的操作

压缩这块,比常见的做法细太多了,有几个判断特别反常识。

7.1 两个触发时机

压缩就两个触发点:

压力触发:token数到窗口的80%了,提前准备压缩。

溢出触发:真的超了报错了,直接压缩然后重试。

而且配置校验很严,尾部保留比例不能大于阈值比例,不然加载期直接报错。

总比运行时莫名其妙压缩失败强吧。

7.2 压缩请求是主线请求的真前缀

这个设计我真的要吹爆。压缩调用的system和tools段,直接复用主线请求的,就末尾加一条压缩指令。

为啥这么干?因为这样和主线请求有公共前缀啊!服务端的KV缓存直接就能命中。

反常识吧?很多人以为给摘要单独写个精简的system提示词更省token,实际上因为和主线请求没公共前缀,缓存全失效,总开销反而更高。

把视角从“单次请求多大”换成“和上一次共享多少前缀”,结论直接反过来。

7.3 收敛门:摘要不能越缩越长

还有个收敛门,压缩后的摘要token数要是比原内容还多,直接报错。

别笑,真的可能。本来就没几句话,一摘要,啰啰嗦嗦更长了。要是没这道门,后续的压力判断直接陷入死循环。

8. Profile与组合包:分层装配的艺术

说了这么多可替换,那到底怎么替换?靠Profile和组合包。

运行中的Harness就是一棵插件树,启动的时候一层一层叠加上去的。

组合包就是Cordis配置+代码的分发包;Profile就是具体的组装方案,列了要叠哪些组合包,还有自己的patch。

叠加顺序是固定的:先组合包,再profile自己的patch,再全局的patch,最后命令行的overlay。

一条patch的粒度就是按id定位条目,替换整个config,或者插新条目。给你们看个例子:

- id: session-persistence-jsonl
  name: '@deepseek-ai/dsh-session-persistence-jsonl'
  config:
    root: !!js dshHomePath('sessions')

8.1 想知道最终生效的是什么?打出来

多层配置叠加最常见的问题就是:到底哪层生效了?

人家直接给了个命令,直接打印当前生效的全部配置:

dsh --profile web --dump-config

而且打出来的任何一条,你都能用自己的patch替换。

可替换性不是靠预留几个扩展点实现的,是靠所有东西都以可寻址的条目形式存在。

就这一条命令,成本极低,收益极高。多少项目踩过配置叠加的坑,到线上了都不知道实际跑的是什么配置。

9. Host与Client:被类型系统逼出来的边界

还有个很有意思的设计:Host和Client分成两个独立的类型检查单元。

为啥要分开?不是因为前后端分离的常规操作,是因为类型层面冲突。

两边都用声明合并往ctx上挂服务,还用同一批键名。放同一个program里,类型定义直接打架。

解决办法不是改键名,是干脆分成两个program,互相看不见。

你敢信?架构边界居然是被类型系统逼出来的。

9.1 边界不靠lint,靠两道门禁

人家也没写什么“client不许import host”的lint规则,靠两道机器检查保证隔离。

第一道是Project Reference面隔离,脚本遍历检查引用对不对。

第二道是客户端构建期纯度门,不在白名单的跨包运行时import直接报错。

为啥不用lint?因为lint查的是语法,这条边界要查的是“运行时会不会真的产生依赖”,这是打包器才知道的事儿。

10. 子Agent:同一个接口,后面可以是另一个产品

子Agent在Harness里就是个普通接缝,提供方有六个。

有同进程新建的,有从当前会话分叉的,还有更狠的:直接委派给Codex、Claude Code,甚至通过SDK委派给别的实现。

也就是说,Codex和Claude Code在这儿的地位,跟本地bash执行器是一样的,都是某个接缝下的一个实现。

接口抽象得足够干净,下面装的是自家函数还是别家产品,对调用方来说没区别。

11. 总结:最值得抄的七个设计

最后给大家提炼一下,最值得迁移到自研Agent里的七个设计,按优先级排:

  1. 注册即可逆副作用。统一原语,返回disposer,插件能装就能卸。

  2. 模型可见即已记录。日志是唯一来源,从根源杜绝上下文不一致。

  3. 能力接缝三角色齐备。接口、实现、消费方缺一不可,拒绝假扩展点。

  4. 把不可推翻编码进类型。守卫只能拒绝不能放行,顺序不影响结果。

  5. 结构性事实一律生成。目录、依赖图都从代码生成,人工只写判断。

  6. 装配做成可寻址条目,加dump命令。配置透明,避免叠加黑洞。

  7. 辅助调用做成主线真前缀。复用缓存,性能反而更好。

11.1 没有银弹:代价也得说

当然,没有免费的午餐。不设特权内核也有代价。

第一,概念负担重。上手得先学Cordis五个概念,不然门都入不了。

第二,边界会出现在意想不到的地方。比如Host和Client的分离,是类型系统逼的,不是业务需求。

第三,装配可见性依赖工具。没有dump命令,你根本不知道最终跑的是什么。

第四,兼容承诺难给。可替换的边界太多,每个都是兼容负担。

所以说,Codex和Harness不是优劣之分,是同一道题的两个解。

一个留着特权内核换演进速度,一个取消特权内核换彻底的可替换性。选哪个,取决于你打算把哪一部分长期钉死。

P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐