fix(openai): Tool-Calling-Reliability verbessern (maxIterations, forced tool_choice, Thinking-Strip)
- maxIterations aus Server-Settings an OpenAI-StreamChat durchreichen (war hardcoded 10) - Auto-Continue: tool_choice="required" erzwingt Tool-Call statt erneuter Ankündigung, wird nach erfolgreichem Tool-Call wieder auf "auto" zurückgesetzt - Direktive Continuation-Message statt einfaches "Continue." - StripOldThinking: <think>-Blöcke aus älteren Assistant-Messages entfernen (nur letzte 4 behalten), reduziert Kontext-Inflation die ab ~20k Tokens zu Tool-Call-Degradation führt - reasoning_split=true entfernt (wird von MiniMax-API ignoriert) - parallel_tool_calls=false für MiniMax (Streaming, Non-Streaming, Payload-Preview) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f2f026baa
commit
358d9328be
3 files changed
+94
-11
No files matched your search
@@ -240,7 +240,7 @@ public static class ChatEndpoint
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, apiKeyOverride: openAiKeyOverride, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, sseCt: sseCt);
|
await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, maxIterations: ctx.MaxIterations, apiKeyOverride: openAiKeyOverride, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, sseCt: sseCt);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -347,7 +347,11 @@ public static class SessionEndpoint
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider == "minimax")
|
if (provider == "minimax")
|
||||||
payload["extra_body"] = new { reasoning_split = true };
|
{
|
||||||
|
payload["temperature"] = 1.0;
|
||||||
|
payload["top_p"] = 0.95;
|
||||||
|
payload["parallel_tool_calls"] = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (provider == "nvidia")
|
if (provider == "nvidia")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ public class OpenAiService
|
|||||||
var client = CreateClient(providerConfig.ApiKey);
|
var client = CreateClient(providerConfig.ApiKey);
|
||||||
// MiniMax-spezifisch: höheres Output-Budget (Thinking-Modelle brauchen viel)
|
// MiniMax-spezifisch: höheres Output-Budget (Thinking-Modelle brauchen viel)
|
||||||
var maxTokens = provider == "minimax" ? 32768 : 16384;
|
var maxTokens = provider == "minimax" ? 32768 : 16384;
|
||||||
|
// Bei Auto-Continue: tool_choice = "required" erzwingt einen Tool-Call,
|
||||||
|
// statt dass das Modell erneut nur eine Ankündigung als Text schreibt.
|
||||||
|
var toolChoice = autoContinueCount > 0 ? "required" : "auto";
|
||||||
var payload = new Dictionary<string, object>
|
var payload = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["model"] = modelId,
|
["model"] = modelId,
|
||||||
@@ -378,20 +381,23 @@ public class OpenAiService
|
|||||||
["max_tokens"] = maxTokens,
|
["max_tokens"] = maxTokens,
|
||||||
["stream_options"] = new { include_usage = true },
|
["stream_options"] = new { include_usage = true },
|
||||||
["tools"] = _openAiToolDefs,
|
["tools"] = _openAiToolDefs,
|
||||||
["tool_choice"] = "auto"
|
["tool_choice"] = toolChoice
|
||||||
};
|
};
|
||||||
|
|
||||||
if (provider == "nvidia" && IsNvidiaThinkingModel(modelId))
|
if (provider == "nvidia" && IsNvidiaThinkingModel(modelId))
|
||||||
payload["chat_template_kwargs"] = new { thinking = true };
|
payload["chat_template_kwargs"] = new { thinking = true };
|
||||||
|
|
||||||
// MiniMax-spezifisch: reasoning_split → Thinking landet in delta.reasoning_details
|
// MiniMax-spezifisch:
|
||||||
// statt als <think>...</think> inline in delta.content (siehe MiniMax-OpenAI-Compat-Docs).
|
// - temperature=1.0, top_p=0.95: MiniMax-Empfehlung für agentic Performance
|
||||||
// Sampling-Parameter: temperature=1.0, top_p=0.95 (MiniMax-Empfehlung für Agentic Performance).
|
// - parallel_tool_calls=false: Modell muss nur einen Tool-Call planen statt mehrere parallel,
|
||||||
|
// was bei komplexen Multi-Step-Tasks die Zuverlässigkeit erhöht
|
||||||
|
// Hinweis: reasoning_split=true wurde entfernt – wird von der gehosteten MiniMax-API ignoriert
|
||||||
|
// (Thinking kommt trotzdem als inline <think>...</think> in delta.content)
|
||||||
if (provider == "minimax")
|
if (provider == "minimax")
|
||||||
{
|
{
|
||||||
payload["extra_body"] = new { reasoning_split = true };
|
|
||||||
payload["temperature"] = 1.0;
|
payload["temperature"] = 1.0;
|
||||||
payload["top_p"] = 0.95;
|
payload["top_p"] = 0.95;
|
||||||
|
payload["parallel_tool_calls"] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonPayload = JsonSerializer.Serialize(payload);
|
var jsonPayload = JsonSerializer.Serialize(payload);
|
||||||
@@ -857,8 +863,9 @@ public class OpenAiService
|
|||||||
if (!string.IsNullOrEmpty(continueContent))
|
if (!string.IsNullOrEmpty(continueContent))
|
||||||
apiMessages.Add(new { role = "assistant", content = continueContent });
|
apiMessages.Add(new { role = "assistant", content = continueContent });
|
||||||
|
|
||||||
// Synthetische User-Message injizieren
|
// Synthetische User-Message injizieren – direktiv, nicht nur "Continue."
|
||||||
apiMessages.Add(new { role = "user", content = "Continue." });
|
// Damit das Modell den Tool-Call tatsächlich ausführt statt nur erneut anzukündigen.
|
||||||
|
apiMessages.Add(new { role = "user", content = "You announced an action but didn't execute it. Call the appropriate tool now to carry out what you described. Do not repeat the announcement." });
|
||||||
|
|
||||||
// SSE-Event an Frontend senden
|
// SSE-Event an Frontend senden
|
||||||
await SseHelper.TryWriteSse(response, "auto_continue", new
|
await SseHelper.TryWriteSse(response, "auto_continue", new
|
||||||
@@ -885,7 +892,10 @@ public class OpenAiService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool-Calls ausführen
|
// Tool-Calls ausführen → autoContinueCount zurücksetzen, damit tool_choice
|
||||||
|
// wieder "auto" wird (Modell darf wieder frei zwischen Text und Tool-Call wählen)
|
||||||
|
autoContinueCount = 0;
|
||||||
|
|
||||||
// Assistant-Message mit tool_calls in die History einfügen
|
// Assistant-Message mit tool_calls in die History einfügen
|
||||||
var toolCallsForHistory = new List<object>();
|
var toolCallsForHistory = new List<object>();
|
||||||
foreach (var (_, (tcId, tcName, tcArgs, tcTs)) in toolCallAccumulators)
|
foreach (var (_, (tcId, tcName, tcArgs, tcTs)) in toolCallAccumulators)
|
||||||
@@ -1150,7 +1160,11 @@ public class OpenAiService
|
|||||||
payload["chat_template_kwargs"] = new { thinking = true };
|
payload["chat_template_kwargs"] = new { thinking = true };
|
||||||
|
|
||||||
if (provider == "minimax")
|
if (provider == "minimax")
|
||||||
payload["extra_body"] = new { reasoning_split = true };
|
{
|
||||||
|
payload["temperature"] = 1.0;
|
||||||
|
payload["top_p"] = 0.95;
|
||||||
|
payload["parallel_tool_calls"] = false;
|
||||||
|
}
|
||||||
|
|
||||||
var jsonPayload = JsonSerializer.Serialize(payload);
|
var jsonPayload = JsonSerializer.Serialize(payload);
|
||||||
var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
|
var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
|
||||||
@@ -1513,9 +1527,74 @@ public class OpenAiService
|
|||||||
}
|
}
|
||||||
apiMessages.Add(converted);
|
apiMessages.Add(converted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thinking aus älteren Turns strippen (Kontext-Spar-Maßnahme):
|
||||||
|
// MiniMax/DeepSeek-R1/QwQ brauchen ihr eigenes Thinking in der History,
|
||||||
|
// aber nur die letzten paar Turns sind relevant – ältere Thinking-Blöcke
|
||||||
|
// blähen den Kontext auf und verursachen Tool-Call-Degradation ab ~20k Tokens.
|
||||||
|
StripOldThinking(apiMessages, keepRecentCount: 4);
|
||||||
|
|
||||||
return apiMessages;
|
return apiMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entfernt <think>...</think>-Blöcke aus älteren Assistant-Messages,
|
||||||
|
/// behält sie aber in den letzten <paramref name="keepRecentCount"/> Turns.
|
||||||
|
/// Reduziert Kontext-Inflation bei Thinking-Modellen (MiniMax-M2, DeepSeek-R1, etc.).
|
||||||
|
/// </summary>
|
||||||
|
private static void StripOldThinking(List<object> apiMessages, int keepRecentCount)
|
||||||
|
{
|
||||||
|
// Finde alle Indizes von Assistant-Messages mit <think>-Blöcken
|
||||||
|
var assistantIndices = new List<int>();
|
||||||
|
for (int i = 0; i < apiMessages.Count; i++)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(apiMessages[i]);
|
||||||
|
if (json.Contains("\"role\":\"assistant\"") && json.Contains("<think>"))
|
||||||
|
assistantIndices.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur die ältesten strippen, die neuesten keepRecentCount behalten
|
||||||
|
var stripCount = assistantIndices.Count - keepRecentCount;
|
||||||
|
if (stripCount <= 0) return;
|
||||||
|
|
||||||
|
for (int j = 0; j < stripCount; j++)
|
||||||
|
{
|
||||||
|
var idx = assistantIndices[j];
|
||||||
|
var json = JsonSerializer.Serialize(apiMessages[idx]);
|
||||||
|
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||||||
|
|
||||||
|
if (!el.TryGetProperty("content", out var contentProp) || contentProp.ValueKind != JsonValueKind.String)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var content = contentProp.GetString() ?? "";
|
||||||
|
// <think>...\n</think>\n\n entfernen (greedy innerhalb eines Blocks)
|
||||||
|
var stripped = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
content, @"<think>[\s\S]*?</think>\s*", "", System.Text.RegularExpressions.RegexOptions.None);
|
||||||
|
stripped = stripped.TrimStart();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(stripped))
|
||||||
|
{
|
||||||
|
// Kein sichtbarer Text übrig nach Strip → Message trotzdem behalten falls tool_calls dran sind
|
||||||
|
if (json.Contains("\"tool_calls\""))
|
||||||
|
stripped = "";
|
||||||
|
else
|
||||||
|
continue; // Komplett leer → überspringen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Message mit gestripptem Content erstellen
|
||||||
|
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
|
||||||
|
var newDict = new Dictionary<string, object>();
|
||||||
|
foreach (var kv in dict)
|
||||||
|
{
|
||||||
|
if (kv.Key == "content")
|
||||||
|
newDict["content"] = stripped;
|
||||||
|
else
|
||||||
|
newDict[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
apiMessages[idx] = newDict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
|
private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
|
||||||
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw);
|
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user