Bedrock과 LangChain이 'input_tokens'의 의미에 대해 이견을 보이자 - 누가 계산했는지 모른 채 가격 책정하지 마세요
요약
Bedrock과 LangChain을 사용하는 환경에서 토큰 사용량 계산 방식의 불일치 문제를 지적하며, 가격 책정 시 'input_tokens'의 정의와 계산 규칙을 명확히 해야 한다고 주장합니다. 특히 캐싱 기능이 청구액을 줄이는 목적인데도 불구하고, 이로 인해 토큰이 중복 또는 누락되어 과다/과소 청구가 발생할 수 있음을 경고합니다.
핵심 포인트
- 토큰 카운트는 어떤 규칙으로 보고되었는지 필수적으로 확인해야 합니다.
- 가격 책정 시 캐시 적용 전후의 'input_tokens' 정의 차이를 명확히 해야 합니다.
- LangChain은 전체 입력값을, Raw InvokeModel은 캐시되지 않은 나머지 값을 보고합니다.
- 이러한 불일치는 토큰 과다/과소 청구로 이어질 수 있으므로 주의해야 합니다.
저는 사용량 원장(usage ledger)에 cache-read와 cache-write 열을 추가하면서, 파싱 방법을 정확히 하기 위해 두 개의 사용 페이로드(usage payloads)를 나란히 배치했습니다. 둘 다 Bedrock에서 Claude로부터 왔습니다. 둘 다 input_tokens를 가지고 있었고 프롬프트 캐시 활동을 보고했습니다.
하나는 캐시가 적용된 후에 남은 것이 input_tokens였습니다. 다른 하나는 이미 캐시가 포함된 상태였습니다.
즉, 동일한 모델 패밀리, 동일한 AWS 계정, 동일한 필드 이름입니다. 이 둘을 똑같이 취급하는 가격 책정 함수는 모든 프롬프트의 캐시 부분을 두 번 청구합니다. 그런데 프롬프트 캐싱은 청구를 줄이기 위해 켠 기능입니다.
제가 주장하고 싶은 내용은 다음과 같습니다:
💡 누가 계산했는지 물어보세요. 토큰 카운트는 그것이 어떤 규칙으로 보고되었는지 알게 될 때 비로소 숫자가 됩니다. 그 규칙을 가격 책정의 필수 인수로 만드세요. 결코 그 안에 숨겨진 가정이 되어서는 안 됩니다.
60초 요약 버전
- Anthropic 메시지 본체를 사용한 원본
InvokeModel은input_tokens를 캐시되지 않은 나머지로 보고하며, 캐시 카운트는 그 위에 추가됩니다. 반면, 저희 클라이언트가 읽는 LangChain의usage_metadata는 전체 입력값을 보고하고, 캐시 카운트는 그것의 **분해(breakdown)**입니다. - 저는 이 규칙을 모든 측정된 호출의 필수 필드로 만들었습니다. 가격 책정 엔진은 접힌 행에서 슬라이스를 빼냅니다.
- 그 뺄셈 때문에 미측정 슬라이스가 두 배로 무료가 되었습니다. GitHub Copilot의 검토자가 제가 병합(merge)하기 50초 전에 이를 발견했습니다.
- 제가 정한 규칙은 비대칭적입니다. 캐시 읽기를 과장하는 것은 용인할 수 있습니다. 하지만 제공업체가 청구한 토큰을 잃는 것이 실패입니다.
하나의 필드 이름, 두 가지 의미
이것은 규제된 SaaS 제품 내부에 있는 AI Copilot입니다. 모든 모델 호출은 사용량 원장에 기록되고 버전별 요금표에 따라 가격 책정됩니다. 두 개의 스택이 이 원장에 데이터를 공급합니다. 하나는 자연어 질문을 검색 필터로 변환하여 InvokeModel을 직접 호출하는 .NET 기능이고, 다른 하나는 LangChain의 ChatBedrockConverse를 거치는 문서 검색 Copilot과 질문 생성 기능을 포함한 Python 스택입니다.
Raw InvokeModel (Anthropic messages body) | LangChain usage_metadata (via ChatBedrockConverse) | |
|---|---|---|
input_tokens means | uncached remainder (캐시되지 않은 나머지) | full input, cache included (전체 입력, 캐시 포함) |
| ... | ||
| The Python client는 breakdown을 버리는 시점에서 다음과 같습니다: |
details = raw.usage_metadata.get("input_token_details") or {} # A per-TTL cacheDetails breakdown puts the write under the ephemeral keys and may leave# cache_creation at zero, so accept either representation.
...```
이것은 같은 딕셔너리 내의 두 번째 함정입니다. 만약 `cache_creation`만 읽는다면, 캐시 쓰기가 조용히 사라질 수 있습니다. .NET 측에는 자체적인 방식이 있습니다: `JsonElement.TryGetInt32`는 비(非)숫자 요소에 대해 오류를 발생시키므로, 파서는 먼저 `ValueKind`를 확인합니다. 그렇지 않으면 잘못된 카운트 하나가 전체 요청을 실패하게 만듭니다.
범위(Scope)에 대해서 말씀드리자면: 저는 Anthropic 바디를 가진 원본 `InvokeModel`과 클라이언트가 받는 LangChain의 `usage_metadata` 모두를 지지합니다. 이 코드는 폴딩이 Converse에서 발생하는 것인지 아니면 langchain-aws에서 발생하는 것인지를 알려주지 않으며, 다른 Bedrock API는 확인하지 않았습니다.
## 이중 계산(Double Count)이 비용으로 치르는 금액 (가상 화폐 기준)
여기서는 테스트 스위트의 숫자를 따르겠습니다. 여기는 Sonnet 5 가상 속도(rate card의 원래 리스트 가격 시드)로 책정되어 있습니다: 입력 토큰당 $2, 출력 토큰당 $10, 캐시 읽기당 $0.20, 캐시 쓰기당 $2.50 (백만 토큰 기준).
[Fact]
public void Price_Takes_The_Cache_Slices_Out_Of_The_Input_Count_On_A_LangChain_Row()
{
...
이것의 덧셈 쌍(additive twin)은 동일한 카운트를 가지고 $0.001445를 주장합니다. 엔드투엔드 가상 시나리오는 캐시된 Copilot 사용입니다: 입력 토큰 12,000개 중 10,000개가 캐시에서 읽히고 1,500개가 쓰였으며, 출력은 800개입니다. 올바르게 가격 책정하면 $0.01475입니다. 잘못된 경로를 거치면 $0.03775가 되며, 이는 2.56배 차이입니다.
승수(multiplier)는 캐시 적중률(cache-hit rate)에 따라서도 증가합니다. 캐싱이 잘 될수록 과다 청구(overbill)는 더 심해집니다.
## 규칙을 호출(call)의 일부로 만드세요
해결책은 영리하지 않습니다. 측정되는 호출(metered call)은 어떻게 계산하는지 명시하지 않고서는 구성할 수 없습니다:
public enum TokenConvention
{
/// Raw Bedrock: the input count is the uncached remainder, so the cache slices add to it.
...
`required`는 폴백할 기본값이 없다는 의미입니다. Ledger 행은 카운트를 보고된 그대로 유지하며, PR에서 저는 리뷰어들에게 각 컨벤션을 명시한 컬럼 주석이 곧 계약이라고 말했습니다. 나중에 사용처가 `cache_additive` 또는 `cache_folded`를 선언하지 않은 이벤트를 통해 인제스트 엔드포인트로 이동하면서, 기본값 처리되는 대신 거부되기 시작했습니다. 누가 계산했는지 물어보고 문 앞에서 물어보세요.
Folded 행의 경우 엔진이 슬라이스를 빼고 0으로 내림(floors at zero)하기 때문에, 일관성 없는 행이 음수 청구액이 될 수 없습니다. 제가 병합한 버전은 다음과 같습니다:
// The cache slices are always priced at their own rates. What differs is whether the input count
// still contains them: under CacheFolded it does, so subtract them out before charging input rate.
var billableInput = call.InputTokens;
...
첫 번째 주석을 다시 읽으세요. _항상요._
## 🪤 두 번 무료로 제공받기
레이트 카드상의 채팅 행은 캐시 요율(cache rates)을 생략할 수 있도록 허용됩니다. gpt-oss에는 프롬프트 캐시가 없기 때문에 반드시 그래야 합니다. 이와 같은 folded 행에 대해 가격 책정을 할 경우, 520개의 입력 토큰 중 450개가 입력 카운트에서 빠져나가고 `?? 0m`으로 가격이 매겨집니다. 이들은 입력 요율 청구 대상에서 제외되며, 별도로는 아무런 비용도 부과되지 않습니다. 이 설정에서는 $0.00124 대신 $0.00034가 됩니다.
제가 놓쳤습니다. GitHub Copilot의 리뷰어는 17:03:08 UTC에 놓쳤습니다:
> "CacheFolded 모드에서, 청구 가능한 입력(billable input)은 항상 캐시 읽기/쓰기 토큰을 제외합니다. 만약 해결된 요율이 존재하지만 캐시 슬라이스 요율이 null인 경우(예: 프롬프트 캐시가 없는 채팅 행에 허용됨), 해당 캐시 토큰들은 입력에서 제외되고 $0으로 가격이 책정됩니다(because PerMillion(..., null) => 0). 이는 사실상 무료로 만듭니다. 실제로 별도로 청구될 슬라이스만 빼세요."
저는 17:03:58에 병합했고, 그 주석은 답변 없이 그대로 들어갔습니다.
이것은 청구(billing) 문제가 아니었습니다. 아직 이를 소비할 롤업(rollup)이 도착하지 않았기 때문에 엔진을 호출한 적도 없었고, 따라서 버그는 실제 청구를 발생시키지 않았습니다. 또한 제가 이 봇을 영웅이나 웃음거리로 만들고 싶지는 않습니다. 이 봇은 저보다 'null-rate' 케이스를 더 신중하게 읽었으며, 저는 읽기보다 빠르게 병합했습니다. 수정 사항은 댓글이 달린 후 2분 만에 커밋되었고 다음 날의 후속 PR에 반영되었습니다:
var pricedSeparately =
(rate.CacheReadUsdPerMTok != null ? (decimal)call.CacheReadTokens : 0m)
+ (rate.CacheWriteUsdPerMTok != null ? (decimal)call.CacheWriteTokens : 0m);
...
슬라이스(slice)는 다른 무언가가 이를 청구할 예정일 때만 입력 카운트에서 빠집니다:
[Fact]
public void Price_Charges_A_Folded_Cache_Slice_As_Input_When_No_Rate_Covers_It()
{
...
### 전송된 토큰은 결코 무료가 아닙니다
미정산(unrated) 읽기 요청을 입력 요율로 청구하는 것은 과대평가입니다. 왜냐하면 시드(seed)에서는 읽기가 입력의 1/10 비용이기 때문입니다. 저는 그것으로 충분히 견딜 수 있습니다. 커밋 메시지가 제가 지금보다 더 잘 설명해줍니다: 이는 '실제 할인 대비 캐시 읽기 요청을 과대평가하지만, 전송된 토큰은 절대 손실하지 않는다.'
두 오류는 대칭적이지 않습니다. 과대평가된 읽기는 경계가 있고 청구서에 높은 부하를 주는 원장(ledger)으로 나타납니다. 반면, 손실된 토큰은 그 어디에도 나타나지 않습니다. 같은 규칙이 이 작업의 덜 화려한 절반을 주도했습니다. 실패 경로(fail paths)는 이제 이미 사용된 토큰을 보존합니다. 이전에는 두 가지 오류 경로(응답 없음, 파싱할 수 없는 응답)가 요청 행을 전혀 기록하지 않았기 때문에, 해당 요청은 토큰이 사라진 채 영원히 `processing` 상태에 머물렀습니다.
### CHECK 제약 조건으로 안 될까요?
요금표는 잘못되었다고 증명할 수 있는 형태 자체를 거부합니다. 출력 요율이 없는 채팅 행은 모든 답변을 무료로 청구하므로 거부됩니다. 이 형태는 거부될 수 없습니다. 검토 과정에서 제가 언급했듯이, "일부 모델은 프롬프트 캐시가 없어 합법적으로 아무것도 가지고 있지 않으며(gpt-oss), 이를 요구하면 올바른 행이 거부됩니다. 캐싱을 하지만 캐시 요율 없이 시딩된 모델의 경우, 캐시된 슬라이스를 과소 청구합니다. AWS 인보이스와 원장을 일치시키는 것이 그 문제를 잡아내는 것이지, 스키마가 아닙니다."
같은 검토자가 이전에 두 가지를 더 발견했습니다. 하나는 해당 뺄셈에서의 `long` 산술로, 이는 손상된 행을 "0으로 바닥 처리하는 대신 극도의 과금"으로 감쌀 수 있다는 것이었습니다. 이제는 `decimal`입니다. 다른 하나는 데이터베이스가 없을 때 단순히 `return`하는 통합 테스트였는데, 이것은 통과로 간주됩니다. 아무것도 단언하지 않았기 때문에 녹색이었습니다. 명시적으로 건너뛰었을 때, 데이터베이스가 없는 실행 횟수는 53개 건너뜀에서 60개로 늘어났습니다.
## 제가 스스로 반박하고 싶은 부분
- **제 규칙은 한 브랜치에만 적용됩니다.** 추가 경로에서는 요율이 책정되지 않은 캐시 슬라이스도 여전히 0달러로 가격이 책정되며, gpt-oss 테스트가 그 동작을 고정합니다. 이는 캐시가 없는 모델에게는 맞습니다. 캐시 요율 없이 시딩된 캐싱 모델의 경우, 다른 브랜치에서 무료 토큰 버그가 다시 발생합니다.
- **쓰기(Writes)가 과장되지 않았습니다.** 시드에서는 쓰기가 입력의 1.25배 비용이 들기 때문에, 요율이 책정되지 않은 쓰기를 입력으로 청구하면 20%만큼 과소 청구됩니다. 손실되는 것은 없지만, "err high"는 정말로 읽기(reads)만 다룹니다.
- **요율은 리스트 가격입니다.** Claude 행은 Bedrock의 대용품으로 Anthropic의 퍼스트파티 리스트 요율을 사용하며, 저는 아직 AWS 인보이스와 원장을 일치시키지 않았습니다. 제가 할 때까지 이 엔진이 생성하는 모든 숫자는 리스트 가격 추정치입니다.
- **위험 부담은 고정된 값입니다.** 수정된 엔진은 이제 실제 트래픽에 대한 가격을 책정하지만, 2.56배가 넘는 것은 제 청구서에서 나온 것이 아니라 테스트에서 나온 것입니다.
첫 번째 글머리 기호는 제 자체 설계에 대한 가장 강력한 반론입니다. 가격 책정기(pricer)에 두 가지 규칙이 있으면, 모든 엣지 케이스가 두 곳에서 정확해야 합니다. 제가 나중에 작성한 사용량 집계(usage rollup)는 토큰 합계를 위해 입력을 전체로 정규화하므로, 대안은 제 코드 자체에 있습니다: 수집 단계에서 한 번만 정규화하고 가격 책정기를 단일 경로로 유지하는 것입니다. 저는 이 규칙을 가격까지 가져갔기 때문에, 모든 원장(ledger) 행이 제공업체가 보고한 내용을 정확히 담고 있습니다. 그 이유는 재가격 책정(repricing) 때문입니다: 과거 기간은 엔진을 원본 행에 다시 실행하여 재가격 책정됩니다. 만약 제가 잘못된 규칙으로 소스를 제출한다면, 레이블을 수정하고 재계산하며, 증거는 여전히 원장에 남아있습니다. 입구에서 정규화된 행은 이미 그 증거를 버린 것입니다.
그렇다면 경계(boundary)에서 정규화한 다음 단일 형태에 대해 가격 책정할 것인가요, 아니면 규칙을 가격까지 가져가서 두 개의 분기점을 소유할 것인가요?
빼기에 관한 전체 게시물을 읽어주셔서 감사합니다 🧮 만약 여러 SDK를 통해 프롬프트 캐싱(prompt caching)을 측정하거나, 원장이 AWS 청구서와 의심스러울 정도로 벗어난 적이 있다면, 어떤 규칙을 선택했는지 알려주세요. 저는 [LinkedIn](https://www.linkedin.com/in/rodrigo-diego-67867185/)에 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기