這筆帳是誰加出來的:拆開 Agent SDK 的 usage、total_cost_usd 與 modelUsage
total_cost_usd 這個數字,是誰在哪一刻把它加出來的?
答案是你自己的機器。Claude Agent SDK 身上打包了一張價目表,把這通呼叫用掉的 token 乘一乘,填進最後那則 result message。官方文件在這頁最上面掛了一段警告,用的字眼是 client-side estimates, not authoritative billing data。
這句話讀起來像免責條款,實際上是整件事的起點。一旦知道那個數字是本機加出來的,下一個問題就自己冒出來了:它加了哪些東西進去,又漏了哪些?而三個看起來都在講用量的欄位,答案完全不一樣。
一次呼叫會吐出兩種帶 usage 的東西
先把作用域分清楚,所有的低估都是從這裡開始歪的。
一次 query() 裡面可以包好幾個 step。一個 step 就是一輪 request/response:Claude 回話、叫工具、拿到結果、再回話。每個 step 產生 assistant message,TypeScript 這邊每則訊息裡包了一個 BetaMessage,用 message.message 拿到,裡面有 id 跟 usage;Python 那邊平一層,直接是 message.usage 跟 message.message_id。
整通呼叫跑完,SDK 吐一則 result message,帶著累計的 usage 跟 total_cost_usd。再往上一層是 session,用 resume 把好幾次 query() 串起來。SDK 不提供 session 層級的總計,每次呼叫的 result 只反映那一次的成本,總帳得你自己累加。
所以「一次 query 的總量」跟「一個 step 的用量」是兩個東西,而 session 的總量根本沒人幫你算。
同一個 id 會連著出現好幾次
Claude 在同一輪裡平行叫了好幾個工具的時候,會產生好幾則 assistant message,而這幾則共用同一個 id,usage 資料也一模一樣。官方文件那張訊息流示意圖畫的第一個 step,就是四則訊息共用同一個 id。
你如果老實地把每則訊息的 input_tokens 加一遍,這個 step 會被算四次。
這件事的形狀跟餐廳結帳很像:四個人點餐,服務生給了四張單據,上面印的卻是同一張帳。實作上就是拿一個 Set 記住看過的 id,重複的跳過。
每則訊息上的 output_tokens 都是同一個假數字
這一段是整篇最容易寫錯的地方,而且它錯得很安靜。
Claude Code 組 assistant message 的時候,用的是 API 在回應剛開始那一刻報回來的 usage。那個時間點在 message_start,回應還沒生出來,所以 output_tokens 不是這則回應真正吐了多少。更麻煩的是一次 API 回應可以產生好幾則 assistant message,每一則都帶著同一個佔位值。
真正的 output 數字,API 在回應結束的時候才報,Claude Code 把它加進 result message。官方文件把這條寫成一個警告框:去重之後的 per-step 數值,對 input 跟 cache token 是準確的,output_tokens 是佔位值,要從 result message 讀。
照文件抄改,骨架長這樣:
1 | const seenIds = new Set<string>(); |
想看 output 邊 streaming 邊長,開 includePartialMessages(Python 是 include_partial_messages),讀每個 message_delta stream event 上的 usage。
這裡就有一條看起來很合理、實際上會給你一份假帳的做法:把每則 assistant message 的 usage 全部加起來,當成這通呼叫的總量。 它同時踩到三個地雷:平行工具呼叫沒去重、output 全是佔位值、subagent 的訊息根本不在這條流裡面。官方文件唯一給它名分的地方,是 session 崩潰後的復原流程,而且明講那是萬不得已的 step 2:這樣只救得回主迴圈的 input 與 cache token,subagent 用量救不回來,output token 跟美金金額也救不回來。
所以它是退路,不是量法。
三個欄位,三種涵蓋範圍
result message 上那三個欄位,差別在 agent 生 subagent 的時候才會現形。官方給了一張表:
| 欄位 | subagent 的用量 |
|---|---|
usage |
排除。只算最上層的 agent loop,subagent 裡面燒掉的 token 不會加進來 |
total_cost_usd |
包含。subagent 的請求跟頂層迴圈一起算 |
modelUsage / model_usage |
包含。跟頂層迴圈一起算,而且按 model 拆開 |
表上那個「排除」,實際長這樣:你用 Haiku 跑一堆 subagent、Opus 跑主線,Haiku 那一整批的 token 在 usage 裡是隱形的。streaming input mode 底下還要再窄一層,只涵蓋那一個 turn,主迴圈以外一樣不算。
total_cost_usd 那格是一個美金數字,沒有 token 拆解;streaming input mode 底下它是整通呼叫到目前為止的累計,不是單一 turn。
modelUsage(Python 是 model_usage)是一張 map,key 是 model 名字,值裡面有 costUSD、inputTokens、outputTokens、cacheReadInputTokens、cacheCreationInputTokens。官方那句話講得很直接:要做整棵樹的 token 帳,用 modelUsage,usage 一旦出現巢狀就會少算。
1 | for (const [modelName, usage] of Object.entries(message.modelUsage)) { |
還有兩個地方能反過來驗證這張表。第一,預算被撞破的時候(error_max_budget_usd),usage 會漏掉那個跨過預算的回應,total_cost_usd 跟 modelUsage 有算進去。第二,單一訊息模式下如果最後一輪結束時背景 subagent 還在跑,Claude Code 會等它(有上限),而那段等待期間做的工作,算進 total_cost_usd、duration_api_ms 跟 modelUsage。
我的立場很單純:要量 token 就讀 modelUsage,usage 當作輔助。什麼情況下我會改口?如果你的 agent 從頭到尾不生任何 subagent,那兩個欄位算的就是同一件事,上面這幾段可以整段跳過。而人通常是加了 subagent 之後才回頭看帳,那時候少掉的已經少掉了。
/clear 會把累計歸零,連預算上限一起
streaming input mode 有自己一套帳。一次 query() 帶好幾個 user turn,每個 turn 各自發一則 result,而 total_cost_usd 跟 modelUsage 是整通呼叫的 running total。只要 app 從來沒送過 /clear、/reset、/new,讀最後一則 result 就是全部。
送了那三個指令之一,running total 會重新從零開始,而且會帶一個新的 session_id。要湊出整通呼叫的總帳,得把每次 /clear 之前的最後一則 result,加上整通呼叫的最終 result。中間那些都會被後面的蓋掉。
小標那句「連預算上限一起」,講的就是這個。maxBudgetUsd(Python 是 max_budget_usd)比對的是同一個 running total,所以 /clear 一下去,預算也跟著重新開始算。
你在 app 裡替每個使用者設了一個成本上限。某個人快撞到了,他按一下清空,額度滿血回來;再按一次,再滿一次。按幾次沒有人在攔。
而且帳面上看不出來。每次重置之後的 result 都是從零長起的,那份 log 讀起來就是一個很省的使用者。要擋,累加得由你自己那一層做,maxBudgetUsd 接不住這件事。
TypeScript 會在每次重置發一則 SDKConversationResetMessage,Python 是 ConversationResetMessage;但 Python SDK v0.2.137 之前的 iterator 會把這則訊息丟掉,跑在更舊版本上就得自己從送出的 /clear 去數。
程式崩掉的時候更乾脆。Claude Code 會發一則 error_during_execution 的 result 然後退出,那則 result 的 usage、total_cost_usd、modelUsage 可能整組是零。想撿回總量,得從崩潰前那一則 result 撿,撿不到才退回上面那個只救得回一半的 fallback。
快取那兩欄要單獨拉出來看
usage 物件裡除了 input_tokens 跟 output_tokens,還有兩個 cache 欄位:cache_creation_input_tokens 是建立新快取條目用掉的 token,費率比一般 input 高;cache_read_input_tokens 是從既有快取讀出來的,費率比較低。
這兩個要跟 input_tokens 分開追,不然你永遠看不出快取到底幫你省了多少——三個數字加在一起只會得到一個「總 input 量」,而它們的單價根本不同。Agent SDK 預設就在用 prompt caching,不需要你自己開。至於快取活多久、主對話跟 subagent 的 TTL 為什麼要分兩個鍵設,站上先前那篇 prompt cache 到底存在哪、活多久 已經拆過,這裡不重複。
這組數字不能拿去開發票
回到開頭那個問題。既然是本機算的,它什麼時候會跟真實帳單對不上?官方列了三種:價格變動、安裝的 SDK 版本不認得某個 model、有一些計費規則 client 端模不出來。
有一條規則 SDK 倒是有模:data residency。當某則回應的 usage 回報 inference_geo: "us",SDK 會把那則回應的 token 照牌價乘 1.1;web search 這種按次收的費用不乘。這個行為要 TypeScript Agent SDK v0.3.239 或 Python Agent SDK v0.2.144 以上。
想知道某個 model 的價格是照哪張表算的,modelUsage 每個 entry 有個 costBasis:list 是牌價、managed 是你自己設的 modelPricing 表、unknown 代表兩邊都沒對上這個 model ID。這個欄位需要 Claude Code v2.1.246 以上。這幾個版本門檻是 2026 年 9 月初官方文件上寫的,動手前回去確認一次。
我沒有實際打過這支 SDK 跑一輪來對帳,這篇整理的是官方 cost-tracking 文件上寫的欄位語意與行為,上面兩段程式碼也是照文件的範例抄改的。估算跟實際帳單的誤差幅度有多大,文件沒給數字,我也不會替它編一個。
官方那句話是命令句:不要拿這些欄位對終端使用者收費,也不要用它觸發財務決策。要準的,去打 Usage and Cost API,或看 Console 的 Usage 頁。這組欄位的定位是開發時看個大概、抓個預算量級。
欄位名不會告訴你它量的是哪一段
整件事真正的教訓,其實跟成本沒什麼關係。
usage 這個名字沒有任何一個字元在暗示「不含 subagent」,output_tokens 也沒有在暗示「這是回應開始那一刻的佔位值」。你只能從文件知道它量的是哪一段路,而人的直覺總是先從名字推導涵蓋範圍,推完就當成事實。
這個形狀在別的地方也一樣。socket timeout 量的是每次 read 的閒置上限,不是整個請求的總時間;quota 顯示的是已經結算完的部分,不含已經開始但還沒結算的量。名字都取得很合理,涵蓋範圍卻不一樣。
所以下次要拿某個欄位當判斷依據之前,先問它兩個問題:你算的是哪一段?你不算的又是哪一段?兩個都答得出來,那個數字才有資格進你的儀表板。




































































































































































































