8月11日、Swathidurgamが「Architectural Patterns for Managing Token Bloat in Stateful AI Agents」と題した記事を公開した。Google Cloudの公式パブリケーションとして掲載されたこの記事では、ターン2でAPIペイロードを1件注入しただけで入力トークンが+10,192増加し、セッション全体が30,000トークンを超えたという実測値とともに、ステートフルAIエージェントにおけるトークン肥大化の原因と抑制パターンが解説されている。
問題の核心:セッション状態がそのままプロンプトに入り込む
LLMを使ったエンタープライズ向けAIエージェント(トラッキング、サポート、フルフィルメント処理など)では、複数ターンの会話にわたって状態を保持する必要がある。この記事が対象とするのは、Google Cloudが提供するAgent Development Kit(ADK)やVertex AI Agent Builderといったステートフルエージェントフレームワークだ。これらのフレームワークでは、セッション変数は会話のターンごとにシリアライズされ、プロンプトのコンテキストに注入されるという共通の仕組みを持つ。
問題は、下流サービスから返ってきたAPIレスポンスをそのままセッション状態に格納するケースだ。トラッキングサービスやERPのAPIレスポンスには、次のようなデータが含まれることが多い。
- 数十件分のスキャン履歴ログ
- 詳細な住所オブジェクト・受取人プロフィール・地理座標
- 内部システムフラグ、デバッグパラメータ、不要なメタデータ
state["common_data"] = response.json() のようにレスポンスをそのまま格納すると、そのJSON文字列全体が以降のすべてのターンでLLMのプロンプトに再注入され続ける。
実測値:1回のAPI呼び出しで+10,000トークン超
記事では、具体的な計測結果が示されている。ターン2で生のAPIペイロードを1件注入したところ、入力トークンが+10,192増加し、セッション全体のトークン数が30,000を超えた。このコンテキストをターン3の前にクリアしたところ、レイテンシは3.60秒から1.97秒に低下した。
この数字が示すのは、トークン増加がコストだけでなくレイテンシに直接効いてくるという事実だ。LLMの推論コストは入出力トークン数に比例して線形スケールするため、多ターンのワークフローでは不要データが積み重なるほどAPIコストが増幅する。さらに、コンテキストウィンドウの上限に達すると、システム指示や初期の会話ターンがウィンドウから押し出されるリスクも生じる。
解決策:Pythonプルーニングヘルパーで注入前に刈り込む
単純にセッション変数を全消去すればいいわけではない。ADKのようなフレームワークでは、下流のPythonツールやハンドオフコーディネーター(会話を別のエージェントやモジュールへ引き継ぐ処理。ADKにおいては AgentTool や TransferToAgent などのメカニズムが該当する)は、context.state から特定フィールドを読み取って動作するため、セッションを空にすると経路制御やエスカレーション処理が壊れる。
目標は「下流ツールが必要とするメタデータだけを残し、冗長なデータ配列を除去する」こと。記事ではそのためのプルーニングヘルパー関数のパターンが紹介されている。
def prune_agent_state(raw_response: dict) -> dict:
"""Prunes heavy API payloads to essential fields before saving to session state."""
shipment_info = raw_response.get("fetchShipmentDataInfo", {})
track_details = shipment_info.get("trackDetails", [])
pruned_tracks = []
for track in track_details:
pruned_tracks.append({
"trackingNumber": track.get("trackingNumber"),
"serviceType": track.get("shipmentInfo", {}).get("serviceType"),
"keyStatus": track.get("shipmentInfo", {}).get("keyStatus"),
"scanEvents": track.get("scanEvents", [])[:5], # スキャン履歴を直近5件に制限
})
return {"trackingDetails": pruned_tracks}
ポイントは3つだ。
- APIレスポンス受信直後に変換する:
context.stateに格納する前にプルーニング関数を通す - スカラー値のみ保持:ロジック分岐やハンドオフに必要な
trackingNumber、serviceType、keyStatusなどの単純フィールドだけを残す - 配列は上限を設ける:スキャン履歴のような大きなコレクションは直近数件(上記では5件)に切り捨てる
本番環境でのトークン肥大化の検出:BigQueryクエリ
問題が発生しているセッションを特定するために、記事ではBigQueryによるテレメトリ分析のSQLクエリも紹介している。このクエリは、ツール実行の前後ターン(turn_index = 1 と turn_index = 2)の入力トークン数をJOINで突き合わせ、その差分を standalone_payload_tokens_delta として算出するものだ。なお、サブクエリ内の $"input token count" はBigQueryのJSONパスとして属性名を指定している。ペイロード注入による正確なトークン増分をターン単位で可視化できる点が実運用上の強みとなる。
WITH turn_tokens AS (
SELECT
t.conversation_id AS session_id,
SAFE_CAST(t.turn_index AS INT64) AS turn_index,
(
SELECT
COALESCE(SUM(SAFE_CAST(JSON_VALUE(cs_item.attributes, "$\"input token count\"") AS INT64)), 0)
FROM
UNNEST(t.root_spans) AS rs,
UNNEST(JSON_QUERY_ARRAY(rs.child_spans)) AS cs_item
) AS input_tokens
FROM `projectid.bigquerydataset.appid` AS t
WHERE
trim(t.conversation_id) = "session_id"
)
SELECT
curr.session_id,
curr.turn_index AS tool_injection_turn,
prev.input_tokens AS pre_tool_input_tokens,
curr.input_tokens AS post_tool_input_tokens,
(curr.input_tokens - prev.input_tokens) AS standalone_payload_tokens_delta
FROM turn_tokens curr
JOIN turn_tokens prev
ON curr.session_id = prev.session_id
AND curr.turn_index = prev.turn_index + 1
WHERE
curr.turn_index = 2;
このクエリをベースにターンごとのトークンデルタを監視することで、状態シリアライズの問題を早期に発見できる。テレメトリ監視とプルーニングヘルパーを組み合わせることが、実運用での対策として推奨されている。
詳細はArchitectural Patterns for Managing Token Bloat in Stateful AI Agentsを参照していただきたい。