品質を損なわずにトークン使用量を減らす設計
先月、私はカスタマーサポートエージェントをレビューしました。そのエージェントは、文章をひと言も生成する前に、チケットごとに18,000~42,000の入力トークンを消費していました。実際のユーザーの問題は、たいてい300トークン未満でした。残りは「念のため」のコンテキストです。会話履歴の全体、キーワード検索で取得した社内ポリシーページのすべて、そのままコピーしたJSON件のツール結果、そして半年かけて肥大化した900トークンのシステムプロンプトが含まれていました。
解決策は「もっと小さいモデルを使う」ことでも、「次の値を下げて max_tokens あとはうまくいくことを祈る」ことでもありませんでした。コンテキストの整理、より絞り込んだ根拠の取得、状態の要約、出力スキーマの適用、トークンの正確な計測という地道なエンジニアリング作業により、回答品質を維持しながらトークン使用量の中央値を約68%削減しました。
この記事で扱うのは、そうした作業です。プロンプトを極限まで短くする競技でも、モデルの性能を落とす小手先のテクニックでもありません。システムの能力を落とさずに、消費するトークンを減らすための実用的な方法です。
トークン使用量はアーキテクチャの問題
多くのチームは、トークンコストをモデルの料金の問題として扱います。しかし実際には、たいてい情報のルーティングの問題です。
一般的な本番環境のLLM呼び出しでは、トークンは次の四つに分類できます。
- 指示トークン:システムプロンプト、開発者プロンプト、書式ルール、安全性に関する制約。
- コンテキストトークン:チャット履歴、検索で取得したドキュメント、ツール出力、データベースのレコード。
- 推論・タスクトークン:実際のユーザーリクエストと、タスクの中間的な詳細情報。
- 出力トークン数:生成された応答、JSONオブジェクト、SQLクエリ、要約、ツール呼び出し。
トークン使用量が制御できなくなるとき、その原因は通常、コンテキストと出力にあります。モデルのコストが高いのは、ユーザーが難しい質問をしたからではありません。アプリケーションがあらゆる情報をプロンプトに詰め込み、そのうえモデルが長々と話すことを許したからです。
最初に行うとよいのは、リクエスト全体のサイズだけでなく、セクションごとのトークン使用量をログに記録することです。
{
"request_id": "ticket_81291",
"model": "sonnet-4.6",
"tokens": {
"system": 742,
"history": 3890,
"retrieval": 12640,
"tool_results": 2110,
"user": 184,
"output": 931
},
"latency_ms": 4820
}
この内訳が見えれば、最適化の方向は明らかになります。Claude Opus 4.8、Sonnet 4.6、GPT-5.5、Gemini 3、Fable 5、あるいはQwenやDeepSeekのようなオープンモデルのどれが抽象的に「最良」かを議論する必要はありません。まず、無関係な検索結果に12,000トークンも費やして送信するのをやめる必要があります。
ルートごとのトークン予算から始める
私は、全体で一律に設定するのではなく、プロダクトの処理ルートごとに予算を割り当てる方法を好みます。法的な契約書のレビュー、コーディングエージェント、FAQボットで、同じトークン量の想定を使うべきではありません。
サポートアシスタントの予算例:
| 利用経路 | 入力予算 | 出力予算 | モデルクラス | 備考 |
|---|---|---|---|---|
| チケットの分類 | 600 | 50 | 低コストで高速なモデル | ラベル、緊急度、言語 |
| 根拠の取得 | 1,500 | 0 | 埋め込み・検索 | 生成は不要 |
| 回答の下書き | 4,000 | 600 | 高性能な中位・上位モデル | 選択した根拠を使用 |
| スレッドの要約 | 1,000 | 250 | 低コストで高速なモデル | メモリの状態を更新 |
| エスカレーションの分析 | 6,000 | 900 | 高性能モデル | 複雑なケースのみ |
これらの数値があらゆる場合に正しいということではありません。意図的に控えめな初期制約として設定しています。有用なのは、すべての段階でトークンを使う理由を説明しなければならなくなることです。
よくある落とし穴:次の設定だけを行っても max_tokens 、出力だけが制限され、入力は気づかないうちに膨れ上がる可能性があります。出力制限は長い回答を防ぎますが、検索結果の詰め込みは防ぎません。
検索の後ではなく、検索の前にコンテキストを整理する
多くのシステムは、最新のユーザーメッセージを使ってドキュメントを検索しながら、最終的なプロンプトには会話履歴の全体を含めています。そのため、過去の脱線、以前の失敗したツール呼び出し、古くなった前提がコンテキストに残ります。
代わりに、コンパクトな作業状態を維持します。
{
"user_goal": "Get refund for duplicate annual subscription charge",
"known_facts": [
"User paid twice on 2025-01-08",
"Invoice IDs: inv_1042 and inv_1077",
"Account email: user@example.com"
],
"constraints": [
"Refund policy allows duplicate-charge refunds within 90 days",
"User prefers email follow-up"
],
"open_questions": [
"Whether one charge has already been reversed"
],
"last_updated_turn": 12
}
そうすると、最終的な呼び出しに含めるのは次の内容になります。
- コンパクトな状態。
- 最新のユーザーメッセージ。
- 検索で取得した、関連するポリシーの抜粋だけ。
- 口調の一貫性が重要な場合は、直近の一、二往復の会話も含めることがあります。
過去のメッセージをすべて無条件に含めないでください。チャット履歴はメモリではなく、会話の記録です。メモリとは、タスクを続けるために必要な状態を抽出してまとめたものです。
実際には、私は三つの層を使います。
- 直近のやり取り:タスクの繊細さに応じて、直近の1~4往復。
- 継続的に更新する要約:変わらない事実と決定事項。
- 外部検索:ドキュメント、データベースのレコード、ツール出力。
通常、これは会話記録をそのまま詰め込む方法よりも優れています。モデルに渡るノイズや、矛盾する指示が減るためです。
検索は飾りではなく、選別のために使う
検索拡張生成は、品質改善のために導入されながら、トークンの浪費源になることがよくあります。その失敗パターンは簡単に見分けられます。top-kが10または20に設定され、各チャンクが1,000トークンあり、最終的なプロンプトに関連性の曖昧なドキュメントが山積みになっている状態です。
より優れた検索は、通常、より低コストな検索でもあります。
実用的な検索パイプライン:
def build_context(query, user_state):
search_query = rewrite_for_search(query, user_state)
candidates = vector_search(
query=search_query,
top_k=30,
filters={"product": user_state["product"]}
)
reranked = rerank_cross_encoder(
query=search_query,
docs=candidates,
top_k=6
)
selected = []
token_budget = 1800
for doc in reranked:
snippet = extract_relevant_span(doc.text, query=search_query, max_tokens=300)
if token_count(selected) + token_count(snippet) <= token_budget:
selected.append({
"doc_id": doc.id,
"title": doc.title,
"text": snippet
})
return selected
重要なポイント:
- 含める予定の数よりも多くの候補を取得します。
- 生成の前に再ランキングします。
- チャンク全体を渡す代わりに、関連する部分を抽出します。
- 検索結果のトークン予算を厳守します。
- モデルが内部で根拠を引用または参照できるように、ドキュメントIDを保持します。
チャンクサイズは重要です。チャンクが小さすぎると、モデルは文脈を失います。大きすぎると、検索で一致するたびに無関係なテキストまで取り込まれます。製品ドキュメントやポリシーページでは、私は見出しを保持したまま、チャンクあたり約300~600トークンから始め、失敗例に基づいて調整することが多いです。
検索で取得したコンテキストを詰め込みすぎると、実際には何が起きるのでしょうか。モデルの精度が下がることがよくあります。近接していても無関係なポリシー同士を整合させようとしたり、古い注意事項を拾ったり、質問の別の解釈に対して答えたりします。トークンを減らすことで、注意をそらす情報が取り除かれ、品質が向上することがあります。
要約は、説明責任を伴う圧縮
要約によってコンテキストを大幅に削減できますが、粗雑な要約は危険です。要約から制約が抜け落ちると、エージェントは自信を持って誤った行動を取る可能性があります。
私は、文章形式よりも構造化された要約を好みます。
{
"task": "Debug failing OAuth callback in staging",
"environment": {
"service": "auth-api",
"branch": "release-2025-02-14",
"runtime": "node 20"
},
"confirmed": [
"Callback returns 302 to /login",
"Provider sends code and state",
"State validation passes"
],
"suspected": [
"Session cookie is not persisted after callback"
],
"rejected": [
"Provider credentials are invalid",
"Redirect URI mismatch"
],
"next_steps": [
"Inspect Set-Cookie attributes in staging response",
"Compare SameSite and Secure flags with production"
]
}
これは、次のような記述よりもトークン効率が高く、信頼性も高くなります。
ユーザーはOAuthのデバッグ中で、さまざまな方法を試している…
適切な要約処理では、次の情報を保持する必要があります。
- ユーザーの目的。
- 確実な事実。
- すでに下された決定。
- 制約と好み。
- 失敗した試み。
- 未解決の疑問。
- ID、ファイル名、タイムスタンプなどの正確な識別情報。
正確な値は、本当に無関係でない限り、要約で省略しないでください。「ユーザーに請求書の問題がある」という記述はトークンを節約できますが、役に立ちません。「重複請求: inv_1042 と inv_1077 、発生日:2025-01-08」のような圧縮が望ましい形です。
長時間稼働するエージェントでは、要約の各バージョンも保存しています。モデルが不適切な要約を作成した場合、いつ状態にずれが生じたかを調べられる必要があります。
スキーマで冗長な出力を抑える
トークンの無駄は、驚くほど多くが出力側で発生します。プロンプトに「簡潔に」と書いてあっても、モデルは前置き、注意事項、振り返り、追加支援の申し出を書きます。
機械が処理する出力には、スキーマを使ってください。安易に「JSON形式の応答」を求めるのではなく、構造を定義し、それ以外の出力は拒否してください。
分類呼び出しの例:
{
"type": "object",
"additionalProperties": false,
"required": ["category", "priority", "needs_human", "reason"],
"properties": {
"category": {
"type": "string",
"enum": ["billing", "bug", "account", "how_to", "other"]
},
"priority": {
"type": "string",
"enum": ["low", "normal", "high", "urgent"]
},
"needs_human": {
"type": "boolean"
},
"reason": {
"type": "string",
"maxLength": 160
}
}
}
ユーザー向けの応答には、厳格な上限を設けた形式を指定してください。
Write the answer in this structure:
- First sentence: direct answer, max 25 words.
- Then 2-4 bullets with concrete steps.
- Do not include a greeting.
- Do not include "let me know if you need anything else."
- Stop after the final bullet.
これは「短くしてください」よりも効果的です。モデルに具体的な構造を示せるためです。
よくある落とし穴:低く設定した max_tokens によって、有用な出力が説明の途中で切れてしまうことがあります。スキーマや制約付きの形式を使えば、完全性を保ちながら出力を短くできます。打ち切りは最後の手段であり、書式設定の方法ではありません。
停止シーケンスを慎重に使う
停止シーケンスは、生成内容に自然な区切りがある場合に役立ちます。たとえば、テンプレート化された文書のあるセクションをモデルが生成している場合、次のセクションのマーカーで停止できます。
{
"model": "gpt-5.5",
"messages": [
{
"role": "user",
"content": "Generate only the Risk Summary section for this review..."
}
],
"max_tokens": 500,
"stop": ["\n## Mitigations", "\n## Appendix"]
}
自由形式のアシスタント応答では、正当な内容を途中で切ってしまう可能性があるため、停止シーケンスの有用性は低くなります。また、APIやモデル系列によって動作が異なり、特にツール呼び出しや構造化出力が関わる場合は違いが顕著です。主要な圧縮手法ではなく、境界を守るための仕組みとして扱ってください。
適切な用途:
- 既知のテンプレートの特定のセクションを生成する。
- モデルが続けて例を書き始めるのを防ぐ。
- バッチ処理で区切り文字に達したら停止する。
- 従来の補完形式のプロンプトに制約を設ける。
リスクのある用途:
- 一般的な句読点で停止する。
- 通常の回答にも現れうる単語で停止する。
- 適切な出力スキーマの代わりに停止シーケンスを使う。
- Claude、GPT、Gemini、オープンモデルのゲートウェイで同じように動作すると想定する。
低コストのモデルにタスクを分担させる
すべての処理に最も高性能なモデルが必要なわけではありません。本番システムでは、サブタスクを小型モデルや低コストのモデルに振り分けることがよくあります。
| サブタスク | 一般的な要件 | 適した選択肢 |
|---|---|---|
| 言語検出 | 単純な分類 | 小型・低コストのモデル |
| チケットのカテゴリ分類 | 構造化されたラベル出力 | 小型または中規模のモデル |
| クエリの書き換え | 短いテキストの変換 | 小型または中規模のモデル |
| 文書の再ランキング | 専用のリランカーまたは小型モデル | 可能な場合は非生成型の手法 |
| 最終回答 | 推論と適切な語調 | より高性能なモデル |
| ポリシーの例外 | 微妙な違いとリスクの判断 | 最も高性能なモデル |
複数のモデルへのアクセスが運用上役立つのは、こうした場面です。エージェントによる文章作成の大半にはSonnet 4.6、難しい推論にはGPT-5.5、特定のマルチモーダルや長文コンテキストのワークフローにはGemini 3、1Mのコンテキストウィンドウが本当に必要な場合にはFable 5、管理された内部タスクにはLlama、Qwen、DeepSeek、MiniMax、Kimiなどのオープンモデルを使いたい場合があります。エンジニアリング上の要点は、ブランドへの忠誠ではなく、振り分けにあります。
制約として、オーケストレーションのオーバーヘッドは実際に存在します。モデル呼び出しが増えるたびに、レイテンシ、障害の発生パターン、ログの複雑さ、評価対象の範囲が増えます。低コストの分類器が300トークンを節約しても、600ミリ秒の遅延と新たな再試行経路が加わるなら、割に合わないかもしれません。
私が好んで使うパターン:
if route == "simple_faq":
model = "haiku-4.5"
elif route == "standard_support":
model = "sonnet-4.6"
elif route == "legal_or_financial_exception":
model = "opus-4.8"
else:
model = "sonnet-4.6"
具体的な名前は変わっていくでしょう。それでも方針は変えるべきではありません。タスクの品質基準を確実に満たす、最も低コストのモデルを使ってください。
入出力トークンを正確に測定する
チームがリクエストあたりの平均コストだけを見ていると、トークン最適化はうまくいきません。経路と処理段階ごとに分けた分布が必要です。
少なくとも次を追跡しましょう:
- プロンプトのセクション別の入力トークン数。
- 出力トークン数。
- 取得した文書の数と検索取得トークン数。
- モデル名とバージョン。
- レイテンシのパーセンタイル。
- プロンプトまたはコンテキストのキャッシュを使用している場合、そのキャッシュヒット率。
- 再試行回数。
- ユーザーが体感する品質の指標。
- 人間へのエスカレーション率または修正率。
有用なダッシュボードには、p50、p90、p99を表示します。トークンに関する不具合は、分布の裾に潜んでいることがよくあります。
ログ行の例:
{
"route": "support.draft_answer",
"model": "sonnet-4.6",
"input_tokens": 3820,
"output_tokens": 514,
"retrieved_docs": 4,
"retrieval_tokens": 1320,
"latency_ms": 3100,
"cache_hit": false,
"quality": {
"user_thumb": "up",
"human_edited": false
}
}
「トークンを40%削減した」という主張には注意が必要です。再試行は増えていませんか。エスカレーションは増えていませんか。回答が簡潔すぎるために、ユーザーからの追加質問が増えていませんか。低コストのモデルが無効なJSONをより多く生成し、修復のための呼び出しが必要になっていませんか。
実態を表す指標は、呼び出しあたりのトークン数ではありません。正常に完了したタスクあたりのトークン数です。
長文コンテキストは手を抜く理由にならない
大きなコンテキストウィンドウは、確かに有用です。Fable 5の1M規模のコンテキスト、Geminiの長文コンテキストにおける強み、最先端モデル各種の長文コンテキストモードにより、数年前には扱いにくかったワークフローが可能になります。リポジトリ全体のコード分析、大量の文書レビュー、数時間にわたる文字起こし、情報密度の高い調査資料などです。
ただし、長文コンテキストは能力の一つであり、標準のアーキテクチャではありません。
長文コンテキストの呼び出しが適切なのは、次のような場合です。
- 回答が、入力内の離れた多数の箇所に依存している。
- 検索取得では、文書間の関係を見落としてしまう。
- タスクが探索的で、何が重要かまだ分かっていない。
- 詳細を失うコストが、より多くの情報を読むコストを上回る。
次のような場合は、通常、無駄になります。
- ユーザーが限定的な事実について質問している。
- 少数の出典の抜粋で十分である。
- コンテキストに重複した情報や古い情報が含まれている。
- モデルをデータベースのフィルターとして使っている。
よくあるパターンは、オフライン分析に長文コンテキストを使い、オンラインでの応答処理向けにコンパクトな成果物を作ることです。たとえば、ポリシー文書群全体を一度分析し、構造化されたポリシーマップ、埋め込み、要約、テストケースを生成します。実行時のアシスタントが、チケットごとに文書群全体を読み直すべきではありません。
実践的な最適化ワークフロー
トークンを大量に消費するLLMシステムを引き継いだとき、私は通常、次の順序で取り組みます。
-
まず計測を導入する
セクションごとのトークン集計を追加します。状況が見えないまま最適化しないでください。 -
検索取得コンテキストに上限を設ける
検索取得に使うトークンの厳格な上限を設定します。再ランキングを行い、必要な範囲を抽出します。 -
会話履歴の詰め込みを状態管理に置き換える
直近のやり取りと構造化されたメモリを保持します。 -
出力に制約を設ける
機械向けの出力にはスキーマを、ユーザー向けの出力には明示的な形式を使います。 -
サブタスクを振り分ける
信頼性を確保できる場合は、分類、書き換え、要約をより低コストのモデルに移します。 -
タスク単位で品質を評価する
呼び出しごとのコストだけでなく、正常に解決できた結果を比較します。 -
分布の裾に注目する
p95とp99でのトークン数の急増を調査します。検索処理の不具合や履歴の際限ない増大が見つかることがよくあります。
私がサービスで使ってきたシンプルなガードレールを紹介します。
TOKEN_LIMITS = {
"support.classify": {"input": 800, "output": 80},
"support.retrieve": {"input": 500, "output": 0},
"support.draft": {"input": 4500, "output": 700},
"support.summarize": {"input": 1800, "output": 300},
}
def assert_token_budget(route, prompt_sections, max_output_tokens):
input_tokens = {
name: count_tokens(text)
for name, text in prompt_sections.items()
}
total_input = sum(input_tokens.values())
limits = TOKEN_LIMITS[route]
if total_input > limits["input"]:
raise TokenBudgetError(route, total_input, input_tokens)
if max_output_tokens > limits["output"]:
raise TokenBudgetError(route, max_output_tokens, {"output": max_output_tokens})
不適切な検索クエリが無関係なPDFに予算を費やしていたと一週間後に気づくより、早期に処理を停止するほうがよいでしょう。
実践上の要点
- トークン削減は可視化から始めるべきです。プロンプトのセクション、ルート、モデルごとに入力と出力のトークン数を記録します。
- 検索対象を絞り込めば、大量の情報を詰め込むよりも検索のほうが効果的です。再ランキングを行い、必要な箇所を抽出し、コンテキストの上限を厳守します。
- 要約は曖昧な文章ではなく、構造化された状態情報にするべきです。正確な事実、ID、制約、失敗した試行を保持します。
- 出力スキーマは、単純に引き下げるよりも安全に冗長な出力を抑えます
max_tokens. - 停止シーケンスは出力の境界を守るのに役立ちますが、構造化出力の代わりにはなりません。
- 単純なサブタスクをより低コストのモデルに振り分けるのは、追加のレイテンシや障害パターンを考慮してもメリットがある場合に限ります。
- 削減効果は、個々の呼び出しあたりのトークン数ではなく、成功したタスクあたりのトークン数で判断します。
- 長いコンテキストを扱えるモデルは強力ですが、情報設計が不要になるわけではありません。
Claude Opus 5.5、Sonnet 5、Haiku 4.5、Fable 5.1に加え、GPT-6のモデルを、単一のAPIキーで利用できます。従量課金制で、サブスクリプションは不要です。
APIキーを取得 →