Tool Calling / Runtime Observation / Context Design

如何优化 toolCall 给大模型带来的噪音问题

工具调用帮助模型执行动作,但当越来越多的执行结果进入上下文后,又慢慢地给模型带来了噪音。

如何优化 toolCall 给大模型带来的噪音问题 左侧是混乱工具输出,中间是 runtime renderer,右侧是模型可读的结构化输入。 toolCall result noise 从工具输出 到模型可读输入 不是把 stdout 塞回去,而是把事实整理成下一轮推理的工作记忆。 Raw Tool Result Runtime annotate + render meaning + refs LLM Input

工具调用的噪音问题

过去几年,tool calling 已经成为大模型 Agent 的基础能力,模型看见工具列表,选择一个工具,填好参数,runtime 执行工具,再把结果塞回上下文,模型继续判断下一步。这个循环简单、直接,在模型厂商的专门训练下产生的威力巨大。

但这里也有一个问题一直困扰着各家厂商:

模型输出 tool call,runtime 执行工具,然后把 工具调用的JSON、结果stdout、搜索的列表、文件片段或报错信息全都塞回上下文。这样做的目的是保留真实原始信息,让大模型有足够的信息来判断下一步该怎么做。

但在多数 tool call loop 里,模型下一轮只能依赖当前上下文,因此 runtime 往往需要把前面相关的工具调用和结果继续放进上下文。当工具调用越来越多,整个上下文就开始变得嘈杂了。

这些“真实原始信息”虽然保留了所有细节,但同时往往也很冗长、结构复杂,模型需要先花注意力去解析它们的结构、区分字段类型、恢复各部分之间的对应关系。

比如当连续调用工具时,多个tool call连续出现,再跟着多个tool result,大模型需要根据它们的call_id来完成配对,而配对call和result显然跟当前会话的任务无直接关系

久而久之,模型的注意力就这样被分散了,在面对大量工具调用结果时,就难以快速抓住关键信息并做出有效决策。

模型厂商一般都会对模型进行工具调用的强化训练:学习成绩不好是吧?多做作业!记不住是吧?多背就会了!

但应试教育终有其弊端,我们还是应该从源头上解决问题。

User LLM Tool Call Runtime Tool Result Observation Formatting?
大家更常讨论模型如何发起 toolCall,却较少讨论 tool result 如何重新进入模型。

大模型需要什么样的输入

如果盲目堆叠工具调用结果会有上面的问题,那怎么组织上下文比较好呢?通过上面的分析,我们知道大模型的很多推理浪费在了理解工具调用原因、哪个是对应的结果等等方面,这说明它缺少的不是原始信息,而是关于这些原始信息的一份说明。

举个常见场景:模型为了修复一个 bug,调用代码搜索工具搜索某个函数名。工具返回二十个命中项,每个命中项包含路径、行号、上下文片段、匹配分数,甚至还夹着调试字段。此时,模型真正需要的是什么?

  • 搜索了什么。
  • 为什么要搜索。
  • 找到了几个文件?
  • 哪些位置可以被引用或继续读取。
  • 有没有遗漏或不确定性。
  • 如果需要文件完整内容,应该去哪里取。

这些信息有些工具结果里已有,但有些需要模型自己推断,有些甚至就没有!

所以,我们不仅应该关注工具调用,还应该关注如何组织和呈现工具结果,以帮助模型更好地理解和利用这些信息,是时候重新定义会话上下文的结构了。

Raw Tool Result LLM Context Window debug fields full stdout duplicate matches internal metadata result refs
原始输出不是不能读,而是可用信息和运行元信息混在一起,模型需要额外整理。

输入不是日志,而是模型下一轮推理的工作记忆

现在做法是把工具调用结果当成了日志对待,但工具结果进入模型上下文以后,会参与下一轮推理,所以它的角色更应该是工作记忆,而不是归档日志。

日志追求的是完整、可复查,而工作记忆要求的是边界清楚、来源明确、可追溯、能支持下一步判断,这两者不能搞混。

runtime 可以完整保存工具输出,包括 stdout、搜索结果、文件快照、测试报告、命令状态、时间戳和内部 metadata。但输入给模型的内容应该换一种方式,应该把工具调用结果标注成工作记忆:说明这次调用的目的、执行状态、结果来源、内容类型、可引用位置、结果范围,以及必要时给出简短概览。

一个好的 runtime observation,至少要回答:执行了什么、为什么执行、用了哪些参数、执行是否成功、每一部分结果从哪里来、是什么类型、对应哪次工具调用、如果结果被折叠或截断,完整内容在哪里查看。
Archive Log Working Memory Why this tool was called Decision overview Result refs shape
日志负责完整复查,工作记忆负责支持下一轮判断。

把调用原因带回输入

在回传工具调用结果时,除了包含调用了什么工具,结果是什么,最好还包含“模型为什么要调用这个工具”,这个 reason 或 purpose 很重要,它是连接“调用动作”和“执行结果”的桥。

因为模型下一轮只能依赖当前上下文。除非上下文里明确写出来,否则它并不天然知道上一次 toolCall 的意图。

有些系统会把 reasoning 或 thinking block 带回后续请求,这有助于保持推理连续性,但它不是专门面向工具结果的说明层。它通常不够稳定、精练,也不一定直接说明某次工具调用的结果结构。

更好的做法是:当模型发起工具调用时,要求它给出一个简短的 reasonpurpose。runtime 执行完成后,把这个 reason 一起带回 observation。

当然,reason 不是神谕。它是模型事前的判断,可能不准确。runtime 不应该把 reason 当成事实结论,只应该把它作为上下文线索:这是当时为什么执行这一步。

Tool Call tool: search params: refreshSession reason / purpose Runtime Observation executed action same reason is visible overview + evidence carry intent forward
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 没有创造新事实,也没有替模型判断哪些内容重要。它只是把原始结果拆开标注:哪些是工具返回值,哪些是运行元信息,哪些是文件位置,当前搜索覆盖了什么范围。模型仍然要推理,但不必把注意力浪费在低价值的结构解析上。

Raw JSON "query": "refreshSession" "elapsed": 318 "score": 0.91 "debug": {"backend": "rg"} "text": "export async..." Observation 为什么执行:定位刷新链路 概览:实现 + 测试命中 位置:refresh.ts:42 / test.ts:88 引用:artifact://search/...
同一份搜索结果,后者更像下一轮推理可以直接使用的工作台。

用语义标注组织 observation

上面的方案实际上是对工具结果加了一层语义标注:调用原因、执行状态、结果来源、内容类型、可引用位置、完整内容引用都被明确标了出来。

语义标注可以用 Markdown transcript,这是一个低摩擦的承载形式,它接近自然语言,适合混合标题、解释、列表、证据和警告。但 Markdown 不是关键,关键是这层说明要稳定、可预测、字段固定。

Raw JSON { "results": [ { "file": "...", "score": 0.91, "debug": {...}, "elapsed": 318 } ] } annotate Annotated Observation 为什么执行 结果概览 可引用位置 完整内容引用
关键不是 Markdown 打败 JSON,而是 raw result 变成 annotated observation。

如何评估语义标注是否有效

当然,这个方向不能只靠感觉判断,需要系统评估。

可以从几个维度看:任务成功率、模型轮次、replayed observation tokens、错误恢复质量、call/result 配对错误率、结果定位耗时、证据可追溯性、用户可解释性。

一个合理的实验对比可以包括三组:原始 toolCall、toolCall 加语义标注 observation、以及更结构化的 agent-runtime 交互方式。这样可以把“结果标注”的收益,和更大协议设计的收益分开观察。

Raw toolCall toolCall + Observation Structured Runtime success success success turns turns turns mapping errors mapping errors mapping errors
要单独评估“结果语义标注”,不要把它和更大的协议收益混在一起。

结语:优化工具调用,不只是优化调用本身

tool calling 解决了一个关键问题:模型如何请求外部工具,这个问题很重要,但它不是 Agent runtime 的全部。

工具执行之后,结果如何重新进入模型上下文,同样值得设计。

直接塞 JSON 或 stdout,是把整理责任推给模型,更好的做法是让 runtime 把结果标注成面向模型推理的 observation:保留调用原因,标明结果来源、内容类型、运行元信息、可引用位置和完整内容引用。

harness 不只是负责调用工具,也要负责把工具结果解释成模型能继续使用的输入。

下一步值得做的,是把这件事系统评估起来,看它是否真的减少上下文污染,是否减少无效轮次,是否提高错误恢复质量,是否让最终答案更可追溯。

优化调用 how to call tools 还要设计结果如何返回 优化输入 how results become context
Agent runtime 的质量,一半在调用工具,一半在整理世界给模型看。