Claude Media
Claude APIのthinking_tokensで思考トークンを切り分ける

Claude APIのthinking_tokensで思考トークンを切り分ける

usage.output_tokens_details.thinking_tokensは、課金対象のoutput_tokensのうち内部推論に使われた分を返す内数です。要約表示でも生の量を返す点、ずれ方、集計コードを扱います。

usage.output_tokens_details.thinking_tokensは、レスポンスのoutput_tokensのうち、モデルが内部推論に使った分を返すフィールドです。課金の正はoutput_tokensのままで、thinking_tokensはその内訳を見るための読み取り専用の数字にあたります。思考にどれだけ払ったかを、レスポンスだけで切り分けられます。

thinking_tokensは何の内数か

output_tokensは思考と本文を合わせた出力トークンの総数で、請求の根拠もこの値です。output_tokens_detailsはその分解で、現在はthinking_tokensだけが入っています。

数字

usageの3つの数字の関係

  • output_tokens

    総数

    課金の正。思考と本文を含む

  • thinking_tokens

    内数

    内部推論に使った分。output_tokens以下

  • 差分

    近似

    output_tokens - thinking_tokens が非推論分

API リファレンスの Usage の記述に沿った整理

thinking_tokensには、思考ブロックの区切りトークンも含まれます。値はoutput_tokensを超えないと定義されています。引き算した残りは、本文やツール呼び出しなど推論以外の出力の近似です。「近似」と書かれているのは、次の節で触れる数え方の事情があるためです。

output_tokens_detailsの型は「オブジェクトまたはnull」です。フィールドが無いレスポンスを想定して、読む側でnullを許容しておきます。

レスポンスのどこを見るか

非ストリーミングでは、usageの直下に並びます。次は形を示す例で、数値は説明用の仮の値です。

{
  "usage": {
    "input_tokens": 2095,
    "output_tokens": 4200,
    "output_tokens_details": {
      "thinking_tokens": 3100
    }
  }
}

この例なら、4,200トークンの出力のうち3,100トークンが推論で、残り1,100トークンが本文などの推論以外です。思考が出力の約74%を占めている、と一目で分かります。

APIリファレンスのレスポンス例では、output_tokensが503でthinking_tokensが0の形で載っています。思考を使わなかった呼び出しでは、thinking_tokensの既定値は0です。

要約表示でも生の推論量が返る

ここが一番誤解されやすい点です。display: "summarized"を指定すると、レスポンスのthinkingブロックには思考の要約が入ります。thinking_tokensが数えるのは、この要約ではなく、モデルが実際に生成した生の推論です。

思考の課金も同じ考え方です。要約のトークンではなく、元のリクエストで生成された思考トークン全体が課金されます。レスポンスに見える文章を数えても、請求されるoutput_tokensとは一致しません。

くらべる

同じ呼び出しで数が合わない理由

見える側

レスポンスの thinking テキスト

要約された思考です。display: "omitted"なら空文字で、数えるものがありません。

数えられる側

thinking_tokens

要約前の生の推論の量です。表示の設定を変えても、同じ推論なら同じ規模の数字になります。

displayの既定値が"omitted"のモデルでは、thinkingが空のままthinking_tokensだけが大きい、という状態が普通に起きます。空の理由はthinkingのdisplay: summarizedとはで扱っています。思考が空でも課金は発生しており、その大きさを確かめる手段がthinking_tokensです。

数トークンずれる理由

thinking_tokensは、生の推論テキストを再トークナイズして数えた値です。そのため、モデルが実際に生成したトークン数と、小さな差が出ることがあります。

この差は数トークン規模のものとして説明されています。1リクエスト単位の厳密な照合には向きませんが、思考の比率を見る監視や、budget_tokensの調整の判断には支障のない精度です。請求額と突き合わせるときは、常にoutput_tokensを使います。

ストリーミングでは最後のmessage_deltaだけに載る

ストリーミング時は、この内訳が最終のmessage_deltaイベントにだけ現れます。途中のイベントでは取れません。

message_deltaのusageは累積値です。イベントごとに足し算すると二重に数えることになるので、最後の1件を採ります。

import anthropic
 
client = anthropic.Anthropic()
 
usage = None
stream = client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=16000,
    thinking={"type": "adaptive", "display": "summarized"},
    messages=[{"role": "user", "content": "素数判定のアルゴリズムを比較して"}],
    stream=True,
)
for event in stream:
    if event.type == "message_delta":
        usage = event.usage  # 累積値。最後の1件が確定値

ループを抜けた時点のusageが、最終のmessage_deltaのものです。event.usageにoutput_tokens_detailsが付いているかは、次の集計関数が吸収します。

集計用の関数を1つ作る

非ストリーミングのresponse.usageも、ストリーミングのevent.usageも、同じ関数で分解できるようにします。

def split_output(usage) -> dict:
    total = usage.output_tokens
    details = getattr(usage, "output_tokens_details", None)
    thinking = getattr(details, "thinking_tokens", 0) if details else 0
    thinking = min(thinking, total)  # 内数なので総数を超えさせない
    return {
        "output_tokens": total,
        "thinking_tokens": thinking,
        "other_tokens": total - thinking,  # 非推論分の近似
        "thinking_ratio": thinking / total if total else 0.0,
    }

ポイントは3つあります。output_tokens_detailsが無い・nullの場合は0として扱うこと。minで総数を超えないよう丸めること。差分はother_tokensのように「近似」と分かる名前にすることです。

ログに書くときは、output_tokensの列とthinking_tokensの列を別々に持たせます。比率は後から計算できるので、生の2列を残しておきます。

row = split_output(usage)
print(
    f"model={model} out={row['output_tokens']} "
    f"think={row['thinking_tokens']} ratio={row['thinking_ratio']:.0%}"
)

数字をどう使うか

thinking_tokensが使える場面は、思考にかけた量を調整する判断です。

  • budget_tokensを設定している呼び出しでは、予算に対して実際のthinking_tokensがどこまで使われたかを見る。budget_tokensは厳密な上限ではなく目標値で、Claudeが予算を使い切る前に推論をやめることもある
  • max_tokensで出力が途切れる場合、思考もmax_tokensに含まれるため、thinking_tokensが大きければ本文に回る枠が削られている。max_tokensが思考で食われているかどうかは、この数字で判断できる
  • effortを変えたのに思考量が変わらない、という疑いは、変更前後のthinking_tokensを並べると数字で確かめられる。原因の切り分けはeffortを変えてもthinkingの深さが変わらないときにまとめている

budget_tokensの制約は、最小1,024トークンでmax_tokens未満です。小さいタスクは下限付近から始め、複雑なタスクは16,000トークン以上から始めて調整する、という目安が示されています。調整のたびにthinking_tokensの実績を見れば、予算が余っているのか、足りずに頭打ちなのかが分かります。思考が32,000トークンを超える用途は、ネットワーク上の問題を避けるためバッチ処理が勧められています。

ツール呼び出しを挟む長いやり取りでは、レスポンスのusage.iterationsに、サンプリング単位のエントリが並びます。その各エントリにもoutput_tokens_detailsが定義されています。ターン全体の合計は上位のusageで、どの反復で思考が膨らんだかはiterations側で追う、という使い分けになります。

iterationsを集計に使うときは、エントリのtypeを見ます。messageのエントリはモデルのサンプリング1回分で、サーバー側のツール実行ループの各ターンがここに並びます。compactionのエントリは、コンテキストを要約する処理そのものの使用量です。この分は上位のusageのトークン数に含まれません。上位のusageとiterationsの合計が一致しなくても、compactionがあればその分の差で説明がつきます。

反復ごとのthinking_tokensを時系列で並べると、ツールの結果を受けた後に推論が増える箇所が見えます。ターン全体の値だけでは平均化されて見えない動きなので、思考が重いと感じる長いエージェントの調査では、この粒度まで下りる価値があります。

curlで1回だけ確かめる

集計コードを組む前に、手元で形を見ておくと安心です。次はjqでusageだけを取り出す例です。

curl -s https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-sonnet-5-5",
    "max_tokens": 16000,
    "thinking": {"type": "adaptive", "display": "omitted"},
    "messages": [{"role": "user", "content": "17の3乗は?"}]
  }' | jq '{usage, thinking_text: [.content[] | select(.type=="thinking") | .thinking]}'

display: "omitted"にしているので、thinking_textは空文字の配列になり、usage.output_tokens_details.thinking_tokensだけに推論量が現れます。要約を返す設定に変えて同じ質問を投げても、thinking_tokensは同じ規模のままです。表示の設定と推論量が別物だと、自分の目で確かめられます。

thinkingのパラメータを付けない呼び出しでthinking_tokensが0になるのは、Opus 4.8・4.7・4.6やSonnet 4.6のように思考が既定でオフのモデルです。Sonnet 5.5・Opus 5.5・Haiku 5.5など思考が既定でオンのモデルは、パラメータを省いても思考が走るため、この例のclaude-sonnet-5-5でも0にはなりません。思考を使わなかった呼び出しでは0で、0という値自体はフィールドの既定値です。

症状から数字の読み方を引く

output_tokensとthinking_tokensの組み合わせから、次に調べる場所が決まります。

見えている状態数字の読み方次に見る場所
thinkingが空、thinking_tokensが大きい数字の読み方推論は走っており、表示だけ省かれている次に見る場所displayの設定
thinking_tokensが0、output_tokensは大きい数字の読み方本文など推論以外が長い次に見る場所max_tokensと出力の長さ
stop_reasonがmax_tokens、thinking_tokensが大半数字の読み方思考が出力枠を使い切り、本文が途切れた次に見る場所max_tokensの引き上げ、effort
変更前後でthinking_tokensがほぼ同じ数字の読み方設定変更が思考量に効いていない次に見る場所effort・budget_tokensの指定経路
請求とoutput_tokensの合計が合わない数字の読み方thinking_tokensを別料金として足していないか次に見る場所集計式

2行目の「thinking_tokensが0」は、思考がオフだったのか、思考が短く済んだのかを区別しません。区別したいときは、リクエストに載せたthinkingの設定をログに一緒に残します。

日次で思考の比率を集計する

1回ごとの数字は揺れるので、運用では集計して傾向を見ます。先ほどのsplit_outputの結果を貯める想定で、モデルごとの比率を出す関数は次のとおりです。

from collections import defaultdict
 
def thinking_share(rows: list[dict]) -> dict[str, float]:
    """rows: model と split_output の結果を持つ辞書のリスト"""
    total = defaultdict(int)
    think = defaultdict(int)
    for r in rows:
        total[r["model"]] += r["output_tokens"]
        think[r["model"]] += r["thinking_tokens"]
    return {m: think[m] / total[m] for m in total if total[m]}

比率は「トークンの合計どうしの割り算」で出します。呼び出しごとの比率を平均すると、短い呼び出しの影響が過大になります。個々の呼び出しが数トークンずれる件は、合計に対しては無視できる大きさです。

effortやbudget_tokensを変えた日の前後でこの比率を比べれば、変更が思考量に効いたかどうかを、感覚ではなく数字で判断できます。MAX_THINKING_TOKENSで予算を固定している場合の考え方はMAX_THINKING_TOKENSの解説にあります。

取り違えやすい点

落とし穴

thinking_tokensを足して請求額を出してしまう

thinking_tokensはoutput_tokensの内数なので、両者を足すと思考を二重に数えます。請求の計算はoutput_tokensだけで行い、thinking_tokensは内訳の表示に限ります。

次の3点も、集計でつまずきやすい箇所です。

  1. 要約テキストの長さからthinking_tokensを推定しない。要約と生の推論は別物で、display: "omitted"では要約が空になる
  2. ストリーミングの途中イベントに内訳を期待しない。最終のmessage_deltaを待つ
  3. output_tokens - thinking_tokensを厳密な本文トークン数として請求と突き合わせない。再トークナイズに由来する差が含まれる

thinkingの設定を変えるとプロンプトキャッシュの効き方も変わります。思考の量とあわせて見るなら、thinking設定とキャッシュヒット率の記事が手がかりになります。

まとめ

thinking_tokensは、課金の正であるoutput_tokensの内訳を、表示設定に関係なく生の推論量で返す数字です。請求はoutput_tokens、思考の比率の監視はthinking_tokensと役割を分ければ、二重計上は起きません。ストリーミングでは最後のmessage_deltaだけを読み、nullと総数超えをコード側で丸めておけば、集計関数は十数行で済みます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. platform.claude.com/docs/en/api/beta/messages/create
  2. platform.claude.com/docs/en/build-with-claude/extended-thinking
  3. platform.claude.com/docs/en/build-with-claude/thinking
  4. platform.claude.com/docs/en/build-with-claude/streaming
この記事を共有:XはてブLinkedIn