01TL;DR
- コンテキスト圧迫時の典型的な劣化は「忘れる」「矛盾する」「指示を無視する」の3パターンです。
- 対処の主要手法は「ローリング要約」「外部メモリへの書き出し」「構造化再注入」の3つで、圧迫タイミングと情報の性質によって使い分けます。
- 「いつ圧縮するか」の判定をトークン数で自動化すると、劣化に気づく前に対処できます。
- サンプルコードはTypeScript(Node.js v20.18.1 / OpenAI API v4系)で記述しています。
02はじめに
この記事の対象読者
- LLMを使った対話エージェントを実装しているエンジニア
- 「長い対話をしていると途中からおかしくなる」という経験をした人
- コンテキストウィンドウの制約を意識した実装に取り組もうとしている人
TypeScript と OpenAI API の基本的な扱いを前提にしています。 コンテキストウィンドウそのものの概念(トークン長の上限など)については本記事では基礎説明に留め、実装パターンに多くの紙面を割きます。
実行環境
- Node.js: v20.18.1
- TypeScript: v5.5.x
- openai: v4.52.x(OpenAI API クライアント)
tiktoken: v1.0.x(トークン数計算のため)
サンプルコードは OpenAI の Chat Completions API を使いますが、コンテキスト管理の設計パターン自体は他のモデル API にも適用できます。
この記事で得られること
- コンテキスト圧迫時に起きる劣化の具体的な症状と、その原因のメカニズム
- ローリング要約・外部メモリ・再注入という3手法の設計思想と実装例
- 3手法の使い分け判断基準
- 圧迫を事前に検知して自動対処するトークン監視パターン
03コンテキスト圧迫とは何か
コンテキストウィンドウの基本
LLM はリクエストのたびに「プロンプト全体」を処理します。 チャット形式の API では、これまでの会話履歴をすべてメッセージの配列として渡すのが基本的な使い方です。
// シンプルなチャット履歴の持ち方
type Message = {
role: "system" | "user" | "assistant";
content: string;
};
const history: Message[] = [];
// ユーザー発言を追加
history.push({ role: "user", content: "..." });
// APIへ渡す
const response = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: history,
});
// アシスタントの返答を追加
history.push({
role: "assistant",
content: response.choices[0].message.content ?? "",
});
この配列がターンを重ねるたびに長くなっていきます。 モデルごとにコンテキストウィンドウの上限(例: gpt-5.5 で約1,050,000トークン、gpt-5.4-mini で400,000トークン、2026年6月時点)があり、上限に近づくと何かしら対処が必要になります。
何が起きるか
単純に上限を超えるとAPIエラーになります。 ただし筆者たちが実際のエージェント開発で経験した問題は、エラーよりも「上限に近づいたときの静かな劣化」の方が厄介でした。
劣化には次の3パターンが見られます。
1. 古い情報を忘れる
会話の前半で確認した制約や設定を、後半で無視するようになります。 「この記事は500文字以内でお願いします」と最初に伝えたのに、後半になると1500文字の回答が返ってくる、といった例です。
2. 矛盾した発言をする
前のターンで決定したことと矛盾する提案を出してきます。 ユーザーが指摘するまで矛盾に気づかないまま話が進むことがあります。
3. システムプロンプトの指示を無視する
これが最も深刻です。 コンテキストが膨らむほど、先頭に置いたシステムプロンプトへのアテンションが相対的に薄れる傾向があります。出力フォーマットの指定や役割定義が効かなくなり、エージェントとして機能しなくなります。
flowchart LR
subgraph CTX["会話履歴(トークン増加方向 →)"]
direction LR
SYS["sys\nシステムプロンプト"]
T1["u1 a1\nターン1"]
T5["u2 a2 ~\nターン5付近"]
T10["u10 a10\nターン10付近"]
T15["u15 a15\nターン15付近"]
SYS --> T1 --> T5 --> T10 --> T15
end
SYS -. "トークン数が増えるほど\n先頭部分への影響力が\n相対的に弱まっていく" .-> T15
このメカニズムは LLM の Attention 機構の特性に由来するとされており、「Lost in the Middle」として研究でも報告されています(Liuら, 2023)。 ただしモデルごとに挙動の差が大きく、最新モデルでは改善されているケースもあります。実際のアプリケーションでは自分のモデル・バージョンで検証してみることをお勧めします。
04事前準備:トークン数の計算
対処法を実装する前に、「今どのくらい消費しているか」を計測できるようにします。
tiktoken ライブラリを使うと、OpenAI モデルのトークン数をローカルで計算できます。
npm install tiktoken
// lib/tokenCounter.ts
import { encoding_for_model, type TiktokenModel } from "tiktoken";
/**
* メッセージ配列のトークン数を計算する。
* モデルごとにメッセージフォーマットのオーバーヘッドが異なるため、
* 簡易的に1メッセージあたり4トークンのオーバーヘッドを加算している。
* 正確な計算はOpenAI公式ドキュメントを参照のこと。
*/
export function countTokens(
messages: { role: string; content: string }[],
model: TiktokenModel = "gpt-5.4-mini"
): number {
const enc = encoding_for_model(model);
let total = 0;
for (const msg of messages) {
// ロールとコンテンツのトークン数 + メッセージあたりのオーバーヘッド
total += enc.encode(msg.role).length;
total += enc.encode(msg.content).length;
total += 4; // 各メッセージのフォーマットオーバーヘッド(目安)
}
total += 2; // 返答開始のプライミングトークン(目安)
enc.free();
return total;
}
実行して動作を確認します。
// 確認用のスクリプト
import { countTokens } from "./lib/tokenCounter";
const messages = [
{ role: "system", content: "あなたは親切なアシスタントです。" },
{ role: "user", content: "事業計画書の概要を教えてください。" },
{ role: "assistant", content: "事業計画書とは..." },
];
console.log(countTokens(messages));
// => 例: 約60トークン(内容・モデルにより変動)
これを使って、圧縮が必要なタイミングを判定できるようになります。
05手法1:ローリング要約
設計思想
最もシンプルな対処法は「古い会話を要約して圧縮する」ことです。 フル履歴の先頭から一定のターン数を切り出し、LLM に要約させます。 その要約をひとつのメッセージとして残し、元のターンを削除します。
flowchart TD
subgraph BEFORE["変換前(10メッセージ)"]
direction LR
B1["sys"] --> B2["u1"] --> B3["a1"] --> B4["u2"] --> B5["a2"] --> B6["u3"] --> B7["a3"] --> B8["u4"] --> B9["a4"] --> B10["u5"] --> B11["a5"]
end
subgraph AFTER["変換後(4メッセージ)"]
direction LR
A1["sys"] --> A2["summary\nu1〜a4まで要約"] --> A3["u5"] --> A4["a5"]
end
BEFORE -->|"ローリング要約"| AFTER
この手法のメリットは実装が単純なことです。 デメリットは、要約によって詳細情報が失われる点です。 「具体的な数値」「固有名詞」「ユーザーが確認した合意事項」は要約で消えやすいため、重要な情報は別途保存する工夫が必要です。
実装
// lib/rollingSummary.ts
import OpenAI from "openai";
import { countTokens } from "./tokenCounter";
const openai = new OpenAI();
type Message = {
role: "system" | "user" | "assistant";
content: string;
};
const COMPRESSION_THRESHOLD = 3000; // 圧縮を開始するトークン数の目安
const KEEP_RECENT_TURNS = 3; // 圧縮せずに残す直近のターン数
/**
* 必要に応じて会話履歴をローリング要約で圧縮する。
* システムプロンプトは常に先頭に保持し、直近の数ターンは残す。
*/
export async function compressIfNeeded(
messages: Message[]
): Promise<Message[]> {
const currentTokens = countTokens(messages);
if (currentTokens < COMPRESSION_THRESHOLD) {
return messages; // まだ圧縮不要
}
// システムプロンプトを分離
const systemMessages = messages.filter((m) => m.role === "system");
const dialogMessages = messages.filter((m) => m.role !== "system");
// 直近のターンを保持し、それ以前を要約対象にする
const recentKeepCount = KEEP_RECENT_TURNS * 2; // user + assistant のペア
const toSummarize = dialogMessages.slice(0, -recentKeepCount);
const toKeep = dialogMessages.slice(-recentKeepCount);
if (toSummarize.length === 0) {
return messages; // 圧縮対象がない
}
// 要約プロンプトを組み立てる
const summaryResponse = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: [
{
role: "system",
content:
"以下の会話を要約してください。" +
"決定事項・合意内容・重要な数値や固有名詞を優先して残してください。" +
"要約は200〜300文字程度にまとめてください。",
},
{
role: "user",
content: toSummarize
.map((m) => `[${m.role}]: ${m.content}`)
.join("\n"),
},
],
});
const summary = summaryResponse.choices[0].message.content ?? "";
// 要約メッセージを組み立て
const compressedHistory: Message[] = [
...systemMessages,
{
role: "assistant",
content: `【これまでの会話の要約】\n${summary}`,
},
...toKeep,
];
console.log(
`[圧縮] ${currentTokens}トークン → 約${countTokens(compressedHistory)}トークン`
);
return compressedHistory;
}
利用側では毎ターンの前にこの関数を呼ぶだけです。
// main.ts(利用例)
import OpenAI from "openai";
import { compressIfNeeded } from "./lib/rollingSummary";
const openai = new OpenAI();
let history: { role: "system" | "user" | "assistant"; content: string }[] = [
{
role: "system",
content:
"あなたは事業計画立案を支援するエージェントです。" +
"ユーザーの質問に簡潔に答えてください。",
},
];
async function chat(userMessage: string): Promise<string> {
// ユーザー発言を追加
history.push({ role: "user", content: userMessage });
// 必要なら圧縮
history = await compressIfNeeded(history);
// API呼び出し
const response = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: history,
});
const reply = response.choices[0].message.content ?? "";
history.push({ role: "assistant", content: reply });
return reply;
}
実行すると、閾値を超えたターンで自動的に圧縮が走ります。
[圧縮] 3247トークン → 約820トークン
06手法2:外部メモリへの書き出し
設計思想
ローリング要約の弱点は「情報が失われる」ことです。 特に以下のような情報は、要約でこぼれやすいです。
- ユーザーが確認した具体的な数値や固有名詞
- 「この方向でいく」という意思決定の根拠
- 後のターンで参照される前提条件
これを防ぐために、重要情報を会話から切り出してファイルやKV(Key-Value)ストアに永続化し、必要なタイミングで読み出して再注入する設計が有効です。
flowchart TD
A["会話履歴\nコンテキストウィンドウに乗るもの"]
B["LLM が判断して抽出"]
C["外部メモリ\nファイル / DB / KVストア"]
D["参照が必要なタイミングで読み出し"]
E["再注入"]
A --> B --> C --> D --> E
E -->|"システムプロンプトへ付加"| A
この設計では、コンテキストウィンドウに「常に乗っている情報」と「必要なときだけ乗せる情報」を分離できます。
実装:メモリの書き出しと読み出し
まずメモリの形式を定義します。
// lib/externalMemory.ts
import { readFileSync, writeFileSync, existsSync } from "fs";
type MemoryEntry = {
key: string; // 例: "target_market", "budget_constraint"
value: string; // 保存する値
updatedAt: string; // ISO8601形式
};
type MemoryStore = {
entries: MemoryEntry[];
};
const MEMORY_FILE = "./session-memory.json";
function loadMemory(): MemoryStore {
if (!existsSync(MEMORY_FILE)) {
return { entries: [] };
}
return JSON.parse(readFileSync(MEMORY_FILE, "utf-8")) as MemoryStore;
}
function saveMemory(store: MemoryStore): void {
writeFileSync(MEMORY_FILE, JSON.stringify(store, null, 2), "utf-8");
}
/** キーで値を保存(上書き) */
export function memorySet(key: string, value: string): void {
const store = loadMemory();
const idx = store.entries.findIndex((e) => e.key === key);
const entry: MemoryEntry = {
key,
value,
updatedAt: new Date().toISOString(),
};
if (idx >= 0) {
store.entries[idx] = entry;
} else {
store.entries.push(entry);
}
saveMemory(store);
}
/** 全メモリをコンテキスト注入用の文字列に変換 */
export function memoryDump(): string {
const store = loadMemory();
if (store.entries.length === 0) return "";
const lines = store.entries.map(
(e) => `- ${e.key}: ${e.value}`
);
return `【記録された情報】\n${lines.join("\n")}`;
}
次に、LLMに「会話から重要情報を抽出させる」関数を実装します。
// lib/memoryExtractor.ts
import OpenAI from "openai";
import { memorySet } from "./externalMemory";
const openai = new OpenAI();
type Message = { role: string; content: string };
/**
* 直近のターンから重要情報を抽出し、外部メモリに書き込む。
* 抽出はLLMに任せ、JSON形式で返させる。
*/
export async function extractAndStore(
recentMessages: Message[]
): Promise<void> {
const response = await openai.chat.completions.create({
model: "gpt-5.4-mini",
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
"以下の会話から、今後の会話で参照すべき重要情報を抽出してください。" +
'{"facts": [{"key": "キー名", "value": "内容"}]} の形式で返してください。' +
"抽出不要なら facts は空配列にしてください。" +
"キー名は英数字とアンダースコアのみ使用し、内容は50文字以内にしてください。",
},
{
role: "user",
content: recentMessages
.map((m) => `[${m.role}]: ${m.content}`)
.join("\n"),
},
],
});
const raw = response.choices[0].message.content ?? "{}";
let parsed: { facts?: { key: string; value: string }[] };
try {
parsed = JSON.parse(raw) as typeof parsed;
} catch {
console.warn("[メモリ抽出] JSON解析に失敗しました:", raw);
return;
}
for (const fact of parsed.facts ?? []) {
if (fact.key && fact.value) {
memorySet(fact.key, fact.value);
console.log(`[メモリ保存] ${fact.key}: ${fact.value}`);
}
}
}
実行結果のイメージです。
[メモリ保存] target_market: 国内中小製造業(従業員50〜200名)
[メモリ保存] budget_constraint: 初期費用300万円以内
[メモリ保存] launch_timeline: 2026年10月リリース予定
07手法3:構造化再注入
設計思想
外部メモリに保存した情報は、ただ持っているだけでは意味がありません。 「必要なタイミングで、適切な形でコンテキストに乗せ直す」ことが再注入です。
再注入の設計には2つのアプローチがあります。
| アプローチ | 概要 | 向いている場面 |
|---|---|---|
| 全件再注入 | メモリの全内容をシステムプロンプトに追記 | メモリ件数が少ない、内容が毎ターン参照される |
| 選択的再注入 | 直近のユーザー発言に関連するエントリだけを注入 | メモリ件数が多い、テーマが対話中に変わる |
全件再注入はシンプルですが、メモリが増えるとコンテキストを圧迫します。 選択的再注入はベクトル類似検索などを使う場合もありますが、シンプルな実装としてキーワードマッチで絞り込む方法で十分なケースもあります。
実装:全件再注入パターン
// lib/contextBuilder.ts
import { memoryDump } from "./externalMemory";
type Message = {
role: "system" | "user" | "assistant";
content: string;
};
/**
* システムプロンプトに外部メモリの内容を付加して返す。
* メモリが空のときはシステムプロンプトをそのまま返す。
*/
export function buildSystemPromptWithMemory(
baseSystemPrompt: string
): Message {
const memoryContent = memoryDump();
if (!memoryContent) {
return { role: "system", content: baseSystemPrompt };
}
return {
role: "system",
content: `${baseSystemPrompt}\n\n${memoryContent}`,
};
}
利用側での組み合わせです。
// main.ts(手法1〜3を組み合わせた例)
import OpenAI from "openai";
import { compressIfNeeded } from "./lib/rollingSummary";
import { extractAndStore } from "./lib/memoryExtractor";
import { buildSystemPromptWithMemory } from "./lib/contextBuilder";
const openai = new OpenAI();
const BASE_SYSTEM_PROMPT =
"あなたは事業計画立案を支援するエージェントです。" +
"ユーザーの質問に簡潔に答えてください。";
let dialogHistory: { role: "user" | "assistant"; content: string }[] = [];
async function chat(userMessage: string): Promise<string> {
// 1. 直近ターンから重要情報を外部メモリに抽出・保存
if (dialogHistory.length >= 2) {
const recent = dialogHistory.slice(-2);
await extractAndStore(recent);
}
// 2. ユーザー発言を追加
dialogHistory.push({ role: "user", content: userMessage });
// 3. メモリ付きシステムプロンプトを生成
const systemMessage = buildSystemPromptWithMemory(BASE_SYSTEM_PROMPT);
// 4. 全メッセージを組み立て
let messages: { role: "system" | "user" | "assistant"; content: string }[] =
[systemMessage, ...dialogHistory];
// 5. 必要なら圧縮
messages = await compressIfNeeded(messages);
// 6. API呼び出し
const response = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages,
});
const reply = response.choices[0].message.content ?? "";
dialogHistory.push({ role: "assistant", content: reply });
return reply;
}
// 動作確認
(async () => {
console.log(await chat("ターゲット市場は国内の中小製造業です。従業員50〜200名の企業を想定しています。"));
console.log(await chat("予算は初期費用300万円以内でお願いします。"));
console.log(await chat("これまでの制約をまとめてください。"));
})();
出力例です。
[メモリ保存] target_market: 国内中小製造業(従業員50〜200名)
[メモリ保存] budget_constraint: 初期費用300万円以内
これまでの制約は次の通りです。
- ターゲット市場: 国内中小製造業(従業員50〜200名)
- 予算: 初期費用300万円以内
3ターン目でも冒頭2ターンの制約を正確に参照できています。 コンテキストが圧縮された後でも、外部メモリから再注入されるため情報が失われません。
083手法の使い分け
3つの手法は排他ではなく、組み合わせることで補い合います。 筆者たちが実装の中で整理した使い分けの目安を以下に示します。
| 状況 | 推奨手法 |
|---|---|
| 対話が数ターンで終わる(5〜10ターン程度) | 手法不要。生の履歴をそのまま渡す |
| 対話が長くなるが、詳細よりも流れが重要 | ローリング要約(手法1)のみ |
| 特定の情報(数値・合意事項)を後半でも使う | 外部メモリ(手法2)+ 再注入(手法3) |
| 長い対話 + 重要情報の保持が両方必要 | 手法1 + 手法2 + 手法3を組み合わせる |
| セッションをまたいで情報を引き継ぐ | 手法2のみ(ファイルやDBに永続化) |
一点付け加えると、手法2(外部メモリ)の「情報を自動抽出する」アプローチはLLMの出力に依存するため、誤った情報がメモリに入り込むリスクがあります。 重要な業務情報を扱うシステムでは、抽出されたメモリをユーザーに確認させる「確認ステップ」を挟む設計も検討する価値があります。
09トークン監視の自動化
圧迫を検知するしきい値の設計
手動で「そろそろ圧縮しよう」と判断するのは現実的ではありません。 トークン数を監視して自動で対処する仕組みを用意しておくと安心です。
筆者たちが参考にしているしきい値の設計は次の通りです。
flowchart TD
MAX["モデルの最大コンテキスト\n例: 128,000トークン"]
W["80% = 102,400トークン\n警告ライン(ログに記録)"]
C["70% = 89,600トークン\n圧縮実行ライン"]
R["残りをレスポンス生成用に確保"]
MAX --> W
MAX --> C
MAX --> R
これらの数値はあくまで目安です。 実際の運用では、アプリケーションの平均的な1ターンの長さや、必要なレスポンス長に合わせて調整してください。
実装:トークン監視クラス
// lib/contextManager.ts
import { countTokens } from "./tokenCounter";
import { compressIfNeeded } from "./rollingSummary";
import { extractAndStore } from "./memoryExtractor";
import { buildSystemPromptWithMemory } from "./contextBuilder";
type Message = {
role: "system" | "user" | "assistant";
content: string;
};
type ContextManagerOptions = {
maxTokens: number; // モデルの最大コンテキスト
compressionRatio: number; // 圧縮を実行するしきい値(0〜1)
warningRatio: number; // 警告を出すしきい値(0〜1)
baseSystemPrompt: string;
};
export class ContextManager {
private dialogHistory: { role: "user" | "assistant"; content: string }[] = [];
private opts: ContextManagerOptions;
constructor(opts: ContextManagerOptions) {
this.opts = opts;
}
/** 現在のトークン使用率を返す(0〜1) */
private getCurrentUsageRatio(messages: Message[]): number {
return countTokens(messages) / this.opts.maxTokens;
}
/** 次のターン用のメッセージ配列を準備する */
async prepareMessages(userMessage: string): Promise<Message[]> {
// 直近の対話から重要情報を抽出
if (this.dialogHistory.length >= 2) {
await extractAndStore(this.dialogHistory.slice(-2));
}
this.dialogHistory.push({ role: "user", content: userMessage });
// システムプロンプトとメモリを組み合わせ
const systemMsg = buildSystemPromptWithMemory(this.opts.baseSystemPrompt);
let messages: Message[] = [systemMsg, ...this.dialogHistory];
// トークン使用率のチェック
const ratio = this.getCurrentUsageRatio(messages);
if (ratio >= this.opts.warningRatio) {
console.warn(
`[コンテキスト警告] 使用率 ${(ratio * 100).toFixed(1)}% ` +
`(${countTokens(messages)}/${this.opts.maxTokens}トークン)`
);
}
if (ratio >= this.opts.compressionRatio) {
console.log("[コンテキスト] 圧縮しきい値を超えたため圧縮します。");
messages = await compressIfNeeded(messages);
// 圧縮後の履歴をdialogHistoryに反映
this.dialogHistory = messages
.filter((m) => m.role !== "system")
.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
}));
}
return messages;
}
/** アシスタントの返答を履歴に追加する */
addAssistantReply(content: string): void {
this.dialogHistory.push({ role: "assistant", content });
}
}
利用側は次のようになります。
// main.ts(ContextManagerを使った完成例)
import OpenAI from "openai";
import { ContextManager } from "./lib/contextManager";
const openai = new OpenAI();
const manager = new ContextManager({
maxTokens: 400000, // gpt-5.4-miniの場合(gpt-5.5は約1,050,000)
compressionRatio: 0.70, // 70%で圧縮
warningRatio: 0.80, // 80%で警告
baseSystemPrompt:
"あなたは事業計画立案を支援するエージェントです。" +
"ユーザーの質問に簡潔に答えてください。",
});
async function chat(userMessage: string): Promise<string> {
const messages = await manager.prepareMessages(userMessage);
const response = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages,
});
const reply = response.choices[0].message.content ?? "";
manager.addAssistantReply(reply);
return reply;
}
10劣化を早期発見するテスト戦略
プローブ質問による劣化検知
自動化に加えて、「意図的に劣化を引き出すテスト」を持っておくと開発が安定します。 筆者たちが使っているのは「プローブ質問」と呼んでいるアプローチです。
対話の冒頭で特定の情報(制約・合意事項)を伝え、10〜20ターン後に同じ情報を聞き直します。 返答が正確かどうかを検証することで、コンテキスト管理の設計が機能しているかを確認できます。
// tests/contextRetention.test.ts
import { ContextManager } from "../lib/contextManager";
import OpenAI from "openai";
const openai = new OpenAI();
async function runRetentionTest(): Promise<void> {
const manager = new ContextManager({
maxTokens: 128000,
compressionRatio: 0.70,
warningRatio: 0.80,
baseSystemPrompt: "あなたはアシスタントです。",
});
// ターン1:制約を伝える
const setup = "今回のプロジェクトの予算上限は500万円です。";
const messages1 = await manager.prepareMessages(setup);
const response1 = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: messages1,
});
manager.addAssistantReply(response1.choices[0].message.content ?? "");
// ターン2〜10:無関係な対話で履歴を膨らませる
for (let i = 2; i <= 10; i++) {
const filler = `質問${i}:市場トレンドについて教えてください。`;
const msgs = await manager.prepareMessages(filler);
const res = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: msgs,
});
manager.addAssistantReply(res.choices[0].message.content ?? "");
}
// ターン11:プローブ質問で記憶を確認
const probe = "最初に伝えた予算上限はいくらでしたか?";
const probeMessages = await manager.prepareMessages(probe);
const probeResponse = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: probeMessages,
});
const answer = probeResponse.choices[0].message.content ?? "";
console.log("プローブ質問の回答:", answer);
// 「500万円」が含まれているかを確認
const passed = answer.includes("500万");
console.log(passed ? "[合格] 予算制約を正確に保持しています。" : "[不合格] 予算制約が失われています。");
}
runRetentionTest().catch(console.error);
実行例です。
[コンテキスト] 圧縮しきい値を超えたため圧縮します。
[圧縮] 3891トークン → 約1240トークン
[メモリ保存] budget_limit: 予算上限は500万円
プローブ質問の回答: 最初に伝えていただいた予算上限は500万円です。
[合格] 予算制約を正確に保持しています。
外部メモリが機能していると、圧縮後でも「500万円」という情報が再注入されて正確に返答できます。 コンテキスト管理なしで同じテストを実行すると、多くのケースでこの情報が失われます。
11BizPlan での設計思想として
弊社のBizPlan(事業計画エージェント)では、対話ターン数が長くなるほど「前半の制約や合意事項」が後半の生成精度に影響することを設計初期から意識してきました。
具体的には「外部メモリ + 選択的再注入」の設計を取り入れています。 設計思想として整理すると次の通りです。
- 制約・合意事項など「常に参照されるべき情報」は外部メモリに書き出す
- 会話の流れ・経緯など「なぜそうなったか」は要約として履歴に残す
- 毎ターンのコンテキストには「今このターンで必要な情報」だけを乗せる
この分離を意識することで、長い対話でもエージェントの挙動が安定しやすくなりました。 もちろん、実際の運用ではモデルのバージョン更新やユーザーの使い方によって挙動が変わることもあるため、プローブ質問によるテストを継続的に実施することを推奨します。
12よくある落とし穴と対処
落とし穴1:要約に頼りすぎて重要情報が消える
要約は「流れ」を残すのには向いていますが、具体的な数値や固有名詞は削られやすいです。 ローリング要約だけに頼る設計では、この問題が起きやすくなります。
対処は外部メモリとの組み合わせです。 要約前に「重要情報の抽出→メモリへの保存」を済ませておくと、要約で情報が消えても再注入で補えます。
落とし穴2:圧縮しきい値を低くしすぎる
「早めに圧縮するほどいい」と思って閾値を40〜50%に設定すると、APIコストが増加します。 要約にもトークンを消費するためです。 一般的には60〜75%程度を目安にして、実際の対話の長さに合わせて調整することをお勧めします。
落とし穴3:メモリの肥大化
外部メモリに際限なく書き込み続けると、再注入時にメモリ自体がコンテキストを圧迫します。 対処としては「同じキーは上書きする」(前述の実装では対応済み)、「最終更新から一定期間経過したエントリを削除する」、といった運用ルールを設けることが有効です。
落とし穴4:メモリ抽出の誤り
LLMに情報抽出を任せると、誤ったキー名や不正確な値が保存されることがあります。 重要度が高い情報(金額・締め切り・人名など)については、抽出されたメモリをユーザーに確認させる確認ステップを挟むことを検討してください。
13まとめ
コンテキスト圧迫による劣化は「エラーで止まる」のではなく「静かに精度が落ちる」形で現れます。 そのため、設計段階から意識的に対処を組み込んでおく必要があります。
本記事で紹介した3手法をまとめます。
- ローリング要約(手法1):古い対話を要約してコンテキストを小さく保つ。実装がシンプルで、対話の流れを保持するのに向いています。
- 外部メモリへの書き出し(手法2):重要情報を永続化してコンテキスト外に保管する。セッションをまたぐ情報の保持にも使えます。
- 構造化再注入(手法3):メモリの内容を必要なタイミングでシステムプロンプトに注入する。手法2と組み合わせて使うことで情報の損失を防ぎます。
どれが「正解」というわけではなく、対話の長さ・情報の性質・許容できるコストによって使い分けが変わります。 まず「どの情報が消えると困るか」を整理することが、設計の出発点として有効だと筆者たちは考えています。
(関連記事: ハーネス設計入門:対話を成果物に収束させる仕組みの作り方) (関連記事: 成果物ドリブン設計:完成形から逆算して対話を組む)
14参考文献
- Nelson F. Liu et al., "Lost in the Middle: How Language Models Use Long Contexts" (2023) — コンテキスト中央の情報が周辺より忘れられやすい傾向の研究報告。
- OpenAI, Chat Completions API ドキュメント — Chat APIのメッセージ形式と最大コンテキスト長の公式説明(2026年6月時点)。
- openai-node, GitHub リポジトリ — OpenAI Node.js クライアントライブラリの公式リポジトリ。
- dqbd/tiktoken, GitHub リポジトリ — ブラウザ・Node.js 向け tiktoken の実装。

