LLM を使ったアプリを実装していると、あるタイミングでこんな現象にぶつかることがあります。
-
max_tokensを小さめにしたら、返答が途中で切れる - 会話履歴を積み始めたら、急に回答が不安定になる
- RAG で関連文書をたくさん渡したのに、むしろ回答品質が落ちる
- 「トークン量」を意識しているつもりなのに、何が原因なのか切り分けにくい
私自身、最初は
「トークン量というのは、コンテキストを読む量と返答を生成する量を全部まとめたものなのだろう」
くらいの理解で実装していました。
ただ、実際に API を触って試してみると、それだけでは説明がつかない挙動がかなりあります。
特に、max_tokens=300 のように設定したときに、なぜ不自然なところで返答が終わるのかは、実装してみると気になりやすいポイントでした。
そこで、挙動を確認しながら整理してみたところ、LLM アプリでは次の 4 つを分けて考える必要があるとわかりました。
- 入力トークン
- 出力トークン
- コンテキストウィンドウ
- 会話履歴や RAG を含めたアプリ側の予算設計
この記事では、その整理を
API 実装者向けに、会話履歴・RAG 込みでどう設計すると安定しやすいか
という観点でまとめます。
この記事でお伝えしたいこと
先に結論を書くと、返答が途中で切れる問題を理解するには、次のように分けて考えるのが大切です。
-
入力トークン
- システムプロンプト
- ユーザー入力
- 会話履歴
- RAG で取得した文書
- ツール実行結果 など
-
出力トークン
- モデルが生成する返答そのもの
-
コンテキストウィンドウ
- 1 回の推論で扱える総量の上限
-
アプリ側の設計
- 履歴をどこまで保持するか
- RAG を何件入れるか
- 出力上限をどれくらい確保するか
特に大事なのは、max_tokens=300 のような設定は、多くの場合
「今回の出力は最大 300 トークンまで」
という意味であることです。
つまり、返答が途中で切れるのは、
総量が 300 なのではなく、出力の上限が 300 に達したから
というケースが非常に多いです。
ただし一方で、会話履歴や RAG の文書も同じコンテキスト枠を消費するので、最終的には
入力と出力の両方を含めた予算設計
が必要になります。
なぜこの記事を書こうと思ったか
きっかけは、LLM を組み込んだアプリで返答がたまに不自然に途切れたことでした。
最初は単純に、
- モデルの性能の問題かな
- API の気まぐれかな
- 指示の書き方が悪いのかな
と考えていたのですが、実際にはそうではなく、かなりの部分がトークン設計の問題でした。
たとえば、
-
max_tokensを小さくすると返答が明らかに途中で終わる - 会話履歴を増やすと、同じ
max_tokensでも応答が短く感じる - RAG で大量の文書を入れると、回答が散漫になる
といったことが起きます。
このあたりを試行錯誤しながら整理していくと、
-
max_tokensは何を制限しているのか - コンテキスト上限とは何なのか
- 入力と出力はどういう関係なのか
- 実装でどのように予算を組むべきか
が少しずつ見えてきました。
同じようなところで引っかかる方は多いと思うので、記事としてまとめておきます。
1. まず「トークン量」という言葉を分解して考える
「トークン量」という言葉は便利なのですが、実装の観点では少し曖昧です。
この言葉のまま考えると混乱しやすいので、まずは 3 つに分けて整理します。
1-1. 入力トークン
入力トークンは、モデルに読ませる側の情報です。
たとえば、以下はすべて入力に含まれます。
- system prompt
- developer instruction
- user prompt
- 過去の会話履歴
- RAG で取得した文書チャンク
- ツール実行結果
- 出力フォーマットの制約
- JSON Schema など
図にすると、次のようなイメージです。
[入力トークン]
= システム指示
+ ユーザー質問
+ 会話履歴
+ RAG文書
+ ツール結果
+ 出力形式の指定
1-2. 出力トークン
出力トークンは、モデルが今回生成する返答です。
たとえば次のようなものが該当します。
- 通常の自然文
- Markdown
- JSON
- コード
- SQL
- 要約文
多くの API で max_tokens や max_output_tokens といったパラメータが指しているのは、この出力側の上限です。
1-3. コンテキストウィンドウ
コンテキストウィンドウは、1 回の推論でモデルが扱える総量の上限です。
アプリ設計の観点では、まず次のように理解すると整理しやすいです。
[コンテキスト総枠]
= 入力トークン + 出力トークン
内部実装にはモデルごとの差がありますが、アプリ実装ではこの捉え方で十分役に立ちます。
2. max_tokens=300 で返答が途中で切れる理由
ここが最も誤解しやすいところだと思います。
私も最初は、max_tokens=300 を見て
入力と出力の合計が 300 なのかな
となんとなく考えていました。
ですが、実際には多くの API 実装でこれは
今回の出力を最大 300 トークンまでに制限する
という意味です。
つまり、たとえば次のような状況を考えます。
- system prompt: 200
- user prompt: 100
- 会話履歴: 1200
- RAG 文書: 2000
max_tokens=300
この場合、入力はすでにかなりありますが、返答として生成できるのは最大 300 トークンです。
そのため、本来モデルがもっと続けたい内容を持っていても、300 トークンに達した時点で止まります。
イメージは次の通りです。
コンテキスト総枠: たとえば 8000
入力:
system prompt 200
user prompt 100
会話履歴 1200
RAG文書 2000
-------------------------
入力合計 3500
出力上限:
max_tokens 300
総使用見込み:
3500 + 300 = 3800
このケースで返答が途中で切れる原因は、コンテキスト上限の不足ではなく、出力上限が 300 だからです。
3. ただし、入力と出力は完全に無関係ではない
ここで、
では入力量は関係ないのか
というと、もちろんそうではありません。
入力トークンと出力トークンは、最終的には同じコンテキスト枠を使うため、実務上は強く関係します。
たとえばモデルのコンテキスト上限が 8000 で、
- 入力が 7600
- 出力上限を 1000
にすると、単純計算では合計 8600 になってしまいます。
この場合は、
- エラーになる
- 古い履歴が切り捨てられる
- RAG の一部を入れられない
- アプリ側で圧縮が必要になる
といった対応が必要です。
なので、最終的には次の 2 つを両方考えなければなりません。
- 出力上限としての
max_tokens - 入力 + 出力の総量としてのコンテキスト設計
この 2 つが混ざって「トークン量」という言葉で語られがちなので、混乱しやすいのだと思います。
4. 実装者が設計すべきもの
LLM アプリを安定して動かすには、max_tokens を調整するだけでは足りません。
大事なのは、どの情報にどれだけトークン予算を配るかを決めることです。
私はこのあたりを試していて、
LLM アプリの設計は、ある意味で「限られたトークン予算の配分設計」だな
と感じるようになりました。
考えるべきものは主に次の 4 つです。
- 出力予算
- 会話履歴の保持方針
- RAG 文書の投入量
- 安全マージン
5. 出力予算を先に決める
実装では、まず今回どれくらいの長さを返させたいかを先に決めるのがおすすめです。
たとえば、ざっくり次のような目安です。
- 短い FAQ 回答: 150〜300
- 普通の説明文: 400〜800
- 詳細な解説: 800〜2000
- JSON 出力: 想定より多め
- コード生成: かなり多め
ここで大切なのは、入力を詰め込んだあとに余った分を出力に回すのではなく、先に
今回の出力予算 = 800 トークン
のように決めてしまうことです。
そのうえで、入力に使える予算を逆算します。
入力予算
= コンテキスト上限 - 出力予算 - 安全マージン
この考え方にしてから、返答が急に短くなる問題や、履歴が増えたときの不安定さをかなり整理しやすくなりました。
6. 会話履歴は「全部入れる」ではなく「残すべきものを残す」
チャットアプリで特に重要なのがここです。
最初はつい、毎回こうしたくなります。
過去メッセージ全部
+ 今回のユーザー入力
を毎回そのまま送る
実際、最初のうちはこれでも動きます。
ただ、会話が伸びるほど確実に苦しくなります。
なぜ厳しいのか
会話履歴は、ユーザーが何もしなくても毎ターン増えていく入力だからです。
たとえば、こんな状態になりがちです。
会話履歴 6000
RAG 2000
質問 100
出力予算 800
----------------
合計 8900
コンテキスト上限を超える、あるいはギリギリになって不安定になります。
どう設計するか
実務では、履歴を次のように分けると扱いやすいです。
-
常に残すべきもの
- system prompt
- アプリのルール
- ユーザー設定
- セッション前提
-
直近の会話
- 最新の数往復
-
古い履歴の要約
- 決定事項や重要な前提だけ残す
図にすると、こうです。
[会話履歴の設計]
常設:
- system prompt
- アプリ方針
- ユーザー設定
短期記憶:
- 直近 3〜10 往復
長期記憶の圧縮:
- 過去会話の要約
実装としては、たとえば以下のような構成にします。
conversation_context = [
system_prompt,
app_policy,
user_profile_summary,
summarized_old_messages,
*recent_messages[-6:],
current_user_message,
]
重要なのは、履歴フル投入を前提にしないことです。
7. RAG は「多ければ良い」わけではない
これも実際に試してみるまで勘違いしやすいポイントでした。
RAG を入れ始めると、
関連文書はたくさん入れたほうが、より正確になるのでは
と考えたくなります。
ただ、実際には文書を増やしすぎると逆効果になることが少なくありません。
なぜ逆効果になるのか
RAG 文書が多いと、
- 入力トークンを大量に消費する
- 重要な根拠が埋もれる
- 関係の薄い情報がノイズになる
- 出力に回せる余白が減る
という問題が起きます。
よくない入れ方
検索上位 20 件をそのまま全部入れる
比較的安定しやすい入れ方
検索
↓
再ランキング
↓
上位 3〜5 件だけ投入
↓
必要なら短く整形して渡す
図にすると次のような流れです。
ユーザー質問
↓
検索
↓
候補 20件
↓
再ランキング
↓
上位 3〜5件
↓
重複除去・圧縮
↓
プロンプト投入
試していて感じたのは、RAG は
多く入れることより、必要な情報を短く入れることのほうが重要
ということでした。
8. 全体設計の考え方
ここまでをまとめると、1 回のリクエストは次のように組み立てると考えやすいです。
┌──────────────────────────────┐
│ 1. 固定プロンプト │
│ - system 指示 │
│ - 出力ルール │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ 2. セッション文脈 │
│ - ユーザー設定 │
│ - 会話要約 │
│ - 直近履歴 │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ 3. 外部知識 │
│ - RAG検索結果 │
│ - ツール実行結果 │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ 4. 今回の質問 │
│ - ユーザーの最新入力 │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ 5. 予算計算 │
│ - 入力トークン見積もり │
│ - 出力トークン確保 │
│ - 上限超過なら圧縮 │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ 6. LLM 呼び出し │
│ - max_output_tokens 設定 │
│ - stop 条件 │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ 7. 後処理 │
│ - finish_reason確認 │
│ - 履歴保存 │
│ - 要約更新 │
└──────────────────────────────┘
9. 実装サンプルコード
ここからは、実装イメージが湧きやすいように、Python で簡易的なサンプルを書いてみます。
なお、以下は考え方を説明するためのサンプルです。
実際の SDK や API 仕様に合わせて適宜調整してください。
9-1. 入力予算を計算する関数
まずは、コンテキスト上限・出力予算・安全マージンから、入力に使える予算を計算します。
from dataclasses import dataclass
@dataclass
class TokenBudget:
model_context_limit: int
reserved_output_tokens: int
safety_margin: int
@property
def available_input_tokens(self) -> int:
return (
self.model_context_limit
- self.reserved_output_tokens
- self.safety_margin
)
使用例です。
budget = TokenBudget(
model_context_limit=128000,
reserved_output_tokens=1200,
safety_margin=1000,
)
print(budget.available_input_tokens)
# 125800
9-2. トークン見積もり付きで入力を詰める
次に、優先度順に入力を詰めていくサンプルです。
ここでは簡単のため、estimate_tokens() はダミー実装にしています。
from dataclasses import dataclass
from typing import List
def estimate_tokens(text: str) -> int:
"""
簡易見積もり用のダミー関数です。
実際には利用中の tokenizer に置き換えてください。
"""
return max(1, len(text) // 2)
@dataclass
class PromptPart:
name: str
text: str
priority: int
def build_prompt_parts(
available_input_tokens: int,
parts: List[PromptPart],
) -> List[PromptPart]:
selected = []
used_tokens = 0
for part in sorted(parts, key=lambda x: x.priority, reverse=True):
token_count = estimate_tokens(part.text)
if used_tokens + token_count <= available_input_tokens:
selected.append(part)
used_tokens += token_count
return selected
使用例です。
parts = [
PromptPart("system", "あなたは丁寧に説明するアシスタントです。", 100),
PromptPart("format", "必要なら箇条書きを使って整理してください。", 95),
PromptPart("user", "トークン量について詳しく教えてください。", 95),
PromptPart("session_summary", "これまでの会話要約...", 80),
PromptPart("recent_history", "直近の会話履歴...", 75),
PromptPart("rag_top1", "関連ドキュメント1...", 70),
PromptPart("rag_top2", "関連ドキュメント2...", 65),
PromptPart("extra_context", "補助的な文脈...", 30),
]
budget = TokenBudget(
model_context_limit=32000,
reserved_output_tokens=1000,
safety_margin=500,
)
selected_parts = build_prompt_parts(
available_input_tokens=budget.available_input_tokens,
parts=parts,
)
for part in selected_parts:
print(part.name)
このようにしておくと、どの情報を残し、どの情報を落とすかが明確になります。
9-3. 会話履歴を圧縮する例
チャットアプリでは、履歴をそのまま増やすのではなく、古い部分を要約して持つ構成が扱いやすいです。
from typing import Dict
def compress_conversation_history(messages: List[Dict[str, str]]) -> Dict[str, str]:
"""
古い履歴を要約に置き換えるイメージの簡易関数です。
実際には別の LLM 呼び出しなどで要約してもよいです。
"""
summary_points = []
for msg in messages:
role = msg["role"]
content = msg["content"]
if role == "user":
summary_points.append(f"ユーザー要望: {content[:60]}")
elif role == "assistant":
summary_points.append(f"応答概要: {content[:60]}")
summary = "\n".join(summary_points[:10])
return {
"role": "system",
"content": f"これまでの会話の要約:\n{summary}"
}
実際には、次のような方針にすると運用しやすいです。
- 直近 6 メッセージ程度は生で保持
- それ以前は要約に畳む
- 要約には決定事項・制約条件・ユーザー意図を残す
9-4. RAG 文書をそのまま入れず整形する例
RAG の検索結果も、そのまま渡すのではなく整形してから入れるほうが、トークン効率と可読性の面で扱いやすいことがあります。
from dataclasses import dataclass
@dataclass
class RetrievedChunk:
title: str
content: str
score: float
source_id: str
def format_rag_chunks(chunks: List[RetrievedChunk], top_k: int = 3) -> str:
sorted_chunks = sorted(chunks, key=lambda x: x.score, reverse=True)[:top_k]
formatted_blocks = []
for i, chunk in enumerate(sorted_chunks, start=1):
formatted_blocks.append(
f"[文書{i}]\n"
f"タイトル: {chunk.title}\n"
f"出典: {chunk.source_id}\n"
f"内容:\n{chunk.content[:500]}\n"
)
return "\n".join(formatted_blocks)
このようにしておくと、検索上位を無差別に大量投入するよりも扱いやすくなります。
9-5. 全体を組み立てて API を呼ぶイメージ
最後に、全体を通したシンプルな例です。
from typing import List, Dict
def create_messages(
system_prompt: str,
session_summary: str,
recent_messages: List[Dict[str, str]],
rag_context: str,
user_message: str,
) -> List[Dict[str, str]]:
messages = []
messages.append({"role": "system", "content": system_prompt})
if session_summary:
messages.append({
"role": "system",
"content": f"会話要約:\n{session_summary}"
})
if rag_context:
messages.append({
"role": "system",
"content": f"参考情報:\n{rag_context}"
})
messages.extend(recent_messages)
messages.append({"role": "user", "content": user_message})
return messages
実際の呼び出し部分は、利用する API に応じて変わりますが、考え方としては次のようになります。
def call_llm_api(client, messages: List[Dict[str, str]], max_output_tokens: int):
response = client.responses.create(
model="your-model-name",
input=messages,
max_output_tokens=max_output_tokens,
)
return response
ポイントは、入力を無制限に積むのではなく、予算を見ながら構成することです。
10. 実務で意識しておくと良かったこと
試行錯誤していて、個人的に特に重要だと感じた点をまとめます。
10-1. まず出力予算を決める
最初に「どれくらい話させたいか」を決めると、全体設計がかなり安定します。
10-2. 履歴フル投入は長期的に厳しい
小さい検証では動いても、会話が伸びると破綻しやすいです。
10-3. RAG は少数精鋭のほうが扱いやすい
たくさん入れるより、必要な情報を絞って入れるほうが良い結果になりやすいです。
10-4. JSON とコードは思ったよりトークンを使う
自然文の感覚で max_tokens を決めると不足しやすいです。
10-5. finish reason を記録する
返答が切れたときに、
- 出力上限で切れたのか
- stop 条件で止まったのか
- 別の理由なのか
を見られるようにしておくと切り分けしやすいです。
11. まとめ
LLM アプリを実装していて返答が途中で切れると、最初はモデルの問題に見えることがあります。
私も最初はそう感じていました。
ただ、試行錯誤しながら整理してみると、多くの場合は
-
max_tokensの意味の誤解 - 会話履歴の増加
- RAG の入れすぎ
- 出力予算の未設計
といった、トークン予算の扱い方に原因がありました。
整理すると、次のようになります。
-
max_tokensは多くの場合、出力上限 - 一方で、モデルには 入力 + 出力 の総枠がある
- 会話履歴はそのまま伸ばさず、要約・圧縮する
- RAG は多く入れるより、絞って整形して入れる
- 実装では、出力予算を先に決めてから入力を設計する
つまり、LLM アプリの設計は
限られたトークン予算をどう配分するか
を考えることでもあります。
この点を分けて考えるようにしてから、返答が途中で切れる問題や、会話が長くなったときの品質低下の見え方がかなり変わりました。
同じようなところで引っかかっている方の参考になればうれしいです。
おわりに
最初のうちは、
- 履歴も全部入れたい
- RAG もたくさん入れたい
- 返答も長くしたい
と考えがちです。
ただ実際には、それらはすべて同じ枠を奪い合っています。
そのため、安定したアプリにするには、何を残して何を削るかを決める必要があります。
この記事が、LLM アプリ実装時の「トークン量」の整理に少しでも役立てば幸いです。