LLMの入力トークンを実際に削って分かった、効くパターンと失敗パターン
LLMへの入力トークンを削る前処理の実践パターン
コスト削減の話をすると「プロンプトを短くしましょう」で終わる記事が多い。当たり前だ。でも実務では「何をどこまで削っていいのか」の判断が難しい。削りすぎてモデルの精度が落ちたり、逆に削り方が甘くてコストが全然変わらなかったりする。
この記事では、自分が実際にやってみて効いたパターンと、やってみて失敗したパターンを整理する。
ドキュメント圧縮:何を消して何を残すか
RAGや長文読み取りのユースケースでは、取得したドキュメントをそのままコンテキストに突っ込みがちだ。HTMLなら余計なタグ、PDFから抽出したテキストなら改ページ記号やヘッダー・フッターの繰り返し、JSONなら使わないフィールドが山ほどある。
自分がまずやったのは「構造の除去」と「重複の除去」を分けて考えることだった。
構造の除去は比較的安全で、精度への影響が少ない。HTMLのタグをstripping、マークダウンの装飾を外す、連続する空白行を圧縮する、といった処理はほぼ無損失でトークンを削れる。
import re
def strip_html_and_normalize(text: str) -> str:
# タグ除去
text = re.sub(r"<[^>]+>", " ", text)
# 連続空白・改行の圧縮
text = re.sub(r"\s+", " ", text)
return text.strip()
重複の除去はもう少し慎重にやる必要がある。社内ドキュメントのRAGでよくある問題として、同じ内容がセクションごとに微妙に言い換えられて繰り返されているケースがある。これをそのまま入れるとトークンが膨らむ。ただし単純なdeduplicationだと、微妙に異なる情報(改訂日が違うとか、適用範囲が違うとか)を消してしまうリスクがある。
自分のチームでは、チャンクをembeddingして類似度が0.95以上のものを同一視し、最新のものだけ残すという処理を入れた。精度に影響が出るケースはほとんどなかったが、法的文書のような「同じ文言でも文脈が決定的に重要」な領域では使わないほうがいい。
不要フィールドの除去:スキーマを意識する
API レスポンスやDBのレコードをそのままLLMに渡すケースでは、フィールドの取捨選択が効く。
例えばユーザーの行動ログを渡してサマリーを生成するとき、created_atのミリ秒タイムスタンプ、内部ID、サーバーサイドのデバッグ用フラグなどはほぼ不要だ。これを明示的に除いたスキーマを定義しておく。
ALLOWED_FIELDS = {"event_type", "timestamp_utc", "page_url", "duration_sec"}
def filter_event(event: dict) -> dict:
return {k: v for k, v in event.items() if k in ALLOWED_FIELDS}
ここでよくやる失敗は「とりあえず全部渡して、モデルに判断させればいい」という思考だ。モデルは確かに不要な情報を無視できることが多い。でも不要なトークンを処理するコストは確実に発生しているし、長いコンテキストではattentionが散漫になって精度が下がることが実験でも確認されている。渡す情報の設計はモデルへの敬意でもある。
数値フィールドの精度も意識したい。3.141592653589793を3.14にするだけでも、数十フィールドあれば無視できない差になる。精度が業務的に不要なら丸める。
会話履歴の要約タイミング
チャットボット系の実装で一番悩むのがこれだ。会話履歴をどこまで持つか、いつ要約するか。
単純な「直近N件のみ保持」は実装が楽だが、Nより前の文脈が消えたとき、モデルが文脈を見失う。ユーザーが「さっき言ったやつで」と言ってくる場面で詰む。
自分が試した中で安定していたのは、ターン数ではなくトークン数でしきい値を設けるアプローチだ。会話全体のトークンが一定数を超えたら、古い履歴をLLM自身に要約させてコンパクトにする。
def maybe_summarize_history(
history: list[dict],
model_client,
token_threshold: int = 3000,
) -> list[dict]:
total_tokens = count_tokens(history)
if total_tokens < token_threshold:
return history
# 古い履歴(末尾の直近2ターンは残す)を要約
to_summarize = history[:-4] # 2ターン = user + assistant × 2
recent = history[-4:]
summary_text = model_client.summarize(to_summarize)
summary_message = {"role": "system", "content": f"[会話要約] {summary_text}"}
return [summary_message] + recent
このアプローチのトレードオフは明確で、要約LLM呼び出しのコストが発生する点と、要約の品質がそのままコンテキスト精度に影響する点だ。要約プロンプトは「誰が何を質問し、何が決まったか」という意思決定ポイントを中心に残すよう指示すると精度劣化が少なかった。
もう一つ実感しているのは、「要約しても問題ない会話」と「要約すると致命的な会話」があるということ。コードレビューや数値の確認作業が続くスレッドでは、細部が消えると答えが変わる。ユースケースによって要約の粒度を変えるか、そもそも要約しないという選択肢も残しておくべきだ。
精度とコストのトレードオフをどう測るか
削減効果を定量的に把握しないと、「なんとなく削った」で終わる。自分がやっている最低限の計測は2つだ。
-
前処理前後のトークン数の比較をログに残す。 削減率が20%以下なら見直す余地がある。50%以上削れているなら精度への影響を検証する。
-
評価セットを用意して、前処理ありなしで出力の品質を比較する。 評価基準は「正答率」でも「別LLMによるスコアリング」でもいいが、主観だけで「問題なさそう」と判断するのは危ない。
コスト削減の施策は、精度に悪影響が出てから巻き戻すのが一番コストがかかる。削る前に評価ラインを決めておく癖をつけると、後悔が減る。