English version: Multi-agent adversarial review as an engineering practice。“LLM 应用工程”系列第 13 篇。
问题:LLM 产出的 finding,典型失效是“像真的”
用 LLM 审查代码,产量从来不是瓶颈——让它对一个代码库列五十条“潜在问题”毫不费力。瓶颈在另一端:LLM finding 的典型失效模式是 plausible-but-wrong。行号具体、失败叙事完整、修复建议成套,但拿到代码里对质就散了:护栏其实已经存在、所谓的竞态路径根本走不到、“泄漏”的资源在另一处早被回收。
没有核验环节的审查会被假 finding 淹没,而假 finding 的代价是双向的:要么逐条人工证伪,审查省下的时间全数赔回;要么开始整体忽略报告,真问题跟着陪葬。后者更常见也更致命——一份真假混杂的报告,工程信息价值趋近于零。
另一个独立的问题是广度:单一视角的 review——无论人还是单个 agent——无法在安全、并发、计价、跨端一致性这些维度上同时保持警觉。
两个问题指向同一个结构性解法:把“找问题”和“核实问题”拆成两段目标相反的流程——找的一方求全,核的一方求真。本文以一个生产项目的两次实跑(一次全项目审计、一次会话级审查)加一组翻案数据为实证,拆解这套流程。
结构:按维度并行发现,逐条对抗核实
第一段:N 个审计员并行,每人只守一个维度。全项目审计用了 6 个(后端 core、API-DB-安全、LaTeX-verify-distill、前端、测试盲区、文档漂移);会话级审查用了 4 个(并发/runs、安全、前端回归、数据完整性)。按维度切分的意义不在并行提速,而在视角强制:一个只看并发的审计员,不会被功能正确性分走注意力。
finding 有格式纪律:每条必须落到 file:line + 具体失败场景 + 修法。这一条是后面一切的前提——它把 finding 从“感觉不对”变成可证伪命题。
第二段:每条 finding 交给独立核验者。核验者不是产出这条 finding 的那个 agent,不带着它的推理过程进场;指令是默认怀疑、专攻证伪——去代码里找这条 finding 不成立的证据。裁决三值:CONFIRMED / uncertain / dismissed,只有 CONFIRMED 进入修复队列。
独立性是必需而非可选。同一个 agent 自查自纠时,自己的 finding 就是上下文里的沉没成本,它会为之辩护;把“翻案成功”设为核验者的成功判据,激励才对齐。这与系列第 9 篇讨论的评委效度是同一件事的两面:核验者本身就是一种评委,它的效度要靠“确实翻得了案”来证明——下文有数据。
两次实测
| 全项目审计 | 会话级审查 | |
|---|---|---|
| 对象 | 整个代码库(后端+前端+文档+测试),重点是更广/更老的部分 | 单会话改动:44 文件 / 1467 行,含一次核心路径重构 |
| 审计员 | 6,按子系统/维度 | 4,按风险维度 |
| CONFIRMED | 27(5 高 / 14 中 / 8 低) | 7 |
| uncertain / dismissed | 0 / 2 | 其余全部证伪 |
| 处置 | 修 24,显式 defer 3 | 修 7 |
| 修复后验证 | pytest 243 passed(+6 新测)、vite build 通过 | pytest 18 passed + 定向实测 |
数字之外,更值得看的是问题的类型——确认清单里有相当一部分,靠单人通读 diff 很难抓到:
- 409 并发护栏的 TOCTOU 竞态(会话级):“检查是否已有 running run”与“创建 run”非原子,并发请求可以双开。修法是把判断、创建、登记句柄整体移进
runs._LOCK;修复以实测收口——并发两请求返回[200, 409]。 - chat 计价用了未解析的 provider(会话级):
DEFAULT_PROVIDER=anthropic且请求省略 provider 时,按 deepseek 的单价计费,约 60× 低估。provider 解析在一处、计价在另一处,单模块 review 两边各自“看起来都对”。 - Anthropic 流式
max_tokens=32000而 DeepSeek 是 64000(全项目,中):长报告在其中一家静默截断,靠跨 provider 一致性对照才现形;修为统一_STREAM_MAX_TOKENS=64000。 - 出图沙箱环境 POSIX 硬编码(全项目,高):Windows 分发包(主平台)上 CPython 子进程因缺 SystemRoot/TEMP 起不来,
make_figure必崩。 EXPOSE_RESET_TOKEN默认 true(全项目,高):任意匿名者可拿重置令牌改密,构成账号接管;改为安全默认 false。
这些问题分别需要并发时序推理、跨模块数据流追踪、跨 provider 对照、跨平台运行时知识——正是“每维一个专职视角”覆盖得到、单人一遍通读容易漏的类别。
处置侧还有一个值得抄的细节:3 条 defer 不是沉默的遗漏,而是带理由的显式决策——均为 SaaS-only 问题,在产品出货的 STANDALONE 形态里被 _no_cloud/强制 local 中和,硬修反而要动一批非 STANDALONE 的测试语义,故留档待重启 SaaS 时处理。审查的产出不只是修复,还包括这类可追溯的“为什么不修”。
核验不是橡皮图章:翻案数据
对抗核验环节是否真的在工作,需要它自己的证据。同一套“发现 → 对抗复核”结构曾用于另一类对象:审计蒸馏实验(系列第 12 篇)产出的写作规则是否真在学“判断”、而非结构模板换皮。共 11 个 agent:每对规则先逐条初审分类,再做对抗式复核,指令同样是默认怀疑,专攻“because 是同义反复 / 结构动作只贴了层薄理由”的条目。
| 对 | 初审判断占比 | 对抗复核后 | 整对结论 |
|---|---|---|---|
| 1 | 87% | 80% | 维持 |
| 2 | 82% | 73% | 维持 |
| 3 | 73% | 67% | 维持 |
| 4 | 82% | 64% | 维持 |
| 5 | 100% | 90% | 维持 |
五对的初审数字全部被下调,对抗后均值 0.746;被翻案的条目模式高度一致——动作与结构模板重叠、理由近乎同义反复。整对结论 5/5 维持,但没有一对毫发无损。原报告还主动提示 pair 5 的 0.9 偏乐观(初审 1.0 本身可疑)。
这组数据说明两点。第一,核验者确实在逐条翻案,不是流程装饰。第二,对抗的价值是双向的:在代码审查里它杀假阳性(2 条 dismissed、会话级“其余全部证伪”),在评估任务里它压乐观自评(100%→90%)。
成本与适用
成本结构很直白:每条 finding 都要多付一次专门的核验过程,外加编排开销。什么时候值得:
- **大改动面。**会话级那次覆盖 44 文件/1467 行、含一次核心路径重构(“对话并入 run”),改动面超出单人一遍能守住的范围。
- 发布前 / 安全面。
EXPOSE_RESET_TOKEN、Stripe webhook 未配密钥即不校验(修为失败关闭:未配密钥返回 503)这类问题,漏过的代价与核验成本完全不成比例。 - **从未系统审过的存量。**全项目审计的目标本来就是“不止新代码,重点是更广/更老的部分”——这些代码不在任何近期 review 的视野里。
什么时候不值得,源记录里有现成判断:单功能实现是“顺序+交织、无 fan-out”,solo 完成即可——审查/审计(广度+独立验证)才是多 agent 工作流的强项。推论到小改动:几十行的 diff 上,人工证伪三条 finding 比编排一轮六审计员流程快得多,核验开销直接超过收益。
局限
- **同源盲区。**核验者与被审代码的作者是同一个(或同族)LLM。对抗结构消除的是立场偏差——不再为自己的产出辩护——但不扩展能力边界:双方共同看不见的问题类别,核验环节同样看不见。它替代不了异源信号——真实运行、并发实测、人的领域知识。
- **“0 uncertain”不等于“0 漏网”。**27 条 CONFIRMED、0 uncertain 度量的是精度侧:提出来的问题几乎都是真的;它对召回侧——没被任何审计员提出的问题——没有任何陈述。这次审计本身就确认了三条测试盲区:审计能找到测试的洞,却没有工具度量审计自己的洞。
- **核验深度本身是抽样的。**翻案实验的结论页诚实注明:只实证抽查了 pair 4 的源文件,其余依赖子 agent 的标注。核验也可能是浅的,浅核验盖出的 CONFIRMED 应打折扣——把“核验做了”当成“核验做实了”,是这套流程最诱人的自我误导。
- 结构依赖 finding 纪律。
file:line+ 失败场景 + 修法把 finding 变成可证伪命题;放松成“某模块可能有问题”,核验者无从对质,整个流程退化为两轮意见交换。