ARTICLE / 7 MIN READ

重构与审查互相踢皮球:用全局状态机和 Router 仲裁死锁

两个 Agent 无限循环烧 Token 的根因不是跳数太多,而是没有仲裁者。用集中式状态机、结构化收敛信号和第三方裁决,从架构层杜绝踢皮球。

“代码重构 Agent”改一版,“代码审查 Agent”打回来,重构再改,审查又打回——两个 Agent 各自都很勤奋,合起来却是一台烧 Token 的永动机。我第一反应也是加 Max Hops,但那只是让永动机在第 8 步停机,没解决它为什么转起来。根因是:这个协作里没有裁判,两个 Agent 都以为对方该让步,谁也无权终局。要从架构上解决,得把仲裁权从两个执行 Agent 手里收走,交给一个不写代码、只做判定的全局 Router。

别让两个 Agent 直接对话

分布式协作(Agent 之间点对点喊话)在这种对抗场景必然死锁:审查的输出是重构的输入,重构的输出又是审查的输入,形成闭环却没有出口节点。分布式的好处是灵活、没有单点,适合角色多且目标一致的协作;但一旦出现“谁都能否决、谁都没有终局权”的对抗关系,它就退化成两个对等节点互相甩锅。我会改成集中式编排——重构和审查都不直接通信,各自只跟中间的 Router 交互。Router 持有全局状态机,是唯一能决定“下一步去哪、要不要停”的角色。执行 Agent 降级成纯函数:给它上下文,它产出结果,无权决定循环是否继续。这个降级是关键——踢皮球的前提是双方都自以为握有“再来一轮”的决定权,把决定权上收到 Router,两个执行 Agent 就算再倔,也只能在 Router 分配的回合里干活。

        ┌──────────────────── Router (仲裁 / 持有全局状态) ────────────────────┐
        │                                                                      │
   ┌────▼────┐   patch    ┌──────────┐   verdict   ┌─────────────┐            │
   │ DRAFTING├──────────► │ REVIEWING├───────────► │ CONVERGE_CHK│            │
   │ 重构中  │            │ 审查中   │             │ 收敛判定    │            │
   └─────────┘            └──────────┘             └──┬───┬───┬──┘            │
        ▲                                             │   │   │               │
        │ 下降且有可执行修改 (D_n<D_{n-1})            │   │   │ 收敛达标      │
        └─────────────────────────────────────────────┘   │   └────► DONE 交付│
                                                           │ 连续 N 轮无下降  │
                                                           ▼                   │
                                                     ┌───────────┐             │
                                                     │ ESCALATED │─► 人工 / 裁决Agent
                                                     │ 升级仲裁  │             │
                                                     └───────────┘             │
   兜底:hops>max 或 token>budget ──► ABORTED 熔断(与收敛判定并联,非主路径)  │
        └──────────────────────────────────────────────────────────────────────┘

每轮传结构化信号,不传自由文本

踢皮球的技术温床是“审查意见是一段自然语言”。模型读一段散文,无法可靠判断“这轮到底解决了没有”,只能凭感觉再改一版。我会强制审查 Agent 输出结构化裁定,Router 才有可计算的收敛依据:

ReviewVerdict {
  round: 4,
  issues: [
    { id: "SEC-01", severity: "blocker", category: "security",
      location: "auth.go:42", actionable_fix: "用 subtle.ConstantTimeCompare 替换 ==",
      status: "open" },
    { id: "PERF-03", severity: "major", category: "performance",
      location: "list.go:88", actionable_fix: "N+1 查询改批量 IN", status: "open" },
    { id: "STYLE-09", severity: "nit", category: "style", status: "resolved" }
  ],
  blocker_count: 1, major_count: 1
}

关键约束:每条 issue 必须带 severity 分级(blocker / major / minor / nit)和 actionable_fix。审查不允许说“这里写得不好”,必须给出定位和可执行的修改动作。没有 actionable_fix 的意见,Router 直接判无效、不计入本轮债务——这一条就掐掉了“审查为了显得尽责而无限挑刺”的动力。

收敛判定:给债务定价,看它降没降

Router 在 CONVERGE_CHK 节点做纯计算,不调模型。先给未解决问题定义加权债务分(权重是我拍的、可按项目调):

D_n = 3·blocker + 1·major + 0.2·minor + 0·nit   // nit 不阻塞收敛

收敛达标(→ DONE):blocker_count == 0 且 major_count == 0
有效推进(→ 回 DRAFTING):D_n < D_{n-1} 且 本轮存在 status 从 open→resolved 的 issue
原地踏步(stall):D_n >= D_{n-1},或问题集哈希重复

循环检测用两把尺子叠加,因为单靠债务分会漏判一种情况:审查每轮换一批新问题来挑,债务分看着一直在“变化”,其实是在原地打转。所以第一把尺子是状态哈希:把本轮所有 open issue 的签名 (category, 规范化 location, severity) 排序后哈希,若同一个哈希在最近窗口内重复出现,说明“改了个寂寞”,同一批问题又被原样提出来了。location 要先规范化(去掉行号漂移、只保留函数级定位),否则重构挪动几行代码,签名就变了,哈希检测会失灵。第二把尺子是债务 diff:连续 N=2D_n 不下降,即判定 stall。两把尺子任一命中,Router 不再把球踢回重构,直接转 ESCALATED。这里的阈值 N=2 是权衡:给一次真实的“改错方向”留出纠正机会,但不给无限次;把它调成 1 会误伤那些“第一轮改偏、第二轮改对”的正常波动,调成 4 又等于纵容烧四轮 Token 才升级。

升级:请一个不下场的第三方裁决

进入 ESCALATED,不能再让原班人马继续。我会引入第三方裁决 Agent——它读双方完整历史(每轮 patch 的 diff 摘要 + 每轮 ReviewVerdict),产出一个有约束力的终局决定,只能三选一:

  1. 接受当前代码,把剩余 blocker 显式降级为 follow-up issue 单独建票——适用于审查在钻牛角尖、争议点不该阻塞合并。
  2. 裁定唯一修改方向,明确“就改这一处,按这个方案”,回 DRAFTING 但带上裁决约束,审查下一轮只准验这一处。
  3. 转人工,附上争议焦点的一页纸摘要——适用于涉及产品语义、裁决 Agent 也没把握的分歧。

裁决 Agent 的价值在于它不在循环里:它没有“再改一版”的选项,只能收敛。这就打破了对称性——踢皮球的本质是两个对等角色都能拒绝,引入一个只能拍板的非对等角色,死锁的结构基础就没了。

预算和跳数只是保险丝

Max Hops、Token Budget 我仍然留着,但把它们的定位讲清楚:它们和收敛判定是并联的熔断,不是主控逻辑。健康的流程应该在 ESCALATED 里优雅收敛,根本走不到 hops > max 那一步。如果生产里频繁靠 ABORTED 熔断兜底,说明收敛信号或裁决机制失效了,该去修那里,而不是把 max_hops 从 8 调到 16。把跳数当主要防线,等于承认自己没有仲裁机制——那才是最初那台永动机的设计。

边界

这套集中式仲裁不是免费的。Router 成了单点,它的判定逻辑(债务权重、stall 阈值)需要按领域调参,权重拍错会“过早接受”或“过晚升级”。它也更适合目标可判定的对抗协作——代码有 blocker/major 这种能分级的客观信号。换成“文案润色 vs 品牌审查”这类主观拉扯,债务分很难定义,收敛判定会退化,那种场景更应该在前期用明确的验收清单约束,而不是指望运行时的 Router 算出对错。仲裁能治死锁,但治不了“需求本身没有正确答案”。

参考资料:Anthropic — Building Effective Agents

GITHUB DISCUSSION

评论与回复

评论保存在 GitHub Discussions,登录 GitHub 后即可参与,发布和回复都在本页完成。

评论区进入视口后自动加载。