0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Agent Harness の記憶圧縮実践:関数結果は圧縮したのに、なぜ Token は右肩上がりのままなのか?

0
Posted at

Agent アプリを開発していると、私たちはすぐに現実的な問題に直面します。対話のターン数が増えるほど、モデルに送るコンテキストは長くなり、Token 消費もどんどん増えていきます。

通常のチャットは比較的シンプルです。ですが、Agent が検索、データベース、ファイル読み取り、あるいは業務 API を呼び出すようになると、1 つのツールが一度に数千、場合によっては数万文字を返すことがあります。数ターンも経てば、会話履歴は急速に肥大化します。

Microsoft Agent Framework にはコンテキスト圧縮機能があり、モデル呼び出しの前に、古いメッセージを折りたたんだり、要約したり、削除したりできます。

この記事では、ある小説検索の例を通して、以下の設定を重点的に見ていきます。

var compactionStrategy =
    new ToolResultCompactionStrategy(
        trigger: CompactionTriggers.TokensExceed(1000),
        minimumPreservedGroups: 3);

答えるべき問いは 3 つです。

  • ToolResultCompactionStrategy は一体何を圧縮するのか?
  • シリアライズ結果から見て、圧縮は本当に効いているのか?
  • すでに圧縮されているのに、なぜ TotalTokenCount は増え続けるのか?
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 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<string>
{
    "小説『霧港来信』を探したい",
    "小説『紙の月』を探したい",
    "小説『第七の庭園』を探したい",
    "小説『雨中の棋局』を探したい",
    "小説『星火列車』を探したい"
};
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);
//Console.WriteLine(JsonSerializer.Serialize(serializedSession, options: options));
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<string, string>
    {
        {
            "《霧港来信》",
            "朝の霧が港を、未開封の手紙のように包み込んでいた。林澈は古い郵便局で夜勤をしているとき、消印のない青い封筒を見つける。宛先は、三十年前に行方不明になった母親だった。封筒の中には一文だけが書かれていた:灯台を消してはならない。その夜、廃墟となった灯台が突然灯り、海面から聞き慣れた歌声が響く。林澈が声のする方へ向かうと、若き日の母の姿が波の先端に立っていた。彼女はこう告げる。港は三十年ごとに記憶を呑み込み、灯台は忘れられた人を照らし返せるのだと。夜明け前、林澈は選ばなければならない:母を留めるか、それとも都市全体が失った過去を取り戻すか。"
        },
        {
            "《紙の月》",
            "町の上空には毎晩、紙でできた月が昇る。その薄さは、背後の星明かりが透けて見えるほどだ。阿眠は町で唯一の月修復師で、銀糸で月面のひび割れを縫い合わせている。ある日、彼女はその亀裂の中に逆さまに吊られた都市を見つける。そこに住む人々は昼に生き、夜になると影になる。都市の少年・紀南は、私たちは紙の裏側に百年も閉じ込められているのだと訴え、月を切り裂いてほしいと頼む。しかし月が落ちれば、町は永遠に夜を失ってしまう。阿眠は自分の影を亀裂の中へ送り込み、紀南に代わって都市を支えさせた。それ以来、紙の月は変わらず昇り続ける。ただし月光には、ひとりの少女が縫い合わせる姿が宿るようになった。"
        },
        {
            "《第七の庭園》",
            "王宮の背後には四季を通じて咲き続ける六つの庭園があるが、伝説の第七の庭園だけは誰も見たことがなかった。庭師の少年・陸安は薔薇を剪定しているとき、壁の中から自分の名を呼ぶ声を聞く。蔦に覆われた小さな扉を押し開けると、色を失った花の海へと入っていった。庭を守る盲目の王女は、ここには人々が諦めた願いが集められているのだと語り、色を取り戻してこそ庭園は人の世界へ戻れるのだと言う。陸安は自分の夢を一輪ずつ花に語り聞かせ、白い花はやがて朝焼け、湖、炎の色に染まっていく。だが最後の一輪には、彼の未来が必要だった。陸安は笑いながら種を植えた。目を覚ますと、彼はすでに老人となっており、手には黄金色の花びらが一枚残されていた。"
        },
        {
            "《雨中棋局》",
            "江南には七日連続で雨が降り、茶館には傘を持たない老人が現れた。老人は将棋盤を広げ、勝った者は失ったものを一つ取り戻せると言う。多くの人が記憶を失っていく中、傘を売る女・蘇青だけは席を立たなかった。彼女は、戦死した兄が残した最後の一言を取り戻したかったのだ。中盤に差しかかると、雨音は突然馬蹄の音へと変わり、駒は戦場の兵甲へと姿を変える。蘇青は兄が陣中に閉じ込められているのを見て、老人が賭博師ではなく、亡魂の帰る道を探す「引棋人」だと悟る。彼女は車を捨てて王を守り、黒子に包囲を突破させた。対局が終わると老人は姿を消し、傘の下に小さなため息が落ちる:しっかり生きろ、もう雨がやむのを待つな。"
        },
        {
            "《星火列車》",
            "真夜中の零時、廃駅には切符のない列車が停まり、車窓は星明かりで満ちている。少年・遅野は家を出た父を探すため、こっそり最後尾の車両に乗り込んだ。車掌はこの列車は未完の別れだけを乗せるのだと告げる。停車するたび、乗客は会いたい人に一分だけ会えるのだ。遅野は流星のトンネルを抜け、父が石炭ストーブのそばで懐中時計を修理しているのを見る。父は、自分は離れたのではなく、車輪を動かす星火となって、息子の成長を見守っているのだと言う。終点の前に、遅野は懐中時計を父に返した。列車は再び走り出し、空には微かに輝く新しい星が一つ増え、遅野はようやく家へ帰ることを覚えた。"
        }
    };
    return novels.TryGetValue(name, out var novel) ? novel : "この小説は見つかりませんでした。";
}

実行結果:

あなた: 小説『霧港来信』を探したい
アシスタント:

《霧港来信》

朝の霧が港を、未開封の手紙のように包み込んでいた。

林澈は古い郵便局で夜勤をしているとき、消印のない青い封筒を見つける。宛先は、三十年前に行方不明になった母親だった。封筒の中には一文だけが書かれていた:

灯台を消してはならない。

その夜、廃墟となった灯台が突然灯り、海面から聞き慣れた歌声が響く。林澈が声のする方へ向かうと、若き日の母の姿が波の先端に立っていた。

彼女はこう告げる。港は三十年ごとに記憶を呑み込み、灯台は忘れられた人を照らし返せるのだと。

夜明け前、林澈は選ばなければならない:

母を留めるか、それとも都市全体が失った過去を取り戻すか。

総Token数:736
入力Token数:518
出力Token数:218
キャッシュ入力Token数:0
推論Token数:6

あなた: 小説『紙の月』を探したい
アシスタント:

《紙の月》

町の上空には毎晩、紙でできた月が昇る。その薄さは、背後の星明かりが透けて見えるほどだ。

阿眠は町で唯一の月修復師で、銀糸で月面のひび割れを縫い合わせている。ある日、彼女はその亀裂の中に逆さまに吊られた都市を見つける。そこに住む人々は昼に生き、夜になると影になる。

都市の少年・紀南は、私たちは紙の裏側に百年も閉じ込められているのだと訴え、月を切り裂いてほしいと頼む。しかし月が落ちれば、町は永遠に夜を失ってしまう。

阿眠は自分の影を亀裂の中へ送り込み、紀南に代わって都市を支えさせた。

それ以来、紙の月は変わらず昇り続ける。ただし月光には、ひとりの少女が縫い合わせる姿が宿るようになった。

総Token数:1530
入力Token数:1325
出力Token数:205
キャッシュ入力Token数:0
推論Token数:5

あなた: 小説『第七の庭園』を探したい
アシスタント:

《第七の庭園》

王宮の背後には四季を通じて咲き続ける六つの庭園があるが、伝説の第七の庭園だけは誰も見たことがなかった。

庭師の少年・陸安は薔薇を剪定しているとき、壁の中から自分の名を呼ぶ声を聞く。蔦に覆われた小さな扉を押し開けると、色を失った花の海へと入っていった。

庭を守る盲目の王女は、ここには人々が諦めた願いが集められているのだと語り、色を取り戻してこそ庭園は人の世界へ戻れるのだと言う。

陸安は自分の夢を一輪ずつ花に語り聞かせ、白い花はやがて朝焼け、湖、炎の色に染まっていく。

だが最後の一輪には、彼の未来が必要だった。

陸安は笑いながら種を植えた。目を覚ますと、彼はすでに老人となっており、手には黄金色の花びらが一枚残されていた。

総Token数:2355
入力Token数:2130
出力Token数:225
キャッシュ入力Token数:0
推論Token数:5

あなた: 小説『雨中棋局』を探したい
アシスタント:

《雨中棋局》

江南には七日連続で雨が降り、茶館には傘を持たない老人が現れた。老人は将棋盤を広げ、勝った者は失ったものを一つ取り戻せると言う。

多くの人が記憶を失っていく中、傘を売る女・蘇青だけは席を立たなかった。彼女は、戦死した兄が残した最後の一言を取り戻したかったのだ。

中盤に差しかかると、雨音は突然馬蹄の音へと変わり、駒は戦場の兵甲へと姿を変える。蘇青は兄が陣中に閉じ込められているのを見て、老人が賭博師ではなく、亡魂の帰る道を探す「引棋人」だと悟る。

彼女は車を捨てて王を守り、黒子に包囲を突破させた。

対局が終わると老人は姿を消し、傘の下に小さなため息が落ちる:

しっかり生きろ、もう雨がやむのを待つな。

総Token数:3132
入力Token数:2922
出力Token数:210
キャッシュ入力Token数:0
推論Token数:0

あなた: 小説『星火列車』を探したい
アシスタント:

《星火列車》

真夜中の零時、廃駅には切符のない列車が停まり、車窓は星明かりで満ちている。

少年・遅野は家を出た父を探すため、こっそり最後尾の車両に乗り込んだ。車掌はこの列車は未完の別れだけを乗せるのだと告げる。停車するたび、乗客は会いたい人に一分だけ会えるのだ。

遅野は流星のトンネルを抜け、父が石炭ストーブのそばで懐中時計を修理しているのを見る。

父は、自分は離れたのではなく、車輪を動かす星火となって、息子の成長を見守っているのだと言う。

終点の前に、遅野は懐中時計を父に返した。

列車は再び走り出し、空には微かに輝く新しい星が一つ増え、遅野はようやく家へ帰ることを覚えた。

総Token数:3886
入力Token数:3674
出力Token数:212
キャッシュ入力Token数:0
推論Token数:6

{
    "stateBag": {
        "identifier": "00001",
        "InMemoryChatHistoryProvider": {
            "messages": [{
                "role": "user",
                "contents": [{
                    "$type": "text",
                    "text": "こんにちは、私は何の証書を持っていますか"
                }]
            }, {
                "role": "system",
                "contents": [{
                    "$type": "text",
                    "text": "**李明 | Backend Software Engineer** \r\n📍 上海, 中国 | 📞 +86 138-xxxx-xxxx | ✉️ liming@example.com | [GitHub](https://github.com/liming) | [LinkedIn](https://linkedin.com/in/liming)\r\n\r\n---\r\n\r\n### **個人概要**\r\n性別:男、1988年生まれ、計算機科学と技術専攻卒業。3年のバックエンド開発経験があり、Java と Python に精通し、システム設計、API 開発、性能最適化を得意とする。優れたエンジニアリング思考とチーム協働能力を備え、高品質で拡張性のあるコードを書くことを好む。マイクロサービスアーキテクチャに精通し、技術で実際の業務課題を解決することに取り組んでいる。\r\n\r\n---\r\n\r\n### **スキル一覧**\r\n- **プログラミング言語:** Java, Python, Go, SQL\r\n- **フレームワークと技術:** Spring Boot, Django, FastAPI, gRPC, Redis, Kafka\r\n- **データベース:** MySQL, PostgreSQL, MongoDB\r\n- **その他:** デザインパターン, RESTful API 設計, ロードバランシング, キャッシュ最適化\r\n\r\n---\r\n\r\n### **職務経歴**\r\n\r\n**バックエンドソフトウェアエンジニア | バイトダンス | 2022.05 - 現在 | 上海**\r\n- ショート動画配信システムのバックエンド開発を担当し、レコメンドサービス API を設計・最適化、システム QPS を 30% 向上。\r\n- Spring Cloud ベースのマイクロサービス分割プロジェクトを主導し、サービスリリースサイクルを 20% 短縮。\r\n- Redis + Kafka を用いてメッセージキャッシュと非同期処理を実現し、システム安定性を 99.99% に向上。\r\n- 詳細な技術文書を作成し、新人教育とチームのコードレビューを支援して、コード品質を保証。\r\n\r\n**バックエンド開発インターン | テンセント | 2021.01 - 2021.08 | 深圳**\r\n- 企業微信のバックエンドサービス開発に参加し、Django で内部管理ツールを開発、手作業時間を約 40% 削減。\r\n- SQL クエリを最適化し、平均応答時間を 20% 短縮し、後続の大規模展開の基礎を築いた。\r\n- フロントエンドエンジニアと協力し、複数機能モジュールの API 設計と連携検証を完了。\r\n\r\n---\r\n\r\n### **プロジェクト経歴**\r\n\r\n**高並行 EC セール秒殺システム(個人プロジェクト)**\r\n- Spring Boot + Redis + RabbitMQ で秒殺サービスを設計し、10万超の同時ユーザー購入を支援。\r\n- 在庫の事前差し引き、非同期注文、メッセージ補償メカニズムを実装し、過剰販売を効果的に防止。\r\n\r\n**スマートカスタマーサポートシステム(チームプロジェクト)**\r\n- FastAPI ベースでバックエンドサービスを構築し、外部 AI Q&A エンジンを接続、平均応答時間を 100ms 以内に制御。\r\n- モジュール化された API を設計し、フロントエンド連携を容易にして、製品開発効率を向上。\r\n\r\n---\r\n\r\n### **学歴**\r\n\r\n**上海交通大学 | 計算機科学と技術 | 学士 | 2017 - 2021**\r\n\r\n- 主な履修科目:データ構造とアルゴリズム、データベースシステム、オペレーティングシステム、コンピュータネットワーク\r\n- 受賞歴:ACM プログラミングコンテスト学内二等賞\r\n\r\n---\r\n\r\n### **証書**\r\n- アリババクラウド ACP 認定:クラウドコンピューティングエンジニア(2023)\r\n- Oracle 認定 Java プログラマ(OCP, 2022)"
                }],
                "additionalProperties": {
                    "_attribution": {
                        "sourceType": {
                            "value": "ChatHistory"
                        },
                        "sourceId": "Microsoft.Agents.AI.InMemoryChatHistoryProvider"
                    }
                }
            }, {
                "authorName": "WorkMate",
                "createdAt": "2026-07-24T06:33:41+00:00",
                "role": "assistant",
                "contents": [{
                    "$type": "text",
                    "text": "あなたには以下の2つの証書があります:\n\n1. **アリババクラウド ACP 認定:クラウドコンピューティングエンジニア**(2023年)\n2. **Oracle 認定 Java プログラマ(OCP)**(2022年)"
                }],
                "messageId": "chatcmpl-E53lVGwQOlhDSUdwGczdDfLLcn7Fy"
            }, {
                "role": "user",
                "contents": [{
                    "$type": "text",
                    "text": "最近、日本語能力試験 N2 に合格しました"
                }]
            }, {
                "authorName": "WorkMate",
                "createdAt": "2026-07-24T06:33:55+00:00",
                "role": "assistant",
                "contents": [{
                    "$type": "text",
                    "text": "おめでとうございます!あなたは最近、**日本語能力試験 N2 認定**を取得しました。\n\n現在の証書は以下のとおりです:\n\n1. **日本語能力試験 N2 認定**(最近取得)\n2. **アリババクラウド ACP 認定:クラウドコンピューティングエンジニア**(2023年)\n3. **Oracle 認定 Java プログラマ(OCP)**(2022年)"
                }],
                "messageId": "chatcmpl-E53ljo7x8f1Y7W6ZNseegQLF5vWOy"
            }]
        },
        "session_id": "dddadf86-f9d8-4e0c-a651-ec1be0457fe9"
    }
}

一、まず結論:圧縮は確かに効いているが、削減量は大きくない

実行結果を見ると、圧縮機能は失敗していません。前の 4 回のツール呼び出しグループには、次の 2 つの変化が現れています。

"isExcluded": true,
"excludeReason": "Collapsed by ToolResultCompactionStrategy"

同時に、除外された各ツール呼び出しグループの横には、新しい Summary メッセージが生成されています。つまり、フレームワークは次のように置き換えています。

元の FunctionCall + FunctionResult → 新しい Assistant Summary

ただし、この戦略がデフォルトで生成するのは「内容の要約」ではありません。

単に元のツール名とツールの返却値を、次のようなテキストへ再パッケージしているだけです。

[Tool Calls]
GetNovel:
 - 朝の霧が港を、未開封の手紙のように包み込んでいた……

小説本文は依然として完全に保持されています。

つまり、今回の圧縮で主に削減されているのは、関数呼び出しプロトコル、呼び出し引数、メッセージ構造、一部のメタデータであり、小説本文そのものは実質的に短くなっていません。

公式ドキュメントでも、ToolResultCompactionStrategy は古いツール呼び出しグループを簡潔な Assistant メッセージへ折りたたむが、デフォルト形式ではツール名とツールが返した結果を記録すると明記されています。ユーザーメッセージを削除することも、通常の Assistant テキスト回答を削除することもありません。

これが、今回の実験で Token が目に見えて下がらなかった核心的な理由です。

二、この Agent プログラムはどう動くのか?

このサンプルでは、GetNovel というツールが登録されています。

ユーザーが「小説『霧港来信』を探したい」と入力すると、Agent はおおよそ次の流れで処理します。

画像

ここで注意が必要なのは、1 回の

agent.RunAsync(input, session)

が、必ずしも 1 回の下位モデル呼び出しに対応するとは限らないことです。

この例では、モデルはまずどのツールを呼ぶかを判断し、ツール実行後に、その結果をもとに最終回答を生成します。したがって、1 回の Agent 実行には複数の中間メッセージと複数回のモデルサービス呼び出しが含まれる可能性があります。AgentResponse 自体も、ツール呼び出し、ツール結果、その他の中間過程メッセージを含められます。

そのため、response.Usage は「今回の Agent 実行でどれだけのリソースを消費したか」を表すのに適しており、「現在の会話履歴が合計で何 Token あるか」と直接みなすことはできません。

三、Message Group とは何か?

圧縮戦略が処理する基本単位は、単一メッセージではなく CompactionMessageGroup です。

フレームワークは、関連するメッセージを同じ論理グループにまとめます。たとえば、Assistant の関数呼び出しメッセージと、それに対応する Tool の関数結果メッセージは、1 つの ToolCall グループとして組み合わされます。

これは、ツール呼び出しの整合性を保つためです。

関数呼び出しだけを残して関数結果を削除したり、逆に関数結果だけを残して対応する呼び出し情報を削除したりすると、モデルに送るメッセージ構造が不正になる可能性があります。公式ドキュメントでは、この挙動をメッセージグループの原子性と呼び、関連メッセージは一緒に保持するか、一緒に除外するべきだとしています。

今回の出力では、主に 4 種類のグループが現れます。

グループ種類 表す内容 圧縮後の状態
User ユーザー入力 保持
ToolCall 関数呼び出しと関数戻り値 古いグループは除外
Summary 圧縮後に生成された代替メッセージ 保持
AssistantText 最終的にユーザーへ表示される回答 保持

isExcluded: true は、データがシリアライズ状態から物理的に削除されたことを意味しません。

これは、そのメッセージ群が状態としては残され、保存・診断・デバッグに使える一方で、モデルへ送るメッセージリストを構築する際にはスキップされることを示します。CompactionMessageIndex は全メッセージと除外されていないメッセージを別々に集計し、未除外のメッセージだけが圧縮後の投影結果に入ります。

言い換えると、元の素材は倉庫に残っているが、モデルの荷物箱にはもう入れない、というイメージです。

四、圧縮はいつ発火するのか?

コード中の発火条件は次のとおりです。

CompactionTriggers.TokensExceed(1000)

意味は、現在コンテキストに参加しているメッセージの Token 数が 1000 を超えたら、圧縮を試みる、ということです。

TokensExceed はあくまで発火条件であって、モデルリクエストの厳密な Token 上限ではありません。公式の定義では、未除外メッセージの Token 数が指定値を超えたときにトリガーが成立します。言い換えると、1000 というのは「圧縮後の入力が必ず 1000 Token 以下でなければならない」という意味ではありません。1000 Token を超えたら、圧縮可能な古いツール呼び出しグループを探し始める、という意味です。圧縮可能なツールグループが少ない場合や、置き換え後の Summary が依然として長い場合、最終的なコンテキストは 1000 Token を超え続ける可能性があります。

五、minimumPreservedGroups: 3 の役割は何か?

圧縮戦略にはもう 1 つパラメータがあります。

minimumPreservedGroups: 3

これは、Token 圧力がどれだけ強くても、少なくとも直近の 3 つの非システムメッセージグループは保持する、という意味です。これはハードな保護線です。圧縮後でも目標に達しない場合であっても、この保護された 3 グループをさらに処理することはできません。公式ドキュメントでは MinimumPreservedGroups を hard floor、つまり圧縮が越えてはいけない下限と定義しています。これが、5 回目の ToolCall にまだ次のように表示されている理由でもあります。

"isExcluded": false

それは直近のメッセージ範囲に属しており、まだ折りたためる履歴領域には入っていません。

その後の会話を続け、新しいメッセージグループが追加されると、会話ウィンドウは後ろへずれていき、そのグループのツール呼び出しが「より古いツール呼び出し」とみなされ、圧縮対象になる可能性があります。

六、実行結果から計算すると、実際に節約されたのは 62 Token だけ

tokenCount を使って、おおよその計算を直接行えます。

除外された前 4 つの ToolCall グループは、それぞれ 156 + 159 + 167 + 161 = 643

対応する 4 つの Summary は、それぞれ 140 + 144 + 151 + 146 = 581

したがって、4 回のツール呼び出しは圧縮後、およそ 643 - 581 = 62 Token の削減にとどまります。

元の 643 Token に対して、約 9.6% しか減っていません。

これは、圧縮ロジック自体は確かに動いているが、圧縮の効果は非常に限定的であることを示しています。

理由は明快です。デフォルトの Summary が、小説本文をほぼ完全に残しているからです。

『霧港来信』を例にすると、元のツール結果は「朝の霧が港を、未開封の手紙のように包み込んでいた……」でした。

圧縮後は次のようになります。

[Tool Calls]
GetNovel:
 - 朝の霧が港を、未開封の手紙のように包み込んでいた……

本文は 1 文字も減っておらず、関数呼び出し構造だけが単純になっています。

七、もう 1 つの隠れた問題:同じ小説が実質 2 回保存されている

各回の問い合わせで、小説本文はまずツール結果として現れます:FunctionResult

モデルがツール結果を読み取ったあと、さらに小説を整理して最終回答にします:AssistantText

すると、同じ小説が会話の意味上 2 回現れます。つまり、ツール結果中の完全な小説 + 最終回答中の完全な小説です。

ToolResultCompactionStrategy を適用すると、これが Summary 中の完全な小説 + AssistantText 中の完全な小説に変わります。

ツールメッセージは折りたたまれますが、重複内容は依然として残ります。

そして ToolResultCompactionStrategy は通常の Assistant テキストメッセージは処理しません。これは比較的穏やかな圧縮戦略で、古いツール呼び出しグループだけを対象にしています。

そのため、問い合わせ回数が増えるにつれて、履歴には小説本文が引き続き蓄積されます。

これで、なぜ各回の入力 Token が上がり続けるのか説明できます。

  • 1 回目の入力 Token: 518
  • 2 回目の入力 Token: 1325
  • 3 回目の入力 Token: 2130
  • 4 回目の入力 Token: 2922
  • 5 回目の入力 Token: 3674

圧縮は関数呼び出しの構造を少し削りましたが、実際にスペースを占める長文問題は解決していません。

八、なぜ TotalTokenCount だけで圧縮効果を判断できないのか?

プログラムが出力しているのは次の値です。

response.Usage.TotalTokenCount
response.Usage.InputTokenCount
response.Usage.OutputTokenCount

InputTokenCount は今回の応答生成に使われた入力 Token 数を表し、TotalTokenCount は応答生成に使用された総 Token 数を表します。

これらはリソース消費指標であって、圧縮器内部の状態指標ではありません。

ツール呼び出しを含む Agent では、1 回の実行で次のような過程をたどることがあります。

  • 1 回目のモデルリクエスト:関数呼び出しを判断し生成
  • 2 回目のモデルリクエスト:関数結果を読み取り最終回答を生成

2 回のリクエストはいずれも、一定のコンテキストを読み込みます。

そのため、今回表示された Input Token は、単一の圧縮後メッセージ一覧の長さではなく、Agent 実行全体で使われたリソースを反映している可能性があります。

圧縮効果を判断するには、少なくとも 2 つの観点を同時に見る必要があります。モデルが実際に消費した Usage と、圧縮インデックス中の IncludedTokenCount です。

前者はコスト分析、後者はコンテキストがどれだけ短くなったかの分析に使います。

九、圧縮前後の有効 Token をどう出力するか?

すでに次の階層を読み取れる状態になっています。

stateBag
 └─ ToolResultCompactionStrategy
     └─ messagegroups

既存コードに、集計関数を追加できます。

static (int StoredTokens, int IncludedTokens, int ExcludedTokens)
    GetCompactionTokenStats(
        JsonElement serializedSession,
        string propertyName)
{
    var storedTokens = 0;
    var includedTokens = 0;
    if (!serializedSession.TryGetProperty("stateBag", out var stateBag) ||
        !stateBag.TryGetProperty(propertyName, out var strategy) ||
        !strategy.TryGetProperty("messagegroups", out var messageGroups) ||
        messageGroups.ValueKind != JsonValueKind.Array)
    {
        return (0, 0, 0);
    }
    foreach (var group in messageGroups.EnumerateArray())
    {
        var tokenCount =
            group.TryGetProperty("tokenCount", out var tokenElement)
                ? tokenElement.GetInt32()
                : 0;
        var isExcluded =
            group.TryGetProperty("isExcluded", out var excludedElement) &&
            excludedElement.GetBoolean();
        storedTokens += tokenCount;
        if (!isExcluded)
        {
            includedTokens += tokenCount;
        }
    }
    return (
        StoredTokens: storedTokens,
        IncludedTokens: includedTokens,
        ExcludedTokens: storedTokens - includedTokens);
}

呼び出し方は次のとおりです。

var stats = GetCompactionTokenStats(
    serializedSession,
    "ToolResultCompactionStrategy");
Console.WriteLine($"圧縮インデックス内の全グループ Token: {stats.StoredTokens}");
Console.WriteLine($"引き続きコンテキストに参加する Token: {stats.IncludedTokens}");
Console.WriteLine($"除外されたグループの Token: {stats.ExcludedTokens}");

この結果は圧縮傾向を観察するのに適していますが、サーバーが返す Usage の代わりにはなりません。

一方は「メッセージ索引に何が残っているか」を示し、もう一方は「モデルリクエストで実際に何を消費したか」を示します。両者が解いている問題は異なります。

また、現在の API ドキュメントにはまだプレビューに関する説明があり、シリアライズ状態の内部フィールド構造は将来変更される可能性があります。そのため、stateBag.messagegroups を長期的に安定した業務データ契約として直接扱うことは推奨されません。

十、関数結果を本当に短くするにはどうすればよいか?

大きなツール結果に対しては、デフォルトの ToolResultCompactionStrategy だけでは不十分なことが多いです。

より有効なのは、カスタム ToolCallFormatter を提供することです。

公式 API では、次のものを通して:

Func<CompactionMessageGroup, string>

古いツール呼び出しグループをカスタムテキストへ変換できます。カスタムフォーマッターが提供されない場合にのみ、フレームワークはデフォルトの YAML 風結果を使います。

以下は簡単な例です。

var compactionStrategy =
    new ToolResultCompactionStrategy(
        trigger: CompactionTriggers.TokensExceed(4000),
        minimumPreservedGroups: 6,
        target: CompactionTriggers.TokensBelow(2500))
    {
        ToolCallFormatter = group =>
        {
            var text =
                ToolResultCompactionStrategy
                    .DefaultToolCallFormatter(group);
            const int maxChars = 400;
            if (text.Length <= maxChars)
            {
                return text;
            }
            return text[..maxChars] +
                "\n - [旧ツール結果は切り詰められ、先頭400文字のみ保持]";
        }
    };

ここでは、別の target を追加しています。

target: CompactionTriggers.TokensBelow(2500)

これは、圧縮が一度発火したあと、実効 Token が 2500 未満になるまで、あるいは条件に合うツールグループがもう存在しなくなるまで、古いツールグループの処理を継続しようとすることを意味します。

文字列切り詰めはあくまでデモ向けです。

本番環境では、ツールの種類に応じて本当に価値のある情報を抽出する方が推奨されます。たとえば、古い小説検索結果では次のような要素だけを残すことができます。

呼び出しツール:GetNovel
小説名:《霧港来信》
呼び出し結果:成功
内容テーマ:少年が灯台を通して行方不明の母を探す
本文全文:省略済み

データベース検索では、検索条件、件数、主要結論を残します。

検索ツールでは、クエリ語、ソース URL、最終結論を残します。

ファイル読み取りツールでは、ファイル名、ページ番号、段落位置、内容要約を残します。

本当に有効な圧縮は、単に「別のメッセージ形式に変える」ことではなく、次を決めることです。

将来の対話が、どの情報をまだ覚えておく必要があるのか。

十一、本番環境では階層的圧縮を採用すべき

ToolResultCompactionStrategy は、ツール呼び出し構造が重すぎる問題を解くのに適していますが、会話全体の圧縮は担当しません。

完全な本番向けの方案では、通常は階層的に処理する必要があります。

  • 第 1 層ではツール結果を処理し、古くなった API 応答、検索結果、ファイル内容を折りたたむ。
  • 第 2 層では古いユーザーメッセージと Assistant 応答を意味要約し、ユーザーの好み、重要な決定、業務状態、未完了タスクだけを残す。
  • 第 3 層ではスライディングウィンドウまたは切り詰め戦略を設定し、コンテキストがモデル上限に近づいたら最も古く、価値の低いメッセージを削除する。

Microsoft Agent Framework には、ツール結果圧縮、対話要約、スライディングウィンドウ、切り詰め、順次実行といった複数の圧縮戦略を組み合わせる Pipeline が用意されています。

全体像は次のようにまとめられます。

まずツール結果を圧縮
    ↓
次に古い対話を要約
    ↓
最後に価値の低い履歴を切り詰める

このうち、ツール圧縮は構造的な無駄を減らし、意味要約は長期記憶を残し、切り詰めはシステムがモデルのコンテキスト制限を超えないようにします。

この 3 つは、それぞれ解く問題が異なります。

十二、このサンプルで最も覚えておくべき 3 つのこと

第一に、isExcluded: true は圧縮がすでに効いていることを示します。

元のツール呼び出しグループは状態の中に残っていますが、もはやモデル入力には参加しません。圧縮は必ずしも物理削除を意味しません。

第二に、デフォルトのツール圧縮は意味要約ではありません。

デフォルト形式には、なおツールの完全な返却結果が含まれます。ツールが長文記事、Web ページ本文、大きな JSON、ファイル内容を返す場合、圧縮効果は非常に限られる可能性があります。

第三に、応答 Usage は現在の会話長ではありません。

それは今回の応答生成に消費したリソースを表します。関数呼び出し型 Agent では 1 回のターン内で複数回のモデル対話が発生するため、TotalTokenCount だけで圧縮成功の有無を判断することはできません。

まとめ

Agent の記憶管理は、本質的にはメッセージを残すか削除するかの単純な話ではありません。

本当に解くべき問題は 3 つです。

  • どの情報を長期保存しなければならないか?
  • どの情報はこのターンだけ使えればよいか?
  • どの情報は使用後すぐに要約や索引へ格下げできるか?

今回の小説サンプルでは、ToolResultCompactionStrategy は古いツール呼び出しを正しく Summary に折りたたみました。

しかし、デフォルト Summary がなお小説本文を完全に保持し、最終的な Assistant 応答も小説本文を 1 份保持しているため、会話全体の主要部分は実際にはあまり短くなっていません。

これが今回の実験で最も重要な結論です。圧縮戦略が実行されたかどうかと、コンテキストが本当に小さくなったかどうかは、別の問題です。

本番システムでは、Token のしきい値を 1 つ設定して終わりにしてはいけません。

ツールが返すデータ構造に応じて要約ルールを設計し、Assistant が大きなツール内容を繰り返し出力しないよう制御し、ツール圧縮に加えて対話要約と最終切り詰め戦略を追加する必要があります。

そうして初めて、Agent は記憶の連続性を保ちながら、コンテキスト長、応答遅延、モデル呼び出しコストを本当に制御できます。

(Translated by GPT)

元のリンク:https://mp.weixin.qq.com/s/CQD1rjHgcs6uiUrGhjamOA?token=1307544074&lang=zh_CN&wt.mc_id=MVP_325642

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?