모델로 가는 하나의 문, 4부. 요청 경로 밖의 비동기 작업
batch 추론은 두 번째 문이다. 파일이 들어가고 job이 돌고 파일이 나오는 동안 게이트웨이 정책은 경로에 없다. 4부는 회계를 정직하게 유지한다.

Azure의 batch 추론 경로는 API 호출처럼 생기지 않았다. JSONL 파일을 업로드하고, job을 만들고, 최대 24시간 뒤에 출력 파일을 수거한다. 게이트웨이 정책이 앞에 앉을 요청 단위 트랜잭션 자체가 없다는 뜻이다. 3부가 쌓은 모든 보장, 토큰 한도, 테넌트별 계량, 라우팅, circuit breaking은 API Management를 통과하는 요청에 적용된다. batch에는 구조적으로 그중 아무것도 없다. 이번 편은 애초에 요청 경로에 있어서는 안 됐던 작업을 옮기고, 그렇게 옮기는 순간 모델로 들어가는 두 번째 문이 열린다는 사실을 다룬다.
요청 경로 밖으로 나가야 하는 것
이 회사의 다섯 애플리케이션은 세 종류의 작업을 만들어 내는데, 이들이 동기 경로에 있는 이유는 단지 거기가 가장 넣기 쉬운 자리였기 때문이다. 판별 기준은 "느린가"가 아니라 누가 기다리고 있는가다.
- 지원 티켓의 야간 요약. 수만 건의 항목, 기다리는 사람 없음, 결과는 아침 보고서까지만 있으면 된다. 완벽한 batch 후보인데, 지금은 대화 중인 고객과 같은 TPM quota를 놓고 경쟁하고 있다.
- 리테일 지식 검색을 위한 문서 수집. 업로드가 트리거하는 버스트성 작업이고, 분 단위 지연은 견딘다. batch job이 아니라 queue다. 분 단위 지연은 괜찮지만 시간 단위 지연은 안 되는데, 누군가 문서를 올린 이유가 그것을 찾기 위해서이기 때문이다.
- 프롬프트 변경 후의 재채점. 코퍼스 전체를 대상으로 돌고, 사용자가 아예 없으며, 끝까지 완주하는 횟수보다 중간에 취소되는 횟수가 더 많다. batch, 단 명시적인 취소 시나리오와 함께.
세 가지 형태이고, 정확히 세 가지 메커니즘에 대응한다. 분 단위에는 worker가 붙은 queue, 시간 단위에는 batch job, 사람이 지켜보는 모든 것에는 동기 게이트웨이. 이 셋을 뒤섞는 것이 마케팅 작업이 고객 서비스 어시스턴트를 쓰러뜨리는 경위이고, 그것이 바로 1부의 인시던트다.
queue, 그리고 거기 넣지 말아야 할 것
분 단위 작업은 Azure Service Bus가 나른다. 중요한 설계 결정은 어떤 브로커를 쓰느냐가 아니라 메시지에 무엇을 담느냐에 관한 것이다.
프롬프트를 메시지에 넣지 마라. Service Bus는 개별 메시지 속성을 32 KB로, 헤더와 user properties, system properties의 합을 64 KB로 제한하고, 이를 넘으면 조용히 잘리는 대신 직렬화 예외를 던진다. 바디 안에서도 1 MB를 넘는 페이로드는 엔티티 크기 quota에 두 배로 계산된다. 답은 claim-check 패턴이다. 문서는 Blob Storage로 가고, 메시지는 blob 참조, 테넌트 ID, 모델 alias, correlation ID를 나른다. 메시지는 작게 유지되고, queue 깊이는 의미 있는 메트릭으로 남고, 페이로드는 worker가 스트리밍해 오고 싶어 하는 바로 그 자리에 이미 있다.
worker pool의 형태를 정하는 제약이 두 가지 더 있다. 하나의 queue, topic, subscription은 동시 receive 요청 5,000개까지만 받고 그 이상은 server busy 오류로 거절하는데, 이는 처리량이 아니라 receiver 수의 상한이고, 태스크마다 receiver를 여는 worker라면 예상보다 빨리 도달한다. 그리고 namespace는 동시 AMQP 연결을 5,000개까지 허용하므로, worker의 connection pooling은 최적화가 아니라 규모에서의 필수 조건이다.
dead-letter 처리는 LLM queue가 평범한 queue와 갈라지는 지점이다. 모델이 콘텐츠 필터 차단을 반환해서 실패한 메시지는 worker가 죽어서 실패한 메시지와 같지 않고, 재시도해야 하는 것은 후자뿐이다. 모델이 거부로 답하면 worker는 메시지를 complete 처리하고 그 거부를 결과로 기록하며, 인프라 실패에만 abandon한다. 그러지 않으면 절대 성공할 수 없는 메시지 위에서 delivery count가 한도까지 올라가고, dead-letter queue는 아무도 진짜 실패와 구분할 수 없는 항목들로 채워진다.
KEDA로 worker 스케일링
worker는 2부가 프로비저닝한 AKS 클러스터에 살고, queue가 비어 있을 때는 돌지 말아야 한다. KEDA는 AKS add-on으로 제공되고 워크로드를 0까지 스케일하며, deployment에는 ScaledObject를, job 형태의 작업에는 ScaledJob을 구동하고, 인증은 Microsoft Entra Workload ID를 통해 워크로드에서 분리된다.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: ingest-worker
spec:
scaleTargetRef:
name: ingest-worker
minReplicaCount: 0
maxReplicaCount: 20
pollingInterval: 15
cooldownPeriod: 120
triggers:
- type: azure-servicebus
metadata:
queueName: doc-ingest
messageCount: "20" # target backlog per replica
authenticationRef:
name: keda-workload-identity
add-on의 세 가지 제약이 이것이 첫 시도에 동작할지 세 번째 시도에 동작할지를 결정한다. 같은 워크로드에 ScaledObject와 Horizontal Pod Autoscaler를 함께 붙이지 마라. KEDA는 밑에서 HPA를 쓰기 때문에 둘이 경쟁한다. HPA가 먼저 있으면 ScaledObject 생성이 실패하고, ScaledObject가 먼저 있으면 HPA가 어쨌든 생성되어 스케일링 동작이 이상해진다. 클러스터당 external metric server는 하나만 허용되므로 KEDA add-on이 유일한 것이어야 하고 KEDA 다중 설치는 지원되지 않는데, 이는 어느 팀이 플랫폼 것 옆에 Helm으로 자기 것을 설치하는 경우를 배제한다. 그리고 AKS Standard에서는 KEDA add-on을 켜기 전에 workload identity를 켜라. 순서가 뒤바뀌면 KEDA operator pod가 올바른 환경을 집어 들기 위해 재시작이 필요하다.
유용한 기본값 하나. 절대적인 queue 깊이가 아니라 replica당 backlog로 스케일하고, maxReplicaCount는 클러스터 용량이 아니라 모델 quota에서 정하라. 각자 429를 받는 worker 스무 개는 그러지 않는 다섯 개보다 나쁘고, queue는 메시지가 얼마나 기다리는지 신경 쓰지 않는다.
batch 경로, 그리고 왜 두 번째 문인가
시간 단위 작업은 Global-Batch 모델 배포로 가고, 그 API의 형태가 이번 편의 요점이다. 가로챌 요청이 없다. 파일이 업로드되고, job이 생성되고, job이 완료되면 출력 파일이 나타난다. 메커니즘은 정확히 적어 둘 가치가 있을 만큼 구체적이다.
입력은 JSONL이고, 한 줄에 요청 객체 하나이며, 모든 줄이 custom_id를 나른다:
{"custom_id": "ticket-88412", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}
{"custom_id": "ticket-88413", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}
응답은 파일이 정의한 순서로 돌아오지 않는다. custom_id가 편의가 아니라 필수인 이유가 이것이다. 응답을 입력에 다시 join하는 유일한 방법이기 때문이다. model 속성은 Global Batch 배포의 이름이어야 하고, 모든 줄에 같은 배포 이름이 나타나야 한다. 두 번째 배포를 겨냥하려면 두 번째 파일과 두 번째 job이 필요하고, 그래서 "이 batch를 오늘 더 싼 모델로 보내라"는 라우팅 결정이 아니라 제출 시점 결정이 된다. Microsoft 자체 가이드도 작은 파일 여러 개보다 큰 파일을 제출하라는 쪽이다.
batch = client.batches.create(
input_file_id=file_id,
endpoint="/chat/completions",
completion_window="24h",
# 1209600 to 2592000 seconds, 14 to 30 days, before the output file expires
extra_body={"output_expires_after": {"seconds": 1209600, "anchor": "created_at"}},
)
이후 job은 validating, in_progress, finalizing, completed를 거치고, 생성 24시간 뒤로 잡힌 expires_at과 완료, 실패, 전체를 세는 request_counts를 나른다. completion window는 24시간이고, 그 안에 끝나지 않은 job은 계속되는 대신 만료된다. 출력 파일 자체도 만료되는데, 그 윈도는 14일에서 30일 사이로 설정할 수 있으므로, 다음 분기에도 결과가 그 자리에 있으리라 가정하는 파이프라인은 예약된 데이터 손실이다.
용량도 여기서는 다르게 동작한다. batch job은 enqueued token quota를 소비하고, 그것을 초과할 만큼 큰 job은 앞선 job 뒤에 줄을 서는 대신 거절된다. 일부 리전은 이제 fail-fast 동작을 지원해서 여러 batch job을 exponential backoff와 함께 줄 세울 수 있고, 하나가 끝나면 다음이 자동으로 시작된다. 그것이 없으면 재시도 루프는 제출자의 몫이고, 각 애플리케이션이 아니라 control plane에 있어야 한다.
회계를 정직하게 유지하기
여기가 불편한 부분이다. batch 경로는 API Management를 통과하지 않으므로 llm-token-limit이 조절하지 않고, llm-emit-token-metric이 계량하지 않으며, 5부가 곧 만들 테넌트별 귀속에는 회사에서 가장 큰 워크로드만 한 사각지대가 생긴다.
답은 둘이고, 둘의 차이는 기본값에 떠밀리는 대신 의도적으로 결정할 가치가 있다.
- 애플리케이션이 batch job을 직접 제출하게 두고 계량되지 않는 두 번째 문을 받아들인다. 가장 단순하고, 1부가 쓰인 이유였던 바로 그 문제, 아무도 귀속할 수 없는 청구서 한 장을 조용히 다시 불러들인다.
- control plane을 유일한 batch 제출자로 만든다. 애플리케이션은 control plane에 batch 요청을 보내고, control plane이 테넌트를 검증하고, 모델 alias를 Global Batch 배포로 해석하고, JSONL을 쓰고, job을 제출하고, 제출을 테넌트 앞으로 기록하고, 완료까지 폴링하고, 출력을 돌려준다. 동기 트래픽에는 게이트웨이가 여전히 유일한 문이고, 비동기 트래픽에는 control plane이 유일한 문이다.
두 번째가 일이 더 많고, 시리즈의 전제를 참으로 유지하는 쪽이다. 회계도 3부의 streaming 사례보다 단단한 땅에 내려앉는다. 완료된 batch job은 자신의 request_counts를 보고하고 출력 파일은 응답별 usage를 나르므로, batch 지출은 정확하게 귀속된다. 동기 경로의 스트리밍된 트래픽보다도 더 정확하다. 비동기는 근사치일 거라고 누군가 가정하기 전에 알아 둘 만한 기분 좋은 역전이다.
지켜봐야 할 실패 모드
- 성공할 수 없는 재시도 루프. 콘텐츠 필터 거부는 실패가 아니라 결과다. 그것을 재시도하면 quota를 태우고, delivery count를 부풀리고, 올바르게 답변된 메시지로 가득 찬 dead-letter queue로 끝난다.
- quota 너머로 스케일된 worker. KEDA는 backlog만 보고 기꺼이
maxReplicaCount까지 스케일한다. 그것이 모델 배포의 TPM이 허용하는 수준을 넘으면, 초과 replica는 429만 만들어 내고 queue는 조금도 빨리 비워지지 않는다. - 조용히 만료되는 batch job. 24시간 윈도는 누군가의 코드 속 예외가 아니라
expired상태로 끝난다. job 상태에 알림이 없으면 아침 보고서는 그냥 비어 있고, 처음 알아차리는 사람은 그것을 읽는 사람이다. - 나이 들어 사라지는 출력 파일. 결과 파일의 만료는 14일에서 30일 사이다. 보관해야 하는 것은 나중이 아니라 제출 시점에 플랫폼 소유의 스토리지로 복사된다.
- 편의가 다시 여는 두 번째 문. Global Batch 배포에 직접 접근하는 팀 하나가 귀속 모델을 무너뜨린다. 1부의 네트워크 규칙과 알림은 여기에도 적용되고, 여기가 가장 놓치기 쉬운 경로다.
5부가 물려받는 것
동기 트래픽은 게이트웨이를 통과하고, 분 단위 작업은 0까지 스케일하는 worker가 붙은 queue에 있고, 시간 단위 작업은 control plane이 Global Batch 배포에 제출한다. 세 개의 경로, 세 개의 지연 프로파일, 그리고 같은 질문을 먹이는 세 가지 품질의 사용량 데이터. 어느 팀이 얼마를 썼는가. 그 질문이 다음 편이고, "어느 팀"을 애초에 답할 수 있게 만드는 아이덴티티 모델도 함께 온다.
다음으로 읽을 글
- 5부, 아이덴티티, Quota, Chargeback, 이 경로들에서 나온 세 가지 사용량 신호가 팀별 숫자 하나가 되는 곳이고, 그것을 어떻게 만들지는 Azure Monitor의 한도가 정한다.
- 3부, Provider 추상화와 Streaming, 이 그림의 동기 쪽 절반이자, 이번 편이 우회한 전송 방식별 보장이다.
- Bedrock 배치 추론: 기다릴 수 있다면 반값, AWS 쪽에서 본 같은 지연 대 비용 거래와, 언제 수지가 맞는지에 대한 산수다.
이것을 대규모로 운영할 때의 인프라와 플랫폼 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.
참고 자료
Ercan의 다른 글
같은 저자, 다른 영역의 사이트 두 개.