摘要: 当 Mistral 模型同时请求多个工具(例如三次搜索)时,Discourse AI 仅执行第一个。随后,代理基于部分结果作答,且不会向管理员或用户发出任何警告。
当 LLM 在**单个流式块(streamed chunk)**中返回多个工具调用时,Discourse AI 会保留第一个调用并静默丢弃其余调用。Mistral 在进行并行工具调用时正是如此。因此,如果配置了 Mistral LLM,代理会请求多个工具,但实际上只有一个会被执行。系统不会抛出或记录任何错误,且最终的回答看起来是完整的。
已在 2026.10.0-latest(67bc74d0d,截至 2026-10-04 main、latest 和 tests-passed 分支的当前头提交)上测试,提供商为 mistral,模型为 mistral-large-2512,使用原生工具(native tools)和流式传输(streaming,默认设置)。我最初是在使用 mistral-medium-2508 时发现的。
最小复现步骤(无需网络,无需 API 密钥)
# frozen_string_literal: true
# 无需网络的复现。运行方式:bin/rails runner repro.rb
Processor = DiscourseAi::Completions::OpenAiMessageProcessor
calls = [
{ index: 0, id: "call_1", type: "function", function: { name: "echo", arguments: '{"text":"one"}' } },
{ index: 1, id: "call_2", type: "function", function: { name: "echo", arguments: '{"text":"two"}' } },
]
# 流式传输:两个工具调用在同一个块中,正如 Mistral 发送的那样
processor = Processor.new
chunk = { choices: [{ index: 0, delta: { tool_calls: calls }, finish_reason: "tool_calls" }] }
streamed = [processor.process_streamed_message(chunk), *processor.finish].compact
puts "streamed: #{streamed.size} of #{calls.size} -> #{streamed.map(&:id).inspect}"
# 对照:相同的工具调用作为非流式响应
message = { choices: [{ index: 0, message: { role: "assistant", tool_calls: calls }, finish_reason: "tool_calls" }] }
non_streamed = Processor.new.process_message(message)
puts "non-streamed: #{non_streamed.size} of #{calls.size} -> #{non_streamed.map(&:id).inspect}"
输出:
streamed: 1 of 2 -> ["call_1"]
non-streamed: 2 of 2 -> ["call_1", "call_2"]
使用 3 个调用时结果相同(3 个中只有 1 个执行),使用 partial_tool_calls: true 时结果相同,且当原始 SSE 通过 Endpoints::Mistral#decode_chunk 处理时结果也相同。
Mistral 实际流式传输的内容(原始块)
直接调用 Mistral API 进行流式传输,使用两个虚拟工具,提示词为*“巴黎现在的天气如何?东京的当地时间是多少?”*。每个调用都是完整的,全部在一个块中,且 finish_reason 也在同一个块中:
data: {"id":"5a2166a42dd14374a54944726c759667","object":"chat.completion.chunk","created":1791036485,"model":"mistral-large-2512","choices":[{"index":0,"delta":{"tool_calls":[{"id":"bM0Xl0KSh","type":"function","function":{"name":"get_weather","arguments":"{\"city\": \"Paris\"}"},"index":0},{"id":"kgOYVL46d","type":"function","function":{"name":"get_local_time","arguments":"{\"city\": \"Tokyo\"}"},"index":1}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":172,"total_tokens":195,"completion_tokens":23,"prompt_tokens_details":{"cached_tokens":0},"service_tier":"standard"},"p":"abcdefghijklmnopqrstuvwxyz"}
Mistral 在 6 次尝试中(2 次和 3 次调用)有 6 次都这样做了。如果在请求中设置 parallel_tool_calls: false,它每次响应只返回一个调用(5 次尝试中 5 次成功)。
原因
process_streamed_message 仅读取 tool_calls 数组中的第 0 个元素,并且处理器只跟踪单个 @tool:
只有当多个调用每个块到达一个时,才能正确处理:后续块中的新 id 会关闭当前的调用。这是 OpenAI 流式传输的方式,也是规范*“正确处理多个工具调用”*所涵盖的情况。当多个调用共享同一个块时,第 1 个及以后的元素永远不会被读取,而 finish 只会关闭它知道的那个唯一调用。
非流式路径(process_message)会遍历整个数组,这就是为什么复现步骤中的对照测试能获取所有内容。
影响
- 在使用真实代理(搜索和读取工具,Mistral,流式传输)的一次运行中,6 次 LLM 响应中:第一次响应在一个块中包含3 次搜索,但只执行了 1 次。接下来的 4 次响应每次包含一个调用,且全部执行。最后一个是最终回答。总共请求了 7 次工具调用,但只执行了 5 次。两次搜索从未执行,而代理表现得好像它拥有所有结果一样。
- 被丢弃的调用记录在 Discourse AI 自己的
AiApiAuditLog(raw_response_payload)中,因此在任何受影响的站点上都可以轻松检查。没有任何信息到达日志、Logster 或用户。 bot.rb不涉及此问题:它会执行收到的每个ToolCall。被丢弃的调用从未到达它。- UI 中唯一的控制选项是
disable_native_tools,这会切换到 XML 工具并放弃原生工具调用(此处未测试)。mistral提供商未公开disable_streaming,且 Discourse AI 从不发送parallel_tool_calls。
范围: 该处理器由所有兼容 OpenAI 的端点共享(OpenAI、Azure、Groq、Mistral、OpenRouter、vLLM)。我只在 Mistral 上确认了该问题。其他提供商在将多个调用分组在一个块中时也会受到影响,但我尚未测试。
临时解决方案
我使用了一个小的插件补丁,要求 Mistral 每次响应只返回一个工具调用。自 9 月初以来一直有效。不过,它依赖于一个私有方法,并且并未修复解码器。
临时解决方案补丁
# 临时解决方案:OpenAiMessageProcessor#process_streamed_message 仅读取
# 每个流式块中 tool_calls[0]。Mistral 可能在同一个块中返回多个工具调用
# (parallel_tool_calls 默认为 true),因此除了第一个之外的所有调用都会被静默丢弃。
# 要求 Mistral 每次响应只返回一个工具调用。
module MistralSingleToolCallPerResponse
private
def prepare_payload(prompt, model_params, dialect)
payload = super
payload[:parallel_tool_calls] = false if payload[:tools].present?
payload
end
end
# plugin.rb,在 after_initialize 内部
reloadable_patch do
if defined?(::DiscourseAi::Completions::Endpoints::Mistral)
::DiscourseAi::Completions::Endpoints::Mistral.prepend(MistralSingleToolCallPerResponse)
end
end
可能的修复方案
- 读取
tool_calls中的每个元素,为每个index(回退到id)保留一个状态,而不是单个@tool,并让process_streamed_message返回多个对象(decode_chunk已经展平)。 - 让
finish关闭所有未完成的调用。 - 使用
partial_tool_calls时,为每个调用使用一个流式解析器。 - 添加一个针对单个块中包含多个工具调用的规范(spec)。
一个快速的缓解措施可以是针对 Mistral 发送 parallel_tool_calls: false,或者为该提供商公开 disable_streaming。
我很乐意协助测试修复方案,或者如果这有帮助的话,我可以提交一个 PR。