ある夜、自分の Antigravity 環境で 6 時間ほど走らせ続けたコードレビューエージェントが、だんだん同じファイルを 3 回ずつ読みに行くようになりました。プロンプトの中に「useAuth.ts を確認しました」というツール呼び出しの履歴が 50 回近く溜まっていて、エージェント自身がその記憶を読み切れずに迷子になっていたのです。
長時間タスクを動かす AgentKit 2.0 のエージェントでは、これは珍しい話ではありません。LLM が見る履歴は会話が進むほど肥大していき、ある閾値を超えたあたりから「読んでいるはずなのに思い出せない」状態に入ります。ここでは私が個人開発のエージェント運用で実際に組み込んだ「コンテキスト圧縮サブエージェント」の設計を、構造化スキーマと評価基盤まで含めて共有します。
なぜ単純な「履歴を切り詰める」では破綻するのか
最初に試したくなるのは、メッセージ配列の古い方から N 件を捨てることです。トークン上限を超えそうになったら、先頭をスライスしてしまうあの実装ですね。ところがこれを本番で使うと、3 つの典型的な失敗が必ず起きます。
ひとつめは、ユーザーの初期指示が消えることです。「テスト追加は React Testing Library で書く」と最初に決めた制約が、3 時間後にはエージェントの記憶から落ちていて、勝手に Jest だけのテストを書き始めます。
ふたつめは、ツール呼び出しの結果ペアが壊れることです。tool_use ブロックだけが残って tool_result が消えると、AgentKit 2.0 は API エラーを返します。スライスは構文的に妥当な履歴を保証してくれません。
みっつめは、エージェント自身が「以前何を試したか」を忘れて同じ間違いを繰り返すことです。これは性能劣化として観測されるので、ユーザーから見ると「最近このエージェント急にバカになった」という体験になります。
私自身、この 3 つを全部踏みました。だからこそ、単純な切り詰めではなく圧縮という発想に切り替える価値があります。
コンテキスト圧縮サブエージェントの全体像
設計の核は、メインエージェントとは別に「履歴を読んで構造化要約を返すだけのサブエージェント」を一つ用意することです。AgentKit 2.0 のサブエージェント機構を使えば、専用のシステムプロンプトとモデルを割り当てた小型のワーカーとして登録できます。
全体の流れは次のように動きます。まずメインエージェントが実行されるたびに、現在のメッセージ配列のトークン数を計算します。トークン数が事前に決めたしきい値(例: モデルの上限の 60%)を超えたら、圧縮サブエージェントを呼び出します。サブエージェントは過去のメッセージを読み、構造化スキーマに沿った要約を返します。メインエージェントの履歴は、最新の数ターンを残しつつ、それより古い部分が要約 1 ブロックに置き換わります。
ここで重要なのは、圧縮にはメインエージェントより小さいモデルを使うことです。私の運用では、メインが Gemini 2.5 Pro で、圧縮サブエージェントは Gemma 4 Flash です。圧縮処理に上位モデルを使うと、コスト最適化どころか逆効果になります。
ステップ 1: 圧縮トリガーの設計
トリガーには 3 通りの考え方があります。トークン数しきい値、ターン数しきい値、そしてイベント駆動です。
トークン数しきい値は最も素直で、tiktoken などで現在のメッセージ配列の合計トークンを計算し、上限を超えそうになったら発火します。ただし計算コストがかかるので、毎ターンではなく N ターンごとに測るのが現実的です。
ターン数しきい値はもっと粗い見積もりで、たとえば「30 ターンごとに必ず圧縮する」と決めてしまいます。実装は楽ですが、ターンごとのトークン量がばらつくシナリオには弱いです。
イベント駆動は、特定のツール呼び出し(例: 大量のファイル読み込みを返す search_codebase)の直後に圧縮を挟む方式です。トークン消費の山がどこに来るかを把握しているならこれが最も効率的になります。
私の本番運用では、トークン数しきい値とイベント駆動のハイブリッドを使っています。コードを見せると次のようになります。
// src/agent/compression-trigger.ts
import { encodingForModel } from "js-tiktoken";
const enc = encodingForModel("gpt-4o"); // 近似で十分
export interface CompressionDecision {
shouldCompress: boolean;
reason: "token-threshold" | "tool-event" | "turn-limit" | "skip";
estimatedTokens: number;
}
const TOKEN_THRESHOLD_RATIO = 0.6; // モデル上限の 60%
const HEAVY_TOOLS = new Set(["search_codebase", "read_many_files"]);
export function evaluateCompression(
messages: Array<{ role: string; content: string; toolName?: string }>,
modelMaxTokens: number,
turnsSinceLastCompression: number
): CompressionDecision {
// 1. トークン数で測る
const totalTokens = messages.reduce(
(sum, m) => sum + enc.encode(m.content).length,
0
);
if (totalTokens > modelMaxTokens * TOKEN_THRESHOLD_RATIO) {
return { shouldCompress: true, reason: "token-threshold", estimatedTokens: totalTokens };
}
// 2. 直前が重いツール呼び出しなら、しきい値の 0.4 倍でも発火
const last = messages[messages.length - 1];
if (last?.toolName && HEAVY_TOOLS.has(last.toolName)) {
if (totalTokens > modelMaxTokens * 0.4) {
return { shouldCompress: true, reason: "tool-event", estimatedTokens: totalTokens };
}
}
// 3. 最低保証 — 30 ターン経過したら強制圧縮
if (turnsSinceLastCompression >= 30) {
return { shouldCompress: true, reason: "turn-limit", estimatedTokens: totalTokens };
}
return { shouldCompress: false, reason: "skip", estimatedTokens: totalTokens };
}この関数はメインループから毎ターン呼びます。reason を返しているのは、後で「なぜ圧縮した/しなかった」をログから追えるようにするためです。本番では絶対にこのログを出力しておくことを勧めます。発火しすぎ/少なすぎを後から判定できる唯一の手がかりになります。
ステップ 2: 構造化スキーマで「失わない情報」を固定する
圧縮で一番怖いのは、自由記述の要約だけを返すことです。LLM が「重要そうに見える部分」を勝手に判断して残すので、再現性がありません。私が痛い目に遭ったのは、ユーザーが最初に出した制約条件(「テストは TDD で書く」)が要約のどこにも含まれていなかったケースです。
これを防ぐには、要約に必須フィールドを持つ構造化スキーマを与え、Gemma 4 / Flash に JSON で返すよう強制します。
// src/agent/compression-schema.ts
export interface CompressedContext {
// ユーザーから受け取った制約・要件(消してはいけない)
user_constraints: string[];
// これまでに下した重要な決定(理由付きで残す)
decisions: Array<{ decision: string; rationale: string }>;
// 試して失敗したアプローチ(同じ間違いを避けるため)
failed_attempts: Array<{ approach: string; reason_failed: string }>;
// 実行済みのツール呼び出しサマリ(重複呼び出し防止)
tool_calls_summary: Array<{ tool: string; target: string; outcome: "success" | "error" | "partial" }>;
// 直近 5 ターンの自由記述要約(流れの維持用)
recent_narrative: string;
// タスクの現在地点(何が終わって何が残っているか)
task_state: { completed: string[]; remaining: string[]; blockers: string[] };
}このスキーマは「圧縮しても絶対に失われては困る情報」を明示的に列挙したものです。フィールドを増やすほど復元性は上がりますが、サブエージェントの推論コストが上がります。私はこの 6 フィールドを 6 ヶ月運用していますが、これより削るとほぼ確実に問題が起きます。
JSON Schema 形式で AgentKit 2.0 の structured output に渡すことで、サブエージェントが必ずこの形で返すよう強制できます。
ステップ 3: 圧縮サブエージェントの実装
ここが本丸です。サブエージェントには「メインエージェントの履歴をすべて読み、上記スキーマを埋めて返す」という単純な仕事だけを与えます。
// src/agent/compression-agent.ts
import type { Message, AgentKit } from "@google/agentkit";
import type { CompressedContext } from "./compression-schema";
const COMPRESSION_SYSTEM_PROMPT = `
あなたはエージェント実行履歴の構造化要約を作る専門のサブエージェントです。
入力として、メインエージェントが行ってきた一連のメッセージと
ツール呼び出し履歴を受け取ります。
以下の指針で要約してください:
1. ユーザーの制約条件(言語、ライブラリ、命名規則など)は一字一句残す
2. 「決定」と「失敗」を分けて記録する
3. ツール呼び出しは「何を / どこに / 結果は」の3点で要約する
4. 直近5ターンは流れがわかる程度に narrative として残す
5. 推測や創作はしない — 履歴に書かれていない情報は出力しない
`;
export async function compressContext(
agentkit: AgentKit,
messages: Message[]
): Promise<CompressedContext> {
// 直近 5 ターンは「圧縮しない」ので分離
const compressTarget = messages.slice(0, -5);
const recent = messages.slice(-5);
if (compressTarget.length === 0) {
throw new Error("compressContext: 圧縮対象が 0 件です");
}
// structured output で JSON を強制
const result = await agentkit.subAgents.run({
name: "context-compressor",
model: "gemma-4-flash",
systemPrompt: COMPRESSION_SYSTEM_PROMPT,
messages: [
{
role: "user",
content: `次のメッセージ列を CompressedContext スキーマに従って要約してください:\n\n${JSON.stringify(compressTarget)}`,
},
],
responseSchema: COMPRESSED_CONTEXT_JSON_SCHEMA,
temperature: 0.0, // 圧縮は決定論的にする
maxOutputTokens: 2048,
});
// JSON パース失敗は明示的にエラー化
let parsed: CompressedContext;
try {
parsed = JSON.parse(result.text);
} catch (e) {
throw new Error(`圧縮結果のJSONパースに失敗: ${result.text.slice(0, 200)}`);
}
// 最低限の妥当性検証
if (!Array.isArray(parsed.user_constraints) || !parsed.task_state) {
throw new Error("圧縮結果に必須フィールドが欠落しています");
}
// 直近 5 ターンの narrative にマージ
const recentText = recent.map((m) => `[${m.role}] ${m.content.slice(0, 200)}`).join("\n");
parsed.recent_narrative = `${parsed.recent_narrative}\n\n[直近5ターン保全]\n${recentText}`;
return parsed;
}temperature: 0.0 は意図的です。圧縮は再現性が命なので、毎回違う要約を返されると履歴に「ゆらぎ」が混入し、A/B 計測ができなくなります。
期待する出力は次のようなものです(一部のみ抜粋)。
{
"user_constraints": [
"TypeScript で書く",
"テストは Vitest を使う",
"認証ロジックは src/auth/ 配下に集約する"
],
"decisions": [
{
"decision": "JWT のリフレッシュトークンは httpOnly Cookie に保存",
"rationale": "XSS リスクを下げるため、localStorage は使わない"
}
],
"failed_attempts": [
{
"approach": "useAuth フックを App.tsx に直接配置",
"reason_failed": "Context Provider が未設定でランタイムエラー"
}
],
"task_state": {
"completed": ["ログインフォーム実装", "JWT 発行 API"],
"remaining": ["リフレッシュトークン更新フロー", "ログアウト処理"],
"blockers": []
}
}ステップ 4: メインエージェントへの組み込み
圧縮結果を返すだけでは意味がありません。メインエージェントの履歴を実際に書き換える必要があります。AgentKit 2.0 では、onBeforeStep フックで履歴を差し替えられます。
// src/agent/main-agent.ts
import { evaluateCompression } from "./compression-trigger";
import { compressContext } from "./compression-agent";
const mainAgent = agentkit.agents.create({
name: "main",
model: "gemini-2.5-pro",
systemPrompt: MAIN_PROMPT,
hooks: {
onBeforeStep: async (ctx) => {
const decision = evaluateCompression(
ctx.messages,
128_000, // gemini-2.5-pro の上限
ctx.metadata.turnsSinceLastCompression ?? 0
);
ctx.logger.info({ decision }, "compression check");
if (!decision.shouldCompress) {
ctx.metadata.turnsSinceLastCompression =
(ctx.metadata.turnsSinceLastCompression ?? 0) + 1;
return;
}
// 直近 5 ターンを残し、それ以外を圧縮
const compressed = await compressContext(ctx.agentkit, ctx.messages);
const recentMessages = ctx.messages.slice(-5);
ctx.replaceMessages([
{
role: "system",
content: `[CONTEXT_SUMMARY]\n${JSON.stringify(compressed, null, 2)}`,
},
...recentMessages,
]);
ctx.metadata.turnsSinceLastCompression = 0;
ctx.metrics.recordCompression({
reason: decision.reason,
beforeTokens: decision.estimatedTokens,
});
},
},
});replaceMessages の第一要素として「圧縮要約をシステムメッセージとして注入する」のがポイントです。メインエージェントは次のステップでこの要約を「これまでの自分の記憶」として読みます。
ここで陥りがちな罠が、ツール呼び出しの整合性です。直近 5 ターンの中に tool_use だけがあって対応する tool_result が含まれていない、という状態で replaceMessages を呼ぶと AgentKit がエラーを返します。recentMessages を切り出す前に、必ず「対のないツール呼び出しを補正」してください。
ステップ 5: 圧縮品質を計測する評価基盤
圧縮を導入したら、必ず計測します。私が使っている評価項目は以下の 3 つです。
ひとつめはタスク完了率。圧縮ありとなしで、同じ初期プロンプトに対して何 % のタスクが完走するか。これが下がるなら圧縮はやめた方がよいです。
ふたつめは矛盾発生率。圧縮後にエージェントが「すでに決まったこと」と矛盾する発言・実装をした回数を数えます。1 タスクあたり 0.5 回を超えたら、スキーマの設計を見直すサインです。
みっつめはコスト削減率。圧縮ありの実行で、累積入力トークン数が圧縮なしと比べて何 % 減ったか。私の運用ではここが 40〜55% の範囲に収まります。30% を下回るなら、しきい値が高すぎて圧縮が走っていない可能性があります。
評価コードの骨格は次のようになります。
// src/agent/eval/compression-ab-test.ts
interface EvalResult {
taskId: string;
completed: boolean;
contradictions: number;
totalInputTokens: number;
totalOutputTokens: number;
durationMs: number;
}
export async function runABTest(
tasks: Array<{ id: string; prompt: string }>,
options: { withCompression: boolean }
): Promise<EvalResult[]> {
const results: EvalResult[] = [];
for (const task of tasks) {
const agent = createMainAgent({ enableCompression: options.withCompression });
const start = Date.now();
try {
const out = await agent.run({ prompt: task.prompt, maxSteps: 100 });
results.push({
taskId: task.id,
completed: out.status === "success",
contradictions: countContradictions(out.history),
totalInputTokens: out.usage.input,
totalOutputTokens: out.usage.output,
durationMs: Date.now() - start,
});
} catch (e) {
results.push({
taskId: task.id,
completed: false,
contradictions: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
durationMs: Date.now() - start,
});
}
}
return results;
}
// 矛盾検出は別 LLM に判定させる(ここでは割愛)
function countContradictions(history: Message[]): number {
// 例: 「TypeScript で書く」と決めたあとに .js を生成していないか
// をルールベースまたは judge LLM で数える
return 0;
}このスクリプトを 50 タスク程度のシードデータセットで回し、圧縮あり/なしの統計差を週次で監視するのが本番運用での最低ラインです。
よくある落とし穴
実装してから本番に出すまでの間に、私自身が踏んだ落とし穴を共有しておきます。
ひとつめは、圧縮サブエージェントに temperature をデフォルト値(多くは 0.7〜1.0)で動かしてしまうことです。圧縮結果が毎回ぶれるため、A/B 評価で差が出ているのか単なるノイズなのかが判別不能になります。必ず 0.0 で固定してください。
ふたつめは、直近ターンの境界で tool_use と tool_result を分断することです。messages.slice(-5) のような単純な切り出しはツール呼び出しの中央で切る危険があります。境界を決めるときは「直近 5 メッセージ以上で、かつ最後のメッセージが tool_use ペアの完結後である」を満たすところまで拡張してください。
みっつめは、圧縮要約を user ロールで注入することです。これをやると、エージェントが「ユーザーがこれを言った」と誤解して、要約に書かれた内容にユーザーが同意したかのように振る舞います。必ず system ロール、または [CONTEXT_SUMMARY] のような明示タグを付けて区別します。
よっつめは、圧縮を初期数ターンから走らせることです。ユーザーの初期指示はそのまま保ったほうが結果が安定します。私のしきい値は「最低 15 ターン経過後でないと圧縮しない」です。早すぎる圧縮はメリットがほぼゼロでコストだけ上がります。
本番投入時の判断基準
圧縮ありを本番のデフォルトにするかどうか、迷う人は多いと思います。私が判断軸にしているのは以下の 3 点です。
平均タスク長が 30 ターン未満で完了するエージェントなら、圧縮は不要です。むしろ圧縮の処理時間(500ms〜1.5 秒)の方がコストになります。一方、長時間タスク(コードレビュー、リサーチ、データ分析)は 50 ターンを超えるのが普通なので、圧縮しないと精度劣化が確実に起きます。
A/B 評価で矛盾発生率が圧縮なし時の 1.2 倍を超えたら、圧縮スキーマかしきい値の見直しが必要です。1.0 倍前後で収まっていれば、安心して有効化してよいシグナルです。
最後に、ユーザー体験として 応答時間が 2 倍以上に伸びる場面があるかを確認します。圧縮が同期的に走るタイミングでは確実に遅くなるので、進捗表示(「履歴を整理しています…」)を UI 側に組み込んでおくと体験が大きく変わります。
次の一歩
ここまで読んでくれたあなたが今日できる最小の一歩は、自分のエージェントの 30 ターン目以降のメッセージ配列を JSON として保存し、合計トークンを tiktoken で数える ことです。それだけで「自分のエージェントが本当に圧縮を必要としているか」がわかります。
数字を見て「これは圧縮しないと無理だ」と感じたら、まずは本記事の evaluateCompression をそのまま貼り付けて、しきい値だけを自分のモデル上限に合わせて起動してみてください。圧縮スキーマや評価基盤は段階的に育てれば十分です。
エージェントの運用を続けていく上での周辺トピックとして、私自身の経験を書いたAntigravity の Context Window 管理術と、コスト最適化全般を扱ったAntigravity エージェントのコスト最適化もあります。本記事のコンテキスト圧縮は「履歴側」の最適化、リンク先 2 本は「入力側」と「ループ側」の最適化なので、組み合わせると相乗効果が出ます。記憶アーキテクチャ全体を整理したい場合は AI エージェントのメモリ設計完全ガイド も併せて読んでみてください。
本記事の圧縮スキーマも、この書籍で示されている「保持すべき情報の階層化」のアイデアを応用しています。