Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

【AI開発指南書:第3回】PromptからContextへ:長文LLM時代の Context Engineering の極意

0
Posted at

【次世代AI開発:第3回】PromptからContextへ:長文LLM時代の Context Engineering の極意

新連載:MCP・高度RAG・自律エージェントの現場設計論

※本連載は、MCP(Model Context Protocol)、Context Engineering、GraphRAG、Self-Healing Agent、ローカルSLMなどの最先端技術スタックを用い、実務プロダクションで真に耐えうる次世代AIシステムを構築する重厚なハンズオン連載です。


1. イントロダクション(本記事のねらいと到達目標)

前回までの2回にわたり、MCP (Model Context Protocol) を用いてAIエージェントと外部ツール・データベースを安全に型接続する技術を解説しました。

しかし、エージェントが複数のツールを呼び出し、長い会話や膨大なドキュメントを取り扱うようになると、開発者は次の深刻な壁に直面します。それは「LLMのコンテキストウィンドウが128kや1Mトークンに拡大したにもかかわらず、入力文章を長くするほどAIの回答精度が落ち、トークンコストが爆発する」という問題です。

「プロンプトのテキスト表現を工夫する」という従来の Prompt Engineering(プロンプトエンジニアリング) のアプローチだけでは、この長文コンテキストにおける精度低下やコスト問題を解決することはできません。今、現場の開発者に求められているのは、LLMに与える情報全体の配置、構造化、動的トリミング、キャッシュ制御をシステムとして設計する 「Context Engineering(コンテキストエンジニアリング)」 の思考です。

第3回となる今回は、長文プロンプトで情報が忘れ去られる「Lost in the Middle 現象」の学術的背景を解説します。そして、コンテキストを4つの層(レイヤー)に分離し、トークン上限に応じて動的に最適化する「Context Manager」を Python で完全実装することを目標とします。

前提条件と動作検証環境(Prerequisites)

  • Pythonバージョン: Python 3.10 以上
  • 動作確認済み主要ライブラリ:
    • tiktoken>=0.7.0(OpenAI提供の高速トークナイザー)
    • openai>=1.30.0(LLM呼び出し用ライブラリ)
  • 動作確認環境: macOS / Linux / Windows (PowerShell)

2. 【理論・背景】Lost in the Middle 現象と長文コンテキストの罠

コンテキストウィンドウの巨大化(例: GPT-4oの128k, Claude 3.5の200k, Gemini 1.5の2Mトークン)に伴い、開発現場では「すべてのドキュメントや会話履歴をそのままプロンプトに詰め込めば良い」という誤解が広まりました。

しかし、スタンフォード大学の Liu et al. (2024) による有名な研究 『Lost in the Middle: How Language Models Use Long Contexts』 は、この幻想を打ち砕きました。

1. U字型精度曲線(U-Shaped Attention Curve)の真実

LLMの標準的な Transformer アテンション機構は、入力されたプロンプト全体の情報を均等に処理しているわけではありません。実験データによると、モデルはプロンプトの 「最初(Prefix)」 と 「最後(Suffix)」 に置かれた情報に対して最も高いアテンション(注目度)を払い、「中央部(Middle)」 に置かれた情報は無視されたり、検索に失敗したりする確率が飛躍的に高まることが実証されています。

【Lost in the Middle の精度分布 (U字型曲線)】

 100% |  ██ (高精度)                                  ██ (高精度)
      |  ██                                          ██
      |  ██                                          ██
  50% |  ██                                          ██
      |  ██             ░░ (低精度/失念)             ██
      |  ██             ░░                           ██
   0% +--------------------------------------------------->
       プロンプト先頭      プロンプト中央部          プロンプト末尾
       (Instructions)     (検索結果・長文ログ)       (最新の質問)

2. Prompt Engineering と Context Engineering の決定的な違い

LLMの入力を最適化するアプローチは、単なる「言葉遣いの工夫」から「ペイロード全体のシステム設計」へと進化しています。

評価項目 従来の Prompt Engineering 次世代 Context Engineering (本手法)
主対象 システム指示文の言い回し・Few-shot例の改善 プロンプト全体のデータ配置・レイヤー構造・トークン制御
課題へのアプローチ 「分かりやすく書く」ことでLLMの理解を促す 「Lost in the Middle」を避ける位置へ動的に情報を再配置する
コンテキスト管理 静的(固定テキストのコピペ) 動的(トークン数に応じた階層圧縮・要約・キャッシュ最適化)
コスト対策 人手でプロンプトを短く削る アプリケーションが tiktoken 等でトークン上限を自動マネジメント

3. 【アーキテクチャ解剖】Context Engineering の4層レイヤード構造

Lost in the Middle 現象を物理的に回避し、トークンコストを最小化するためには、プロンプトを構成する情報を以下の 4つの層(Layer) に分離し、明確な順序で組み立てるアーキテクチャを採用します。

4層レイヤーの役割と配置ルール

  1. Layer 1: Core System Instruction(最重要指示 / 先頭配置):
    • エージェントの役割、守るべき制約ルール、出力フォーマット。
    • アテンションが最も高いプロンプトの絶対先頭に配置します。
  2. Layer 2: User Profile & Persistent Memory(定常記憶 / 前方配置):
    • ユーザーの属性、過去の重要記憶、プロジェクト設定。
    • 後述する Prompt Caching を効かせるため、変化の少ないこの第1・第2層を固定ブロックとして固定化します。
  3. Layer 3: Dynamic Middle Buffer(動的文脈バッファ / 中央配置):
    • RAGで取得したドキュメント、過去の会話ログ、ツールの実行結果。
    • 情報が肥大化しやすいため、**トークン上限を超えた場合はこの中身だけを動的に削除・要約(Dynamic Truncation)**します。
  4. Layer 4: Current Query & Execution Cue(最新命令 / 末尾配置):
    • ユーザーが今回入力した最新の質問、および「以下の形式で即座に回答を開始せよ」という実行トリガー。
    • アテンションが最も高まるプロンプトの絶対末尾に配置します。

4. 【完全実装】Context Manager によるトークン最適化と層状配置の実装

それでは、tiktoken ライブラリを使用して正確なトークン数をリアルタイム計算し、指定したトークン上限(例: 2000トークン)を超えないように中身を自動圧縮・4層配置してプロンプトを組み立てる完全な Python クラス ContextManager を実装しましょう。

新規ファイル context_engineering_demo.py を作成し、以下のコードを記述します。

# 動作確認済みライブラリバージョン: tiktoken>=0.7.0, openai>=1.30.0
import os
import sys
from typing import List, Dict, Any, Optional
import tiktoken
from openai import OpenAI

class ContextManager:
    """Lost in the Middle 現象を回避し、トークン数を厳格に制御するコンテキストマネージャー"""
    
    def __init__(self, model_name: str = "gpt-4o", max_allowed_tokens: int = 2500):
        self.model_name = model_name
        self.max_allowed_tokens = max_allowed_tokens
        
        # モデルに対応する tiktoken エンコーダーの取得
        try:
            self.encoder = tiktoken.encoding_for_model(model_name)
        except KeyError:
            # 該当モデルが見つからない場合は cl100k_base をデフォルト使用
            self.encoder = tiktoken.get_encoding("cl100k_base")

    def count_tokens(self, text: str) -> int:
        """文字列の正確なトークン数を計算します"""
        return len(self.encoder.encode(text))

    def truncate_text_by_tokens(self, text: str, max_tokens: int) -> str:
        """指定したトークン数以内に文字列を正確に切り詰めます"""
        tokens = self.encoder.encode(text)
        if len(tokens) <= max_tokens:
            return text
        # トークン配列を切断してデコード復元
        truncated_tokens = tokens[:max_tokens]
        return self.encoder.decode(truncated_tokens) + "\n...[中略: トークン制限のため圧縮されました]..."

    def build_structured_context(
        self,
        system_instruction: str,
        user_profile: str,
        retrieved_documents: List[str],
        current_query: str
    ) -> List[Dict[str, str]]:
        """4層レイヤード構造(Prefix/Middle/Suffix)に基づいて最適なコンテキストメッセージ配列を生成します。"""
        
        # 1. 必須レイヤー (Layer 1, 2, 4) の固定トークン数を計算
        layer1_text = f"【システム絶対指示】\n{system_instruction.strip()}"
        layer2_text = f"【ユーザー定常記憶】\n{user_profile.strip()}"
        layer4_text = f"【最新の質問・実行指示】\n{current_query.strip()}\n\n回答は指示に従い論理的に述べてください。"

        token_l1 = self.count_tokens(layer1_text)
        token_l2 = self.count_tokens(layer2_text)
        token_l4 = self.count_tokens(layer4_text)

        reserved_tokens = token_l1 + token_l2 + token_l4
        
        print(f"📊 トークン計算: 固定レイヤー消費 = {reserved_tokens} トークン (上限 = {self.max_allowed_tokens})")

        # 2. Layer 3 (中央バッファ: RAGドキュメント) に割り当て可能な残容量を算出
        available_for_l3 = self.max_allowed_tokens - reserved_tokens

        if available_for_l3 <= 0:
            print("⚠️ 警告: 固定レイヤーのみで上限を超過しています。Layer 2をトリミングします。")
            layer2_text = self.truncate_text_by_tokens(layer2_text, max(50, self.max_allowed_tokens - token_l1 - token_l4))
            available_for_l3 = 0

        # 3. Layer 3 (Middle Buffer) の動的構築と圧縮
        l3_combined_text = ""
        if available_for_l3 > 0 and retrieved_documents:
            raw_l3_text = "【参照ナレッジドキュメント (RAG)】\n" + "\n---\n".join(retrieved_documents)
            l3_tokens = self.count_tokens(raw_l3_text)
            
            if l3_tokens > available_for_l3:
                print(f"✂️ Layer 3 が容量突破 ({l3_tokens} > {available_for_l3}) -> 動的トリミングを実行します。")
                l3_combined_text = self.truncate_text_by_tokens(raw_l3_text, available_for_l3)
            else:
                l3_combined_text = raw_l3_text
                print(f"✅ Layer 3 は容量内です ({l3_tokens} / {available_for_l3} トークン)")

        # 4. OpenAI / Anthropic 互換のメッセージ配列の組み立て
        #  - 先頭 (System): Layer 1 + Layer 2
        #  - 中央 (User): Layer 3 (ノード中央)
        #  - 末尾 (User): Layer 4 (思考直前の最高アテンション位置)
        
        messages = [
            {
                "role": "system",
                "content": f"{layer1_text}\n\n{layer2_text}"
            }
        ]

        if l3_combined_text:
            messages.append({
                "role": "user",
                "content": f"{l3_combined_text}"
            })

        messages.append({
            "role": "user",
            "content": f"{layer4_text}"
        })

        total_final_tokens = sum(self.count_tokens(m["content"]) for m in messages)
        print(f"🎉 最終構築プロンプト総トークン数: {total_final_tokens} トークン")

        return messages


# =====================================================================
# 実践デモ実行
# =====================================================================
def main():
    # OpenAI APIキーのセットアップ(環境変数がない場合はデモモード)
    api_key = os.environ.get("OPENAI_API_KEY", "dummy_key_for_testing")
    
    # マネージャーの初期化(上限をあえて小さめの 800 トークンに設定して挙動をテスト)
    manager = ContextManager(model_name="gpt-4o", max_allowed_tokens=800)

    # 1. 各レイヤーのダミーデータ作成
    sys_instruction = "あなたはエンタープライズソリューションアーキテクトです。質問に対して結論ファーストで回答してください。"
    user_profile = "ユーザー名: 山田太郎 | 役職: CTO | 関心技術: MCP, RAG, Python, Kubernetes"
    
    # あえて長大なダミーRAGドキュメントを作成(中間バッファ溢れの検証用)
    huge_documents = [
        f"ドキュメント #{i}: これはシステム仕様書の一部です。" + "詳細なパラメータ設定についての記述が続いています。" * 20
        for i in range(1, 10)
    ]
    
    query = "システムにMCPを導入した場合のトークンコスト削減効果とアーキテクチャ上のメリットを教えてください。"

    # 2. 層状コンテキストの自動組み立て
    print("==================================================")
    print("       Context Manager による構造化実行         ")
    print("==================================================")
    structured_messages = manager.build_structured_context(
        system_instruction=sys_instruction,
        user_profile=user_profile,
        retrieved_documents=huge_documents,
        current_query=query
    )

    print("\n--- 組み立てられたプロンプトのメッセージ構造 ---")
    for idx, msg in enumerate(structured_messages, 1):
        print(f"\n【メッセージ #{idx} - Role: {msg['role']}")
        print(msg['content'][:300] + ("..." if len(msg['content']) > 300 else ""))

    # APIキーが設定されている場合は実際にLLMを呼び出し疎通
    if api_key != "dummy_key_for_testing":
        print("\n🤖 LLM (GPT-4o) への問い合わせを実行中...")
        client = OpenAI(api_key=api_key)
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=structured_messages,
            temperature=0.2
        )
        print("\n--- LLMからの返答 ---")
        print(response.choices[0].message.content)
    else:
        print("\n💡 NOTE: OPENAI_API_KEY が設定されていないため、LLM呼び出しはスキップされました。")

if __name__ == "__main__":
    main()

5. コードの行別・ロジック詳細解説

実装した ContextManager の技術的なポイントを解剖します。

1. tiktoken.encoding_for_model(model_name) による厳密計算

  • 文字列の長さを len(text) で計算すると、日本語(マルチバイト文字)やコード記号のトークン数を大きく見誤り、コンテキスト制限オーバーを起こします。
  • tiktoken を使用して encoder.encode(text) することで、LLMが実際に消費するBPE(Byte Pair Encoding)トークン数をミリ秒単位で正確に取得しています。

2. 残容量計算と Layer 3 の動的切断 (truncate_text_by_tokens)

  • reserved_tokens = token_l1 + token_l2 + token_l4 で「絶対に切ってはならない最重要情報」のトークン数を先取り確保します。
  • available_for_l3 = self.max_allowed_tokens - reserved_tokens により、中央に配置する RAGドキュメント(Layer 3)に利用できる残容量を逆算し、溢れた分だけを切り詰めます。これにより、プロンプト溢れによるエラー(BadRequestError)が100%発生しなくなります

3. メッセージ配列の Prefix / Middle / Suffix 構造

  • messages 配列の先頭に system(Layer 1 + Layer 2)を配置。
  • 中央に user(Layer 3: 検索ドキュメント)を配置。
  • 末尾に user(Layer 4: 最新指示)を配置。
  • これにより、LLMのアテンション機構が最高の精度を発揮する両端に重要命令が固定され、「Lost in the Middle 現象」を構造的に回避しています。

6. プロダクション導入・コスト削減・パフォーマンス最適化

Context Engineering を本番サービスに適用する際の、実戦的な最適化テクニックを解説します。

1. Prompt Caching(プロンプトキャッシング)とのシナジー

Anthropic (Claude 3.5 Sonnet) や OpenAI (GPT-4o) は、プロンプトの先頭部分が過去のリクエストと一致している場合に「入力トークン料金を50%〜90%割引し、レイテンシを激減させる Prompt Caching」 を提供しています。

【Prompt Caching を最大化するレイヤー設計】
[Layer 1: System Instruction]   <-- 完全固定 (キャッシュ命中率 100%)
[Layer 2: User Profile/Memory]  <-- ユーザー単位固定 (キャッシュ命中)
----------------------------------- Cache Boundary (境界線) -----------------------------------
[Layer 3: Dynamic Documents]    <-- リクエストごとに変動
[Layer 4: Current Query]        <-- 毎回変動

固定文脈である Layer 1 と Layer 2 をプロンプトの絶対先頭に集約しておくことで、LLMプロバイダーのキャッシュバッファに100%ヒットさせ、大幅なコスト削減とレスポンス高速化を両立できます。

2. セマンティック・リランキング (Reranking) との組み合わせ

Layer 3 に挿入するドキュメントが多数存在する場合、単純なキーワード検索やベクトル検索の上位順のまま並べるのではなく、Cohere Rerank などのリランカーを通して「最も関連性の高いドキュメント」を Layer 3 の先頭と末尾(バッファの両端)に再配置するテクニックが極めて有効です。


7. まとめと次回の展望

今回は、長文LLM時代においてAIの精度低下を引き起こす「Lost in the Middle 現象」の理論背景と、それを構造的に打破する Context Engineering の4層レイヤー設計、および tiktoken を活用した動的コンテキストマネージャーの Python 実装を解説しました。

単にプロンプトの文言をいじる段階を終え、トークン数とアテンション構造を計算したシステム設計を行うことで、コスト削減と精度の向上が同時に手に入ることを実感いただけたかと思います。

しかし、文脈を完璧に整理しても、外部データから情報を取得する「RAG (検索拡張生成)」の検索クエリ自体が下手だった場合、そもそも間違ったドキュメントが Layer 3 に流し込まれてしまいます。一発の検索に頼る従来のRAGには限界があります。

次回、第4回。
検索クエリをエージェント自身が自律的に修正し、回答にハルシネーションがないかを自己評価してリトライする高度な検索ループ、**『Agentic RAGの実践:Self-RAGとクエリ自律再生成・自己評価ループ』**に進みます。

検索と生成のループを自律制御し、100%に近い回答精度を叩き出す次世代RAGの極意へ進みましょう。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?