프롬프트 배칭으로 내 LLM 앱이 더 비싸졌을 때

발행: (2026년 6월 10일 PM 08:16 GMT+9)
9 분 소요
원문: Dev.to

출처: Dev.to

LLM 기반 문서 번역의 비용 최적화를 진행하고 있었습니다.
그때 LLM 번역 흐름은 아직 매우 직관적이었는데, 텍스트를 하나씩 추출해서 바로 번역하는 방식이었습니다.
작동은 했지만 비용 면에서는 이상적이지 않았습니다.
텍스트 세그먼트가 많은 문서일수록 API 호출 횟수가 선형적으로 증가했습니다.

쉽게 말하면:
각 텍스트 세그먼트마다 API 호출을 보내는 대신, 여러 세그먼트를 하나로 묶어 보내면 된다는 것이었습니다.
그게 계획이었습니다.

하지만 첫 번째 실제 벤치마크에서, “최적화”가 시스템을 더 비싸게 만들었습니다.
테스트에 사용된 입력 파일은 다음과 같습니다.

  • 파일: sample_10p.pdf
  • 언어 쌍: zh‑TW → en
  • 모델: gpt-4.1-nano

배치 전

배치 없이 시스템은 세그먼트당 하나의 API 호출을 수행했습니다.

메트릭배치 없음
세그먼트160
API 호출160
입력 토큰14,287
출력 토큰2,506
추정 비용$0.0024
소요 시간30.4 s

이것은 단순하고 예측 가능했습니다: 160개의 세그먼트가 160개의 API 호출을 의미했죠.
문제도 명확했습니다: 비용을 줄이려면 호출 횟수를 줄여야 했습니다.

첫 번째 배치 구현

첫 구현에서는 프롬프트 배치를 추가했습니다.
아이디어는 최대 20개의 텍스트 세그먼트를 하나의 요청으로 묶는 것이었습니다. 이를 위해 키드 JSON을 사용했습니다.

keyed_subset = {str(idx): text for idx, text in enumerate(masked_subset)}

kwargs = {
    "model": settings.OLLAMA_MODEL_NAME,
    "messages": [
        {"role": "system", "content": self._sys_batch},
        {"role": "user", "content": user_msg},
    ],
    "temperature": self._temperature,
    "response_format": {"type": "json_object"},
}

언뜻 보기엔 API 호출 수가 160에서 감소했기 때문에 결과가 더 좋아 보였습니다.
하지만 비용과 지연 시간은 오히려 악화되었습니다.

메트릭배치 없음첫 번째 배치
세그먼트160140
API 호출160107
입력 토큰14,28714,876
출력 토큰2,5064,541
추정 비용$0.0024$0.0033
소요 시간30.4 s136.2 s
폴백 비율0%71.43%

배치를 통해 API 호출이 33% 감소했지만 비용은 37% 증가했습니다.
이 부분이 혼란스러웠습니다.
대시보드에는 호출 수가 줄었다고 표시되었지만, 최종 청구 예상액은 오히려 늘었습니다.

그렇다면 추가 비용은 어디서 발생했을까요?

배치 크기는 20이었습니다.
세그먼트가 140개라면 이론적으로 필요한 배치 호출 수는

140 / 20 = 7  (배치 호출)

하지만 7개의 배치 호출 중 5개가 검증에 실패했습니다.
JSON 응답에 하나의 ID가 누락되면, 기존 폴백 로직이 전체 배치를 다시 시도했습니다.

for i in range(len(subset)):
    key = str(i)
    if key in keyed_translations:
        translated_list.append(keyed_translations[key])
    else:
        mismatch_found = True
        break

if mismatch_found or len(translated_list) != len(subset):
    return self._fallback_per_item(texts, tracker)

즉, 하나의 번역이 누락되면 19개의 성공적인 번역까지 무시되고 전체가 재시도됩니다.
재구성된 호출 수는 대시보드와 일치했습니다:

  • 7개의 배치 호출
  • 5개의 실패 배치 × 20개의 아이템 재시도 = 100개의 재시도 호출

총 API 호출 = 7 + 100 = 107

따라서 107개의 호출 중 100개가 재시도였으며, 이것이 실제 비용 상승 요인이었습니다.

첫 구현에서는

"response_format": {"type": "json_object"}

만 사용했습니다. 이는 모델에게 유효한 JSON을 반환하도록 요청했을 뿐, 모든 필수 ID가 포함될 것을 보장하지는 않았습니다.
프롬프트에 “ID를 건너뛰지 말라”고 명시했지만, 프롬프트 지시는 여전히 불완전했습니다.

로그를 보면 누락된 ID가 배치의 뒤쪽에 몰리는 경향이 있었습니다:

  • ID 19 missing
  • ID 18 missing
  • ID 12 missing
  • ID 18 missing
  • ID 14 missing

이는 긴 구조화된 출력이 뒤쪽으로 갈수록 품질이 떨어지는 현상과 일치했습니다.

해결 방안

수정은 세 부분으로 이루어졌습니다.

1. 응답 형식 강화 (OpenAI 엔드포인트)

json_object 대신 엄격한 JSON 스키마를 사용하도록 변경했습니다.

keys = [str(i) for i in range(n_items)]

return {
    "type": "json_schema",
    "json_schema": {
        "name": "batch_translation",
        "strict": True,
        "schema": {
            "type": "object",
            "properties": {
                "translations": {
                    "type": "object",
                    "properties": {
                        k: {"type": "string"} for k in keys
                    },
                    "required": keys,
                    "additionalProperties": False,
                }
            },
            "required": ["translations"],
            "additionalProperties": False,
        },
    },
}

이제 모든 예상 ID가 필수 항목으로 명시됩니다.
OpenAI가 아닌 엔드포인트에서는 여전히 json_object 모드(최선 노력)를 사용합니다.

2. 폴백 로직을 부분적으로 변경

전체 배치를 재시도하는 대신, 성공한 번역은 유지하고 누락된 항목만 재시도합니다.

missing = [i for i, v in enumerate(translated) if v is None]

if missing:
    tracker.record_prompt_batch_fallback()

    if len(missing) > 1:
        retry_result = self._request_batch_keyed(
            [masked_subset[i] for i in missing],
            context,
            tracker,
        )

    still_missing = [i for i, v in enumerate(translated) if v is None]
    for i in still_missing:
        translated[i] = self.translate(subset[i], tracker)

3. 토큰 제한 및 잘림 처리

배치 요청에 max_tokens를 지정하고, 출력이 길이 제한(finish_reason == "length")에 걸리면 배치를 반으로 나누어 재시도합니다.

if choice.finish_reason == "length" and len(items) > 1:
    mid = len(items) // 2
    left = self._request_batch_keyed(items[:mid], context, tracker)
    right = self._request_batch_keyed(items[mid:], context, tracker)
    return left + right

잘린 배치는 더 작은 배치로 분할되어 재시도되며, 전체 폴백으로 전락하지 않습니다.

수정 후 재벤치마크

메트릭첫 번째 배치수정된 배치배치 없음
API 호출1077160
폴백 비율71.43%0.00%0%
입력 토큰14,8766,20614,287
출력 토큰4,5412,6402,506
추정 비용$0.0033$0.0017$0.0024
소요 시간136.2 s22.1 s30.4 s
처리된 세그먼트240140160

수정된 버전은 최종 목표를 달성했습니다:

  • API 호출이 160 → 7로 감소
  • 추정 비용이 $0.0024 → $0.0017로 감소
  • 소요 시간이 30.4 s → 22.1 s로 단축
  • 폴백 비율이 0%로 사라짐

교훈

배치가 자동으로 비용을 절감해 주지는 않습니다.
배치 응답이 부분적으로 실패할 경우, 폴백 전략이 비용에 큰 영향을 미칩니다.

구조화된 LLM 워크플로에서 중요한 점은 다음과 같습니다:

  • 엔드포인트가 지원한다면 스키마 강제를 사용한다.
  • 필수 필드에 대해 프롬프트 지시만 의존하지
0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...