0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

context_length_exceeded の前に token budget をログに出す

0
Posted at

はじめに

RAG の context を少し足しただけのつもりが、急にリクエストごと落ちることがあります。私も最初は「検索結果が多すぎたのかな」くらいで見ていたのですが、ログに残っていたのはステータスコードと request_id だけでした。

これだと、どのユーザー入力が長いのか、RAG の chunk が多いのか、会話履歴が膨らんだのか、max_tokensmax_completion_tokens を取りすぎたのかが分かりません。

この記事では、context_length_exceededprompt is too long 系のエラーを見たときに、prompt を作る直前で token budget をログに出す形に直します。ポイントは、prompt 本文を保存することではなく、削る判断に必要な数字だけを残すことです。

再現環境

今回の手元確認は次の条件です。

  • Python 3.12
  • OpenAI 互換の Chat Completions endpoint
  • tiktokeno200k_base で入力 token を概算
  • 実測エラーの model は claude-haiku-4-5
  • max_tokens は 16

タイトルには context_length_exceeded と書いていますが、今回そのものの OpenAI エラー本文は取れていません。GPT 系モデルへの live request は router 側で channel unavailable になったため、ここでは同じ予算超過の症状として実際に取れた prompt is too long を使います。OpenAI の公式ドキュメント上も、context window は input token と output token、モデルによっては reasoning token の合計で考える必要があります。

エラー全文

実際に 300,000 個の hello を user message に入れて投げたときのレスポンスです。request body や API key はログに残していません。

{
  "error": {
    "message": "prompt is too long: 300028 tokens > 200000 maximum",
    "type": "invalid_request_error",
    "param": "",
    "code": null
  }
}

ここで見たいのは、300028 tokens > 200000 maximum の部分です。アプリ側で事前に近い数字を出せていれば、少なくとも「RAG chunk を何件削ればいいか」は判断できます。

何が起きたか

context window は、入力だけの箱ではありません。OpenAI のドキュメントでは、入力 token、出力 token、reasoning model では reasoning token も同じ window の中で考えると説明されています。

たとえば公式 docs 上の gpt-4o は context window が 128,000 tokens、max output が 16,384 tokens です。gpt-5.4-mini は context window が 400,000 tokens で、max input が 272,000 tokens、max output が 128,000 tokens とされています。

つまり、アプリ側で max_output_tokens を大きく予約すると、そのぶん入力に使える予算は減ります。RAG で「検索結果を 20 chunk 入れたら落ちた」と見えていても、実際には会話履歴、system prompt、few-shot example、tool schema、予約した output token が足し算されて超えていることがあります。

再現手順

まず token 推定用のライブラリを入れます。

python -m venv .venv
source .venv/bin/activate
python -m pip install tiktoken

最小の再現はこんな感じです。--call を付けない限り API には投げず、token budget log だけを出すようにしています。

import json
import tiktoken

MODEL_CONTEXT_WINDOWS = {
    "claude-haiku-4-5": 200_000,
    "gpt-4o": 128_000,
    "gpt-5.4-mini": 400_000,
}


def estimate_messages_tokens(messages):
    encoding = tiktoken.get_encoding("o200k_base")
    return sum(len(encoding.encode(m["content"])) for m in messages)


model = "claude-haiku-4-5"
max_output_tokens = 16
messages = [
    {"role": "system", "content": "Answer briefly."},
    {"role": "user", "content": "hello " * 300_000},
]

input_estimate = estimate_messages_tokens(messages)
reserved_total = input_estimate + max_output_tokens

print(json.dumps({
    "event": "llm.token_budget",
    "model": model,
    "input_estimate_tokens": input_estimate,
    "max_output_tokens": max_output_tokens,
    "reserved_total_tokens": reserved_total,
    "context_window_tokens": MODEL_CONTEXT_WINDOWS[model],
    "over_budget_tokens": max(0, reserved_total - MODEL_CONTEXT_WINDOWS[model]),
    "truncation_policy": "reject_over_budget",
}, ensure_ascii=False, indent=2))

私の手元では、このログになりました。

{
  "event": "llm.token_budget",
  "model": "claude-haiku-4-5",
  "input_estimate_tokens": 300004,
  "max_output_tokens": 16,
  "reserved_total_tokens": 300020,
  "context_window_tokens": 200000,
  "over_budget_tokens": 100020,
  "truncation_policy": "reject_over_budget"
}

API が返した token 数は 300028、手元推定は 300004 でした。24 token ずれています。ここを完全一致させようとすると provider ごとの差や message wrapper の差でつらくなるので、私は「estimate」として扱い、余白を持たせる方に寄せています。

先に出したかったログ

本番で残したいのは、だいたいこのくらいです。

{
  "event": "llm.token_budget",
  "request_id": "app_01hxx...",
  "model": "gpt-4o",
  "input_estimate_tokens": 118900,
  "max_output_tokens": 12000,
  "reserved_total_tokens": 130900,
  "context_window_tokens": 128000,
  "over_budget_tokens": 2900,
  "truncation_policy": "drop_low_score_rag_chunks",
  "rag_chunk_count": 18,
  "conversation_turns": 12
}

逆に、私はここに prompt 本文、RAG の原文、ユーザーの入力全文、API key、Authorization header は入れません。エラー解決のためのログで、別の事故を作るのは普通に嫌だからです。

修正: prompt 作成直前に予算を見てから削る

RAG の場合、いきなり全文を詰めて API に投げるのではなく、prompt を組む直前に「入れる候補」をスコア順に積みます。

import logging
import tiktoken

log = logging.getLogger(__name__)


def count_tokens(text):
    encoding = tiktoken.get_encoding("o200k_base")
    return len(encoding.encode(text))


def estimate_messages_tokens(messages):
    return sum(count_tokens(m["content"]) for m in messages)


def build_messages(system_prompt, question, chunk_texts):
    context = "\n\n".join(chunk_texts)
    return [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": f"質問:\n{question}\n\n参考資料:\n{context}"},
    ]


def fit_rag_context(system_prompt, question, chunks, model, context_window, max_output_tokens):
    limit = int(context_window * 0.95)
    kept = []

    for chunk in sorted(chunks, key=lambda x: x["score"], reverse=True):
        trial = kept + [chunk["text"]]
        messages = build_messages(system_prompt, question, trial)
        reserved_total = estimate_messages_tokens(messages) + max_output_tokens
        if reserved_total <= limit:
            kept = trial

    final_messages = build_messages(system_prompt, question, kept)
    input_estimate = estimate_messages_tokens(final_messages)
    reserved_total = input_estimate + max_output_tokens

    log.info("llm.token_budget", extra={
        "model": model,
        "input_estimate_tokens": input_estimate,
        "max_output_tokens": max_output_tokens,
        "reserved_total_tokens": reserved_total,
        "context_window_tokens": context_window,
        "rag_chunk_count": len(kept),
        "dropped_chunk_count": len(chunks) - len(kept),
        "truncation_policy": "drop_low_score_rag_chunks",
    })

    return final_messages

削る順番も先に決めておくと、障害時に議論しやすいです。私なら次の順で削ります。

  1. 生の全文資料を削り、検索済み chunk だけにする
  2. 類似度の低い RAG chunk を削る
  3. 古い会話履歴を summary に寄せる
  4. few-shot example を減らす
  5. 最後に max_output_tokens を下げる

max_output_tokens を先に下げると、回答が途中で切れて別の問い合わせになります。まず入力側の無駄を削る方が、原因の説明もしやすいと思います。

Responses API の truncation について

Responses API には truncation の設定があります。公式リファレンスでは、auto は context window に収まるように会話の先頭側 item を落とし、disabled は input が window を超える場合に 400 error にする、という説明です。

個人的には、RAG では auto に全部任せるより、アプリ側で削った事実をログに出したいです。どの chunk が消えたか分からないまま回答だけ返ると、後で「なぜこの根拠が使われなかったのか」を追えなくなります。

Flatkey AI で確認したかったこと

Flatkey AI は OpenAI 互換の base URL で複数モデルを呼べて、key、usage、routing を同じ dashboard で見られる前提のサービスです。こういう router を挟む場合でも、アプリ側の token budget log は残した方がいいです。

dashboard 側の usage は請求や routing の確認に向いています。一方で、アプリ側の input_estimate_tokensmax_output_tokensrag_chunk_count は「どの機能の prompt 設計が悪かったか」を見るためのログです。役割が違うので、どちらか片方だけだと少し足りません。

まとめ

context_length_exceeded 系のエラーで最初に見るべきなのは、model 名、input token estimate、reserved output token、context window、truncation policy です。

私の今回の反省は、request を送った後の error log だけでは遅かったことです。prompt を作る直前に budget を出しておけば、RAG chunk を削るのか、会話履歴を summary にするのか、出力上限を下げるのかをその場で判断できました。

参考にしたもの:

  • OpenAI docs: Managing context for text generation
  • OpenAI model docs: gpt-4o, gpt-5.4-mini
  • OpenAI API reference: Chat Completions max_completion_tokens, Responses truncation
  • Flatkey AI product knowledge: one key, OpenAI-compatible endpoint, usage and routing dashboard

間違いあったらコメントください。よろしくお願いします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?