トークンとコンテキストウィンドウ¶
前章 で「1 回の LLM 呼び出しでは messages に全部書き下す必要がある」と言ったが、いくらでも書き放題ではない。LLM には「1 回に受け取れる入力の最大量」という硬い制限があり、この単位がトークンで、枠のサイズがコンテキストウィンドウ。
この章ではトークンとは何か、なぜ大事か、どう付き合うかを整理する。
トークンとは (ざっくり)¶
LLM は文字列をそのまま読むのではなく、サブワード単位に区切った「トークン列」として扱う。英単語 1 個 = 1 トークン ... とは限らず、よく出る語は 1 トークン、珍しい単語は複数トークンに分解される。
代表的なトークン化方式は BPE (Byte-Pair Encoding) 系。以下は OpenAI の cl100k_base トークナイザで "Hello, world!" を分解したときの例:
Helloは 1 トークン (よく出るので),(カンマ) は 1 トークンworld(先頭スペース込み) は 1 トークン!は 1 トークン
スペースが単語の先頭に付くのが BPE 系の特徴。"hello world" は ["hello", " world"] の 2 トークンだが、先頭で " hello" のようにスペースから始まると別のトークンになることもある。
日本語はもっと細かく割れる¶
日本語は Unicode 1 文字あたり 3 バイトを超えることが多く、BPE の語彙に含まれていない漢字や組み合わせは1 文字で複数トークンになる。雑な経験則として:
| 言語 | 文字数 → トークン数の目安 |
|---|---|
| 英語 | 1 トークン ≈ 3〜4 文字 (英単語 1〜1.5 語分) |
| 日本語 | 1 トークン ≈ 1〜2 文字 (漢字混じり) |
| コード | 1 トークン ≈ 3〜5 文字 (記号が多い) |
同じ情報量でも日本語は英語より 2〜3 倍トークンを食うので、同じコンテキストウィンドウに入る文章量も少なくなるし、課金も重くなる。この差はモデルによっても違い、Gemini 系は日本語トークナイザが比較的効率的、OpenAI 系はやや大食い、Anthropic は中間、というのが一般的な体感。
自分で確認するには¶
いちばん手軽なのは LiteLLM のレスポンスに入ってくる usage:
curl -s http://litellm.home.arpa/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"Hello, world!"}]}' \
| jq '{prompt: .usage.prompt_tokens, completion: .usage.completion_tokens}'
同じ文字列を日本語と英語で投げて prompt_tokens を比べてみると、日本語の方がトークン数が多いのがはっきり分かる (本章の末尾でもう少し遊ぶ)。
コンテキストウィンドウ¶
LLM が一度に覚えていられるトークンの上限。リクエストの messages (system + user + assistant + tool) + tools スキーマ + LLM の生成応答、全部合わせてこのウィンドウに収まらないといけない。
┌────────────────── コンテキストウィンドウ (例: 200,000 トークン) ──────────────────┐
│ │
│ [system prompt] [tools スキーマ] [過去の user / assistant / tool のやり取り] │
│ │
│ ↑ ここまでが prompt_tokens ──────────────────────────────────────┐ │
│ │ │
│ [新しい user メッセージ] │ │
│ ──────────────┘ │
│ │
│ [LLM 生成応答] │
│ ↑ completion_tokens │
│ │
└────────────────────────────────────────────────────────────────────────────────────┘
prompt_tokens + completion_tokens ≤ context window という制約。超えたら:
- リクエスト時点で prompt が既にオーバー → API が 400 エラーで即拒否
- prompt は収まるが
max_tokens(生成側の上限) が無いまま走って途中で打ち切り →finish_reason: "length"で応答が途中まで - エージェントがループで
messagesを増やしていった結果、N ターン後にオーバー → N-1 ターン目までは動いていたのに突然失敗
主要モデルの上限感 (目安)¶
数字は頻繁に更新される (新モデル / 拡張版 / ベータ等) ので、大雑把な世代感として見る。
| モデル | だいたいの context window |
|---|---|
| GPT-5.4 系 (gpt-5.4 / mini / nano) | 1M トークン級 |
| Claude Opus 4.6 / Sonnet 4.6 / Haiku 4.5 | 200K (extended thinking モードで 1M になるものもあり) |
| Gemini 2.5 Pro | 1M 〜 2M |
| Gemini 2.5 Flash | 1M 級 |
| Llama 3.1 / 3.3 | 128K |
| Qwen3.5 | 128K 前後 (モデルサイズ依存) |
ウィンドウは「長ければ長いほどよい」わけではない。以下の副作用がある:
- 料金: 入力トークン数 × 単価で線形に増える
- レイテンシ: 長いほど生成開始も終了も遅い
- 品質: 長大な context ではモデルの集中力が落ち、途中の情報を見落とす「lost in the middle」問題が起きやすい
- トークン価格は固定ではない: 一部モデルは長コンテキストだと単価が上がる ("> 128K tokens → $$/M が倍" のような階層課金)
だから実務では「ウィンドウは大きいけど実際に送る量は必要最小限にする」のが基本方針になる。
何がトークンを食うか¶
messages 配列の中身だけでなく、LLM にメタデータとして渡しているもの全部がトークンを消費する。
| 項目 | 該当する agent-demo のコード |
|---|---|
| system prompt | setup.ts の SYSTEM_PROMPT (数百トークン) |
| user メッセージ | 人の入力 |
| 過去の assistant メッセージ (再送) | 対話モードの messages 配列 |
| 過去の tool_call と tool result (再送) | 同上 |
| ツール定義 (JSON schema) | tools.ts の name / description / schema 全部 (1 ツール 50〜200 トークン程度) |
| LLM の生成応答 | 出力側の completion_tokens |
| (推論モデルの場合) 内部思考トークン | completion_tokens_details.reasoning_tokens として別集計されるモデルもある |
意外と大きいのがツール定義。本リポジトリの agent-demo は 7 ツール (search/fetch_url/wikipedia/now/calc/random_int/end_chat) を全部渡しているので、それだけで毎リクエストに数百〜千トークン強の固定オーバヘッドが乗る。
これが「ツールは増やせば増やすほど LLM 判断力が上がる」わけではない理由の 1 つで、多すぎるツール = (1) プロンプトが膨らむ、(2) LLM が選択に迷って誤選択が増える。agent-demo で AGENT_TOOLS=search,now のように絞れるようにしたのはこのため (ただし学習目的なのでデフォルトは全部入り)。
上限との付き合い方¶
長い会話や大量の検索結果を扱うエージェントは、以下のどれかで「入る量」を制御する:
1. スライディングウィンドウ (古いメッセージから捨てる)¶
シンプル。messages の先頭から (system は残して user/assistant の古いものから) 捨てる。
- 長所: 実装が簡単、計算コストゼロ
- 短所: 古い文脈が消えるので「さっき話した私の名前」が突然忘れられる
2. 要約圧縮¶
古い会話を別 LLM 呼び出しで「これまでの要約」に変換し、先頭に入れる。LangChain / LangGraph には ConversationSummaryMemory のような実装がある。
- 長所: 情報の骨格が残る
- 短所: 要約もトークンを食う (余計な呼び出し)、要約段階での情報損失
3. 選択的保持¶
過去メッセージのうち「重要」と判定したものだけ残す。重要度スコアリング + 閾値で足切り。
- 長所: 情報密度が高く保てる
- 短所: 重要度判定のロジックが必要
4. 外部化 (RAG)¶
過去の会話 / 知識を Vector DB 等に貯めておき、必要になったら検索して必要な部分だけ context に戻す。第 7 章 記憶の多層モデル の L4 と同じ話。
- 長所: 容量が実質無制限
- 短所: retrieval の精度が品質を左右する / 別途インフラが必要
プロンプトキャッシュで課金トークンを減らす¶
「送るトークン数」は減らせないが、プロバイダ側のキャッシュが効けば課金される実効トークン数は減る。Anthropic の cache_control や OpenAI の prompt caching を使うと:
- 2 回目以降の同一プレフィックス (system + 長い固定 context) は実質無料 〜 割引価格
- レイテンシも短くなる
- TTL (数分〜1 時間) を超えると消える
詳細は 第 7 章 記憶の多層モデル の L2 "プロンプトキャッシュ (プロバイダ側 TTL)" を参照。
実機で遊んでみる¶
同じ意味の質問を英語と日本語で投げて、prompt_tokens の差を見る:
# 英語版
curl -s http://litellm.home.arpa/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"What is the height of Mount Fuji?"}]}' \
| jq '.usage'
# 日本語版
curl -s http://litellm.home.arpa/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"富士山の高さは何メートルですか?"}]}' \
| jq '.usage'
同じ情報を聞いているのに prompt_tokens が異なる。通常は日本語の方が大きい。これが「日本語は英語より context を食う」ことの直接確認。
さらに claude-sonnet-4-6 と gemini-2.5-flash で同じプロンプトを投げ比べると、トークン数がずれる。トークナイザがモデルごとに違うので、「何トークンか」はモデル依存だと分かる。
概念のおさらい¶
- トークン = LLM が文字列を分解する最小単位。サブワードレベル
- コンテキストウィンドウ = 入力 + 出力の合計トークンの上限
- 日本語は英語より 2〜3 倍トークンを食う
- ツール定義も毎リクエストに乗るので、ツールを増やすほど固定オーバヘッドが増える
- ウィンドウは広ければよいものではない。料金 / レイテンシ / 品質 (lost in the middle) の三重苦
- 上限との付き合い方は truncate / summarize / 選択 / RAG の 4 パターン
- キャッシュ (第 7 章 L2) で課金トークンを減らせる場合がある
agent-demo との対応¶
setup.tsのSYSTEM_PROMPT= 毎リクエストに乗る固定オーバヘッドtools.tsで定義したツールのdescription/schema= これも毎リクエストに乗るagent-chat.tsのmessages = result.messagesで履歴を蓄積する部分 = ターンを重ねるほど context が膨らむ- 現状の agent-demo にはコンテキスト超過対策が入っていない。長時間の対話では
lost in the middleや context window エラーが起き得る。ここを鍛えるには第 7 章の L3/L4 の拡張が必要