验证全通过,我们一集成就崩了。

日志里没有任何异常。

(工程复盘:基于有限样本的复现与观察,结论未必可泛化。)

我们的多 Agent 协作系统有一套完整的验证协议:Agent 生产代码,下游 Agent 验证,验证通过后冻结产物。整条链路运行良好——事件日志里全是 pass,流程三轮稳定收敛。

然后我们把六个 Agent 的产物拼在一起运行,游戏直接崩溃了。

日志里没有任何异常。


问题#

我们研究的系统是一个多 Agent 协作平台。六个 Agent 各自负责一个模块(物理引擎、关卡数据、玩家控制、敌人行为、游戏状态、集成入口),通过一套结构化协议协作:

  1. PRODUCE – 每个 Agent 生产自己的代码模块
  2. VERIFY – 下游 Agent 验证上游的产物
  3. FREEZE – 系统根据验证结果决定是否冻结产物

所有协调事件写入一个共享事件日志。我们对这条日志做实时分析,计算协作健康度指标,检测失败模式。

在两次独立运行中,我们遇到了同一类致命问题:

第一次(Run 1): Agent B 实现了 loadLevel() 函数,返回关卡数据时做了"归一化"——只保留旗帜的 {x, y} 坐标,丢弃了 widthheight。Agent E 的 checkWin() 用 AABB 碰撞检测判断通关,需要 flag.widthflag.heightflag.widthundefinedflag.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=fail

Agent 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.

工程师打开这个文件,一屏看完:

  1. HIGH = 不能发布。 getDifficulty:number 告诉你哪个函数、什么类型。直接打开 game_state.js 修。
  2. MEDIUM = 覆盖盲区。 三个 Agent 的验证没有执行契约检查——下次运行时补上。
  3. 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 都意味着安全。 有些错误不是验证做得不好——而是验证从未被设计来检测它们。


发布建议: 技术博客 | 脱敏状态: 已完成


权威链接https://gameai.one/zh/thoughts/verification-pass-integration-fail/
许可CC BY 4.0
引用Cookfl(2026-01-30)《验证都通过了,游戏一集成就崩:多 Agent 协作里一个看不见的盲区》GameAI.one:https://gameai.one/zh/thoughts/verification-pass-integration-fail/
更正/侵权联系hello@gameai.one