- claude-code compact 흐름을 참고해 AX에 session memory compact, microcompact, collapse/snip 단계를 추가하고 ContextCondenser를 단계별 결과 반환 구조로 확장함 - ChatConversation과 AX Agent 하단 컨텍스트 카드에 대화별 compact 누적 회수, 절감 토큰, session memory/microcompact/snip 집계를 반영함 - AGENTS.md에 개발 단계부터 최적화와 실행 속도를 우선 고려하는 작업 지침을 추가함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
@@ -2,14 +2,32 @@
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class ContextCompactionResult
|
||||
{
|
||||
public bool Changed { get; set; }
|
||||
public int BeforeTokens { get; set; }
|
||||
public int AfterTokens { get; set; }
|
||||
public bool SessionMemoryApplied { get; set; }
|
||||
public int SessionMemoryMergedMessages { get; set; }
|
||||
public int MicrocompactBoundaryCount { get; set; }
|
||||
public int MicrocompactSingleCount { get; set; }
|
||||
public int SnippedMessageCount { get; set; }
|
||||
public int CollapsedBoundaryCount { get; set; }
|
||||
public List<string> AppliedStages { get; } = new();
|
||||
public int SavedTokens => Math.Max(0, BeforeTokens - AfterTokens);
|
||||
public string StageSummary => AppliedStages.Count == 0 ? "없음" : string.Join(" -> ", AppliedStages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
|
||||
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
|
||||
///
|
||||
/// 3단계 압축 전략:
|
||||
/// 5단계 압축 전략:
|
||||
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
|
||||
/// 2단계 — microcompact: 오래된 실행 로그/도구 묶음을 경계 요약으로 치환 (LLM 호출 없음)
|
||||
/// 3단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
|
||||
/// 2단계 — session memory compact: 이전 경계/요약을 하나의 세션 메모로 통합
|
||||
/// 3단계 — microcompact: 오래된 실행 로그/도구 묶음을 경계 요약으로 치환 (LLM 호출 없음)
|
||||
/// 4단계 — collapse/snip: 긴 로그/도구 결과를 더 짧은 경계/앞뒤 일부로 절단
|
||||
/// 5단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
|
||||
/// </summary>
|
||||
public static class ContextCondenser
|
||||
{
|
||||
@@ -17,6 +35,8 @@ public static class ContextCondenser
|
||||
private const int MaxToolResultChars = 1500;
|
||||
private const int MicrocompactSingleKeepChars = 480;
|
||||
private const int MicrocompactGroupMinCount = 2;
|
||||
private const int SnipKeepHeadChars = 220;
|
||||
private const int SnipKeepTailChars = 140;
|
||||
|
||||
/// <summary>요약 시 유지할 최근 메시지 수</summary>
|
||||
private const int RecentKeepCount = 6;
|
||||
@@ -51,8 +71,22 @@ public static class ContextCondenser
|
||||
bool force = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (messages.Count < 6) return false;
|
||||
if (!force && !proactiveEnabled) return false;
|
||||
var result = await CondenseWithStatsAsync(messages, llm, maxOutputTokens, proactiveEnabled, triggerPercent, force, ct);
|
||||
return result.Changed;
|
||||
}
|
||||
|
||||
public static async Task<ContextCompactionResult> CondenseWithStatsAsync(
|
||||
List<ChatMessage> messages,
|
||||
LlmService llm,
|
||||
int maxOutputTokens,
|
||||
bool proactiveEnabled = true,
|
||||
int triggerPercent = 80,
|
||||
bool force = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var result = new ContextCompactionResult();
|
||||
if (messages.Count < 6) return result;
|
||||
if (!force && !proactiveEnabled) return result;
|
||||
|
||||
// 현재 모델의 입력 토큰 한도
|
||||
var settings = llm.GetCurrentModelInfo();
|
||||
@@ -62,34 +96,106 @@ public static class ContextCondenser
|
||||
var threshold = (int)(effectiveMax * (percent / 100.0)); // 설정 임계치에서 압축 시작
|
||||
|
||||
var currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold) return false;
|
||||
result.BeforeTokens = currentTokens;
|
||||
if (!force && currentTokens < threshold)
|
||||
{
|
||||
result.AfterTokens = currentTokens;
|
||||
return result;
|
||||
}
|
||||
var originalTokens = currentTokens;
|
||||
|
||||
bool didCompress = false;
|
||||
|
||||
// ── 1단계: 도구 결과 축약 (LLM 호출 없음, 즉시 실행) ──
|
||||
didCompress |= TruncateToolResults(messages);
|
||||
if (TruncateToolResults(messages))
|
||||
{
|
||||
didCompress = true;
|
||||
result.AppliedStages.Add("tool-result");
|
||||
}
|
||||
|
||||
// 1단계 후 다시 추정
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold) return didCompress;
|
||||
if (!force && currentTokens < threshold)
|
||||
{
|
||||
result.Changed = didCompress;
|
||||
result.AfterTokens = currentTokens;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 2단계: 오래된 실행/도구 묶음 경량 압축 ──
|
||||
didCompress |= MicrocompactOlderMessages(messages);
|
||||
// ── 2단계: 이전 경계/요약을 세션 메모로 통합 ──
|
||||
var sessionMemoryMergedMessages = SessionMemoryCompactOlderMessages(messages);
|
||||
if (sessionMemoryMergedMessages > 0)
|
||||
{
|
||||
didCompress = true;
|
||||
result.SessionMemoryApplied = true;
|
||||
result.SessionMemoryMergedMessages = sessionMemoryMergedMessages;
|
||||
result.AppliedStages.Add("session-memory");
|
||||
}
|
||||
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold) return didCompress;
|
||||
if (!force && currentTokens < threshold)
|
||||
{
|
||||
result.Changed = didCompress;
|
||||
result.AfterTokens = currentTokens;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 3단계: 이전 대화 LLM 요약 ──
|
||||
didCompress |= await SummarizeOldMessagesAsync(messages, llm, ct);
|
||||
// ── 3단계: 오래된 실행/도구 묶음 경량 압축 ──
|
||||
var (boundaryCount, singleCount) = MicrocompactOlderMessages(messages);
|
||||
if (boundaryCount > 0 || singleCount > 0)
|
||||
{
|
||||
didCompress = true;
|
||||
result.MicrocompactBoundaryCount = boundaryCount;
|
||||
result.MicrocompactSingleCount = singleCount;
|
||||
result.AppliedStages.Add("microcompact");
|
||||
}
|
||||
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold)
|
||||
{
|
||||
result.Changed = didCompress;
|
||||
result.AfterTokens = currentTokens;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 4단계: collapse/snip ──
|
||||
var (snippedCount, collapsedCount) = CollapseAndSnipOlderMessages(messages);
|
||||
if (snippedCount > 0 || collapsedCount > 0)
|
||||
{
|
||||
didCompress = true;
|
||||
result.SnippedMessageCount = snippedCount;
|
||||
result.CollapsedBoundaryCount = collapsedCount;
|
||||
result.AppliedStages.Add("snip");
|
||||
}
|
||||
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold)
|
||||
{
|
||||
result.Changed = didCompress;
|
||||
result.AfterTokens = currentTokens;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 5단계: 이전 대화 LLM 요약 ──
|
||||
if (await SummarizeOldMessagesAsync(messages, llm, ct))
|
||||
{
|
||||
didCompress = true;
|
||||
result.AppliedStages.Add("summary");
|
||||
}
|
||||
|
||||
if (didCompress)
|
||||
{
|
||||
var afterTokens = TokenEstimator.EstimateMessages(messages);
|
||||
LogService.Info($"Context Condenser: {originalTokens} → {afterTokens} 토큰 (절감 {originalTokens - afterTokens})");
|
||||
result.Changed = true;
|
||||
result.AfterTokens = afterTokens;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.AfterTokens = currentTokens;
|
||||
}
|
||||
|
||||
return didCompress;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -164,11 +270,11 @@ public static class ContextCondenser
|
||||
/// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다.
|
||||
/// claw-code의 microcompact처럼 LLM 호출 전에 토큰을 한 번 더 줄이는 단계입니다.
|
||||
/// </summary>
|
||||
private static bool MicrocompactOlderMessages(List<ChatMessage> messages)
|
||||
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
if (nonSystemMessages.Count <= RecentKeepCount + 2) return false;
|
||||
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
@@ -176,6 +282,8 @@ public static class ContextCondenser
|
||||
|
||||
var rewritten = new List<ChatMessage>(oldMessages.Count);
|
||||
bool changed = false;
|
||||
int boundaryCount = 0;
|
||||
int singleCount = 0;
|
||||
|
||||
for (int i = 0; i < oldMessages.Count;)
|
||||
{
|
||||
@@ -194,12 +302,14 @@ public static class ContextCondenser
|
||||
{
|
||||
rewritten.Add(BuildMicrocompactBoundary(group));
|
||||
changed = true;
|
||||
boundaryCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var singleChanged = TryMicrocompactSingle(group[0], out var compacted);
|
||||
rewritten.Add(singleChanged ? compacted : group[0]);
|
||||
changed |= singleChanged;
|
||||
if (singleChanged) singleCount++;
|
||||
}
|
||||
|
||||
i = j;
|
||||
@@ -210,6 +320,7 @@ public static class ContextCondenser
|
||||
{
|
||||
rewritten.Add(singleCompacted);
|
||||
changed = true;
|
||||
singleCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -219,12 +330,204 @@ public static class ContextCondenser
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!changed) return false;
|
||||
if (!changed) return (0, 0);
|
||||
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
messages.AddRange(rewritten);
|
||||
messages.AddRange(recentMessages);
|
||||
return (boundaryCount, singleCount);
|
||||
}
|
||||
|
||||
private static int SessionMemoryCompactOlderMessages(List<ChatMessage> messages)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
if (nonSystemMessages.Count <= RecentKeepCount + 3) return 0;
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
|
||||
var candidates = oldMessages
|
||||
.Select((message, index) => new { message, index })
|
||||
.Where(x => IsSessionMemoryCandidate(x.message))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count < 2) return 0;
|
||||
|
||||
var attachedFiles = candidates
|
||||
.SelectMany(x => x.message.AttachedFiles ?? Enumerable.Empty<string>())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path =>
|
||||
{
|
||||
try { return System.IO.Path.GetFileName(path); }
|
||||
catch { return path; }
|
||||
})
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
var merged = new ChatMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Timestamp = candidates.Last().message.Timestamp,
|
||||
MetaKind = "session_memory_compaction",
|
||||
MetaRunId = candidates.Last().message.MetaRunId,
|
||||
AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null,
|
||||
Content = string.Join("\n", new[]
|
||||
{
|
||||
$"[세션 메모리 압축 - {candidates.Count}개 이전 경계 통합]",
|
||||
"- 이전 요약, 실행 경계, 오래된 메타 로그를 하나의 세션 메모로 합쳤습니다.",
|
||||
attachedFiles.Count > 0 ? $"- 관련 파일: {string.Join(", ", attachedFiles)}" : "- 관련 파일: 없음",
|
||||
})
|
||||
};
|
||||
|
||||
var rewritten = new List<ChatMessage>(oldMessages.Count - candidates.Count + 1);
|
||||
bool inserted = false;
|
||||
for (int i = 0; i < oldMessages.Count; i++)
|
||||
{
|
||||
if (candidates.Any(x => x.index == i))
|
||||
{
|
||||
if (!inserted)
|
||||
{
|
||||
rewritten.Add(merged);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rewritten.Add(oldMessages[i]);
|
||||
}
|
||||
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
messages.AddRange(rewritten);
|
||||
messages.AddRange(recentMessages);
|
||||
return candidates.Count;
|
||||
}
|
||||
|
||||
private static bool IsSessionMemoryCandidate(ChatMessage message)
|
||||
{
|
||||
var content = message.Content ?? "";
|
||||
if (string.IsNullOrWhiteSpace(content)) return false;
|
||||
|
||||
return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|
||||
|| content.StartsWith("[이전 대화 요약", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[이전 도구 결과 축약", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[이전 도구 호출 묶음 축약", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[이전 실행 메타 축약", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static (int SnippedCount, int CollapsedCount) CollapseAndSnipOlderMessages(List<ChatMessage> messages)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
|
||||
var rewritten = new List<ChatMessage>(oldMessages.Count);
|
||||
int snippedCount = 0;
|
||||
int collapsedCount = 0;
|
||||
|
||||
for (int i = 0; i < oldMessages.Count;)
|
||||
{
|
||||
var current = oldMessages[i];
|
||||
if (IsBoundaryLike(current))
|
||||
{
|
||||
var group = new List<ChatMessage> { current };
|
||||
int j = i + 1;
|
||||
while (j < oldMessages.Count && IsBoundaryLike(oldMessages[j]))
|
||||
{
|
||||
group.Add(oldMessages[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
if (group.Count >= 2)
|
||||
{
|
||||
rewritten.Add(new ChatMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Timestamp = group.Last().Timestamp,
|
||||
MetaKind = "collapsed_boundary",
|
||||
MetaRunId = group.Last().MetaRunId,
|
||||
Content = $"[이전 압축 경계 병합 - {group.Count}개]\n- 이전 compact/snippet 경계를 하나로 합쳤습니다."
|
||||
});
|
||||
collapsedCount += group.Count;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (TrySnipMessage(current, out var snipped))
|
||||
{
|
||||
rewritten.Add(snipped);
|
||||
snippedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
rewritten.Add(current);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (snippedCount == 0 && collapsedCount == 0) return (0, 0);
|
||||
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
messages.AddRange(rewritten);
|
||||
messages.AddRange(recentMessages);
|
||||
return (snippedCount, collapsedCount);
|
||||
}
|
||||
|
||||
private static bool IsBoundaryLike(ChatMessage message)
|
||||
{
|
||||
var content = message.Content ?? "";
|
||||
return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|
||||
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal)
|
||||
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool TrySnipMessage(ChatMessage source, out ChatMessage snipped)
|
||||
{
|
||||
snipped = source;
|
||||
var content = source.Content ?? "";
|
||||
if (string.IsNullOrWhiteSpace(content)) return false;
|
||||
if (source.Role == "system") return false;
|
||||
if (content.Length < 900 && content.Split('\n').Length < 12) return false;
|
||||
|
||||
var normalized = content.Replace("\r\n", "\n");
|
||||
var lines = normalized
|
||||
.Split('\n')
|
||||
.Select(line => line.TrimEnd())
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
|
||||
if (lines.Count >= 6)
|
||||
{
|
||||
var headLines = lines.Take(3);
|
||||
var tailLines = lines.TakeLast(2);
|
||||
snipped = CloneWithContent(
|
||||
source,
|
||||
string.Join("\n", headLines) +
|
||||
"\n...[snip: 중간 실행 로그/출력 축약]...\n" +
|
||||
string.Join("\n", tailLines));
|
||||
return true;
|
||||
}
|
||||
|
||||
var head = normalized[..Math.Min(SnipKeepHeadChars, normalized.Length)];
|
||||
var tail = normalized.Length > SnipKeepTailChars ? normalized[^SnipKeepTailChars..] : "";
|
||||
snipped = CloneWithContent(
|
||||
source,
|
||||
head + "\n...[snip: 중간 내용 축약]...\n" + tail);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user