Discourse AI: Mistral 스트리밍에서 병렬 도구 호출이 조용히 무시됨

요약: Mistral 모델이 여러 도구를 동시에 요청할 때(예: 3개의 검색), Discourse AI는 첫 번째 도구만 실행합니다. 이후 에이전트는 부분적인 결과로 답변을 생성하며, 관리자나 사용자에게 어떠한 경고도 표시되지 않습니다.

LLM이 단일 스트림 청크에서 여러 도구 호출을 반환할 경우, Discourse AI는 첫 번째 호출만 유지하고 나머지는 조용히 버립니다. Mistral은 병렬 도구 호출을 수행할 때 정확히 이러한 방식으로 동작하므로, Mistral LLM이 구성된 경우 에이전트가 여러 도구를 요청하지만 실제로 실행되는 도구는 하나뿐입니다. 에러가 발생하거나 로그에 기록되지 않으며, 최종 답변은 완전한 것처럼 보입니다.

2026.10.0-latest(67bc74d0d, 2026-10-04 기준 main, latest, tests-passed의 현재 헤드), 프로바이더 mistral, 모델 mistral-large-2512, 네이티브 도구, 스트리밍(기본값) 환경에서 테스트했습니다. 처음에는 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에 대한 직접 스트리밍 호출, 두 개의 더미 도구, 프롬프트 “What is the current weather in Paris and the local time in Tokyo?”. 모든 호출이 완결되어 있으며, 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는 tool_calls 배열의 0번 요소만 읽으며, 프로세서가 단일 @tool만 추적합니다:

여러 호출은 청크당 하나씩 도착할 때만 처리됩니다: 이후 청크에서 새로운 id가 나타나면 현재 호출이 닫힙니다. 이는 OpenAI가 스트리밍하는 방식이며, 스펙 *“properly handles multiple tool calls”*가 다루는 경우입니다. 여러 호출이 하나의 청크를 공유하는 경우, 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월 초부터 작동하고 있습니다. 다만, 이는 비공개 메서드에 의존하며, 디코더 문제를 해결하지는 않습니다.

우회 패치
# 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 대신 index(id로 폴백)별 상태를 하나씩 유지하면서 tool_calls의 모든 요소를 읽고, process_streamed_message가 여러 개체를 반환하도록 합니다(decode_chunk는 이미 평탄화합니다).
  • finish가 모든 열린 호출을 닫도록 합니다.
  • partial_tool_calls의 경우, 호출별로 하나의 스트리밍 파서를 사용합니다.
  • 하나의 청크에 여러 도구 호출이 있는 경우의 스펙을 추가합니다.

간단한 완화책으로는 Mistral에 대해 parallel_tool_calls: false를 전송하거나, 해당 프로바이더에 대해 disable_streaming을 노출하는 것이 될 수 있습니다.

수정 사항을 테스트하는 데 도움을 줄 수 있거나, 유용하다면 PR을 열겠습니다.

2개의 좋아요