[에이전트] 9. 컨텍스트 : KV 캐시와 압축의 사투 — 하루 만의 revert까지

폐쇄망 사내 에이전트 9편 - 컨텍스트

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(폐쇄망 사내 에이전트 ⑨) 컨텍스트

이 시리즈에서 커밋이 가장 많이 쌓인 전장이 컨텍스트 관리다. 좁은 10,000토큰 안에서 도구 정의가 71%를 먹는 채로(8편), 대화는 길어지고 답변은 커진다. 이번 편은 그 사투의 기록 — 조용히 틀리는 버그 둘, 방향을 통째로 되돌린 revert 하나, 그리고 클로드 코드에게 배운 결론이다.

괴담 1: 2턴째부터 첫 인사만 반복하는 모델

증상이 괴담 같았다. 두 번째 질문부터 모델이 무슨 질문을 해도 첫 인사에만 답한다. 사고 로그엔 네 번째 질문인데도 “사용자가 인사만 했습니다”가 찍혔다. DB도 정상, 모델에 넘기는 이력도 정상. 범인은 추론 라이브러리에 넘기던 최적화 옵션 하나였다 — 직전 평가 창을 재사용하는 lastEvaluationContextWindow. 턴마다 새 메시지가 붙는 흐름에서 이 옵션을 주면 이력이 아무리 옳아도 평가되는 것은 지난 턴의 창이었다. 한 줄을 지우자 바로 정상.

무서운 건 증상의 성격이다. 느려지는 버그는 티가 나지만 이건 조용히 틀린 답이라 실행 테스트로 잡기 어렵다. 그래서 수정 후에 ‘그 옵션이 소스에 다시 들어오지 않았는지’를 보는 회귀 테스트를 넣고, 왜 뺐는지 주석으로 박아 되살아나는 것까지 막았다. 컨텍스트 기억 편에서 말한 “맥락이 죽는” 사고의 실물 버전이다.

괴담 2: 만들고, 하루 만에 되돌리다

긴 답변이 다음 턴 프롬프트에 통째로 재적재돼 예산을 태우는 문제가 있었다. 그래서 ‘표시용 대화와 모델용 대화를 분리해, 오래된 답변을 참조로 치환하는’ 상시 축소를 만들었다. 동작했다. 그리고 다음 날 revert했다.

commit 9af03af  (2026-08-03)
revert: ContextWindow 상시 축소를 되돌리고 HistoryCompactor로 합친다

  동작은 했지만 방향이 틀렸다.
  1. KV 접두사를 매 턴 깬다 — 참조 치환 경계가 턴마다 2칸씩 밀려
     그 지점부터 접두사가 끊긴다. 이 비용을 재지 않고 커밋했다.
  2. 이미 있는 기능을 두 번째로 만들었다 — HistoryCompactor가
     같은 일을 하고 있었고, 그쪽은 접두사가 압축 사이에 고정된다.

  클로드 코드는 이력을 상시로 줄이지 않는다 — 전문을 그대로 두고
  컨텍스트가 찰 때 한 번 요약본으로 갈아 끼운다.
  안정된 접두사 + 드문 이산 압축이 옳다.

4편의 웜 캐시 36배는 프롬프트 앞부분이 안정적일 때만 성립한다. 상시 축소는 매 턴 앞부분을 조금씩 밀어서 그 이득을 스스로 파괴하고 있었다. revert 커밋이 실패를 지우는 대신 ‘무엇이 왜 틀렸는지’를 남겼고, 첫 시도에서 쓸모 있던 두 조각(원문 길이 표기, “화면의 전문은 그대로”라는 통지)만 살려서 옮겼다. 실패도 기록이면 자산이다.

괴담 3: 줄이려던 요약이 자리를 더 먹는다

압축이 자리를 잡나 싶더니 역전 버그가 나왔다. 요약은 SYSTEM 메시지로 이력 앞머리에 붙는데, 앞머리 SYSTEM을 세는 루프가 그 요약까지 시스템 프롬프트로 보고 건너뛰었다. 두 번째 압축이 이전 요약을 남긴 채 새 요약을 또 붙이고… 압축할수록 요약이 1개, 2개, 3개 쌓이며 각각 예산을 먹었다. 수정: 요약 머리말로 ‘우리가 만든 요약’을 알아보고 이월분을 접어 넣어 요약은 항상 정확히 하나. 원문 회수 경로 줄은 예산 밖으로 뺐다 — 그 줄이 잘리면 원문을 되찾을 길이 영영 사라지니, 본문을 더 줄여서라도 경로는 남긴다.

판정도 근사가 아니라 실측으로

마지막 퍼즐. 압축 파이프라인을 실모델로 끝까지 돌려보니(ctx 4096, 9턴) “추정 3,349 < 임계 3,481, 미달”이 찍힌 바로 다음 턴에 실제 렌더가 4,096을 치고 죽었다. 토큰 추정과 실제 렌더(채팅 템플릿 마커 등)의 오차가 약 700토큰(20%)이었던 것. 수정은 직전 턴이 끝난 시점의 KV 실토큰을 고정부의 하한으로 쓰는 것 — 재실행에서 보정이 오차를 정확히 메꿨고, 같은 시나리오가 압축 5회 발동으로 죽지 않고 완주했다.

최종 파이프라인을 그림으로

flowchart TD
  T[매 턴: 이력 + 새 메시지] --> J{KV 실토큰 기준\n예산 85% 초과?}
  J -->|아니오| G[그대로 생성\n접두사 KV 재사용]
  J -->|예| A[잘릴 구간을\n파일로 보관 .md]
  A --> S["구조적 요약 생성\n(모델 호출 없음)\n질문 N: 원문 보존"]
  S --> F[이전 요약을 접어 넣기\n요약은 항상 1개]
  F --> N[사용자에게 notice\n'화면 전문은 그대로']
  N --> G

이 그림에 이 편의 사투가 전부 들어 있다 — 판정은 추정이 아니라 KV 실토큰(괴담 4), 요약은 모델 호출 없는 규칙 기반(4B에게 요약을 시키면 그 요약이 또 틀린다), 이전 요약은 접어 넣어 항상 하나(괴담 3), 원문은 파일로 회수 가능, 그리고 사용자에게 통지(11편).

코드로 — 요약을 접어 넣는 부분

// providers/node-llama-cpp/src/HistoryCompactor.ts
export const SUMMARY_HEADER_PREFIX = "[이전 대화 요약";
/** 원문 회수 경로 줄 — 요약을 접을 때 이 줄들은 잘리지 않고 전부 이월 */
const ARCHIVE_LINE_PREFIX = "[줄인 앞부분의 원문:";

/** 요약 본문을 머리말 · 회수 경로 · 나머지로 가른다 */
function splitSummary(content: string) {
    for (const line of content.split("\n")) {
        if (line.startsWith(SUMMARY_HEADER_PREFIX)) continue;
        // 머리말은 새 요약 것을 쓴다 — 같은 문장이 두 번 실릴 이유가 없다
        (line.startsWith(ARCHIVE_LINE_PREFIX) ? archives : body).push(line);
    }
}

괴담 3의 수정이 이 몇 줄이다. ‘우리가 만든 요약’을 머리말 접두사로 식별하고, 접을 때 회수 경로 줄만은 무조건 이월한다 — 그 줄이 잘리면 원문을 되찾을 길이 영영 사라지니까. 문자열 접두사라는 원시적인 수단이지만, 시스템 메시지에 타입 필드가 없는 세계에서는 이게 계약이다. 접두사를 상수로 export해 테스트가 같은 상수를 참조하게 한 것까지가 마무리다.

✅ 확인

  • 압축 판정의 입력이 실측치(KV 토큰)인지, 추정치인지 확인했다 — 오차 20%면 죽는다.
  • 요약이 반복될 때 요약 자신이 쌓이지 않는지 2회 이상 연속 압축으로 테스트했다.
  • 잘린 원문의 회수 통로가 있고, 그 경로 표시가 절단에서 보호되는지 확인했다.
  • 압축·시프트가 사용자에게 보이는지 — 조용한 컨텍스트 변형이 ‘조용히 틀린 답’의 뿌리다.

요약 형식도 실측이 바꿨다. 압축 후 “첫 번째 질문이 뭐였나”에 4B가 한 칸 밀려 답하는 걸 보고, 질문은 요약하지 않고 “질문 N:” 순번과 원문을 보존하는 클로드 코드 방식으로 바꿨다(순번은 재압축을 넘어 이어진다). 재평가에서 첫·두 번째 질문 모두 정답.

이 전장의 교훈 — ① 캐시 앞부분은 성역이다: 상시로 건드리지 말고, 찰 때 한 번 이산적으로 접어라 ② 요약은 손실 압축이니 원문 회수 통로(파일)를 남겨라 ③ 판정은 추정치 말고 실측치로. 셋 다 클로드 코드가 먼저 밟은 길이었고, 작은 모델일수록 그 길이 더 절실했다.

자주 묻는 질문 (FAQ)

컨텍스트 압축은 언제 하는 게 맞나요?

매 턴 상시 축소는 KV 접두사 캐시를 계속 깨서 성능 이득을 스스로 파괴합니다. 전문을 그대로 두다가 임계(예: 85%)에 닿을 때 한 번 요약으로 갈아 끼우는 이산 압축이 캐시 친화적입니다. 클로드 코드도 같은 방식입니다.

요약으로 잘린 내용은 영영 사라지나요?

이 프로젝트는 잘려 나가는 구간을 마크다운 파일로 보관하고 요약 끝에 그 경로를 답니다. 모델이 필요하면 기존 filesystem.read로 되읽습니다 — 회수용 도구를 새로 만들면 그 정의가 아끼려던 컨텍스트를 도로 먹기 때문입니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다