归档说明:本文于 2026 年 11 月 12 日上线,页面日期用于系列归档;案例发生时间以正文为准。

2026 年 10 月,我花了不少时间折腾多个 CLI Agent 之间的协作编排:让一个主 Agent 负责拆解和推进任务,把具体子任务委派给另一个 Agent 执行,最后再把结果汇总回来;后来又往链路里加上了前端页面追问、用户补充澄清信息和断点继续执行。

整条调用链路确实顺利跑通了,但紧接着我就撞上了一个更棘手的工程问题:系统里显示的“成功”,到底是什么意思?

很多时候,模型嘴上说“任务已完成”,子进程退出码是 0,工作流节点也亮起了绿灯,但掀开底下的实际产物一看,离真正可用的结果还差着一大截。

这篇文章想记录两个让我印象极深的真实排障案例:一个是多 Agent 在中途等待用户补充信息时怎么把上下文弄丢的;另一个是一套安全修复验证工具,在 60 个测试格全部崩溃(CRASH)的情况下,为什么依然输出了“修复已生效”(CONFIRMED_FIX_EFFECTIVE)。正是这两个案例,逼着我把“进程执行状态”和“业务验收结论”彻底拆开。

一、能让另一个模型干活,只是协作的第一步

最初跑通的最小协作链路,是让 GPT 节点把子任务委派给 Gemini CLI,跑完后再由 GPT 汇总结果。这个原型证明了跨 CLI 调度是可行的,当时配套的初版测试集也顺利跑通了(不过那版评分器还只是演示性质,不能直接等同于完整的业务评测)。

等往流程里加入“向用户追问补充信息”的环节后,真正的麻烦才浮出水面。试想这样一个场景:子 Agent 在执行过程中发现缺少必要参数,于是暂停下来向页面发起提问;等用户在界面上填完补充信息后,这段话应该交回给哪个节点?主 Agent 在最后做汇总时能不能看到这段补充说明?之前已经跑完的其他平级子任务,会不会因为这次恢复执行而被迫重跑一遍?

在早期的真实联调里,我就抓到了一个隐蔽的漏传 bug:用户补充的澄清信息只送到了子 Agent 手里,子 Agent 自己看懂并继续执行了,但在它向主 Agent 返回最终结果时,却没有把这段补充上下文一并带回去,导致主 Agent 在汇总报告时依然按旧假设说话。后来我改了结果交接结构,强制把澄清问答记录随子任务结果一块打包回传,并从头跑了一遍“委派 -> 提问 -> 回答 -> 恢复 -> 汇总”的完整链路,才彻底把这个缺口堵上。

这种问题如果只盯着单次模型调用的日志看,几乎很难察觉:每一段单看都挺正常,唯独拼在一起时漏掉了关键上下文。

二、任务的启停与等待,必须交由确定性程序管理

大模型擅长决定“怎么拆任务、缺什么资料、怎么解读工具返回”,但任务的启动、挂起等待、超时停止和断点恢复,必须由宿主程序用明确的状态机管起来。

比如当子任务停下来等待用户补充信息时,宿主程序应当把该任务标记为挂起状态,并及时释放占用的执行槽位,让其他不相关的任务能继续跑;等用户提交答复后,再精准唤醒刚才挂起的那个节点接着往下走,而不是把整条工作流从头重跑一遍。同理,万一服务中途重启,也不能把上次还没确认结果的外部写操作糊里糊涂地再发一遍。

如果把单条任务的最小状态流画出来,大致如下(仅作简化示意,非项目完整状态枚举):

待执行 → 执行中 → 等待输入 → 执行中 → 已完成
             └→ 失败 / 超时 / 停止

这里最关键的一条经验是:状态机里的“已完成”,仅仅代表这一段子进程或节点跑完了,绝不代表它产出的结果已经合格。 如果把“进程跑完”和“结果验收通过”全塞进同一个绿色的成功图标里,后面排查问题时就会非常痛苦。

顺带提一个在本地调多 CLI 时很容易忽略的边界:复用本机 CLI 工具的登录态,并不等于另一个 Agent 自动拿到了当前对话的全部历史背景。跨角色委派任务时,所需的上下文资料必须显式拼进输入里;反过来,宿主环境里的敏感凭据也绝不能图省事当成普通文本塞进对话上下文。

三、8 条工作流全部“执行成功”,为什么还不算验收通过?

后来在接入一套 Dify 业务工作流评测时,我遇到了一个很典型的分层结果:一轮测试跑下来,8 条工作流全部执行成功(没有抛异常、全部走到了结束节点);但用结构化规则去检查输出时,只有 7 条通过了结构校验;再往深处核对业务内容,里面还留着原始字段值被意外遮挡、向用户发起了不该问的错误追问、以及节点间交接遗漏等毛病。

你看,“8 条执行成功”“7 条结构合格”和“内容仍存在缺陷”,这三件事其实一点都不矛盾:

  1. 调用成功:只说明网络没断、节点没崩、跑到了终点;
  2. 结构合格:只说明模型吐出来的 JSON 符合预期的字段名和类型;
  3. 语义与业务正确:才真正关心字段里填的证据、推导出的结论和下一步动作到底对不对得上任务要求。

如果评测脚本只盯着第一层的“成功调用率”看,后面两层的真实问题就会被彻底掩盖。另外,这种小样本的协作记录也只能用来排查当前链路的设计缺陷,在没有固定相同的输入集、配置、调用预算和判分标准之前,绝不能拿这几次运行结果去给不同大模型排座次。

Agent 结果的三层验收

图 1:运行完成和结构合格是独立检查项。业务结论仍须核对完整证据,执行异常应进入待核实状态。(机制示意,点击图片可放大。)

四、更夸张的一次评估:60 个测试格全崩了,工具却报“修复成功”

今年 11 月在评估一套安全修复复现工具时,我遇到了一个更直观的反例。

那套工具的交付包里带了多个目标版本和一组测试输入,交叉组合成 80 个测试格(test cells)。我先在本地原样跑了一遍离线复测,得到的输出和交付包里的记录完全一致。

但“能跑出一样的结果”只证明了工具可复现,还不代表它的判定逻辑没毛病。为了验证它的异常分支,我另外拷了一份干净的解压副本,做了一个很简单的故障注入:故意删掉了其中三个修复版本的目标入口文件,然后再跑一遍评估。

按理说,入口文件都没了,测试根本没法正常执行。实际运行日志也显示,那三个版本对应的 60 个测试格全部变成了 CRASH(崩溃)。然而到了最后一步汇总报告时,工具却赫然打出了 CONFIRMED_FIX_EFFECTIVE(确认修复生效),而且进程退出码依然是 0!

翻看源码才发现原因非常朴素:汇总函数的逻辑写成了“只要在运行结果里没有检测到原型链污染成功的标记,就一律判定为漏洞已修复”。它压根没去检查目标程序是不是因为缺文件而直接崩掉了:结果就是,被测程序连门都没进去,“没跑出漏洞”就直接被当成了“漏洞已修复”。

(注:这次故障注入测试仅在本地独立副本上完成,用来验证原型工具判定逻辑的缺陷,后续产出了针对性的接入改进提案,当时并未直接改动线上容器或做平台端到端验收。)

五、连喂给测试器的输入本身,都可能半路失真

也是在那次评估里,我还顺手揪出了另一个隐蔽的输入失真问题:测试集里有 6 条专门构造的输入用例,在经过 JavaScript 对象序列化之后,原本声明的 __proto__ 属性在半路上悄无声息地丢了。用例文件里看着明明白白写了这个字段,但真正传进子进程里的对象却成了空壳。

这其实是 JavaScript 语法里的一个经典老坑:在普通对象字面量(Object Literal)里写 __proto__: value,引擎会把它当成修改对象原型的特殊语法,而不会把它创建为对象自身的自有属性(own property);只有在 JSON.parse() 解析 JSON 文本时,__proto__ 才会被保留为普通的自有属性(参见 MDN 对象初始化与 JSON 的区别)。

用几行纯演示代码就能看清两者的差别(仅展示序列化行为,不涉及任何漏洞利用):

const literal = { __proto__: { marker: true } };
const parsed = JSON.parse('{"__proto__":{"marker":true}}');

console.log(Object.hasOwn(literal, "__proto__")); // false
console.log(JSON.stringify(literal));             // "{}"

console.log(Object.hasOwn(parsed, "__proto__"));  // true
console.log(JSON.stringify(parsed));              // '{"__proto__":{"marker":true}}'

这跟上一篇排查 Dify 输入末尾多出换行符(\n)的教训如出一辙:代码里写出来的输入、经过序列化传输后的输入、以及目标程序真正收到的输入,必须逐层核对。 如果测试输入在半路上就已经失真变形了,后面生成的测试报告哪怕格式再整齐,也根本没测到原本想测的逻辑。

六、我现在怎么拆分“运行结果”的数据结构

踩完这两次坑之后,我现在设计评估脚本或多 Agent 任务汇总时,都会强制把“执行状态”“观测完整性”和“最终业务判定”拆成独立的字段保存。以安全验证为例,我倾向于用下面这种结构(仅为本文整理的结构示意):

{
  "execution_status": "crashed",
  "observation_status": "incomplete",
  "finding": "unknown",
  "verdict": "inconclusive",
  "reason": "target_entry_missing"
}

道理其实很简单:当被测程序崩溃或超时的时候,它可能是在触发漏洞的瞬间崩的,也可能是一启动缺依赖就崩了。因此,“执行异常”和“未发现安全问题”绝对不能混为一谈。真要给一个修复版本盖章认定“修复生效”,必须同时满足三个条件:漏洞版本基线确实能稳定复现问题、修复版本确实正常跑完了全部测试步骤、且全过程的关键观测点完整无缺。

把这个逻辑换到普通的业务 Agent 上也完全通用:

  • 外部接口请求超时了,只能记为“查询失败”,不能写成“查询结果为空”;
  • 工具因为没权限被拒了,只能记为“权限不足”,不能写成“目标资源不存在”;
  • 验证步骤压根没跑完,只能记为“待核实(inconclusive)”,不能写成“未发现风险”。

小结:验收前,先故意让它失败一次

现在回头看,做多 Agent 协作和自动化评测,真正花功夫的往往不是“怎么让模型在顺风顺水时跑出结果”,而是怎么处理交接、异常和验收边界。

所以我现在养成了一个习惯:除了跑正常的正向测试样例,每次验收前一定会专门构造几个明确的失败场景:比如故意删掉入口文件、喂一条格式错误的输入、人为掐断网络制造超时、或者拿掉一段关键证据。我就盯着看系统在撞上这些异常时,能不能老老实实地停在 failed 或 inconclusive 并报出准确原因,还是依然面不改色地吐出一份圆满的“成功报告”。

一套系统敢于在证据不足时承认“这一轮没测完”,远比任何时候都凑出一份漂亮报告要可靠得多。