如何优化 toolCall 给大模型带来的噪音问题
工具调用帮助模型执行动作,但当越来越多的执行结果进入上下文后,又慢慢地给模型带来了噪音。
工具调用的噪音问题
过去几年,tool calling 已经成为大模型 Agent 的基础能力,模型看见工具列表,选择一个工具,填好参数,runtime 执行工具,再把结果塞回上下文,模型继续判断下一步。这个循环简单、直接,在模型厂商的专门训练下产生的威力巨大。
但这里也有一个问题一直困扰着各家厂商:
模型输出 tool call,runtime 执行工具,然后把 工具调用的JSON、结果stdout、搜索的列表、文件片段或报错信息全都塞回上下文。这样做的目的是保留真实原始信息,让大模型有足够的信息来判断下一步该怎么做。
但在多数 tool call loop 里,模型下一轮只能依赖当前上下文,因此 runtime 往往需要把前面相关的工具调用和结果继续放进上下文。当工具调用越来越多,整个上下文就开始变得嘈杂了。
这些“真实原始信息”虽然保留了所有细节,但同时往往也很冗长、结构复杂,模型需要先花注意力去解析它们的结构、区分字段类型、恢复各部分之间的对应关系。
比如当连续调用工具时,多个tool call连续出现,再跟着多个tool result,大模型需要根据它们的call_id来完成配对,而配对call和result显然跟当前会话的任务无直接关系
久而久之,模型的注意力就这样被分散了,在面对大量工具调用结果时,就难以快速抓住关键信息并做出有效决策。
模型厂商一般都会对模型进行工具调用的强化训练:学习成绩不好是吧?多做作业!记不住是吧?多背就会了!
但应试教育终有其弊端,我们还是应该从源头上解决问题。
大模型需要什么样的输入
如果盲目堆叠工具调用结果会有上面的问题,那怎么组织上下文比较好呢?通过上面的分析,我们知道大模型的很多推理浪费在了理解工具调用原因、哪个是对应的结果等等方面,这说明它缺少的不是原始信息,而是关于这些原始信息的一份说明。
举个常见场景:模型为了修复一个 bug,调用代码搜索工具搜索某个函数名。工具返回二十个命中项,每个命中项包含路径、行号、上下文片段、匹配分数,甚至还夹着调试字段。此时,模型真正需要的是什么?
- 搜索了什么。
- 为什么要搜索。
- 找到了几个文件?
- 哪些位置可以被引用或继续读取。
- 有没有遗漏或不确定性。
- 如果需要文件完整内容,应该去哪里取。
这些信息有些工具结果里已有,但有些需要模型自己推断,有些甚至就没有!
所以,我们不仅应该关注工具调用,还应该关注如何组织和呈现工具结果,以帮助模型更好地理解和利用这些信息,是时候重新定义会话上下文的结构了。
输入不是日志,而是模型下一轮推理的工作记忆
现在做法是把工具调用结果当成了日志对待,但工具结果进入模型上下文以后,会参与下一轮推理,所以它的角色更应该是工作记忆,而不是归档日志。
日志追求的是完整、可复查,而工作记忆要求的是边界清楚、来源明确、可追溯、能支持下一步判断,这两者不能搞混。
runtime 可以完整保存工具输出,包括 stdout、搜索结果、文件快照、测试报告、命令状态、时间戳和内部 metadata。但输入给模型的内容应该换一种方式,应该把工具调用结果标注成工作记忆:说明这次调用的目的、执行状态、结果来源、内容类型、可引用位置、结果范围,以及必要时给出简短概览。
把调用原因带回输入
在回传工具调用结果时,除了包含调用了什么工具,结果是什么,最好还包含“模型为什么要调用这个工具”,这个 reason 或 purpose 很重要,它是连接“调用动作”和“执行结果”的桥。
因为模型下一轮只能依赖当前上下文。除非上下文里明确写出来,否则它并不天然知道上一次 toolCall 的意图。
有些系统会把 reasoning 或 thinking block 带回后续请求,这有助于保持推理连续性,但它不是专门面向工具结果的说明层。它通常不够稳定、精练,也不一定直接说明某次工具调用的结果结构。
更好的做法是:当模型发起工具调用时,要求它给出一个简短的 reason 或 purpose。runtime 执行完成后,把这个 reason 一起带回 observation。
当然,reason 不是神谕。它是模型事前的判断,可能不准确。runtime 不应该把 reason 当成事实结论,只应该把它作为上下文线索:这是当时为什么执行这一步。
搜索代码后的回传格式对比
我们可以对比一下搜索 refreshSession 后直接回传原始 JSON,和回传带语义标注的 observation 有什么区别。
原始 JSON 回传
{
"query": "refreshSession",
"elapsed": 318,
"results": [
{
"file": "packages/session/refresh.ts",
"line": 42,
"score": 0.91,
"text": "export async function refreshSession(ctx) {"
},
{
"file": "packages/session/refresh.test.ts",
"line": 88,
"score": 0.87,
"text": "test('refreshSession renews token before expiry', async () => {"
}
],
"debug": {
"backend": "ripgrep",
"truncated": false
}
}
使用这种格式模型需要自己判断:为什么搜它?命中够不够?哪个结果是实现,哪个是测试?下一步该读什么?轮次多了又容易乱。
语义标注后的 observation
### 工具执行结果:代码搜索
执行了什么:
搜索 refreshSession 的定义和调用位置。
为什么执行:
定位登录状态刷新链路,判断用户状态丢失可能发生在 token 续期、session 写入还是测试断言阶段。
执行状态:
成功,返回 2 个搜索命中。
结果结构:
搜索结果包含 1 个实现文件命中和 1 个测试文件命中。
可引用位置:
- packages/session/refresh.ts:42 定义了 refreshSession。
- packages/session/refresh.test.ts:88 包含 token 续期相关测试。
运行元信息:
- debug.backend = ripgrep,表示搜索后端。
- elapsed = 318,表示本次搜索耗时。
范围说明:
- 当前只搜索了 refreshSession,尚未覆盖 session 写入链路。
完整内容引用:
- artifact://search/refreshSession
这段 observation 没有创造新事实,也没有替模型判断哪些内容重要。它只是把原始结果拆开标注:哪些是工具返回值,哪些是运行元信息,哪些是文件位置,当前搜索覆盖了什么范围。模型仍然要推理,但不必把注意力浪费在低价值的结构解析上。
用语义标注组织 observation
上面的方案实际上是对工具结果加了一层语义标注:调用原因、执行状态、结果来源、内容类型、可引用位置、完整内容引用都被明确标了出来。
语义标注可以用 Markdown transcript,这是一个低摩擦的承载形式,它接近自然语言,适合混合标题、解释、列表、证据和警告。但 Markdown 不是关键,关键是这层说明要稳定、可预测、字段固定。
如何评估语义标注是否有效
当然,这个方向不能只靠感觉判断,需要系统评估。
可以从几个维度看:任务成功率、模型轮次、replayed observation tokens、错误恢复质量、call/result 配对错误率、结果定位耗时、证据可追溯性、用户可解释性。
一个合理的实验对比可以包括三组:原始 toolCall、toolCall 加语义标注 observation、以及更结构化的 agent-runtime 交互方式。这样可以把“结果标注”的收益,和更大协议设计的收益分开观察。
结语:优化工具调用,不只是优化调用本身
tool calling 解决了一个关键问题:模型如何请求外部工具,这个问题很重要,但它不是 Agent runtime 的全部。
工具执行之后,结果如何重新进入模型上下文,同样值得设计。
直接塞 JSON 或 stdout,是把整理责任推给模型,更好的做法是让 runtime 把结果标注成面向模型推理的 observation:保留调用原因,标明结果来源、内容类型、运行元信息、可引用位置和完整内容引用。
harness 不只是负责调用工具,也要负责把工具结果解释成模型能继续使用的输入。
下一步值得做的,是把这件事系统评估起来,看它是否真的减少上下文污染,是否减少无效轮次,是否提高错误恢复质量,是否让最终答案更可追溯。