석 달 전에 내 리바이즈 루프가 300줄짜리 프로토콜을 갖게 된 이유를 썼다. request-id 카운터, solicit_sent_at 타임스탬프, 2단계 메시지 분류기, degrade 레이어. 모든 섹션이 내가 직접 목격한 실패 하나씩을 되사는 것이었다. 그리고 이렇게 끝맺었다.

persistent teammate + 오래 걸리는 리뷰어 조합은 프롬프트 규율만으로는 못 고치는 race를 만든다.

이 주장은 틀렸고, 그걸 알아내는 데 비용 이상 징후가 필요했다.

증상

hyperclaude(코드)는 autonomous loop을 돌린다. Claude 쪽 에이전트가 플랜을 쓰고, Codex가 리뷰하고, 에이전트가 고치고, Codex가 블로커를 그만 낼 때까지 반복한다. 그런데 이 루프를 한 번 돌리면 같은 시간 동안 단일 세션을 돌릴 때보다 5시간 한도가 훨씬 빨리 찼다. 가설 둘: planner가 다른 모델이라서, 아니면 프롬프트 캐시에 뭔가 문제가 있어서.

Claude Code는 모든 세션과 서브에이전트를 ~/.claude/projects/**/*.jsonl에 남기고, assistant 항목마다 raw usage 블록이 들어 있다. 감으로 답할 질문이 아니라 측정할 질문이다.

함정이 둘 있었고 나는 둘 다 밟았다.

  • message.id로 dedupe해야 한다. 요청 하나가 콘텐츠 블록마다 기록되기 때문에 줄 수로 세면 사용량이 2~3배로 부풀려진다.
  • 토큰 수보다 비용 가중치가 중요하다. base input 기준으로 5분 캐시 쓰기는 1.25배, 1시간 쓰기는 2배, 그런데 캐시 읽기는 0.1배다. 재작성 한 번이 캐시 히트의 스무 배다. raw 토큰만 합산하는 분석은 엉뚱한 곳을 가리킨다.

모델 가설은 금방 죽었다. 캐시 효율로 정렬해보니 모델로 돌린 일회성 서브에이전트가 전체 코퍼스에서 write:read 비율 1위였다. 모델과는 무상관이었다. 대신 다른 게 완벽하게 상관했다.

메커니즘

에이전트 트랜스크립트마다 *.meta.json이 붙어 있다. 어떤 건 agentType: "myplugin:planner" — 내가 요청한 네임스페이스 타입 — 이었고, 어떤 건 agentType: "planner" — 내가 넘긴 이름 — 이었다. 434개를 교차표로 놓으니:

에이전트 정의 skill_listing 첨부
해석됨 165/165에서 0건
유실됨 62/62에서 0건 초과

겹치는 구간이 없었다. 그리고 “유실됨” 쪽은 첨부 횟수가 라운드 수와 정확히 같았다.

A/B로 확인한 사슬은 이렇다. Agent 툴에 name:을 넘기면 스폰된 에이전트가 팀 멤버가 되고, 그러면 하네스가 플러그인 에이전트의 정의를 — tools: allowlist를 포함해서 — 폐기한다. Skill 툴이 다시 범위에 들어오니 하네스가 18KB짜리 스킬 목록을 첨부한다. 그리고 매 라운드 같은 목록을 다시 첨부한다. “이미 보냈음” 상태가 재호출을 못 넘기기 때문이다.

대화 중간에 콘텐츠를 다시 끼워 넣으면 그 지점부터 프롬프트 캐시가 무효화된다. 그래서 매 라운드 에이전트의 누적 컨텍스트 전체가 재작성됐다. cache_read는 13,818 토큰 — 시스템 프롬프트와 툴 정의, 그게 전부 — 에 고정된 채, 쓰기는 8라운드에 걸쳐 131k → 228k로, 다른 실행에서는 9라운드에 161k → 512k로 올라갔다.

라운드 수에 대해 제곱이다. 이 루프가 비쌌던 건 일을 많이 해서가 아니라, 생각할 때마다 자기 컨텍스트를 다시 사고 있어서였다.

A/B는 한 줄이다. 같은 에이전트, 같은 프롬프트, 파라미터 하나 차이.

subagent_type: "myplugin:planner", name: "probe"  →  skill_listing 1개, 툴 56개
subagent_type: "myplugin:planner"                 →  skill_listing 0개, 툴  0개

업스트림에 두 번 올라와 있다(#78234, #81746). 한 제보자는 바이너리를 디스어셈블해서 plugin-scoped 정의를 버리는 source filter까지 찾아냈다.

버그를 없애니 프로토콜이 같이 사라졌다

여기가 예상 못 한 부분이다. name:이 바로 에이전트를 팀 멤버로 만드는 것이고, 팀 멤버라서 메일박스가 생긴다. 그거 없이 스폰하면 에이전트는 백그라운드 태스크가 되고, 최종 텍스트가 그 태스크의 결과로 돌아온다. 메일박스가 없으니 라우팅할 것이 없다.

그리고 그 프로토콜은 라우팅을 위한 것이었다.

사라진 것들: request-id 상태기계, 2단계 메시지 분류기, unsolicited 메시지 방어, solicit_sent_at과 stale-idle 가드, degrade 레이어, teardown 절차, 루프가 요구하던 실험 기능 전제조건. 공유 프로토콜 파일이 192줄에서 39줄이 됐다. 전체 변경은 순 −730줄.

살아남은 건 애초에 전송과 무관했던 것들이다. 답신 검증, corrective 예산, 심각도 게이트, 리뷰 캡.

그러니까 지난 글의 프로토콜이 틀렸던 건 아니다. 존재하지 말았어야 할 전송 방식에 대한 올바른 해법이었다. 내가 기록한 race는 전부 실재했고 직접 목격했지만, 그것들은 정의가 조용히 폐기되는 것의 하류 증상이었다. 이건 좀 불편한 종류의 “맞음”이다. 고칠 지점이 증상이 나타난 레이어가 아니었다.

측정이 말린 두 가지 수정

전송 방식을 바꿔도 다 해결되진 않았다. 서브에이전트는 프롬프트 캐시를 5분 TTL로 쓰는데 메인 스레드는 1시간을 받고, Codex 리뷰는 5분보다 오래 걸린다. 그래서 리뷰가 낀 라운드마다 누적 컨텍스트가 여전히 한 번 만료된다.

이걸 두 번 고칠 뻔했고, 두 번 다 숫자가 말렸다.

첫 번째: 긴 TTL 강제. TTL은 서브에이전트가 매치되지 않는 querySource allowlist에서 결정되는데, 그걸 전부 덮는 환경변수가 있다. 한 줄이면 끝. 그런데 1시간 쓰기는 2배, 5분 쓰기는 1.25배라서, 손익분기를 맞추려면 만료 때문에 생긴 토큰이 전체 쓰기의 39%여야 한다. 480개 트랜스크립트로 시뮬레이션하니 분류를 아무리 후하게 해도 13~19%였고, 덮어쓰기는 전체 18% 손해로 나왔다. 모든 버킷이 손해였다. 도우려던 다중 라운드 에이전트까지. 60% 할증은 모든 쓰기에 붙는데 절약은 만료된 소수에만 붙기 때문이다.

두 번째: keepalive. 캐시 읽기가 0.1배로 TTL을 갱신하니, 리뷰 중간에 에이전트를 한 번 찔러주면 싸게 따뜻하게 유지할 수 있다. 나는 이걸 “스케줄러가 필요하다”며 기각했었는데 그건 틀렸고 정정할 가치가 있다. 백그라운드로 sleep N; echo PING을 던지면 그 출력이 알림으로 돌아온다. 프롬프트 어디에도 스케줄링 로직 없이 알람 시계가 생긴다.

메커니즘은 멀쩡했다. 문제는 가치였다. 관측된 만료 경계 105개에서, 갱신 횟수를 최적값으로 제한하면 서브에이전트 무게의 약 5% — 세션 무게로는 1% 정도 — 를 번다. 그리고 그 105개 중 21개는 예산을 다 쓰고도 만료된다. 갱신을 예약하는 시점에 그 간격이 5분일지 한 시간일지 알 수 없기 때문이다. 제한을 안 두면 파국이다. 한 시간을 넘긴 경계가 열여섯 개였고, 그걸 하나 잇자면 15회 이상 갱신하며 매번 전체 컨텍스트를 읽는다. 순 −18%.

둘 다 직관대로였으면 그냥 반영됐을 것이다. 첫 번째는 심지어 내가 적극적으로 낙관하고 있었다.

일반화

틀렸던 주장을 대체하는 수정판.

시스템의 동작을 견디기 위한 프로토콜을 짓기 전에, 그 동작이 의도된 것인지 먼저 측정하라. 나는 1-round-lag race에 오후 하나를 쓰고 그걸 persistent agent의 법칙처럼 써냈다. 실은 넘기지 말았어야 할 파라미터의 증상이었다.

그리고 따라오는 따름정리는 사실 비용 가중치 이야기다. 캐시가 있는 시스템에서 비싼 연산은 토큰 수에 안 보인다. 필요한 건 전부 로컬 JSONL 파일에 처음부터 있었다. 실제로 결론을 낸 측정 — 메타데이터 필드 하나를 첨부 횟수와 교차표로 놓기 — 은 볼 곳을 알고 나니 몇 분이면 됐고, 내 석 달 치 결론을 뒤집었다.

릴리스 후 도그푸딩은 전송 방식이 버틴다고 말한다. 실전 실행에서 skill_listing 0건, 그리고 라운드 경계의 cache_read가 13,818에 고정되는 대신 89k~132k에 앉는다. 프리픽스가 살아남는다. 5분 TTL은 여전히 거기 있고, 측정됐고, 의도적으로 그냥 뒀다.