AI

LLMの文脈長制限を実装で回避する:2つの戦略と使い分け

LLMを使ったアプリケーションを作り込んでいくと、必ずぶつかるのが文脈長制限の壁です。チャットが長くなるにつれてトークンがあふれ、ドキュメント分析では1ファイルすら収まらないケースも珍しくありません。「古い履歴を削除すればいい」と安易に片付けると、今度は生成品質の低下という別の問題を引き込みます。本記事では、スライディングウィンドウと段階的サマリーという2つの戦略を実装レベルで整理し、どちらをどの場面で選ぶべきかを具体的に示します。

文脈長制限が顕在化する2つのシーン

LLMの文脈長問題が表面化しやすいのは、主に次の2つのシーンです。

チャットボットでは、会話が数十ターンを超えると全履歴をプロンプトに収めることが難しくなります。モデルごとにコンテキストウィンドウのサイズは異なり、ログや背景情報を含むシステムプロンプトが大きければ、思ったより早く上限に達します。

ドキュメント分析では、単一のPDFや長文テキストがそもそもウィンドウに収まらないケースがあります。法律文書や技術仕様書は数万〜数十万文字に達することがあり、全文をそのままAPIに渡せません。

どちらのシーンでも「何を残し、何を捨てるか」という判断が品質の分かれ目になります。

よくある対応策とその落とし穴

最新N件の切り捨て

最も単純な対処は「最新N件だけ残して古いメッセージを削除する」ことです。実装コストが低く、トークン管理も単純です。しかし、会話の冒頭で確認した要件や制約が消えてしまうと、モデルはその情報を参照できなくなります。「最初にユーザーが伝えた予算の上限」「設定済みのシステム前提条件」といった情報が失われると、後半の回答が矛盾を含む原因になります。

LLMによる要約

要約で履歴を圧縮する方法にも落とし穴があります。漠然と要約させると、固有名詞・数値・条件分岐といった具体的な情報ほど抜け落ちやすい傾向があります。「Aの場合はX、Bの場合はY」という手順が「適切に対応する」に圧縮されると、要約が逆効果になりかねません。

「削除か要約か」という二択よりも先に、何を保持すべきかを設計することが重要です。

スライディングウィンドウ:直近N件を保持する

スライディングウィンドウは、直近のメッセージをそのまま保持する戦略です。長期的な文脈よりも直近の一貫性が重要な場合、たとえばカスタマーサポートボットやターンごとに話題が変わりやすい用途に向いています。

from collections import deque
from typing import Any

class SlidingWindowMemory:
    def __init__(self, max_turns: int = 10, max_tokens: int = 4000):
        self.max_turns = max_turns
        self.max_tokens = max_tokens
        self._history: deque[dict[str, Any]] = deque()

    def add(self, role: str, content: str) -> None:
        self._history.append({"role": role, "content": content})
        # ターン数の上限を超えたら古いものから削除(1ターン = user + assistant)
        while len(self._history) > self.max_turns * 2:
            self._history.popleft()

    def get_messages(self) -> list[dict[str, Any]]:
        return list(self._history)

max_turns の設定はユースケースから逆算します。サポートボットなら直近5〜10ターンで十分なことが多く、コードレビューボットなら1ファイル分のやり取り全体を保持したい場合もあります。

段階的サマリー:重要情報を抽出しながら圧縮する

長期的な文脈を保持したい場合は、段階的サマリーが有効です。古い履歴を要約し、その要約と最新の履歴を組み合わせてプロンプトを構築します。

import anthropic

client = anthropic.Anthropic()

SUMMARY_PROMPT = """以下の会話履歴を要約してください。
要約には以下の情報を必ず含めること:
- ユーザーが明示した制約・要件(数値、固有名詞を含む)
- 決定済みの事項
- 未解決の課題

会話履歴:
{history}

要約:"""

def summarize_old_turns(
    turns: list[dict],
    model: str = "claude-3-5-sonnet-20241022"
) -> str:
    history_text = "\n".join(
        f"{m['role']}: {m['content']}" for m in turns
    )
    response = client.messages.create(
        model=model,
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": SUMMARY_PROMPT.format(history=history_text)
        }]
    )
    return response.content[0].text


class SummaryMemory:
    def __init__(self, recent_turns: int = 6, archive_every: int = 10):
        self.recent_turns = recent_turns
        self.archive_every = archive_every
        self._recent: list[dict] = []
        self._summary: str = ""
        self._turn_count: int = 0

    def add(self, role: str, content: str) -> None:
        self._recent.append({"role": role, "content": content})
        self._turn_count += 1
        if self._turn_count % (self.archive_every * 2) == 0:
            # recent の前半をアーカイブしてサマリーを更新
            to_archive = self._recent[: -self.recent_turns * 2]
            self._recent = self._recent[-self.recent_turns * 2 :]
            new_summary = summarize_old_turns(to_archive)
            self._summary = (
                f"{self._summary}\n{new_summary}".strip()
                if self._summary else new_summary
            )

    def build_messages(self, system_prompt: str) -> list[dict]:
        messages = []
        if self._summary:
            messages.append({
                "role": "user",
                "content": f"[これまでの会話の要約]\n{self._summary}"
            })
            messages.append({
                "role": "assistant",
                "content": "了解しました。続きをどうぞ。"
            })
        messages.extend(self._recent)
        return messages

サマリープロンプトに「数値や固有名詞を保持する」という明示的な指示を入れているのがポイントです。漠然と「要約してください」とだけ書くと、具体的な情報が抜け落ちやすくなります。

トークン数の事前計算でコストとエラーを抑える

プロンプトを送る前にトークン数を推計しておくことで、不要なAPI呼び出しやエラーを防げます。Claude APIではcount_tokensエンドポイント、OpenAIではtiktokenライブラリを利用できます。

import tiktoken

def count_tokens_openai(messages: list[dict], model: str = "gpt-4o") -> int:
    enc = tiktoken.encoding_for_model(model)
    # メッセージ形式のオーバーヘッドを加算(OpenAI公式の計算式に準拠)
    total = 3  # reply priming
    for message in messages:
        total += 4  # role + content の区切り
        total += len(enc.encode(message["content"]))
    return total

def build_safe_messages(
    memory: SummaryMemory,
    system_prompt: str,
    token_limit: int = 100_000
) -> list[dict]:
    messages = memory.build_messages(system_prompt)
    token_count = count_tokens_openai(messages)
    if token_count > token_limit:
        # 超過した場合はさらに古いメッセージを削除
        while token_count > token_limit and len(messages) > 2:
            messages.pop(2)  # summary の次から削除
            token_count = count_tokens_openai(messages)
    return messages

トークン数の事前計算は、段階的サマリーのアーカイブタイミングを動的に決める際にも応用できます。固定ターン数でアーカイブするよりも、トークン数ベースで判断した方が無駄が少なくなります。

どの戦略を選ぶか:シーン別の目安

大きなコンテキストウィンドウを持つモデルが登場したことで、「すべて詰め込めばよい」という考えも出てきます。実際、一部のモデルが持つ大容量コンテキストは、多くの長文処理ユースケースで切り捨て不要を実現できます。

ただし、注意点が2つあります。

コスト:入力トークンが増えるほどコストは線形に増加します。ユーザー数が増えたときの費用を事前にシミュレーションしておくことが重要です。

Lost in the middle問題:コンテキストウィンドウが大きくても、プロンプトの中間に置かれた情報はモデルが参照しにくいという研究報告があります。重要な情報はプロンプトの先頭または末尾に配置するのが基本です。

シーン別の選択目安をまとめると、以下のようになります。

シーン推奨戦略
短い会話・サポートボットスライディングウィンドウ
長期タスク・設計レビュー段階的サマリー
単一大規模ドキュメントの分析大容量コンテキストモデルを活用
コスト最優先のバッチ処理チャンク分割+Map-Reduce的集約

文脈長の問題は、モデルの能力が上がっても完全には解消しません。ウィンドウが広がれば、今度はコストと中間情報の参照精度が課題になります。スライディングウィンドウと段階的サマリーは、「コンテキストをどう設計するか」という問いへの回答として、大きなウィンドウを持つモデルを使う場合でも有効な考え方です。