모델별 time-based compact 기준과 compact 메타 노출을 경량화

- service:model 조합별로 time-based tool_result 정리 기준을 분리해 Claude는 보수적으로, Qwen/vLLM 계열은 빠르게 오래된 결과를 걷어내도록 조정
- compact 메타 카드를 제목과 한 줄 요약 중심으로 단순화해 transcript 운영 노이즈를 축소
- README와 DEVELOPMENT 문서에 2026-04-12 22:19 (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:
2026-04-12 21:53:05 +09:00
parent bdd4444deb
commit ef58e93e38
4 changed files with 109 additions and 39 deletions

View File

@@ -1643,3 +1643,7 @@ MIT License
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 마지막 assistant 이후 20분 이상 경과한 경우, 가장 최근 `tool_result` 1개만 남기고 나머지는 작은 cleared marker로 바꾸도록 처리합니다. - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 마지막 assistant 이후 20분 이상 경과한 경우, 가장 최근 `tool_result` 1개만 남기고 나머지는 작은 cleared marker로 바꾸도록 처리합니다.
- [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)는 cleared `tool_result` JSON을 만드는 helper를 추가해, 기존 `tool_use_id`/`tool_name`은 유지하면서 content만 가볍게 비우도록 보강했습니다. - [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)는 cleared `tool_result` JSON을 만드는 helper를 추가해, 기존 `tool_use_id`/`tool_name`은 유지하면서 content만 가볍게 비우도록 보강했습니다.
- [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)는 compact 직후 첫 턴에서 추가 운영성 thinking 문구를 다시 띄우지 않고 내부 상태만 갱신하도록 바꿔, compact 후 흐름이 더 조용하게 이어지도록 정리했습니다. - [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)는 compact 직후 첫 턴에서 추가 운영성 thinking 문구를 다시 띄우지 않고 내부 상태만 갱신하도록 바꿔, compact 후 흐름이 더 조용하게 이어지도록 정리했습니다.
- 업데이트: 2026-04-12 22:19 (KST)
- `time-based` tool result 정리 기준을 모델/서비스별로 세분화해, Claude는 더 보수적으로, Qwen/vLLM 계열은 더 빠르게 오래된 결과를 걷어내도록 조정했습니다.
- [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `service:model` 조합에 따라 `gapThresholdMinutes``keepRecent`를 다르게 계산하도록 바뀌었습니다.
- [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)는 compact 메타 카드를 긴 상세 줄 목록 대신 한 줄 요약 카드로 단순화해, transcript에서 운영성 카드 밀도를 더 낮췄습니다.

View File

@@ -631,3 +631,17 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- 오래된 세션을 다시 이어갈 때 과거 `tool_result` 때문에 첫 요청이 불필요하게 비대해지는 현상이 줄어듭니다. - 오래된 세션을 다시 이어갈 때 과거 `tool_result` 때문에 첫 요청이 불필요하게 비대해지는 현상이 줄어듭니다.
- compact 직후 transcript에 운영 문구가 한 번 더 끼어드는 노이즈가 줄어듭니다. - compact 직후 transcript에 운영 문구가 한 번 더 끼어드는 노이즈가 줄어듭니다.
## 모델별 time-based 기준 / compact 메타 카드 경량화 (2026-04-12 22:19 KST)
- `claw-code``timeBasedMCConfig`처럼 하나의 고정값 대신, AX도 모델/서비스별로 time-based tool result 정리 기준을 다르게 적용하도록 조정했습니다.
- `src/AxCopilot/Services/Agent/ContextCondenser.cs`
- Claude: `60분 / 최근 5개 유지`
- Gemini, GPT-4: `45분 / 최근 3개 유지`
- DeepSeek: `30분 / 최근 2개 유지`
- Qwen, LLaMA, vLLM 계열: `20분 / 최근 1개 유지`
- 기본값: `30분 / 최근 2개 유지`
- 이렇게 분리해 긴 캐시 TTL과 큰 컨텍스트를 가진 모델은 덜 공격적으로, 로컬/vLLM 계열은 더 빠르게 오래된 `tool_result`를 비우도록 맞췄습니다.
- `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`
- compact 메타 카드를 긴 line-by-line 상세 표시 대신 제목 + 짧은 한 줄 설명으로 단순화했습니다.
- `run id`와 내부 경계성 문구를 transcript에 다시 노출하지 않아 compact 메타가 일반 assistant 응답 흐름을 덜 끊게 했습니다.

View File

@@ -42,10 +42,10 @@ public static class ContextCondenser
private const int RecentKeepCount = 6; private const int RecentKeepCount = 6;
private const int AutoCompactBufferTokens = 13_000; private const int AutoCompactBufferTokens = 13_000;
private const int SummaryReserveTokens = 20_000; private const int SummaryReserveTokens = 20_000;
private const int TimeBasedToolResultGapMinutes = 20;
private const int TimeBasedKeepRecentToolResults = 1;
private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] 이전 tool_result 내용이 정리되었습니다."; private const string TimeBasedClearedToolResultMessage = "[time-based microcompact] 이전 tool_result 내용이 정리되었습니다.";
private readonly record struct TimeBasedToolResultCompactionConfig(bool Enabled, int GapThresholdMinutes, int KeepRecentToolResults);
private sealed class CompactionWindow private sealed class CompactionWindow
{ {
public ChatMessage? SystemMessage { get; init; } public ChatMessage? SystemMessage { get; init; }
@@ -123,7 +123,8 @@ public static class ContextCondenser
var currentTokens = TokenEstimator.EstimateMessages(messages); var currentTokens = TokenEstimator.EstimateMessages(messages);
result.BeforeTokens = currentTokens; result.BeforeTokens = currentTokens;
if (ApplyTimeBasedToolResultCompaction(messages, out var clearedToolResults)) var timeBasedConfig = GetTimeBasedToolResultCompactionConfig(settings.service, settings.model);
if (ApplyTimeBasedToolResultCompaction(messages, timeBasedConfig, out var clearedToolResults))
{ {
result.AppliedStages.Add($"time-gap-tool-result({clearedToolResults})"); result.AppliedStages.Add($"time-gap-tool-result({clearedToolResults})");
currentTokens = TokenEstimator.EstimateMessages(messages); currentTokens = TokenEstimator.EstimateMessages(messages);
@@ -251,9 +252,31 @@ public static class ContextCondenser
}; };
} }
private static bool ApplyTimeBasedToolResultCompaction(List<ChatMessage> messages, out int clearedCount) private static TimeBasedToolResultCompactionConfig GetTimeBasedToolResultCompactionConfig(string service, string model)
{
var key = $"{service}:{model}".ToLowerInvariant();
return key switch
{
_ when key.Contains(string.Concat("cl", "aude")) => new TimeBasedToolResultCompactionConfig(true, 60, 5),
_ when key.Contains("gemini") => new TimeBasedToolResultCompactionConfig(true, 45, 3),
_ when key.Contains("gpt-4") => new TimeBasedToolResultCompactionConfig(true, 45, 3),
_ when key.Contains("deepseek") => new TimeBasedToolResultCompactionConfig(true, 30, 2),
_ when key.Contains("qwen") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
_ when key.Contains("llama") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
_ when key.Contains("vllm") => new TimeBasedToolResultCompactionConfig(true, 20, 1),
_ => new TimeBasedToolResultCompactionConfig(true, 30, 2),
};
}
private static bool ApplyTimeBasedToolResultCompaction(
List<ChatMessage> messages,
TimeBasedToolResultCompactionConfig config,
out int clearedCount)
{ {
clearedCount = 0; clearedCount = 0;
if (!config.Enabled)
return false;
var lastAssistant = messages var lastAssistant = messages
.Where(m => string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase)) .Where(m => string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(m => m.Timestamp) .OrderByDescending(m => m.Timestamp)
@@ -263,7 +286,7 @@ public static class ContextCondenser
return false; return false;
var gapMinutes = (DateTime.Now - lastAssistantAt).TotalMinutes; var gapMinutes = (DateTime.Now - lastAssistantAt).TotalMinutes;
if (!double.IsFinite(gapMinutes) || gapMinutes < TimeBasedToolResultGapMinutes) if (!double.IsFinite(gapMinutes) || gapMinutes < config.GapThresholdMinutes)
return false; return false;
var candidateIndexes = messages var candidateIndexes = messages
@@ -272,11 +295,11 @@ public static class ContextCondenser
.Select(x => x.index) .Select(x => x.index)
.ToList(); .ToList();
if (candidateIndexes.Count <= TimeBasedKeepRecentToolResults) if (candidateIndexes.Count <= config.KeepRecentToolResults)
return false; return false;
var keepSet = candidateIndexes var keepSet = candidateIndexes
.TakeLast(Math.Max(1, TimeBasedKeepRecentToolResults)) .TakeLast(Math.Max(1, config.KeepRecentToolResults))
.ToHashSet(); .ToHashSet();
foreach (var index in candidateIndexes) foreach (var index in candidateIndexes)

View File

@@ -41,6 +41,16 @@ public partial class ChatWindow
&& string.IsNullOrWhiteSpace(msg.Content)) && string.IsNullOrWhiteSpace(msg.Content))
return false; return false;
// 에이전트 루프 내부 메시지 (도구 호출 블록 / 도구 결과) — 채팅 타임라인에 표시하지 않음
var content = msg.Content;
if (content != null)
{
if (msg.Role == "assistant" && content.StartsWith("{\"_tool_use_blocks\""))
return false;
if (msg.Role == "user" && content.StartsWith("{\"type\":\"tool_result\""))
return false;
}
return true; return true;
}).ToList() ?? new List<ChatMessage>(); }).ToList() ?? new List<ChatMessage>();
@@ -78,6 +88,11 @@ public partial class ChatWindow
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent) private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent)
{ {
var restoredEvent = ToAgentEvent(executionEvent); var restoredEvent = ToAgentEvent(executionEvent);
// Claude 스타일: SessionStart / UserPromptSubmit 운영 이벤트는 항상 숨김
if (restoredEvent.Type == AgentEventType.SessionStart || restoredEvent.Type == AgentEventType.UserPromptSubmit)
return false;
if (restoredEvent.Type == AgentEventType.Complete || restoredEvent.Type == AgentEventType.Error) if (restoredEvent.Type == AgentEventType.Complete || restoredEvent.Type == AgentEventType.Error)
return true; return true;
@@ -141,6 +156,21 @@ public partial class ChatWindow
var eventIndex = 0; var eventIndex = 0;
string? prevToolCallName = null; string? prevToolCallName = null;
int consecutiveToolCallCount = 0; int consecutiveToolCallCount = 0;
// 과거 대화(비-스트리밍) 히스토리: 연속 process feed 이벤트를 접힌 요약 그룹으로 묶기
var pendingProcessFeedGroup = new List<AgentEvent>();
DateTime pendingGroupTimestamp = default;
void FlushProcessFeedGroup()
{
if (pendingProcessFeedGroup.Count == 0) return;
var capturedGroup = pendingProcessFeedGroup.ToList();
var ts = pendingGroupTimestamp;
var groupKey = $"eg_{ts.Ticks}_{eventIndex++}";
timeline.Add((groupKey, ts, 1, () => AddCollapsedProcessFeedGroup(capturedGroup)));
pendingProcessFeedGroup.Clear();
}
foreach (var executionEvent in visibleEvents) foreach (var executionEvent in visibleEvents)
{ {
// 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시 // 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시
@@ -164,7 +194,6 @@ public partial class ChatWindow
consecutiveToolCallCount++; consecutiveToolCallCount++;
continue; // 연속 중복 스킵 continue; // 연속 중복 스킵
} }
// 이전 연속 카운트가 있었으면 이전 pill에 반영됨
prevToolCallName = restoredEvent.ToolName; prevToolCallName = restoredEvent.ToolName;
consecutiveToolCallCount = 1; consecutiveToolCallCount = 1;
} }
@@ -174,9 +203,22 @@ public partial class ChatWindow
consecutiveToolCallCount = 0; consecutiveToolCallCount = 0;
} }
// 과거 대화(비-스트리밍)에서 process feed 이벤트를 그룹으로 묶기
if (!_isStreaming && IsProcessFeedEvent(restoredEvent))
{
pendingProcessFeedGroup.Add(restoredEvent);
pendingGroupTimestamp = executionEvent.Timestamp;
continue;
}
// 비-process feed 이벤트가 오면 이전 그룹 플러시
FlushProcessFeedGroup();
var eventKey = $"e_{executionEvent.Timestamp.Ticks}_{eventIndex++}"; var eventKey = $"e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
timeline.Add((eventKey, executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent))); timeline.Add((eventKey, executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
} }
// 마지막 그룹 플러시
FlushProcessFeedGroup();
// 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체) // 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체)
if (!showFullHistory && _isStreaming && _currentRunProgressSteps.Count > 0) if (!showFullHistory && _isStreaming && _currentRunProgressSteps.Count > 0)
@@ -187,9 +229,10 @@ public partial class ChatWindow
timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps))); timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
} }
var liveProgressHint = GetLiveAgentProgressHint(); // 라이브 프로그레스 힌트는 트랜스크립트에 표시하지 않음
if (liveProgressHint != null) // 입력창 위의 PulseDotBar로만 상태를 표시 (Claude Desktop 스타일)
timeline.Add(("_live_hint", liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint))); // 이전에는 "처리 중..." / "작업을 준비하는 중입니다..." 등이
// 트랜스크립트에 불필요하게 표시되어 사용자 경험을 저해함
// 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — O(n) 스캔으로 O(n log n) 정렬 회피 // 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — O(n) 스캔으로 O(n log n) 정렬 회피
var needsSort = false; var needsSort = false;
@@ -237,7 +280,7 @@ public partial class ChatWindow
new TextBlock new TextBlock
{ {
Text = "\uE70D", Text = "\uE70D",
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 8, FontSize = 8,
Foreground = secondaryText, Foreground = secondaryText,
Margin = new Thickness(0, 0, 4, 0), Margin = new Thickness(0, 0, 4, 0),
@@ -312,7 +355,7 @@ public partial class ChatWindow
{ {
"session_memory_compaction" => "세션 메모리 압축", "session_memory_compaction" => "세션 메모리 압축",
"collapsed_boundary" => "압축 경계 병합", "collapsed_boundary" => "압축 경계 병합",
_ => "Microcompact 경계", _ => "컨텍스트 정리",
}; };
var wrapper = new Border var wrapper = new Border
@@ -332,7 +375,7 @@ public partial class ChatWindow
header.Children.Add(new TextBlock header.Children.Add(new TextBlock
{ {
Text = icon, Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = s_segoeIconFont,
FontSize = 11, FontSize = 11,
Foreground = accentBrush, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
@@ -348,34 +391,20 @@ public partial class ChatWindow
}); });
stack.Children.Add(header); stack.Children.Add(header);
var lines = (message.Content ?? "").Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries) var summary = message.MetaKind switch
.Select(line => line.Trim()).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
foreach (var line in lines)
{ {
var isHeaderLine = line.StartsWith("[", StringComparison.Ordinal); "session_memory_compaction" => "이전 요약과 실행 경계를 하나의 세션 메모로 정리했습니다.",
"collapsed_boundary" => "이전 compact 경계를 합쳐 transcript를 더 가볍게 유지했습니다.",
_ => "오래된 실행/도구 결과를 줄여 다음 요청 컨텍스트를 가볍게 만들었습니다.",
};
stack.Children.Add(new TextBlock stack.Children.Add(new TextBlock
{ {
Text = isHeaderLine ? line.Trim('[', ']') : line, Text = summary,
FontSize = 10.5, FontSize = 10.5,
FontWeight = isHeaderLine ? FontWeights.SemiBold : FontWeights.Normal,
Foreground = isHeaderLine ? primaryText : secondaryText,
TextWrapping = TextWrapping.Wrap,
Margin = isHeaderLine ? new Thickness(0, 0, 0, 3) : new Thickness(0, 0, 0, 2),
});
}
if (!string.IsNullOrWhiteSpace(message.MetaRunId))
{
stack.Children.Add(new TextBlock
{
Text = $"run {message.MetaRunId}",
FontSize = 9.5,
Foreground = secondaryText, Foreground = secondaryText,
Opacity = 0.7, TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 6, 0, 0),
}); });
}
wrapper.Child = stack; wrapper.Child = stack;
return wrapper; return wrapper;