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:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user