Claude compaction 비용은 usage.input_tokens에 잡히지 않는다

seonwooj0810·3일 전
post-thumbnail

긴 에이전트 작업에서 usage.input_tokens만 더해 비용을 추적하고 있다면, compaction을 켠 순간부터 그 숫자는 실제 소비량보다 작아질 수 있습니다. Claude API 문서 "Compaction at a token threshold"의 응답 예시가 그렇습니다. 최상위 usage에는 input_tokens: 23000, output_tokens: 1000이 찍혀 있는데, 바로 아래 iterations 배열에는 input_tokens: 180000, output_tokens: 3500짜리 항목이 하나 더 있습니다. 18만 토큰은 어디서 왔고, 왜 최상위 숫자에는 빠져 있을까요? (숫자는 문서 예시 값이고, 동작·기본값은 2026-10-30 기준 문서로 확인했습니다.)

한 요청 안에서 샘플링이 두 번 일어난다

threshold compaction은 Messages API 요청의 context_management.edits에 compact_20260112 전략을 넣고, 베타 헤더 compact-2026-01-12를 붙여 켭니다. 문서가 설명하는 흐름은 입력 토큰이 트리거에 도달했는지 감지하고, 대화 요약을 생성해 compaction 블록에 담은 뒤, 압축된 컨텍스트로 응답을 이어 가는 순서입니다. 요약 생성은 응답과 별개인 "추가 샘플링 단계"이고, 레이트 리밋과 과금에도 들어간다고 문서는 적습니다. 처음 예시의 18만 토큰은 요약 단계가 읽은 전체 대화이고, 2만 3천 토큰은 요약으로 줄어든 뒤 본 응답이 읽은 컨텍스트입니다. 요약은 요청에 지정한 모델이 그대로 수행하며 더 싼 모델로 바꿀 수 없습니다("Current limitations" 절).

문서의 파라미터 표는 다음과 같습니다.

파라미터기본값의미
trigger{"type": "input_tokens", "value": 150000}지원하는 타입은 input_tokens 하나, value는 최소 50,000
pause_after_compactionfalse요약 직후 멈출지 여부
instructionsnull요약 프롬프트. 주면 기본 프롬프트를 완전히 대체

문서 표현은 임계값에 "도달하면"이고, 등호 포함 여부까지는 적혀 있지 않습니다. 또 웹 검색 같은 서버 도구를 쓰는 요청에서는 트리거를 매 샘플링 반복의 시작마다 검사하므로, 출력량에 따라 한 요청 안에서 compaction이 여러 번 일어날 수 있다고 합니다.

compaction 블록 앞의 모든 것은 무시된다

요약이 끝나면 API는 응답 content 맨 앞에 compaction 블록을 넣습니다.

{
  "content": [
    { "type": "compaction", "content": "Summary of the conversation: ..." },
    { "type": "text", "text": "Based on our conversation so far..." }
  ]
}

클라이언트는 이 응답을 평소처럼 messages에 덧붙이면 됩니다. 다음 요청에서 API가 compaction 블록을 받으면 그 앞의 모든 콘텐츠 블록을 무시하므로, 원본 메시지는 남겨 둬도 잘라 내도 됩니다. 그렇다면 요약에 무엇이 빠져도 괜찮을까요? 대화가 길어져 여러 번 압축됐다면 마지막 compaction 블록이 최종 상태를 나타냅니다.

요약에 담기지 않은 정보는 다음 턴의 모델이 다시 볼 방법이 없고, 문서가 짚는 손실 지점은 세 가지입니다.

  • Claude 5.1 이후 모델에서는 compaction 블록 이전의 thinking 블록이 이어지지 않습니다. 앞선 작업에 대해 모델이 가진 것은 요약뿐입니다.
  • instructions를 직접 주면 기본 요약 프롬프트(상태·다음 단계·배운 점을 <summary> 태그에 적으라는 취지)가 통째로 대체되므로, 보존할 것을 직접 적어야 합니다.
  • tools가 정의된 요청에서는 모델이 요약 단계에서 요약을 쓰는 대신 도구를 호출하는 일이 가끔 있고, 그러면 content: null인 compaction 블록이 돌아옵니다. 문서는 "요약하는 동안 도구를 호출하지 말라"고 명시한 instructions로 이를 막으라고 안내합니다.

최근 몇 턴을 원문 그대로 남기고 싶다면 pause_after_compaction: true를 씁니다. 그러면 API는 요약 블록을 만든 직후 stop_reason: "compaction"으로 응답을 끝냅니다. 클라이언트는 보존할 메시지를 다시 붙여 이어 가는데, 5.1 이후 모델에서는 다시 붙인 assistant 턴의 thinking 블록이 prefix 검사에 걸리므로 빼야 한다고 문서는 경고합니다.

최상위 usage는 요약 단계를 빼고 센다

문서의 Note에 따르면 최상위 input_tokens와 output_tokens는 compaction 반복을 포함하지 않고, compaction이 아닌 반복들의 합만 담습니다. 요청 하나에서 실제로 소비·과금된 토큰을 구하려면 usage.iterations의 모든 항목을 더해야 합니다.

문서 예시로 계산하면 이 요청이 실제로 쓴 입력은 180,000 + 23,000 = 203,000 토큰이고, 출력은 3,500 + 1,000 = 4,500 토큰입니다. 최상위 숫자만 본 대시보드라면 입력의 약 89%를 놓치게 됩니다.

다만 베타가 켜진 응답에는 항상 iterations가 들어 있지만, compaction 항목은 그 요청에서 새로 compaction이 일어났을 때만 나타납니다. 이전에 받은 블록을 다시 보낼 때는 다시 요약하는 비용이 없고 최상위 usage도 정확하다고 문서는 적습니다. 그러니 누락은 임계값을 넘는 요청에서만 생기지만, 그 요청의 요약 단계는 임계값 이상 쌓인 대화 전체를 읽으므로 한 건 한 건이 큽니다.

캐시도 영향을 받습니다. 요약은 새 콘텐츠라 캐시에 새로 써야 하고, 시스템 프롬프트 끝에 별도 브레이크포인트가 없으면 그 캐시까지 무효화된다고 문서는 설명합니다(브레이크포인트 동작).

Java SDK로 반복별 토큰을 합산하기

anthropic-sdk-java 소스(main, 커밋 c0f5915)에서 BetaUsage.iterations()는 Optional<List<Iteration>>을 돌려주고, Iteration은 message·compaction 등 여러 변형의 유니언이면서 공통으로 inputTokens()·outputTokens()를 제공합니다. 트리거는 BetaInputTokensTrigger.of(value)로 만들고, 빌더가 type을 "input_tokens"로 미리 채워 둡니다.

아래는 그 API들로 요청을 보내고 반복별 토큰을 합산하는 스니펫입니다. 메서드 이름은 소스에서 확인했지만 이번 실행에서 컴파일하거나 API를 호출하지는 않았습니다.

import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.beta.AnthropicBeta;
import com.anthropic.models.beta.messages.*;

public class CompactionUsage {
    public static void main(String[] args) {
        AnthropicClient client = AnthropicOkHttpClient.fromEnv();

        MessageCreateParams params = MessageCreateParams.builder()
                .model("claude-opus-5-5")
                .maxTokens(4096)
                .addBeta(AnthropicBeta.COMPACT_2026_01_12)
                .contextManagement(BetaContextManagementConfig.builder()
                        .addEdit(BetaCompact20260112Edit.builder()
                                .trigger(BetaInputTokensTrigger.of(100_000)) // 최소 50,000
                                .build())
                        .build())
                .addUserMessage("이전 작업을 이어서 진행해 줘")
                .build();

        BetaMessage response = client.beta().messages().create(params);
        BetaUsage usage = response.usage();

        long input = 0, output = 0, compactionInput = 0;
        for (BetaUsage.Iteration it : usage.iterations().orElse(java.util.List.of())) {
            input += it.inputTokens();          // 과금 합산은 반복 전체 기준
            output += it.outputTokens();
            if (it.isCompaction()) compactionInput += it.inputTokens();
        }
        // 프롬프트 캐싱을 쓰면 cacheReadInputTokens()·cacheCreationInputTokens()도 함께 더해야 한다
        System.out.printf("top=%d sum=%d compaction=%d out=%d%n",
                usage.inputTokens(), input, compactionInput, output);
    }
}

다음 턴에는 addMessage(response)로 응답 전체를 덧붙이면 compaction 블록이 함께 돌아갑니다.

임계값 방식과 on-demand 방식은 같이 쓰지 않는다

여기까지 다룬 것은 API가 시점을 정하는 threshold 방식입니다. 2026-10-30 기준 Compaction overview 페이지는 쓸 수 있는 곳이라면 on-demand 방식을 먼저 쓰라고 권합니다. 이쪽은 애플리케이션이 요약 시점을 정하고, 베타 헤더(compact-2026-09-04)도 다르며, context_management 대신 최상위 compaction 파라미터를 씁니다. on-demand 문서는 두 방식을 한 요청에 섞을 수 없다고 명시합니다.

요약도 한 번의 추론이다

compaction은 컨텍스트를 줄여 주지만, 줄이는 작업 자체가 같은 모델이 전체 대화를 한 번 더 읽는 추론이고 그 비용은 usage.iterations에만 적힙니다. 비용 계측을 usage.input_tokens에 묶어 두었다면, compaction을 켜는 날 집계 기준도 iterations의 합으로 옮겨야 합니다.

참고 자료

profile
원리를 알아야 문제를 잘 풀 수 있다고 믿는 개발자의 기록

0개의 댓글