이번 시간에는 Claude Code와 API 사이에서 도구 결과를 압축해 준다는 로컬 프록시 Headroom이 실제로 토큰을 줄이는지를 claude -p로 측정하며 배워가는 시간입니다. claude -p는 대화 화면 없이 질문 하나를 보내고 결과를 받는 실행 방식이에요.
같은 프록시를 압축만 끄고 띄운 조건과 번갈아 보내서, 줄어든 입력과 대신 늘어난 호출을 함께 볼게요. 다시 돌리면 숫자가 달라질 수 있고, --model opus가 가리키는 모델도 시간이 지나면 달라질 수 있어요.
Headroom이 줄이는 것과 공개 수치
Headroom은 Claude Code가 보내는 요청을 내 컴퓨터에서 먼저 받아, 그 안의 테스트 로그·JSON·파일 내용 같은 도구 결과를 압축해 Anthropic API로 넘기는 프록시예요. 저장소 설명은 "20% fewer tokens for coding agents, 60-95% fewer tokens for JSON, same answers"이고, README의 고정 시드 오프라인 측정은 시나리오별 21~57% 절감을 보여요.
압축한 자리에는 원문을 다시 꺼내는 도구(headroom_retrieve)를 요청에 함께 넣어 두는데, 모델이 이 도구를 부르면 Headroom이 원문을 붙여 API를 한 번 더 부릅니다. 저자는 한계 문서에서 여러 턴 에이전트 대화는 "Not independently measured"라고 적었어요.
실습 준비
이 글의 명령은 모두 실습 코드의 headroom-proxy-compress/ 디렉터리에서 실행합니다.
gen_project.py: 고정 시드로 실습 프로젝트work/와 정답truth.json을 만드는 스크립트proxy.sh: 압축 모델을 미리 받고, 두 조건의 프록시와 사용량 기록 중계를 띄우는 스크립트acct.py: 프록시와 API 사이에서 요청마다 사용량(usage)을 기록하는 중계prompt-fixed.txt,prompt-free.txt: 명령을 지정한 질문과 Claude가 명령을 고르는 질문schedule.txt,run.sh: 호출 순서와, 그 순서대로claude -p를 부르는 스크립트summarize.py: 결과를 조건별로 모으고 정답을 판정하는 스크립트README.md: 파일 목록
따라 하시려면 Claude Code 2.1.309와 구독 로그인, Docker, jq, 그리고 Claude가 테스트를 돌릴 Mac의 python3가 필요해요.
공식 설치는 pip install "headroom-ai[all]" 뒤 headroom wrap claude예요. wrap은 프록시를 띄우고 ANTHROPIC_BASE_URL과 ENABLE_TOOL_SEARCH=true를 넣어 Claude Code를 실행해 줍니다. wrap은 Serena MCP 서버도 등록하는데, 여기서는 Mac에 아무것도 설치하지 않고 압축을 끈 프록시와 나란히 비교하려고 공식 Docker 이미지로 프록시만 띄울게요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
py312() { docker run --rm --network none -v "$PWD":/w -w /w python:3.12-slim python -I "$@"; }
excerpt() { jq 'select(.type == "result") | {usage: (.usage | {input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens}), total_cost_usd, num_turns, duration_ms}' "$@"; }
gen_project.py는 주문 영수증을 계산하는 작은 패키지와 테스트 150개를 만들어요. 테스트마다 JSON 로그 한 줄을 찍고, "10개 이상이면 10% 할인" 규칙을 qty > 10으로 잘못 쓴 탓에 수량이 정확히 10인 주문 두 건이 실패합니다.
python3 gen_project.py
work/: 주문 150건, 테스트 150개
{"file": "shop/pricing.py", "line": 11, "failing": ["test_order_0061", "test_order_0137"]}
정답은 work/ 밖의 truth.json에 둬서 Claude가 볼 일이 없게 했어요.
Headroom은 처음 뜰 때 텍스트 압축 모델(Kompress)을 huggingface.co에서 받아요. ./proxy.sh model은 익명 통계 전송(HEADROOM_BEACON=off, DO_NOT_TRACK=1)과 업데이트 확인을 끈 채 이 모델을 Docker 볼륨에 미리 받아 둡니다.
chmod +x proxy.sh run.sh
./proxy.sh model
Downloading Kompress ONNX model from chopratejas/kompress-v2-base ...
Kompress: warmed in the background in 27482 ms (backend onnx)
264M /root/.cache/huggingface
측정용 프록시는 모델을 받아 둔 볼륨을 붙이고 HEADROOM_OFFLINE=1로 띄워서, 내 요청을 API로 넘기는 것 말고 Headroom이 스스로 여는 바깥 연결을 모두 막아요. ./proxy.sh의 핵심 부분은 이렇습니다(발췌).
for cond in base hr; do
touch "logs/acct-$cond.jsonl" "logs/proxy-$cond.jsonl"
docker run -d --name "acct-$cond" --label hr-lab=1 -v "$PWD/acct.py:/acct.py:ro" -v "$PWD/logs:/logs" \
python:3.12-slim python -I /acct.py "/logs/acct-$cond.jsonl" > /dev/null
ip=$(docker inspect -f '{{.NetworkSettings.IPAddress}}' "acct-$cond")
port=8787; opt=""
if [ "$cond" = base ]; then port=8788; opt="--no-optimize"; fi
docker run -d --name "proxy-$cond" --label hr-lab=1 -p "127.0.0.1:$port:8787" \
--add-host "api.anthropic.com:$ip" -e ANTHROPIC_TARGET_API_URL=http://api.anthropic.com:9000 \
-e HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1 -e HEADROOM_OFFLINE=1 "${QUIET[@]}" "${MODEL[@]}" \
-v "$PWD/logs:/logs" "$IMG" --host 0.0.0.0 --port 8787 --no-subscription-tracking \
--log-file "/logs/proxy-$cond.jsonl" --log-messages $opt > /dev/null
echo "proxy-$cond: http://127.0.0.1:$port $opt"
done
두 조건은 같은 이미지, 같은 설정이고 기준 조건 base에만 압축을 끄는 --no-optimize가 붙어요. 이 옵션을 켠 프록시는 요청을 한 바이트도 바꾸지 않고 넘겨요. Headroom 쪽은 압축 말고도 원문을 꺼내는 도구를 붙이고 일부 요청을 스트리밍 없이 보내는데, 이 때문에 시간이 달라질 수 있어 시간은 해석하지 않았어요.
-p "127.0.0.1:$port:8787": 프록시를 내 컴퓨터 안에서만 열어요. 컨테이너 안에서 0.0.0.0으로 듣게 하려고HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1을 줬습니다.acct.py: Headroom이 원문을 꺼내 주며 부르는 추가 호출은 Claude Code의 결과 JSON에 잡히지 않아서, 프록시 뒤에 이 중계를 두고 실제로 API에 간 요청의 usage를 모두 셉니다. 인증 헤더는 기록하지 않아요.--add-host api.anthropic.com:$ip: Headroom은 상대 주소가api.anthropic.com이 아니면 다른 제공자로 보고 도구 처리를 바꿔요. 그래서 이름은 그대로 두고 그 이름이 중계를 가리키게 했습니다.
./proxy.sh
proxy-base: http://127.0.0.1:8788 --no-optimize
proxy-hr: http://127.0.0.1:8787
실습 1: 큰 테스트 출력을 그대로 받게 한 과제
Headroom이 내세우는 경우처럼 큰 테스트 로그가 실제로 요청에 들어가게, 명령을 지정한 질문을 만들었어요.
cat prompt-fixed.txt
이 저장소의 테스트 중 일부가 실패합니다. 먼저 `python3 -m unittest discover -s tests -t . -v 2>&1`을 그대로 한 번 실행해(tail, head, grep 같은 필터를 붙이지 마세요) 전체 출력에서 실패한 테스트를 확인하고, 원인이 되는 코드 위치를 찾아 짧게 설명해 주세요. 파일은 고치지 마세요. 마지막 줄은 정확히 `ANSWER: <파일 경로>:<줄 번호> <실패한 테스트 메서드 이름들을 쉼표로>` 형식으로 써 주세요.
run.sh는 schedule.txt의 순서대로 질문을 보내요. claude -p를 부르는 부분은 이렇습니다(발췌).
ask() {
local cond="$1" prompt="$2" out="$3" port=8787
if [ "$cond" = base ]; then port=8788; fi
(cd work && ANTHROPIC_BASE_URL="http://127.0.0.1:$port" ENABLE_TOOL_SEARCH=true \
perl -e 'alarm shift; exec @ARGV' 900 \
claude -p "$prompt" \
--tools "Bash,Read,Grep,Glob" --allowedTools "Bash Read Grep Glob" \
--model opus --effort medium \
--setting-sources project --strict-mcp-config \
--no-session-persistence --output-format stream-json --verbose \
< /dev/null) > "$out" 2> "${out%.jsonl}.err"
find work -name __pycache__ -type d -prune -exec rm -rf {} +
}
base URL을 바꾸면 Claude Code가 도구 정의를 미뤄 싣는 동작이 달라질 수 있어서, 두 조건에 ENABLE_TOOL_SEARCH=true를 똑같이 줬어요. 호출마다 두 로그에서 그 호출 몫을 run-<회차>.acct.jsonl, run-<회차>.proxy.jsonl로 잘라 남깁니다.
./run.sh
명령을 지정한 과제 20번과 실습 2의 과제 8번을 한 번에 돌려요. 출력은 이렇게 시작합니다(발췌).
fixed base 1
fixed hr 1
fixed hr 2
fixed base 2
먼저 같은 명령의 원본 출력이 얼마나 큰지 볼게요. 출력에는 테스트 파일의 절대 경로가 들어가서, 실습 디렉터리 위치에 따라 크기가 조금 달라요.
(cd work && python3 -m unittest discover -s tests -t . -v > ../out/raw.txt 2>&1); wc -c out/raw.txt
py312 summarize.py pair out/fixed/hr/run-1.proxy.jsonl
grep -n "characters truncated" out/pair-before.txt
28569 out/raw.txt
압축 전 10,040자 -> 압축 후 7,656자
60:... [18580 characters truncated] ...
summarize.py pair는 프록시 로그에서 처음 압축된 도구 결과를 찾아 압축 전(out/pair-before.txt)과 압축 후(out/pair-after.txt)로 꺼내요. 압축 전이라는 쪽이 이미 10,040자예요.
왜 그럴까요? Claude Code 2.1.309가 이 명령의 출력 28,569자에서 가운데 18,580자를 잘라 앞뒤만 보냈기 때문이에요. Headroom이 손댈 수 있는 건 남은 1만 자였고, 1회차에 2,384자를 더 줄였습니다.
이 자름은 테스트가 실패해 오류로 끝난 명령에서 본 거예요. 실습 2에서 성공으로 끝난 2만 9천 자 출력은 잘리지 않고 그대로 들어갔어요.
무엇이 줄었는지 같은 부분을 나란히 볼게요.
sed -n '68,71p' out/pair-before.txt
sed -n '54,56p;72p' out/pair-after.txt
test_order_0136 (tests.test_orders.OrderReceipts) ... {"event": "priced", "sku": "SKU-017", "qty": 9, "subtotal": 355.05, "tax": 0.0, "total": 355.05, "category": "books"}
ok
test_order_0137 (tests.test_orders.OrderReceipts) ... {"event": "priced", "sku": "SKU-020", "qty": 10, "subtotal": 301.6, "tax": 0.0, "total": 301.6, "category": "books"}
FAIL
test_order_0136 (tests.test_orders.OrderReceipts) "SKU-017", 9, 355.05, 0.0, 355.05,
(tests.test_orders.OrderReceipts) "SKU-020", 10, 301.6, 0.0, 301.6,
FAIL
[739 words compressed to 455 (from 89 source lines). Original content preserved. Retrieve more: hash=c9637c426f3ad1a9e09a8e90]======================================================================
Kompress 모델이 로그 줄에서 키 이름과 ok, 실패한 테스트의 이름까지 지웠어요. 끝에는 원문을 다시 꺼낼 때 쓰는 해시가 붙습니다. 실패한 두 테스트의 트레이스백은 그 뒤에 그대로 남아 있었어요.
20번 결과에서 조건별 머리 줄, 숨은 호출(Headroom이 원문을 꺼내 주려고 Claude Code 몰래 따로 부른 API 호출)이 있던 회차, 중앙값, 턴 수별 중앙값, 합계만 골라 볼게요(발췌). "입력"은 결과 JSON의 입력 세 필드 합이고, "업스트림"은 중계가 센 실제 API 호출 수와 그 입력 합이에요.
py312 summarize.py fixed | grep -E '^fixed|숨은 호출 1|중앙값|턴:|합계'
fixed base: 10회, 정답 10/10
중앙값: 입력 45,228, 업스트림 입력 45,228, 캐시 쓰기 6,100, 출력 1,132, $0.0795, 4.0턴, 17.0초
3턴: 1회, 입력 중앙값 32,358
4턴: 6회, 입력 중앙값 45,062
5턴: 3회, 입력 중앙값 58,127
합계: 입력 477,820, 업스트림 입력 477,820, 숨은 호출 0회
fixed hr: 10회, 정답 10/10
run-1: 정답 O, 압축 1, 잘림 1, 입력 46,260 (캐시 쓰기 765), 출력 1,153, $0.0569, 4턴, 34.5초, 업스트림 5회(숨은 호출 1, 입력 57,362)
run-4: 정답 O, 압축 1, 잘림 1, 입력 45,639 (캐시 쓰기 808), 출력 982, $0.0415, 4턴, 23.7초, 업스트림 5회(숨은 호출 1, 입력 57,598)
중앙값: 입력 44,328, 업스트림 입력 44,328, 캐시 쓰기 3,373, 출력 1,139, $0.0646, 4.0턴, 19.9초
4턴: 9회, 입력 중앙값 44,247
5턴: 1회, 입력 중앙값 56,988
합계: 입력 458,699, 업스트림 입력 481,760, 숨은 호출 2회
정답은 두 조건 모두 10번 중 10번이었고, Headroom은 10번 모두 테스트 출력을 압축했어요. 입력 중앙값은 45,228에서 44,328로 2.0% 줄었습니다.
비용 추정 중앙값($0.0795 대 $0.0646)은 그대로 믿기 어려워요. Headroom 쪽 10번 중 5번은 앞 호출이 캐시에 써 둔 요청을 그대로 읽어 캐시 쓰기가 1천 토큰 안팎이었고, 이 차이가 비용을 갈랐습니다. 아래에서 볼 숨은 호출 몫도 이 추정치에는 들어 있지 않아요.
1회차와 4회차에 숨은 호출이 있었어요. 1회차의 결과 JSON과, 같은 호출에서 중계가 센 요청을 나란히 볼게요.
excerpt out/fixed/hr/run-1.jsonl
jq -c 'select(.path | startswith("/v1/messages")) | {n_messages, usage}' out/fixed/hr/run-1.acct.jsonl
{
"usage": {
"input_tokens": 4896,
"cache_creation_input_tokens": 765,
"cache_read_input_tokens": 40599,
"output_tokens": 1153
},
"total_cost_usd": 0.056883800000000005,
"num_turns": 4,
"duration_ms": 34485
}
{"n_messages":2,"usage":{"input_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":7021,"output_tokens":97}}
{"n_messages":4,"usage":{"input_tokens":2,"cache_creation_input_tokens":4079,"cache_read_input_tokens":7021,"output_tokens":300}}
{"n_messages":6,"usage":{"input_tokens":4890,"cache_creation_input_tokens":0,"cache_read_input_tokens":11100,"output_tokens":140}}
{"n_messages":6,"usage":{"input_tokens":2,"cache_creation_input_tokens":278,"cache_read_input_tokens":11100,"output_tokens":134}}
{"n_messages":8,"usage":{"input_tokens":2,"cache_creation_input_tokens":487,"cache_read_input_tokens":11378,"output_tokens":782}}
결과 JSON은 4턴인데 API로 간 요청은 5개예요. 두 번째 요청에서 모델이 압축된 테스트 출력을 보고 headroom_retrieve로 원문을 달라고 했고, Headroom은 이 호출을 Claude Code에 돌려주지 않고 원문을 붙여 세 번째 요청을 보냈어요.
결과 JSON에는 세 번째 요청의 usage만 남고, 두 번째 요청의 입력 11,102토큰과 출력 300토큰은 빠졌어요. 세 번째 요청은 되찾은 원문 때문에 캐시되지 않은 입력이 4,890토큰이었습니다.
숨은 호출 두 번을 더하면, 10번 합계로는 결과 JSON 기준 477,820 대 458,699(4.0% 감소)가 실제 API 기준 477,820 대 481,760(0.8% 증가)으로 바뀌어요. 결과 JSON만 보면 이 차이를 놓치게 됩니다.
다만 기준 쪽에는 5턴 회차가 세 번, Headroom 쪽에는 한 번이라 합계는 턴 수에 크게 흔들려요. 턴 수가 같은 4턴 회차끼리 보면 입력 중앙값 차이는 45,062 대 44,247로 815토큰이고, 10번을 더해도 숨은 호출 두 번이 더한 23,061토큰(481,760에서 458,699를 뺀 값)보다 작았어요.
2회차부터는 압축 폭도 줄었어요.
py312 summarize.py pair out/fixed/hr/run-2.proxy.jsonl
grep -o "\[[0-9]* words compressed to [0-9]*" out/pair-after.txt
압축 전 10,040자 -> 압축 후 9,548자
[234 words compressed to 163
2~10회차는 모두 이 크기였어요. Kompress에 넘긴 텍스트가 1회차 739단어에서 2회차부터 234단어로 줄었는데, 왜 그런지는 확인하지 못했어요. 약해진 압축을 받은 4회차에서도 원문 재요청은 다시 있었습니다.
실습 2: Claude가 명령을 고르는 과제
prompt-free.txt는 prompt-fixed.txt에서 실행할 명령과 "필터를 붙이지 말라"는 부분만 뺀 질문이에요. 실제로 쓸 때처럼 무엇을 실행할지는 Claude가 정합니다.
py312 summarize.py free | grep -E '^free|테스트 명령|^ 중앙값'
py312 summarize.py pair out/free/hr/run-1.proxy.jsonl
free base: 4회, 정답 4/4
테스트 명령: python3 -m pytest -q 2>&1 | tail -40
테스트 명령: python3 -m pytest -q 2>&1 | tail -40
테스트 명령: python -m pytest -q 2>&1 | tail -40
테스트 명령: python3 -I -m unittest discover -s tests -t . 2>&1 | tail -40
중앙값: 입력 28,240, 업스트림 입력 28,240, 캐시 쓰기 3,882, 출력 884, $0.0536, 3.5턴, 15.4초
free hr: 4회, 정답 4/4
테스트 명령: python3 -m unittest discover -s tests -t . 2>&1 | grep -E '^(FAIL|ERROR):|^Ran|^FAILED|^OK'
테스트 명령: python3 -m pytest -q 2>&1 | tail -40
테스트 명령: python3 -m pytest -q 2>&1 | tail -40
테스트 명령: python3 -m pytest -q 2>&1 | tail -40
중앙값: 입력 42,862, 업스트림 입력 42,862, 캐시 쓰기 3,842, 출력 1,173, $0.0650, 4.5턴, 19.0초
압축 전 140자 -> 압축 후 131자
8번 모두 Claude가 테스트 출력을 tail -40이나 grep으로 직접 줄여서 받았어요. python3 -m pytest는 이 Mac에 pytest가 없어 오류 한 줄만 받았고, 실제 결과는 그다음 unittest에 tail이나 grep을 붙여 받았습니다.
Headroom이 압축한 건 두 번, 파일 목록 140자를 131자로 줄인 게 전부였어요. 기준 2회차와 Headroom 4회차는 Claude가 주문 JSON 파일을 cat으로 통째로 읽어 2만 9천 자가 그대로 들어갔는데, Headroom은 이 출력을 압축하지 않았어요(프록시 로그 router:noop, 라우터 기록 read_protected).
입력 중앙값의 차이(28,240 대 42,862)는 회차마다 Claude가 파일을 얼마나 읽었는지에 따라 갈린 것이라, 4번씩으로는 Headroom의 효과로 볼 수 없습니다.
정리
범위: Claude Code 2.1.309의 claude -p, Opus 5.5(claude-opus-5-5), effort medium, Headroom 0.41.0 공식 Docker 이미지의 기본 설정 대 --no-optimize, 두 조건 모두 ENABLE_TOOL_SEARCH=true와 사용량 기록 중계 경유, 테스트 150개 프로젝트 하나에 명령 지정 과제 20번과 자유 과제 8번.
- 명령을 지정해 큰 테스트 출력을 받게 하자 Headroom은 10번 모두 압축했지만, 입력 토큰 중앙값은 45,228에서 44,328로 2.0%만 줄었습니다. Claude Code가 실패한 명령의 출력 28,569자를 이미 1만 자로 잘라 보내고 있어서, Headroom이 줄일 수 있는 건 그 1만 자뿐이었어요.
- 압축된 출력을 본 모델이 원문을 다시 달라고 한 회차가 10번 중 2번 있었고, 그때마다 Headroom이 API를 한 번 더 불러 입력이 1만 1천~1만 2천 토큰 더 들었습니다. 이 호출은 결과 JSON에 잡히지 않으니, 프록시 뒤에서 직접 세어 보세요.
- 숨은 호출까지 더한 10번 합계는 481,760으로, 압축을 끈 조건의 477,820보다 0.8% 많았습니다. 결과 JSON만 더하면 4.0% 줄어든 것처럼 보이지만, 숨은 호출 두 번이 더한 23,061토큰이 10번 동안 압축으로 줄인 양보다 컸어요.
- Claude가 명령을 고르게 한 8번은 모두 테스트 출력을
tail -40이나grep으로 줄여 받았고, Headroom이 압축한 건 파일 목록 9자씩 두 번이었습니다.cat으로 읽은 2만 9천 자 JSON은 압축하지 않았으니, 내 작업에서 어떤 출력이 실제로 압축되는지 프록시 로그로 확인하세요. - 공식 이미지는 첫 실행 때 압축 모델 264MB를 받고 익명 통계 전송이 기본으로 켜져 있어요. 측정이나 사내망에서는 모델을 볼륨에 미리 받아 두고
HEADROOM_BEACON=off,HEADROOM_OFFLINE=1로 띄우세요.