Skip to content

Conversation Compaction — 컨텍스트가 차기 전에 대화를 요약한다

Status: IMPLEMENTED 적용 대상: aimon-core, aimon-llm-openai 관련 규칙: .claude/rules/hook-development.md, .claude/rules/multi-instance-design.md, .claude/rules/llm-provider.md 관련 문서: orca-executor.md (ReAct 루프), hook-development-guide.md


1. 무엇을 푸는가

ReAct 루프는 매 iteration 마다 TranscriptBuffer전체 메시지를 LLM 에 다시 보낸다. 대화가 길어지면 다음 호출이 모델의 context window 를 넘고, 프로바이더는 그것을 복구 불가능한 예외로 돌려준다 (Anthropic "prompt is too long", OpenAI context_length_exceeded). 스케줄 루틴이나 서브에이전트 체인처럼 오래 도는 실행일수록 이 벽에 먼저 닿는다.

Compaction 은 그 벽에 닿기 전에 오래된 메시지를 요약본으로 갈아끼워 루프를 계속 살린다.

1.1 원칙

  • 프로바이더 중립LlmClient 인터페이스만으로 동작한다. Anthropic cache_edits 기반 압축(참조 구현의 L1)은 이 원칙과 충돌하므로 도입하지 않았다 (§10.1)
  • 원본 무손상 — 요약 LLM 호출이 실패하면 대화는 한 글자도 바뀌지 않는다. 교체는 요약을 손에 쥔 뒤 단 한 번의 replaceWith() 로만 일어난다
  • 재귀 불가 — 요약 호출이 다시 compaction 을 트리거하지 않는다 (§4.4)
  • 훅으로 열되 루프는 막지 않는다 — Pre 훅은 블로킹, Post 훅은 논블로킹
  • 반복 실패는 차단한다 — 연속 실패가 임계에 닿으면 AUTO 경로를 끊는다 (circuit breaker, §3.3)

1.2 용어

용어 정의
Compaction 대화 히스토리를 줄여 context 를 확보하는 메커니즘의 총칭
Autocompact 토큰 임계값 초과 시 가드가 자동으로 수행하는 compaction
Manual compact /compact 명령으로 사용자가 명시 요청하는 compaction
Full compaction (L3) LLM 호출로 대화를 요약해 통째로 대체하는 방식 — 이 문서의 주 경로
Time-based microcompact (L0) 오래된 tool_result 본문만 placeholder 로 비우는 무비용 압축
Compact boundary compaction 이 일어난 지점을 표시하는 마커 메시지 쌍
Effective context window contextWindow - reservedOutputTokens

2. 계층

판정(언제)과 실행(어떻게)이 분리되어 있다. 실행기는 가드 하나만 알고, 가드가 엔진을 부른다.

OrcaAgentExecutor.executeReActLoop()
  └─ (매 iteration 머리에서)
     CompactionGuard.maybeCompact(transcriptBuffer, model, hookRegistry, environment)
        │  임계값 판정 · 세션 락 · circuit breaker
        └─ CompactionEngine.compact(CompactionRequest)
             │  PreCompact 훅 → strip → 요약 LLM 호출 → replaceWith → PostCompact 훅
             ├─ TokenEstimator            (얼마나 찼는가)
             ├─ ModelContextWindowRegistry (모델별 한계)
             ├─ SummaryPromptTemplate      (요약 지시문)
             ├─ MessageStripper            (이미지·문서 → placeholder, 선택적 redaction)
             └─ CompactBoundary            (경계·요약 마커 생성)
컴포넌트 패키지 역할
CompactionGuard / DefaultCompactionGuard / NoOpCompactionGuard at.aimon.core.agent.compact 임계값 판정과 트리거
CompactionEngine / DefaultCompactionEngine at.aimon.core.agent.compact L3 요약 오케스트레이션
CompactionRequest / CompactionResult / CompactionMetadata / CompactionDecision at.aimon.core.agent.compact 불변 입출력 값
CompactionRange at.aimon.core.agent.compact 부분 compaction 구간 (§4.3)
CompactBoundary at.aimon.core.agent.compact 마커 문자열·마커 메시지 팩토리
SummaryPromptTemplate / MessageStripper / SensitivePatternRedactor / RedactionPattern at.aimon.core.agent.compact 요약 프롬프트와 입력 정제
CompactionFailureStore / InMemoryCompactionFailureStore / SessionRecordCompactionFailureStore at.aimon.core.agent.compact circuit breaker 카운터 (§8)
TimeBasedMicrocompact at.aimon.core.agent.compact L0 무비용 압축 (§9.1)
TokenEstimator / HeuristicTokenEstimator at.aimon.core.llm.token 토큰 수 추정
ModelContextWindowRegistry / InMemoryModelContextWindowRegistry / ModelContextLimits at.aimon.core.llm 모델명 → 한계값
PreCompactHook / PostCompactHook + 컨텍스트 at.aimon.core.hook.event 생명주기 훅 (§6)
ContextWindowExceededException at.aimon.core.agent.exception 압축 후에도 초과일 때
CompactCommand at.aimon.core.command.system /compact (§7)

패키지가 at.aimon.core.agent.compact 인 것은 compaction 이 특정 agent 구현이 아니라 공용 기능이기 때문이다 — at.aimon.core.agent.impl.<name> 은 구현 전용 자리다.


3. 언제 압축하는가 — DefaultCompactionGuard

3.1 임계값

ModelContextLimits 가 모델별 한계를 들고 있고, 임계값 셋은 거기서 파생된다.

필드 기본값
contextWindow 128,000 모델이 한 번에 처리 가능한 토큰 수
reservedOutputTokens 20,000 출력용으로 남겨 두는 몫
autoCompactBuffer 13,000 자동 compaction 여유
warningBuffer 20,000 경고 여유
blockingBuffer 3,000 차단 여유

생성자가 강제하는 불변 규칙이 둘 있다 — reservedOutputTokens + autoCompactBuffer + warningBuffer < contextWindow (임계값이 음수가 되는 것을 막는다), blockingBuffer ≤ reservedOutputTokens. 위반은 IllegalArgumentException 이다.

InMemoryModelContextWindowRegistry 가 모델명을 여기에 매핑한다 (Claude 계열 200K, GPT-4 계열 128K, 미등록 모델은 기본값). 읽기 전용 설정이므로 in-memory 구현으로 충분하고, 어셈블리가 build-time 에 구성한다.

3.2 판정 순서

evaluate()네 단계를 이 순서로 본다. 순서 자체가 설계다.

  1. blocking limit 초과 — circuit breaker보다 먼저 본다. 여기서 압축하지 못하면 다음 LLM 호출은 확실히 실패하므로, "AUTO 를 껐다"는 상태가 이 마지막 시도를 막아서는 안 된다. 압축 성공이면 실패 카운터를 리셋하고 COMPACT, 실패면 BLOCK
  2. circuit breaker 개방 — 연속 실패가 임계(DEFAULT_MAX_CONSECUTIVE_FAILURES = 3)에 닿았으면 NONE("circuit breaker open"). AUTO 만 막는다
  3. auto-compact 밴드estimated ≥ effectiveAutoThreshold 이고 사전조건이 충족되면 엔진 호출
  4. warning 밴드 — 압축은 하지 않고 WARN

어느 밴드에도 닿지 않으면 NONE 이다.

사전조건(preconditionMet) — 마지막 메시지가 미해소 tool_use 를 남긴 상태면 압축하지 않는다. tool_use 와 tool_result 가 짝을 잃은 메시지 배열은 프로바이더가 거부하기 때문이다. blocking limit 에 닿았는데 사전조건이 미충족이면 BLOCK 이 아니라 WARN("...deferring") 을 돌려주어, tool_result 가 붙는 다음 iteration 에서 재평가되게 한다.

budget-forced 패스 — 실행기가 ExecutionBudget.compactionTokenThreshold 로 선제 압축을 요청하면 (forceCompact) 유효 임계값이 auto-compact 밴드가 아니라 더 낮은 warning 밴드로 내려간다. 자기 제한적이다 — 한 번 작아지고 나면 다음 forceCompactNONE 을 돌려준다. 예산 쪽 설명은 orca-executor.md §4 에 있다.

3.3 동시성과 circuit breaker

  • 세션당 락SessionIdReentrantLocktryLock 으로 잡는다. 이미 다른 스레드가 같은 세션을 평가 중이면 기다리지 않고 비켜난다. AUTO 와 MANUAL 이 같은 대화를 동시에 갈아엎는 사고를 막는다
  • 락 맵은 유계BoundedLruMap 으로 DEFAULT_MAX_TRACKED_SESSIONS = 1024 개까지만 유지한다. 긴 수명 프로세스에서 세션 수만큼 락이 쌓이는 누수를 막기 위한 것이다
  • 실패 카운트 대상은 일시적 실패뿐CompactionBlockedByHookException(훅이 의도적으로 막음)과 CompactionReentrancyException(프로그래머 오류)은 카운트하지 않는다. 이 둘을 세면 "설정대로 동작한 것"이 AUTO compaction 을 조용히 꺼 버린다
  • 성공은 리셋 — 압축이 성공하면 카운터는 0 으로 돌아간다. /compact 로 사용자가 직접 성공시킨 경우도 마찬가지로 recordExternalSuccess(sessionId) 가 breaker 를 닫는다

4. 어떻게 압축하는가 — DefaultCompactionEngine

4.1 실행 순서

compact(CompactionRequest) 은 다음을 수행한다.

  1. 재진입 검사ThreadLocal<Boolean> COMPACTION_IN_PROGRESS 가 이미 true 면 즉시 CompactionReentrancyException 실패로 반환
  2. 스냅샷과 구간 결정 — 원본 메시지·시스템 프롬프트를 복사해 두고 pre-token 을 잰다. compactRange 가 없으면 전체가 대상이다 (§4.3)
  3. 관찰 정보 수집 — 요약 대상 구간에서 사용된 도구 이름, Read 로 읽은 최근 파일 경로, Skill 호출 기록(InvokedSkillRecord)을 뽑는다. Post 훅이 복원에 쓴다
  4. PreCompact 훅 — 지침(customInstructions)을 모은다. block 처리 규칙은 §6
  5. 정제MessageStripper 가 이미지 → [image], 문서 → [document] 로 바꾸고, 설정된 경우 SensitivePatternRedactor 를 텍스트에 적용한다
  6. 요약 LLM 호출tools 는 빈 리스트, feature = COMPACTION, traceId 는 세션 id. 호출자가 준 LlmCallMetadata 가 겹치는 필드에서 이기고 엔진 기본값이 나머지를 채운다
  7. 교체 — 살아남은 prefix + [boundary, summary] + tail 을 만들어 단 한 번의 replaceWith() 로 바꾼다. 이 지점 이전에 실패하면 원본은 그대로다
  8. PostCompact 훅 — 논블로킹. 예외는 로깅만 하고 삼킨다

교체 뒤 post-token 을 다시 재서 CompactionMetadata 에 담는다 — 추정치를 재사용하지 않고 실제 교체 결과를 측정한다.

4.2 실패는 전부 CompactionResult.failure

던지지 않는다. 실패 종류는 넷이고, 각각 metadata 를 붙여 돌려준다.

실패 원인 breaker 카운트
CompactionReentrancyException 같은 스레드에서 재진입 안 함
IllegalArgumentException 요청된 구간이 유효하지 않음
CompactionBlockedByHookException AUTO 인데 Pre 훅이 block 안 함
LLM 예외 / 빈 요약 요약 호출 실패 또는 공백 응답

4.3 부분 compaction

CompactionRequest.compactRange 가 있으면 그 구간만 요약하고 앞뒤(prefix/tail)는 보존한다. 구간은 메시지 크기와 절단면의 tool_use/tool_result 정합성까지 검증되며, 위반이면 실패로 반환한다. 범위가 없을 때의 동작(prefix·tail 이 모두 비어 [boundary, summary] 만 남는 것)이 전체 compaction 이므로 두 경로는 같은 코드다.

이 채널은 아직 프로덕션 호출자가 없다 — 엔진과 검증은 있지만 자동으로 구간을 고르는 정책이 없다. 정책이 생기면 그때 가드 쪽에 붙는다.

4.4 재귀 방지 — 이중 방어

  1. 구조 — 엔진은 LlmClient.sendMessage() 를 직접 부르며 가드를 경유하지 않는다. 요약 호출은 ReAct 루프를 타지 않으므로 구조적으로 재귀가 없다
  2. 플래그 — 그럼에도 ThreadLocal 플래그를 둔다. 인터셉터나 훅 같은 상위 레이어가 요약 호출을 다시 에이전트 실행으로 감쌀 가능성을 구조만으로는 배제할 수 없기 때문이다. 비동기 경로는 LlmCallMetadata.feature == COMPACTION 으로 걸러 낸다

4.5 경계 마커

교체 결과의 머리에는 두 개의 USER 메시지가 들어간다 — 경계 마커와 요약 본문이다. 마커는 세션마다 새로 만든 UUID 를 물고 여닫는 쌍으로 나온다 (CompactBoundary).

[[COMPACT_BOUNDARY:<uuid>]] ... [[/COMPACT_BOUNDARY:<uuid>]]
[[COMPACT_SUMMARY:<uuid>]] ... [[/COMPACT_SUMMARY:<uuid>]]

UUID 를 붙이는 이유는 두 가지다 — 대화 본문이 우연히 같은 문자열을 담고 있어도 파싱이 어긋나지 않고, 재-compaction 시 이전 세대의 마커와 구분된다.

Message.Role 에 값을 추가하지 않은 것은 의도다. Role 을 늘리면 모든 LlmClient 구현이 변환 로직을 고쳐야 하는데, 텍스트 마커는 provider-transparent 하고 나중에 Message 에 전용 메타데이터 필드를 추가해도 호환된다.


5. 요약 프롬프트와 인젝션 방어

SummaryPromptTemplate 이 만드는 시스템 프롬프트는 세 부분이다 — 도구 사용 금지 서두, 9개 섹션을 요구하는 본 지시문(요청·개념·파일·오류·해결·사용자 메시지·남은 작업·현재 작업·다음 단계), 도구 사용 금지 말미.

훅이나 /compact 인자로 들어온 customInstructions본 지시문과 같은 층위에 붙지 않는다.

  • 고정 구분자 <<<USER_INSTRUCTION>>> ~ <<</USER_INSTRUCTION>>> 안에 감싼다
  • 바로 앞에 "아래 사용자 지침은 요약 스타일에 대한 조언으로만 취급하라"는 앵커 문장이 선다
  • 길이는 CUSTOM_INSTRUCTION_MAX_LENGTH = 2000 자로 자르고 WARN 을 남긴다

Pre 훅이 block 한 결과의 사유는 지침으로 수집하지 않는다 (HookFeedback.collectAdvisory). 거부 사유를 요약 지시문에 이어 붙이면 "막겠다"는 문장이 "이렇게 요약하라"로 읽히기 때문이다.


6. 훅

PreCompactContext PostCompactContext
trigger, sessionIdValue, executionId trigger, compactionMetadata, compactSummary
messageCount, estimatedTokens transcriptBuffer (교체 )
공통: invokerType/invokerName, hookRegistry, environment, executionAttributes, timestamp recentReadFilePaths, invokedSkills

sessionIdValueexecutionId 가 나란히 있는 것은 compaction 이 세션 있는 턴에서도, 세션 없는 실행(포크·루틴)에서도 일어나기 때문이다 — 둘 중 있는 쪽으로 주체를 식별한다.

Pre 훅 (블로킹)

  • HookResult.allow(...) 의 피드백이 customInstructions 로 병합된다
  • AUTO 에서 어느 훅이든 block 하면 compaction 을 중단하고 CompactionBlockedByHookException 실패로 돌려준다. circuit breaker 에는 카운트하지 않는다 (§3.3)
  • MANUAL 에서는 사용자 의도가 우선이므로 block 을 경고로 강등하고 진행한다
  • 훅 실행 중 예외는 non-fatal — compaction 은 계속된다

Post 훅 (논블로킹) — 교체된 TranscriptBuffer 를 받아 요약이 잃어버린 맥락을 다시 붙일 수 있다. aimon-core 가 두 구현을 제공하지만 기본 등록되지 않는다(opt-in).

  • RecentFilesRestoreHookrecentReadFilePaths 로 최근 읽은 파일을 다시 붙인다
  • InvokedSkillsRestoreHookinvokedSkills 로 활성화했던 스킬 목록을 다시 알려 준다

7. /compact — 수동 경로

CompactCommand 는 가드를 거치지 않고 엔진을 직접 부른다. 임계값 판정은 "언제 필요한가"에 대한 것이고, 사용자가 명령을 쳤다는 사실이 그 판정을 대체하기 때문이다.

  • trigger = MANUAL, 명령 인자는 customInstructions 로 전달
  • 성공하면 compactionGuard.recordExternalSuccess(sessionId) 로 breaker 를 닫는다 — 가드가 모르는 곳에서 성공한 압축도 "이 세션은 압축이 되는 세션"이라는 증거이므로
  • 완료 메시지는 "Conversation compacted" — 여기서 "conversation" 은 수명이 아니라 LLM 메시지 교환을 가리키는 단어다 (glossary.md §2)

8. 멀티 인스턴스 — 실패 카운터를 어디에 두는가

circuit breaker 카운터는 CompactionFailureStore 뒤에 있고 구현이 둘이다.

구현 저장 위치 도달 범위
InMemoryCompactionFailureStore 프로세스 힙 이 노드만 — 노드마다 독립적으로 breaker 를 연다
SessionRecordCompactionFailureStore SessionRecord.compactionFailureCount 그 세션을 서빙하는 모든 노드가 같은 breaker 를 공유

기본값은 in-memory 다. 스케일아웃 부트스트랩은 OrcaAgentRuntimeFactory.withCompactionFailureStore(new SessionRecordCompactionFailureStore(sessionStore.records())) 로 갈아끼운다. 인자로 records() 즉 펜싱된 뷰를 주는 것이 중요하다 — 원시 레코드 스토어를 주면 축출된 노드가 계속 카운터를 쓸 수 있다.

카운터가 델타(incrementCompactionFailureCount)로 쓰이는 이유는 연속 실패가 서로 다른 노드에서 관측될 수 있어 어느 writer 도 되쓸 값을 손에 들고 있지 않기 때문이다.

영속 구현은 두 가지 방식으로 조용히 물러난다. 둘 다 의도한 것이다.

  • 레코드가 없는 실행 — 서브에이전트 포크·스킬 포크·스케줄 루틴은 자기 세션이 없다. 증가는 아무 데도 닿지 않고 0 을 보고하며, 공유 breaker 를 절대 열지 않는다. 그 실행이 속한 "세션의 실패 연속"이 존재하지 않으므로 이것이 정직한 결과다. 프로세스 단위로라도 막고 싶으면 포크 경로에 in-memory 구현을 준다
  • 리스 상실 — 펜싱된 쓰기가 SessionNotHeldException 을 내면 로그만 남기고 "센 것 없음"으로 보고한다. 압축 실패를 기록하는 데 실패한 것은 압축 실패가 아니다. 그 외 백엔드 오류는 전파한다 — 고장 난 레코드 스토어는 보이게 두어야지 열린 회로로 둔갑시키면 안 된다

9. 옆에 있는 것들 — 셋 다 opt-in

9.1 TimeBasedMicrocompact (L0)

LLM 호출 없이 오래된 tool_result 본문만 "[Old tool result cleared]" 로 갈아치운다. maxAgekeepRecent(기본 2)로 무엇을 남길지 정하고, TranscriptBuffer.getMessageTimestamps()replaceMessageAt() 위에서 동작한다. 어디에도 자동으로 배선되어 있지 않다 — 쓰려면 어셈블리가 직접 호출한다.

9.2 정밀 토큰 추정

기본값 HeuristicTokenEstimator 는 UTF-8 바이트를 3.5 로 나누고 이미지 블록에 고정 가중치(1,500)를 준다. 정확한 카운팅은 프로바이더별 토크나이저가 필요하므로 core 에 두지 않았고, aimon-llm-openaiTikTokenEstimator 를 제공한다. 휴리스틱은 과대 추정 방향이라 임계값 판정에는 안전한 쪽으로 틀린다.

9.3 시크릿 redaction

SensitivePatternRedactorRedactionPattern 목록으로 요약 입력 텍스트를 가린다. MessageStripper 의 기본값은 none() — 켜려면 어셈블리가 defaults() 나 자체 패턴 목록으로 stripper 를 구성한다.


10. 설계 결정

10.1 L1 Cached Microcompact 미채용

Anthropic cache_edits 기반 압축은 비용 절감 효과가 크지만 LlmClient 인터페이스에 프로바이더 전용 메서드를 들여야 한다. 다른 구현체에는 의미 없는 메서드가 생기므로 기각했다. 프로바이더 내부 최적화로 다루는 것은 여전히 열려 있다.

10.2 TranscriptBuffer 확장 vs 새 타입

replaceWith(List<Message>) 를 기존 버퍼에 추가했고 CompactedTranscriptBuffer 같은 타입은 만들지 않았다. 버퍼는 이미 mutable 이고 mutator API 를 가지므로 새 메서드가 기존 패턴과 일관되며, 타입을 나누면 실행기가 두 종류를 구분 처리해야 한다.

10.3 압축 실패는 턴을 죽이지 않는다

가드가 COMPACT(실패) 를 돌려주면 실행기는 WARN 을 남기고 원본 대화 그대로 LLM 호출을 계속한다. 압축은 최적화지 필수 단계가 아니다. 정말로 호출이 불가능한 지점(blocking limit 초과 + 압축 실패)에서만 ContextWindowExceededException(estimatedTokens, blockingLimit, reason) 으로 턴을 끝낸다.

프로바이더가 그럼에도 prompt-too-long 을 돌려주는 경우의 마지막 그물은 PromptSizeRecoveryStrategy 이며, 그 배선은 orca-executor.md §3 에 있다.


11. 남은 것

  • 부분 compaction 정책CompactionRange 채널은 열려 있으나 구간을 고르는 자동 정책이 없다 (§4.3)
  • L2 Session Memory Compact — 백그라운드 요약 에이전트가 선행되어야 한다. 별도 설계 대상
  • Message 에 id 필드 — L0 의 정확도를 높이지만 모든 LlmClient 변환 로직에 파급된다
  • 기본 배선되지 않은 셋 — Post 복원 훅, TimeBasedMicrocompact, redaction (§9). 켜는 쪽이 명시적으로 구성해야 한다

부록: 참조 파일 지도

관심사 파일
판정 DefaultCompactionGuard.java
실행 DefaultCompactionEngine.java
한계값 ModelContextLimits.java
마커 CompactBoundary.java
프롬프트 SummaryPromptTemplate.java
영속 breaker SessionRecordCompactionFailureStore.java
루프 통합 OrcaAgentExecutor.java
배선 OrcaAgentRuntimeFactory.java
PreCompactHook.java, PostCompactHook.java
수동 명령 CompactCommand.java