Кратко: когда модель Mistral запрашивает несколько инструментов одновременно (например, три поиска), Discourse AI выполняет только первый. Затем агент отвечает на основе частичных результатов, и ни администратор, ни пользователь не получают никаких предупреждений.
Когда LLM возвращает несколько вызовов инструментов в одном потоковом фрагменте (chunk), Discourse AI сохраняет первый и молча отбрасывает остальные. Mistral делает именно это при параллельных вызовах инструментов, поэтому при настроенной LLM Mistral агент запрашивает несколько инструментов, но фактически выполняется только один. Ни одна ошибка не возникает и не записывается в журнал, а финальный ответ выглядит завершённым.
Проверено на 2026.10.0-latest (67bc74d0d, текущая голова веток main, latest и tests-passed на 2026-10-04), провайдер 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 вызовах (1 из 3), при partial_tool_calls: true, а также когда сырой SSE проходит через Endpoints::Mistral#decode_chunk.
Что на самом деле стримит Mistral (сырой фрагмент)
Прямой стриминговый запрос к API Mistral, два фиктивных инструмента, промпт «Какая сейчас погода в Париже и местное время в Токио?». Каждый вызов завершён, все находятся в одном фрагменте, и 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 в запросе он возвращает один вызов на ответ (5 из 5).
Причина
process_streamed_message читает только элемент 0 массива tool_calls, а процессор отслеживает единственный @tool:
Несколько вызовов обрабатываются только тогда, когда они приходят по одному на фрагмент: новый id в последующем фрагменте закрывает текущий вызов. Именно так их стримит OpenAI, и именно это покрывает спецификация «properly handles multiple tool calls». Когда несколько вызовов находятся в одном фрагменте, элементы 1 и далее никогда не читаются, а finish закрывает единственный вызов, о котором он знает.
Поток без стриминга (process_message) перебирает весь массив, поэтому в контрольном примере из репро получаются все вызовы.
Влияние
- С реальным агентом (инструменты поиска и чтения, Mistral, стриминг) за один прогон из 6 ответов LLM: в первом ответе было 3 поиска в одном фрагменте, и выполнился только 1. В следующих 4 ответах было по одному вызову, и все они выполнились. Последний был финальным ответом. В общей сложности было запрошено 7 вызовов инструментов, а выполнено 5. Два поиска так и не были выполнены, и агент ответил, как будто у него были все результаты.
- Отброшенные вызовы записываются в собственный
AiApiAuditLogDiscourse AI (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 возвращать один вызов инструмента на ответ. Он работает с начала сентября. Однако он опирается на приватный метод и не исправляет декодер.
Патч обходного решения
# 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, поддерживая состояние для каждогоindex(с откатом наid), вместо единственного@tool, и позволятьprocess_streamed_messageвозвращать несколько объектов (decode_chunkуже разворачивает их). - Сделать так, чтобы
finishзакрывал все открытые вызовы. - При
partial_tool_callsиспользовать один стриминговый парсер на каждый вызов. - Добавить спецификацию для нескольких вызовов инструментов в одном фрагменте.
Быстрая мера по смягчению ситуации — отправить parallel_tool_calls: false для Mistral или предоставить disable_streaming для этого провайдера.
Готов помочь с тестированием исправления или открыть PR, если это будет полезно.