AI Agent を作るとき、実際に Token を消費しているのはチャット内容とは限らず、むしろツールの返り値である可能性が高いです。
たとえばデータベースの検索、文書の読み込み、資料の検索などでは、ツールが一度に数千字を返すことがあります。これらの内容が上下文に残り続けると、会話はすぐに長く、高コストになります。
そんなときに使えるのが ToolResultCompactionStrategy です。
ツール結果の圧縮とは
これはユーザーの質問を削除するわけでも、通常のアシスタント回答を削除するわけでもありません。
より古いツール呼び出しの記録だけを処理し、元の「ツール呼び出しメッセージ+完全なツール結果」を、より短い 1 つのアシスタントメッセージに折りたたみます。
簡単に言えば、大きなツール呼び出し記録の束を、短いレシート 1 枚に変えるようなものです。
何のツールを呼び出し、どんな結果が返ったのかは分かりますが、完全なメッセージ構造をそのまま持ち続ける必要はありません。
Microsoft はこれを、破壊性が最も低いコンテキスト圧縮戦略と呼んでいます。
このコードはどう圧縮を発火するのか
コードでは TokensExceed(1000) をトリガー条件として使っています。
有効なコンテキストが 1000 Token を超えると、戦略はより古いツール呼び出しグループを探し、それらを短い記録へ折りたたみ始めます。
minimumPreservedGroups は 3 に設定されており、直近の 3 つの非システムメッセージグループは保護され、圧縮対象にはなりません。
この戦略は、Token 数が下がるまで、または条件に合う古いツール結果がなくなるまで、古いツール呼び出しグループを処理し続けます。
これは切り詰め圧縮とは違う
切り詰め圧縮は、より古いメッセージグループを直接削除します。ユーザーの質問やアシスタントの回答も消える可能性があります。
ツール結果圧縮は、はるかに穏やかです。
ユーザーメッセージにも通常のアシスタント回答にも手を付けず、ツール呼び出しに関する内容だけを圧縮します。
そのため、Microsoft が提供する圧縮パイプラインでは、通常は最初に実行される層になります。
完全なコード
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();
// Token が 1000 を超えたら、より古いツール呼び出しグループを短い記録に折りたたむ。
// 直近 3 つの非システムメッセージグループは常に元の内容を保持する。
var compactionStrategy = new ToolResultCompactionStrategy(
trigger: CompactionTriggers.TokensExceed(1000),
minimumPreservedGroups: 3
);
// コンテキスト圧縮機能を持つエージェントを構築し、
// 小説内容を取得するツール関数を登録する。
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,
"ToolResultCompactionStrategy"
);
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("名前で小説を取得する")]
static string GetNovel(
[Description("名称(書名符号付き)")] string name)
{
var novels = new Dictionary
{
{
"《雾港来信》",
"夜明けの霧が港を未開封の手紙のように包み込んでいた。林澈は古い郵便局で夜勤をしていたが、切手のない青い封筒を見つける。宛先は、なんと 30 年前に失踪した母親だった。手紙にはただ一文だけ書かれていた。「灯台を消してはいけない。」その夜、放棄された灯台が突然光り、海面から聞き覚えのある歌声が届く。林澈が声のする方へ向かうと、母親が若い頃の姿で波間に立っていた。彼女はこう告げる。港は 30 年ごとに記憶を 1 つ飲み込み、灯台は忘れ去られた人々を照らし返すことができるのだ、と。夜明け前、林澈は選ばなければならなかった。母を留めるのか、それとも街全体が失った過去を取り戻すのか。"
},
{
"《纸月亮》",
"町の空には毎晩、紙でできた月が昇る。その薄さは、裏側の星明かりが見えるほどだった。阿眠は町で唯一の月修理師で、銀の糸で月面のひびを縫い直していた。ある日、彼女はその裂け目の中に逆さまに吊り下げられた都市を見つける。そこに住む人々は昼に生き、夜になると影へと変わるのだった。都市の少年・紀南は、百年ものあいだ紙の裏側に閉じ込められているのだと訴え、月を切り離してほしいと頼む。だが月が落ちれば、町は永遠に夜を失ってしまう。阿眠は自分の影を裂け目へ送り込み、紀南の代わりに都市を支えさせた。それ以来、紙の月は今も昇る。ただし月光の中には、糸を縫う少女の影がひとつ増えていた。"
},
{
"《第七座花园》",
"王宮の後ろには 6 つの庭園があり、四季を通じて花が絶えない。しかし伝説の第 7 の庭園だけは、誰も見たことがなかった。庭師の少年・陸安はバラを剪定しているとき、壁の内側から自分の名を呼ぶ声を聞く。つる植物に覆われた小さな扉を開けると、そこには色のない花の海が広がっていた。庭を守る盲目の王女は言う。ここには人々が諦めた願いが集められており、色を取り戻してこそ庭園は再び人間の世界へ戻れるのだ、と。陸安は自分の夢を一つひとつ花に語りかける。すると白い花は、朝焼けや湖、炎の色に少しずつ染まっていった。だが最後の花には、彼の未来が必要だった。陸安は笑って種を植え、目を覚ますとすでに老人になっていた。手には金色の花びらが 1 枚握られていた。"
},
{
"《雨中棋局》",
"江南に 7 日間雨が降り続いた。茶館に、傘を持たない老人が現れる。彼は盤を広げ、勝った者には失った物を 1 つ返すと言った。多くの人が記憶を失って負けていく中、傘売りの娘・蘇青だけは席を立たなかった。彼女は、戦死した兄の最後の一言を取り戻したかったのだ。中盤に差しかかると、雨音は突然馬蹄の音に変わり、駒は戦場の兵器へと姿を変える。蘇青は兄が陣中に閉じ込められているのを見て、老人が賭博師ではなく、亡魂の帰る道を導く「引き棋」の人だと理解する。彼女は車を捨てて王を守り、黒駒を包囲から脱出させた。対局が終わると老人は姿を消し、傘の下に小さなため息が落ちた。「ちゃんと生きろ。もう雨が止むのを待つな。」"
},
{
"《星火列车》",
"真夜中 0 時、廃駅には切符のない列車が止まり、車窓には星明かりが満ちていた。少年・遅野は、家を出た父を探すため、こっそり最後尾の車両に乗り込む。車掌は言う。この列車が運ぶのは、まだ終えられていない別れだけで、停車するたびに乗客は会いたい人に 1 分だけ会えるのだ、と。遅野が流星のトンネルを抜けると、父は石炭ストーブのそばで懐中時計を修理していた。父は、自分は去ったのではなく、車輪を動かす星火となって、息子の成長を見守っているのだと言う。終点の手前で、遅野は懐中時計を父に返した。列車が再び走り出すと、空の端に微かに輝く新しい星がひとつ増えていた。そして遅野は、ようやく家へ帰ることを学んだ。"
}
};
return novels.TryGetValue(name, out var novel)
? novel
: "この小説は見つかりませんでした。";
}
実行するとどうなるか
この例では 5 冊の小説を連続で検索し、そのたびに GetNovel が呼ばれ、比較的長い小説本文が返されます。
コンテキストが 1000 Token を超えると、古いツール呼び出しグループが折りたたまれます。
元のツール呼び出しとツール結果は除外対象としてマークされ、代わりにその呼び出し内容を読みやすく残す、より短いアシスタントメッセージが生成されます。
プログラムの最後では、すべてのメッセージグループが出力されます。赤はすでに有効コンテキストから外れた元の内容、緑は現在もモデルに送られる内容を表します。
注意すべき点
ツール結果の圧縮は、ツール呼び出しグループのみを処理します。
Token の大部分が大量の通常チャットメッセージから来ている場合、あるいは保護対象の直近 3 グループ自体が長い場合、この戦略だけではコンテキストを 1000 Token 未満まで圧縮できないことがあります。
つまり、これは第一段階の整理に近いものです。
実際のプロジェクトでは、まずツール結果を圧縮し、必要に応じて要約圧縮や切り詰め圧縮を後段に接続して、穏やかなものから強いものへとつながる処理パイプラインを構成できます。
最後に
ツールの返り値は重要ですが、いつまでもそのまま持ち続ける必要はありません。
「何を呼び出したか」「何が返ってきたか」を残しつつ、重いメッセージ構造や詳細な結果を削れば、それで十分なことが多いです。
少しだけ履歴の荷物を減らせば、Agent はより長く会話できます。
(Translated by GPT)
元のリンク:https://mp.weixin.qq.com/s/C_RwI6a0Rzt4V-iMPdYEhQ?token=620137141&lang=zh_CN&wt.mc_id=MVP_325642