src/core/*.ts)를 뜯어, "왜 글자를 그림으로 만들면 토큰이 줄어드는가"라는 반직관적 트릭의 작동 원리와 그 한계를 초보자용으로 풀어낸다.
이 저장소가 대체 무엇인가.
pxpipe(npm 패키지명 pxpipe-proxy)는 Claude Code나 Codex 같은 AI 코딩 에이전트와 실제 모델 API 사이에 끼어 앉는 로컬 프록시(local proxy)다. 에이전트가 API로 보내는 요청에서 부피가 큰 텍스트 덩어리 — 시스템 프롬프트, 툴 설명 문서, 이미 지나간 대화 기록 — 를 가로채, 그것을 작고 조밀한 PNG 이미지로 그려서 대신 실어 보낸다. 그 결과 입력 토큰이 대략 60~70% 줄어든다.
127.0.0.1:47821에 떠서 "에이전트 → (pxpipe) → api.anthropic.com" 흐름의 가운데를 맡는다. 에이전트 입장에선 그냥 API처럼 보인다.AI 에이전트는 대화가 이어질수록 매 턴마다 "너는 이런 도구를 쓸 수 있고, 규칙은 이렇고, 지금까지 이런 얘기를 했어"를 텍스트로 처음부터 다시 보낸다. 이 재전송 분량이 입력 요금의 대부분이다.
pxpipe는 그 반복되는 두꺼운 부분을 글자가 빼곡히 박힌 사진 한 장으로 바꿔 보낸다. 사람이 스크린샷 속 글자를 읽듯, 요즘 모델은 이미지 속 글자를 읽는다. 그런데 사진의 요금은 담긴 글자 수가 아니라 사진의 픽셀 크기로 고정돼 있어서, 같은 내용을 훨씬 싸게 실어 나른다.
텍스트로 보내면 약 7,000 토큰인 분량을, 이미지로는 약 1,522 토큰에 담는다 (약 4.6배 밀도). 실제 코딩 트래픽에서는 텍스트 토큰이 더 잘게 쪼개져서, 체감 절감이 약 68%까지 나온다. 버전 0.8.0, 라이선스 MIT, 주 언어 TypeScript.
이미지 속 정확한 문자열(긴 ID, 해시, 시크릿)을 모델이 글자 하나 안 틀리고 되읽는 것은 보장되지 않는다. 그것도 에러를 내는 게 아니라 그럴듯한 값으로 조용히 지어낸다. 그래서 pxpipe는 최근 대화·정확값은 텍스트로 남기고, 기본적으로 Fable 5 모델에서만 켠다. 자세한 원리는 §2·§4에서.
트렌딩 이유와, 왜 하필 지금 이게 통하게 됐는가.
핵심 통찰은 딱 한 줄이다. 이미지의 토큰 비용은 그 안에 담긴 글자 수가 아니라, 이미지의 픽셀 크기로 결정된다. 이 비대칭을 극한까지 밀어붙인 게 pxpipe다.
소스의 src/core/render.ts 주석에는 2026-07-01에 직접 측정한 근거가 박혀 있다. Anthropic API는 이미지를 이렇게 과금한다:
즉 28,080자를 텍스트로 보내면(프로즈 기준 대략 4자/토큰) 약 7,020 토큰이지만, 이미지로는 약 1,522 토큰이다. 게다가 코드·JSON처럼 토큰이 잘게 쪼개지는 조밀한 콘텐츠에서는 텍스트 효율이 1자/토큰 근처까지 나빠지기 때문에, 이미지 쪽의 상대 이득이 더 커져 실측 절감이 59% → 70% 구간으로 나온다.
택배 요금이 "상자 안 물건 개수"가 아니라 "상자 크기"로만 매겨진다고 하자. 그러면 같은 크기 상자에 물건을 최대한 빽빽하게 채워 넣는 사람이 이긴다. pxpipe의 reflow가 하는 일이 정확히 이거다 — 글자를 페이지에 빈틈없이 채워 "상자 채움률"을 29%에서 75~80%로 끌어올린다.
이 아이디어 자체는 새롭지 않다. 2025년 10월 DeepSeek-OCR이 "이미지가 텍스트 컨텍스트의 채널이 될 수 있다"를 전용 인코더로 증명했다. 하지만 당시엔 어떤 상용 프로덕션 모델도 이렇게 빽빽한 렌더를 제대로 못 읽었다. pxpipe의 README가 지목하는 변곡점은 Fable 5다 — 같은 페이지에서 12자리 hex를 Fable 5는 15개 중 13개 읽었고, Opus 4.8은 0개를 읽었다. "이제 최전선 모델이 조밀 렌더를 읽는다"는 이 변화에 올라탄 첫 프로덕션 프록시라는 점이 트렌딩의 핵심이다.
| 구분 | DeepSeek-OCR (2025.10) | pxpipe (2026) |
|---|---|---|
| 목적 | "이미지=컨텍스트 채널" 가능성 증명 | 실제 프로덕션 트래픽에서 요금 절감 |
| 모델 | 전용 인코더/디코더 필요 | stock Fable 5 등 상용 모델 그대로 |
| 형태 | 연구 데모 | npx 한 방 로컬 프록시 + 대시보드 |
| 대상 | - | Claude Code · Codex(OpenAI 호환) |
SWE-bench Lite 파일럿에서 켠 쪽·끈 쪽 모두 10/10 통과에 요청 토큰은 −65%. 13,709개 요청 스냅샷에서 59% 절감($100→~$41), 이후 8,904개 압축 요청 추적에서 ~70%. 자기참조적으로 흥미로운 점: 이 레포의 커밋 대부분을, pxpipe 뒤에서 도는 에이전트가 자기 대화 기록을 이미지로 읽으며 작성했다고 README가 밝힌다.
무엇으로 만들어졌나 — 언어·의존성·렌더러·빌드.
가장 놀라운 사실부터: 이 프로젝트는 런타임 의존성이 딱 1개다. 이미지를 그리는데 흔히 쓰는 무거운 네이티브 캔버스 라이브러리가 런타임에는 하나도 없다.
| 계층 | 선택 | 메모 |
|---|---|---|
| 언어 | TypeScript (ESM, "type":"module") | .ts 80개. 코어는 런타임 무관하게 작성 |
| 런타임 | Node ≥ 18 + Cloudflare Workers | 같은 코어 코드가 서버·엣지 양쪽에서 동작 |
| 런타임 의존성 | gpt-tokenizer@^3.4.0 단 1개 | GPT/Codex 경로의 토큰 수 추정용 |
| 렌더러 | 자체 제작 (무의존) | 비트맵 폰트 아틀라스 + 순수 JS PNG 인코더 |
| 폰트 | Spleen 5×8 + Unifont(다국어), 옵션 JetBrains Mono | assets/에 원본+라이선스 동봉 |
| 빌드 의존성 | @napi-rs/canvas, esbuild 0.28, tsx, vite 8, vitest 4.1, wrangler 4.92 | @napi-rs/canvas는 빌드 타임에만 씀 |
| 패키지 매니저 | pnpm@10.21.0 | — |
@napi-rs/canvas로 이 표를 만들어 src/core/atlas*.ts에 Uint8Array 상수로 박아둔다. 그래서 실행할 때는 무거운 폰트 엔진 없이, 표에서 글자 모양을 복사(blit)만 하면 된다. 이 덕분에 브라우저 엔진이 없는 Cloudflare 엣지에서도 이미지를 그릴 수 있다.글자를 그릴 때마다 붓과 물감(폰트 엔진)을 꺼내는 대신, 글자 도장 세트를 미리 만들어 두고 필요한 도장을 찍기만 하는 방식이다. 도장 세트는 공장(빌드)에서 미리 만들고, 현장(런타임)에서는 찍기만 한다.
요청 하나가 프록시 안에서 어떻게 이미지로 변신하는가.
지원하지 않는 경로·모델이거나 POST가 아니면, 바이트 하나 안 바꾸고 그대로 통과시킨다(fail-closed = 문제 상황에선 무조건 원본 유지). 모델 응답은 절대 건드리지 않는다.
| 파일 | 줄수 | 역할 |
|---|---|---|
| core/transform.ts | 2,275 | 변환 파이프라인 본체 — 요청을 이미지 섞인 형태로 재조립 |
| core/render.ts | 1,254 | 텍스트→PNG 렌더러 — 아틀라스 blit, reflow, 페이지네이션 |
| core/proxy.ts | 842 | 런타임 무관 fetch 핸들러 — 라우팅·SSE 스트림 파싱·usage 추출 |
| core/history.ts | 666 | 오래된 대화 턴만 골라 이미지로 접기(collapse) |
| core/factsheet.ts | 253 | 이미지 옆에 붙일 정확값(ID/경로/SHA) 텍스트 추출 |
| core/png.ts | 149 | 순수 JS PNG 인코더 (네이티브 zlib 없이) |
| core/applicability.ts | 125 | 모델 allowlist 게이트 — 기본 fable-5만 허용 |
| core/measurement.ts / baseline.ts | 214 / 132 | counterfactual 토큰 측정 + 비용 수식 |
| node.ts / worker.ts | 1,112 / 152 | Node http 서버 / Cloudflare Worker 진입점 |
| dashboard.ts | 1,526 | 절감량·text→image 비교·kill switch UI |
LLM API는 프롬프트 앞부분이 글자까지 똑같으면 캐시해서 싸게 재사용한다(prompt caching). 그런데 이미지로 바꾸면 이 캐시가 깨질 위험이 있다. 그래서 pxpipe는 system을 "매 턴 바뀌는 부분(billingLine·<env>)"과 "안 바뀌는 부분(정적 지침)"으로 쪼개, 안 바뀌는 부분만 이미지로 만들고 앞쪽 정적 prefix를 보존한다.
조밀한 콘텐츠만 이미지화하고, 성긴 산문은 텍스트로 통과시킨다. transform.ts의 판정 수식은 대략 이렇다:
// 이미지로 바꿨을 때 총비용 < 텍스트로 뒀을 때 총비용 이면 압축
return imageTokensCost + burnImageSide
< textTokensEquivalent + burnTextSide;
여기서 burn 항은 "모드를 바꾸면 따뜻하게 데워둔 캐시가 무효화되는 비용"을 양쪽에 대칭으로 더한 것. 이게 없으면 게이트가 매 턴 켰다 껐다 깜빡여(flapping) 오히려 손해다. 실제 프로덕션 391개 행으로 이 수식을 보정했다.
매 요청마다 압축 안 한 원본 바디로 무료 count_tokens 프로브를 동시에 쏴서, "안 했으면 얼마였을지"를 같은 행에 같이 기록한다. 턴 수나 실행 편차 같은 교란요인 없이 "이번 요청에서 실제로 아낀 양"을 바로 비교한다. 코드에는 이 정직성을 CI에서 검증하는 savings-honesty.test.ts까지 있다.
어디에 무엇이 있나.
docs/의 NOT-OCR.md는 특히 읽어볼 가치가 있다 — "왜 이 방식의 오독이 OCR 오류와 달리 조용한 지어냄(silent confabulation)인가"를 설명한다. src(15,257줄)와 tests(13,868줄)가 거의 1:1이라는 점도, 이 프로젝트가 "요금 절감이 진짜인지"를 얼마나 자기검증에 집착하는지 보여준다.
이 레포에서 실제로 배울 수 있는 것.
"이미지는 픽셀로 과금되고 텍스트는 토큰으로 과금된다"는 비대칭을 실전에서 어떻게 돈으로 바꾸는지 배운다. render.ts 주석의 측정 방법(직접 count_tokens sweep을 돌려 경계를 찾음)은 API 요금을 리버스 엔지니어링하는 좋은 예시다.
코어를 Web 표준(Request/Response/fetch/crypto.subtle/CompressionStream)만으로 짜서 Node와 Cloudflare Workers 양쪽에서 같은 코드가 도는 구조. "플랫폼 API가 아니라 표준 API에 의존하라"는 이식성 설계의 교과서 사례다.
prompt caching이 어떻게 동작하는지, 그리고 "앞부분을 글자까지 고정해야 캐시가 산다"는 제약 아래에서 무엇을 이미지로 바꾸고 무엇을 텍스트로 남길지 결정하는 법. LLM 비용 최적화의 실전 감각을 준다.
png.ts 149줄만 읽어도 PNG 파일 포맷(청크·필터·zlib deflate)의 뼈대를 이해할 수 있다. 네이티브 라이브러리 없이 이미지 바이트를 손으로 조립하는 경험은 흔치 않다.
PXPIPE_DUMP_DIR 환경변수를 켜면 pxpipe가 실제로 모델에 보내는 PNG를 파일로 떨궈준다. 이걸 열어보면 "내 시스템 프롬프트가 이렇게 생긴 사진으로 바뀌어 나가는구나"를 눈으로 확인할 수 있다. §8에서 단계별로 다룬다.
무엇이 있어야 돌아가나 — 30초면 켠다.
| 항목 | 요구/기본값 |
|---|---|
| 런타임 | Node ≥ 18 (또는 Cloudflare Workers) |
| 설치 | npx pxpipe-proxy — 전역 설치 불필요 |
| 포트/호스트 | 기본 127.0.0.1:47821 (loopback 전용) |
| API 키 | 기존 것 그대로 — 프록시는 키를 저장하지 않고 통과만 |
| 대상 모델 | 기본 claude-fable-5만 압축 (나머지는 opt-in) |
# 1) 프록시 기동
npx pxpipe-proxy
# 2) Claude Code를 프록시로 향하게
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude
# 대시보드: 브라우저에서 http://127.0.0.1:47821/
기본 호스트가 127.0.0.1인 이유가 이것이다. HOST=0.0.0.0으로 바꿔 LAN에 노출하면 같은 네트워크의 누구나 대시보드(및 트래픽 기록)를 볼 수 있다. 꼭 필요할 때만 opt-in.
| 변수 | 의미 |
|---|---|
| PXPIPE_MODELS | 이미지화할 모델 CSV. 기본 claude-fable-5, off면 비활성 |
| PXPIPE_DUMP_DIR | 렌더된 PNG를 이 폴더로 덤프(디버그·학습용) |
| PXPIPE_LOG | 이벤트 로그 경로 (기본 ~/.pxpipe/events.jsonl) |
| OPENAI_BASE_URL | Codex 등 OpenAI 호환 클라이언트를 …:47821/v1로 |
| ANTHROPIC_MAX_HEIGHT_PX 등 | 이미지 geometry(높이·열수·픽셀/토큰) 미세 조정 |
눈으로 확인하는 것부터, 코드를 뜯는 것까지.
npx pxpipe-proxy로 띄우고 Claude Code를 붙인 뒤, 몇 턴 대화하고 127.0.0.1:47821 대시보드를 연다. "text vs image" 나란히 비교와 누적 절감률을 관찰한다. kill switch로 껐다 켜며 차이를 체감해 본다.
PXPIPE_DUMP_DIR=./dump를 켜고 한 세션 돌린 뒤, 떨어진 PNG를 연다. 312열 × 90행에 글자가 빽빽이 박힌 페이지를 직접 보고, "사람인 나는 이걸 얼마나 읽을 수 있나"를 모델의 성능과 비교해 본다.
프록시 없이 코드에서 직접 호출:
import { renderTextToImages } from "pxpipe-proxy";
const { pages } = await renderTextToImages(longText);
// pages[i].png : Uint8Array → 파일로 저장해 열어보기
긴 텍스트를 넣고 페이지가 몇 장 나오는지, 한 장에 몇 자가 들어가는지 세어 본다.
transform.ts의 isCompressionProfitable과 ANTHROPIC_PIXELS_PER_TOKEN·charsPerToken 값을 바꿔가며, 어떤 콘텐츠(코드 vs 산문)에서 게이트가 켜지고 꺼지는지 관찰한다. "언제 이미지가 손해인가"의 경계를 스스로 찾아본다.
eval/의 hex recall 하네스를 돌려, 12자리 hex를 이미지로 넣었을 때 Fable 5와 다른 모델의 정확 리콜 차이(13/15 vs 0/15)를 직접 재현한다. "왜 이건 OCR이 아니라 지어냄인가"를 docs/NOT-OCR.md와 대조해 이해한다.
이 레포를 제대로 씹으려면 무엇을 곁들일까 (주차별).
| 주차 | 주제 | 무엇을 |
|---|---|---|
| 1주차 | LLM 토큰·요금 기초 | 토크나이저가 텍스트를 어떻게 쪼개나, 입력/출력/캐시 토큰 요금 구조 |
| 2주차 | 비전 모델 입력 | 이미지 patch embedding, 멀티모달 모델이 스크린샷을 읽는 원리 |
| 3주차 | 프롬프트 캐싱 | cache breakpoint, prefix 고정 규칙, 캐시 hit/miss 요금 배수 |
| 4주차 | PNG·폰트 렌더링 | PNG 청크 구조, zlib deflate, 비트맵 폰트 아틀라스·blit |
| 5주차 | 프록시·엣지 런타임 | HTTP 프록시, SSE 스트리밍 파싱, Web 표준 API로 Node/Workers 양립 |
| 6주차 | 측정·평가 방법론 | counterfactual 측정, A/B 벤치(SWE-bench 등), "정직한 절감률" 설계 |
이 문서에 나온 용어 빠른 정리.
↵ 글자로 바뀌고 이를 모델에 알린다. 채움률을 29%→75~80%로 올린다.더 파고들 곳.