
前言封闭告一段落开始系统复盘一个生产 Agent 到底怎么跑起来。踩坑、经验和落地最主要的是用户认可产品。尽量用几篇文章讲清这篇文章是系列第3篇接上 Function Calling模型按约定格式提出「要调哪个工具、带什么参数」不等于有 Agent。要把「想一步、做一步、看结果」做成能停、失败能写进对话、中途缺东西能问用户的循环。用户说“看看支付服务最近报错顺便对照一下文档里的处理建议。”上一篇把意图认清楚了。这篇盯住认完意图之后Agent 往往不是调一次工具就结束而是要走好几步。很多团队接上 Function Calling 之后会默认「模型选工具 → 执行 → 把结果当答案」就算 Agent 了。线上很快会撞墙用户一句里有两件事只办了一件工具一报错整轮接口直接 400中间缺参数想问用户状态却丢了前端只看见最后一段字不知道中间查过什么。继续用案例 OpsPilot企业运维助手虚构案例技术栈仍是 TypeScript。这篇只把循环本身写清楚想一步、做一步、看结果、再想。ReActReason Act边推理边行动要解决的就是把这件事做成稳定循环而不是调一次就收工。先把循环画出来核心就这一圈会转好几次Thought → Action → Observation → Thought → ... → Final Answer框里是主循环。看完结果默认回到 Thought再想下一轮只有不用工具了、要问用户、或转太多次才从圈里出来。图里几块分别是Thought模型思考下一步干什么。Action提出要调哪个工具、带什么参数并真正执行。Observation工具跑完后写进对话的结果成功、失败、取消都算。Final Answer不再调工具直接给用户最终回复。问用户 / 上限参数不够或操作有风险就停下来问用户转太多次也要停。问之前先把已经跑完的工具结果写进对话别把半截结果丢掉。落到工程上对话是一条条往下追加的模型可能带 tool_calls要调哪个工具、带什么参数、以及配对用的唯一 id 你的执行层跑工具把结果写成一条 tool 消息每个 id 一条 模型继续想 / 再调工具 / 给出最终回复 直到模型不再调工具或必须停下来问用户或已经转了太多次只要用了工具最少也要两轮模型调用第 1 轮模型看用户话 → 提出要调哪些工具可以一次提好几个→ 代码把这些工具都跑完结果写进对话 第 2 轮模型看工具结果 → 不再调工具直接给出最终回答 → return第 1 轮里即便把需要的工具都调完了仍然要进第 2 轮。第 1 轮结束时对话里只有「要调什么」和「工具结果」还没有给人看的最终回答最终回答是第 2 轮模型看完结果之后才说出来的。代码里是没有toolCalls就 return。正常答完时停下来看模型这一轮还提不提工具另外还有转太多次强制停、以及停下来问用户这两种情况。还会出现第 3、第 4 轮是因为第 2 轮看完结果后可能又要再查一样东西——比如先查到服务 id下一轮才查这份日志。那是步骤有依赖不是一上来就规定必须转几圈。LangChain / LangGraph 的 ReActcreateReactAgent/createAgent也是同一套模型节点 → 有tool_calls就进工具节点 → 再回到模型节点直到最后一条模型消息没有tool_calls才结束。框架已画成图逻辑和下面的for一样。工程上循环在维护一张对话清单清单上只有四种话系统约束、用户说的、模型说的、工具跑完的结果。每一圈把整张清单交给模型模型要么直接答完要么说「去调某某工具」。若要调工具代码真去跑跑完成功或失败都行把结果追加进清单再进入下一圈。若不再调工具循环结束清单里最后那句助手话就是最终回复。若转了太多次还没收工强制停给用户一句说明别让接口挂掉。该不该调、调哪个交给模型循环代码只负责最多转几圈、每条工具结果能不能对上 id、用户取消、以及什么时候停。下面是一个简单的推理循环实现// 对话里的一条消息只能是下面四种之一typeChatMessage|{role:system;content:string}// 系统约束|{role:user;content:string}// 用户说的|{role:assistant;content?:string;toolCalls?:ToolCall[]}// 模型说的可能带着「要调的工具」|{role:tool;toolCallId:string;name:string;content:string}// 工具结果必须对上模型那次提出调工具时带的 id// 模型提出的一次工具调用typeToolCall{id:string// 唯一 id后面写结果时要对上这个name:string// 调哪个工具arguments:unknown// 带什么参数执行前再校验}asyncfunctionreactLoop(input:{messages:ChatMessage[]// 当前对话清单tools:ToolRuntime// 真去跑工具的那一层maxSteps:number// 最多转几圈signal:AbortSignal// 用户点停止}):PromiseChatMessage[]{constmessages[...input.messages]// 复制一份后面往上追加// 每一圈模型想一次 →可能调工具 → 看结果maxSteps 是上限for(letstep0;stepinput.maxSteps;step){// Thought把整张清单交给模型constassistantawaitcallModel({messages,tools:input.tools.definitionsForModel(),signal:input.signal})messages.push(assistant)// 没有 toolCalls → Final Answer结束if(!assistant.toolCalls?.length){returnmessages}// Action跑工具失败也要写成结果constobservationsawaitinput.tools.executeAll(assistant.toolCalls,{signal:input.signal})// Observation每个 id 一条写进清单后再进入下一圈for(constobsofobservations){messages.push({role:tool,toolCallId:obs.callId,name:obs.toolName,content:obs.contentForModel})}}// 触达 maxSteps 还没收工 → 强制停messages.push({role:assistant,content:这次已经转了太多次先停。已查到的可以先告诉用户并说明没能继续。})returnmessages}失败了也要写进对话多个工具要等齐失败也要写成结果写进对话把工具结果写进对话、再交给模型看。而模型提出的每个tool_call.id后面都必须有一条对应的结果消息。少一条下一轮请求对不齐接口会直接 400。OpenAI 和 Anthropic 的客户端工具都是这个要求。各家写法不一样OpenAI 常见是role: toolAnthropic 则是把tool_result放进下一条 user 消息。这些差异写在对接各家模型的适配层里就行别假设「全世界都叫 ToolMessage」。Anthropic 还有一类工具由接口自己代跑不经过你的执行层也不走这套「你执行、你写回结果」的路径。工具里直接throw框架对不上 id整轮请求失败——用户看到的是接口错不是「查库失败换个说法」。正确做法是执行失败也是一种 Observation写成结果给模型看不是让循环崩溃。// 单工具执行成功/失败都变成 Observation禁止 throw 崩整轮asyncfunctionexecuteOne(call:ToolCall,runtime:ToolRuntime){try{constresultawaitruntime.invoke(call)// 统一走校验/鉴权/超时return{callId:call.id,toolName:call.name,contentForModel:JSON.stringify({ok:true,data:result.forModel// 给模型看的裁剪结果}),interrupt:result.interrupt// 若要停下来问用户先记下等这批工具都跑完再处理}}catch(err){// 失败也写成结果写进对话模型可改参数、换工具或告诉用户办不了return{callId:call.id,toolName:call.name,contentForModel:JSON.stringify({ok:false,errorCode:classifyError(err),message:publicErrorMessage(err)// 不要把报错堆栈写给模型}),interrupt:undefined}}}模型看到ok: false可以改参数、换工具或老实告诉用户办不了。一轮提出多个工具时默认先等齐OpsPilot 里常见用户一句要查资源和查文档模型一轮里可能同时提出两个工具tool_calls: [search_resource, search_knowledge_base]若 A 返回「缺参数请用户选择」B 还在飞此时立刻结束请求、丢掉 B 的结果下一轮很难对齐 call id前端步骤条也会缺一块。等这批工具都跑完是我们自己的运行策略不是哪家协议硬性要求。Anthropic 官方也写明并行时怎么执行由你决定但每个tool_use都必须回一条tool_result。LangChain / LangGraph 也一样工具节点里可以一起跑但「要不要等齐、写操作能不能并行」仍是你定的策略不是框架替你拍板。更稳的默认只查不改的工具可以一起跑并等齐结果会改数据的操作重启、发布不要一起跑并行写操作风险高用户点取消时也要给每个已经发出的 id 写回一条「取消了」的结果别取消了还空等到超时。只查不改 → 可以一起跑等齐结果再决定要不要停下来问用户 会改数据 → 串行一批最多做一个写操作 用户取消 → 尽快停但仍给每个已发出的 call id 写回一条取消或失败typeBatchResult{observations:Observation[]// 本批全部工具结果含失败deferredInterrupt?:InterruptPayload// 要问用户的话等这批都跑完再交给上层}// 本批工具先全部跑完再决定要不要停下来问用户避免并行时丢掉某个 idasyncfunctionexecuteBatch(calls:ToolCall[],runtime:ToolRuntime):PromiseBatchResult{// 只查不改的可以一起跑会改数据的不要走进这条并行路径constobservationsawaitPromise.all(calls.map(cexecuteOne(c,runtime)))constinterruptobservations.find(oo.interrupt)?.interruptreturn{observations,deferredInterrupt:interrupt}}constbatchawaitexecuteBatch(assistant.toolCalls,tools)for(constobsofbatch.observations){messages.push(toToolMessage(obs))// 按 call id 把结果写进对话emitSse({type:tool_result,...obs.forFrontend})// SSE服务端推事件给前端步骤条}// 这批都写进对话后再停下来问用户本轮请求结束下次靠保存的进度或用户补上的参数接着跑if(batch.deferredInterrupt){emitSse({type:loop_interrupt,...batch.deferredInterrupt})return}互不依赖的查询适合并行。要先拿到资源 id、再查这份日志时不要指望模型「碰巧」并行对——应串行或走固定流程。模型一次可提多个工具和一次只许一个工具模型自己决定调几个一轮里可以同时提出好几个工具执行层可以一起跑。适合探索、检索、说明类任务。这一圈只许一个工具跑完再把结果写进对话。适合必须先 A 后 B。关键点助手消息里一旦出现多个tool_calls每一个 id 都要有结果。不要只执行第一个、丢掉其余——下一轮会协议报错。更稳的做法是这一圈只让模型看见一个工具或强制它必须调这一个从源头让它只提一个如果还是多提了没执行的那些也要写回一条「这一圈没执行」的结果。// 串行一圈只让模型看见这一个工具用来做「必须先 A 后 B」asyncfunctionserialStep(input:{messages:ChatMessage[]tool:ToolDefinition// 这一圈唯一允许的工具signal:AbortSignal}){constassistantawaitcallModel({messages:input.messages,tools:[input.tool.forModel],// 只给模型看这一个工具toolChoice:{type:auto},// 或改成强制必须调这个工具signal:input.signal})constcallsassistant.toolCalls??[]if(!calls.length){return{assistant,observations:[]asObservation[]}}// 每个 call id 都必须有一条结果没执行的也要写回去否则下一轮 400constobservations:Observation[][]for(constcallofcalls){if(call.nameinput.tool.name){observations.push(awaitexecuteOne(call,/* 仅含本圈工具的执行层 */))}else{observations.push({callId:call.id,toolName:call.name,contentForModel:JSON.stringify({ok:false,errorCode:skipped_in_serial_step,message:这一圈只允许${input.tool.name}此调用未执行}),interrupt:undefined})}}return{assistant,observations}}产品上可以拆两条路主对话让模型自己决定调几个工具开通、发布这类必须按顺序来的走串行或状态机。别只靠 Prompt 硬逼“永远先查资源再查库”否则用户换个说法就是漏洞。边跑边推前端以及停下来问用户循环跑的时候前端最好别干等最后一段字。模型一提出要调工具、工具一跑完、要停下来问人时都应该立刻推到页面上用户才知道正在查日志、正在对照文档。常见两种推法推法前端看到的适合按步骤推事件正在调用某某工具、工具返回了什么要步骤条的运维助手按字推最终回答回答一个字一个字冒出来几乎不调工具的纯聊天框架每次推整份对话时只处理新增的那几条有的框架例如 LangGraph 的streamMode: values每次推过来的不是「刚多出来的一条」而是到目前为止的整份对话。打个比方第 1 次推来[A]第 2 次推来[A, B]第 3 次推来[A, B, C]。如果你每次都把整份列表再推给前端一遍用户会看到 A、B 重复出现。做法记下上次处理到第几条只把新增的那几条转成前端事件再通过 SSE服务端推事件推给页面。SSE 协议怎么定、前端怎么接系列后面再单独写这里只要求别把同一批步骤推两遍。letprocessed0// 上次已经处理到第几条forawait(conststateofagent.stream(input,{streamMode:values})){constmessagesstate.messagesasChatMessage[]constdeltamessages.slice(processed)// 只取新增的切记processedmessages.lengthfor(constmsgofdelta){if(msg.roleassistantmsg.toolCalls?.length){for(constcallofmsg.toolCalls){emitSse({type:tool_call,id:call.id,name:call.name,args:call.arguments})}}if(msg.roletool){emitSse({type:tool_result,toolCallId:msg.toolCallId,content:msg.content})}}}页面上步骤条怎么排、最终气泡怎么显示是产品问题。循环这边至少做到推出去的事件顺序事后能还原数据库里能留下工具这一段同一批步骤别重复推循环里怎么停下来问用户工具发现还缺参数比如没选环境或这是高风险操作比如重启时别假装已经成功。让工具返回一种固定结构表示“先停一下问问用户”typeInterruptPayload{interrupt:true// 标记要问用户不是普通失败interruptType:params_required|confirm_risky// 还缺参数 / 高风险要确认reason:string// 给人看的原因payload:Recordstring,unknown// 前端要展示的表单、选项等}工具返回示例{interrupt:true,interruptType:params_required,reason:需要选择环境和应用后才能查日志,payload:{fields:[environment,serviceId]}}主循环看到这个标记后给前端推一条“请补充或确认”然后结束本轮 HTTP。用户选完再发下一轮——参数可以写进用户消息也可以写进这次运行保存的进度。LangGraph 里常见对应是interrupt把图挂起用户补完再用 Command 之类接着跑。思路相同只是进度由框架帮你存。注意三点前端要展示成「等你确认」而不是红字报错。这是正常流程不是失败。服务端可以暂时不存进度靠会话历史数据库 用户这次提交的参数接着跑有运行记录和保存的进度更稳。后面的文章再写别在入口用关键词决定弹不弹框用户说了应用名可能该先搜资源不能一看到「日志」就弹表单。缺不缺参数由工具或意图层的结构化数据决定。什么时候强制停以及事后怎么排查不设上限的 ReAct 会烧钱也会死循环。一般来说一步就是循环转一圈模型想一次可能调工具再把结果写回去。生产里通常就卡这几条最多转几圈、整轮最多跑多久、连续失败几次就停、同一个工具带同一组参数反复调就停。LangGraph 的recursion_limit管的是图里跳了多少步和手写循环里的maxSteps是一类开关别指望框架默认就替你拦住“同参反复调”。typeLoopLimits{maxSteps:number// 最多转几圈建议起点 612按场景自测maxWallTimeMs:number// 整轮真实耗时上限防请求挂死maxToolFailsInRow:number// 连续工具失败上限identicalCallLimit:number// 同名同参连续重复上限}// 任一触达即停functionshouldStop(ctx:{step:numberstartedAt:numberrecentCalls:string[]// 工具名 参数摘要用来发现同一件事反复调failStreak:numberlimits:LoopLimits}):continue|max_steps|timeout|loop_detected|too_many_fails{if(ctx.stepctx.limits.maxSteps)returnmax_stepsif(Date.now()-ctx.startedAtctx.limits.maxWallTimeMs)returntimeoutif(ctx.failStreakctx.limits.maxToolFailsInRow)returntoo_many_failsconstlastctx.recentCalls[ctx.recentCalls.length-1]letstreak0for(letictx.recentCalls.length-1;i0;i--){if(ctx.recentCalls[i]!last)breakstreak}if(laststreakctx.limits.identicalCallLimit)returnloop_detectedreturncontinue}事后要能排查每次循环至少留下这次运行、这次会话的 id。每一步的模型耗时、是否产生tool_calls。每个工具的callId、参数摘要、成功、失败、停下来问用户、耗时。终止原因正常答完、问用户、转太多次 …看数据面板先看三件事平均转了几圈工具失败率停下来问用户之后有多少能接着跑完。圈数突然飙升往往是工具说明没写清或写回给模型的结果又长又乱模型在原地反复调。什么时候适合什么时候不必上合适就几步就能答完的业务问答查资源 → 查文档 → 回答。需要根据中间结果改主意的探索任务。工具不多、失败可以重试、或能跟用户说清楚办不了的场景。吃力要等几个小时、要好几个人审批、必须严格先 A 后 B 的长流程。生产变更必须能知道走到第几步了。成本和延迟很敏感、不允许模型自己多转几圈的批量任务。后面这种把“流程推进”交给规划、状态机或固定工作流可以某一小步里再嵌一段 ReAct。把整段长任务丢给自由循环常见结局是重复踩坑、状态说不清、账单和风险一起涨。常见实现误区工具一抛异常就让整轮 400— 失败应写成 Observation写进对话。多个工具还没跑完就丢掉结果— 只查的要默认等齐会改数据的不要一起跑用户取消时也要写回一条结果。助手提了多个 tool_calls却只把其中一个结果写回去— 每个 id 都必须有结果。把完整 Observation 原文写回模型— 报错堆栈、手机号邮箱、超长列表会把上下文弄脏写回去之前先去掉敏感信息过长的截断。用关键词在入口预判要不要进 ReAct— 用户那句话什么意思交给意图层按数据格式输出循环入口只做鉴权这个用户还能不能继续用。没有最多转几圈、也没有“同一工具同一参数反复调”的检测— 模型会围着错误参数原地打转。前端只渲染最终文本— 排障和用户信任都靠中间步骤至少库里能还原发生过什么。把“停下来问用户”当成普通报错— 问完还要能接着跑不是前端飘一条失败提示就结束。最小动手路径用两个只读假工具search_resource、search_docs跑通上面的reactLoop。故意让一个工具抛错确认循环仍能返回友好说明且每个 call id 都有工具结果消息。让模型一轮并行调两个工具SSE或日志里能看到四条事件两个 call、两个 result。让其中一个工具返回「要问用户」确认另一条结果仍被记录然后再结束本轮。让模型反复调同一个假工具确认连续同参会触发loop_detected再把maxSteps收紧确认转太多次时能停并且给用户一句可读说明。开发的时候都通了再接真实鉴权、问用户之后怎么接着跑、以及监控。这篇没展开、系列后面还会写的这里面提到很多细节点一篇文章写不完后面专门拿出来写都很重要主题后面打算怎么写流式推送、停下来问用户、接着跑多轮对话与 SSE工具注册、参数校验、重试、权限工具执行层规划、状态机、长任务Agent Runtime上下文窗口、长期记忆记忆与上下文转了几圈、花了多少钱模型成本ReAct 是短任务的默认循环长任务还要加规划或状态机。先把消息怎么配对、失败怎么写进对话、问用户前先等齐结果、以及什么时候强制停这几件事做对再谈框架选型返工会少很多。小结上面细节不少就这三条工具失败了也要把结果写进对话不要让整轮接口直接失败退出每个tool_callid 都要对上一条结果。会改数据的工具别一起跑用户取消了每个 call id 仍要有一条结果。设好最多转几圈并拦住“同一工具同一参数反复调”别一直烧 Token。下一篇继续《Agent 工具调用工程化把 Tool Runtime 做扎实》。希望这篇文章对你有所帮助、有所借鉴。