LLM 비용 청구서에는 두 가지 측면이 있습니다. 두 가지를 모두 보여주는 원장을 만드세요.
요약
RAG 시스템의 비용 추정이 실제 청구액과 차이가 나는 네 가지 주요 원인을 분석합니다. 단순 청크 계산을 넘어 에이전트 루프, 스캐폴딩 오버헤드, 출력 토큰 비용, 다중 호출 합성 문제를 고려한 정확한 비용 추적 방법을 제안합니다.
핵심 포인트
- 단순 청크 계산은 쿼리 컨덴서나 SDK 재시도 등 숨겨진 호출을 반영하지 못함
- 시스템 프롬프트와 도구 스키마 같은 스캐폴딩이 매 호출마다 비용을 발생시킴
- 출력 토큰은 입력 토큰보다 훨씬 비싸며, 다중 호출 시 비용이 기하급수적으로 증가함
- 정확한 비용 관리를 위해 API가 반환하는 실제 사용량(usage)을 SDK 호출 지점에서 기록해야 함
모든 RAG (Retrieval-Augmented Generation) 비용 추정은 동일한 방식으로 시작됩니다. 입력 토큰 (input tokens)은 top_k에 청크 크기 (chunk size)를 곱한 값에 약간의 오버헤드 (overhead)를 더한 것과 같습니다. 대부분의 사람들은 거기서 멈춥니다. 그러다 청구서가 도착하면 추정치의 3배가 찍혀 있고, 팀은 실제 돈이 그 공식이 전혀 닿지 않는 어딘가로 새어나가는 동안 청크 크기를 조정하며 스프린트 (sprint)를 허비합니다.
LLM 시스템의 비용은 쿼리 (query)가 트리거하는 모든 호출 (call)에 걸쳐 합산된 총 입력 및 출력 토큰 (input and output tokens)에 의해 결정됩니다. 호출당 비용이 아닙니다. 입력 전용도 아닙니다. 모든 호출의 양방향 모두입니다. 이 포스트에서 단 한 문장만 기억해야 한다면, 바로 이 문장입니다.
청크 계산이 거짓말을 하는 이유
실제 파이프라인 (pipeline)에서 제가 주로 발견하는 순서대로 나열한 네 가지 누수 요인입니다:
존재를 잊어버린 호출들. 사용자 질문을 다시 쓰는 쿼리 컨덴서 (query condenser). 조용한 SDK 재시도 (retry). 잘못된 응답 이후의 JSON 복구 (repair) 호출. 한 번 대신 세 번의 턴을 소모한 에이전트 루프 (agent loop). 각각은 청크 공식이 예측할 수 없는 정가 API 호출이며, 일부 프레임워크는 사용자에게 알리지 않고 이를 수행합니다.
매 호출마다 다시 전송되는 스캐폴딩 (Scaffolding). 시스템 프롬프트 (system prompt), 도구 스키마 (tool schemas), 포맷팅 지침 (formatting instructions), 퓨샷 예시 (few-shot examples). 이 고정된 오버헤드는 모든 개별 호출에 따라붙으며, 다중 호출 파이프라인 (multi-call pipelines)에서는 여러분이 정성스럽게 조정하고 있는 검색된 청크 (retrieved chunks)보다 더 큰 비중을 차지하는 경우가 많습니다.
프리미엄 가격으로 청구되는 출력 측면. 출력 토큰 (output tokens)은 모든 주요 제공업체의 정가 기준으로 입력 토큰보다 몇 배 더 비싸며, 현재 Claude 모델에서는 5배에 달합니다. 입력 토큰을 추적하는 대시보드는 거래의 저렴한 절반만을 지켜보고 있는 셈입니다.
자신의 초안을 다시 청구하는 다중 호출 합성 (Multi-call synthesis). Refine 스타일의 체인 (chains)은 매 단계마다 전체 진행 중인 답변을 재생성합니다. 최종 초안만 전달되지만, N개의 모든 초안이 출력 요율 (output rates)로 청구되며, 버려진 각 초안은 다음 호출의 입력으로 다시 전송됩니다. 비용 중 답변 구성 요소는 N에 따라 확장되며, 어떤 청크 조정으로도 이를 해결할 수 없습니다. 프롬프트 캐싱 (Prompt caching)도 이를 구원하지 못합니다. 계속 커지는 답변이 프롬프트 중간에 위치하여 접두사 일치 (prefix match)를 깨뜨리기 때문입니다.
토큰을 세는 것을 멈추고, 토큰을 읽기 시작하세요.
해결책은 더 나은 추정치를 구하는 것이 아닙니다. 추정하기를 거부하는 것입니다. 모든 제공업체(provider)는 이미 모든 응답에 대해 정확한 사용량(usage)을 반환하므로, 전체 작업은 API가 알려주는 내용을 기록하고 쿼리(query)별로 합산하는 것뿐입니다.
어디에 후크(hook)를 거느냐가 생각보다 중요합니다:
- **SDK 호출 지점 (The SDK call site)**은 신뢰할 수 있는 병목 지점입니다. 각
create()함수를 감싸는 얇은 래퍼(wrapper)는 스트리밍 여부와 관계없이 당신이 수행하는 모든 호출에 대해 서버가 보고한 사용량을 확인합니다. - 프레임워크 콜백 (Framework callbacks)은 더 누수가 많습니다. 일부는 서버가 보고한 사용량을 제대로 읽어 중첩된 호출을 통해 전파하지만 (LangChain의 usage metadata callback이 그러합니다), 다른 것들은 네트워크 데이터(wire)를 읽는 대신 로컬 토크나이저(tokenizer)로 추정합니다 (LlamaIndex의 TokenCountingHandler가 문서화된 사례입니다). 그리고 이들 중 어느 것도 프레임워크 외부에서 이루어진 호출은 감지하지 못합니다.
- HTTP 계층 후크 (HTTP-layer hooks)는 스트리밍에 대한 사각지대가 있습니다.
httpx응답 후크는 본문(body)이 도착하기 전, 헤더가 도착할 때 실행됩니다. 비스트리밍(non-streaming) 호출의 경우 후크 내부에서 본문을 읽을 수 있지만, SSE 스트림의 경우 사용량 정보는 후크가 안전하게 소비할 수 없는 본문의 끝에 도착합니다. 스트림에 대해 네트워크 수준(wire-level)의 캡처를 원한다면, 로깅 프록시(logging proxy)나 스트림을 분기(tee)하는 트랜스포트 래퍼(transport wrapper)가 필요합니다.
대부분의 코드베이스에서 호출 지점 래퍼를 만드는 것은 반나절 정도의 작업이며, 중요한 모든 것을 커버할 수 있습니다.
원장 (The ledger)
나중에 실제로 쿼리되는 내용을 통해 배운, 제 역할을 다하는 컬럼(column)들:
| 컬럼 | 존재하는 이유 |
|---|---|
| query_id | 한 사용자의 요청 내 모든 호출을 하나로 묶습니다. contextvar를 통해 이를 전파하세요. |
| ... |
A 최소한의 구현체, SQLite와 contextvar를 사용한 예시:
import sqlite3, time, uuid
from contextvars import ContextVar
...
비스트리밍 Anthropic 호출을 래핑하는 예시:
import anthropic, time
client = anthropic.Anthropic()
...
각 사용자 요청의 상단에서 한 번 contextvar를 설정하고 (query_id.set(str(uuid.uuid4()))), 각 호출 지점에서 stage 레이블을 전달하면 원장이 자동으로 채워집니다.
스트리밍 시 주의사항 (The streaming gotchas)
스트리밍 (Streaming)은 단순한 캡처 방식이 조용히 데이터를 놓치는 지점이며, 두 주요 제공업체는 서로 다른 방향으로 실패합니다.
OpenAI는 스트리밍 호출 시 명시적으로 요청해야만 사용량 (usage)을 방출합니다: stream_options={"include_usage": True}를 전달하세요. 그러면 사용량은 마지막 청크 (chunk)에 도착하며, 그 이전의 모든 청크는 usage=None을 포함합니다. 이 플래그를 잊는다면, 미터기(meter)는 돌아가고 있지만 원장에는 아무것도 기록되지 않습니다.
Anthropic은 스트림 전체에 걸쳐 사용량을 분할합니다: message_start가 입력 측 (input side)을 전달하고, 출력 카운트 (output count)는 끝부분 근처의 message_delta를 통해 도착합니다. SDK의 스트리밍 헬퍼 (streaming helper)를 사용한다면, 누적된 최종 메시지에 완전한 사용량이 포함되므로 stream.get_final_message().usage를 사용하는 것이 쉬운 방법입니다.
제공업체마다 필드 이름 (Field names)이 일치하지 않습니다. prompt_tokens 대 input_tokens, 한 API에서는 prompt_tokens_details.cached_tokens 아래에 있는 캐시된 토큰 (cached tokens)이 다른 API에서는 cache_read_input_tokens로 나타납니다. 그리고 일부 자체 호스팅되는 OpenAI 호환 서버들은 사용량을 완전히 생략하기도 합니다. 쿼리 시점 (query time)이 아니라 쓰기 시점 (write time)에 여러분만의 스키마 (schema)로 정규화 (Normalize) 하세요.
첫 일주일이 보여주는 것
이를 프로덕션 파이프라인 (production pipelines)에 적용해 보면, 놀라운 일들이 일정한 순서로 발생합니다.
첫 번째 놀라움: 호출 횟수 (call count). 누군가 3월에 추가한 재작성 (rewrite) 단계, 잘린 응답 (truncated response)이 발생할 때마다 실행되는 재시도 (retry), 한 번의 턴을 예산으로 잡았으나 평균 2.4번이 실행되는 에이전트 루프 (agent loop). GROUP BY query_id를 실행했을 때 두 개가 아닌 8개의 행이 나오는 쿼리들이 바로 여러분의 청구서입니다.
두 번째: 스캐폴딩 (the scaffolding). 방금 발견한 호출 횟수에 곱해진 고정 프롬프트 오버헤드 (fixed prompt overhead)는 검색된 컨텍스트 (retrieved context)보다 큰 경우가 빈번합니다. 사람들은 2,000개의 가변 토큰 (variable tokens)은 조정하면서, 모든 호출에 따라붙는 3,500개의 고정 토큰 (fixed tokens)은 무시합니다.
세 번째, 그리고 마지막 세 번째: 청크 크기 (chunk size)와 top_k, 이는 모두가 가장 먼저 조정하는 요소들입니다.
또한 이 원장(ledger)은 중복성(redundancy)을 가시화합니다. 만약 코퍼스(corpus)에 거의 동일한 문서들이 있다면, 이들은 쿼리 전반에 걸쳐 반복되는, 거의 동일한 입력 델타(input deltas)로 나타납니다. 즉, 모델에 동일한 단락들을 계속해서 다시 보내는 비용을 지불하고 있는 것입니다. 이에 대한 해결책(컨텍스트 윈도우(context window)에 넣기 전 중복 제거(dedup))은 품질을 개선하는 동시에 결과적으로 비용을 절감하는 해결책이 됩니다.
솔직히 말해서, 전체 분석은 단 하나의 쿼리입니다:
SELECT query_id, COUNT(*) AS calls,
SUM(input_tokens) AS input,
SUM(output_tokens) AS output,
...
5는 리스트 가격(list-price) 기준의 출력 대 입력 비율(output-to-input ratio)입니다. 사용 중인 제공업체의 비율로 교체하여 사용하세요. 해당 쿼리의 상위 20개 행은 이 글을 포함한 그 어떤 비용 최적화 블로그 포스트보다 더 가치 있는 정보를 제공합니다.
Sid Probstein은 이번 주에 아키텍처 측면에서 논거를 제시하며, 다양한 프레임워크를 통해 RAG 스택이 실제로 전송하는 양을 비교했습니다. 그의 마지막 문장은 이 주제 전체를 올바르게 요약합니다. top_k가 아니라, 바로 그 숫자가 당신의 청구서입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기