Published on

OpenAI Batch API로 대량추론 비용 80% 절감하기

Authors

서빙 트래픽이 아니라 대량(배치) 추론이 비용을 폭발시키는 순간이 있습니다. 예를 들어 검색 인덱스용 요약을 수십만 건 생성하거나, 고객 상담 로그를 매일 밤 분류/태깅하거나, 데이터 라벨을 생성하는 작업처럼 지연 허용(몇 분~몇 시간) 이 가능한 워크로드입니다.

이때 실시간 API를 그대로 돌리면 다음 문제가 겹칩니다.

  • 요청을 초당/분당 제한에 맞추기 위해 복잡한 스로틀링이 필요
  • 네트워크 왕복과 커넥션 오버헤드가 누적
  • 재시도/부분 실패 처리로 파이프라인이 지저분해짐
  • 무엇보다 단가가 높게 느껴짐(실시간 SLA를 위한 리소스가 가격에 반영)

OpenAI Batch API는 이런 작업을 위해 설계된 인터페이스입니다. 요청을 한 번에 업로드하고, OpenAI가 백그라운드에서 처리한 뒤 결과 파일을 내려받는 방식이라 운영 부담이 줄고, 일반적으로 온라인 대비 큰 폭의 비용 절감(케이스에 따라 최대 80% 수준까지 체감)도 가능합니다.

아래에서는 “정말로 80% 절감이 가능한가?”를 과장된 마케팅 문구가 아니라, 어떤 조건에서 절감이 발생하는지, 그리고 실제로 운영 가능한 형태로 파이프라인을 어떻게 짜는지를 중심으로 설명합니다.

Batch API가 유리한 워크로드 조건

Batch API는 만능이 아닙니다. 아래 조건에 해당할수록 효과가 큽니다.

1) 지연 허용(비동기 처리)

결과를 즉시 사용자에게 보여줄 필요가 없고, 몇 분~몇 시간 내 완료면 되는 작업이 가장 적합합니다.

  • 야간 배치로 문서 요약/키워드 추출
  • 데이터셋 전체 재임베딩(re-embedding)
  • 대량 분류/정규화(예: 카테고리 매핑)

2) 요청 형태가 균질하고 반복적

프롬프트 템플릿이 일정하고 입력만 바뀌는 형태일수록 배치 처리 효율이 좋습니다.

3) 부분 실패를 허용하고 재처리할 수 있음

배치는 “전체가 한 번에 완벽히 성공”하기보다, 일부 실패를 재시도 큐로 회수하는 운영 모델이 현실적입니다.

재시도 설계는 네트워크/타임아웃 복구 패턴과 유사합니다. 대량 호출에서의 재시도 전략은 아래 글의 관점(지수 백오프, idempotency, 에러 분류)이 그대로 도움이 됩니다.

Batch API 처리 흐름(전체 아키텍처)

Batch API를 “파일 기반 비동기 잡”으로 생각하면 이해가 쉽습니다.

  1. 개별 요청을 JSONL(한 줄에 JSON 하나)로 만든다
  2. JSONL 파일을 업로드한다
  3. 업로드한 파일을 입력으로 배치 작업(batch job) 을 생성한다
  4. 상태를 폴링하거나 웹훅/스케줄러로 완료를 감지한다
  5. 결과 파일을 내려받아 파싱한다
  6. 실패 건은 원인별로 분리해 재처리한다

운영에서는 이 흐름을 Airflow/Temporal/Step Functions 같은 오케스트레이터에 얹는 경우가 많습니다. 특히 실패 보상과 재처리 흐름이 길어지면 사가 패턴 관점이 유용합니다.

JSONL 요청 포맷 설계(가장 중요한 부분)

Batch API의 핵심은 각 라인에 들어가는 요청 객체입니다. 여기서 설계를 잘하면 결과 매핑/재처리/추적이 쉬워집니다.

필수로 추천하는 필드:

  • custom_id: 우리 시스템의 레코드 키(예: 문서 ID, 고객 ID, doc_12345)
  • method: 보통 POST
  • url: 호출할 엔드포인트(예: /v1/responses)
  • body: 모델, 입력, 파라미터

custom_id는 “결과를 DB에 합치는 키”이자 “재처리 단위”입니다. 반드시 유일하고 안정적이어야 합니다.

예시: Responses API를 배치로 요약 생성

아래는 JSONL 한 줄 예시입니다(가독성을 위해 줄바꿈했지만 실제 파일에서는 한 줄 JSON으로 저장합니다).

{"custom_id":"doc_10293","method":"POST","url":"/v1/responses","body":{"model":"gpt-4.1-mini","input":[{"role":"system","content":"너는 문서 요약기다. 출력은 한국어로 5줄 이내."},{"role":"user","content":"다음 문서를 요약해줘: ..."}] ,"temperature":0.2}}

운영 팁:

  • temperature는 배치 품질 편차를 줄이려면 낮추는 편이 안전
  • 출력 포맷이 필요하면 JSON 스키마/고정 템플릿을 강제
  • 입력이 길다면 토큰 상한을 고려해 잘라내기(또는 chunking)

Python으로 Batch API 실행: 생성부터 결과 수집까지

아래 코드는 “문서 N개를 요약”하는 배치를 생성하고 결과를 수집하는 최소 예시입니다. (SDK 버전과 엔드포인트는 변경될 수 있으니, 실행 전 공식 문서의 최신 필드명을 확인하세요.)

주의: 본문에 < > 문자가 노출되면 MDX 빌드 에러가 날 수 있어, 타입/제네릭/화살표 표현은 모두 인라인 코드로 표기합니다.

import json
import time
from pathlib import Path
from openai import OpenAI

client = OpenAI()


def build_jsonl(docs, out_path: str):
    p = Path(out_path)
    with p.open("w", encoding="utf-8") as f:
        for doc in docs:
            req = {
                "custom_id": f"doc_{doc['id']}",
                "method": "POST",
                "url": "/v1/responses",
                "body": {
                    "model": "gpt-4.1-mini",
                    "input": [
                        {"role": "system", "content": "너는 문서 요약기다. 한국어로 5줄 이내."},
                        {"role": "user", "content": f"다음 문서를 요약해줘:\n\n{doc['text']}"},
                    ],
                    "temperature": 0.2,
                },
            }
            f.write(json.dumps(req, ensure_ascii=False) + "\n")


def submit_batch(jsonl_path: str):
    # 1) 입력 파일 업로드
    input_file = client.files.create(
        file=open(jsonl_path, "rb"),
        purpose="batch",
    )

    # 2) 배치 생성
    batch = client.batches.create(
        input_file_id=input_file.id,
        endpoint="/v1/responses",
        completion_window="24h",
        metadata={"job": "daily-doc-summarize"},
    )
    return batch.id


def wait_and_download(batch_id: str, out_dir: str):
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)

    while True:
        b = client.batches.retrieve(batch_id)
        status = b.status
        print("batch status:", status)

        if status in ("completed", "failed", "cancelled", "expired"):
            break
        time.sleep(10)

    if b.status != "completed":
        raise RuntimeError(f"batch not completed: {b.status}")

    # 결과 파일 다운로드
    output_file_id = b.output_file_id
    content = client.files.content(output_file_id).read()

    out_path = out / f"{batch_id}.output.jsonl"
    out_path.write_bytes(content)
    return str(out_path)


def parse_results(output_jsonl_path: str):
    results = []
    with open(output_jsonl_path, "r", encoding="utf-8") as f:
        for line in f:
            obj = json.loads(line)
            # obj 형태는 배치 출력 스키마에 따라 달라질 수 있음
            custom_id = obj.get("custom_id")
            response = obj.get("response")
            error = obj.get("error")
            results.append({"custom_id": custom_id, "response": response, "error": error})
    return results


if __name__ == "__main__":
    docs = [
        {"id": 1, "text": "..."},
        {"id": 2, "text": "..."},
    ]

    build_jsonl(docs, "input.jsonl")
    batch_id = submit_batch("input.jsonl")
    output_path = wait_and_download(batch_id, "./batch_out")
    results = parse_results(output_path)
    print(results[:1])

결과 매핑(중요): custom_id를 DB 키로 합치기

배치 결과는 입력 순서를 보장하지 않을 수 있습니다. 따라서 custom_id를 기준으로 DB에 upsert하는 패턴이 안전합니다.

  • custom_id = 문서 PK
  • 결과가 있으면 요약 컬럼 업데이트
  • 에러면 에러 코드/메시지를 저장하고 재처리 큐로 이동

비용을 80%까지 줄이는 실전 레버

“Batch API를 쓰면 자동으로 80% 절감” 같은 단순 공식은 없습니다. 대신 아래 레버를 조합하면 대량 추론 총비용이 크게 내려갑니다.

1) 온라인 SLA 비용 제거: 비동기 창(completion_window) 활용

배치의 본질은 “즉시 처리”가 아니라 “정해진 시간 안에 처리”입니다. 이 유연성이 단가에 반영되는 구조라, 지연 허용 워크로드라면 가장 큰 절감 요인이 됩니다.

2) 토큰 최적화: 입력을 줄이면 비용은 선형으로 감소

대량 추론 비용은 결국 토큰이 지배합니다.

  • 불필요한 시스템 프롬프트 문장 제거
  • 문서 전문을 넣지 말고 필요한 섹션만 추출
  • 요약/분류면 temperature 낮추고 출력 길이 제한
  • 가능하면 작은 모델(mini 계열 등)부터 검증

특히 “데이터 전처리”에서 토큰이 크게 줄어드는 경우가 많습니다. 예를 들어 파케이/애로우에서 컬럼 타입이 섞여 폭발적으로 문자열이 늘어나는 문제를 잡으면 입력이 줄어듭니다.

3) 실패 재처리 비용 최소화: 에러를 분류해 재시도

모든 실패를 무조건 재시도하면 비용이 새고, 완료 시간이 늘어납니다.

  • 429류(레이트/혼잡): 재시도 가치 높음
  • 400류(입력 스키마/길이): 재시도 전에 입력 수정 필요
  • 콘텐츠 정책/금칙어: 별도 큐로 격리

재시도는 “원인별로 다른 정책”을 적용해야 비용이 통제됩니다.

4) 중복 제거: idempotency 키와 캐시

대량 배치에서는 동일 문서가 여러 파이프라인에서 중복 처리되는 경우가 흔합니다.

  • 문서 해시(sha256)를 만들어 결과 캐시
  • custom_id에 버전 포함(예: doc_10293_v3)
  • 기존 결과가 있으면 배치 입력에서 제외

이 한 가지만으로도 체감 비용이 크게 떨어지는 경우가 많습니다.

운영에서 자주 겪는 함정과 해결책

1) “배치가 완료됐는데 일부 결과가 비어있다”

가능한 원인:

  • 입력 JSONL 라인이 깨짐(인코딩, 줄바꿈, 따옴표)
  • 특정 요청만 검증 실패로 error에 기록됨

대응:

  • JSONL 생성 시 json.dumps를 강제하고 수동 문자열 결합 금지
  • 결과 파싱 시 response가 없으면 error를 반드시 저장
  • 실패 건만 모아 “재처리용 JSONL”을 다시 생성

2) “배치 파일이 너무 커서 관리가 어렵다”

권장:

  • 하루치를 한 번에 넣기보다 N건 단위로 쪼개기(예: 5만 건)
  • 파일명/메타데이터에 날짜와 파티션 키를 포함
  • 배치 작업 테이블을 따로 두고 상태 추적

3) “완료 감지가 애매해서 폴링이 난잡해진다”

운영 팁:

  • 폴링 주기를 작업 크기에 맞게 늘리기(예: 10초 -> 60초)
  • 오케스트레이터(Temporal/Airflow)로 상태 머신화
  • 상태가 completed가 아니면 결과 파일을 내려받지 않기

품질을 유지하면서 비용 줄이는 프롬프트 템플릿

대량 처리에서는 “한 건의 고급 프롬프트”보다 “전체 분산이 낮은 프롬프트”가 중요합니다.

아래는 요약 품질을 안정화하는 템플릿 예시입니다.

시스템: 너는 기업 문서 요약기다.
규칙:
1) 한국어로 작성
2) 5줄 이내
3) 수치/날짜/고유명사는 유지
4) 추측 금지, 문서에 없는 내용 생성 금지

사용자: 다음 문서를 요약해줘.
---
{document}
---

이런 규칙형 템플릿은 출력 길이를 통제해 토큰 비용을 줄이고, 재처리율(품질 문제로 다시 돌리는 비율)도 낮춰 총비용을 줄이는 데 기여합니다.

체크리스트: 배치 파이프라인을 배포하기 전

  • custom_id가 유일하고, 결과를 DB에 합칠 키로 충분한가
  • 입력 JSONL 생성이 완전 자동화되어 있고, 깨진 라인을 사전 검증하는가
  • 결과 JSONL 파서가 error를 수집하고 재처리 큐를 만드는가
  • 토큰 예산(입력/출력 상한)을 강제하는가
  • 중복 처리 방지(해시/버전/캐시)가 있는가
  • 배치 분할(파티셔닝)과 상태 추적 테이블이 있는가

마무리: “대량 추론”은 비동기/파일 기반이 정답인 경우가 많다

대량 추론에서 비용을 줄이는 핵심은 “더 싸게 호출하는 트릭”이 아니라, 워크로드 성격에 맞는 실행 모델로 바꾸는 것입니다. Batch API는 온라인 호출을 억지로 병렬화하며 생기는 복잡도를 줄이고, 지연 허용이라는 현실적 조건을 가격/운영 효율로 환원해줍니다.

정리하면 다음 순서로 접근하는 것이 가장 안전합니다.

  1. 배치로 전환 가능한 작업을 먼저 분리
  2. custom_id 중심으로 JSONL과 결과 매핑 설계
  3. 토큰 최적화와 중복 제거로 총량을 줄이기
  4. 실패를 분류하고 재처리 파이프라인을 만들기

이 4가지를 갖추면 “비용 80% 절감”은 과장이 아니라, 충분히 달성 가능한 운영 목표가 됩니다.