Initial commit to new repository
This commit is contained in:
250
src/AxCopilot/Services/Agent/ContextCondenser.cs
Normal file
250
src/AxCopilot/Services/Agent/ContextCondenser.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
|
||||
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
|
||||
///
|
||||
/// 2단계 압축 전략:
|
||||
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
|
||||
/// 2단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
|
||||
/// </summary>
|
||||
public static class ContextCondenser
|
||||
{
|
||||
/// <summary>도구 결과 1개당 최대 유지 길이 (자)</summary>
|
||||
private const int MaxToolResultChars = 1500;
|
||||
|
||||
/// <summary>요약 시 유지할 최근 메시지 수</summary>
|
||||
private const int RecentKeepCount = 6;
|
||||
|
||||
/// <summary>모델별 입력 토큰 한도 (대략). 정확한 값은 중요하지 않음 — 안전 마진으로 70% 적용.</summary>
|
||||
private static int GetModelInputLimit(string service, string model)
|
||||
{
|
||||
var key = $"{service}:{model}".ToLowerInvariant();
|
||||
return key switch
|
||||
{
|
||||
_ when key.Contains(string.Concat("cl", "aude")) => 180_000, // Claude 계열 200K
|
||||
_ when key.Contains("gemini-2.5") => 900_000, // Gemini 1M
|
||||
_ when key.Contains("gemini-2.0") => 900_000,
|
||||
_ when key.Contains("gemini") => 900_000,
|
||||
_ when key.Contains("gpt-4") => 120_000, // GPT-4 128K
|
||||
_ => 16_000, // Ollama/vLLM 로컬 모델 기본값
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
|
||||
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
|
||||
/// 2단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면)
|
||||
/// </summary>
|
||||
public static async Task<bool> CondenseIfNeededAsync(
|
||||
List<ChatMessage> messages, LlmService llm, int maxOutputTokens, CancellationToken ct = default)
|
||||
{
|
||||
if (messages.Count < 6) return false;
|
||||
|
||||
// 현재 모델의 입력 토큰 한도
|
||||
var settings = llm.GetCurrentModelInfo();
|
||||
var inputLimit = GetModelInputLimit(settings.service, settings.model);
|
||||
var threshold = (int)(inputLimit * 0.65); // 65%에서 압축 시작
|
||||
|
||||
var currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (currentTokens < threshold) return false;
|
||||
|
||||
bool didCompress = false;
|
||||
|
||||
// ── 1단계: 도구 결과 축약 (LLM 호출 없음, 즉시 실행) ──
|
||||
didCompress |= TruncateToolResults(messages);
|
||||
|
||||
// 1단계 후 다시 추정
|
||||
currentTokens = TokenEstimator.EstimateMessages(messages);
|
||||
if (currentTokens < threshold) return didCompress;
|
||||
|
||||
// ── 2단계: 이전 대화 LLM 요약 ──
|
||||
didCompress |= await SummarizeOldMessagesAsync(messages, llm, ct);
|
||||
|
||||
if (didCompress)
|
||||
{
|
||||
var afterTokens = TokenEstimator.EstimateMessages(messages);
|
||||
LogService.Info($"Context Condenser: {currentTokens} → {afterTokens} 토큰 (절감 {currentTokens - afterTokens})");
|
||||
}
|
||||
|
||||
return didCompress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1단계: 대용량 도구 결과를 축약합니다.
|
||||
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
|
||||
/// </summary>
|
||||
private static bool TruncateToolResults(List<ChatMessage> messages)
|
||||
{
|
||||
bool truncated = false;
|
||||
|
||||
// 최근 RecentKeepCount개는 건드리지 않음 (방금 실행한 도구 결과는 유지)
|
||||
var cutoff = Math.Max(0, messages.Count - RecentKeepCount);
|
||||
|
||||
for (int i = 0; i < cutoff; i++)
|
||||
{
|
||||
var msg = messages[i];
|
||||
if (msg.Content == null) continue;
|
||||
|
||||
// tool_result 메시지의 대용량 출력 축약
|
||||
if (msg.Content.StartsWith("{\"type\":\"tool_result\"") && msg.Content.Length > MaxToolResultChars)
|
||||
{
|
||||
// JSON 구조를 유지하되 output 부분만 축약
|
||||
messages[i] = CloneWithContent(msg, TruncateToolResultJson(msg.Content));
|
||||
truncated = true;
|
||||
}
|
||||
// assistant의 도구 호출 블록 내 긴 텍스트도 축약
|
||||
else if (msg.Role == "assistant" && msg.Content.Length > MaxToolResultChars * 2 &&
|
||||
msg.Content.StartsWith("{\"_tool_use_blocks\""))
|
||||
{
|
||||
// 도구 호출 구조는 유지, 텍스트 블록만 축약
|
||||
if (msg.Content.Length > MaxToolResultChars * 3)
|
||||
{
|
||||
messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[축약됨]\"]}");
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
// 일반 assistant/user 메시지 중 비정상적으로 긴 것 (예: 파일 내용 전체 붙여넣기)
|
||||
else if (msg.Content.Length > MaxToolResultChars * 3 && msg.Role != "system")
|
||||
{
|
||||
messages[i] = CloneWithContent(
|
||||
msg,
|
||||
msg.Content[..MaxToolResultChars] + "\n\n...[이전 내용 축약됨 — 원본 " +
|
||||
$"{msg.Content.Length:N0}자 중 {MaxToolResultChars:N0}자 유지]");
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return truncated;
|
||||
}
|
||||
|
||||
private static ChatMessage CloneWithContent(ChatMessage source, string content)
|
||||
{
|
||||
return new ChatMessage
|
||||
{
|
||||
Role = source.Role,
|
||||
Content = content,
|
||||
Timestamp = source.Timestamp,
|
||||
MetaKind = source.MetaKind,
|
||||
MetaRunId = source.MetaRunId,
|
||||
Feedback = source.Feedback,
|
||||
AttachedFiles = source.AttachedFiles?.ToList(),
|
||||
Images = source.Images?.Select(x => new ImageAttachment
|
||||
{
|
||||
Base64 = x.Base64,
|
||||
MimeType = x.MimeType,
|
||||
FileName = x.FileName,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>tool_result JSON 내의 output 값을 축약합니다.</summary>
|
||||
private static string TruncateToolResultJson(string json)
|
||||
{
|
||||
// 간단한 문자열 처리로 output 부분만 축약 (JSON 파서 없이)
|
||||
const string marker = "\"output\":\"";
|
||||
var idx = json.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (idx < 0) return json[..Math.Min(json.Length, MaxToolResultChars)] + "...[축약됨]}";
|
||||
|
||||
var outputStart = idx + marker.Length;
|
||||
// output 끝 찾기 (이스케이프된 따옴표 고려)
|
||||
var outputEnd = outputStart;
|
||||
while (outputEnd < json.Length)
|
||||
{
|
||||
if (json[outputEnd] == '\\') { outputEnd += 2; continue; }
|
||||
if (json[outputEnd] == '"') break;
|
||||
outputEnd++;
|
||||
}
|
||||
|
||||
var outputLen = outputEnd - outputStart;
|
||||
if (outputLen <= MaxToolResultChars) return json; // 이미 짧음
|
||||
|
||||
// 앞부분 + "...[축약됨]" + 뒷부분
|
||||
var keepLen = MaxToolResultChars / 2;
|
||||
var prefix = json[..outputStart];
|
||||
var outputText = json[outputStart..outputEnd];
|
||||
var suffix = json[outputEnd..];
|
||||
|
||||
return prefix +
|
||||
outputText[..keepLen] +
|
||||
"\\n...[축약됨: " + $"{outputLen:N0}" + "자 중 " + $"{MaxToolResultChars:N0}" + "자 유지]\\n" +
|
||||
outputText[^keepLen..] +
|
||||
suffix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2단계: 오래된 메시지를 LLM으로 요약합니다.
|
||||
/// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.
|
||||
/// </summary>
|
||||
private static async Task<bool> SummarizeOldMessagesAsync(
|
||||
List<ChatMessage> messages, LlmService llm, CancellationToken ct)
|
||||
{
|
||||
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
|
||||
var systemCount = systemMsg != null ? 1 : 0;
|
||||
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
|
||||
|
||||
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
|
||||
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
|
||||
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
|
||||
|
||||
if (oldMessages.Count < 3) return false;
|
||||
|
||||
// 요약 대상 텍스트 구성
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("다음 대화 기록을 간결하게 요약하세요.");
|
||||
sb.AppendLine("반드시 유지할 정보: 사용자 요청, 핵심 결정 사항, 생성/수정된 파일 경로, 작업 진행 상황, 중요한 결과.");
|
||||
sb.AppendLine("제거할 정보: 도구 실행의 상세 출력, 반복되는 내용, 중간 사고 과정.");
|
||||
sb.AppendLine("요약은 대화와 동일한 언어로 작성하세요. 글머리 기호(-)로 핵심 사항만 나열하세요.\n---");
|
||||
|
||||
foreach (var m in oldMessages)
|
||||
{
|
||||
var content = m.Content ?? "";
|
||||
if (content.StartsWith("{\"_tool_use_blocks\""))
|
||||
content = "[도구 호출]";
|
||||
else if (content.StartsWith("{\"type\":\"tool_result\""))
|
||||
content = "[도구 결과]";
|
||||
else if (content.Length > 300)
|
||||
content = content[..300] + "...";
|
||||
|
||||
sb.AppendLine($"[{m.Role}]: {content}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var summaryMessages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "user", Content = sb.ToString() }
|
||||
};
|
||||
|
||||
var summary = await llm.SendAsync(summaryMessages, ct);
|
||||
if (string.IsNullOrEmpty(summary)) return false;
|
||||
|
||||
// 메시지 재구성: system + 요약 + 최근 메시지
|
||||
messages.Clear();
|
||||
if (systemMsg != null) messages.Add(systemMsg);
|
||||
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = $"[이전 대화 요약 — {oldMessages.Count}개 메시지 압축]\n{summary}",
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = "이전 대화 내용을 확인했습니다. 이어서 작업하겠습니다.",
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
|
||||
messages.AddRange(recentMessages);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"Context Condenser 요약 실패: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user