LLM 앱 운영 #4 배칭 — 급하지 않은 작업은 반값에
3편까지의 절감은 실시간 요청 안에서의 일이었습니다. 이번에는 질문을 바꿉니다. 이 요청, 정말 지금 답이 필요한가요? 야간에 쌓인 문서 분류, 주간 리포트 생성, 평가셋 채점 같은 작업은 몇 시간 뒤에 결과가 나와도 아무 문제가 없습니다. Batches API는 그런 작업을 비동기로 받아 가는 대신 입력과 출력 모든 토큰을 50% 할인해 줍니다. 조건 없는 반값이라서, 해당하는 작업이 있다면 가장 손쉬운 절감입니다.
어떤 작업이 배치감인가 #
기준은 하나, 지연 요구사항입니다. 사람이 화면 앞에서 기다리면 실시간, 아니면 배치 후보입니다.
| 실시간으로 남길 것 | 배치로 보낼 것 |
|---|---|
| 챗봇 응답, 에이전트 작업 | 쌓인 문서의 분류·요약·임베딩용 전처리 |
| 사용자가 누른 “요약” 버튼 | 야간 일괄 리포트, 주간 다이제스트 |
| 검색 시점의 쿼리 변환 | RAG 심화 6편 평가셋의 정기 채점 |
| 신규 데이터의 사전 라벨링·태깅 |
배치의 계약 조건도 같이 알아 둡니다. 한 배치에 최대 10만 건, 대부분 1시간 안에 끝나지만 보장은 24시간 이내, 결과는 29일간 보관됩니다. “대부분 빠르지만 최악은 24시간"이라는 폭 때문에, 마감이 빠듯한 작업은 배치감이 아닙니다.
제출하고, 기다리고, 수거하기 #
배치의 단위는 개별 요청에 custom_id를 붙인 묶음입니다. 흐름은 제출 → 폴링 → 결과 수거의 세 단계입니다.
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
batch = client.messages.batches.create(
requests=[
Request(
custom_id=f"doc-{doc.id}", # 결과를 되찾을 열쇠
params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5", # 2편의 라우팅이 여기서도 적용된다
max_tokens=256,
system=CLASSIFY_SYSTEM,
messages=[{"role": "user", "content": doc.text}],
),
)
for doc in pending_docs
]
)
print(batch.id, batch.processing_status)import time
while True:
b = client.messages.batches.retrieve(batch.id)
if b.processing_status == "ended":
break
time.sleep(60)
for result in client.messages.batches.results(batch.id):
if result.result.type == "succeeded":
msg = result.result.message
text = next((blk.text for blk in msg.content if blk.type == "text"), "")
save_classification(result.custom_id, text) # custom_id로 원본과 연결
elif result.result.type == "errored":
retry_later(result.custom_id) # 서버 에러는 재제출 대상운영 포인트 세 가지입니다.
- custom_id가 생명선입니다. 결과는 제출 순서와 무관하게 옵니다. 원본 데이터와 잇는 유일한 끈이 custom_id이므로, 재실행해도 안전하게 같은 ID가 나오는 규칙(문서 ID 기반 등)으로 만듭니다.
- 건별로 성패가 갈립니다. 배치 전체가 성공하거나 실패하는 것이 아니라, 결과에
succeeded/errored/expired가 섞여 옵니다. 에러 건만 골라 다음 배치에 재제출하는 흐름을 처음부터 둡니다. - 다른 절감과 겹쳐 적용됩니다. 요청 params는 일반 호출과 같으므로 2편의 모델 라우팅이 그대로 적용되고, 같은 시스템 프롬프트를 쓰는 요청들이 모이면 3편의 캐싱도 함께 동작합니다. haiku + 배치라면 opus 실시간 대비 10분의 1 이하의 단가가 됩니다.
운영 패턴 — 큐를 사이에 두기 #
배치를 일회성 스크립트가 아니라 상시 운영으로 만들 때의 표준 모양은 큐입니다.
이벤트 발생 → 작업 큐(테이블)에 적재
└─ (cron, 예: 매시) 쌓인 것을 모아 배치 제출
└─ (cron) 끝난 배치를 수거해 결과 반영, 실패 건 재적재애플리케이션은 “분류해 줘"라는 행을 테이블에 넣을 뿐이고, 제출과 수거는 주기 작업이 맡습니다. 이 구조의 장점은 자연스러운 완충입니다. 트래픽이 몰려도 실시간 경로의 한도(5편의 주제)를 건드리지 않고, 배치 크기도 모아서 조절할 수 있습니다. 1편의 계측 래퍼와 같은 형태로 배치 결과의 usage 도 로깅해 두면, 기능별 비용 대시보드에 배치 작업도 같은 기준으로 잡힙니다.
실시간을 배치로 바꾸는 발상 #
이미 배치인 작업을 옮기는 것을 넘어, 제품 설계를 바꿔 배치로 보내는 경우도 있습니다. 예를 들어 “업로드하면 즉시 요약"을 “요약이 준비되면 알림"으로 바꿀 수 있다면, 그 기능의 비용은 반값이 되고 트래픽 급증에도 강해집니다. 모든 기능이 가능하지는 않지만, 1편 로그에서 비용 상위 기능을 볼 때 “이건 정말 동기여야 하나"를 한 번씩 묻는 것은 공짜입니다.
흔히 걸려 넘어지는 곳 #
- 마감 있는 작업을 배치에 건다 — 보장은 24시간입니다. “아침 9시까지 꼭"인 작업을 전날 밤 배치에 걸면 가끔 약속을 어기게 됩니다. 마감이 있으면 여유를 두거나 실시간으로 둡니다.
- 결과 수거를 잊는다 — 제출은 했는데 수거 작업이 죽어 있으면 비용만 내고 결과는 29일 뒤 사라집니다. 수거 cron의 동작 여부도 모니터링 대상입니다.
- custom_id를 일련번호로 만든다 — 재실행하면 다른 번호가 붙는 ID는 결과를 원본과 못 잇게 만듭니다. 원본 데이터에서 결정적으로 나오는 ID를 씁니다.
마무리 #
이번 글에서는 비실시간 작업을 반값 경로로 옮겼습니다.
- 기준은 지연 요구사항입니다. 사람이 기다리지 않는 작업은 배치 후보이고, 모든 토큰이 50% 할인됩니다.
- custom_id 설계, 건별 성패 처리, 수거 자동화가 배치 운영의 세 기둥입니다.
- 라우팅·캐싱과 겹쳐 적용되며, 큐를 사이에 둔 파이프라인이 상시 운영의 표준 모양입니다.
비용 3부작(라우팅, 캐싱, 배칭)이 끝났습니다. 다음 주제는 돈이 아니라 멈추지 않는 것입니다. 다음 글인 “LLM 앱 운영 #5 신뢰성 — 레이트리밋, 재시도, 폴백"에서 한도와 장애 앞에서 버티는 구조를 만들겠습니다.