コンテンツにスキップ

コンテキストエンジニアリング 学習メモ+用語集

LLM の有限なコンテキストに「何を載せ、何を外に出すか」を設計する力を、 履歴の増殖を実測 → 削る → 畳む → 書き残す → 剪定する → 想起する、の6例で一周する。 ロードマップ本体は ../../context-engineering/STUDY_NOTES.md の STEP 1〜4、 サンプルの並びは README.md を参照。

実装は LangChain(+ ex04 だけ LangGraph)の代表 API に載せ替えてある。概念は素手版(生 OpenAI SDK)と同一で、「ツールを触って学ぶ」ことが狙い。コード片もこの版に合わせて更新済み。素手版 → 代表ツールの対応は README.md の対応表と、本メモ §7 の API 用語集を参照。会話履歴は list[BaseMessage]、トークン実測は AIMessage.usage_metadata、見積もりは ChatOpenAI.get_num_tokens_from_messages()


全体像:4戦略を6例でなぞる

コンテキスト管理には「散らかった履歴を縮める」方向(compress)だけでなく、 そもそも外に書き出す(write)/ 必要な分だけ選ぶ(select)/ 別の頭に隔離する(isolate)という4つの方向がある。 本フォルダの6例はこの4戦略を順に体験する設計になっている。

# ファイル 学ぶ概念 戦略 一言で
01 ex01_context_growth.py API は stateless。履歴全送信で入力トークンが線形に育つ —(問題提起) まず「放置すると育つ」を実測する
02 ex02_trimming.py スライディングウィンドウ。トークンは減るが序盤の名前を忘れる compress(雑) 古い順に捨てる=重要度を見ない
03 ex03_compaction.py 要約圧縮。閾値超過で古いターンを LLM に畳ませる compress プロンプトで「何を残すか」指示できる
04 ex04_scratchpad.py 構造化メモ。履歴ではなく「状態」を運ぶ write 能動的に書き残す。履歴をほぼ送らない
05 ex05_tool_result_pruning.py ツール結果の剪定。生注入 vs 抽出+ファイル参照 select 一番太るのはツール出力。要点だけ載せる
06 ex06_long_term_memory.py 長期メモリ。embedding 付き永続化+関連分だけ想起 write+select セッションを跨ぐ記憶も select する

isolate(コンテキスト分離=サブエージェントに別の頭で考えさせる)だけは本フォルダに実装がなく、 ../claude_agent_sdk/ の ex06 や ../multi_agent/ で体験する。

4戦略の関係(Mermaid)

flowchart TD
    P["問題: コンテキストは放置すると単調増加する<br/>(ex01 で実測)"]

    P --> W["write(書き出す)<br/>履歴の外に状態を置く"]
    P --> S["select(選ぶ)<br/>必要な分だけ載せる"]
    P --> C["compress(畳む)<br/>載っているものを縮める"]
    P --> I["isolate(隔離する)<br/>別の頭で考えさせる"]

    W --> W1["ex04 スクラッチパッド<br/>メモ+直近ターンだけ送る"]
    W --> W2["ex06 長期メモリ(保存側)<br/>事実をファイルに永続化"]

    S --> S1["ex05 ツール結果の剪定<br/>抽出+ファイル参照"]
    S --> S2["ex06 長期メモリ(想起側)<br/>cosine top-k で関連分だけ"]

    C --> C1["ex02 トリミング<br/>古い順に捨てる(雑)"]
    C --> C2["ex03 要約圧縮 compaction<br/>LLM に畳ませる(指示できる)"]

    I --> I1["本フォルダには無し<br/>→ claude_agent_sdk / multi_agent"]

    style P fill:#ffe0e0
    style W fill:#e0f0ff
    style S fill:#e0ffe0
    style C fill:#fff0d0
    style I fill:#f0e0ff

4戦略は「組み合わせて使う」ものである

実務ではどれか1つを選ぶのではなく、1つのエージェントの中で全部走る。 ex05 末尾のコメントが要点を言っている:

write(ex04) / select(本例) / compress(ex03) / isolate は組み合わせて使う

たとえば Claude Code 自身は、TODO リスト(write)+長大ツール出力の切り詰め(select)+auto-compact(compress)+サブエージェント(isolate)を全部同時に回している。


サンプル別の要点

ex01 — コンテキストの増殖を実測する(出発点)

学ぶこと: Chat Completions API は会話を覚えていない(stateless)。messages 配列を毎ターンフルで送り直すのが唯一の文脈伝達手段であり、その結果 prompt_tokens(=コスト・レイテンシ)がターン数に対してほぼ線形に育つ、という出発点を usage の実測で体に入れる。

内部メカニズム: 「会話が続いている」感覚はサーバ側の記憶ではなく、クライアントが過去の user / assistant メッセージを全部詰め直して送っているから成立している。ここを誤解すると「なぜ毎回トークンが増えるのか」が分からなくなる。

要所(ex01_context_growth.py):

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)        # ①
messages: list[BaseMessage] = [SystemMessage("...")]        # ② 履歴は Message の list
...
estimated = llm.get_num_tokens_from_messages(messages)      # ③ 送信前の見積もり
response = llm.invoke(messages, langfuse_config())          # 実行(任意で langfuse 計装)
usage = response.usage_metadata or {}                       # ④ AIMessage に実測が載る
messages.append(response)                                   # ⑤ AIMessage を履歴に追記
やってること なぜそうする
ChatOpenAI で OpenAI Chat をラップ(生 SDK の client.chat.completions 役) LangChain 流の LLM ハンドル。invoke の戻り値 AIMessage に usage を載せてもらう
履歴を dict ではなく BaseMessage(System/Human/AIMessage)の list で持つ LangChain の標準。trim_messages や ToolMessage と同じ語彙で扱える
get_num_tokens_from_messages で送信前に見積もる(内部で tiktoken) 自前で content を encode するより高精度。メッセージ境界も数える
AIMessage.usage_metadata["input_tokens"] で実測を取る 生 SDK の usage.prompt_tokens に対応。input=prompt, output=completion
assistant の回答(AIMessage)を履歴に追記 これを忘れると「会話」にならない。だが追記する限り入力は単調増加する=本フォルダの主題のトレードオフ

観察ポイント: LangChain 版では 見積実測 input がほぼ完全一致する(どちらも tiktoken 由来)。実測した値は 36→124→265→373→482 と毎ターン「直前の output ぶん」上乗せされて増えるのが目で見える。

ex02 — トリミング(スライディングウィンドウ)

学ぶこと: 履歴肥大の最も素朴な対策は「古いメッセージを捨てる」。トークンは確実に減るが、捨てた情報は完全に失われる。序盤に伝えた名前を窓の外に押し出して忘れる、という副作用をこの目で確認する。

要所(ex02_trimming.py):

MAX_TOKENS = 120
def trim(messages):
    return trim_messages(                # ① 末尾優先でトークン上限まで古いものを捨てる
        messages,
        max_tokens=MAX_TOKENS,
        token_counter=llm,               # ② モデルを渡すと実トークンで測る
        strategy="last",
        include_system=True,             # ③ system は必ず残す
        start_on="human",                # ④ user→assistant ペア境界を崩さない
    )
やってること なぜそうする
langchain_core.messages.trim_messages で末尾優先で切り詰める 自前 slice の代わりに LangChain 標準のトリマを使う。文字数でもトークンでも測れる
token_counter=llm を渡す llm.get_num_tokens_from_messages が呼ばれ、実トークン数で窓を切る(素手版の「N ターン」より実態に即す)
include_system=True system には役割・制約が入るので常に保持する
start_on="human" 窓の先頭が必ず user になる=user/assistant ペアの境界を崩さない

実験設計が巧妙: 序盤に「私の名前はケンです。Python が好きです」を入れ、その後の雑談で履歴を埋めてから「名前と好きな言語は?」と聞く。フル履歴版(345tok)は答えられ、窓版(95tok)は答えられない(実行すると「情報を持っていません」と返す)。

観察ポイント: トリミングは「古い=不要」という仮定の上に成り立つが、実際は序盤にこそ重要情報(要件・制約・名前)が来ることが多い。MAX_TOKENS を大きくすると名前が窓に残り答えられるようになる=窓幅がそのまま仕様。だから「重要度を見て選んで残す」手段(ex03 要約 / ex04 メモ / ex06 長期メモリ)が要る、という動機づけになっている。

ex03 — 要約圧縮(compaction)

学ぶこと: Claude Code の /compact や Anthropic API の compaction が内部でやっていることの最小版。閾値を超えたら古いターンを LLM に要約させ、1メッセージに置き換える。ex02 と違い、要約プロンプトで「何を必ず残すか」を指示できる(その代わり残し損ねるリスクは負う)。

内部メカニズム: 「閾値超過 → 古い部分を要約 → summary + 直近ターンに置換」のループ。compaction の品質は要約プロンプトが全てであり、これが本サンプルの核心。

要所(ex03_compaction.py):

COMPACT_PROMPT = ChatPromptTemplate.from_messages([          # ① 要約プロンプト
    ("system", "...ユーザーに関する事実(名前・好み・属性)は必ず残す..."),
    ("user", "{transcript}"),
])
compact_chain = COMPACT_PROMPT | llm | StrOutputParser()     # ② LCEL チェイン
CHAR_THRESHOLD = 300                                          # 本来はトークンで測る

def compact(messages):
    head, old, recent = messages[0], messages[1:-2], messages[-2:]  # ③
    transcript = "\n".join(f"{m.type}: {_text(m)}" for m in old)
    summary = compact_chain.invoke({"transcript": transcript})       # 要約を1本道で取る
    summary_msg = SystemMessage(f"[これまでの会話の要約]\n{summary}")  # ④
    return [head, summary_msg, *recent]
やってること なぜそうする
要約プロンプトを ChatPromptTemplate で組み、「固有名詞・属性・決定事項は必ず残す」と明示 丸投げすると雑談の流れだけ要約して固有名詞を落とす。ここが compaction の品質そのもの
prompt \| llm \| StrOutputParserLCEL チェインにする 生 SDK の create() + choices[0].message.content がこの一本道に化ける。入力→整形→LLM→文字列抽出
直近1ターン(2msg)は生のまま、それ以前を要約対象に切る 直近の文脈は劣化させず、古い部分だけ圧縮する
要約を SystemMessage で「[これまでの会話の要約]」とラベル付けして挿入 「これは過去の会話の要約だ」とモデルに分からせる実務上のコツ

観察ポイント(README とコード末尾が指示): CHAR_THRESHOLD=300 だと実行時に compaction が2回発火し(9msg→4msg6msg→4msg)、それでも名前は要約に残り最後の質問に答えられる。COMPACT_PROMPT から「ユーザーに関する事実は必ず残す」を消して再実行すると名前が落ちることがある。要約は「何を残すか」の指示で品質が決まることを実感する用の壊し方。

ex04 — スクラッチパッド(構造化メモ)

学ぶこと: ex03 の要約が「溢れそうになってから慌てて畳む」受動的な戦略なのに対し、こちらは毎ターン、モデル自身に「覚えるべきことをメモに書け」と指示し、履歴の代わりに メモ + 直近ターンだけ を送る能動的な戦略(write)。Claude Code の TODO リストや memory ディレクトリ、Manus の todo.md と同じ発想。

内部メカニズム: 「会話履歴」と「状態(メモ)」を分離する。この版では LangGraph の StateGraph の state に状態を持たせる — 生の履歴(messages)は貯めず、notes(事実の全量)と last_turn(直近1往復)だけを state キーとして運ぶ。LangGraph の state は「ノードを跨いで運ぶ作業領域」=スクラッチパッドそのもの。メモ更新は with_structured_output で型安全に受ける。

要所(ex04_scratchpad.py):

class Reply(BaseModel):                              # ① 構造化出力スキーマ
    answer: str
    notes: list[str]
structured_llm = llm.with_structured_output(Reply)   # ① 出力が必ず Reply 型でパースされる

class ChatState(TypedDict):                          # ② state がスクラッチパッド
    user_text: str
    answer: str
    notes: list[str]                                 #    蓄積メモ
    last_turn: list[tuple[str, str]]                 #    直近1往復だけ
    prompt_tokens: int

def respond(state):                                  # ③ ノード: メモ+直近だけ送る
    messages = [SystemMessage(SYSTEM), SystemMessage("[メモ]\n...")]
    # last_turn を足し、今回の発話を足す(生履歴は state に無い)
    reply = structured_llm.invoke(messages)
    return {"answer": reply.answer, "notes": reply.notes,
            "last_turn": [("user", ...), ("assistant", reply.answer)]}  # ④ 直近を上書き
やってること なぜそうする
with_structured_output(Reply) で answer + notes を型安全に受ける 生 SDK の response_format=json_object + 手 json.loads が不要に。パース失敗を構造的に防ぐ
StateGraph の state に notes / last_turn を持つ(messages を貯めない) LangGraph の state はノードを跨ぐ作業領域=スクラッチパッド。生履歴を構造的に保持しない設計
respond ノードは system + メモ + 直近1ターン + 今回の発話のみ送る これが write 戦略の本体。prompt_tokens が ex01 のように線形に育たなくなる
直近ターンとして今回のやり取りだけ返す(生履歴をここで上書き=捨てる) ノードの戻り値(差分 dict)が既存 state にマージされる LangGraph の流儀

観察ポイント: 実行すると prompt_tokens は 85→169→190→309→190 とメモの分しか増えず、ex01 の線形増加(482 まで)と対照的。生履歴を捨てているのに最後の「名前・言語・今日・明日のテーマをまとめて」に全部答えられる。ただし「何をメモすべきか」の判断もモデルの仕事になるので、落とされて困る情報があれば system でメモの観点を明示する(ex03 と同じ教訓)。

ex05 — ツール結果の剪定(select)

学ぶこと: エージェントのコンテキストで最も太るのは会話ではなくツール結果(ログ・検索結果・ファイル内容)。生のまま履歴に積むか、要点だけ抽出して「全文はファイル参照」にするかで、トークンも回答品質も変わる。Claude Code が長大なツール出力を切り詰めてファイルパスを残すのと同じ発想(offload パターン)。

要所(ex05_tool_result_pruning.py):

@tool
def read_app_log(lines: int = 400) -> str:           # ① ツールを @tool で定義
    rows = [... "INFO request ok" ...]
    rows[317] = "...ERROR db connection timeout..."   #    399行の正常ログに1行だけエラー
    return "\n".join(rows)

extract_chain = EXTRACT_PROMPT | llm | StrOutputParser()  # ② purpose を runtime で差す

def ask_with_tool_message(tool_content, question):
    tool_call = {"name": "read_app_log", "args": {"lines": 400}, "id": "log-1", ...}
    messages = [
        SystemMessage("..."), HumanMessage(question),
        AIMessage(content="", tool_calls=[tool_call]),   # ③ ToolMessage はこれとペア必須
        ToolMessage(content=tool_content, tool_call_id="log-1"),  # ④ ツール結果を積む
    ]
    response = llm.invoke(messages)
    return response.content, response.usage_metadata["input_tokens"]

戦略の対比:

戦略A:生ログ注入 戦略B:抽出+参照
ToolMessage に載せるもの ログ全文(400行) 抽出した要点 + [全文は /tmp/... に保存済み]
トークン 多い 大幅減(実測で 削減率 ~98%
回答の正しさ 出る(DB 接続タイムアウト) ほぼ同等に出る
漏れたとき ファイルを読み直せる(エージェントなら read_file で再アクセス)
やってること なぜそうする
ログ取得を @tool 関数として定義し、400行中1行だけエラーを仕込む エージェントのツール呼び出しを忠実に再現。「正常ログの海に1行の真実」で剪定が当たるか試せる
抽出器を LCEL チェインにし、purpose(何のために読むか)を runtime で差す 目的を伝えないと「正常リクエストが多い」のような汎用要約になる。剪定はタスク依存であって汎用要約ではない
ツール結果を積む前に、tool_calls を持つ AIMessage を1つ挟む OpenAI API は ToolMessage 単独を弾く(直前に tool_calls のある assistant が必須)。エージェントの正規フローでもある
ツール結果を ToolMessage として会話に積む これがエージェントの実際の文脈の積み方。A/B で content の中身(占めるトークン)が変わる

観察ポイント: extract_chainpurpose を「パフォーマンス分析」に変えると digest の中身が変わる。全文をファイルに退避して参照だけ残すのが効く(抽出に漏れがあっても読み直す道がある)。

ex06 — 長期メモリ(write + select)

学ぶこと: ex04 のメモはセッション内で完結する。これをファイルに永続化し(write)、次のセッションで「今の話題に関連する記憶だけ」を embedding 検索で選んで(select)注入するのが長期メモリ。Claude Code の memory/、LangMem、Mem0 の原理。全記憶を毎回注入しないのがポイント。

内部メカニズム: 記憶 = Document(page_content=事実)の集合を InMemoryVectorStore に入れる。ベクトル化は OpenAIEmbeddings がストア内部で自動実行。永続化は vectorstore.dump(path) / InMemoryVectorStore.load(path, embeddings)。想起 = similarity_search_with_score(query, k)(cosine top-k を1行で。素手版の自前 cosine と同じ計算)。セッション1で保存 → プロセスを跨いだ想定のセッション2で想起、の往復。

要所(ex06_long_term_memory.py):

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

def save_memories(facts):
    store = InMemoryVectorStore(embeddings)
    store.add_documents([Document(page_content=f) for f in facts])  # ① 1件=1事実=1 Doc
    store.dump(str(MEMORY_PATH))                                    # ② JSON に永続化

def recall(query, k=2):
    store = InMemoryVectorStore.load(str(MEMORY_PATH), embeddings)  # ② ストアを復元
    scored = store.similarity_search_with_score(query, k=len(store.store))  # ③ cosine
    return [doc.page_content for doc, _ in scored[:k]]              # ④ top-k だけ返す
やってること なぜそうする
記憶は「1件=1事実=1 Document」で粒度を細かく保存。ベクトル化はストアに任せる 塊で保存すると、想起時に無関係な情報まで一緒に注入される
dump / load で JSON 永続化。プロセスを跨いだ想定でストアを復元 Chroma 等の永続 DB に差し替えれば実運用の長期メモリになる(API は共通)
similarity_search_with_score で cosine 類似度付き top-k を1行で取る 自前の cosine 計算(numpy)をストアが内部で代行。numpy は依存に必要
スコア順に並んだ結果から top-k だけ注入(全件ではない) メモリも select 対象のコンテキスト。記憶が増えるほど「全部入れる」は破綻する

観察ポイント: 実行するとスコア一覧で RAG 関連の記憶が上位(~0.44)、絵本の記憶が下位(~0.10)に来る。recall(query, k=4)(全件)にすると無関係な記憶も混入する。保存粒度を「1件=全部まとめ」に変えると想起の質が落ちる、という壊し方も用意されている。


用語集(最重要)

写経しながら言葉が混乱するポイントを1枚にまとめた。ここが本メモの主役。

1. 一番混乱する4語:write / select / compress / isolate

コンテキスト管理の戦略は、まずこの4分類に落とす。「縮める(compress)」だけが対策ではないのが最大の気づき。

戦略 何をするか 方向 具体例(本フォルダ) 使う手法・API
write(書き出す) コンテキストのに状態を置く 履歴 → 外部状態 ex04 スクラッチパッド、ex06 保存側 StateGraph の state メモ、InMemoryVectorStore.dump への永続化
select(選ぶ) 外部から必要な分だけ持ってくる 外部 → コンテキスト ex05 抽出、ex06 想起側 LCEL 抽出チェイン、similarity_search_with_score top-k
compress(畳む) 載っているものを縮める コンテキスト内で縮小 ex02 トリミング、ex03 要約圧縮 trim_messages、LCEL 要約チェイン
isolate(隔離する) 別の頭(別コンテキスト)で考えさせ、結果だけ受け取る コンテキストを分割 (本フォルダ外)サブエージェント claude_agent_sdk / multi_agent

覚え方: write は「外に出す」、select は「持ってくる」で対。compress は「中で縮める」、isolate は「別の箱に分ける」で対。

2. compress の中の2手法:トリミング vs 要約圧縮(compaction)

同じ「縮める」でも別物。ここが ex02 と ex03 の対比の核。

トリミング(ex02) 要約圧縮 / compaction(ex03)
やること 古いメッセージをそのまま捨てる 古い部分を LLM に要約させて1メッセージに置換
何を残すかの制御 できない(古い順で機械的) できる(要約プロンプトで指示)
情報の損失 捨てた分は完全に消える 残し損ねなければ保持。要約に圧縮される
コスト ゼロ(ただ切るだけ) 要約のための LLM 呼び出しが要る
失敗モード 序盤の重要情報を忘れる プロンプトが甘いと固有名詞を落とす
対応する Claude Code (素朴な切り詰め) /compact・auto-compact

判定基準: 速度・コスト最優先で「古い=不要」が成り立つ雑談ならトリミング。 序盤に要件・制約・固有名詞があり何を残すか制御したいなら要約圧縮。 一般のエージェントは後者(だから Claude Code は /compact を持つ)。

3. compress(受動)vs write(能動):要約圧縮 vs スクラッチパッド

両方「履歴を肥大させない」が、タイミングと主体が逆。

要約圧縮(ex03) スクラッチパッド(ex04)
発動タイミング 溢れそうになってから畳む(受動的) 毎ターン書き残す(能動的)
引き継ぐもの 過去履歴の要約 構造化された状態(notes)
生履歴 閾値まで溜める ほぼ送らない(メモ+直近のみ)
比喩 散らかった机を定期的に片付ける 最初からノートに清書しながら進める

判定基準: 既存の会話ループに後付けで肥大対策を入れるなら要約圧縮(割り込みで済む)。 設計段階から状態を分離できるならスクラッチパッド(線形増加そのものを止められる)。

4. write の中の2スコープ:スクラッチパッド vs 長期メモリ

どちらも「外に書き出す」write だが、寿命が違う。

スクラッチパッド(ex04) 長期メモリ(ex06)
寿命 1セッション内(プロセスが死ねば消える) セッションを跨ぐ(ファイルに永続化)
注入方法 毎回全量を注入 関連分だけ select(top-k) して注入
ストア メモリ上の list[str] {text, embedding} の JSON ファイル
対応する Claude Code TODO リスト・作業中メモ memory/ ディレクトリ + MEMORY.md

判定基準: 「この会話の中だけ覚えていればいい」ならスクラッチパッド。 「次に立ち上げたときも覚えていてほしい」なら長期メモリ(+想起時の select 必須)。

5. context window と トークン予算(threshold)

混同しやすい「容量」と「方針」の違い。

用語 何を指すか 性質 本フォルダでの登場
context window モデルが1回の呼び出しで受け取れるトークン上限(モデル仕様で固定) ハード上限。超えるとエラー or 切り捨て 暗黙の前提(gpt-4o-mini は128k)
トークン予算 / 閾値 「これを超えたら圧縮する」と自分で決める運用ライン ソフトな方針。window より手前に置く ex03 の CHAR_THRESHOLD = 600
prompt_tokens その呼び出しで実際に消費した入力トークン(API 実測値) 事後の測定値 ex01・ex04 の usage.prompt_tokens

ポイント: 閾値は context window ギリギリではなく手前に置く。直近ターンの生テキスト分の余白が要るため。ex03 が文字数で測っているのは写経の簡略化で、本来はトークンで測る(コメントにも明記)。

6. 見積もり vs 実測(LangChain 版)

ex01 で2つの数字が出る。LangChain 版ではどちらも ChatOpenAI 経由で取る。

get_num_tokens_from_messages 見積もり usage_metadata 実測
取り方 llm.get_num_tokens_from_messages(messages) ai_message.usage_metadata["input_tokens"]
計算場所 ローカル(内部で tiktoken。API を叩かない) OpenAI サーバ側
タイミング 送信 送信AIMessage に付く)
含むもの メッセージ境界のオーバーヘッド込み(高精度) 実消費トークン
用途 コストの事前見積もり・予算超過の予測 実際のコスト計算・正確な観測

LangChain 版は見積もりも境界を数えるので、生 SDK + 自前 tiktoken で出た「見積<実測」のズレがほぼ消え、実測とほぼ一致する(ex01 実行で確認)。それでも見積もりは予測、実測は精算という役割分担は同じ。usage_metadatainput_tokens(=prompt)と output_tokens(=completion)を持つ。

7. LangChain / LangGraph の API 用語(この版で触る道具)

実装を代表ツールに載せ替えたぶん、新しい API 語が出る。混同しやすいものを整理する。

用語 何か 本フォルダでの使い所 判定基準・一言
BaseMessage 会話1メッセージの型。SystemMessage / HumanMessage / AIMessage / ToolMessage 全 ex。履歴は list[BaseMessage] dict の role がクラスに化けた。AIMessage が assistant、ToolMessage がツール結果
ChatOpenAI OpenAI Chat のラッパ(生 SDK の client 役) 全 ex .invoke(messages) で実行。戻り値は AIMessage
usage_metadata AIMessage が持つ実測トークン(input_tokens/output_tokens ex01, ex05 生 SDK の response.usage 相当。メッセージ側に付くのが LangChain 流
get_num_tokens_from_messages 送信前のトークン見積もり(内部 tiktoken) ex01, ex02, ex04 自前 tiktoken より高精度(境界込み)
trim_messages 履歴を上限まで切り詰めるトリマ ex02 token_counter=llm で実トークン基準。strategy="last" で末尾優先
LCEL(prompt \| llm \| parser Runnable\| で繋ぐ宣言的チェイン ex03, ex05, ex06 「入力→整形→LLM→抽出」の一本道。StrOutputParser で文字列だけ取る
ChatPromptTemplate {変数} 穴あきプロンプト。invoke({...}) で埋める ex03, ex05, ex06 生 SDK の messages 手組みが、再利用可能なテンプレに化ける
with_structured_output(Model) LLM 出力を Pydantic 型で受ける ex04 response_format=json_object + 手パースの上位互換。型安全
@tool 関数をツール化するデコレータ ex05 .invoke({...}) で直接呼べる。docstring がツール説明になる
ToolMessage ツール実行結果のメッセージ ex05 直前に tool_calls 持ちの AIMessage が無いと API に弾かれる
StateGraph(LangGraph) state をノード間で運ぶグラフ ex04 state が「スクラッチパッド」。ノードは差分 dict を返すとマージされる
InMemoryVectorStore メモリ上のベクトルストア(dump/load で永続化可) ex06 add_documents で投入、similarity_search_with_score で top-k 想起
OpenAIEmbeddings テキスト→ベクトルの埋め込みモデル ex06 ストアが内部で呼ぶ。text-embedding-3-small を指定
CallbackHandler(langfuse) invoke の config に挿すと自動トレース 全 ex(任意) _trace.pylangfuse_config() 経由。キー無しなら素通り

trim_messages vs compact(ex03)の判定: 「機械的に古いものを捨てる」なら trim_messages(compress 雑)。「LLM に畳ませて何を残すか制御する」なら LCEL 要約チェイン(compress)。§2 の対比そのもの。 with_structured_output vs 生 JSON の判定: 構造化出力が要るなら必ず with_structured_output(Pydantic で型・必須・説明を一括指定でき、手 json.loads が消える)。

よくある誤解の訂正

  • 誤解: 「API は会話を覚えている」 → : stateless。覚えているように見えるのはクライアントが履歴を毎回送り直しているだけ(ex01)。
  • 誤解: 「ToolMessage は単独で会話に積める」 → : 直前に tool_calls を持つ AIMessage とペアでないと OpenAI API に弾かれる(ex05 で実際にエラーになった)。
  • 誤解: 「LangChain にすると概念も変わる」 → : write/select/compress/isolate の戦略は不変。実装の道具が変わるだけで、トレードオフは素手版と同一。
  • 誤解: 「コンテキスト対策=とにかく縮める(compress)」 → : 縮めるのは4戦略の1つ。write / select / isolate の方が効くことが多い。
  • 誤解: 「トリミングと要約は同じようなもの」 → : トリミングは何を失うか制御できない。要約はプロンプトで制御できる(§2)。
  • 誤解: 「ツール結果は履歴にそのまま積めばいい」 → : コンテキストを一番圧迫するのがツール出力。要点抽出+ファイル参照が基本(ex05)。
  • 誤解: 「記憶は全部注入すれば賢くなる」 → : 記憶が増えるほど全件注入は破綻する。メモリも select 対象(ex06、k を全件にすると無関係な記憶が混入)。
  • 誤解: 「閾値=context window」 → : 閾値は自分で決める運用ラインで、window より手前に置く(§5)。

クイック早見表(困りごと → 見る/使うもの)

困りごと 見る/使う戦略・サンプル
なぜ毎ターン入力トークンが増えるのか ex01。API は stateless で履歴を全送信している
履歴が長すぎる、とにかく減らしたい(雑談) compress:ex02 トリミング
減らしたいが序盤の要件・固有名詞は残したい compress:ex03 要約圧縮(プロンプトで残す指示)
そもそも線形増加を止めたい(設計段階) write:ex04 スクラッチパッド(状態と履歴を分離)
ツールのログ・検索結果でコンテキストが太る select:ex05 抽出+ファイル参照
次のセッションでも覚えていてほしい write+select:ex06 長期メモリ(保存+top-k 想起)
関連する記憶だけ注入したい select:ex06 recall(query, k) の cosine top-k
別タスクで本流の文脈を汚したくない isolate:claude_agent_sdk / multi_agent(本フォルダ外)
送信前にコストを見積もりたい ex01 の llm.get_num_tokens_from_messages(messages)
実際に何トークン食ったか知りたい ai_message.usage_metadata["input_tokens"](ex01・ex05)

学んだこと(要点)

  • API は stateless。会話の継続はクライアントが履歴を毎回送り直して成立させており、放置すると入力トークンは線形に育つ(ex01 が出発点)。
  • コンテキスト対策は write / select / compress / isolate の4方向ある。「縮める(compress)」は1つにすぎず、外に書き出す(write)・必要分だけ選ぶ(select)の方が線形増加を根本から止められる。
  • トリミングと要約圧縮は別物。トリミングは何を失うか制御できず序盤の重要情報を忘れる。要約圧縮はプロンプトが品質そのもので、「何を残すか」を指示できる。
  • スクラッチパッド(write)は能動的。溢れてから畳む要約圧縮と違い、毎ターン状態を書き残して履歴の代わりに送るので、トークンが線形に育たない。
  • コンテキストを一番圧迫するのはツール結果。生注入ではなく「抽出+全文はファイル参照(offload)」が基本で、抽出は purpose 依存のタスク特化操作(汎用要約ではない)。
  • 長期メモリも select する。記憶を細かい粒度で永続化し、想起時は cosine top-k で関連分だけ注入する。全件注入は記憶が増えると破綻する。
  • これら6例は Claude Code の機能(/compact・TODO・ツール出力切り詰め・memory/・サブエージェント)の原理そのもの。

拡張アイデア

  1. トークンで閾値を測る: ex03 の CHAR_THRESHOLD(文字数)を llm.get_num_tokens_from_messages() ベースに置き換え、トークン数で compaction を発動させる。文字数とトークン数のズレ(日本語/英語/コードで違う)を実測する。
  2. ハイブリッド戦略を1つのループに統合: ex03(compress)+ ex04(write)+ ex05(select)を組み合わせた小さなエージェントを作り、ターンごとに prompt_tokens がどう推移するかをグラフ化する。
  3. 要約品質の劣化を計測: ex03 で「圧縮を N 回繰り返す」と、要約の要約の…で情報が劣化する(lossy な多段圧縮)。何回目で名前が落ちるかを測り、「直近 K ターンは生で残す」設計の効果を確認する。
  4. メモリの上書き・矛盾解決: ex06 で「ユーザーは Python が好き」の後に「やっぱり Rust が好き」を保存したとき、想起で両方出てしまう問題を、保存時の重複検出 or 上書き(LangMem 的な memory update)で解く。InMemoryVectorStore を Chroma に差し替えて delete を使うのも手。
  5. isolate を体験する: 長大なログ調査(ex05 の拡張)をサブエージェントに丸投げし、本流コンテキストには結論だけ返す構成を claude_agent_sdk で組み、本流のトークンが汚れないことを確認する。
  6. トリミング戦略の比較: ex02 の「末尾 N ターン」だけでなく「system + 最初の1ターン(要件)+ 末尾 N ターン」を残す折衷を実装し、序盤情報の保持とトークン削減の両立を測る。

参考


作成: 2026-06-12 / 最終更新: 2026-06-12