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을 열겠습니다.

3개의 좋아요

할 수 있다면 PR을 열어주세요 :+1: 리뷰하겠습니다

좋아요, PR을 열겠습니다. 리뷰 기대하겠습니다.

아니, 걱정 마, 거의 정리했어.

참고로 이건 비표준적인 형태야. 예를 들어 vllm은 요즘 Open AI가 응답에서 겪고 있는 문제에 취약하지 않거든.

어쨌든… PR은 곧 올라갈 거야. 이 문제를 알려줘서 고마워.

다음과 같이 수정될 예정입니다:

1개의 좋아요

샘, 빨리 처리해줘서 고마워! 업그레이드해서 Mistral과 대조해볼게.

1개의 좋아요

빠른 수정을 해준 @sam 과 빠른 답변을 해준 @zogstrip 에게 진심으로 감사드립니다.

모든 기능이 기대대로 작동합니다 :+1: 결과도 훌륭합니다. 이제 에이전트가 1개의 응답으로 여러 읽기 요청을 보낼 수 있게 되어, 제 RAG 테스트 시나리오에서 LLM 호출이 43% 감소했고, 검색 및 읽기에 40% 적은 시간이 소요되며, 비용도 33% 절감되었습니다.

2026.10.0-latest (f18a1985b, #44270 포함) 환경에서 Mistral mistral-large-2512 모델을 사용하여 검증했습니다 (제 parallel_tool_calls: false 우회 조치를 제거한 후):

  • 첫 번째 게시글의 네트워크 없이 재현 가능한 스크립트는 이제 모든 호출을 반환합니다. 1개 청크에서 2개 중 2개, 3개 중 3개의 호출이 반환되었으며, partial_tool_calls 및 Endpoints::Mistral#decode_chunk를 통해서도 동일하게 작동합니다. 청크당 1개의 호출도 여전히 정상 작동합니다. (process_streamed_message가 배열을 반환할 수 있으므로 스크립트에 이제 .flatten이 필요합니다.)
  • 실제 RAG 에이전트를 우회 조치 없이 사용할 때, Mistral은 단일 청크로 4개의 병렬 읽기 요청을 보냈고, 4개 모두 실행되었습니다. 14번의 에이전트 실행 동안 요청된 모든 도구 호출이 실행되었습니다.

다시 한번 감사드립니다!

1개의 좋아요