Engineering

排查 LLM Agent 的工具重复调用:先看模型真正收到的 messages

遇到工具重复调用时,不要先猜模型、提示词或 provider,先把原始输入和最终 provider payload 对齐,找出 messages 第一次变形的位置。

前端看到的现象很简单:一个工具调用链路没有按预期结束,模型反复触发同一个工具。

这类问题很容易被快速命名成“模型不听话”“prompt 没写清楚”“OpenAI-compatible provider 有兼容问题”,或者更具体一点,“Qwen 对 tool calls 的处理不对”。这些猜测都可能成立,但它们不应该成为第一步。

对 LLM Agent 和 tool calling 问题,最稳的起点是先问一句:

模型最终到底收到了什么 messages?

模型只会根据它真正收到的上下文行动。如果最终送进模型的消息已经错了,继续猜模型能力、提示词措辞或 provider 行为,都会把排查带偏。

先抓两端

这次排查里,最关键的动作不是马上读完整源码,而是抓住链路两端:

  1. 调用方原始输入的 messages。
  2. 最终发给 OpenAI-compatible provider 的 messages payload。

两边一对比,问题立刻变得具体:原始输入里的消息顺序是正常的,但进入 provider 前,消息已经发生了重排和合并。

原始链路可以简化成这样:

user A
assistant B,包含一次 tool call 和一段文本
user C
assistant D,包含多次 tool call

最终 provider payload 却更像这样:

system
user A
assistant/tool call ...
assistant/tool call ...
assistant,把本来不相邻的多个 tool_calls 合到一起
tool result ...
assistant 文本
user C

最关键的异常不是“模型又调用了工具”,而是:

  • 第二个 user message 被排到了最后。
  • 本来属于不同 turn 的 assistant tool call 被合并到同一个 assistant message 里。
  • provider 收到的上下文已经不是调用方传入的上下文。

这时问题空间就收窄了。根因不应该优先放在 Qwen,也不应该优先放在 OpenAI-compatible provider,而应该先看 provider payload 生成之前,哪一层改写了 messages。

找第一次变形点

排查复杂链路时,不要试图一口气理解整个系统。先找一条数据,看它从入口到出口在哪里第一次变形。

一个实用顺序是:

调用方输入
-> route / adapter
-> agent
-> message list / memory
-> model prompt converter
-> provider SDK
-> provider payload

如果原始输入和 provider payload 一致,再继续怀疑 prompt、tool schema、provider 行为或模型理解。

如果两者不一致,就沿中间链路找第一次差异出现的位置。这个位置比“最终报错位置”更重要,因为最终报错往往只是下游后果。

这次的链路大致是:

chatRoute(v6) 收到 HTTP 请求
-> agent.stream(messages)
-> prepare-memory-step 创建 MessageList
-> messageList.add(options.messages, "input")
-> MessageList.addOne() 归一化、补 timestamp、merge、sort
-> messageList.get.all.aiV5.llmPrompt() 生成 provider prompt
-> OpenAI-compatible provider 转成 Chat Completions messages

chatRoute(v6) 基本只是把请求里的 messages 传给 agent.stream()。真正可疑的位置,是 messages 进入 MessageList 之后。

把现象翻译成源码对象

只用用户看到的现象去搜,通常搜不到真正的问题。

“工具重复调用”“Qwen 消息顺序错了”“OpenAI-compatible tool calls 合并异常”这些词都太靠近外部表现。维护者在 issue、测试和源码里使用的词,往往是内部对象名和机制名。

这次有效的搜索对象是:

  • MessageList
  • createdAt
  • addOne
  • sort
  • MessageMerger
  • shouldMerge
  • llmPrompt

用这些符号去读源码和测试,才看到关键机制:

this.messages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());

也就是说,messages 进入 MessageList 后,并不只是按原始数组顺序保存。它会被归一化、补 timestamp,然后按 createdAt 排序。

更重要的是,后续 merge 判断使用的 latest message,不一定是原始输入数组里的上一条消息,而是排序后的最后一条消息。

一旦 timestamp 和原始 turn 顺序不一致,原本中间隔着 user message 的两个 assistant message,就可能在排序后被当成可以合并的相邻对象。

没有 createdAt 不等于保序

直觉上会以为:如果输入 messages 没有 createdAt,框架应该按数组顺序处理。

但在这条链路里,“没有 createdAt”并不意味着“完全保留原始顺序”。

Mastra 会为 message 生成 createdAt,也可能为 part 写入 createdAt。随后内部的 lastCreatedAt 会同时受到 message 和 part 两层 timestamp 影响。每次 addOne() 后,MessageList 又会按 message.createdAt 重新排序。

这就带来一个微妙后果:

原始数组里的上一条消息
不一定等于
排序后的 latest message

而 merge 逻辑如果只看“latest 和 incoming 都是 assistant、同 thread、不是 memory source”,就可能把原始输入里并不相邻的 assistant turn 合并。

这解释了为什么原本属于不同上下文的 tool calls,最后会出现在同一个 assistant.tool_calls 数组里。provider 并不是主动跨消息合并;在进入 provider 之前,上游框架已经把它们放进了同一个 assistant message / step block。

issue 是校验,不是替代判断

这次最相关的是 Mastra issue #16893。它讨论的是 MessageList.generateCreatedAt 在长对话里把输入消息 timestamp 推到未来,导致 assistant response 可能被排序到历史中间,下一轮模型因此重复发 tool call。

这和当前问题不是每个细节都相同,但属于同一类机制:

timestamp ordering
-> turn 边界错位
-> tool-call 上下文被误解
-> 模型重复触发工具

另一个相关背景是 issue #10683,里面也出现过 messages without createdAt 被 shuffle 的问题。

这里 issue 的作用是校验:说明这个方向不是孤例,历史上确实出现过类似机制。但 issue 不能替代当前代码判断。更稳的顺序仍然是:

  1. 先从两端样本确认差异。
  2. 再用源码对象定位可能机制。
  3. 再用测试、注释和 issue 校验理解。
  4. 最后回到当前代码,确认是否覆盖这个 case。

如果一开始只用自然语言搜 issue,很容易搜不到;如果一开始只读 issue,也可能把别人的 case 硬套到自己的问题上。

什么时候可以说是依赖库问题

不要太早把问题归因给依赖库。比较稳的判断条件是:

  1. 能证明传给依赖库的输入是对的。
  2. 能证明依赖库输出或生成的下游 payload 已经错了。
  3. 能指出依赖库内部哪个机制制造了变化。
  4. 这个变化不符合调用方的合理预期,或和文档、测试、issue 的语义冲突。
  5. 最好能用最小输入复现;如果暂时不能复现,也至少要有很短的解释链。

这次基本满足前四点:

input messages 正常
-> MessageList.add(options.messages, "input")
-> addOne() 补 timestamp、sort、merge
-> llmPrompt() 生成的 provider prompt 已经错序

所以问题边界更接近 Mastra 内部 message 处理,而不是 Qwen 或 provider 主动跨 turn 合并。

可以怎么规避

如果暂时不能改依赖库,可以先从调用方规避:

  • 普通 chat 请求只传最后一条 user,让框架自己的 memory 机制加载历史。
  • 如果必须传完整历史,给所有 message 显式、严格递增的 createdAt
  • 避免把包含 tool parts 的完整 assistant 历史作为 input 反复传入。
  • 检查 part 级 createdAt 或 provider metadata,避免它们影响内部排序。

如果要修 Mastra 本身,最小修复方向可能在 MessageList.addOne()MessageMerger.shouldMerge()

  • messageSource === "input" 禁止 assistant 自动 merge,只让 response 流式增量走 merge。
  • 或者让 merge 判断基于原始插入邻接关系,而不是排序后的 latest message。
  • 至少应避免被 user 分隔的两个 assistant turn 被合并。

这里的核心不是某个特定补丁,而是责任边界:输入历史的 turn 顺序和 assistant/tool 边界,是 tool calling 正确性的前提。框架可以归一化消息,但不应该在没有明确语义的情况下改变这些边界。

下次怎么排查

遇到 LLM Agent 的奇怪行为时,先不要急着判断是模型、prompt、provider 还是框架。

先做这七步:

  1. 记录用户侧现象,不要提前命名根因。
  2. 抓模型最终收到的 provider payload。
  3. 抓调用方原始输入。
  4. 对比两者第一处差异。
  5. 沿链路找到第一次制造差异的代码。
  6. 判断这是调用方误用、adapter 转换、依赖库内部逻辑,还是模型理解问题。
  7. 用源码对象去搜索测试、注释、issue 和 release notes。

这次真正值得记住的不是 Mastra 某个具体 bug,而是这个排查顺序:

先看模型真正收到什么;
再看调用方原本传了什么;
差异在哪里,就沿那一段链路找第一个改写者。

在 LLM / Agent / tool calling 系统里,很多“模型行为问题”其实是上下文构造问题。模型只是最后一个表现问题的地方,不一定是第一个制造问题的地方。

Back to Blog

Related Posts

View All Posts »