대규모 세션 운영과 확장
한 번의 호출이 잘 돈다고 해서 하루 수만 건이 잘 돈다는 보장은 없다. 규모가 커지면 비용, 속도 제한, 연결 안정성이 차례로 병목이 된다. CCA-F의 컨텍스트 관리와 신뢰성 도메인이 이 절의 무대다. 시험은 "왜 이 캐시 배치가 옳은가", "왜 이 재연결 전략이 세션 교착을 막는가"처럼 규모에서만 드러나는 의사결정을 묻는다. 출발점은 다시 세 가지 불변 사실이다. 프롬프트 캐시는 prefix match이고, 속도 제한은 모델 추론과 오케스트레이션 엔드포인트가 별도 풀을 쓰며, SSE 스트림은 재생되지 않는다.
프롬프트 캐시를 유지하는 것이 1순위 비용 통제다
대규모에서 가장 큰 비용 누수는 캐시 미스다. 프롬프트 캐싱은 prefix match라서, 프리픽스의 어느 바이트든 바뀌면 그 이후가 전부 무효화된다. 렌더 순서는 tools → system → messages이므로, 안정적인 내용을 앞에, 휘발성 내용을 마지막 캐시 분기점 뒤에 둬야 한다. 규모가 커질 때 흔히 생기는 조용한 무효화 요인이 시험 보기로 그대로 나온다. system 프롬프트에 datetime.now()나 요청 ID를 끼워 넣기, json.dumps를 정렬 없이 직렬화하기, 사용자마다 도구 집합을 바꾸기가 대표적이다. 도구는 위치 0에 렌더되므로 도구 정의를 사용자별로 바꾸면 전 사용자에 걸쳐 아무것도 캐시되지 않는다.
대규모 운영에서 특히 중요한 두 가지 워크어라운드가 있다. 첫째, 세션 도중 운영자 지시를 system 프롬프트에 끼워 넣어 캐시를 깨는 대신, messages에 {"role": "system", ...} 메시지를 덧붙인다(beta 헤더 mid-conversation-system-2026-04-07). 캐시된 프리픽스는 그대로 두면서 운영자 권한 채널로 동작한다. 둘째, 동시 요청 타이밍이다. 캐시 항목은 첫 응답이 스트리밍을 시작한 뒤에야 읽을 수 있으므로, 동일 프리픽스를 가진 N개를 한꺼번에 던지면 전부 풀가로 캐시를 각자 쓴다. 팬아웃 시에는 1건을 먼저 보내 캐시가 채워진 것을 확인한 뒤 나머지 N-1건을 발사한다. 아래의 동기 버전은 첫 호출이 완전히 끝날 때까지 기다리므로 N-1건이 확실히 캐시를 읽지만, 진짜로 첫 토큰 시점에 겹쳐 발사해 지연을 줄이려면 비동기로 첫 호출의 첫 토큰을 await 한 뒤 나머지를 발사해야 한다.
# fanout_cache_warm.pyimport anthropicclient = anthropic.Anthropic()SHARED_SYSTEM = "<large shared context...>"def run_fanout(questions): # # 1건 먼저 — 캐시를 채운다 with client.messages.stream( model="claude-opus-4-8", max_tokens=16000, system=[{"type": "text", "text": SHARED_SYSTEM, "cache_control": {"type": "ephemeral"}}], messages=[{"role": "user", "content": questions[0]}], ) as first: for _ in first.text_stream: break # 첫 토큰만 기다린다 first.get_final_message() # 나머지는 캐시를 읽는다 results = [] for q in questions[1:]: r = client.messages.create( model="claude-opus-4-8", max_tokens=16000, system=[{"type": "text", "text": SHARED_SYSTEM, "cache_control": {"type": "ephemeral"}}], messages=[{"role": "user", "content": q}], ) results.append(r) print(r.usage.cache_read_input_tokens) # 0이면 무효화 요인 점검 # return results
usage.cache_read_input_tokens가 반복 요청에서 0이라면 조용한 무효화가 일어나고 있다는 신호다. 비용 추적 시 input_tokens는 캐시되지 않은 잔량만 뜻하므로, 전체 프롬프트 크기는 세 필드(input_tokens + cache_creation_input_tokens + cache_read_input_tokens)의 합으로 봐야 한다는 점도 자주 출제된다.
속도 제한은 분리된 풀로 관리한다
규모가 커지면 429가 흔해진다. 여기서 시험이 노리는 핵심은 속도 제한 풀이 분리돼 있다는 사실이다. Managed Agents 엔드포인트(에이전트·세션·환경 등)는 모델 추론과 별도인 조직 단위 RPM 제한을 가진다. 동시에 세션 안에서 도는 모델 추론은 조직의 표준 토큰 제한(ITPM/OTPM)을 따로 끌어다 쓴다. 따라서 "세션 생성이 429를 맞는다"와 "세션 안 추론이 429를 맞는다"는 다른 풀의 문제이고, 대응도 다르다. SDK는 429와 5xx를 지수 백오프로 자동 재시도하므로(기본 max_retries=2), 대부분의 경우 직접 재시도 루프를 짜기보다 SDK 기본값을 신뢰하고 필요할 때 max_retries만 조정하는 것이 정답이다. 스케줄 기반 배치에는 50% 저렴한 Batches API가 별도 경로다.
스트림 재연결은 history 합치기로 교착을 막는다
장시간 세션의 가장 위험한 실패는 스트림 끊김이다. SSE 스트림은 재생되지 않는다. 연결이 끊겼다가 순진하게 다시 열면 "지금" 이후 이벤트만 받고 그 사이 이벤트는 영영 놓친다. 더 나쁜 경우, 도구 확인이나 custom tool 결과를 기다리는 도중에 끊기면 세션이 교착에 빠진다(클라이언트가 끊겨 세션은 idle, 재연결돼도 응답을 보내는 주체가 없음). 정답 패턴은 매 (재)연결마다 스트림을 먼저 열고, events.list로 전체 이력을 가져와 이벤트 ID로 dedupe 한 뒤 라이브 스트림을 이어 받는 것이다.
여기서 시험이 자주 비트는 두 지점이 있다. 첫째, dedupe가 terminal 검사까지 막으면 안 된다. 이미 본 이벤트라도 종료 이벤트일 수 있으므로, dedupe는 처리만 건너뛰고 종료 판정은 항상 돌려야 한다. 둘째, idle만 보고 루프를 끊으면 안 된다. 세션은 도구 확인을 기다리거나 병렬 도구 실행 사이에 일시적으로 idle이 된다. 끊는 기준은 session.status_terminated이거나, session.status_idle이면서 stop_reason.type이 terminal일 때다. requires_action은 아직 클라이언트 응답을 기다린다는 뜻이므로 끊지 말고 처리해야 한다. 또한 스트림은 항상 메시지를 보내기 전에 열어야 한다(stream-first). 보낸 뒤에 열면 초기 이벤트가 한 묶음으로 버퍼링돼 실시간 반응을 잃는다.
raw HTTP로 폴링을 직접 짤 때의 함정도 있다. requests의 timeout이나 httpx.Timeout은 per-chunk read timeout이라 바이트가 올 때마다 리셋된다. 따라서 trickling 응답은 그 값을 줘도 무한히 블로킹될 수 있다. 하드 데드라인이 필요하면 루프 레벨에서 time.monotonic()을 추적해 직접 끊어야 하며, 대부분의 경우 SDK의 events.stream()/events.list()를 쓰는 것이 옳다.
비용을 모델·effort·webhook으로 통제한다
대규모 토큰 비용은 세 레버로 누른다. 모델 선택(고볼륨 단순 작업은 Sonnet/Haiku), output_config.effort 낮추기(낮은 effort는 도구 호출과 서두를 줄인다), 그리고 폴링 대신 webhook이다. 수천 세션을 SSE로 동시에 붙들면 연결 자체가 비용·병목이 된다. webhook은 상태 전이를 얇은 페이로드(ID만)로 받아 그때 자원을 조회하므로 연결을 상시 유지할 필요가 없다. 다만 webhook의 data.type은 SSE 이벤트 타입과 별도 네임스페이스라는 점이 함정이다. webhook은 session.status_idled, SSE는 session.status_idle처럼 이름이 미묘하게 다르고, webhook 핸들러에서 SSE 상수를 재사용하면 매칭이 깨진다. 재시도는 같은 event.id로 오므로 그 ID로 dedupe 한다.
정리
- 대규모 비용 통제의 1순위는 프롬프트 캐시 유지다. system 프롬프트의 타임스탬프·요청 ID, 비정렬 직렬화, 사용자별 도구 변경이 조용한 무효화 요인이다.
cache_read_input_tokens가 0이면 점검하고, 전체 프롬프트 크기는 세 usage 필드의 합으로 본다. - 속도 제한 풀은 분리돼 있다. 오케스트레이션 엔드포인트의 RPM과 세션 내 추론의 토큰 제한은 별개다. 429/5xx는 SDK가 지수 백오프로 자동 재시도하므로 기본값을 신뢰하고
max_retries만 조정한다. - SSE 스트림은 재생되지 않는다. (재)연결마다 stream-first로 열고
events.list로 history를 합쳐 ID로 dedupe 한다. dedupe가 terminal 검사를 막으면 안 되고, idle만으로 루프를 끊지 말고stop_reason이 terminal일 때 또는terminated일 때 끊는다. - 비용은 모델 선택·
effort하향·webhook으로 통제한다. webhook의data.type(session.status_idled)은 SSE 이벤트 타입(session.status_idle)과 별도 네임스페이스이며, 재시도는 같은event.id로 dedupe 한다.