컨텍스트 압축 3단계 이식과 microcompact 경계 요약 보강
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- claude-code compact 흐름을 참고해 AX ContextCondenser를 도구 결과 축약 -> microcompact -> 이전 대화 요약 3단계로 확장함 - 오래된 실행 로그, tool_result, 메타 이벤트, 과도하게 긴 메시지를 microcompact_boundary로 먼저 압축해 LLM 요약 전 토큰을 덜어내도록 보강함 - README 및 개발 문서/로드맵에 2026-04-04 23:32 (KST) 기준 이력을 반영함 - 검증: 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:
@@ -6,14 +6,17 @@ namespace AxCopilot.Services.Agent;
|
||||
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
|
||||
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
|
||||
///
|
||||
/// 2단계 압축 전략:
|
||||
/// 3단계 압축 전략:
|
||||
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
|
||||
/// 2단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
|
||||
/// 2단계 — microcompact: 오래된 실행 로그/도구 묶음을 경계 요약으로 치환 (LLM 호출 없음)
|
||||
/// 3단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
|
||||
/// </summary>
|
||||
public static class ContextCondenser
|
||||
{
|
||||
/// <summary>도구 결과 1개당 최대 유지 길이 (자)</summary>
|
||||
private const int MaxToolResultChars = 1500;
|
||||
private const int MicrocompactSingleKeepChars = 480;
|
||||
private const int MicrocompactGroupMinCount = 2;
|
||||
|
||||
/// <summary>요약 시 유지할 최근 메시지 수</summary>
|
||||
private const int RecentKeepCount = 6;
|
||||
@@ -36,7 +39,8 @@ public static class ContextCondenser
|
||||
/// <summary>
|
||||
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
|
||||
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
|
||||
/// 2단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면)
|
||||
/// 2단계: microcompact 경량 압축 (토큰이 여전히 높으면)
|
||||
/// 3단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면)
|
||||
/// </summary>
|
||||
public static async Task<bool> CondenseIfNeededAsync(
|
||||
List<ChatMessage> messages,
|
||||
@@ -59,6 +63,7 @@ public static class ContextCondenser
|
||||
|
||||
var currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold) return false;
|
||||
var originalTokens = currentTokens;
|
||||
|
||||
bool didCompress = false;
|
||||
|
||||
@@ -69,13 +74,19 @@ public static class ContextCondenser
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold) return didCompress;
|
||||
|
||||
// ── 2단계: 이전 대화 LLM 요약 ──
|
||||
// ── 2단계: 오래된 실행/도구 묶음 경량 압축 ──
|
||||
didCompress |= MicrocompactOlderMessages(messages);
|
||||
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (!force && currentTokens < threshold) return didCompress;
|
||||
|
||||
// ── 3단계: 이전 대화 LLM 요약 ──
|
||||
didCompress |= await SummarizeOldMessagesAsync(messages, llm, ct);
|
||||
|
||||
if (didCompress)
|
||||
{
|
||||
var afterTokens = TokenEstimator.EstimateMessages(messages);
|
||||
LogService.Info($"Context Condenser: {currentTokens} → {afterTokens} 토큰 (절감 {currentTokens - afterTokens})");
|
||||
LogService.Info($"Context Condenser: {originalTokens} → {afterTokens} 토큰 (절감 {originalTokens - afterTokens})");
|
||||
}
|
||||
|
||||
return didCompress;
|
||||
@@ -149,6 +160,168 @@ public static class ContextCondenser
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다.
|
||||
/// claw-code의 microcompact처럼 LLM 호출 전에 토큰을 한 번 더 줄이는 단계입니다.
|
||||
/// </summary>
|
||||
private static bool 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;
|
||||
|
||||
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);
|
||||
bool changed = false;
|
||||
|
||||
for (int i = 0; i < oldMessages.Count;)
|
||||
{
|
||||
if (IsMicrocompactCandidate(oldMessages[i]))
|
||||
{
|
||||
var group = new List<ChatMessage>();
|
||||
int j = i;
|
||||
while (j < oldMessages.Count && IsMicrocompactCandidate(oldMessages[j]))
|
||||
{
|
||||
group.Add(oldMessages[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
var totalChars = group.Sum(m => m.Content?.Length ?? 0);
|
||||
if (group.Count >= MicrocompactGroupMinCount || totalChars > MaxToolResultChars * 2)
|
||||
{
|
||||
rewritten.Add(BuildMicrocompactBoundary(group));
|
||||
changed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var singleChanged = TryMicrocompactSingle(group[0], out var compacted);
|
||||
rewritten.Add(singleChanged ? compacted : group[0]);
|
||||
changed |= singleChanged;
|
||||
}
|
||||
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryMicrocompactSingle(oldMessages[i], out var singleCompacted))
|
||||
{
|
||||
rewritten.Add(singleCompacted);
|
||||
changed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
rewritten.Add(oldMessages[i]);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!changed) return false;
|
||||
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
messages.AddRange(rewritten);
|
||||
messages.AddRange(recentMessages);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsMicrocompactCandidate(ChatMessage message)
|
||||
{
|
||||
var content = message.Content ?? "";
|
||||
if (string.IsNullOrWhiteSpace(content)) return false;
|
||||
|
||||
return message.MetaKind != null
|
||||
|| content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)
|
||||
|| content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)
|
||||
|| (message.Role != "system" && content.Length > MaxToolResultChars);
|
||||
}
|
||||
|
||||
private static bool TryMicrocompactSingle(ChatMessage source, out ChatMessage compacted)
|
||||
{
|
||||
compacted = source;
|
||||
var content = source.Content ?? "";
|
||||
if (string.IsNullOrWhiteSpace(content)) return false;
|
||||
if (source.Role == "system") return false;
|
||||
|
||||
if (content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
|
||||
{
|
||||
compacted = CloneWithContent(
|
||||
source,
|
||||
$"[이전 도구 결과 축약]\n- 상세 출력은 압축되었습니다.\n- 원본 길이: {content.Length:N0}자");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal))
|
||||
{
|
||||
compacted = CloneWithContent(
|
||||
source,
|
||||
$"[이전 도구 호출 묶음 축약]\n- 도구 호출 구조는 유지하지 않고 요약만 남겼습니다.\n- 원본 길이: {content.Length:N0}자");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (source.MetaKind != null)
|
||||
{
|
||||
compacted = CloneWithContent(
|
||||
source,
|
||||
$"[이전 실행 메타 축약]\n- 종류: {source.MetaKind}\n- 자세한 실행 이벤트는 압축되었습니다.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (content.Length <= MaxToolResultChars) return false;
|
||||
|
||||
var head = content[..Math.Min(MicrocompactSingleKeepChars, content.Length)];
|
||||
compacted = CloneWithContent(
|
||||
source,
|
||||
head + $"\n\n...[이전 메시지 microcompact 적용 — 원본 {content.Length:N0}자]");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ChatMessage BuildMicrocompactBoundary(List<ChatMessage> group)
|
||||
{
|
||||
int toolResults = group.Count(m => (m.Content ?? "").StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal));
|
||||
int toolCalls = group.Count(m => (m.Content ?? "").StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal));
|
||||
int metaEvents = group.Count(m => m.MetaKind != null);
|
||||
int longTexts = group.Count(m =>
|
||||
!(m.Content ?? "").StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal) &&
|
||||
!(m.Content ?? "").StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) &&
|
||||
m.MetaKind == null);
|
||||
|
||||
var attachedFiles = group
|
||||
.SelectMany(m => m.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(4)
|
||||
.ToList();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"[이전 실행 묶음 압축 — {group.Count}개 메시지]"
|
||||
};
|
||||
|
||||
if (toolResults > 0) lines.Add($"- 도구 결과 {toolResults}건 정리");
|
||||
if (toolCalls > 0) lines.Add($"- 도구 호출 {toolCalls}건 정리");
|
||||
if (metaEvents > 0) lines.Add($"- 실행 메타/로그 {metaEvents}건 정리");
|
||||
if (longTexts > 0) lines.Add($"- 긴 설명/출력 {longTexts}건 축약");
|
||||
if (attachedFiles.Count > 0) lines.Add($"- 관련 파일: {string.Join(", ", attachedFiles)}");
|
||||
|
||||
return new ChatMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = string.Join("\n", lines),
|
||||
Timestamp = group.Last().Timestamp,
|
||||
MetaKind = "microcompact_boundary",
|
||||
MetaRunId = group.Last().MetaRunId,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>tool_result JSON 내의 output 값을 축약합니다.</summary>
|
||||
private static string TruncateToolResultJson(string json)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user