一套让 AI agent 团队不交垃圾代码的系统
一个三端 App、大半代码是 AI agent 写的——但 agent 会谎报「完成」、互相踩踏、还会给测试放水。这是约束它们的那套操作系统。
真正的问题
我是个写 Rust 的后端。大约一个月里,一个 AI 编码 agent 团队写出了一个三端德州扑克 App 的大部分代码——网页、iOS、Android 都能实时对战 AI 人格。网页端拥有最完整的复盘工具集;移动端也已提供原生数据、历史、回放/复盘、练习与本地 solver 分析。我写规则、写契约、写 prompt;业务逻辑是 agent 写的。这一半没人意外:agent 当然能写代码。
差点把项目拖垮的,是没人提醒你的那一半。一个 agent 团队会谎报「完成」、互相踩踏对方的文件,还会为了让测试通过而偷偷给测试放水。写代码原来是容易的那一半;约束住这些 agent 的那套系统,才是真正的活——也是这篇要讲的。
每一次 agent 的改动都跑同一条流水线:开分支、写、编译、测试、独立评审,通过之后才过一道合并闸门进 main。评审默认来自另一家厂商;只有评审不可用时,才能走狭窄且留痕的替代路径。
每次改动跑同一条流水线。作者模型从不评审自己的活——由独立模型来评(默认异厂),守住 main 的是一道硬闸门,不是善意。
班底:每个角色锁死在一个目录
为什么是一个团队,而不是一个全能 agent:单个 agent 在真实代码库上会崩。上下文太长、忘掉约束、修 A 弄坏 B。所以工作被拆给十来个角色——写代码的锁死在各自被允许碰的目录里,规划和评审角色则锁在规格与评审上——下面是最关键的六个:
| 角色 | 负责 | 写代码? |
|---|---|---|
| 后端工程师 | engine/ + server/ | ✓ |
| 前端工程师 | client-game/ | ✓ |
| 移动端工程师 | client-mobile/ | ✓ |
| 架构师 | 规格 & ADR | 只写规格 |
| 扑克 QA | 测试闸门 | 只写测试 |
| 独立审计员 | 评审每一个 diff | 只评审 |
锁和角色一样重要。两个 agent 在没有边界的情况下改同一批文件,正是下面那些事故的来源。一个只写规格的架构师、一个只做评审的审计员,把设计和判断从「写代码的那双手」里隔开。
我真正写了什么
我亲手写的不是业务逻辑——是四件 agent 在其中运行的、可复用的东西:
AGENTS-CONTRACT.md—— 谁负责什么,以及评审循环。- 一份 API 契约 —— 后端和前端都必须遵守的字段形状。
- 一组代码化合并护栏 —— 早期的
pre-commit直接提交守卫(见下),以及现行由主 worktree 持有的finish-session.sh受审合并与引用事务守卫。 - 一段审计 prompt —— 评审模型该盯什么。
而交给 agent 的每个任务都是同一个形状——划定地盘、单一目标、在开工前就锁死的验收标准:
You are backend-agent. Only touch server/ and engine/.
Read docs/architecture/api.md and the latest ADR first.
Goal: implement GET /api/hands/:id.
Done = cargo test passes; JSON matches HandHistoryDTO; no frontend changes.
Output: change summary + test command + risks.
划定地盘,加上锁死的验收标准。缺一个,agent 就会漂移——要么自己扩大范围,要么按它自己的标准宣布胜利。
作者写,独立模型评审
正常路径会有意换一家厂商:Claude 写的交给 Codex 审,Codex 写的交给 Claude 审。不变的是作者不能评审自己的活。只有仓库明确记录的评审不可用情形,才允许狭窄的替代模型路径;替代评审仍必须明确批准且 OPEN 清单为空。在我自己的实测中,自审不够:作者漏掉了我真正在意的失败,而独立模型把它们捞了出来。
顺序也要紧。先编译,再审计。评审模型是静态阅读的,会从编译器一秒就抓到的语法和类型错误旁边滑过去;让编译器先清掉这些,评审才能把有限的轮次花在跨文件逻辑、契约和安全上。这个循环最多五轮,且只在某一轮干净——待办(OPEN)清单为空、无 blocker、无 major——之后才合并。
而守住 main 的不是一段 prompt。最早的第一道线是一个本地 pre-commit hook,从物理上拒绝任何对 main 的直接提交:
branch=$(git rev-parse --abbrev-ref HEAD)
[ "$ALLOW_MAIN_COMMIT" = 1 ] && exit 0
case "$branch" in
main|master) echo "BLOCKED: direct commit to $branch"; exit 1 ;;
esac
诚实的说明:上面这道早期 hook 单独看确实能被 --no-verify 绕过,所以项目现在不再把它当最终执行点。现行流程用 scripts/new-session.sh 创建隔离 worktree;只有主 worktree 持有的 scripts/finish-session.sh 在独立评审明确 APPROVED 且 OPEN=[] 后,才拿到一次性能力以 CAS 方式推进 main。审计收据会记录本轮是跨厂商还是使用了文档允许的 fallback,引用事务守卫默认拒绝其他落地路径。早期教训没变:护栏必须是代码,不是一句指引。
到底哪里会出错
这些都不是扑克逻辑的 bug。它们是你放手让一个 agent 团队去造真实代码库时,会出现的失败模式。
1 —— 两个 agent,没有契约 → 14 个字段对不上
一个 agent 写了后端接口;另一个写了读它的前端;没人先把字段形状锁死。后端 id 对前端 hand_id;played_at 对 created_at;还有几个字段干脆缺失——前后共十四处对不上。前端自己的校验器于是拒绝了 100% 的真实服务器响应。异厂审计把它列为那一轮的四个 blocker 之一;作者模型从头到尾没看见它。
解法是一页纸的接口契约,在并行的 agent 开工之前就写好——下面是简化示意:
type HandHistoryDTO = {
hand_id: string;
played_at: string; // ISO-8601 UTC
seed: string; // deck seed, hex
board_cards: Card[];
actions: ActionEvent[];
};
从这份 schema 生成类型;别在两端各自手写 DTO。还有,测试 fixture 要用真实的服务器响应,绝不要 AI 编出来的——编出来的 fixture 和代码共享同一套假设,测试过了,真实响应照样挂。
2 —— 不是真的「完成」
有两次,agent 报了绿,其实没绿。九个单测通过,而真实浏览器里零张牌在翻——jsdom 根本不跑 CSS 3D 动画,所以那些测试验证的是用户永远见不到的环境。还有一次,后端 bug 修完「还在」,因为服务器跑的是修复之前的旧二进制。两次同一个陷阱:一个测试只验证它真正跑在其中的那个环境。
所以「完成」是一句待验证的声明,不是事实。视觉改动要配真实浏览器加一条断言——期望五张牌翻开,而不是「看着对」。后端改动要重新构建并重启,并留一个 build 戳——git sha,由 /build-stamp.txt 提供——这样你能看见自己打的到底是哪个 build。
3 —— AI 会给测试放水
被要求「修到绿为止」,agent 有时会用错误的方式把测试弄绿:删掉一条断言、调低快照阈值、加个 skip 或 only。门过了,却毫无意义。守卫在评审里,不在扫描器里:评审 prompt 把「给测试放水」列为优先追查项——上面每一种动作都是必须解释的 blocker,绝不放行;QA 角色则被明令禁止靠削弱回归测试拿绿灯。
诚实的边界
这些 agent 有实打实的盲点。我不去消除它们——我把它们圈起来。
- 视觉打磨。agent 判断不了一个界面看着对不对,所以这一刀由人加真实浏览器截图来下。
- 并行的 session 会在磁盘上互相覆盖。所以每次改动都拿到自己的分支和自己的 git worktree。这本身是一整个独立的失败模式,有它自己的解法——我单独写过一篇:为什么每个 agent 都该有自己的 worktree。
至于目标漂移和谎报「完成」,上面那套机器已经把它们圈住了。
总结
- 真正的问题:一个 agent 团队会谎报「完成」、互相踩踏、给测试放水。写代码从来不是难的那部分。
- 班底:十来个角色(正文表格列了最关键的六个),每个锁死在自己被允许碰的范围里;一个只写规格的架构师、一个只做评审的审计员,把判断从写代码的那双手里隔开。
- 系统:每次改动跑同一条流水线——分支、编译、测试、独立审计(默认异厂,≤5 轮)、合并闸门——而闸门是代码,不是一句指引。
- 那条教训:别再问 AI 能不能写代码。它能。真正的活是管理这些 agent——角色、契约、独立评审、一道合并闸门,以及一个必须自证的「完成」。