做AI应用最容易被低估的一个东西,不是模型参数,不是Prompt技巧,而是“循环”。用户问一句,模型想一会儿,调几个工具,把结果拼回去,再继续想下一步;任务没完成,循环就得接着转。很多人把这个过程当成理所当然的while(true),但真正把项目推到生产环境之后,你会发现模型反复输出同一句话、上下文越来越贵、工具调用错了还被当成事实继续推理——这些问题全出在“循环”本身。最近社区里冒出来的Loop Engineering这个词,说的就是把这个反复运行的机制当成一个真正的工程系统来设计、调试和治理。这篇文章不聊概念玄学,核心就两件事:把Loop Engineering掰开揉碎讲清楚,然后从零开始保姆级带你做一个能跑通、能调稳的循环项目。
1. 先把这个概念讲透:Loop Engineering到底在工程哪件事
1.1 两种“循环”:底层事件循环与上层Agent循环
第一次听到Loop Engineering的人通常会困惑:Node.js里不是早就有一个Event Loop吗?浏览器渲染引擎也有自己的循环机制,这些难道不叫Loop Engineering?
这里存在一个双关。传统意义上的Loop Engineering,指的是围绕事件循环机制做性能调优和并发治理——IO密集任务怎么调度、回调队列怎么处理背压、定时器怎么避免饿死,这都是很成熟的工程话题。而现在AI应用开发圈子里火起来的“循环工程”,指的是大模型应用里那一层更宏观的循环:Agent接收到目标后,不断感知环境、做出决策、执行动作、观察结果,然后继续往下走的这个周而复始的过程。
我倾向于把两层都纳入视野:底层的异步事件循环决定了你跑Agent的运行时环境是否稳定,上层的Agent循环决定了系统能不能真正完成任务。底层循环出事,程序直接卡死或内存爆掉;上层循环出事,程序可能不卡死,但会一直在错误的方向上打转,浪费Token还给出错误答案。这两层都需要用“工程”的方式对待,但本文重点放在更适合入门和实战的上层Agent循环。
1.2 为什么“循环”需要专门的工程方法
你可能觉得:不就是一个while循环里塞几句调用吗?直接写就行了。但实操过复杂Agent项目的人都知道,问题远不止“让它多跑几轮”这么简单。
第一,循环意味着状态在持续累积。第1轮的正确输出会成为第10轮的输入,一步错步步错,而且错误会以指数方式放大。第二,循环意味着成本在持续累积。每多转一圈,就要多消耗一轮模型的推理Token,而上下文窗口会越来越接近上限。第三,循环意味着不确定性被持续放大。单次模型输出已经带有随机性,循环会把每个环节的随机性叠在一起,表现得不稳定。
用一个生活类比:普通程序像单程快递,从寄件到派送一路走直线;带循环的Agent系统像流水线工厂,同一个工件要经过多道工序反复加工。流水线工厂的难点从来不在某一台机器上,而在于工件在不同机器之间怎么传递、出问题时怎么返工、整条线怎么防止某一环卡死拖垮全厂。Loop Engineering,干的正是设计流水线和防范卡死这件事。
从工程管理的角度看,一个成熟的Agent循环至少需要回答四个问题:当前状态是什么?下一步依据什么做决策?动作执行之后如何把观察结果合并回来?循环什么时候才能结束?这四个问题的答案,直接决定系统的稳定性,也是本文实战部分的地基。
1.3 Loop Engineering的四个基本盘:状态、观察、决策、动作
我把这四个基本盘展开来说一下,后续的代码全都围绕它们转。
- 状态(State):循环内部分享的“工作记忆”,包括用户的原始需求、已经执行的工具调用记录、已收集到的中间结论、剩余步数等。状态是整个循环的传话筒。
- 观察(Observation):执行动作之后环境返回的信息,比如工具调用结果、外部API的响应、数据库查询结果。观察必须被明确记录,否则模型会丢失“刚才发生了什么”的判断依据。
- 决策(Decision):根据当前状态决定下一步动作。这一步通常由大模型承担,表现为“调用某个工具”或“直接给出最终回答”。
- 动作(Action):实际执行的外部操作,比如搜索网页、查询数据库、写文件、发请求。动作可以被安全限制、被拦截、被记录。
很多失败的Agent项目,根源在于这四个基本盘混成了一锅粥。状态不清晰、观察和决策相互污染、动作执行没有留痕。Loop Engineering的价值恰恰是把这个过程显式化:每一步有什么输入、发生了什么、产生了什么、如何进入下一步,全都可查、可控、可回滚。把循环本身当成一个一等公民去设计,而不是靠命。
2. 保姆级实战准备:从需求到循环骨架设计
2.1 选一个能说明问题又不至于失控的项目
实战项目不能太简单,否则循环的必要性看不出来;也不能太复杂,否则本末倒置。我的选择是做一个“智能工单分析助手”:用户提交一段客服工单文本,Agent循环负责判断需要哪些分析工具、调用工具、汇总结论,最终输出一个结构化的处理建议。
为什么要选这个场景?
工单分析天然符合循环的节奏:先读工单,觉得需要查订单状态就调订单查询工具,觉得需要看历史客诉就调客诉历史工具,信息还不够就继续调,信息齐了才给出最终归类和建议。其中还有典型的分支情况——工单信息不完整时,Agent要能判断“缺东西”,而不是硬着头皮给结论。这个项目的数据集复杂度适中,工具调用清晰,非常适合作为第一份动手练习。
2.2 不急着写代码:先把循环的状态机画出来
在动手写Loop之前,我一定会先画状态流转。注意,不是画复杂的流程图,而是把“循环每一步可能处于什么状态、可能转移到什么状态”写清楚。你甚至可以写在纸上。
我这个工单助手的循环状态很朴素:
- 初始状态:收到工单原文,尚未做任何处理
- 信息收集中:已做初步分类,但数据不足以支撑最终结论
- 工具调取中:正在调用订单查询、客诉历史、客户等级等工具
- 分析计算中:拿到工具结果,正在做归因或风险判断
- 终态:已产生处理建议与优先级,循环结束
这个状态下,循环的退出条件也清晰了:当分析信息足够(能给出建议)或者达到最大步数时结束。画完这个状态草稿,代码骨架的边界就自然出来了。
2.3 裸写循环,还是直接上框架?
现在市面上有不少Agent编排框架,比如LangGraph、Coze、Dify,它们都内置了循环编排能力。我的建议是:第一轮动手一定要裸写循环。
裸写一次循环,能让你看清每一次“模型输出、工具调用、观察回流”之间到底发生了什么,也能理解框架里那些“节点”“边”“记忆池”其实就是循环的封装。直接上框架虽然快,但出了问题你很难定位是在框架的哪一层出了bug。这篇实战就采用最小依赖、裸写循环的方式,使用的语言是TypeScript,你熟悉的任何语言都可以等价实现。
3. 从零打造一个带工具调用的Agent Loop
3.1 主循环代码骨架
我直接给出核心代码,然后一行一行解释。请先感受整个循环的形状,不急着抄。
type ToolResult = { tool: string; result: string; }; type AgentState = { originalTicket: string; toolResults: ToolResult[]; finalAnswer: string | null; stepCount: number; done: boolean; }; const MAX_STEPS = 6; async function runAgentLoop(ticketText: string): Promise<AgentState> { const state: AgentState = { originalTicket: ticketText, toolResults: [], finalAnswer: null, stepCount: 0, done: false, }; while (!state.done && state.stepCount < MAX_STEPS) { console.log(`--- Step ${state.stepCount} ---`); const decision = await decideNextAction(state); console.log(`Decision: ${JSON.stringify(decision, null, 2)}`); if (decision.type === "final_answer") { state.finalAnswer = decision.content; state.done = true; break; } if (decision.type === "tool_call") { const result = await executeTool(decision.toolName, decision.args); state.toolResults.push({ tool: decision.toolName, result }); } state.stepCount += 1; } if (!state.done) { state.finalAnswer = "已达到最大步数限制,建议补充工单信息或人工介入处理。"; } return state; }这个骨架里最重要的一行是const decision = await decideNextAction(state)。它代表循环的引擎:每转一圈,模型读取当前状态,输出一个结构化决策。结构化很重要,因为我们要让模型输出能被代码直接消费的JSON,而不是自由文本,自由文本会导致下游解析脆弱,整个循环就会跟着变得不可靠。
3.2 决策函数:让模型按规范输出
decideNextAction是决策环节的实现。我的做法是给模型一个描述当前状态的上下文,并要求它输出一个固定的JSON结构:要么是{"type": "tool_call", "toolName": "...", "args": {...}},要么是{"type": "final_answer", "content": "..."}。
import OpenAI from "openai"; const openai = new OpenAI(); const SYSTEM_PROMPT = ` 你是一个工单分析助手。你可以调用以下工具: 1. query_order: 查询订单状态,需要参数 orderId 2. query_history: 查询客户历史客诉,需要参数 customerId 3. query_level: 查询客户会员等级,需要参数 customerId 你必须逐步收集信息,直到足以对工单给出处理建议。 当信息不足时,必须继续调用工具,不能给出猜测性的答案。 当信息足够时,输出最终答复,内容需包含:问题归类、处理建议、优先级。 只输出JSON,不要输出任何解释。 JSON格式: - 调用工具:{"type": "tool_call", "toolName": "query_order", "args": {"orderId": "A001"}} - 最终答复:{"type": "final_answer", "content": "你的最终分析结果"} `; async function decideNextAction(state: AgentState): Promise<any> { const messages = [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: "工单原文:" + state.originalTicket + "\n\n已收集的信息:" + JSON.stringify(state.toolResults), }, ]; const completion = await openai.chat.completions.create({ model: "gpt-4o-mini", messages, response_format: { type: "json_object" }, temperature: 0, }); return JSON.parse(completion.choices[0].message.content ?? "{}"); }这里有几个值得讲究的细节。
temperature必须设成 0,尤其在工具调用的关键环节。循环中的每一步决策都受到上一层输出的影响,如果决策本身带有随机性,整个链路就会在几步之后完全漂移。为了稳定性,我可以接受一些问题(比如偶尔只能给出保守的决策),但不能接受不可复现的输出。
把已收集的工具结果拼接到上下文里,是为了让模型在每轮循环中清楚“我已经知道什么、还不知道什么”。很多Agent项目出现重复调用同一个工具的问题,往往是上下文里缺少“已执行动作历史”,模型每次都像是第一次见面。再加上我传入的state.toolResults就是天然的动作历史。
3.3 工具注册表:循环安全性的第一道闸门
有了决策,下一步就是执行。我们不能让模型去调用任意函数,那太危险了,必须有一个白名单式的执行环境。
const toolRegistry: Record<string, (args: any) => string> = { async query_order(args) { // 实际项目中这里是查询订单数据库 return "订单A001:已发货,预计三天后到达,物流正常。"; }, async query_history(args) { // 实际项目中这里是查询客诉历史表 return "客户历史客诉:2023年有一次发货延迟投诉,已解决。"; }, async query_level(args) { // 实际项目中这里是查询会员等级表 return "客户会员等级:金牌会员。"; }, }; async function executeTool(toolName: string, args: any): Promise<string> { const fn = toolRegistry[toolName]; if (!fn) { throw new Error(`禁止调用未注册的工具: ${toolName}`); } return await fn(args); }每次循环执行工具后,结果都要原样push到state.toolResults。有些实现喜欢把工具结果通过另一个专门的系统消息塞回上下文,这样也有道理,但用一个统一的toolResults数组管理更直观,也方便后续做日志审计。
在执行层还应该主动做参数校验。比如query_order只接受orderId且必须符合[A-Z]+\d+的格式,不合法就拒绝执行并返回“参数非法”给决策层。这样做的好处是把外界的脏数据挡在循环外面,而不是让模型去消化脏数据。我在生产项目里一直把工具注册表当作系统的安全边界,而不是一个简单的函数映射。
3.4 跑通第一版之后,立刻检查的三个信号
代码写完跑一遍是必须的,但跑通不等于跑对。我会在第一时间检查三个信号:
- 信号一:循环在第几步进入终态。如果总是在第5、6步才结束,说明决策不够高效;如果能在2-3步内结束,说明工具信息对决策的支撑度好。
- 信号二:工具调用序列是否有重复。日志里如果出现了
query_history被连续调用三次,大概率是状态合并出了问题,模型没“看见”已经存在的工具结果。 - 信号三:有没有“无用循环”。就是模型明明可以给最终答案了,却还调一个工具再绕一圈,这种可以接受但应该优化。
从这三件事能快速判断一套循环骨架是否健康。如果这三个信号都异常,不要急着换模型或调Prompt,大概率是状态设计的问题,检查toolResults是否真的传进了下一轮决策。
4. Loop失控的常见现象与排查链路
4.1 死循环:模型反复输出同一个动作词
Agent项目最经典、最常见的失控,就是模型在某一轮之后卡住,反复输出同一个工具调用。比如无论状态怎么变,决策总是{"type":"tool_call","toolName":"query_history","args":{"customerId":"C008"}}。
我排查死循环的思路是固定的。第一步,把每一轮的完整决策打出来,确认模型真的在重复,而不是看起来相似;第二步,把传给模型的状态上下文完整打出来,检查toolResults是不是越积越大,导致模型在超长上下文里“迷失”了关键信息;第三步,检查是不是温度太高导致的输出漂移,把temperature设到0再试一次;第四步,检查工具结果是否包含让模型“不舒服”的内容,比如报错信息被塞进去后会诱导模型反复用同一个工具自救。
绝大多数死循环是第三、四种原因被忽视导致的。尤其是工具报错,如果你在executeTool里捕获异常后返回“系统错误”,模型会认为它还可以再试一次,于是无限重试。正确的做法是把错误信息明确标注成“该错误不可恢复,请勿重试,建议改变策略或直接生成最终答复”,这等于在循环里给模型一根救命稻草。
4.2 幻觉累积:错误的观察被当成下一步的事实
循环系统的第二个大坑是幻觉累积。单轮对话里幻觉可能只是一个事实错误,但在循环系统里,错误的观察会作为上下文传入下一轮决策,模型会基于一个根本不存在的“事实”做推理路径,形成越走越偏的链条效应。
举例:工具query_order返回 “订单不存在”,这是真实观察;但模型在下一轮总结时写 “订单已取消”,这就是幻觉。如果下一轮动作基于“订单已取消”执行,整个流程就污染了。
解决幻觉累积没有银弹,但有两个很有效的工程手段。第一是强制引用,要求模型在中间推理时提到工具结果时必须引用完整原文,不能自己改写。第二是隔离事实与推断,在状态里单独设置一个facts字段存放工具返回原文,另设一个analysis字段存放模型的推断,循环决策时优先基于facts生成。把事实和推断分开存,是成本最低、收益最高的Agent循环优化。
4.3 上下文膨胀:循环的隐形价格标签
每个循环都会把之前的工具结果重新拼接进Prompt,循环到第8步时,上下文里可能塞满了历史记录。问题来了:Token费用随步数线性增长,而且模型在超长上下文里对早期信息的注意力会下降。
这就像在一间堆满杂物的房间里找一把钥匙。刚开始房间很整洁,找钥匙很容易;东西越堆越多,你每找一个东西都要把杂物翻一遍,速度越来越慢,还容易漏。
我的缓解策略有三个优先级。初级:给每个工具结果加摘要,强制截断超过200字的工具输出。中级:把已经用过的工具结果折叠成一行摘要,替换上下文中的完整内容。高级:引入外部记忆,把关键结论写入向量库或结构化记录,循环中只传入最近几轮的工具结果。实战项目里先用初级和中级的组合就够了,高级方案留给真正跑长任务的生产系统。
4.4 排查链路:日志、状态快照、重放
无论什么循环问题,排查链路我建议固定成三板斧。
第一板斧:完整日志。每轮循环的decision要完整打印日志,且推荐直接打JSON原文,不要打印美化后的对象,防止丢失字段。第二板斧:状态快照。每一轮结束之后,把state深拷贝一份存进数组,这样你能看到每一轮循环的输入和输出,而不是只知道最终结果。第三板斧:重放。把第一轮的状态和决策重新传给模型,看看是否能复现同一行为,如果复原有困难就修正随机因子。
这条排查链路的本质,是把一个看似玄学的问题变成可回溯、可复现的工程问题。循环系统的bug往往藏在某一轮的上下文拼接逻辑里,没有好日志,你连头绪都找不到。
5. 让Loop变得可控:护栏、预算与退出策略
5.1 不可突破的硬性护栏
循环一旦进入生产环境,护栏就不是可选项。我会在系统里设置三堵墙:
- 最大步数:
MAX_STEPS必须存在,建议根据业务复杂度设为5-10轮,超过即停止并输出人工介入提示。 - 工具白名单:循环只能调用预先注册的工具,任何未注册的工具名一律拒绝。这个守卫要放在决策解析之后、执行动作之前。
- 敏感操作确认:如果工单循环里包含“发退款”“删订单”这类写操作,我会强制要求模型在调用前额外输出一个确认字段,并经过二次校验才放行。
这三堵墙看起来很基础,但不少项目在原型阶段没设,上线后就出事故。 Loop工程最怕“能跑但是没刹车”,护栏不是限制,是让你敢把循环跑更深的前提。
5.2 Token预算管理:循环越长死的越快
Token预算的治理可以从几个维度同时下手。
- 输入侧:只传必要的上下文,不传整个会话历史,而是传“目标描述+当前状态+最近动作结果”。
- 工具侧:限制工具返回结果的大小,聚合查询比逐条查询更省Token。
- 预判侧:提前给循环设置一个预算,比如允许最多消耗5000Token,模型每次动作前都被提醒剩余预算,预算耗尽时循环强制转向“基于已有信息给出答复”。
我自己做过一个实验:同一套工单循环,不设预算时平均每单消耗约8000Token,做了预算控制和上下文折叠之后降到2800Token附近。优化空间不是一点点,而是非常可观。
5.3 结果判定机制:循环不一定要“完美答案”
设计循环退出策略时,很多开发者执拗地认为“必须让模型觉得信息完备才能结束”。这是误区。现实世界的任务信息永远是部分缺失的,过度追求完美会让循环陷入无尽的工具调用。
做生产系统时,我会在状态里加一个confidence(置信度)字段。模型的决策在final_answer之前,必须附带一个0-1之间的置信度值。策略是:置信度高于0.7可以出最终答复;低于0.7但剩余步数不足时,也要出答复,但必须在答复里明确标注“当前结论基于不完整信息”的提示,留给人工审核兜底。
这套结果判定机制的优点,是把“是否收尾”从模糊的模型感觉变成一个可配置的业务规则。你可以根据业务容忍度,随时调整置信度阈值,而不用重新调Prompt。循环工程不追求模型每次都聪明,而是追求系统在最坏情况下也能体面收场。
6. 从单循环到多循环编排:真正的Loop Engineering进阶
6.1 子循环拆分:一个循环干不了所有事
当你把一个Agent系统做复杂之后,会发现把所有逻辑塞进一个循环里会让状态互相干扰。比如工单助手既要分析客诉内容,又要生成回复话术,还要查库存给出解决方案——三种活混在同一个循环里,状态会越来越沉重,模型也容易决策混乱。
合理的做法是拆成子循环。主循环负责整体调度,遇到需要深度文本分析时,交给一个专司总结分析的子循环;遇到需要写回复时,交给一个专司语气风格调整的子循环。每个子循环拥有自己的状态、自己的工具集、自己的退出条件,主循环只关心子循环返回的结果。
这相当于把流水线里的工作分成多个车间,每个车间单独管理内部流程,车间之间只通过标准接口传递半成品。这个模式也是目前再复杂的Multi-Agent系统的底层根源。作为从零到一的开发者,你不需要一开始就搞多Agent集群,但至少要理解“循环可以嵌套、可以并行”的思路。
6.2 多循环之间的通信与竞争条件
多循环系统有一个新手很容易踩的坑,就是共享状态竞争。假设主循环和子循环同时操作同一个toolResults数组,后写的循环会覆盖先写的记录,或者两个循环各自读到不一致的版本。
我在设计多循环通信时坚持两个原则。第一,父子循环之间使用请求-响应模式,子循环只接收主循环派发的任务,完成任务后返回结果,不做双向共享变量。第二,状态传递采用不可变快照,每次循环入参都复制一份,不直接修改上层状态对象。这两个原则可以避开绝大多数并发脏写问题,代价只是多一点内存,完全值得。
6.3 可观测性:跨循环的追踪
最后聊一个生产环境必须考虑的问题:可观测性。单循环的时候,日志还能靠console.log救一下;多循环复杂调度之后,一个请求会穿过好几个循环,你需要在日志里记录traceId,把它贯穿到所有子循环的每一步。
我习惯的做法是:每个任务入口生成一个taskId,在上下文里传给所有循环;每轮循环生成stepId,和taskId拼成完整的日志路径。这样出了问题,可以用grep taskId=xxx直接拉取该任务的完整决策链、工具执行链和状态变化链。做过一次线上事故排查的人都知道,能按任务维度回溯日志比什么都重要。
6.4 我想分享的一个进阶心得
从单循环到多循环,核心心法其实是:让每一个循环做一件小且明确的事,然后用编排机制把它们串起来。不要试图让一个循环拥有全部能力,那会让状态空间爆炸,模型也会在高复杂度下迅速退化。子循环之间的边界越清晰,整个系统的可调试性就越强,也越不容易出现牵一发而动全身的情况。
我在实际项目中还有一个反复用到的技巧,就是给每个循环维护一张“循环身份卡”:它解决什么问题、允许调用哪些工具、最大循环步数是多少、退出条件是什么。这张卡片既是文档,也是调试排障的第一手资料。循环系统的复杂度不可怕,真正可怕的是你不知道当前这一步到底是哪个循环在跑、为什么在跑、跑到哪里了。
Loop Engineering是一个实践性很强的话题。自己做一遍“裸写循环→调试→加护栏→拆子循环”这个路径之后,再回头去看各类Agent框架,你会有完全不同的视角——你能看出它们封装了什么、隐藏了什么、在哪里可能出bug。循环是AI应用最核心的骨架,值得花时间把它用好。