Kompaktierung: kalibriert pruefen, mit Abstand kompaktieren, Zusammenfassung verbuchen (#605)

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>
This commit is contained in:
Richard
2026-09-15 13:37:41 +02:00
co-authored by Claude Opus 5
parent 7dbcc44226
commit 779077a347
4 changed files with 166 additions and 12 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe. gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
--> -->
<Version>0.1.7</Version> <Version>0.1.8</Version>
<!-- <!--
Symbole in die Assembly einbetten statt als .pdb daneben zu legen. Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
+2 -2
View File
@@ -227,7 +227,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Kompaktieren vor dem Aufruf — dieselbe Reihenfolge wie in ChatCoreAsync. // Kompaktieren vor dem Aufruf — dieselbe Reihenfolge wie in ChatCoreAsync.
// Verhindert die Token-Explosion bei komplexen Analysen, und zwar bevor // Verhindert die Token-Explosion bei komplexen Analysen, und zwar bevor
// sie bezahlt ist statt danach. // sie bezahlt ist statt danach.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct)) if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
{ {
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)"); OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
} }
@@ -555,7 +555,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// den promptTokens der gerade bezahlten Anfrage: Der überfüllte Prompt // den promptTokens der gerade bezahlten Anfrage: Der überfüllte Prompt
// war zu dem Zeitpunkt längst abgerechnet, gekürzt wurde erst für den // war zu dem Zeitpunkt längst abgerechnet, gekürzt wurde erst für den
// nächsten Schritt. Genau ein Schritt zu spät, und der teuerste. // nächsten Schritt. Genau ein Schritt zu spät, und der teuerste.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct)) if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
{ {
PersistChatState(agentConfig.AgentId); PersistChatState(agentConfig.AgentId);
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert"); OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
@@ -11,6 +11,17 @@ public sealed class ContextCompactor
private readonly ILogger _logger; private readonly ILogger _logger;
private const int ProtectedTailMessages = 6; 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 int MaxToolResultChars = 2000;
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]"; private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
@@ -95,11 +106,16 @@ public sealed class ContextCompactor
(_, bisher) => Naechster(bisher, 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( public async Task<bool> CompactIfNeededAsync(
List<ChatMessage> messages, List<ChatMessage> messages,
LoopGuardConfig guard, LoopGuardConfig guard,
string model, string model,
CancellationToken ct) CancellationToken ct,
Action<Usage>? verbrauch = null)
{ {
var contextTokens = EstimateFor(model, messages); var contextTokens = EstimateFor(model, messages);
@@ -108,19 +124,24 @@ public sealed class ContextCompactor
if (contextTokens < threshold) if (contextTokens < threshold)
return false; return false;
var ziel = Math.Min(threshold, (int)(guard.MaxContextTokens * ZielAnteil));
_logger.LogInformation( _logger.LogInformation(
"Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold})", "Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold}, Ziel: {Ziel})",
contextTokens, threshold); contextTokens, threshold, ziel);
// Stufe 1: Tool-Results kürzen // Stufe 1: Tool-Results kürzen
var pruned = PruneToolResults(messages); var pruned = PruneToolResults(messages);
if (pruned) if (pruned)
{ {
var afterPrune = EstimateTokens(messages); // #605: Mit derselben, kalibrierten Schaetzung pruefen, die ausgeloest hat. Die
_logger.LogInformation("Stufe 1 (Tool-Pruning): {Before} → {After} geschätzte Tokens", // rohe Zahl (Zeichen/4) lag bei deutschem Text und JSON deutlich zu niedrig,
contextTokens, afterPrune); // 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 < threshold) if (afterPrune < ziel)
return true; return true;
} }
@@ -129,7 +150,22 @@ public sealed class ContextCompactor
? model ? model
: guard.SummaryModel; : guard.SummaryModel;
await CompactViaLlmAsync(messages, summaryModel, ct); 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; return true;
} }
@@ -175,7 +211,7 @@ public sealed class ContextCompactor
} }
private async Task CompactViaLlmAsync( private async Task CompactViaLlmAsync(
List<ChatMessage> messages, string model, CancellationToken ct) List<ChatMessage> messages, string model, CancellationToken ct, Action<Usage>? verbrauch)
{ {
var systemMsg = messages.FirstOrDefault(m => m.Role == "system"); var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
@@ -230,6 +266,11 @@ public sealed class ContextCompactor
try try
{ {
var response = await _client.CompleteAsync(summaryRequest, ct); 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; var summary = response.Choices.FirstOrDefault()?.Message?.Content;
if (string.IsNullOrWhiteSpace(summary)) if (string.IsNullOrWhiteSpace(summary))
@@ -0,0 +1,113 @@
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Engine;
/// <summary>
/// #605: Im Dauerbetrieb lief die Kompaktierung bei jedem Schritt an und blieb wirkungslos -
/// Stufe 1 pruefte mit der rohen statt der kalibrierten Schaetzung, kompaktierte nur knapp
/// unter die Schwelle, und der Zusammenfassungs-Aufruf wurde nicht verbucht.
/// </summary>
public sealed class KompaktierungAbstandTests
{
private const string Model = "test/model";
private static (ContextCompactor Compactor, FakeChatClient Client) Erzeuge()
{
var client = new FakeChatClient().AlwaysRespondsWithText("- Der Agent hat Daten geprueft.");
return (new ContextCompactor(client, TestLogging.Factory), client);
}
/// <summary>
/// Die rohe Schaetzung, wie sie nach Stufe 1 aussaehe - nachgebaut, um Schwellen zu
/// waehlen, an denen alte und neue Pruefung verschieden entscheiden. Werte wie im
/// ContextCompactor: die letzten 6 Nachrichten geschuetzt, Tool-Ergebnisse auf 2.000 Zeichen.
/// </summary>
private static int RohNachStufe1(List<ChatMessage> messages)
{
var kopie = messages
.Select(m => new ChatMessage { Role = m.Role, Content = m.Content, ToolCallId = m.ToolCallId, ToolCalls = m.ToolCalls })
.ToList();
var schutz = Math.Max(0, kopie.Count - 6);
for (var i = 0; i < schutz; i++)
{
if (kopie[i].Role == "tool" && kopie[i].Content is { Length: > 2_000 } inhalt)
kopie[i].Content = inhalt[..2_000] + "\n\n[... Ergebnis gekürzt ...]";
}
return ContextCompactor.EstimateTokens(kopie);
}
private static List<ChatMessage> LangerVerlauf()
=> Conversation.Start().Repeat(times: 6, toolCallsPerCycle: 1, resultLength: 8_000).Build();
[Fact]
public async Task Nach_Stufe_1_entscheidet_die_kalibrierte_Schaetzung()
{
var (compactor, client) = Erzeuge();
// Dieses Modell zaehlt rund dreimal so viele Tokens, wie Zeichen/4 vermuten laesst.
for (var i = 0; i < 12; i++)
compactor.Calibrate(Model, 1_000, 3_000);
var messages = LangerVerlauf();
var rohDanach = RohNachStufe1(messages);
// Roh liegt Stufe 1 unter der Schwelle, kalibriert deutlich darueber.
var schwelle = (int)(rohDanach * 1.5);
var guard = new LoopGuardConfig { MaxContextTokens = schwelle * 2, CompactionThreshold = 0.5 };
await compactor.CompactIfNeededAsync(messages, guard, Model, default);
client.CallCount.ShouldBe(1, "die rohe Zahl meldete faelschlich 'unter der Schwelle' und Stufe 2 lief nie");
ContextInvariants.AssertValid(messages);
}
[Fact]
public async Task Knapp_unter_der_Schwelle_reicht_nicht_es_wird_mit_Abstand_kompaktiert()
{
var (compactor, client) = Erzeuge();
var messages = LangerVerlauf();
var rohDanach = RohNachStufe1(messages);
// Stufe 1 bringt knapp unter die Schwelle (90 %), aber nicht unter das Ziel (55 %).
var schwelle = (int)(rohDanach * 1.2);
var guard = new LoopGuardConfig { MaxContextTokens = (int)(schwelle / 0.9), CompactionThreshold = 0.9 };
await compactor.CompactIfNeededAsync(messages, guard, Model, default);
client.CallCount.ShouldBe(1, "knapp unter der Schwelle waere beim naechsten Schritt wieder darueber");
ContextInvariants.AssertValid(messages);
}
[Fact]
public async Task Der_Zusammenfassungs_Aufruf_wird_verbucht()
{
var (compactor, _) = Erzeuge();
var gebucht = new List<Usage>();
var messages = Conversation.Start().Repeat(15).Build();
await compactor.CompactIfNeededAsync(
messages, new LoopGuardConfig { MaxContextTokens = 1_000, CompactionThreshold = 0.5 }, Model, default, gebucht.Add);
gebucht.ShouldHaveSingleItem().PromptTokens.ShouldBe(100);
}
[Fact]
public async Task Unter_der_Schwelle_wird_nichts_verbucht()
{
var (compactor, client) = Erzeuge();
var gebucht = new List<Usage>();
await compactor.CompactIfNeededAsync(
Conversation.Start().Repeat(2).Build(),
new LoopGuardConfig { MaxContextTokens = 10_000_000, CompactionThreshold = 0.9 }, Model, default, gebucht.Add);
gebucht.ShouldBeEmpty();
client.CallCount.ShouldBe(0);
}
}