一张手绘架构图,标题写着「Cordis:DeepSeek Harness 的底座」,副标题是「Cordis 负责承载插件、共享上下文与运行时组合,Agent 只是被组合出来的一种能力」。图的正中央是一个云朵状的框,写着 Cordis,里面列了三件事:Plugin Host、Context Bus、Runtime Composer;四周八个拼图块往中心插:Agent、Agent Loop、Session、Tools、LLM、SubAgent、Skill、Sandbox;右上角另有一个虚线椭圆,标着 shared ctx。
这张图信息量不大,但它主张的东西很硬:Agent 不是本体,是一份组合配置。这句话如果只当口号听,等于没说;但如果当真,它会推翻很多团队现在写 Agent 的方式。这篇文章就沿着这张图,把「组合」这件事拆到底:宿主管什么、总线管什么、组合器管什么、八个块各自的位置在哪、以及这张图故意没画的那些麻烦。
一、先破一个误解:Agent 不是一段程序
绝大多数人第一次写 Agent,写出来的东西长这样:一个 while 循环,循环里调一次模型,模型返回要调用的工具名,代码里 if/elif 分发到对应的函数,把结果拼回消息列表,再进下一轮。能跑,也确实完成了任务,于是很多人就此认为「Agent 就是这么回事」。
这种写法最致命的地方不在于它简陋,而在于它把「有哪些能力」和「怎么组织这些能力」焊死在同一段代码里 。想加一个工具,要改分发函数;想换一个模型,要改请求构造;想加一个子代理,要改循环体;想让用户审批危险操作,要在分发函数里插判断;想给某类任务换一套更严格的沙箱,只能复制一份循环。每一次需求变化,改的都是同一处------那个已经长到七八百行的 while。
图上把 Agent 画成一个和 Tools、LLM、Sandbox 平级的拼图块,就是要终结这种写法。它的潜台词是:Agent 只是「Plan、Reason、Act、Reflect」这组行为的名字,承载这组行为的东西不是硬编码,而是被组装出来的一份实例。同一个底座,拼上不同的块,可以是写代码的 Agent,可以是做研究的 Agent,也可以是只会答客服问题的 Agent;三者共享同一套循环、同一套上下文管理、同一套权限逻辑,差别只在插了哪些插件、配了什么参数。
用一句更直白的话说:模型决定「能想多深」,底座决定「能走多远」。模型的水平你只能选,改不了;而一个任务能不能从头跑到尾不崩,八成取决于底座------上下文有没有被正确接续、工具失败有没有被正确处理、危险动作有没有被拦下来、长任务跑到第两百步还记不记得最初的目标。这些没有一件是模型自己能解决的。
所以判断一个 Agent 产品成熟不成熟,有个很实用的问法:它的能力清单是新加一个插件,还是新加一段 if? 如果每次加能力都要动核心循环,那它离「底座」还很远,本质上仍是把一段脚本包装成了产品。
二、Cordis 的三个职责:谁管有没有、谁管通不通、谁管配得对不对
图中心写了三行字,看起来像三个模块名,其实是三个必须回答的问题。
Plugin Host(插件宿主)回答「有没有」。 一个能力要能被用,首先得被加载、被初始化、被注入它依赖的东西、在不需要时被卸载。这些是生命周期问题,和业务逻辑无关,但每一项做不好都会以最难看的方式暴露:依赖没注进去,插件启动时报 undefined is not a function;卸载没清干净,热重载三次之后内存里躺着三份监听器,同一件事被触发三遍。
Context Bus(上下文总线)回答「通不通」。 插件之间要交换信息:Tools 要知道当前会话的权限等级,Agent Loop 要知道 Session 里还剩多少预算,SubAgent 要把结果交回主循环。总线的作用是把这些交换收进一条通道,而不是让插件互相 import 对方的实例。这个区别在三个插件时看不出价值,在三十个插件时就是「能不能重构」和「重构就全崩」的差别。
Runtime Composer(运行时组合)回答「配得对不对」。 同一批插件,组合方式不同就是不同产品。给代码任务配「沙箱 + 文件工具 + 严格审批」,给问答任务配「检索工具 + 宽松审批」,两者用的是同一套零件,只是装配单不一样。组合器负责读这份装配单:实例化哪些、按什么顺序、传什么参数、冲突了谁优先。
把三个职责放在一张表里,各自的失败症状会非常清楚:
| 职责 | 一句话定义 | 做砸了的典型症状 | 修复位置 |
|---|---|---|---|
| Plugin Host | 管能力的加载、初始化、依赖注入与卸载 | 插件启动报 undefined、热重载后重复触发、内存缓慢上涨 | 宿主生命周期与依赖声明 |
| Context Bus | 管插件之间的状态、事件与能力查找 | 插件互相 import 成环、改一个插件崩三个、状态两份不一致 | 总线的服务注册与订阅 |
| Runtime Composer | 管这次任务实例化哪些能力、怎么装配 | 配置改了不生效、A 任务的行为泄漏到 B 任务、开关形同虚设 | 组合配置的解析与优先级 |
这张表对着自查,比看任何架构文档都快:症状落在哪一行,就知道该去改哪一层,而不是继续在 while 循环里加补丁。
值得注意的是,这三件事有一个共同点:它们都不产生业务价值,但都决定业务能不能稳定发生。这很像操作系统------用户感知不到调度器,但调度器一坏,所有程序一起卡。图中把 Cordis 画成承载所有拼图块的中心,而不是画在某一侧,说的就是这个位置关系:它不在业务链路的任何一环上,但每一环都要经过它。
三、Plugin Host:把能力做成可插拔的零件
先看一个真实场景。某团队的 Agent 有二十个工具,加工具的方式是在分发函数里再加一个 elif。某天要支持一个新的数据库,工程师照着现有格式写完,测试通过,上线。三天后发现:每次调这个新工具,之前那个「查询订单」的旧工具也会被触发一次。
原因查出来很荒唐------两边都注册了同名的 query 命令,而分发函数的匹配逻辑是 if name in text,只要文本里出现这个词就命中。这不是某个工程师粗心,而是没有宿主的必然结果:能力是散落在代码里的字符串,谁都可以用任何名字,谁都不对冲突负责。
Plugin Host 要解决的正是这一类问题。它把「一个能力」升级成「一个受管对象」,受管意味着四件事:
第一是注册唯一。 每个插件声明自己的名字和服务,宿主负责检测重名。重名不是覆盖,而是启动期直接报错------因为运行期才发现重名,代价是线上事故;启动期发现,代价是一次编译。
第二是依赖注入。 插件不 import 别的插件,只在 inject 里写「我需要 tools.registry」,宿主按依赖图拓扑排序后依次初始化,把实例塞进它的 ctx。这样插件之间没有静态耦合,删掉一个插件,依赖它的那个会在启动时就报错,而不是在某个深夜的空指针异常里。
第三是生命周期对称。 有 apply 就必须有 dispose,而且必须对称:apply 里 on('event') 几次,dispose 里就 off('event') 几次。这条看起来是编码规范,实际上是内存泄漏的唯一防线。一个跑三十天的常驻 Agent,如果每次会话重建插件都漏一个监听器,第三十天它的每条消息会被触发上万次。
第四是可热插拔。 宿主能在不重启主进程的前提下卸载再加载一个插件,这对调试和灰度极其重要:改一个工具的描述,不该让整个系统重启,更不该把正在跑的会话全部打断。
一个最小的宿主实现,核心就是下面这些:
ts
class Host {
private instances = new Map<string, { plugin: Plugin; ctx: Context }>();
async load(plugin: Plugin) {
const provided = new Set(plugin.provide ?? []);
for (const name of provided) {
if (this.instances.has(name)) {
throw new Error(`service conflict: ${name}`);
}
}
const missing = (plugin.inject ?? []).filter((s) => !this.instances.has(s));
if (missing.length) {
throw new Error(`${plugin.name} missing deps: ${missing.join(', ')}`);
}
const ctx = this.makeCtx(plugin.inject ?? []);
await plugin.apply(ctx);
this.instances.set(plugin.name, { plugin, ctx });
}
}
这段代码不到三十行,但它把上面四件事里的三件(唯一、注入、可检查)都落实了。第四件(卸载)只在末尾补一段 dispose 调用即可。
这里有个容易被忽略的判断标准:宿主是否真的在管事,看它敢不敢在启动期让系统起不来 。愿意为一个重名服务直接抛错的宿主,才是在保护你;把所有冲突都降级成 console.warn 然后继续跑的宿主,等于把事故推迟到最不该发生的时刻。
四、Context Bus:插件之间为什么不许直接说话
图里最值得琢磨的一处,是八个拼图块之间没有任何连线。所有箭头都指向 Cordis,没有一条从一个块直接指到另一个块。
这不是画图省事,而是一条硬规矩:插件之间只能经总线通信,不许互相持有引用。
为什么?设想 Tools 插件需要知道当前会话的权限等级。最直觉的写法是 import { session } from './session',直接读 session.level。三个插件时它很清爽,问题是这个组合会迅速失控:
- 权限等级改了字段名,Tools 崩了;
- 想在测试里单独跑 Tools,必须连 Session 一起起起来;
- 有人为了图方便,在 Tools 里直接改
session.level,于是 Session 的状态有了第二个写入口; - 半年后这四行 import 长成了三十七个交叉引用,任何一次重构都像拆炸弹。
总线把这些交换改成「按名字取,按事件听」:
ts
// 服务式:一次性拿到能力,不需要对方的存在感
const sandbox = ctx.get<Sandbox>('sandbox.exec');
// 事件式:只关心「发生了什么」,不关心「谁发的」
ctx.on('tool:before', (e) => {
if (e.risk === 'high' && ctx.get('session').approval !== 'granted') {
e.cancel('needs human approval');
}
});
第一种解决能力查找,第二种解决横切关注点。权限审批、审计日志、耗时统计、token 计数------这些都该是总线上的监听器,而不是每个工具里复制一遍的 if。一个插件只应该知道自己要做什么,不应该知道谁会来管它,这句话在三个插件时是洁癖,在三十个插件时是能不能活下去。
总线还有两个必须提前钉死的细节,图上没画,但落地绕不过:
一是查找失败怎么办。 ctx.get('sandbox.exec') 在沙箱没装配的时候应该返回什么?抛异常还是返回一个永远失败的桩?前者会让「不装沙箱的轻量模式」直接崩,后者会把配置错误伪装成运行期错误。常用做法是:get 抛异常(配置问题要早暴露),tryGet 返回 undefined(可选依赖用它)。
二是事件顺序与可重入。 监听器里再发事件,会不会形成死循环?同一个事件有十个监听器,其中一个抛错,剩下九个还跑不跑?这些必须在总线实现里定死并写进文档,否则每个插件作者都会按自己的猜测写,最后拼出一个无法预测的系统。
最后一点:总线天然是横切能力的汇聚点 。如果想让所有工具调用都留审计日志,最干净的位置不是给每个工具加一行 log,而是在总线上监听 tool:before 和 tool:after。这一处加完,二十个工具同时有了审计,未来第二十一个工具自动继承。这个「一处生效、全体继承」的特性,才是总线真正的价值------它不只是解耦,它让全局策略第一次变得可写。
五、Runtime Composer:组合不是写死的,是运行时决定的
有了宿主和总线,还差最后一环:这次任务到底该装配哪些插件。
很多人把这一环也写死:入口处 new 一批实例,用完拉倒。问题立刻出现------同一个进程既要服务代码任务又要服务问答任务,两套能力全装上,模型看到二十个工具,选择准确率断崖式下跌;更糟的是权限,问答场景本来不该有写文件权限,但因为「反正都装了」,它有。
组合器的作用是把这一步从代码搬到配置,并且让配置能在运行时生效:
yaml
tasks:
code:
plugins: [sandbox, tools.fs, tools.shell, subagent, agent.loop]
options: { approval: strict, maxSteps: 200 }
research:
plugins: [tools.search, tools.fetch, subagent, agent.loop]
options: { approval: loose, maxSteps: 60 }
配置化带来三个立竿见影的好处:能力最小化 (每个任务只装它需要的,模型看到的工具越少越准)、权限最小化 (不装的插件等于没有这个权限,这是最硬的隔离)、灰度可控(新插件只给 5% 的会话装上,出问题影响面可控)。
但组合器最难的部分不是读配置,而是冲突裁决。同一批插件可能被多个来源声明:全局默认装 A,任务配置也装 A 但参数不同,用户在会话里又临时加了一个 A。三个来源,谁说了算?
成熟的做法是明确定义优先级链,并且禁止隐式合并:全局默认 < 任务配置 < 会话覆盖,后者整体替换前者的同名项,而不是字段级合并。字段级合并看起来很贴心,实际是灾难------你永远不知道生效的那份参数是从哪几层拼出来的,排查时只能靠猜。宁可让用户把参数写全,也不要让他猜。
还有一个常被低估的收益:组合让「失败隔离」成为可能 。如果 Skill 插件崩了,只要 Agent 主链路不 inject 它,整个会话还能继续,只是少了一项能力。硬编码的系统做不到这一点,因为所有能力共享同一段代码,任何一处异常都可能终止循环。这也是为什么图里八块是分开画的------分开画的意义不只是好看,而是每一块的崩溃范围可以被界定。
六、八个能力块:逐个对上号
现在把八个拼图块逐一放到工程位置上。理解每一块「不负责什么」,比理解它「负责什么」更重要。
Agent 这一块只该有行为定义:按什么顺序 Plan、什么时候 Reason、Action 失败后怎么 Reflect。它不该知道工具怎么实现、模型怎么调用。一旦 Agent 里出现 if (toolName === 'shell'),它就已经越界了------工具差异属于 Tools 插件。
Agent Loop 是循环引擎,负责「想---做---看---再想」的节奏控制与终止判定。它最容易犯的错是把业务判断塞进来:比如「如果任务包含部署就多跑两轮」。节奏控制是通用的,业务判断应该由 Agent 或 Skill 提供。
Session 管会话管理、状态持久化、历史。它是唯一有资格回答「上次说到哪了」的插件。常见错误是让 Agent 自己维护消息数组------那样一旦进程重启、或换一个入口进来,历史就断了。
Tools 包含三件事:工具注册表、调用、结果解析。注意第三件事常被忽略:工具返回的原始结果往往又长又脏,直接塞回上下文会瞬间吃满窗口;解析层负责截断、结构化、只留关键字段。没有解析层的系统,跑十轮就会上下文溢出。
LLM 是模型适配器,负责请求路由与流式输出。它的价值在于把模型差异挡在外面:不同厂商的 tool call 格式、不同模型的上下文上限、不同接口的重试语义,全部在这一层归一化。上层代码永远只面对一种格式。
SubAgent 负责委派、协作、结果合并。它存在的理由是上下文隔离:一个需要读五十个文件的子任务,放在主循环里会污染主上下文;交给子代理,它烧自己的窗口,只把结论带回来。合并策略是关键------子代理返回的不是「一段话」,而应该是结构化结果,否则主循环还得再解析一遍自然语言。
Skill 是可复用技能,带参数结构与输出结构。它和 Tools 的区别值得说清楚:Tools 是能力(能做什么),Skill 是方法(怎么做)。一个 Skill 通常编排多个 Tools,并规定先做什么后做什么、失败重试几次。把 Skill 当 Tool 用,会得到一个参数多到没人会填的巨型工具;把 Tool 当 Skill 用,会得到一段无法复用的脚本。
Sandbox 负责代码执行、隔离与资源限制。它必须假设自己执行的是敌意代码:不限网络就等着被当成代理跳板,不限文件系统就等着被读走密钥,不限 CPU 就等着被一行死循环拖垮整台机器。这三条任意一条缺失,沙箱就不该被称为沙箱。
把八块这么摆开之后,会发现一个规律:越靠近中心的块越通用,越靠外的块越具体。Sandbox、Skill 几乎是纯工具性的,Agent、Loop 则承载了产品性格。这也解释了为什么同一套底座能长出完全不同的产品------变的是靠外的几块,靠内的几乎不动。
顺带提醒一句:装配单本身要纳入版本管理。它已经是一份会直接影响线上行为的产物------改一个 approval 字段就可能放行危险操作,删一个插件就可能让某类任务直接不可用。把它当成代码一样做变更记录与回滚,是组合式架构必须付的代价,也是它最大的隐患:能力越容易装配,就越容易在没人注意的时候被装配错。
七、shared ctx:唯一不是插件的那个东西
图上唯一用虚线画的是 shared ctx。这个选择很有讲究:八个实线拼图块是插件,虚线椭圆不是------它没有拼图口,因为它不是可插拔的零件,而是宿主自带的一块地基。
它的定义写得很短:「跨插件共享的上下文、记忆、资源与状态」。四个词,每一个都是硬骨头。
上下文:当前任务的目标、已确认的约束、用户的偏好。它不同于消息历史------历史是流水账,上下文是提炼后的结论。把两者混在一起,是最常见的性能灾难:历史越跑越长,每一轮都要把几万 token 的流水账重新喂一遍。
记忆:跨会话存活的东西。这里必须分层,否则会退化成两种失败:全都不记,每个新会话都从零开始,用户每次都要重复说明背景;全都记,三个月后记忆里堆着几千条互相矛盾的旧事实,模型比不记还糊涂。实践中的分法是:会话内短记放上下文,任务级放会话存储,长期事实显式写入并带时间戳与来源,过期即淘汰。
资源:预算。token 配额、工具调用次数、沙箱资源、时间上限。这些必须由宿主集中记账,不能让每个插件自己算------否则十个插件各自限流,合起来仍然会打爆配额。
状态:谁在跑、跑到第几步、哪些步骤已经完成。这是断点续跑的前提。一个跑两小时的任务,如果第 90 分钟进程重启后一切都丢了,那它就只能跑短任务。
shared ctx 最关键的设计问题是寿命。它什么时候被创建?跟着会话走,还是跟着任务走?什么时候被清理?如果一个 ctx 永不清理,长任务跑三百步之后,里面会堆满前两百步的中间产物,模型在这一团浆糊里找当前目标,准确率必然下降。
一个可用的规则是:ctx 只保留「对下一步仍然成立」的东西。已经完成步骤的详细输出应该被压缩成一句结论;被证伪的假设应该被删除而不是留在那里;每完成一个阶段做一次整理。这件事最好由宿主在固定的检查点自动做,而不是指望 Agent 自己想起来------模型在处理具体任务时,几乎不会主动清理自己的上下文。
这也解释了一个反直觉的现象:很多 Agent 在第十轮表现最好,在第一百轮突然变笨。原因不是模型能力下降,是共享上下文被历史噪声淹没了。质量的下滑往往发生在「上下文管理与任务复杂度不匹配」的时候,而不是发生在模型变弱的时候。
八、这张图没画出来的三件事
把图当真之后,会发现它作为架构图有三个明显的空洞。它们都不影响理解,但都会影响实施。
第一个洞:中心是单点。 八个插件全向 Cordis 收口,意味着总线一堵全堵。真实系统必须回答:总线的单次调用有没有超时?超时后是重试还是降级?某个插件卡死会不会拖垮整个循环?图里最需要一条「旁路」,偏偏一条都没画。工程上的对策通常是:总线调用强制带超时、慢插件异步化、关键路径(模型调用)与旁路(日志、统计)分离,绝不放在同一条链路上。
第二个洞:缺权限层。 图里只有 Sandbox 的 Resource Limit,那是资源账,不是权限账。资源限制能防止「把机器跑挂」,防不住「把用户文件删光」。权限必须独立成层,回答三个问题:哪些操作可以自己做?哪些必须问人?批一次能管多久?而且权限判定应该在总线上做(比如监听 tool:before),而不是散落在每个工具内部------散落就意味着一定会漏。
第三个洞:缺可观测性。 一个跑两百步的任务失败后,你要怎么知道它死在哪?图里没有任何位置放 trace、日志、指标。缺了这一层,所有「Agent 表现不稳定」的讨论都会退化成猜谜。最小可用的做法是:每个循环步骤记录一次结构化事件(第几步、调了什么、耗时、结果摘要、token 消耗),任务结束时能回放整条链路。这一步投入不大,回报极高。
把三个洞补上,这张图才算完整。它们有一个共同特点:都属于「约束」,不属于「能力」。而 Harness 这个词的含义,恰恰就是约束那一半------能力的堆叠谁都会做,让能力在失控时不至于崩掉,才是工程。
九、一次完整的推演:从一句话到一次代码修改
前面拆的都是零件,现在把它们装回去跑一遍,看真实的调用链长什么样。场景很简单:用户说「这个接口返回 500,帮我找到原因并修掉」。
第一步是组合,不是推理。 收到这句话时,宿主先做的是解析这份会话属于哪类任务,然后按装配单实例化插件:需要读写文件,装 Tools;需要跑测试,装 Sandbox;需要探索多个模块,装 SubAgent;需要改代码,装 Agent Loop 与 Agent;LLM 一直装,因为它是一切的前提。这一步发生在模型被调用之前------也就是说,在模型第一次思考之前,「它这次能用什么」就已经定了。这是组合式架构最重要的性质:能力在进入循环之前被确定,而不是在循环中被临时发现。
第二步是建上下文。 Session 创建一条会话记录,shared ctx 写入三件事:目标(修掉这个 500)、约束(不要动生产配置)、预算(最多 60 步、最多 200 次工具调用)。这三件事后面会被反复读取------注意,是被读取,而不是被重新推导。很多系统的「跑着跑着偏了」,根子在于每轮都让模型从零推测目标,而不是把目标作为稳定事实喂回去。
第三步进入循环。 Loop 把上下文交给模型,模型输出一个计划:先看日志、再看路由代码、再复现。这三个动作分别落到 Tools 的日志查询、文件读取、Sandbox 的测试执行上。注意这里 Agent 的职责只是「决定下一个动作是什么」,具体怎么读日志、怎么跑测试,它一概不管------管了就越界了。
第四步遇到失败,这是最关键的分叉。 模型跑测试时得到一个超时,而不是期望的 500。此时系统面临三种处理方式:把原始报错直接塞回上下文(最省事,也最容易让模型开始瞎猜)、把报错解析成结构化错误(推荐,模型拿到的是「超时 30s,可能是依赖服务未启动」,而不是三千行堆栈)、或者由 Loop 判定这类错误该重试几次。三种方式的差别在第十轮才会显现:前两种会让上下文迅速被原始输出填满,第三种能撑得久。
第五步是委派。 如果涉及五个模块的排查,主循环继续做会污染上下文。这时 SubAgent 出场:给它一个明确子目标(找出路由层里返回 500 的所有分支)、一份裁剪过的上下文(只相关文件,不含整轮历史)、一个返回结构(文件、行号、原因、置信度)。它烧自己的窗口,只把四行结论交回来。这是上下文管理最有效的一招------把噪声隔离在子上下文里,让主上下文只保留结论。
第六步是审批。 模型决定改一个配置文件。权限层在总线上拦下这个动作,判定为高风险,向用户请求确认。用户同意后,这次批准会带一个作用域(仅本次、仅这个文件),而不是「永久放行」。这个细节决定了系统是可控还是失控:永久放行用三次之后,等于没有审批。
第七步是收尾。 修改完成,Session 写入一条记录:改了什么、为什么、怎么验证的。shared ctx 被压缩------前六步的详细输出丢弃,只留「根因是 X,已改 Y,测试通过」。下一次会话接续时,模型看到的是这一段干净摘要,而不是几百条工具输出。
整条链路走完,可以看到八个块的分工几乎没有重叠:Agent 决定做什么,Loop 控制节奏,Session 记录历史,Tools 提供动作,LLM 提供语言与推理,SubAgent 隔离噪声,Skill 提供方法,Sandbox 提供安全的执行环境。任何两块之间的职责只要粘在一起,这条链路就会在某个环节断掉。
而这也是为什么「一句话改代码」这种事,只有在装配正确时才好用。同一个模型,同样的话,装配单里少一个 Sandbox,它就敢直接跑未经验证的脚本;少一个 Session,它重启后就忘了自己改到哪;少一层审批,它会把配置文件改坏而无人知晓。模型没变,结果天差地别------这句话就是对整张图最直白的注解。
十、六个常见误读
误读一:把 Skill 当 Tool 用。 于是得到一个有十七个参数、没人知道怎么填的巨型工具。正确做法是 Tool 只做原子动作,Skill 负责编排。判据是:如果一个工具的描述里出现「先...然后...再...」,它就该是 Skill。
误读二:把 shared ctx 当消息历史。 于是每轮都把完整流水账喂进去,token 爆炸且模型抓不住重点。上下文应该是提炼后的结论,历史是原始事实,两者存储位置和生命周期都不同。
误读三:以为插件越多越强。 恰恰相反,模型能稳定选择的工具数量是有限的。二十个工具的系统通常还不如八个工具加一个 Skill 编排来得准。能力多不等于解题能力强。
误读四:以为经总线通信会拖慢系统。 真正的性能杀手是串行等待,不是多一层转发。内存中的服务查找耗时以微秒计,而一次模型调用是秒级------把总线开销当成瓶颈,通常是找错了方向。
误读五:以为组合就是一堆开关。 开关只改变参数,组合改变的是隔离边界。不装沙箱和把沙箱参数调小,是完全不同的两件事:前者是能力不存在,后者是能力被限制。安全上,前者才可靠。
误读六:以为 Harness 是一个框架。 框架给你接口,Harness 给你约束。接口决定你能写什么,约束决定你写的东西不会在半夜把系统搞挂。这两件事都不便宜,但只有后者需要真正的工程判断。
误读七:以为「先跑通再补工程」是合理的节奏。 跑通只需要一个循环,补工程要拆宿主、拆总线、拆组合,成本是十倍以上。更现实的做法是先跑通,但从第一天就把边界画出来:插件之间不互相 import、工具调用统一走一个入口、危险动作统一在一个地方拦。这三条几乎不花额外时间,却决定了后面能不能补得动。等代码长到三千行再回头拆,多数团队的选择是「算了,重写一版」------而重写那一版,通常又会走一遍同样的路。
十一、从图到代码:一份可执行的落地清单
如果读完上面这些,真打算按这张图改自己的 Agent,建议按下面的顺序动。顺序很重要,因为后一步依赖前一步的产出。
第一步:把能力清单列成表,而不是留在代码里。 每一行写清名称、做什么、需要什么、提供什么。做完这一步你会发现,很多「能力」其实是同一段逻辑的三个入口,那它们该合并成一个插件加三个调用方式。
第二步:划出宿主的边界。 决定哪些东西归宿主(生命周期、服务注册、配置解析),哪些归插件(业务逻辑)。判据很简单:如果一个能力的实现细节变了,会不会迫使你改宿主?会,说明边界画错了,有东西泄漏了。
第三步:先把总线做出来,再接插件。 顺序反了会付出代价:先写插件再补总线,你会得到一个「插件之间已经互相 import 了」的系统,重构总线等于重写插件。正确顺序是先定服务名与事件名,再写插件。
第四步:给每个插件写 dispose,并从第一天开始测。 测法很土:加载、卸载、再加载,重复十次,看监听器数量与内存是否线性增长。这个测试五分钟能写完,能省掉三个月后的内存排查。
第五步:让组合从配置来。 哪怕只有一个任务类型也要这么做,因为这一步决定了未来加第二个任务类型的成本。加第二个任务只需要复制一段 yaml 时,这套架构就成立了。
第六步:补权限与可观测。 两者都在总线上做,一处生效、全体继承。审计日志从第一次上线就开,不要等到出事才想起来补------补出来的日志永远缺最关键的那几步。
第七步:给共享上下文定寿命。 明确什么时候清理、保留什么、丢弃什么。这条最容易被跳过,也是后期体验下滑最主要的来源。
第八步:把每一步留痕。 在循环的每个动作上记一条结构化事件:第几步、调了什么、耗时多少、结果摘要、花了多少 token。这件事的好处不体现在正常情况下,而体现在失败之后------同一个任务跑了三次,两次成功一次失败,只有留痕才能看出差别发生在哪一步。没有轨迹的系统,优化只能靠猜,而猜出来的改动通常会引入新的问题。最小可用的日志不需要任何框架:一个追加写的 jsonl 文件,每行一条事件,任务结束时把整条链路串起来看。
把七步压缩成一张判据表:
| 阶段 | 完成判据 | 没做到的症状 |
|---|---|---|
| 能力清单 | 加能力只改配置,不改循环 | 每次需求都要动核心代码 |
| 宿主边界 | 换一个插件实现不必改宿主 | 宿主里出现具体业务分支 |
| 总线先行 | 插件之间零 import | 重构一个插件要动五个 |
| 生命周期 | 装卸十次无泄漏 | 长跑后重复触发、内存上涨 |
| 组合配置 | 新产品 = 新 yaml | 一个进程里塞两套逻辑 |
| 权限可观测 | 总线一处拦截全部生效 | 审计日志缺口、出事无法复盘 |
| 上下文寿命 | 长任务第 200 步仍不跑偏 | 前期聪明、后期失忆 |
这张表的好处是每个格子都能当场验证,不需要争论「我们的架构好不好」。对着表逐行问「能不能做到」,做不到的那行就是下一步该做的事。
十二、结语:Agent 是配置,Harness 是约束
回到那张图。它最有价值的地方不是画了八个块,而是把 Agent 从「一个需要被设计的主体」降格成「一个被组装出来的结果」。这个视角转换的意义在于,它把讨论从「怎么让模型更聪明」拉回到「怎么让系统更可控」------前者你使不上力,后者全是工程问题。
但也要看清它的局限。图上八个块、三行字,是能力视图;而一个能长期跑的系统,真正耗掉工程师时间的从来不是能力,是约束:依赖乱了怎么办、总线超时怎么办、危险操作用户点错了怎么办、上下文涨到一百万 token 怎么办、跑到第两百步偏了怎么办。这些在图上一个字都没有,却决定了这个 Agent 是「演示能跑」还是「生产敢用」。
所以更完整的说法是两句话:Agent 是配置,Harness 是约束。 前半句决定你能不能快速长出新产品,后半句决定你的产品会不会在第四个月崩掉。只做前半句,得到的是一个能演示的系统;只做后半句,得到的是一个能跑但没人想用的系统;两句都做,才有可能同时具备迭代速度与稳定性。
最后给一个自检问题,比任何架构评审都有效:把你的核心循环里所有的 if 列出来,逐个问它属于哪个插件。 凡是答不出来的,就是还没被拆干净的地方。等所有 if 都能归到某个插件、或者归到总线上的某个监听器时,这张图才算真的被实现了一遍------而不是被画了一遍。这两者的差别,通常要等到第一次线上事故之后才会显形,而那时往往已来不及补了。