スライディングウィンドウとは
AI アプリを作ると、対話のターン数が増えるほど、コンテキストはどんどん長くなります。
特にツール呼び出しを伴うシーンでは顕著です。ユーザーが一言質問し、モデルが一度ツールを呼び出し、ツールが大量の内容を返し、モデルがそれをもとにもう一度回答を組み立てる。数ラウンド進むだけで、Token はあっという間に増えていきます。
その解決策のひとつが、エージェントにスライディングウィンドウの圧縮を加えることです。
スライディングウィンドウとは
要するに、モデルには直近の会話だけに注目させます。対話が設定したターン数を超えると、古いチャット内容は有効なコンテキストから外され、最近のやり取りだけが残ります。
古い内容を要約したり、長期記憶として抽出したりはしません。やることはとても直接的です。古い内容を বাইরে に出し、新しい内容を入れて、コンテキストを常に制御可能な範囲に保ちます。
この方法は、小説検索、商品検索、天気検索などのシーンに向いています。各質問が比較的独立しており、古い内容を忘れても後続の結果に影響しません。
なぜメッセージを数件削除するだけではいけないのか
1 回の完全なツール呼び出しには、通常、ユーザーの質問、モデルの呼び出し開始、ツールの戻り値、そしてモデルの最終回答が含まれます。
これらのメッセージはひとつの完全な論理単位です。メッセージ数だけを見て適当に削除すると、ツール呼び出しだけ残ってツールの戻り値が削られたり、その逆になったりする可能性があります。
SlidingWindowCompactionStrategy はメッセージグループ単位で履歴を処理し、ツール呼び出しチェーンの完全性をできるだけ保ちます。同時に、システムメッセージは引き続き保持され、ウィンドウがスライドしても失われません。
このコードは何をしているのか
このサンプルは、小説検索アシスタントを作成し、GetNovel ツールを登録しています。
ユーザーが 5 冊の小説を連続で検索します。各検索で、モデルはツールを呼び出し、書名に応じた小説内容を取得できます。
ここでは TurnsExceed(4) によってトリガー条件を設定しています。対話ターンが 4 を超えると、スライディングウィンドウが動作を開始し、古いメッセージグループは後続でモデルに送る有効コンテキストから除外されます。
最後に、プログラムはセッション状態を出力します。赤色はすでに除外された古いメッセージグループ、緑色は現在も対話に参加しているメッセージグループを表します。
完全なコード
using AIConfig;
using Azure;
using Azure.AI.OpenAI;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using System.ClientModel;
using System.ComponentModel;
using System.Text.Json;
#pragma warning disable
var aiConfig = new AzureOpenAIConfig("gpt-5.6-luna");
var credential = new ApiKeyCredential(aiConfig.Key);
var options = new JsonSerializerOptions()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder
.UnsafeRelaxedJsonEscaping
};
// メインの会話モデルクライアントを作成する。
var agentChatClient = new AzureOpenAIClient(
new Uri(aiConfig.Endpoint),
credential
)
.GetChatClient(aiConfig.DeploymentName)
.AsIChatClient();
// 古いチャット内容を取り除き、最近の対話ウィンドウだけを残す。
// 圧縮時は完全な対話単位で処理され、システムメッセージは常に保持される。
var compactionStrategy = new SlidingWindowCompactionStrategy(
trigger: CompactionTriggers.TurnsExceed(4)
);
// コンテキスト圧縮機能を持つエージェントを構築し、
// 小説内容を取得するツール関数を登録する。
var agent = agentChatClient
.AsBuilder()
.UseAIContextProviders(
new CompactionProvider(compactionStrategy)
)
.BuildAIAgent(
new ChatClientAgentOptions
{
Name = "Assistant",
ChatOptions = new()
{
Instructions = "あなたは親切なアシスタントです。",
Tools =
[
AIFunctionFactory.Create(
GetNovel,
nameof(GetNovel)
)
]
}
}
);
// セッションを作成し、複数ラウンドの検索を連続送信して圧縮効果を観察する。
var session = await agent.CreateSessionAsync();
var list = new List
{
"小说《雾港来信》を探したい",
"小说《纸月亮》を探したい",
"小说《第七座花园》を探したい",
"小说《雨中棋局》を探したい",
"小说《星火列车》を探したい"
};
foreach (var input in list)
{
Console.WriteLine("あなた: " + input);
var response = await agent.RunAsync(input, session);
Console.WriteLine("アシスタント:");
Console.WriteLine(response.Text);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(
$"総Token数:{response.Usage.TotalTokenCount}\n" +
$"入力Token数:{response.Usage.InputTokenCount}\n" +
$"出力Token数:{response.Usage.OutputTokenCount}\n" +
$"キャッシュ入力Token数:{response.Usage.CachedInputTokenCount}\n" +
$"推論Token数:{response.Usage.ReasoningTokenCount}"
);
Console.ResetColor();
}
var serializedSession =
await agent.SerializeSessionAsync(session);
PrintMessageGroups(
serializedSession,
options,
"SlidingWindowCompactionStrategy"
);
void PrintMessageGroups(
JsonElement serializedSession,
JsonSerializerOptions options,
string propertyName)
{
if (serializedSession.TryGetProperty(
"stateBag",
out var stateBag
) &&
stateBag.TryGetProperty(
propertyName,
out var strategy
) &&
strategy.TryGetProperty(
"messagegroups",
out var messageGroups
) &&
messageGroups.ValueKind == JsonValueKind.Array)
{
foreach (var item in messageGroups.EnumerateArray())
{
if (item.TryGetProperty(
"isExcluded",
out var isExcluded
) &&
isExcluded.GetBoolean())
{
Console.ForegroundColor = ConsoleColor.Red;
}
else
{
Console.ForegroundColor = ConsoleColor.Green;
}
Console.WriteLine(
JsonSerializer.Serialize(item, options)
);
}
}
Console.ResetColor();
}
[Description("名前で 1 冊の小説を取得する")]
static string GetNovel(
[Description("名前(書名かぎかっこ付き)")] string name)
{
var novels = new Dictionary
{
{
"《雾港来信》",
"夜明けの霧が港を未開封の手紙のように包み込んでいた。林澈は古い郵便局で夜勤をしていたとき、消印のない青い封筒を見つける。宛先は、30年前に失踪した母親だった。封筒の中には一文だけが書かれていた。——灯台を消してはならない。その夜、廃墟になった灯台が突然点灯し、海面から聞き覚えのある歌声が響いた。林澈が声を追うと、母親が若い姿で波頭に立っていた。彼女はこう告げる。港は30年ごとに記憶を飲み込み、灯台は忘れられた人々を照らして呼び戻せるのだ、と。夜明けまでに、林澈は選ばなければならない。母親を留めるか、それとも都市全体が失った過去を取り戻すか。"
},
{
"《纸月亮》",
"町の空には毎晩、紙でできた月が昇る。薄くて、裏側の星明かりまで見えるほどだ。阿眠は町で唯一の月修理人で、銀糸で月面のひびを縫い合わせていた。ある日、彼女は裂け目の中に逆さまの都市を見つける。そこでは、人々は昼に生き、夜になると影になる。都市の少年・紀南は、百年も紙の裏側に閉じ込められているのだと語り、月を切り落としてほしいと頼む。だが月が落ちれば、町は永遠に夜を失ってしまう。阿眠は自分の影を裂け目へ送り込み、紀南の代わりに都市を支えさせた。それ以来、紙の月は変わらず昇り続ける。ただ月光の中には、ひとりの少女が縫い合わせる姿が映るようになった。"
},
{
"《第七座花园》",
"王宮の後ろには 6 つの庭園があり、どれも四季を通して花が絶えない。だが伝説の第 7 の庭園だけは、誰も見たことがなかった。庭師の少年・陸安はバラを剪定しているとき、壁の内側から自分の名前を呼ぶ声を聞いた。つる草に覆われた小さな扉を押し開けると、色のない花の海へと入っていった。庭を守る盲目の王女は言う。ここには人々が諦めた願いが集められており、色を取り戻してこそ、この庭園は人間界に戻れるのだ、と。陸安は自分の夢を一輪一輪、花に語りかけた。すると白い花は少しずつ、朝焼けや湖水や炎の色に染まっていった。だが最後の一輪には、彼の未来が必要だった。陸安は笑って種を植えた。目覚めると彼はすでに老人で、手には金色の花びらが一枚握られていた。"
},
{
"《雨中棋局》",
"江南に 7 日間雨が降り続いた。茶館に傘を持たない老人がやって来る。彼は盤を広げ、勝った者には失ったものを一つ返してやると言った。多くの人が記憶を失って負ける中、傘売りの娘・蘇青だけが席を立たなかった。彼女は戦死した兄の最後の言葉を取り戻したかったのだ。中盤に差しかかると、雨音が突然、馬蹄の音に変わり、碁石は戦場の兵器へと変わった。蘇青は兄が陣中に閉じ込められているのを見て、老人が賭博師ではなく、亡魂の帰路を探す引き碁人だと悟る。彼女は車を捨てて帥を守り、黒子に包囲を突破させた。対局が終わると老人は消え、傘の下にため息が一つ落ちた。——しっかり生きろ、もう雨が上がるのを待つな。"
},
{
"《星火列车》",
"真夜中0時、廃駅には切符のない列車が停まり、車窓は星明かりで満ちている。少年・遅野は家を出た父を探すため、こっそり最後尾の車両に乗り込んだ。車掌は、この列車は未完の別れだけを乗せるのだと告げる。各駅に着くたび、乗客は思いを寄せる人に1分だけ会えるのだ。遅野が流星のトンネルを抜けると、父は石炭ストーブのそばで懐中時計を修理していた。父は、自分は離れたのではなく、車輪を動かす星火となって、息子の成長を見守っていたのだと言う。終点の前に、遅野は懐中時計を父に返した。列車は再び走り出し、地平線にはかすかな新しい星が一つ増え、遅野はようやく帰ることを覚えた。"
}
};
return novels.TryGetValue(name, out var novel)
? novel
: "この小説は見つかりませんでした。";
}
スライディングウィンドウと要約圧縮、どちらを選ぶか
古い対話に長期的な価値がなく、直近数ラウンドだけを見ればよいなら、スライディングウィンドウが最も簡単です。ユーザーが前に言った予算、嗜好、重要な資料が、かなり後になっても必要になるなら、要約圧縮の方が適しています。片方は直接忘れ、もう片方は整理してから記憶します。どちらを選ぶべきかの鍵は、技術の複雑さではなく、古いメッセージに後続の業務上の価値がまだあるかどうかです。
最後に
AI のコンテキストは長ければ長いほどよいわけではありません。関係のない履歴メッセージが多すぎると、Token 消費が増えるだけでなく、モデルの判断を妨げることもあります。スライディングウィンドウの考え方はシンプルです。最近の、本当に有用な対話だけを残し、モデルを身軽にすることです。
(Translated by GPT)
元のリンク:https://mp.weixin.qq.com/s/cLQHDhgX795qukehZnEZIQ?token=620137141&lang=zh_CN&wt.mc_id=MVP_325642