Im Dauerbetrieb lief die Kompaktierung bei jedem Schritt an und blieb wirkungslos: Stufe 1 pruefte mit der rohen Schaetzung, kompaktierte nur knapp unter die Schwelle und brach dabei jedes Mal den Prompt-Cache; der Zusammenfassungs-Aufruf fehlte im Verbrauch. Ziel jetzt min(Schwelle, 55 % des Limits), Usage des Aufrufs geht in den Lauf. Version 0.1.8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
13 KiB
C#
332 lines
13 KiB
C#
using ClawdDotNet.Core.Api;
|
|
using ClawdDotNet.Core.Api.Models;
|
|
using ClawdDotNet.Core.Config;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.Core.Engine;
|
|
|
|
public sealed class ContextCompactor
|
|
{
|
|
private readonly IChatCompletionClient _client;
|
|
private readonly ILogger _logger;
|
|
|
|
private const int ProtectedTailMessages = 6;
|
|
|
|
/// <summary>
|
|
/// Wohin kompaktiert wird, als Anteil von <see cref="LoopGuardConfig.MaxContextTokens"/> -
|
|
/// nie hoeher als die Schwelle selbst (#605).
|
|
///
|
|
/// <para>Vorher reichte "knapp unter der Schwelle". Im Dauerbetrieb kuerzte Stufe 1 dann
|
|
/// bei jedem Schritt ein wenig, der Kontext lag sofort wieder darueber, und weil jede
|
|
/// Kuerzung alte Nachrichten aendert, brach dabei jedes Mal der Prompt-Cache. Mit Abstand
|
|
/// wird selten und kraeftig kompaktiert, und der Cache haelt danach mehrere Laeufe.</para>
|
|
/// </summary>
|
|
internal const double ZielAnteil = 0.55;
|
|
private const int MaxToolResultChars = 2000;
|
|
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
|
|
|
|
/// <summary>
|
|
/// Wie stark ein einzelner Messwert den Faktor verschiebt. Klein genug, dass ein
|
|
/// Ausreißer die Schwelle nicht kippt, groß genug, dass ein Modellwechsel nach
|
|
/// wenigen Aufrufen ankommt.
|
|
/// </summary>
|
|
private const double CalibrationWeight = 0.25;
|
|
|
|
/// <summary>
|
|
/// Je Modell: um welchen Faktor die rohe Schätzung danebenlag. 1,0 heißt „noch nichts
|
|
/// gemessen". Modellweise, weil verschiedene Tokenizer denselben Text verschieden
|
|
/// zerlegen — ein gemeinsamer Faktor wäre der Mittelwert von etwas, das nichts
|
|
/// miteinander zu tun hat.
|
|
/// </summary>
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, double> _calibration = new();
|
|
|
|
public ContextCompactor(IChatCompletionClient client, ILoggerFactory loggerFactory)
|
|
{
|
|
_client = client;
|
|
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die rohe Schätzung: vier Zeichen je Token. Grob, aber ohne Netzaufruf und ohne
|
|
/// Kenntnis des Tokenizers — und darauf kommt es an, weil sie <b>vor</b> dem Senden
|
|
/// gebraucht wird. <see cref="EstimateFor"/> zieht die gemessene Abweichung nach.
|
|
/// </summary>
|
|
public static int EstimateTokens(List<ChatMessage> messages)
|
|
{
|
|
var totalChars = 0;
|
|
foreach (var msg in messages)
|
|
{
|
|
totalChars += msg.Content?.Length ?? 0;
|
|
totalChars += msg.Role.Length + 10;
|
|
|
|
if (msg.ToolCalls is not null)
|
|
{
|
|
foreach (var tc in msg.ToolCalls)
|
|
totalChars += tc.Function.Name.Length + tc.Function.Arguments.Length + 20;
|
|
}
|
|
}
|
|
return totalChars / 4;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die Schätzung für ein bestimmtes Modell — die rohe Rechnung, korrigiert um das,
|
|
/// was bei diesem Modell bisher tatsächlich gemessen wurde.
|
|
/// </summary>
|
|
public int EstimateFor(string model, List<ChatMessage> messages)
|
|
=> (int)(EstimateTokens(messages) * _calibration.GetValueOrDefault(model, 1.0));
|
|
|
|
/// <summary>
|
|
/// Meldet, wie groß der Prompt wirklich war, den eine Schätzung vorhergesagt hat.
|
|
///
|
|
/// <para>Vier Zeichen je Token stimmt für englische Fließtexte ungefähr und liegt
|
|
/// bei deutschem Text, JSON und Code daneben — je nach Modell in beide Richtungen.
|
|
/// Der Faktor zieht die Schätzung dorthin, wo dieses Modell tatsächlich liegt.</para>
|
|
///
|
|
/// <para>Gleitender Mittelwert statt letztem Messwert: Ein einzelner Ausreißer soll
|
|
/// die Schwelle nicht verschieben. Begrenzt auf das Halbe bis Vierfache — was
|
|
/// darüber hinausginge, ist kein Kalibrierungsfall mehr, sondern ein Fehler
|
|
/// woanders, und den soll die Schätzung nicht kaschieren.</para>
|
|
/// </summary>
|
|
public void Calibrate(string model, int estimatedTokens, int actualPromptTokens)
|
|
{
|
|
if (estimatedTokens <= 0 || actualPromptTokens <= 0) return;
|
|
|
|
var gemessen = Math.Clamp((double)actualPromptTokens / estimatedTokens, 0.5, 4.0);
|
|
|
|
// Auch der erste Messwert zählt nur mit seinem Gewicht — Ausgangspunkt ist die
|
|
// rohe Schätzung (Faktor 1,0). Setzte der erste Wert den Faktor voll, könnte ein
|
|
// einzelner Ausreißer die Schwelle sofort verschieben, und genau das soll der
|
|
// gleitende Mittelwert verhindern.
|
|
static double Naechster(double bisher, double gemessen)
|
|
=> bisher * (1 - CalibrationWeight) + gemessen * CalibrationWeight;
|
|
|
|
_calibration.AddOrUpdate(
|
|
model,
|
|
Naechster(1.0, gemessen),
|
|
(_, bisher) => Naechster(bisher, gemessen));
|
|
}
|
|
|
|
/// <param name="verbrauch">
|
|
/// Erhaelt den Verbrauch des Zusammenfassungs-Aufrufs (#605). Die Engine zaehlt ihn zum
|
|
/// Lauf - sonst waere er in Verbrauch, Beleg und Tagesbudget unsichtbar.
|
|
/// </param>
|
|
public async Task<bool> CompactIfNeededAsync(
|
|
List<ChatMessage> messages,
|
|
LoopGuardConfig guard,
|
|
string model,
|
|
CancellationToken ct,
|
|
Action<Usage>? verbrauch = null)
|
|
{
|
|
var contextTokens = EstimateFor(model, messages);
|
|
|
|
var threshold = (int)(guard.MaxContextTokens * guard.CompactionThreshold);
|
|
|
|
if (contextTokens < threshold)
|
|
return false;
|
|
|
|
var ziel = Math.Min(threshold, (int)(guard.MaxContextTokens * ZielAnteil));
|
|
|
|
_logger.LogInformation(
|
|
"Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold}, Ziel: {Ziel})",
|
|
contextTokens, threshold, ziel);
|
|
|
|
// Stufe 1: Tool-Results kürzen
|
|
var pruned = PruneToolResults(messages);
|
|
if (pruned)
|
|
{
|
|
// #605: Mit derselben, kalibrierten Schaetzung pruefen, die ausgeloest hat. Die
|
|
// rohe Zahl (Zeichen/4) lag bei deutschem Text und JSON deutlich zu niedrig,
|
|
// meldete "unter der Schwelle", und Stufe 2 lief nie.
|
|
var afterPrune = EstimateFor(model, messages);
|
|
_logger.LogInformation("Stufe 1 (Tool-Pruning): {Before} → {After} geschätzte Tokens (Ziel: {Ziel})",
|
|
contextTokens, afterPrune, ziel);
|
|
|
|
if (afterPrune < ziel)
|
|
return true;
|
|
}
|
|
|
|
// Stufe 2: Auto-Compaction via LLM — bewusst mit dem günstigen Modell.
|
|
var summaryModel = string.IsNullOrWhiteSpace(guard.SummaryModel)
|
|
? model
|
|
: guard.SummaryModel;
|
|
|
|
await CompactViaLlmAsync(messages, summaryModel, ct, verbrauch);
|
|
|
|
var danach = EstimateFor(model, messages);
|
|
if (danach >= threshold)
|
|
{
|
|
_logger.LogWarning(
|
|
"Kompaktierung bleibt über der Schwelle: {After} geschätzte Tokens (Schwelle: {Threshold}). "
|
|
+ "Das geschützte Ende der letzten {Tail} Nachrichten ist allein zu groß.",
|
|
danach, threshold, ProtectedTailMessages);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("Kompaktierung abgeschlossen: {Before} → {After} geschätzte Tokens (Ziel: {Ziel})",
|
|
contextTokens, danach, ziel);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool PruneToolResults(List<ChatMessage> messages)
|
|
{
|
|
var pruned = false;
|
|
var protectedStart = Math.Max(0, messages.Count - ProtectedTailMessages);
|
|
|
|
for (var i = 0; i < protectedStart; i++)
|
|
{
|
|
var msg = messages[i];
|
|
if (msg.Role != "tool" || msg.Content is null)
|
|
continue;
|
|
|
|
if (msg.Content.Length <= MaxToolResultChars)
|
|
continue;
|
|
|
|
msg.Content = msg.Content[..MaxToolResultChars] + TruncatedMarker;
|
|
pruned = true;
|
|
}
|
|
|
|
return pruned;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sucht einen Index, an dem der Kontext geteilt werden darf, ohne eine Tool-Sequenz
|
|
/// zu zerreißen.
|
|
///
|
|
/// Eine <c>tool</c>-Nachricht darf nie am Anfang des behaltenen Teils stehen — ihr
|
|
/// <c>assistant</c>-Aufruf stünde sonst nicht mehr davor und die API lehnt die
|
|
/// gesamte Anfrage mit HTTP 400 ab. Deshalb wandert die Grenze rückwärts bis zum
|
|
/// Beginn des Blocks, in den sie fällt.
|
|
/// </summary>
|
|
private static int FindSafeTailStart(List<ChatMessage> messages, int desiredStart, int minIndex)
|
|
{
|
|
var i = Math.Clamp(desiredStart, minIndex, messages.Count);
|
|
|
|
// Rückwärts, bis der Anfang keine verwaiste Tool-Antwort mehr ist.
|
|
while (i > minIndex && i < messages.Count && messages[i].Role == "tool")
|
|
i--;
|
|
|
|
return i;
|
|
}
|
|
|
|
private async Task CompactViaLlmAsync(
|
|
List<ChatMessage> messages, string model, CancellationToken ct, Action<Usage>? verbrauch)
|
|
{
|
|
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
|
|
|
// Der System-Prompt wird separat behandelt und darf nie Teil des Tails werden,
|
|
// sonst landet er doppelt im Kontext.
|
|
var firstNonSystem = systemMsg is null ? 0 : messages.IndexOf(systemMsg) + 1;
|
|
|
|
var tailStart = FindSafeTailStart(
|
|
messages,
|
|
messages.Count - ProtectedTailMessages,
|
|
firstNonSystem);
|
|
|
|
// Wenn vor dem geschützten Ende nichts Nennenswertes liegt, gibt es nichts
|
|
// zusammenzufassen — eine Kompaktierung würde den Kontext sogar vergrößern.
|
|
if (tailStart - firstNonSystem < 2)
|
|
{
|
|
_logger.LogInformation(
|
|
"Compaction übersprungen: nur {Count} Nachricht(en) vor dem geschützten Ende",
|
|
tailStart - firstNonSystem);
|
|
return;
|
|
}
|
|
|
|
// Nur den Teil zusammenfassen, der tatsächlich wegfällt — der Tail bleibt
|
|
// ohnehin wörtlich erhalten und müsste sonst doppelt bezahlt werden.
|
|
var conversationParts = messages
|
|
.Take(tailStart)
|
|
.Skip(firstNonSystem)
|
|
.Select(FormatMessageForSummary);
|
|
|
|
var conversationText = string.Join("\n", conversationParts);
|
|
|
|
// Auf max 30k Zeichen begrenzen für den Summarization-Call
|
|
if (conversationText.Length > 30_000)
|
|
conversationText = conversationText[..30_000] + "\n[... weitere Nachrichten ausgelassen ...]";
|
|
|
|
var summaryRequest = new ChatRequest
|
|
{
|
|
Model = model,
|
|
Messages =
|
|
[
|
|
ChatMessage.System(
|
|
"Du bist ein Konversations-Zusammenfasser. Erstelle eine präzise Zusammenfassung " +
|
|
"der bisherigen Konversation. Behalte alle wichtigen Fakten, Entscheidungen, " +
|
|
"Ergebnisse von Tool-Aufrufen und den aktuellen Arbeitsstand bei. " +
|
|
"Schreibe in der dritten Person. Format: Strukturierte Stichpunkte."),
|
|
ChatMessage.User(
|
|
"Fasse die folgende Konversation zusammen. Behalte alle wichtigen Details:\n\n" +
|
|
conversationText)
|
|
]
|
|
};
|
|
|
|
try
|
|
{
|
|
var response = await _client.CompleteAsync(summaryRequest, ct);
|
|
|
|
// Bezahlt ist der Aufruf auch dann, wenn die Zusammenfassung unbrauchbar ist.
|
|
if (response.Usage is { } usage)
|
|
verbrauch?.Invoke(usage);
|
|
|
|
var summary = response.Choices.FirstOrDefault()?.Message?.Content;
|
|
|
|
if (string.IsNullOrWhiteSpace(summary))
|
|
{
|
|
_logger.LogWarning("Compaction: Keine Zusammenfassung erhalten");
|
|
return;
|
|
}
|
|
|
|
// Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten.
|
|
// tailStart liegt garantiert auf einer Blockgrenze und hinter dem System-Prompt.
|
|
var tail = messages.Skip(tailStart).ToList();
|
|
|
|
messages.Clear();
|
|
|
|
if (systemMsg is not null)
|
|
messages.Add(systemMsg);
|
|
|
|
messages.Add(ChatMessage.User(
|
|
"[Zusammenfassung der bisherigen Konversation]\n\n" + summary));
|
|
messages.Add(ChatMessage.Assistant(
|
|
"Verstanden. Ich habe den Kontext der bisherigen Konversation erfasst und arbeite weiter."));
|
|
|
|
messages.AddRange(tail);
|
|
|
|
_logger.LogInformation(
|
|
"Stufe 2 (Auto-Compaction): Konversation auf {Count} Nachrichten kompaktiert",
|
|
messages.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Compaction fehlgeschlagen");
|
|
}
|
|
}
|
|
|
|
private static string FormatMessageForSummary(ChatMessage msg)
|
|
{
|
|
if (msg.Role == "tool")
|
|
{
|
|
var preview = msg.Content?.Length > 200
|
|
? msg.Content[..200] + "..."
|
|
: msg.Content;
|
|
return $"[Tool-Result ({msg.ToolCallId})]: {preview}";
|
|
}
|
|
|
|
if (msg.ToolCalls is { Count: > 0 })
|
|
{
|
|
var calls = string.Join(", ",
|
|
msg.ToolCalls.Select(tc => $"{tc.Function.Name}({tc.Function.Arguments[..Math.Min(100, tc.Function.Arguments.Length)]})"));
|
|
return $"[Assistant → Tool-Calls]: {calls}";
|
|
}
|
|
|
|
var content = msg.Content?.Length > 500
|
|
? msg.Content[..500] + "..."
|
|
: msg.Content;
|
|
|
|
return $"[{msg.Role}]: {content}";
|
|
}
|
|
}
|