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 返回整篇文章。你直接拼进消息里,有两个后果:
- Token 爆炸。Prompt 越长,LLM 的推理能力越差(这叫"lost in the middle",中间部分的内容会被模型忽略)。更别说成本问题了。
- 注意力漂移。模型在大量细节中会丢失原本的任务目标,开始关注那些不重要的字段。
我的做法是加一层 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 的时候有没有遇到过类似的问题?
评论
发表评论