要約: Mistral モデルが一度に複数のツールを要求した場合(例: 3つの検索)、Discourse AI は最初の1つのみを実行します。エージェントは部分的な結果に基づいて回答し、管理者やユーザーに警告は出されません。
LLM が単一のストリーミングチャンク内で複数のツール呼び出しを返した場合、Discourse AI は最初の1つを保持し、残りはサイレントに破棄します。Mistral は並列ツール呼び出しを行う際にまさにこの動作をするため、Mistral LLM が設定されている場合、エージェントは複数のツールを要求しますが、実際に実行されるのはそのうちの1つのみです。エラーは発生も記録もされず、最終的な回答は完全に見えます。
2026.10.0-latest (67bc74d0d、2026-10-04 時点の main、latest および tests-passed の現在のヘッド)、プロバイダ mistral、モデル mistral-large-2512、ネイティブツール、ストリーミング(デフォルト)でテストしました。最初に mistral-medium-2508 で確認しました。
最小再現手順(ネットワーク不要、API キー不要)
# frozen_string_literal: true
# Network-free repro. Run with: 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"}' } },
]
# Streaming: both tool calls in ONE chunk, as Mistral sends them
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}"
# Control: the same tool calls as a non-streamed response
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 への直接ストリーミング呼び出し、2つのダミーツール、プロンプト “What is the current weather in Paris and the local time in Tokyo?”。すべての呼び出しは完了しており、すべて1つのチャンク内にあり、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回中6回(2回および3回の呼び出し)でこの動作をしました。リクエストに parallel_tool_calls: false を指定すると、レスポンスごとに1つの呼び出しを返します(5回中5回)。
原因
process_streamed_message は tool_calls 配列の要素 0 のみを読み取り、プロセッサは単一の @tool を追跡します:
複数の呼び出しは、チャンクごとに1つずつ到着する場合のみ処理されます: 後のチャンクで新しい id が現れると、現在の呼び出しがクローズされます。これは OpenAI がストリーミングする方式であり、仕様 “properly handles multiple tool calls” がカバーしている内容です。複数の呼び出しが1つのチャンクを共有する場合、要素 1 以降は読み取られず、finish は認識している唯一の呼び出しをクローズします。
非ストリーミングパス(process_message)は配列全体を反復するため、再現手順のコントロールケースではすべて取得されます。
影響
- 実際のエージェント(検索ツールと読み取りツール、Mistral、ストリーミング)では、6回の LLM レスポンスの実行中: 最初のレスポンスは1つのチャンクに3つの検索を含んでおり、実行されたのは1つのみでした。次の4つのレスポンスはそれぞれ1つの呼び出しを含んでおり、すべて実行されました。最後は最終的な回答でした。合計で7つのツール呼び出しが要求され、5つが実行されました。2つの検索は実行されず、エージェントはすべての結果を持っているかのように回答しました。
- 破棄された呼び出しは、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 のみです。他のプロバイダも、複数の呼び出しを1つのチャンクにグループ化する場合は影響を受けますが、これはテストしていません。
回避策
私は、Mistral にレスポンスごとに1つのツール呼び出しを要求する小さなプラグインパッチを使用しています。9月上旬以来、機能しています。ただし、プライベートメソッドに依存しており、デコーダーを修正するものではありません。
回避策パッチ
# Workaround: OpenAiMessageProcessor#process_streamed_message only reads
# tool_calls[0] of each streamed chunk. Mistral may return several tool calls
# in the same chunk (parallel_tool_calls defaults to true), so all but the
# first are silently dropped. Ask Mistral for one tool call per response.
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, inside after_initialize
reloadable_patch do
if defined?(::DiscourseAi::Completions::Endpoints::Mistral)
::DiscourseAi::Completions::Endpoints::Mistral.prepend(MistralSingleToolCallPerResponse)
end
end
修正の提案
tool_callsのすべての要素を読み取り、単一の@toolの代わりにindex(フォールバックとしてid)ごとに1つの状態を保持し、process_streamed_messageが複数のオブジェクトを返すようにします(decode_chunkはすでにフラット化しています)。finishがすべてのオープンな呼び出しをクローズするようにします。partial_tool_callsを使用する場合、呼び出しごとに1つのストリーミングパーサーを使用します。- 1つのチャンクに複数のツール呼び出しがある場合の仕様を追加します。
迅速な緩和策としては、Mistral に対して parallel_tool_calls: false を送信するか、そのプロバイダに対して disable_streaming を公開することです。
修正のテストを手伝うことも、必要であれば PR を開くことも喜んで行います。