Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

README.md

s03 · 循环预算与纠偏

本章给 s01 的 while (true) 循环装一个看门狗,处理"模型不喊停、循环就不停"的失控问题。

问题

你让 agent 修一个测试:它跑一次,失败;改一行,再跑,又失败;然后把刚才那行改了回去,再跑……几十分钟过去,token 烧掉一大把,测试还是红的。s01 的循环是 while (true),什么时候停完全由模型说了算——而模型每一轮都真诚地相信"下一次就能成"。

模型不喊停,循环就不停。从外部看,失控有三种典型模式:

失控模式 表现 本质
复读机 一模一样的命令跑了 8 遍 卡在同一个想法里出不来
连环报错 每一轮的工具全在报错,还在换着花样试 撞墙了但没有意识到
原地踏步 动作不重复、也不报错,但全是读操作,世界没有任何变化 看起来在推进,实际没有进展

裸的 while (true) 对这三种情况没有任何防御。

解决方案

给循环装一个看门狗:记录每轮干了什么,发现空转先提醒模型换路,提醒无效再强制停下。

预算是软的——有进展就续期,但无论如何不能超过一个绝对上限。正常推进的 agent 不受惩罚,失控的 agent 也不会无限消耗——硬顶是最后的兜底。

循环预算用软预算续期、纠偏 prompt 和硬顶控制失控循环

运行

不需要 API key:

node notes/s03_loop_budget/demo.mjs

四个剧本 agent 分别演示三种失控被暂停、以及正常推进的 agent 拿到续期。输出示例:

━━━ 场景一:复读机(同一动作反复执行) ━━━
  第 1 轮:run_shell({"command":"grep -r TODO ."})  [预算 1/6]
  ...
  第 4 轮:run_shell({"command":"grep -r TODO ."})  [预算 4/6]
  🟡 可纠偏暂停:同一个工具动作被反复执行,暂停。(reason=repeated_action)

━━━ 场景四:勤奋 agent(有进展就续期,直到硬顶) ━━━
  第 3 轮:edit_file(...)  [预算 3/6]
  第 4 轮:edit_file(...)  [预算 4/12]   ← 续期发生在这里
  ...
  第 25 轮:⛔ 达到硬性上限,强制停止。(reason=hard_max_steps)

实现

看门狗的完整实现在 loop-budget.mjs,不到 150 行,是从真实产品 Reina 的同名模块简化移植的,机制一致。四个关键决定:

① 预算是软的:有进展就续期

一刀切的"最多 20 轮"是坏设计:重构任务干到一半被打断,体验很差。正确形状是软预算 + 硬顶——干得好就续,但无论如何不能超过一个绝对上限:

// 有进展、且预算只剩 2 轮 → 再给一份 baseSteps(封顶 hardMax = 4 倍)
if (hasProgress && this.turns >= this.budget - 2 && this.budget < this.hardMaxSteps) {
  this.budget = Math.min(this.hardMaxSteps, this.budget + this.baseSteps);
}

② 进展的判定:写操作算,重复读不算

function isProgress(record, actionCount) {
  if (record.status !== "completed") return false;
  if (["write_file", "edit_file"].includes(record.name)) return true; // 写 = 永远算
  return Boolean(record.output?.trim()) && actionCount === 1;         // 读 = 只有第一次算
}

这条判定规则很粗,但方向明确:改变了外部状态的动作才算进展。写文件改变了状态;第一次读到新信息也算(认知变了);第二次跑同一条命令、读同一个文件——状态没变,结果也早已知道,不算。

③ 动作指纹:识别"同一个动作"

重复动作的探测,靠给每个动作算一个标识串(指纹),一样就是重复:

const key = `${record.name}:${stableStringify(record.input)}`;

注意是 stableStringify(key 排序后再序列化),不是裸 JSON.stringify。模型两次生成的参数对象字段顺序可能不同({a,b} vs {b,a}),语义上是同一个动作,裸 stringify 会把它们当成两个,探测直接失效。

④ 强制停之前,先给一次纠偏机会

看门狗触发后最简单的做法是直接停。但三种失控往往只是模型缺少外部视角——它自己意识不到在绕圈。所以先往对话里插一条纠偏提示:

自动纠偏触发:同一个工具动作被反复执行,暂停。
循环状态:原因=repeated_action,已用 9 轮,预算 12,硬顶 48。
不要再重复同样的工具调用或失败的命令。先总结目前发生了什么、找出卡点,
换一条不同的路。如果任务被阻塞或有歧义,向用户提一个具体的问题,而不是继续空转。

这条提示的三段结构各有作用:说清发生了什么(模型自己意识不到在重复)、给出禁止项(别再重复)、给出出路(换路,或者问用户)。插进去之后模型往往能跳出来。

纠偏只给一次。再次触发,或预算真正耗尽(max_steps / hard_max_steps),就停止本轮、把控制权交还用户——此时应由人来决定,而不是继续消耗。

接进你的 agent

agent.mjs 是 s02 的 agent + 看门狗,主循环的全部变化:

async function runTurn(messages) {
  const budget = new LoopBudget({ baseSteps: 12 });
  let repaired = false;
  while (true) {
    if (!budget.canContinue()) { /* 预算耗尽 → 停止 */ }

    const msg = await chat(messages);
    // ...执行工具,同时收集 records: [{ name, input, status }]

    const stop = budget.recordTurn(records);
    if (!stop) continue;
    if (isRecoverable(stop) && !repaired) {
      repaired = true;
      messages.push({ role: "user", content: repairPrompt(stop) }); // 注入纠偏提示
      continue;
    }
    return; // 纠偏用过了还停不下来 → 交还用户
  }
}

循环的骨架没变——看门狗是挂在循环上的,不是写进循环里的。

练习

  1. 给 isProgress 加一条规则:run_shell 跑 git commit 这类明确改变外部状态的命令,即使是第二次也算进展。想想怎么判定"改变外部状态的命令"(提示:白名单前缀即可,不必过度设计)。
  2. 思考题:纠偏提示是以 role: "user" 注入的——模型会把它当成用户说的话。有什么副作用?如果换成 system 消息或 tool 消息,各有什么问题?(这个问题没有标准答案,真实产品各有取舍。)

与真实产品对照(延伸阅读)

  • 本章机制对应 Reina 的 packages/core/src/loop-budget.ts。生产阈值:停滞 8 轮、重复动作 10 次、连续报错 5 轮,均可用环境变量覆盖;预算耗尽时最新的工具结果会被完整保留进下一轮,避免停下来后丢失刚发生的上下文。
  • Claude Code 里偶尔出现的 "Paused after several tool turns without new progress" 提示,背后就是这类机制。
  • 分工边界:本章的看门狗管的是行为失控(无效循环)。还有一类失控是物理卡死——工具进程 hang 住、模型流断了,循环根本转不动,行为探测器永远等不到下一轮。那需要另一条看门狗(心跳 + 超时 + 终止前保留输出),在 s09 子代理章介绍。

← 上一章:工具系统 目录 下一章:工具输出预算与溢出 →