ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

让 Agent 不翻车的工程护栏

2026/8/10 9:46:32 拓冰建站 浏览量
让 Agent 不翻车的工程护栏

系列终篇。前四篇把 ReAct 循环讲透了。但"能跑的 demo"和"能用的产品"之间,隔着一堆护栏。这一篇看 LoopAgent 怎么防止 Agent 死循环、烧钱、幻觉、停不下来。⭐

demo 会翻车的四种方式

你自己写过一个 ReAct 循环就会知道,裸循环在真实使用里很快翻车:

  1. 鬼打墙:模型用一模一样的参数反复调同一个工具,原地转圈烧 token;
  2. 撞南墙:某个工具一直失败(文件不存在、命令报错),模型不死心一直重试;
  3. 睁眼说瞎话:模型没真正查过代码,就"编"一个答案出来;
  4. 停不下来:模型迟迟不给最终答案,循环无限进行。

LoopAgent 对这四种翻车,各有一道护栏。它们都挂在第 03 篇提到的三个"记分板"上。先回顾这三个记分板(reactAgentRunner.ts:66-69):

constsuccessfulTools=newSet<string>();// 哪些工具成功跑过 → 证据门禁constsucceededCalls=newMap<string,string>();// 签名 → 结果摘要 → 去重consttoolFailures=newMap<string,number>();// 工具名 → 连续失败数 → 熔断

护栏一:重复调用拦截(治"鬼打墙")

模型有时会用完全相同的参数反复调一个工具——比如同一个查询搜了三遍。LoopAgent 用"工具名 + 原始参数"当签名缓存结果,命中就拦回去(reactAgentRunner.ts:212-220):

constcached=succeededCalls.get(`${toolRequest.name}:${toolRequest.rawArguments}`);if(cached!==undefined){return{content:`重复调用:已用相同参数调用过${toolRequest.name},上次结果:${cached}。请改变查询或给出最终答案。`,succeeded:false,productive:false,evidence:[],};}

注意拦截消息的措辞——它不只是说"别重复",还告诉模型上次的结果,并给出两条明确出路:“改变查询"或"给出最终答案”。这是在引导模型走出循环,而不是单纯报错。

✶ Insight ─────────────────────────────────────
护栏返回的错误消息是写给模型看的 prompt,不是写给日志的。好的护栏消息要包含三要素:发生了什么(重复了)、当前状态(上次结果是什么)、下一步该怎么办(改查询或收尾)。把护栏当成一次微型的"再提示",Agent 的自愈能力会强很多。
─────────────────────────────────────────────────


护栏二:连续失败熔断(治"撞南墙")

某个工具连续失败时,继续重试只是浪费。LoopAgent 按工具名累计连续失败数,达到阈值就终止整个运行(reactAgentRunner.ts:10 定义阈值,reactAgentRunner.ts:253-258 执行):

constMAX_CONSECUTIVE_TOOL_FAILURES=3;// ...}else{constfailures=(toolFailures.get(request.name)??0)+1;toolFailures.set(request.name,failures);if(failures>=MAX_CONSECUTIVE_TOOL_FAILURES){thrownewError(`工具${request.name}连续失败${failures}次,终止运行`);}}

关键是"连续"——成功一次就清零(reactAgentRunner.ts:251toolFailures.set(request.name, 0))。所以偶尔失败一次不会误伤,只有真正"卡死在一个工具上"才熔断。


护栏三:证据门禁(治"睁眼说瞎话")

这是最巧妙的一道。它要解决的问题是:模型不能没真正查过,就编一个答案。

机制分两半。第一半,工具可以标记自己这次"跑成功了但没查到东西"——ReactAgentToolResultproductive字段(reactTypes.ts:42-51):

exporttypeReactAgentToolResult={content:string;evidence:MemoryEvidence[];/** 默认 true。工具在"调用成功但没查到有用信息"时设为 false —— * 比如搜索没命中。这与 succeeded 不同:一次无收获的调用不是失败 * (不该触发失败重试或熔断),但也不能算作证据。 */productive?:boolean;};

第二半,只有既成功、又有收获的调用,才被记入"证据"(reactAgentRunner.ts:240-241):

if(outcome.succeeded&&outcome.productive)successfulTools.add(request.name);

然后在模型想给最终答案时,做一道门禁(reactAgentRunner.ts:147-156):

if(result.kind==="final"){constmissingTools=getMissingRequirements(requiredToolNames,requiredAnyOfToolNames,successfulTools);if(missingTools.length>0){if(requiredToolRetries>=2){thrownewError(`Required tools were not called successfully:${missingTools.join(", ")}`);}requiredToolRetries++;messages.push({role:"user",content:`Before finishing, call required tool(s):${missingTools.join(", ")}.`});continue;// ← 打回去,逼它先真的查一下}// ... 才允许收尾}

✶ Insight ─────────────────────────────────────
productivesucceeded的区分很见功力:一次"搜了但没命中"的调用不是失败(不该触发熔断),但也不算证据(不能凭它就说"我查过了")。如果没有这个区分,要么把"没命中"误判成失败触发熔断,要么让模型拿"我搜了个空"当挡箭牌绕过门禁。两种都错。这种对"成功 / 有收获"两个正交维度的建模,是把 Agent 做扎实的细节。
─────────────────────────────────────────────────

getMissingRequirements(reactAgentRunner.ts:295-303)还支持两种门禁语义:requiredToolNames(全部都要成功)和requiredAnyOfToolNames(至少一个成功),可组合使用。


护栏四:倒计时收尾(治"停不下来")

模型可能磨蹭到步数上限还不给答案。LoopAgent 的循环上界很有讲究(reactAgentRunner.ts:118-119):

for(letstep=initialStep;step<=maxSteps+1+2;step++){constisFinalAnswerStep=step>maxSteps;

循环跑的步数比maxSteps多几步,这几步是专门留的"收尾窗口"。进入收尾窗口后,toolChoice被强制设为"none"(reactAgentRunner.ts:131-132):

constmissingRequirements=getMissingRequirements(requiredToolNames,requiredAnyOfToolNames,successfulTools);consttoolChoice=isFinalAnswerStep&&missingRequirements.length===0?"none":"auto";

toolChoice: "none"协议层禁掉所有工具——模型这一轮除了吐最终答案别无选择。这就是第 02 篇埋的伏笔:因为动作走的是协议而非文本,我们能用协议开关强制模型行为,而不是在 prompt 里"求"它。

如果收尾窗口用完还不给答案,最后一道兜底(reactAgentRunner.ts:272):

thrownewError("Model did not produce a final answer");

✶ Insight ─────────────────────────────────────
注意收尾和证据门禁的优先级:toolChoice只在missingRequirements.length === 0(证据齐了)时才设"none"。也就是说——如果模型还没真查过东西,即便到了收尾窗口,也不禁工具,而是继续逼它去查(护栏三)。“不许瞎编"的优先级高于"赶紧收尾”。两道护栏的这种协作顺序,是刻意设计的。
─────────────────────────────────────────────────


附加:并发批次里的写冲突防护

第 04 篇讲过并发执行。但并发有个风险:两个都要写文件的工具并行跑会冲突。LoopAgent 的分批算法保证只有连续的、都并发安全的工具才会合进同一并行批次(reactAgentRunner.ts:310-327):

for(const[index,request]ofrequests.entries()){constconcurrent=isConcurrencySafe(request,toolsByName);constprevious=batches.at(-1);if(concurrent&&previous?.concurrent){previous.requests.push({request,call:index+1});// 并入上一并行批continue;}batches.push({concurrent,requests:[{request,call:index+1}]});// 否则单独成批}

只读的explore_code可以扎堆并行,一旦遇到会写文件的apply_edit,它就单独成批串行执行。安全和性能兼顾。


五道护栏全景

翻车方式护栏记分板源码
鬼打墙(重复调用)相同签名拦截 + 引导消息succeededCalls:212-220
撞南墙(反复失败)连续失败熔断toolFailures:252-258
睁眼说瞎话(无证据)productive+ 证据门禁successfulTools:147-156, 240
停不下来倒计时强制收尾:118-132, 272
并发写冲突分批串行化:309-326

这五道护栏,单看每一道都不复杂。但正是它们的叠加,把一个"跑得通的 ReAct demo"变成了一个"敢让它动你代码库的产品"。Agent 的工程质量,往往不体现在主循环有多聪明,而体现在这些护栏有多周全。


系列结语 & 项目推广

到这里,我们从"AI 怎么边想边做"的直觉出发,一路走到了函数调用式 ReAct 的协议细节和工程护栏。如果这五篇让你对 AI Agent 的内部机理有了更实在的理解,那它们的目的就达到了。

所有源码都来自我的开源项目LoopAgent—— 一个把 AI 编码 Agent 塞进 VS Code 的扩展。本系列拆的只是它的推理内核,项目里还有更多值得一看的东西:

  • 🧠函数调用式 ReAct 内核——就是本系列的主角,src/extension/agent/;
  • 🔍本地代码智能索引——SQLite FTS5 + 内存降级双路径,10K 符号查询 <10ms,让 Agent 秒级定位代码;
  • 🔀工作流编排——把多个 Agent 步骤编成可复用工作流,支持动态生成与失败恢复;
  • 👁视觉分析——能读你贴进来的截图 / 设计稿;
  • 💾长期记忆——跨会话记住你的偏好和项目上下文。

技术栈全自研 TypeScript,没有藏在框架背后的黑盒,每一层都可读、可改、可学。无论你是想学 Agent 原理、想找一个能改的脚手架,还是想直接用一个开源的编码助手,都欢迎来看看。

🌟 如果这个系列帮到了你

去给 LoopAgent 点个 Star,是对开源作者最直接的鼓励:

👉https://github.com/oi12344/loopagent-vscode

也欢迎:

  • Issue反馈问题或想法;
  • PR一起把开源的 Coding Agent 打磨得更好;
  • 把这个系列转发给同样对 AI Agent 好奇的朋友。

感谢读到这里。我们下个系列见。🚀


📖 上一篇 → 04 · 一步发多个工具:多 tool_call 的格式 | 回到 → 系列索引

开源项目LoopAgent⭐ → https://github.com/oi12344/loopagent-vscode