拓冰建站拓冰建站
首页 / 资讯中心 / 正文

生产级 AgentLoop 实战:从 while(true) 到状态机与上下文治理

写 Agent 的人大概都经历过同一个瞬间花二十分钟搭起一个while(true)循环模型调工具、结果塞回上下文、再调一次模型跑通了兴奋得不行。然后你把它接到真实业务里第一天就收到三条告警——步数跑满没输出、账单涨了 40 倍、某一步工具报错之后整条链直接卡死。AgentLoop 这个词听起来很高级拆开看其实就是模型决策 工具执行 状态推进这三件事反复转圈真正难的不是让圈转起来而是让它在异常、超时、上下文爆炸、模型犯傻的情况下还能可控地转下去。这篇东西写给两类人一类是已经写过 demo 循环、准备往线上推的开发者另一类是正在评估要不要自研 Agent 循环的架构同学。我会把while(true)到生产级循环之间那几十个坑按我实际踩过的顺序捋一遍包含完整代码骨架、参数计算过程以及一份可以直接抄的排查速查表。1. 先把循环拆开看AgentLoop 到底在转什么1.1 从最小可运行版本说起几乎所有 Agent 项目的起点都是这十几行messages [{role: system, content: SYSTEM_PROMPT}] while True: resp llm.chat(messages, toolsTOOL_SCHEMAS) messages.append(resp.message) if not resp.tool_calls: print(resp.content) break for call in resp.tool_calls: result dispatch(call.name, call.arguments) messages.append({role: tool, content: result})这段代码逻辑上没错它也确实是 AgentLoop 的内核。问题在于while True这四个字——它把什么时候停这个最关键的决策完全交给了模型自己。模型心情好就停心情不好就一直调工具工具挂了它就再调一次上下文满了它就开始胡说。演示阶段你看不出毛病因为任务简单、路径短、没人盯着成本。我要强调的第一个判断while(true)不是错在循环而是错在隐式。所有终止条件、状态迁移、错误处理都是隐式的藏在模型的输出里。生产级循环做的事情就是把这些隐式规则全部显式化变成代码里能读、能测、能告警的东西。1.2 演示版和生产版的差距在哪我习惯用一张表来跟团队对齐这件事因为光讲概念大家点头看到差距清单才会真重视。维度演示版 while(true)生产级循环终止条件模型不返回工具调用就停显式状态机 多重兜底步数控制无或一个魔法数字基于 P95 任务分布统计得出上下文一直 append直到报错预算制 分级压缩 外部记忆工具失败异常直接冒泡错误分类 分级重试 降级路径副作用可能重复执行幂等键 执行去重可观测print 大法trace、指标、每步快照成本不知道花了多少每步记账 预算熔断并发单请求串行会话隔离 限流 背压这张表里每一项都可以单独写一篇文章但它们共同指向一个结论生产级循环的核心不是更聪明的提示词而是更笨但更可靠的工程约束。让模型负责它擅长的意图理解、工具选择、参数填充让代码负责它擅长的边界、重试、记账、熔断这个分工一旦理清循环就稳了。1.3 把一圈拆成五个阶段为了后面讲细节方便我先给循环定一套阶段划分这套划分我在三个项目里都用过比较通用观察Observe把当前上下文、可用工具、剩余预算整理成模型能消费的输入。决策Decide调用模型拿到自然语言回复或者工具调用请求。行动Act校验并执行工具产生真实世界的副作用或数据读取。反馈Feed把工具结果裁剪、脱敏、格式化后写回上下文。判定Judge决定继续、结束、还是进入异常分支。while(true)版本里这五步全部挤在一个循环体里一共六行代码。生产级循环里这五步会分别落到五个函数甚至五个模块每步都有自己的超时、指标和失败策略。这不是过度设计而是因为线上出问题时你需要知道是决策错了还是行动错了六行代码混在一起你根本定位不了。2. 四个稳态问题为什么裸循环撑不住2.1 终止条件的两个极端裸循环在终止这件事上会犯两种完全相反的错误。第一种是不肯停模型陷入调工具—看结果—觉得不够—再调工具的循环尤其是检索类任务它总能找到再查一下会更好的理由。我曾经见过一个 Agent 在同一个知识库上查了 47 次每次都是相近的关键词因为每次返回的内容都不完整它就换着说法继续问。第二种是停得太早模型在第一轮就返回一句我已经完成了任务但实际上一个工具都没调。这种情况在任务描述模糊时特别常见模型用自然语言把我不知道怎么做包装成了我做完了。应对这两种极端我的做法是双向判定既要有上限步数、时间、预算也要有下限必须完成的动作清单。上限容易理解下限经常被忽略——比如至少调用一次搜索工具最终回答必须引用至少一条工具返回结果这类硬性检查用规则代码去验而不是信任模型的自述。提示任何模型自称完成了的判断都要在代码层做一次可验证的校验。自述是最不可靠的完成信号。2.2 上下文膨胀每一步都在加料这是最容易被低估的问题。每一轮上下文会增加三块内容模型的思考与工具调用请求、工具返回的原始结果、以及可能的中间摘要。工具返回是最大的变量一次搜索返回十条网页摘要可能就吃掉两千 token一次数据库查询返回两百行 JSON八千 token 起步。我实测过一个任务单轮平均净增约 1200 token。看起来不多但 30 轮就是 36000 token再加上系统提示词、工具 schema、历史对话轻松逼近 64k。而且注意这是线性增长不是固定开销——你多跑一步后面每一步的输入成本都在涨因为完整历史会被反复送入模型。这直接解释了为什么很多人觉得Agent 比单次问答贵几十倍贵的不只是步数是步数的平方级效应。处理方式我在第 4 节详细讲这里先给结论上下文不是日志不能无脑 append。它是有限资源要有预算、有淘汰策略、有外部化机制。2.3 一次工具失败带走整条链裸循环里工具执行通常是这样的result call_external_api(call.arguments)如果这个 API 超时了、返回 500 了、或者返回了一个模型看不懂的结构异常会往上冒整个循环终止用户看到的是一个空白或者一段错误堆栈。更糟糕的情况是异常被静默吞掉工具返回None模型收到一个空结果于是它开始猜测——这时候幻觉就来了。我处理过的真实案例一个订单查询工具在限流时返回了{code: 429, msg: too many requests}模型把这个当成了订单不存在然后回答用户您没有该订单引发了一次客诉。这个 bug 的根因不在模型在于工具层没有把错误语义和业务语义分离。生产级循环必须有一层错误归一层把网络错误、限流、参数错误、业务空结果、权限拒绝这些区分开分别映射成模型能理解的描述并附上你接下来应该怎么做的提示。比如限流就明确告诉它服务繁忙请稍后重试或换用其他工具而不是丢一个裸的 429。2.4 成本与延迟钱到底花在哪没做记账之前我对 Agent 成本的认知基本靠猜。做了之后发现结构很反直觉输入 token 往往占大头而不是输出。因为每轮都要重新发一遍完整历史输入 token 是累加的输出只有当前轮那几百个。延迟同理。一轮循环的耗时约等于模型推理时间 工具执行时间 网络往返。工具有时比模型还慢一次外部 API 三秒、模型两秒十轮就是五十秒。用户等五十秒会以为系统挂了所以流式输出和中间状态提示不是锦上添花是必需项。注意给循环设总超时的同时一定要给单步设超时。只设总超时会出现最后一步卡死导致整个任务失败的情况明明再给两秒就出结果了。3. 生产级循环的骨架设计3.1 把 while 换成显式状态机我不推荐直接用while写业务循环更推荐把循环体写作一个状态机。好处是每一轮的状态可序列化、可恢复、可测试。from enum import Enum class Phase(str, Enum): OBSERVE observe DECIDE decide ACT act FEED feed class LoopState: def __init__(self, task_id, task, budget): self.task_id task_id self.task task self.phase Phase.OBSERVE self.step 0 self.messages [] self.usage {input_tokens: 0, output_tokens: 0, cost: 0.0} self.budget budget # 步数/时间/成本三重预算 self.done False self.final None循环体变成这样def run(state, deps): while not state.done: if state.step state.budget.max_steps: return force_finish(state, reasonmax_steps) if time.time() - state.started_at state.budget.deadline: return force_finish(state, reasontimeout) if state.usage[cost] state.budget.max_cost: return force_finish(state, reasoncost) state observe(state, deps) state decide(state, deps) if state.pending_calls: state act(state, deps) state feed(state, deps) state judge(state, deps) state.step 1 return state.final注意force_finish这个函数非常关键预算耗尽不等于任务失败你要用已有的上下文让模型做一次收尾总结把我查到这些、没查到这些、建议你怎么办输出给用户。直接抛异常是最偷懒也最伤体验的做法。3.2 每一轮要约定清楚的输入输出契约状态机化之后每一轮的输入输出都必须有明确契约否则阶段之间会互相污染。我一般定三张表决策阶段的输出契约{ thought: 用户想知道A和B的关系我需要先查A, tool_calls: [ {id: call_1, name: search, arguments: {q: A 定义}} ], finish: false }行动阶段的输出契约{ call_id: call_1, status: ok, data: ..., truncated: false, elapsed_ms: 812 }反馈阶段要写回上下文的内容{role: tool, tool_call_id: call_1, content: 搜索结果已截断共10条显示前3条...}契约的价值在于所有异常都能映射到status字段里模型看到的永远是结构化的、带解释的结果而不是一段裸数据或者一个空字符串。3.3 工具层要做的四件事工具层经常被当成薄薄的一层包装实际上它是生产级循环里最应该做厚的地方。我的做法是每个工具必须完成四件事参数校验模型给的参数是概率产物类型错、字段缺、枚举值编造都很常见。用 JSON Schema 校验一遍校验失败就返回一条结构化的错误让模型自己改。幂等保护对写操作生成幂等键比如task_id tool_name 参数哈希重复调用直接返回上次结果避免重复下单、重复发消息这类事故。结果裁剪在工具内部就把结果裁剪到合理大小超过阈值的部分做截断并标注。不要把裁剪这件事留给反馈阶段因为工具最清楚自己返回的数据结构截断点放这里最合理。耗时与错误埋点每个工具的上报耗时、成功率、错误分布。这几个指标决定了你后面重试策略怎么配。提示把工具的业务空结果和技术错误分开。前者是正常返回后者才需要重试。混在一起会导致对一个确实查不到的查询疯狂重试。4. 关键实现把循环写扎实4.1 终止判定的三层防线我的终止判定永远写三层任何一层触发都能安全收尾。第一层是模型自述终止模型返回了不带工具调用的消息并且通过了完成度校验。第二层是预算终止步数、时间、成本任一触顶走force_finish收尾。第三层是异常终止连续错误超过阈值、或者检测到循环模式同样的工具加同样的参数出现三次以上直接中断并降级。第三层的循环检测值得单独说一下。实现非常简单对每次工具调用算一个签名hash(name json.dumps(args, sort_keysTrue))维护一个计数器同一个签名出现两次时给模型发一条提醒你已经用相同参数调用过该工具请换个思路出现三次就强制中断。这一个改动把我在测试环境里遇到的空转问题解决了八成以上。4.2 上下文治理的三板斧上下文治理我总结成三板斧预算、压缩、外置。预算把上下文窗口当成一个固定盘子来分。以 128k 窗口为例我的分配是系统提示词 1.5k、工具 schema 3k、用户任务 0.5k、历史消息池 60k、模型输出预留 8k、安全余量 5k。历史消息池到 60k 就触发压缩。压缩压缩不能简单粗暴地截断要分层。我的分层策略是最近 3 轮对话保持原样模型最需要这部分的细节4 到 10 轮之前的工具结果只保留摘要用一次便宜的模型调用生成一句话摘要10 轮以前的内容全部替换成一条任务进展摘要。这样既保住了近期细节又保住了长期脉络。def build_context(state, recent_keep3, summarize_until10): msgs state.messages tail msgs[-recent_keep * 2:] middle msgs[-(summarize_until * 2):-recent_keep * 2] head msgs[:-(summarize_until * 2)] parts [] if head: parts.append({role: system, content: [早期进展摘要] state.early_summary}) for m in middle: if m[role] tool: parts.append({role: system, content: [工具结果摘要] cheap_summarize(m[content])}) else: parts.append(m) return parts tail外置凡是超过一定体积、但又可能被再次引用的内容比如查询到的完整文档、大 JSON全部写到外部存储上下文里只留一个引用 ID 和一段描述。模型需要细节时可以再调一次工具把指定 ID 的内容取回来。这个模式我称之为上下文里只放目录不放全书效果非常好。4.3 错误分类与分级重试重试不能一刀切我的分类是这样的错误类型典型表现处理策略重试次数瞬时网络错误连接超时、5xx指数退避重试2 到 3 次限流429、配额超限退避 换备用通道2 次参数错误Schema 校验失败不重试回传错误让模型改0 次业务空结果查询无记录不重试正常返回0 次权限错误403、无权限不重试降级或告知用户0 次工具内部异常解析失败、空指针修参数重试一次仍失败则降级1 次关键点是退避要有抖动。纯指数退避在多实例并发时会造成重试风暴同一时刻一起重试把下游打垮。加一个 ±30% 的随机抖动问题基本消失。import random, time def backoff(attempt, base0.5, cap8.0): delay min(cap, base * (2 ** attempt)) return delay * (0.7 random.random() * 0.6)4.4 可观测性不埋点等于闭眼开车AgentLoop 的可观测性我按三个粒度做。任务级任务 ID、总步数、总耗时、总成本、最终状态成功/预算耗尽/异常。步骤级每一步的阶段、耗时、输入输出摘要、token 消耗。工具级每个工具的调用次数、成功率、P95 耗时、错误码分布。其中最有用的是每步快照。把每一步的完整上下文和模型输出落盘脱敏后出问题时可以直接重放。我遇到的大部分疑难问题都是靠重放某一步的快照定位的——比如发现某次工具返回被压缩时误删了关键字段导致模型后面连续三轮都在瞎猜。成本记账建议直接记在状态对象里每轮累加超过阈值立刻熔断。别指望月底看账单账单出来的时候损失已经造成了。5. 参数怎么算才不心虚5.1 上下文预算的除法很多人问我上下文该留多少给历史消息我给的是算法不是数字。假设窗口是 128k系统提示词实测 token 数1200工具 schema 实测26008 个工具每个平均 325用户任务平均500输出预留8000Agent 的输出通常不长但要留足安全余量5000那么历史消息池上限 128000 - 1200 - 2600 - 500 - 8000 - 5000 110700。但我不建议真的用到 110k因为模型的注意力在长上下文里会稀释实际有效信息利用率会下降。我的经验值是砍到 60%也就是约 66k 触发压缩。5.2 步数上限怎么定不要拍脑袋定 20 或者 50。正确做法是先从线上采样跑一批真实任务记录每个任务实际用了多少步。假设你统计了 500 个任务得到分布中位数 5 步P90 是 9 步P95 是 13 步P99 是 24 步。那么步数上限应该设在P99 附近也就是 20 到 25。设在 P95 会误杀正常任务设在 P99 的十倍250等于没设。同时要配一个软提醒超过 P95 步数时往上下文里注入一条提示让模型优先收敛而不是继续探索。这个软硬结合的方式比单纯卡一个大数字有效得多。5.3 超时、并发和限流超时我按层设单步模型调用 60 秒流式的话首 token 15 秒单次工具调用 30 秒单任务总时长 300 秒。三层都要有缺一层都会出现整体卡死。并发要注意的是会话隔离。同一个用户的多轮对话必须串行否则两个循环同时在改同一个状态对象会出现上下文错乱。跨会话可以并发但要限流对下游工具按 QPS 限流对模型调用按并发数限流超出的请求排队而不是直接拒绝队列长度也要设上限否则内存会涨。6. 常见问题与排查实录6.1 死循环与空转现象任务跑到步数上限日志里同一个工具的调用重复出现。排查思路先看签名重复率。如果同一签名重复三次以上说明模型没有从上次结果里获得新信息。常见原因有两个一是工具返回的内容被压缩或截断得太狠模型看不到关键信息只好重试二是工具返回了错误但错误信息不明确模型不知道要换参数。解决把循环检测做成主动干预而不是被动计数第 4.1 节的方法同时在工具返回里加上本次结果与前次是否相同的标记让模型明确知道自己在原地打转。6.2 工具参数幻觉现象模型编造了不存在的参数值比如枚举类型给了个列表外的值或者日期给了个未来三年的年份。排查看 Schema 校验失败率。如果某个工具的失败率明显偏高多半是它的参数描述写得不好。解决在工具 description 里明确给出取值示例和取值范围别写日期字符串这种模糊描述写成日期格式 YYYY-MM-DD例如 2024-03-15不得晚于今天。别小看这句描述我实测把参数错误率从 18% 降到了 4%。6.3 重复执行副作用操作现象用户收到两条相同的通知或者订单被创建了两次。根因模型在某一轮重复请求了写操作或者在重试逻辑里没有做幂等。解决写操作必须携带幂等键幂等键由task_id tool_name 参数规范化哈希组成服务端收到重复键直接返回上次结果并标注deduped: true。同时重试策略里只有读操作允许自动重试写操作绝不自动重试。6.4 上下文截断导致失忆现象Agent 跑到中后段突然开始重复问已经问过的信息或者忘了用户最初的要求。根因压缩策略把早期对话压没了或者把用户原始任务挤出窗口。解决把用户原始任务和任务进展摘要固定放在上下文最前面永不参与压缩淘汰。压缩只作用于中间的工具结果和模型思考。这是条硬规则违反了必然出问题。6.5 排查速查表现象优先检查大概率原因快速处置步数跑满无输出终止判定日志缺完成度校验加入动作清单校验成本异常高输入 token 曲线上下文未压缩降低压缩阈值工具成功率骤降下游服务指标限流或下游故障启用降级路径回答与工具结果不符反馈阶段快照结果被过度截断放宽截断阈值偶发卡死不返回各层超时配置缺单步超时补齐三层超时同任务重复副作用幂等键日志未做去重写操作加幂等键模型反复问同一问题压缩前后对比关键信息被压掉原始任务固定不压缩7. 几条我踩出来的硬经验第一条先把护栏做好再考虑让模型更聪明。我早期花大量时间调提示词想让它自主判断该不该停后来发现加一个三行的循环检测代码效果比调两周提示词都好。工程约束的性价比远高于提示词优化。第二条所有数字都要有出处。步数上限来自 P99 统计压缩阈值来自预算除法重试次数来自错误分布。拍脑袋定的数字在出故障时你没法判断是阈值设错了还是系统真有问题有出处的数字可以直接对照。第三条降级路径要提前设计不要临时加。我现在的每个 Agent 都有一条最小可用路径工具全部不可用时直接让模型基于通用知识回答并明确告知用户当前无法访问实时数据。这条路径代码量不到五十行但它在两个大促期间救过场。第四条测试用例要覆盖失败路径。正常路径谁都能跑通真正需要测的是工具超时、工具返回垃圾、模型连续三次重复调用、上下文刚好卡在压缩阈值上、预算在最后一步耗尽。我给每个 Agent 循环都准备了一个捣乱工具它会随机返回超时、错误码、超长文本、空结果用它在预发环境跑一百个任务能提前发现大部分问题。第五条循环最好能做到可暂停可恢复。把状态对象序列化存下来任务可以在任意一步中断之后从断点继续。这在长任务场景下特别有用——用户关掉页面再回来任务还在跑。实现成本不高因为状态机化之后所有状态本来就集中在一个对象里。最后分享一个最近才想明白的点AgentLoop 的成熟度其实可以用一个很简单的指标衡量——你有没有办法在不看日志的情况下回答这个任务为什么停在这里。如果答案是我得去看日志说明状态还没有完全显式化如果答案是看状态对象就知道那这个循环基本可以上生产了。我现在做新 Agent 的第一件事就是把状态对象设计好循环体反而是最后写的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门