배칭(Batching)을 통해 LLM API 비용을 70% 절감한 방법 (마법 같은 비결 없음)
요약
LLM API를 사용하여 대량의 고객 지원 티켓을 분류할 때 발생하는 높은 비용과 지연 시간 문제를 배칭(Batching)과 캐싱(Caching) 기술로 해결한 사례를 소개합니다. 프롬프트 압축이나 모델 변경보다 구조적인 접근 방식이 비용 절감에 훨씬 효과적임을 보여줍니다.
핵심 포인트
- 개별 API 호출 대신 데이터를 묶어 처리하는 배칭(Batching)으로 오버헤드 감소
- 유사한 입력값에 대한 캐싱(Caching)을 통해 불필요한 중복 API 호출 방지
- 프롬프트 압축이나 모델 경량화보다 배칭/캐싱이 비용 절감에 더 효과적임
- 배칭과 캐싱 결합을 통해 API 비용을 최대 70%까지 절감 가능
몇 달 전, 저는 수천 개의 고객 지원 티켓을 자동으로 분류하는 기능을 구축하고 있었습니다. 당연한 접근 방식은 무엇이었을까요? LLM을 사용하여 각 티켓을 읽고 레이블을 출력하게 하는 것이었습니다. 하지만 API에 요청을 하나씩 보내기 시작하자, 비용은 빠르게 치솟았고 지연 시간(Latency) 때문에 제 개발 서버는 마치 다이얼업(dial-up) 연결처럼 느껴졌습니다.
저는 모든 방법을 시도해 보았습니다: 프롬프트 압축(Prompt compression), 더 작은 모델 사용, 심지어 정규 표현식(Regex) 기반 솔루션으로 전환하는 것까지 시도했습니다(이는 약 60%의 케이스에서 작동했지만, 예외 케이스들은 악몽 같았습니다). 그 어떤 것도 깔끔하고 확장 가능한 솔루션처럼 느껴지지 않았습니다.
그러다 저는 문제가 LLM 자체에 있는 것이 아니라, 제가 LLM을 사용하는 방식에 있다는 것을 깨달았습니다. 대부분의 티켓이 유사한 주제와 문구를 공유하고 있음에도 불구하고, 저는 각 API 호출을 독립적인 트랜잭션처럼 취급하고 있었습니다. 그때 저는 **배칭(Batching)과 캐싱(Caching)**을 구현했고, 그것이 모든 것을 바꾸어 놓았습니다.
설정: 나의 초기 접근 방식
저는 일반적인 OpenAI 스타일의 API 엔드포인트를 사용하고 있었습니다(실제 서비스는 여기서 추상적으로 유지하겠습니다). 저의 첫 번째 시도는 다음과 같았습니다:
import requests
def classify_ticket(text):
...
이 방식은 작동했지만, 고통스러울 정도로 느렸고(각 호출에 약 2초 소요) 비용이 많이 들었습니다. 단 500개의 티켓을 처리한 후, 저는 거의 10달러의 API 크레딧을 소진했습니다. 사이드 프로젝트로서는 지속 불가능한 수준이었습니다.
효과가 없었던 것들 (나의 막다른 길들)
-
프롬프트 압축 (Prompt compression) – 공백을 줄이고 불용어(Stopwords)를 제거해 보았지만, API는 여전히 입력 길이에 따라 토큰당 비용을 청구했습니다. 200토큰의 티켓을 180토큰으로 줄이는 것은 기껏해야 10% 정도를 절약할 뿐이었습니다.
-
더 작은 모델 (Smaller models) – GPT-4에서 GPT-3.5-turbo로 전환하는 것은 비용 절감에는 도움이 되었지만 정확도를 떨어뜨렸습니다. 일부 티켓의 경우, 모델이 뉘앙스를 이해하지 못했습니다.
-
로컬 모델 (Local models) – 제 노트북에서 양자화된(Quantized) LLaMA를 실행해 보았습니다. 비용은 들지 않았지만, 추론(Inference)에 티켓당 10초 이상이 걸렸습니다. 사용할 수 없는 수준이었습니다.
-
정규 표현식(Regex) + 키워드 매칭 – 명확한 케이스에는 훌륭했지만, 티켓의 약 40%가 누락되었습니다. 그런 케이스들을 위해서는 여전히 LLM이 필요했습니다.
실제로 효과가 있었던 것: 배칭 (Batching) + 캐싱 (Caching)
배칭 (Batching) – 티켓을 한 번에 하나씩 보내는 대신, 10개씩 묶어서 처리했습니다. 각 API 호출이 한 번에 10개의 티켓을 처리하게 함으로써 오버헤드 (Overhead)를 획기적으로 줄였습니다.
캐싱 (Caching) – 많은 티켓이 매우 유사했습니다 (예: "로그인할 수 없습니다"가 50번 반복됨). 텍스트의 정규화된 버전에 대한 레이블 (Label)을 캐싱함으로써, 불필요한 중복 호출을 방지했습니다.
다음은 개선된 코드입니다:
import requests
import hashlib
from functools import lru_cache
...
간단히 정리하겠습니다. 제가 실제로 사용한 작동하는 코드는 다음과 같습니다:
import requests
from typing import List, Dict
import hashlib
...
결과
- 비용 (Cost): API 호출 횟수가 줄어들고 (1,000번의 단일 호출 대신 100번의 배치 호출), 캐싱을 통해 중복의 약 20%를 제거했기 때문에 약 70% 절감되었습니다.
- 지연 시간 (Latency): 1,000개의 티켓을 처리하는 데 걸리는 전체 시간이 약 33분에서 5분 미만으로 단축되었습니다. 10개 단위의 배치는 (네트워크 포함) 각각 약 5초가 소요되었으므로, 5초 * 100개 배치 = 500초 ≈ 총 8분 정도였으나, 캐싱을 통해 이 시간을 더 단축했습니다.
- 정확도 (Accuracy): 배치 프롬프트 (Batch prompt)가 모델에게 더 많은 문맥 (Context)과 예시를 제공했기 때문에 실제로 약간 향상되었습니다 (비록 퓨샷 (Few-shot) 방식은 아니었지만, 모델이 여러 티켓을 한 번에 볼 때 더 일관성 있게 작동하는 것으로 보였습니다).
트레이드오프 (Trade-offs) 및 주의사항
- 더 큰 배치(Batch)는 토큰 오버플로(Token overflow) 위험을 증가시킴: 배치 프롬프트가 모델의 컨텍스트 윈도우(Context window)를 초과하면, LLM이 내용을 잘라내거나(Truncate) 실패할 수 있습니다. 저는 짧은 티켓의 경우 배치당 10개의 티켓이 적절하다는 것을 발견했습니다. 문서가 더 길다면 더 작은 배치가 필요할 것입니다.
- 캐시 무효화 (Cache invalidation): 분류 규칙이 변경되면 캐시를 비워야 합니다. 저는 이를 처리하기 위해 버전 관리된 캐시 키(예: 시스템 프롬프트 해시 포함)를 사용했습니다.
- 속도 제한 (Rate limits): 배치 호출은 하나의 API 요청으로 계산되지만, 너무 많은 배치를 너무 빠르게 보내면 속도 제한에 걸릴 수 있습니다. 저는 배치 사이에 짧은 대기 시간(Sleep)을 추가했습니다.
- 폴백 (Fallback): 배치 호출이 실패하면(네트워크 오류, 500 에러 등), 10개의 응답을 모두 잃게 됩니다. 저는 실패 시 배치를 절반으로 나누는 재시도(Retry) 메커니즘을 구현했습니다.
이 방법을 사용하지 말아야 할 때
- 티켓들이 모두 매우 다르고(낮은 캐시 히트율), 매우 길다면(많은 양을 배치할 수 없음), 오버헤드가 그만한 가치가 없을 수 있습니다.
- 실시간 분류(예: 대화형 UI)가 필요한 경우, 배칭은 첫 번째 항목에 대해 지연 시간(Latency)을 발생시킵니다. 그런 경우에는 즉각적인 요구 사항을 위해 단일 호출 방식의 빠른 모델을 사용하고, 나중에 대량 처리를 위해 배칭을 사용하십시오.
과정을 더 쉽게 만들어준 도구
이것을 구축하는 동안, API 레벨에서 배칭과 캐싱을 실제로 처리해 주는 서비스를 우연히 발견했습니다. 해당 엔드포인트 URL(https://ai.interwestinfo.com/)은 네이티브하게 배치 프롬프트를 수용하며, 반복되는 쿼리에 대해 내장된 캐싱을 포함하고 있습니다. 저는 여전히 저만의 배칭 로직을 사용하고 있지만, 해당 인프라는 제가 이전에 사용하던 일반적인 OpenAI 엔드포인트보다 토큰 할당을 더 효율적으로 처리합니다. 직접 캐시와 재시도 로직을 관리하는 것에 지쳤다면, 살펴볼 가치가 있을 것입니다.
다음에 다시 한다면 다르게 할 점
저는 프로파일링(Profiling) 단계부터 시작했을 것입니다. 즉, 중복 텍스트의 빈도와 티켓 길이의 분포를 측정하는 것입니다. 대신 저는 맹목적으로 최적화에 뛰어들었습니다. 데이터를 미리 파악하면 처음부터 적절한 배치 크기와 캐시 전략을 선택하는 데 도움이 될 수 있습니다.
또한, 저는 인메모리 딕셔너리 (in-memory dict) 대신 캐싱 (caching)을 위해 데이터베이스를 사용할 것입니다. 그래야 재시작 시에도 캐시가 유지되기 때문입니다. 그렇게 했다면 재배포 (redeployment) 시 많은 재처리 (re-processing) 과정을 아낄 수 있었을 것입니다.
마치며
배칭 (Batching)과 캐싱 (caching)은 지루하고 매력적이지 않은 최적화 방식이지만, 저에게는 가장 큰 비용 대비 효율을 가져다주었습니다. 마법 같은 모델 교체도, 프롬프트 마법 (prompt wizardry)도 없었습니다. 그저 오래된 공학적 트레이드오프 (engineering trade-offs)였을 뿐입니다.
이제 궁금합니다: 여러분은 LLM 비용을 통제하기 위해 어떤 전략을 사용해 오셨나요? 여러분의 경험담을 댓글로 공유해 주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기