← 返回文章

更多智能体,同一个盲点:AI 审查为何没发现真实平台的限制

一个模型实现,另一家厂商的模型审查,仍然让三个 Upstash 运行时限制直到生产迁移时才暴露。我们后来先实测平台,把结果写进测试替身,再在隔离的临时命名空间里演练。

这次迁移为什么终于有了计划,前篇交代了起因:一次“推荐”推送引发的生产事故。

我让一组 AI 智能体执行了一次持续一周的生产数据迁移:一个模型实现,另一家厂商的模型审查,另有一个协调智能体安排并执行各轮迁移操作。审查模型一周给出了约 60 份结论,生产迁移却仍然连续三次失败。每次撞上的,都是没有任何智能体实际探测过的 Upstash Lua 运行时限制。

这次审查的盲点出在验证方式上。实现模型同时写了代码、测试和数据存储的测试替身;审查模型读取这些材料。它们彼此一致,却没有一项检查接触真实平台。增加审查角色,并不会自动增加真实环境的证据。

后来,我们先测量平台,把测量结果写进测试替身,再用真实后端演练完整写入路径。这篇文章说明原因,也给出临时命名空间的防护代码和实测清单。

迁移的内容与分工

我的网站 williamliu.ai 从 Cloudflare R2 提供播客音频,用 Upstash(无服务器 Redis)保存小型媒体索引。这次要把约 3,900 条媒体记录迁移到按内容寻址的格式:文件名包含内容哈希,Lua 脚本负责原子更新索引。

分工如下:

  • 协调: 一个 Claude 会话,规划并执行每轮生产迁移操作。
  • 实现: OpenAI Codex,先写测试,再实现改动。
  • 审查: Claude Opus,为每条发现标明严重程度、文件和行号。CRITICAL 或 HIGH 级别的问题都会阻止改动继续。

单看流程数字,一周约 60 份审查结论,其中约 40% 要求停止推进。可连续三天,生产环境都暴露出了新问题。

三次失败,三个没有测过的限制

模型编写和审查的材料全部通过;第一次接触真实 Upstash,已经是在生产迁移当天
fig 01 模型编写和审查的材料全部通过;第一次接触真实 Upstash,已经是在生产迁移当天

第一天:registry overflow。 每批 100 条记录时失败。Upstash 的 Lua table.concat 有一个按元素数量计算的硬上限,与字节数无关。在我们的数据库上,2,561 个元素可以,2,562 个就失败。一批 100 条记录需要约 3,500 个元素。

100 条记录需要 3,530 个拼接元素,超过 2,561 的上限;20 条记录只需 730 个
fig 02 100 条记录需要 3,530 个拼接元素,超过 2,561 的上限;20 条记录只需 730 个

第二天:一个字段悄悄消失了。 在 Upstash 上,cjson.null 是 nil。将 {"a":null,"b":1} 解码再编码,得到的只有 {"b":1}。真实 Redis 会保留一个表示 null 的特殊值,我们的测试替身也如此。因此,约 3,900 条记录写入时都丢了一个可空字段;最终封存检查正确地拒绝了这批记录。

第三天:HTTP 400。 响应正文是 ERR Error running script: execution timed out。Upstash 限制了单次脚本调用可使用的 CPU 时间。以我们的数据为例,封存 250 条记录耗时 0.15 秒,500 条在 0.27 秒时报超时;因此,这组数据下单次调用可用的实际预算约为四分之一秒。

每次调用的记录数与脚本 CPU 时间:64 条约 0.04 秒,100 条 0.07 秒,250 条 0.15 秒,500 条在 0.27 秒超时
fig 03 每次调用的记录数与脚本 CPU 时间:64 条约 0.04 秒,100 条 0.07 秒,250 条 0.15 秒,500 条在 0.27 秒超时

改变计划的那条指令

第三次出问题时,我对协调智能体说了这句话,保留原来的拼写:

“you do it everyday but nothing is accomplished. wasting time”

这句话指出了反复发生的模式:每天都提交一个经过审查、能够修复昨天故障的正确补丁,第二天又遇到新的生产问题。智能体因此停下局部修补,开始追问为什么整个审查过程总会漏掉这类问题,并由此引出了真实后端演练的规则。

但这条指令也缺少方向。表达不满只能说明现状有问题,没有告诉智能体应该改变什么。更明确的要求应该是:“before the next production session, prove the whole write path on the real backend.” 我本该在第一天就提出这一点。

审查材料为什么会形成闭环

材料 编写者 核对依据
迁移代码 实现模型 测试
数据存储的测试替身 实现模型 同一组测试
测试 实现模型 测试替身
审查结论 审查模型 代码、测试、测试替身

每次检查的依据,都是模型写出的另一份材料。审查模型很善于发现内部矛盾,找出了竞态、缺失的防护和损坏的回滚逻辑,后面第三篇会详述。但这些材料里没有说明 Upstash Lua 与 Redis 的行为差异,它也就无从核对。

智能体审查提高了这套材料的内部一致性;判断代码能否在真实系统里正确运行,还需要真实系统的证据。两部分都要投入。

这种差距对智能体尤其明显。工程师踩过某个平台的坑,通常会把经验带进下一次审查。模型每次审查都可能重新开始,除非我们把那次教训写进测试替身或检查清单。

后来补上的三个步骤

  1. 实现之前先实测。 我们在真实 Upstash 数据库上运行了几个只做计算的探测脚本,约 20 分钟就测出了拼接元素上限、脚本时间预算和 null 的行为。
  2. 把测量结果写进测试替身。 现在的替身会丢弃 null 字段,拒绝超过 2,561 个拼接元素,并为脚本操作设定预算。用旧代码运行时,这三个缺陷都会触发单元测试失败。替身描述的是测到的平台限制。
  3. 在真实后端演练,并限制写入范围。 演练程序连接生产 Upstash,在独立的临时键前缀内运行实际写入路径:开始 → 分批写入 → 封存 → 严格读取 → 创建映射。演练按真实规模运行:2,000 个对象、200 个身份记录。后来正式执行约 2,000 条记录的封存时,按每次调用 64 条分页,总耗时为 2.5 秒。

临时命名空间的防护代码

演练程序需要写入凭据,因此必须让每条命令都经过范围检查:

PREFIX = "m4ctest:"

def guarded(cmd):
    verb = cmd[0]
    if verb == "EVAL":
        keys = cmd[3:3 + cmd[2]]            # only declared KEYS
    elif verb == "SCAN":
        assert cmd[2] == "MATCH" and cmd[3].startswith(PREFIX)
        keys = []
    elif verb in {"GET", "SET", "DEL", "HGET", "HSCAN", "SMEMBERS", "INCR", "TYPE"}:
        keys = cmd[1:] if verb == "DEL" else cmd[1:2]
    else:
        raise AssertionError(f"unreviewed command {verb}")
    assert all(str(k).startswith(PREFIX) for k in keys), (verb, keys[:2])
    return ORIGINAL_POST(cmd)

如果这个前缀下已经存在键,演练程序拒绝启动;结束时,它在 finally 块中清理自己创建的全部内容。

首次生产迁移之前的实测清单

  • [ ] 逐步增大输入,例如 100 / 250 / 500 条记录,测出脚本或函数的 CPU 时间、墙钟时间限制。
  • [ ] 逐步增大规模,找到栈、拼接元素或参数数量开始失败的精确位置。
  • [ ] 对路径上的每个序列化器,检查 null、空值、缺失字段经过解码再编码后的结果。
  • [ ] 检查请求正文和响应正文的大小限制。
  • [ ] 将验证与演练计划的读取量,与每月请求额度比较。我们的额度后来确实成了约束,见第三篇。
  • [ ] 把每项实测结果写进替身,补上能让未处理这些限制的代码失败的回归测试。

我接受的代价

在生产 Upstash 上演练,意味着拿着写入凭据,在生产数据旁边运行程序。我接受了这个风险,前提是有前缀防护、启动前的空前缀检查和结束后的清理。此前的做法则让我在专门留出的生产迁移时段,逐个发现这些限制。

下一篇:可以交给智能体执行的发布器。总共启动了 13 次,每次停止分别是谁造成的?