开发 Agent 的时候,我曾经踩过一个特别别扭的坑:模型明明成功发起了工具调用,工具这边也正常返回了结果,可到了下一轮推理,模型就像失忆了一样,既看不懂结果数据,又重复调用同一个工具。查到最后,问题出在了中间那一段被大多数人忽略的路——工具执行结果怎么变成模型输入。
我管这段路叫“观测链路”。从根上讲,Agent 的完整闭环是:模型决定调工具,运行时执行工具,然后把执行结果“观测”到,再作为用户消息之外的额外输入重新喂给模型,让模型判断下一步做什么。这里最关键的不是工具本身写得有多好,而是工具的返回值能不能被准确、完整、规模可控地翻译成模型真正认识的输入。那才是最见功夫的工程点。这篇文章,我把自己在项目里摸索出来的一套链路设计和避坑方案完整地梳理一遍,希望能给你省点时间。
1. 先理清链路里到底在传什么
1.1 一次工具调用的四个流转环节
观察一个 Agent 调用工具的全过程,大概是这样的:第一步,用户给出一段自然语言请求,模型先根据意图决定需不需要调用外部工具;第二步,模型在响应里带出结构化的工具调用指令,通常是函数名加参数清单;第三步,Agent 运行时捕获这个指令,去执行实际的工具代码;第四步,工具执行的结果拿到手,被重新组装进对话上下文,再发回给模型做后续推理。
前两步已经被各种大模型接口封装得很好了,很多教程也主要停在这两步。真正拉开的差距,其实在第三步到第四步之间。工具返回的往往不是一串干净漂亮的文本,而可能是数据库查询结果、一堆 JSON 数据、一个可能报错的执行日志,甚至是一张图片的二进制内容。这些原始返回值没办法直接塞进模型输入,需要经过一层转换处理,让模型既能读懂、又不至于被撑爆窗口。这就是观测链路的本体。
1.2 为什么“直接拼接字符串”是个危险想法
一个很自然的偷懒思路是:工具返回什么,我就把字符串拼到对话里,完事。我一开始也这么干过,后来被现实打脸了。假设一个查询接口返回三万多条记录,序列化后大约三百多万字符。按常见模型 4 到 6 个字符折算一个 token 的经验比例,这随便就是七八十万 token,绝大多数模型的上下文窗口直接撑炸。
就算数据量不大,原始输出里的噪声也够模型喝一壶的:数据库连接失败时那一长串堆栈信息,模型读了可能会误以为数据库不支持某种操作;工具返回的嵌套 JSON 里全是空字段,模型会浪费注意力在无意义的信息上;更麻烦的是,如果工具输出的是二进制或者图片数据,直接当字符串拼进去,别说模型读不懂,连序列化本身都可能失败。
所以观测链路要干的活,本质上是对工具执行结果做一次“翻译和降噪”,让模型的注意力聚焦在最关键的信息上,而不是淹没在原始数据里。
2. 消息协议:把执行结果装进模型认识的消息容器
2.1 工具消息的字段对应关系
现在主流模型服务大都支持类似 function calling 的协议,流程大体一致:模型响应里带出一个 tool_calls 数组,数组每一项有工具调用的 id、函数名以及参数。Agent 执行完工具之后,需要向对话历史里追加一条 role 为 tool 的消息,这条消息必须通过 tool_call_id 和之前的调用指令对应起来。
这个字段看起来不起眼,但它决定了模型能不能把“结果”和“之前发出的指令”关联起来。经常有人图省事,随手把 role 设成 user 或者 assistant,然后结果内容塞进去,看起来对话历史很流畅,但实际上模型在协议层面压根不知道自己调用过工具,更不可能知道这段结果是工具反馈,后续推理质量自然好不了。正确做法是严格区分消息角色的分工,tool 角色只存放工具执行结果,千万不要跟普通用户消息混在一起。
2.2 多条工具调用时怎么保证顺序
一次推理里模型可能同时发起多个工具调用,这是现代模型的常见行为,比如既要查天气,又要查日历。执行这些工具时,它们可能会有快有慢。关键问题是,无论工具执行先后,所有 tool 消息追加到对话历史时,最好都紧跟在最后一条 assistant 消息之后,再按原有工具声明的顺序排列。
我自己的实操经验是,并行执行可以用,但结果收集必须等所有工具都返回之后,统一按 tool_call 的原始顺序拼接出一条一条 tool 消息。如果你一边执行一边追加,先到的先写,后到的后写,结果就可能跟模型声明工具时的顺序对不上。模型面对错乱的反馈顺序,容易出现张冠李戴,比如把日历工具的结果当成天气工具的结果去看待,推理结论就会非常离谱。
2.3 循环式调度的骨架代码
整个工具调用与观测回填的过程,本质上是一个循环。我习惯用一段简化到只有主干逻辑的代码来表达这个循环,实际生产里你会加缓存、加错误处理、加超时控制,但核心骨架是不变的:
def run_agent_with_tools(system_prompt, user_query, tool_registry, max_iterations=12): messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_query}, ] for _ in range(max_iterations): response = chat_completion(messages=messages) assistant_message = response["choices"][0]["message"] # 先把助手消息本身追加进历史,无论它是否带工具调用 messages.append(assistant_message) tool_calls = assistant_message.get("tool_calls") if not tool_calls: # 模型不再调用工具,本轮输出就是最终答案 return assistant_message["content"] # 收集所有工具观察结果,统一回填 output_messages = [] for tool_call in tool_calls: tool_name = tool_call["function"]["name"] arguments = json.loads(tool_call["function"]["arguments"]) result = tool_registry.execute(tool_name, arguments) output_messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": format_observation(result), }) messages.extend(output_messages) raise RuntimeError("exceeded max iterations")注意到一个细节:assistant_message 要先追加进历史,再处理工具调用,这保证了模型在下一轮能看到自己上一轮发出的指令。然后再把观测结果接在后面,模型才能把指令和观测对上。这个顺序一旦颠倒,模型就会丢失上下文。
3. 观测结果的格式整形:从原生返回值到模型友好输入
3.1 先做序列化与结构压平
工具返回的数据五花八门,但到了模型输入层,基本要求只有一条:普通文本、结构清晰、信息密度高。对于 JSON 类型的返回值,我一般不会把原始对象整个塞进去,而是先做一轮压平处理。嵌套太深的对象,模型理解起来也费劲,你可以用工具把嵌套结构摊平,比如把response.data.items[0].name这种路径,转成data.0.name的形式再输出。
这个处理看起来简单,但对最终效果影响很大。我自己测过,同样一段数据,原始嵌套 JSON 和压平后的文本,模型提取关键信息的准确率能差出不少。原因也好理解,压平之后,模型少了很多需要解析结构的隐性负担,注意力自然更集中。
3.2 大结果的三级降级策略
工具返回大结果是最常见的场景,我的处理策略是三级降级,从完整输出一路退到最小摘要。第一级,原始结果经过序列化和压平之后,如果估算 token 数在预算范围内,就直接用完整版。第二级,超出预算时启动截断,保留前 N 条记录,并在末尾明确提示模型“剩余 X 条记录未展示,如需更多请缩小查询范围或指定筛选条件”。第三级,如果数据实在太大,只抽取统计聚合信息,比如总数、均值、最大值、最小值、去重后数量,让模型对全貌有个基本概念。
关键是要把预算量化。假设模型上下文窗口是 128k token,系统提示词和用户请求加一块占了 4k,上一次预留给输出的空间是 8k,那观测内容能占用的空间就得严格控制在 100k 以内。但我不建议真用满,我一般习惯留出至少 20% 的余量,因为模型偶尔还会在工具调用之后做二次推理,那也需要空间。
3.3 非文本结果要翻译成描述性文本
有些工具返回的并不是文本,比如一张图片、一个音频文件、一段视频。这里很容易踩坑:你直接把文件路径塞给模型,模型根本没法感知图片内容,除非你用的模型有原生的视觉能力。可靠的做法是把非文本结果翻译成描述性文本,再进入观测链路。
例如图片识别工具返回的是图片文件,那观测结果就不应该是路径,而应该是由其他视觉模型生成的图片描述文字。数据库查询工具返回的结果集,除了数据行之外,我会额外生成 schema 信息和抽样预览。规格上,我习惯用统一的格式组织观测结果,比如附带source字段标注数据来自哪个工具,附带duration_ms标注执行耗时,附带status标注成功或失败。这样一来,模型看到的不只是一段孤立数据,而是带元信息的历史记录,后续决策会稳健不少。
3.4 错误结果的观测格式
工具执行报错是必然的,观测链路同样要覆盖错误场景。我强烈建议在观测结果里固定错误格式,不要直接把 Python 或 Java 的异常堆栈整段贴给模型。模型不是看不懂堆栈,而是堆栈信息里包含大量与决策无关的细节,容易干扰推理,而且堆栈可能很长,白白消耗 token。
我自己常用的错误格式是 JSON 结构:{"status": "error", "error_type": "timeout", "message": "查询数据库超时,连接超过5秒未响应"}。这里关键是把底层错误翻译成“模型能理解并利用”的描述性信息,同时附上可执行的修复建议,比如“建议缩小查询范围后重试”。错误信息不只是给模型看的,它也是 Agent 自我纠错的决策依据,格式太粗糙,模型很难做出聪明的下一步动作。
4. 上下文预算与循环收敛:别让观测链路撑爆窗口
4.1 增量开销怎么估算
每多一轮工具调用,对话历史就要新增两条消息:一条是 assistant 工具调用声明,一条是 tool 执行结果。假设一次工具调用的声明约 200 token,观测结果平均约 800 token,那一轮循环就要新增约 1000 token。如果 Agent 连续调用十次工具,光是这十轮新增的输出就近一万 token,这还不算原始对话历史。模型上下文窗口再大,也扛不住无限轮次的累积。
我在做预算时,会先按“最大 token 数”法估算每一轮的开销,然后倒推允许的最大迭代次数。比如窗口 128k,系统提示词和固定消息占 4k,用户历史消息占 20k,那留给工具调用链路的空间就是 104k,按每轮平均 1k 算,最多能跑上百轮。但这只是理论值,真正的约束往往还来自模型输出 max_tokens 限制,以及每次迭代变慢的延迟。所以我一般把迭代上限压到 10 到 15 轮,超过就强制停止并给用户一个可解释的提示。
4.2 窗口快满时的压缩与摘要
再好的规划也架不住业务复杂。当上下文确实快满了,观测链路需要有一套压缩机制。我的做法是把最早期对话历史里的工具调用指令和原始观测结果一并摘除,替换成一段摘要。注意摘除时不能只删 tool 消息而留下 assistant 消息,因为那样模型会看到自己发出过一个工具调用指令,但对应结果却缺位,反而造成困惑。
摘要本身也有讲究,不是简单浓缩全文。我会保留跟最终任务直接相关的决策信息,比如已执行工具的清单、每个工具结论的一句话、尚未解决的问题。压缩之后,模型依然能理解当前状态,只是失去了过程的精细细节,这对多数任务来说是足够的状态恢复。
4.3 循环跳出与死循环防护
Agent 开发里最让人头疼的问题是死循环:模型反复调用同一个工具,或者在同一组工具之间反复横跳。观测链路做不好,这个问题会特别严重,因为如果模型接收到的观测结果长度为零或者格式错乱,它就会一遍又一遍地认为“还有必要继续调用”,结果就是不收敛。
我现在的做法是三层保护。第一层是硬性最大迭代次数,超了就立刻终止,返回当前能拿到的所有结果以及一个说明性错误。第二层是重复检测,每次追加观测结果时,都会把当前工具调用序列跟上一次比较,如果完全相同而且没产生新的有效数据,就判定为重复调用并强制退出。第三层是内容预警,如果工具返回结果连续多轮都是空值或同样的错误,就给模型一个额外的观察提示,比如“你已经重试了两次,请更换策略,不要继续用相同方式调用同一工具”。这三层配合起来,基本能把死循环问题控制住。
4.4 链路可观测性:把每一轮都打点记录
观测链路拼完之后,整个循环对用户来说像一个黑盒。调试的时候特别痛苦,因为你不知道模型到底看到了什么。我后来习惯在每个关键节点输出结构化日志:每一轮消耗的 token 数、观测结果的长度、当前消息列表里各类角色的数量、距离上下文上限的余量。这些日志平时看着烦,等真出了问题是救命稻草。
我自己的日志模板大致是每轮结束后打一行:本轮新增消息数、总 token 估算、工具调用 ID 列表、工具返回状态、是否触发截断。有了这行日志,排查问题时能一眼看出究竟是哪一轮让上下文膨胀,哪一轮的观测结果格式出了问题。
5. 不同运行场景下观测链路的适配
5.1 同步执行与异步执行的取舍
最朴素的做法是同步执行工具,模型发出调用指令,Agent 阻塞等待工具返回结果,再继续。好处是逻辑简单,调试方便,坏处是遇到工具耗时较长时,整体响应延迟会很高,而且模型上下文处于“挂起”状态,用户感受就是一直转圈。
为了降低延迟,可以把耗时的工具调用放到线程池或子任务里并发执行,等所有结果回来再统一组装消息。但这里有个容易忽略的约束:模型上下文必须等全部工具结果到齐后才能继续,因为工具结果是按顺序排列的,不能边到边喂。所以并行的收益主要体现在减少总等待时间,而不是减少上下文里的消息条数。
我自己测过一个场景:三个工具分别耗时 300ms、600ms、900ms。同步执行总共需要约 1800ms,而并行执行只需要 900ms 左右。代价是代码复杂度增加,尤其是结果收集、顺序排序、超时管理这三部分,都得额外写逻辑。
5.2 流式输出场景下的工具调用组装
很多 Agent 前端会开流式输出,让模型逐字吐出内容,提升用户体感。这里有个隐藏问题:工具调用参数在流式接口里是分段到达的,每个 chunk 只带一小段增量数据,而工具执行必须拿到完整参数才能跑。没有正确组装流式增量,你可能会拿着半截 JSON 参数去执行工具,直接解析失败。
我的做法是在流式循环里维护一个按工具调用 ID 索引的缓冲区,每个 chunk 到达时把增量数据追加到对应 ID 的完整参数上,同时保存工具名、调用 ID 这些元信息。等流式响应结束,再解析缓冲区里的完整 JSON 参数,才开始执行工具。这里还要特别留意:流式模式下要区分哪些内容是最终回答正文,哪些是工具调用增量,否则容易把工具调用声明当成普通文本展示给用户。
5.3 并行工具调用与结果合并的规范
并行工具调用看起来很有力量,但实现细节容易埋雷。比如模型一次给了三个工具调用,你并行发出执行请求,三个工具返回完成后,消息列表里 tool 消息的顺序必须跟模型声明顺序一致。前面我提过一次顺序问题,这里再强调一次实现手法:不用“谁先返回谁先排”,而是预先生成对应顺序的占位符,等所有结果齐了之后按序号填充。
另外,并行调用时还会出现一种情况:一个工具成功、一个工具失败、一个超时。这种混合结果同样要全部以观测消息的形式回填给模型,让模型知道三个工具各自的状态,而不是只回填成功的那个。因为在复杂的多工具场景里,模型需要基于完整的状态决定下一步,你替它筛选掉失败信息,反而会让它误以为所有工具都执行成功了。
6. 常见问题与排查速查表
我在实际项目里把踩过的坑收敛成了一张速查表,每次排查问题都先对着走一遍,效率很高:
| 症状 | 根因 | 排查手段与解决建议 |
|---|---|---|
| 模型下一轮问“你调过工具吗” | tool 角色消息缺失或 tool_call_id 不匹配 | 检查消息历史里是否有对应 assistant 工具声明,确认 tool_call_id 一致 |
| 上下文持续膨胀,很快超窗 | 观测结果未截断,直接塞入大 JSON | 对观测结果执行三级降级,设置硬性 token 上限 |
| 工具明明成功,模型却认为失败 | 错误格式不统一,成功结果与失败结果结构差异过大 | 统一用 status 字段标注成功或失败,模型靠状态字段判断 |
| 模型重复调用同一个工具 | 观测结果为空或不带关键结论 | 空结果也显式返回“无数据”,并补充策略性提示 |
| 消息列表里 tool 消息顺序混乱 | 并行执行结果未按声明顺序排列 | 改用预生成占位符,结果齐了按序号填充 |
| 流式环境下工具参数解析失败 | 流式增量未组装完整就执行了工具 | 维护按工具调用 ID 索引的缓冲区,等流结束再解析参数 |
| 一轮工具调用后模型反而变蠢 | 错误堆栈或日志中的噪声干扰注意力 | 错误翻译成统一 JSON 描述,去掉堆栈细节 |
排这一类问题,有个通用小技巧:把每次发回给模型前最终组装好的消息列表直接 dump 出来,用调试工具看一遍。这比猜要快得多。我以前总觉得 dump 消息列表没必要,直到有一次发现模型看到的 content 里居然混入了换行符和空字段,才意识到问题多半出在这个环节。
还有一点值得特别提醒:不要一边发工具调用请求,一边就把 tool 结果直接拼到原始 messages 原始变量上重发。有些 SDK 会默认把 tool 结果追加到请求,但也有一些需要你手动传,追踪一遍你的消息列表到底由谁维护、谁追加、谁截断,比盲目信任框架默认行为靠谱得多。观测链路的本质是调试,而调试的前提是你要随时能看到模型视角里真正发生了什么。
7. 一点实操体会
我自己把这条观测链路重新理顺之后,最直观的感受是:Agent 系统的稳定性往往不是靠提示词工程堆出来的,而是靠这种不起眼的工程细节托起来的。工具调用本身好写,难的是每一次调用产生的观测结果都能恰如其分地变成模型输入的养分。这里的平衡点需要在实际项目里反复调,比如截断阈值设多大合适,摘要保留哪些字段,迭代上限给多少轮,都会因为业务场景不同而不同。
我的最后一个小建议是,如果你刚开始做 Agent,先别急着把链路做得很花哨。把最核心的循环跑通,加好结构化日志,再逐步补上截断和压缩。等哪一天工具调用开始频繁报错,再回头检查观测链路,你会发现自己已经站在一个很稳的地基上了。