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:
MelekiandClaude Opus 4.6 committed 2026-05-14 12:16:55 +02:00
1 parent 0f2f026baa
commit 358d9328be
3 files changed
+94 -11

No files matched your search

+1 -1
View File
@@ -240,7 +240,7 @@ public static class ChatEndpoint
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
{
+5 -1
View File
@@ -347,7 +347,11 @@ public static class SessionEndpoint
}
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")
{
+88 -9
View File
@@ -370,6 +370,9 @@ public class OpenAiService
var client = CreateClient(providerConfig.ApiKey);
// MiniMax-spezifisch: höheres Output-Budget (Thinking-Modelle brauchen viel)
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>
{
["model"] = modelId,
@@ -378,20 +381,23 @@ public class OpenAiService
["max_tokens"] = maxTokens,
["stream_options"] = new { include_usage = true },
["tools"] = _openAiToolDefs,
["tool_choice"] = "auto"
["tool_choice"] = toolChoice
};
if (provider == "nvidia" && IsNvidiaThinkingModel(modelId))
payload["chat_template_kwargs"] = new { thinking = true };
// MiniMax-spezifisch: reasoning_split → Thinking landet in delta.reasoning_details
// statt als <think>...</think> inline in delta.content (siehe MiniMax-OpenAI-Compat-Docs).
// Sampling-Parameter: temperature=1.0, top_p=0.95 (MiniMax-Empfehlung für Agentic Performance).
// MiniMax-spezifisch:
// - 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")
{
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);
@@ -857,8 +863,9 @@ public class OpenAiService
if (!string.IsNullOrEmpty(continueContent))
apiMessages.Add(new { role = "assistant", content = continueContent });
// Synthetische User-Message injizieren
apiMessages.Add(new { role = "user", content = "Continue." });
// Synthetische User-Message injizieren – direktiv, nicht nur "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
await SseHelper.TryWriteSse(response, "auto_continue", new
@@ -885,7 +892,10 @@ public class OpenAiService
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
var toolCallsForHistory = new List<object>();
foreach (var (_, (tcId, tcName, tcArgs, tcTs)) in toolCallAccumulators)
@@ -1150,7 +1160,11 @@ public class OpenAiService
payload["chat_template_kwargs"] = new { thinking = true };
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 httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
@@ -1513,9 +1527,74 @@ public class OpenAiService
}
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;
}
/// <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)
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw);