Agent 调完 Tool 之后发生了什么?—— 被大部分人跳过的那半个循环

大多数人只关注怎么调工具,却忽略了工具返回值怎么处理——这个被跳过的半圈才是决定 Agent 稳定性的关键。

最近在搭一个多 Agent 系统,最开始我觉得最复杂的是 Agent 之间的通信协议,结果折腾了一圈发现,真正的魔鬼藏在最不起眼的地方——Tool 调完了,返回值拿回来了,然后呢?

你去看市面上的教程,90% 都止步于"调用 function calling,传给 LLM,拿到结果"。没了。好像 Tool 的返回值往 Prompt 里一塞就完事了。

我一开始也是这么干的。结果跑起来之后,什么妖魔鬼怪都出来了。

天真版本:把 Tool 返回直接塞回对话

第一版代码写出来之后长这样:

用户: 查一下上海的天气
→ LLM 返回: function_call → get_weather("上海")
→ 执行 get_weather,拿到 {"temp": 28, "humidity": 65%}
→ 把结果拼成一条 assistant 消息 + tool 消息,塞回 LLM
→ LLM 返回: "上海今天 28 度,湿度 65%"

看起来没问题对吧。但我跑了 20 个测试用例,大概有三分之一出了问题。

最典型的一个:Agent 调了一个数据库查询,返回了 200 条记录。我把完整的 JSON 文本(大概 15KB)塞回给 LLM。LLM 直接干活了——不是罢工,是真的开始胡言乱语,从上下文里抓了一些不相关的字段,编造了一些不存在的数据。Token 太多,模型"醉"了。

这就是第一个教训:不是所有 Tool 返回都适合直接丢进 LLM 的上下文。

问题一:返回数据太大怎么办?

一个查询 Tool 返回几百行 JSON,或者一个文件读取 Tool 返回整篇文章。你直接拼进消息里,有两个后果:

  1. Token 爆炸。Prompt 越长,LLM 的推理能力越差(这叫"lost in the middle",中间部分的内容会被模型忽略)。更别说成本问题了。
  2. 注意力漂移。模型在大量细节中会丢失原本的任务目标,开始关注那些不重要的字段。

我的做法是加一层 Tool Response 预处理。根据不同的 Tool 类型,做不同的处理:

  • 查询类(查数据库、查 API):如果记录太多,先问模型"你需要全部数据还是摘要",或者自动截断 + 提供统计数据
  • 读取类(读文件、读网页):超出上下文窗口 20% 的,自动做摘要后再给模型
  • 写操作(创建、更新、删除):只需要返回操作结果(成功/失败 + ID),不需要返回完整数据

这个预处理层不复杂,但少了它,你的 Agent 会在 5 轮对话之后开始发疯。

问题二:Tool 返回了错误怎么办?

有些教程会告诉你给 Tool 调用加 try-catch,返回错误信息,模型会自己处理。这句话对了一半。

模型确实会尝试处理错误,但它的处理方式你可能受不了。

我试过一个场景:调用一个 API 查询用户信息,API 返回了 500 错误。我把错误信息塞回去,然后 LLM 说:"看起来服务暂时不可用,请稍后再试。"

还行。下一个场景:同样的 API 报错,但这次 LLM 说:"看起来参数有问题,我尝试用不同的参数再查一次。"然后它改了参数,又调了一次。还是报错。它又换了一组参数。循环了四次。

LLM 没有"重试次数上限"的概念。 你不在 Tool 层做限制,它能在一次推理循环中无限重试。

所以我后来给每个 Tool 加了一个 max_retries 属性和一个错误分类策略:

class ToolResult:
    def __init__(self, success: bool, data=None, error=None, error_type=None):
        self.success = success
        self.data = data
        self.error = error
        self.error_type = error_type  # "retryable" | "fatal" | "partial"
  • retryable:网络超时、限流 —— 让模型重试,但有次数限制
  • fatal:认证失败、权限不足 —— 直接告诉模型"别重试了,告诉用户出错了"
  • partial:部分成功 —— 返回已有的成功数据 + 失败部分的说明

这几个分类看起来简单,但省了不知道多少冤枉钱。

问题三:Tool 返回的内容和用户问题没关系了

这是最让我头疼的一类 bug。

场景是这样的:用户让 Agent "帮我查一下上海的天气,然后顺便查一下今天北京飞上海的航班"。Agent 先调了天气 API,拿到了上海的天气数据。然后把天气数据塞回去,LLM 在生成下一步的时候,被天气数据带偏了,直接开始回答用户天气,忘了还要查航班。

Tool 返回值污染了 LLM 的注意力。

解决办法?我之前提过,Tool Response 预处理层里除了截断和摘要,还要做一件事:剥离无关信息,保留任务上下文。

不是把所有 Tool 返回原样丢给 LLM 就行。要让 LLM 明确知道:"这是调用 X Tool 的返回结果,你的原始任务是 Y,请继续。"

我最后实现的方式很朴素——在 Tool 返回消息前面加一段结构化的元信息:

[TASK_CONTEXT]
当前任务:查询天气和航班
已完成步骤:1/2
已完成:查上海天气
下一步:查今天北京飞上海的航班
当前 Tool 调用结果:{上海天气数据}

这个上下文提示帮了大忙。不加的时候,模型经常"失忆";加了之后,准确率从 65% 跳到 92% 左右(至少在我的测试集上)。

Tool 循环的抽象模型

经历了三轮迭代之后,我总结出了一个 Tool 执行循环的抽象模型:

LLM 生成 → 检测 function_call
  → 解析参数 → 执行 Tool
  → 结果预处理(截断/摘要/错误分类)
  → 上下文重建(注入任务状态)
  → 结果交给 LLM 继续生成
  → 重复直到 LLM 返回自然语言

每轮迭代里最容易被忽略的就是"结果预处理"和"上下文重建"这两步。框架(LangChain、Semantic Kernel 之类)帮你做了第一层——消息格式化和拼接——但它们不会替你解决业务层面的问题:这个 Tool 返回了 10MB 的数据你怎么办?这个 Tool 连续报错三次你让模型继续吗?

框架能解决通用问题,但 Tool 循环里的边界情况全是业务相关的。

所以写了这么多,核心就一句话

Function Calling 是 AGI 的一个拼图,但 Tool Execution Loop 才是让 Agent 真正可靠的工程实践。别指望 LLM 帮你处理所有的边界情况——你在 Tool 层做得越细,Agent 跑起来就越稳。

下一篇文章我想聊聊同样头疼的问题:多个 Tool 并行调用时,LLM 的参数冲突怎么处理。你做 Agent 的时候有没有遇到过类似的问题?

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个