Discourse AI: параллельные вызовы инструментов молча отбрасываются в Mistral (потоковая передача)

Кратко: когда модель 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. Два поиска так и не были выполнены, и агент ответил, как будто у него были все результаты.
  • Отброшенные вызовы записываются в собственный AiApiAuditLog Discourse 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, если это будет полезно.

2 лайка