Zusammenfassung: Wenn ein Mistral-Modell mehrere Tools gleichzeitig anfordert (z. B. drei Suchanfragen), führt Discourse AI nur das erste aus. Der Agent antwortet dann auf Basis unvollständiger Ergebnisse, und weder der Administrator noch der Nutzer werden gewarnt.
Wenn ein LLM mehrere Tool-Aufrufe in einem einzelnen gestreamten Chunk zurückgibt, behält Discourse AI den ersten bei und verwirft die anderen stillschweigend. Genau das tut Mistral, wenn es parallele Tool-Aufrufe durchführt. Daher wird bei einer konfigurierten Mistral-LLM-Anbindung von einem Agenten zwar um mehrere Tools gebeten, aber nur eines davon wird tatsächlich ausgeführt. Es wird kein Fehler ausgelöst oder protokolliert, und die finale Antwort wirkt vollständig.
Getestet mit 2026.10.0-latest (67bc74d0d, aktueller Stand von main, latest und tests-passed als vom 04.10.2026), Provider mistral, Modell mistral-large-2512, native Tools, Streaming (Standard). Ich habe das Problem zuerst mit mistral-medium-2508 bemerkt.
Minimale Reproduktion (ohne Netzwerk, ohne API-Schlüssel)
# 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}"
Ausgabe:
streamed: 1 of 2 -> ["call_1"]
on-streamed: 2 of 2 -> ["call_1", "call_2"]
Dasselbe Ergebnis mit 3 Aufrufen (1 von 3), mit partial_tool_calls: true und wenn der rohe SSE-Stream durch Endpoints::Mistral#decode_chunk läuft.
Was Mistral tatsächlich streamt (roher Chunk)
Direkter Streaming-Aufruf an die Mistral-API, zwei Dummy-Tools, Prompt „Was ist das aktuelle Wetter in Paris und die lokale Zeit in Tokio?“. Jeder Aufruf ist vollständig, alle befinden sich in einem einzigen Chunk, mit finish_reason in diesem selben Chunk:
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 hat dies in 6 von 6 Versuchen getan (2 und 3 Aufrufe). Mit parallel_tool_calls: false in der Anfrage gibt es einen Aufruf pro Antwort zurück (5 von 5).
Ursache
process_streamed_message liest nur das Element 0 des tool_calls-Arrays, und der Processor verfolgt nur ein einzelnes @tool:
Mehrere Aufrufe werden nur verarbeitet, wenn sie einer pro Chunk eintreffen: Eine neue id in einem späteren Chunk schließt den aktuellen Aufruf ab. So streamt OpenAI sie, und das deckt die Spezifikation „properly handles multiple tool calls“ ab. Wenn mehrere Aufrufe einen Chunk teilen, werden die Elemente ab Index 1 nie gelesen, und finish schließt den einzigen Aufruf ab, den der Processor kennt.
Der nicht-gestreamte Pfad (process_message) iteriert über das gesamte Array, weshalb die Kontrollgruppe in der Reproduktion alles erhält.
Auswirkungen
- Mit einem echten Agenten (Such- und Lese-Tools, Mistral, Streaming) lief in einem Lauf mit 6 LLM-Antworten Folgendes: Die erste Antwort enthielt 3 Suchanfragen in einem Chunk, und nur 1 wurde ausgeführt. Die nächsten 4 Antworten enthielten jeweils einen Aufruf, und alle wurden ausgeführt. Die letzte war die finale Antwort. Insgesamt wurden 7 Tool-Aufrufe angefordert und 5 ausgeführt. Zwei Suchanfragen wurden nie ausgeführt, und der Agent antwortete, als hätte er alle Ergebnisse.
- Die verworfenen Aufrufe werden in Discourse AIs eigenem
AiApiAuditLog(raw_response_payload) protokolliert, sodass sie auf jeder betroffenen Site leicht überprüft werden können. Nichts davon erreicht die Logs, Logster oder den Nutzer. bot.rbist nicht beteiligt: Es führt jedenToolCallaus, den es erhält. Die verworfenen erreichen es nie.- Der einzige Hebel in der UI ist
disable_native_tools, der auf XML-Tools umschaltet und das native Tool-Calling aufgibt (hier nicht getestet). Dermistral-Provider bietetdisable_streamingnicht an, und Discourse AI sendet nieparallel_tool_calls.
Umfang: Der Processor wird von jedem OpenAI-kompatiblen Endpunkt verwendet (OpenAI, Azure, Groq, Mistral, OpenRouter, vLLM). Ich habe das Problem nur bei Mistral bestätigt. Andere wären betroffen, wann immer sie mehrere Aufrufe in einem Chunk bündeln, was ich nicht getestet habe.
Workaround
Ich verwende ein kleines Plugin-Patch, das Mistral darum bittet, nur einen Tool-Aufruf pro Antwort zu senden. Es funktioniert seit Anfang September. Es stützt sich jedoch auf eine private Methode und behebt den Decoder nicht.
Workaround-Patch
# 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
Mögliche Lösung
- Jedes Element von
tool_callslesen und dabei einen Zustand proindex(mit Fallback aufid) statt eines einzelnen@toolbeibehalten, undprocess_streamed_messagemehrere Objekte zurückgeben lassen (decode_chunkglättet bereits). finishdazu bringen, jeden offenen Aufruf zu schließen.- Bei
partial_tool_callseinen Streaming-Parser pro Aufruf verwenden. - Eine Spezifikation für mehrere Tool-Aufrufe in einem Chunk hinzufügen.
Eine schnelle Minderung könnte darin bestehen, parallel_tool_calls: false für Mistral zu senden oder disable_streaming für diesen Provider bereitzustellen.
Ich helfe gerne beim Testen einer Lösung oder eröffne einen PR, falls das nützlich ist.