Discourse AI: تُهمل استدعاءات الأدوات المتوازية بصمت مع Mistral (بثّ)

ملخص: عندما يطلب نموذج Mistral عدة أدوات في وقت واحد (على سبيل المثال ثلاث عمليات بحث)، يقوم Discourse AI بتشغيل الأولى فقط. ثم يجيب الوكيل بناءً على نتائج جزئية، ولا يوجد أي تنبيه للمدير أو للمستخدم.

عندما يعيد نموذج اللغة الكبير (LLM) عدة استدعاءات للأدوات في شريحة مُبثّة واحدة، يحتفظ Discourse AI بالأول ويسقط الآخرين بصمت. يفعل Mistral ذلك بالضبط عندما يقوم باستدعاءات أدوات متوازية، لذا عند ضبط نموذج 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 فعليًا (الشريحة الخام)

استدعاء بث مباشر إلى واجهة 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 محاولات (استدعاءان و3 استدعاءات). مع parallel_tool_calls: false في الطلب، يعيد استدعاءً واحدًا لكل استجابة (5 من 5).

السبب

تقرأ process_streamed_message العنصر 0 فقط من مصفوفة tool_calls، ويتتبع المعالج حالة واحدة لـ @tool:

يتم التعامل مع الاستدعاءات المتعددة فقط عندما تصل واحدًا لكل شريحة: id جديد في شريحة لاحقة يغلق الاستدعاء الحالي. هذه هي الطريقة التي يبث بها OpenAI، وهي ما تغطيه المواصفة “التعامل بشكل صحيح مع عدة استدعاءات للأدوات”. عندما تشارك عدة استدعاءات شريحة واحدة، لا يتم قراءة العناصر 1+ أبدًا، وتغلق finish الاستدعاء الوحيد الذي تعرفه.

المسار غير المُبثّ (process_message) يتصفح المصفوفة بأكملها، وهذا هو السبب في أن الضبط في إعادة الإنتاج يحصل على كل شيء.

الأثر

  • مع وكيل حقيقي (أدوات بحث وقراءة، Mistral، بث)، على مدى تشغيل واحد لـ 6 استجابات LLM: احتوت الاستجابة الأولى على 3 عمليات بحث في شريحة واحدة، وتم تشغيل واحدة فقط. احتوت الاستجابات الأربع التالية على استدعاء واحد لكل منها، وتم تشغيلها جميعًا. كانت الأخيرة هي الإجابة النهائية. بشكل إجمالي، طُلب 7 استدعاءات للأدوات وتم تشغيل 5. لم يتم تنفيذ بحثين أبدًا، وأجاب الوكيل وكأنه لديه جميع النتائج.
  • يتم تسجيل الاستدعاءات المسقطة في AiApiAuditLog الخاص بـ Discourse AI نفسه (raw_response_payload)، لذا من السهل التحقق منها في أي موقع متأثر. لا يصل أي شيء إلى السجلات أو Logster أو المستخدم.
  • bot.rb غير معني: فهو يشغل كل ToolCall يستلمه. المسقطة لا تصل إليه أبدًا.
  • الرافعة الوحيدة في واجهة المستخدم هي 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)