☰
Agent Loop 设计指南:从单轮问答到自主循环的工程实践
2026/10/19 11:24:58 网站建设 项目流程

1. 为什么 Agent Loop 是整个智能体系统的“心脏”

很多人第一次接触 Agent 开发,注意力几乎都会被工具调用、提示词模板、向量检索这些“看得见”的模块吸引,觉得把这些拼起来就是一个智能体了。但真正上手写过几轮之后你会发现,决定一个 Agent 到底能不能干活、干得好不好的,往往不是某个工具多花哨,而是那个看起来最朴素的循环——Agent Loop。它决定了模型什么时候该思考、什么时候该调工具、什么时候该停下来给用户答复,也决定了整个系统在遇到错误时是原地打转还是优雅退出。

我在前两篇里已经把 Agent 的基础骨架搭起来了:一个能接收用户输入的主流程、一套工具注册与调用的机制、以及一个负责和模型通信的客户端封装。但那时候的流程其实是“一问一答”式的——用户问一句,模型回一句,如果模型想调工具,我们就调一次,然后把结果塞回去再问一次。这种写法在简单场景下能跑,可一旦任务稍微复杂一点,比如需要连续查三次数据、根据中间结果决定下一步、或者工具报错后要重试,整个流程就会变得非常别扭。Agent Loop 要解决的,正是这个“多轮自主决策”的问题。

这篇文章我会把 Agent Loop 从设计思路到落地实现完整拆一遍。核心会围绕几个问题展开:循环的终止条件到底怎么定才安全、工具调用的结果怎么回填给模型、多轮对话里上下文怎么管理才不会爆、以及当模型“卡住”时我们有哪些兜底手段。适合已经写过基础 Agent 流程、想让自己的智能体真正具备“自主干活”能力的开发者。如果你还在纠结工具怎么注册,建议先回看前两篇,这里默认你已经有了一个能跑通单轮工具调用的基础版本。

我个人的判断是:Agent Loop 写得好不好,直接决定了你的 Agent 是玩具还是工具。玩具型 Agent 只能应付演示,工具型 Agent 能在真实任务里连续工作十几轮还不崩。差别就在这个循环的健壮性上。

2. Agent Loop 的整体设计与核心思路拆解

2.1 从“单轮问答”到“自主循环”的本质变化

先把这个变化说清楚。单轮问答的逻辑是线性的:用户输入 → 模型输出 → 如果输出里有工具调用就执行 → 把结果再喂给模型 → 模型给出最终答复 → 结束。这条链路里,模型其实只被“唤醒”了两次,中间的工具执行是我们代码主动驱动的,模型没有真正的决策权。

而 Agent Loop 的核心变化在于:把“是否继续”这个决策权交还给模型。我们不再预设“调一次工具就结束”,而是让模型在每一轮输出后自己判断——我现在信息够了吗?够了我就直接回答,不够我就继续调工具。代码层面就变成了一个 while 循环:只要模型还在请求工具调用,我们就执行工具、回填结果、再问一轮;直到模型输出的是纯文本答复,循环才终止。

这个转变听起来简单,但它带来的连锁反应很大。首先是终止条件变得不可预测,你不能假设三轮之内一定结束,所以必须设置硬性上限。其次是上下文会随着轮次不断膨胀,每一轮的工具结果都要追加进消息历史,几十轮下来 token 消耗会非常吓人。再就是错误处理,单轮流程里工具报错你直接抛异常就行,但在循环里,一次工具失败不应该让整个任务崩掉,模型应该有机会看到错误信息并自己决定换条路走。

提示:把决策权交给模型,不等于把控制权也交出去。循环的“刹车”必须牢牢握在你的代码手里,这是后面所有安全机制的前提。

2.2 循环终止条件的三种设计取舍

终止条件是 Agent Loop 里最容易被低估、也最容易出事的地方。我见过不少实现只判断“模型没有工具调用就退出”,结果遇到模型反复调同一个工具、或者工具一直返回空结果的情况,循环就无限跑下去,直到把 token 烧光或者触发接口限流。

比较稳妥的做法是设置三重终止条件,任意一个满足就退出:

终止条件触发逻辑作用
模型主动结束本轮输出不含工具调用,只有文本正常完成路径
轮次上限循环次数达到预设最大值(如 10 或 15)防止无限循环
无进展检测连续 N 轮工具调用参数与结果高度相似识别“卡住”状态

轮次上限这个值怎么定,我的经验是不要拍脑袋设成 100。大多数真实任务的工具调用轮次在 3 到 8 之间,超过 10 轮还没收敛,基本可以判定是模型陷入了某种循环或者任务本身描述不清。设成 10 到 15 是个比较平衡的选择,既能容纳复杂任务,又不会让失控的循环跑太久。如果你的任务确实需要更多轮,那更应该反思的是任务拆分是否合理,而不是简单调大上限。

无进展检测是进阶玩法,实现起来稍微麻烦一点,但对付“模型反复调同一个工具拿同样的结果”这种死循环特别有效。最简单的做法是记录最近几轮的工具名和参数,如果连续两三轮完全一致,就强制中断并给模型一个提示,让它换个思路。

2.3 消息历史的管理策略

Agent Loop 每跑一轮,消息历史就会多出至少两条:一条是模型请求工具调用的 assistant 消息,一条是工具执行结果。轮次一多,这个数组会迅速膨胀。如果不加管理,很快就会撞上模型的上下文窗口上限。

这里有个关键认知:不是所有历史消息都值得保留。早期的工具调用结果,如果已经被后续推理消化掉了,其实可以压缩甚至丢弃。我常用的策略是分层处理:

  • 系统提示词和用户原始输入永远保留,这是任务的锚点。
  • 最近 N 轮(比如 5 轮)的完整消息原样保留,保证模型有足够的近期上下文。
  • 更早的工具结果做摘要化处理,把冗长的原始返回压缩成一两句话的结论。

摘要化这一步可以调用模型来做,也可以写规则做简单截断。如果追求实现简单,先做截断也能撑一阵子。但要注意,截断工具结果时不能把关键信息(比如 ID、状态码)截掉,否则模型后续推理会出错。我一般会保留结果的前若干字符和后若干字符,中间用省略标记,这样既省 token 又不丢关键信息。

2.4 为什么选择“同步循环”而不是“事件驱动”

有些实现会用事件驱动或者异步回调的方式来组织 Agent Loop,理论上更灵活。但我在实际项目里更倾向于同步的 while 循环,原因很实在:调试成本低。同步循环里,每一轮的输入输出都是线性的,出问题时打日志一目了然,你能清楚看到第几轮、模型说了什么、工具返回了什么。事件驱动虽然性能上可能有优势,但一旦出问题,调用栈会变得非常难追。

当然,如果你的工具里有耗时的网络请求,同步循环会阻塞。这时候可以在工具执行层做并发,但循环本身保持同步。这个取舍的核心逻辑是:Agent 的瓶颈通常在模型推理而不是工具执行,所以循环层面的性能优化优先级并不高,可维护性才是第一位的。

3. 核心细节解析与实操要点

3.1 工具调用结果的回填格式

工具执行完之后,结果怎么塞回消息历史,这个细节直接决定了模型能不能正确理解。不同模型接口对工具结果消息的格式要求不完全一样,但核心结构是类似的:需要标明这是哪个工具调用的结果,以及结果内容本身。

以常见的消息结构为例,一轮完整的交互会涉及三种角色:

  • assistant:模型输出的消息,里面包含工具调用请求(工具名 + 参数 + 调用 ID)。
  • tool:工具执行结果,必须带上对应的调用 ID,这样模型才能把结果和请求对应起来。
  • user:用户输入,或者我们注入的系统级提示。

这里最容易踩的坑是调用 ID 对不上。如果模型一次请求了多个工具调用,你回填结果时 ID 顺序错了或者漏了,模型就会困惑,轻则答非所问,重则直接报错。我的做法是在执行工具时用一个字典把调用 ID 和结果绑定,回填时严格按 ID 匹配,绝不依赖顺序。

还有一个细节是结果的内容格式。工具返回的如果是结构化数据(比如 JSON),建议序列化成字符串再回填,并在系统提示词里告诉模型“工具结果可能是 JSON 字符串,请自行解析”。如果直接塞原始对象,有些接口会报序列化错误。

3.2 循环中的错误处理与重试

工具执行失败在真实场景里是常态,网络抖动、参数错误、外部服务限流都会导致失败。在 Agent Loop 里,工具失败不应该直接抛出异常终止整个循环,而应该把错误信息作为工具结果回填给模型,让模型自己决定怎么办。

我通常会把错误分成两类处理:

  • 可恢复错误:比如参数格式不对、临时性超时。这类错误把错误描述回填给模型,模型往往会自己修正参数重试。
  • 不可恢复错误:比如工具根本不存在、权限不足。这类错误同样回填,但会在系统提示里引导模型“如果工具不可用,请尝试其他方式或直接告知用户”。

这里有个实操心得:错误信息要写得对模型友好。不要直接把 Python 的 traceback 塞回去,那对模型来说是噪音。应该提炼成一句人话,比如“查询失败:参数 date 格式应为 YYYY-MM-DD,收到的是 2024/01/01”。模型看到这种信息,修正的成功率会高很多。

注意:重试要有次数限制。如果同一个工具连续失败三次以上,就应该在循环层面介入,强制中断或者切换策略,而不是无限让模型重试。

3.3 上下文膨胀的监控与压缩

前面提到了消息历史的管理,这里补充具体的监控手段。我习惯在每一轮循环结束时打印当前消息数量和一个粗略的 token 估算值。token 估算不用太精确,按字符数除以 3 到 4 来估就够了,目的是及时发现异常增长。

如果发现某一轮之后 token 突然暴涨,通常是某个工具返回了超大结果。这时候要检查工具本身是不是该做分页或者结果裁剪。工具返回结果的体积控制,应该在工具实现层就做好,而不是全指望循环层去压缩。一个查询接口返回几万条记录,本身就是设计问题。

压缩策略上,我一般设置一个阈值,比如消息历史超过 20 条或者估算 token 超过模型窗口的 60%,就触发一次压缩。压缩时优先处理最早的工具结果,把它们替换成摘要。摘要的生成可以复用模型,但要注意别在压缩上花太多 token,否则得不偿失。

3.4 循环的可观测性设计

Agent Loop 跑起来之后是个黑盒,如果不加日志,出问题你根本不知道发生了什么。我的做法是在循环的每个关键节点都打结构化日志:

  • 循环开始:记录轮次编号、当前消息数量。
  • 模型输出后:记录是否有工具调用、调用了哪些工具。
  • 工具执行后:记录工具名、执行耗时、结果状态(成功/失败)。
  • 循环结束:记录终止原因(模型结束/轮次上限/无进展)。

这些日志在调试阶段价值极高。我踩过的一个坑是:模型反复调用同一个工具但每次参数略有不同,导致无进展检测没触发,循环跑了十几轮。后来看日志才发现,模型是在微调参数试图“碰运气”。这种情况下,日志里的参数对比一眼就能看出问题,比盯着最终输出猜要高效得多。

4. 实操过程与核心环节实现

4.1 循环骨架的搭建

先把最核心的循环结构写出来。这里用伪代码加 Python 风格来描述,重点是逻辑,具体接口调用按你自己的客户端封装来替换。

def run_agent_loop(user_input, tools, max_turns=10): messages = build_initial_messages(user_input) turn = 0 recent_calls = [] while turn < max_turns: turn += 1 log_turn_start(turn, len(messages)) response = call_model(messages, tools) messages.append(response.to_message()) tool_calls = response.tool_calls if not tool_calls: log_loop_end("model_finished") return response.content if detect_no_progress(recent_calls, tool_calls): log_loop_end("no_progress") return "任务似乎陷入了循环,已中止。请补充更多信息。" for call in tool_calls: result = execute_tool_safely(call) messages.append(build_tool_message(call.id, result)) recent_calls.append((call.name, call.arguments)) messages = maybe_compress(messages) log_loop_end("max_turns") return "达到最大轮次限制,任务未完成。"

这段骨架里有几个关键点值得展开。build_initial_messages负责组装系统提示词和用户输入,系统提示词里要明确告诉模型它的行为规范,比如“你可以调用工具来获取信息,信息足够后请直接回答”。execute_tool_safely是包了一层错误处理的工具执行函数,保证任何异常都被捕获并转成可回填的错误信息。maybe_compress就是前面说的上下文压缩入口。

4.2 无进展检测的具体实现

detect_no_progress这个函数是防死循环的关键,实现上不需要太复杂。我的做法是比较最近几轮的工具调用签名:

def detect_no_progress(recent_calls, current_calls, window=3): if len(recent_calls) < window: return False recent_signatures = [ (name, json.dumps(args, sort_keys=True)) for name, args in recent_calls[-window:] ] current_signatures = [ (call.name, json.dumps(call.arguments, sort_keys=True)) for call in current_calls ] return all(sig in recent_signatures for sig in current_signatures)

逻辑很直白:如果当前这轮请求的工具和参数,在最近几轮里都出现过,那基本可以判定模型在原地打转。window设成 3 是个经验值,太小容易误判(正常的重试也会触发),太大则反应迟钝。参数序列化时用sort_keys=True是为了避免字典顺序不同导致的误判,这个细节不注意的话,检测会失效。

4.3 工具执行的安全封装

工具执行这一层,我强烈建议做统一封装,不要让循环直接调用工具函数。封装层要处理几件事:参数校验、异常捕获、超时控制、结果格式化。

def execute_tool_safely(call, timeout=30): tool = tool_registry.get(call.name) if tool is None: return f"错误:工具 {call.name} 不存在,请检查可用工具列表。" try: args = json.loads(call.arguments) except json.JSONDecodeError: return f"错误:工具 {call.name} 的参数不是合法 JSON,请重新生成。" try: result = run_with_timeout(tool, args, timeout) return format_result(result) except TimeoutError: return f"错误:工具 {call.name} 执行超时({timeout}秒),请稍后重试或换用其他方式。" except Exception as e: return f"错误:工具 {call.name} 执行失败,原因:{str(e)[:200]}"

这里每个错误分支返回的都是对模型友好的自然语言,而不是异常对象。超时控制尤其重要,有些工具会卡在外部请求上,没有超时的话整个循环就挂住了。format_result负责把结果转成字符串并做长度裁剪,避免超大结果直接进上下文。

4.4 上下文压缩的落地

压缩函数我一般写成这样:保留系统消息和用户原始输入,保留最近若干轮,对更早的工具结果做摘要替换。

def maybe_compress(messages, max_messages=20, keep_recent=8): if len(messages) <= max_messages: return messages system_and_user = [m for m in messages[:2]] recent = messages[-keep_recent:] middle = messages[2:-keep_recent] compressed_middle = [] for m in middle: if m.role == "tool" and len(m.content) > 200: m = m.copy() m.content = m.content[:100] + "...[已压缩]..." + m.content[-50:] compressed_middle.append(m) return system_and_user + compressed_middle + recent

这个实现比较粗暴,但胜在简单可靠。keep_recent=8保证模型有足够的近期上下文做推理,中间部分只对超长的工具结果做截断。如果你想要更智能的摘要,可以把middle里的工具结果批量丢给模型生成一句话总结,但那样会增加一次模型调用,成本和延迟都要考虑。

4.5 一次完整的循环执行记录

假设用户问“帮我查一下最近三天的订单里金额最高的那笔,然后告诉我它的状态”。这个任务需要两步工具调用:先查订单列表,再根据订单 ID 查详情。循环会这样跑:

第一轮,模型输出工具调用query_orders(days=3)。工具返回一个订单列表,包含若干订单的 ID 和金额。结果回填。

第二轮,模型看到列表,输出工具调用get_order_detail(order_id="最高金额那笔的ID")。工具返回该订单的详细信息,包括状态。结果回填。

第三轮,模型判断信息已足够,输出纯文本答复:“最近三天金额最高的订单是 XXX,当前状态为已发货。”

三轮结束,循环退出。整个过程模型自主决策了两次工具调用,我们没有在代码里硬编码任何“先查列表再查详情”的逻辑,这就是 Agent Loop 的价值所在。

如果第二轮工具返回的是“订单不存在”,模型会看到这个错误,可能重新查列表确认 ID,也可能直接告知用户查询失败。这种灵活性是单轮流程给不了的。

5. 常见问题与排查技巧实录

5.1 循环不终止的几种典型原因

这是最常见的问题,我整理了一个速查表:

现象可能原因排查方向
模型反复调同一工具工具结果没解决模型疑问检查工具返回是否包含模型需要的信息
模型参数每次微调任务描述模糊,模型在试探优化系统提示词,明确任务边界
工具一直返回空结果工具本身有问题或参数不对单独测试工具,确认输入输出
循环到上限才停任务确实复杂或模型能力不足考虑拆分任务或换更强模型

排查这类问题的第一步永远是看日志。把每一轮的模型输出和工具结果打出来,问题基本就现形了。我遇到过一次模型反复调用搜索工具的情况,看日志才发现是搜索结果里包含了一个诱导模型继续搜索的关键词,后来在工具结果里加了一层过滤就好了。

5.2 工具结果被模型忽略

有时候工具明明返回了正确结果,模型却视而不见,继续调别的工具或者给出错误答复。这种情况通常是结果格式或位置有问题。检查两点:一是工具结果消息的调用 ID 是否和请求匹配,ID 对不上模型就关联不起来;二是结果是否被压缩策略误伤,比如关键信息正好在截断的位置。

还有一个隐蔽的原因是系统提示词里没有强调“工具结果可信”。如果提示词里写了“你可以参考工具结果”,模型可能会把它当参考而非事实。改成“工具返回的结果是权威数据,请以此为准”会好很多。

5.3 token 消耗过快

循环跑几轮 token 就爆了,多半是工具结果太大或者历史没压缩。先看单次工具返回的体积,超过几千字符的就要考虑在工具层做裁剪。再看压缩策略是否生效,有时候阈值设得太高,等触发时已经晚了。我的经验是把压缩阈值设在模型窗口的 50% 到 60%,留足余量给后续轮次。

另外,系统提示词本身如果写得太长,每轮都要重复计算,也是隐形消耗。提示词要精炼,把不必要的历史背景和示例删掉。

5.4 模型在循环中“忘记”了原始任务

多轮之后模型跑偏,忘了用户最初要什么。这是上下文管理的经典问题。解决办法是在系统提示词里始终保留原始任务描述,并且在每轮的工具结果回填后,可以追加一句轻量提醒,比如“请记住用户的原始需求是 XXX”。这个提醒不用每轮都加,在轮次超过 5 轮后开始加就行,避免干扰。

提示:原始任务描述最好在消息历史里保持一个固定位置(比如紧跟系统提示词之后),不要让它被压缩掉,这是模型不跑偏的锚点。

5.5 并发工具调用的顺序问题

有些模型会在一轮里请求多个工具调用,这时候执行顺序和结果回填顺序要特别注意。如果工具之间有依赖(比如第二个工具需要第一个的结果),那模型这样请求本身就是错的,你应该把结果按 ID 回填后让模型自己发现依赖问题。如果工具之间独立,那可以并发执行提升效率,但回填时仍要严格按 ID 匹配,不能按执行完成的顺序。

我踩过的坑是并发执行后按完成顺序回填,结果模型把结果对应错了工具,推理全乱。后来改成先收集所有结果,再按请求顺序统一回填,问题就解决了。

6. 循环性能与稳定性的进阶优化

6.1 轮次上限的动态调整

固定轮次上限虽然简单,但对不同任务不够友好。简单的查询任务可能 3 轮就够,复杂的分析任务可能需要 10 轮以上。我后来改成根据任务类型动态设置上限:在系统提示词里让模型先判断任务复杂度,或者由调用方在发起任务时传入一个预期轮次。这样既不会因为上限太低导致复杂任务被截断,也不会因为上限太高让简单任务失控时跑太久。

6.2 工具结果的缓存

同一个工具用相同参数被调用多次,在循环里并不罕见。加一层简单的缓存能省下不少执行时间和 token。缓存键用工具名加参数序列化,缓存有效期设短一点(比如几分钟),避免拿到过期数据。这个优化在工具涉及外部请求时收益特别明显。

6.3 循环中断的优雅恢复

有时候循环因为轮次上限或者外部原因中断了,但任务其实只差最后一步。如果能保存中断时的消息历史,下次可以从中断点继续,而不是从头再来。实现上就是把messages数组持久化,恢复时加载回来接着跑。这个功能在长任务场景下很实用,但要注意消息历史里的工具调用 ID 在恢复后仍然要能对应上,所以持久化时要把完整结构存下来。

6.4 不同模型的循环适配

不同模型对工具调用的支持程度不一样,有的模型一次只能请求一个工具,有的支持并行多个。循环逻辑要能兼容这两种情况。我的做法是在解析模型输出时统一转成工具调用列表,单个也好多个也好,都按列表处理,这样循环主体逻辑不用改。另外,有些模型在工具调用失败后不会自动重试,需要你在提示词里明确引导“如果工具失败,请分析原因并决定是否重试”。

我在实际项目里体会最深的一点是:Agent Loop 的健壮性不是靠一次写完的,而是靠不断踩坑补出来的。每遇到一种新的死循环或者上下文问题,就补一条防护,慢慢地这个循环就变得非常皮实。一开始不要追求完美,先把基本的三重终止条件做上,跑起来,然后在真实任务里观察日志,缺什么补什么。这个迭代过程本身就是 Agent 开发最有意思的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询