验证全通过,我们一集成就崩了。
日志里没有任何异常。
(工程复盘:基于有限样本的复现与观察,结论未必可泛化。)
我们的多 Agent 协作系统有一套完整的验证协议:Agent 生产代码,下游 Agent 验证,验证通过后冻结产物。整条链路运行良好——事件日志里全是 pass,流程三轮稳定收敛。
然后我们把六个 Agent 的产物拼在一起运行,游戏直接崩溃了。
日志里没有任何异常。
问题#
我们研究的系统是一个多 Agent 协作平台。六个 Agent 各自负责一个模块(物理引擎、关卡数据、玩家控制、敌人行为、游戏状态、集成入口),通过一套结构化协议协作:
- PRODUCE – 每个 Agent 生产自己的代码模块
- VERIFY – 下游 Agent 验证上游的产物
- FREEZE – 系统根据验证结果决定是否冻结产物
所有协调事件写入一个共享事件日志。我们对这条日志做实时分析,计算协作健康度指标,检测失败模式。
在两次独立运行中,我们遇到了同一类致命问题:
第一次(Run 1): Agent B 实现了 loadLevel() 函数,返回关卡数据时做了"归一化"——只保留旗帜的 {x, y} 坐标,丢弃了 width 和 height。Agent E 的 checkWin() 用 AABB 碰撞检测判断通关,需要 flag.width 和 flag.height。flag.width 是 undefined,flag.x + undefined 等于 NaN,碰撞判定永远为 false。结果:游戏可以运行,但永远无法通关。
第二次(Run 2): Agent E 实现了 getDifficulty() 函数,返回一个对象 { speedMultiplier: 1.0, senseMultiplier: 1.0 }——从设计角度看完全合理,对象比单个数字更具扩展性。Agent F 的 game.html 拿到返回值后执行 difficulty.toFixed(1) 显示难度。对象没有 .toFixed() 方法,TypeError 抛出,构造函数中断,游戏循环未启动。结果:打开页面只有蓝色天空背景,无任何游戏元素。
两次运行中,事件日志里的 VERIFY 事件全部是 pass。
这不是验证质量差。我们逐份分析了验证报告:其中有的 Agent 贴了完整源码、列了七组输入输出预期、做了交叉验证,甚至诚实声明了"环境未安装 Node.js,基于静态分析"。报告中的技术判断与源码事实 100% 匹配。
验证做得很认真。但它检查的维度不对。
探因:一个信息论约束#
VERIFY 的语义是接口兼容性检查:函数是否存在?参数签名是否匹配?基本逻辑是否合理?
但有一类问题完全超出这个语义的分辨率:
| 维度 | VERIFY 能覆盖 | VERIFY 不能覆盖 |
|---|---|---|
| 函数是否存在 | 可以 | – |
| 参数签名是否匹配 | 可以 | – |
| 基本逻辑是否合理 | 可以(浅~具体) | – |
| 返回值结构是否匹配消费方预期 | – | 不能(2 次复现) |
| 数据管道中属性是否保留 | – | 不能(2 次复现) |
两次复现确认了同一个模式:生产方的实现本身合理,消费方的期望本身合理,但两者对数据结构的隐含假设不一致。 VERIFY 在没有参照物的情况下,无法判断这种不一致。
我们进一步分析了事件日志的信息结构,得出了一个更根本的结论:
这不是验证做得不够好的问题,而是事件模型本身的信息论约束。
PRODUCE事件不包含返回值的类型或结构信息VERIFY事件不包含"检查了什么"和"没检查什么"- 事件日志中零信息量支撑数据契约级的判断
换句话说:即使我们对事件日志做再精巧的离线分析,也不可能从中推断出 getDifficulty() 应该返回 number 还是 object。信息从未进入事件流。
我们尝试构建的第一条检测规则(VERIFY 图结构完整性)在专门设计的测试运行中被证明永远不触发——因为它试图检测的故障已被工程修复消除。我们正式淘汰了这条规则,记录结论为 RULE_INEFFECTIVE。
到这里,我们面对一个选择:宣布"这类错误无法被检测",或者改变事件模型本身。
做法:让事件日志多带一个字段#
我们的解法分两层。
第一层:在任务定义中声明数据契约。
在多 Agent 协作的任务描述文件中,新增一个"数据契约"表,由任务设计者(不是 Agent 自己)声明跨 Agent 的数据接口期望:
| 生产方 | 函数/接口 | 消费方 | 消费方期望 |
|-----------|-------------------|-----------|-----------------------------------------------|
| Agent E | getDifficulty() | Agent F | 返回 number(0.5~3.0),用于 toFixed() 显示 |
| Agent B | loadLevel(n) | Agent E | 返回对象含 flag.x, flag.y, flag.width, flag.height |这张表给了验证行为一个参照物。Agent 不再只检查"函数存在且可调用",还要检查"返回值是否符合消费方的期望"。
第二层:在 VERIFY 事件中新增 contract_check 字段。
当验证方执行了数据契约检查后,在 VERIFY 事件中标注结果:
contract_check: "pass"– 契约匹配contract_check: "fail"– 契约不匹配contract_check: "not_checked"– 本次验证未检查契约(验证方不是契约表中的消费方)
这个字段是可选的。不带此字段的 VERIFY 事件与之前版本完全兼容。冻结判定逻辑不变——contract_check=fail 不阻塞冻结,只产生告警。
系统在冻结阶段自动扫描这些字段,生成两种告警:
| 告警类型 | 触发条件 | 级别 |
|---|---|---|
| CONTRACT_MISMATCH | contract_check = fail | HIGH |
| CONTRACT_NOT_CHECKED | contract_check = not_checked | MEDIUM |
所有告警自动输出到两个文件:一个机器可读的 JSON,一个人可读的 Markdown,与事件日志同级存放。
效果#
我们在第三次运行中首次启用了这套机制。同样的任务、同样的 Agent 角色分配、同样的模型。68 个事件,三轮闭合。
事件序列第 29 号记录了这样一条事件:
seq 29: Agent F VERIFY Agent E result=pass contract_check=failAgent F 检查了 getDifficulty() 的返回值契约,发现返回的是对象而不是数字。这正是前两次运行中导致游戏崩溃的那个 bug。
在旧版协议下,这条事件只有 result: pass。工程师必须手动把所有模块拼在一起、在浏览器里打开、看到崩溃、打开控制台调试——才能找到问题。事件日志中的信息量是零。
在新版协议下,系统自动生成了告警文件:
# Alerts
**4 alerts** (1 HIGH, 3 MEDIUM)
## HIGH -- Contract Mismatch
### seq 29: Agent E getDifficulty() → Agent F
- **Contract**: getDifficulty:number
- **Artifact**: game_state.js
- **Evidence**: verify_agent-e_round2.md
## MEDIUM -- Contract Not Checked
- Agent D (verifier: Agent B) -- VERIFY pass 但未执行契约检查
- Agent C (verifier: Agent D) -- VERIFY pass 但未执行契约检查
- Agent A (verifier: Agent C) -- VERIFY pass 但未执行契约检查
## How to Act
HIGH alerts block release. Fix the data contract violation before shipping.
MEDIUM warnings suggest expanding VERIFY coverage in next run.工程师打开这个文件,一屏看完:
- HIGH = 不能发布。
getDifficulty:number告诉你哪个函数、什么类型。直接打开game_state.js修。 - MEDIUM = 覆盖盲区。 三个 Agent 的验证没有执行契约检查——下次运行时补上。
- Evidence 可追溯。 点开验证报告文件,看到验证方的完整分析过程。
前后对比:
| 维度 | 旧版 (v0.1) | 新版 (v0.2) |
|---|---|---|
| getDifficulty 类型不匹配 | 不可见 – 事件日志只有 VERIFY: pass | 可见 – contract_check=fail + HIGH 告警 |
| 发现时机 | 集成后运行崩溃 | VERIFY 阶段(冻结前) |
| 工程师动作 | 拼代码 → 浏览器打开 → 崩溃 → 调试 | 读告警文件 → 定位函数 → 修改 |
| 覆盖盲区识别 | 无 | 3 条 MEDIUM 标记哪些验证未检查契约 |
两次复现的差异#
值得注意的是,虽然两次失败的根因机制相同(数据契约不匹配 + VERIFY 无法覆盖),但它们的表现形式不同:
| 维度 | Run 1 | Run 2 |
|---|---|---|
| 不匹配类型 | 属性丢失(flag 缺少 width/height) | 返回类型错误(object 而非 number) |
| 生产方逻辑 | “归一化时过滤未知字段”(合理) | “返回对象更具扩展性”(合理) |
| 消费方期望 | “碰撞检测需要宽高”(合理) | “显示难度需要 .toFixed()"(合理) |
| 游戏表现 | 可运行但永远无法通关 | 完全无法启动 |
| 报错位置 | 无报错(NaN 静默传播) | TypeError 在构造函数中抛出 |
Run 1 更阴险——游戏看起来完全正常,只是永远不能通关。没有报错,没有崩溃,只有一个永远为 false 的碰撞判定。这种静默失败比 Run 2 的显式 TypeError 更难排查。
两次复现证明这不是偶发事件,而是一个结构性问题:当多个 Agent 对同一数据结构持有不同的隐含假设时,纯接口级验证无法检测到这种分歧。
局限与未解决的问题#
第一,数据契约必须由人来声明。 当前方案依赖任务设计者在任务定义中提前列出跨 Agent 的数据契约。系统不会自动发现"哪些函数的返回值被其他 Agent 使用”——这需要人对任务结构的理解。如果设计者漏掉了一条契约,系统不会报错,只是那个维度退化回旧版的不可见状态。
第二,contract_check=fail 不阻塞冻结。 这是有意的设计——为了保持与旧版协议的兼容性,也为了避免误报导致所有运行都失败。但这意味着工程师可以忽略 HIGH 告警继续发布。告警的权威性取决于团队纪律,而不是系统强制。
第三,我们只在一个任务类型上验证了这套机制。 所有证据来自游戏开发任务(多 Agent 协作构建一个平台跳跃游戏)。数据契约在这类任务中表现突出,因为函数返回值的结构直接影响运行时行为。在其他类型的任务(文档编写、旅行规划)中,“数据契约"可能不是主要失败模式。
第四,我们没有跑过无协议对照组。 所有运行都在结构化协议下进行。我们无法断言这些失败模式是多 Agent 协作的固有属性,还是我们特定协议设计的产物。这是整个项目的一个根本性局限。
第五,Agent 是否真的在"验证”? 我们分析了六份验证报告,质量方差很大:最差的只有三句套话,最好的贴了完整源码和七组测试用例。验证报告中声称的检查行为是否真的执行了,我们无法完全确认(一个 Agent 声称运行了 node -e 测试,但另一个 Agent 诚实声明环境中没有 Node.js)。
开放问题#
一个更深层的问题是:数据契约能否被 Agent 自行发现,而不是由人预先声明?
我们已经观察到 Agent 会在验证过程中产生对数据结构的"理解"——比如"我理解 getDifficulty() 应该返回一个表示难度系数的值"。如果两个 Agent 对同一个函数的理解不一致(一个认为返回数字,一个认为返回对象),这种分歧本身就是一条信号。
我们正在设计的下一个机制是让 Agent 把这种隐含理解作为一级事件写入事件日志,然后自动比对多个 Agent 的理解是否一致。分歧被标记为"歧义",交由人确认后固化为正式契约。
这条路走通的话,数据契约声明可以从"人写好喂给系统"逐步演化为"系统从 Agent 行为中提炼、人确认后生效"。
但这还只是一个方向,不是已验证的结果。
如果你也在构建多 Agent 协作系统,可以试一件事:检查你的验证协议在哪些维度上有信息量,在哪些维度上是空白的。不是所有 VERIFY: pass 都意味着安全。 有些错误不是验证做得不好——而是验证从未被设计来检测它们。
发布建议: 技术博客 | 脱敏状态: 已完成