[小ネタ] Strands Agents の Context Offloader を試してみた

[小ネタ] Strands Agents の Context Offloader を試してみた

2026.05.12

はじめに

こんにちは、スーパーマーケットが大好きなコンサル部の神野です。

皆さん、Strands Agentsの公式ドキュメントは読みますか?
私も定期的に読むのですが、ふとStrands Agents の公式ドキュメントを見ているとPluginsにNewと書かれた機能があって、気になりました。

CleanShot 2026-05-12 at 21.35.50@2x

Context Offloader は何者なんだ・・・実際に手を動かして検証してみようと思います!

前提

今回使用したバージョンは下記です。
モデルはコストがお安いHaiku君にします。

  • Python 3.12
  • strands-agents 1.39.0
  • Amazon Bedrock
    • Claude Haiku 4.5

まずは Context Offloader がどういった機能か確認してみます。

Context Offloader

Context Offloader は、ツールの実行結果が設定したトークン閾値を超えた場合に、結果を外部ストレージに退避して、コンテキスト内にはプレビュー(先頭の一部)と参照情報だけを残すプラグインです。

公式ドキュメントを確認すると、以下のように記載があります。

The ContextOffloader plugin prevents large tool results from consuming your agent's context window. When a tool returns a result that exceeds a configurable token threshold, the plugin stores each content block individually in an external storage backend and replaces it in the conversation with a truncated preview plus per-block references.

https://strandsagents.com/docs/user-guide/concepts/plugins/context-offloader/

なるほど・・・!データ分析などをツール実行経由で取得した結果が大きい場合に便利そうです。
コンテキストを圧迫しないためにも使いこなしたい機能ですね。

デフォルトのコンテキスト管理ではツール結果によるトークン数が、オーバーフロー時にツール結果を最初と最後の200文字に切り詰めますが、中間の情報が永久に失われる上にAPI呼び出し失敗後の事後処理となります。Context Offloader はツール実行時に先制的に介入するので、そこを解消してくれるわけですね。

動作の流れも整理してみます。

動作の流れ

  1. ツールが結果を返す
  2. プラグインがトークン数を推定し、max_result_tokens 閾値(デフォルト: 2,500トークン)と比較
  3. 閾値を超えた場合、結果を外部ストレージに保存
  4. コンテキストには先頭 preview_tokens トークン(デフォルト: 1,000)とストレージ参照のみを表示

プレビューだけではなくエージェントが全体のデータを必要とする場合は、プラグインが自動登録する retrieve_offloaded_content ツールを使って、参照からデータを取得できます。

ストレージバックエンド

3種類選べます。用途に分けて使い分けできそうですね。S3をサポートしているのは嬉しいなと感じました。

バックエンド 永続性 用途
InMemoryStorage プロセス終了時まで 開発・テスト
FileStorage ディスク ローカル開発・デバッグ
S3Storage Amazon S3 本番環境・共有保存

実装

セットアップ

まずはプロジェクトを作成して、Strands Agents SDK をインストールします。

実行コマンド
uv init context-offload
cd context-offload
uv add strands-agents

大きなレスポンスを返すカスタムツール

検証用に、大量のユーザーデータを生成するカスタムツールを作成しました。

tools.py
import json

from strands import tool

@tool
def generate_large_data(count: int = 100) -> str:
    """大量のユーザーデータを生成して返します。データ分析やレポート生成のシミュレーション用です。

    Args:
        count: 生成するユーザー数(デフォルト: 100)
    """
    users = []
    for i in range(1, count + 1):
        users.append(
            {
                "id": i,
                "name": f"User_{i}",
                "email": f"user{i}@example.com",
                "department": ["Engineering", "Sales", "Marketing", "Support", "HR"][i % 5],
                "role": ["Manager", "Senior", "Junior", "Lead", "Intern"][i % 5],
                "salary": 50000 + (i * 1000),
                "projects": [f"Project_{j}" for j in range(1, (i % 5) + 2)],
                "skills": [f"Skill_{j}" for j in range(1, (i % 3) + 4)],
                "location": ["Tokyo", "Osaka", "Nagoya", "Fukuoka", "Sapporo"][i % 5],
                "joined_date": f"202{i % 5}-{(i % 12) + 1:02d}-{(i % 28) + 1:02d}",
            }
        )

    return json.dumps({"total": count, "users": users}, ensure_ascii=False, indent=2)

200件のデータを生成すると約85KBのJSONになります。

Context Offloader なしのエージェント

まず、Context Offloader を使わない場合のエージェントです。

without_offloader.py
from strands import Agent
from strands.models import BedrockModel
from tools import generate_large_data

model = BedrockModel(
    model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
    region_name="us-east-1",
)

agent = Agent(
    model=model,
    tools=[generate_large_data],
    system_prompt="あなたはデータ分析アシスタントです。ユーザーの質問に日本語で回答してください。",
)

result = agent(
    "generate_large_dataツールを使って200件のユーザーデータを取得し、部署ごとの人数を集計してください。"
)

for i, cycle in enumerate(result.metrics.agent_invocations[0].cycles):
    print(f"サイクル{i+1}: input={cycle.usage['inputTokens']}, output={cycle.usage['outputTokens']}")
print(f"合計: {result.metrics.agent_invocations[0].usage}")

Strands Agents では result.metrics.agent_invocations[0].cycles にサイクル(LLM呼び出し)ごとのトークン使用量が記録されています。これを使ってContext Offloaderの効果を数値で確認していきます。

Context Offloader ありのエージェント

次に、Context Offloader を有効にしたバージョンです。

with_offloader.py
from strands import Agent
from strands.models import BedrockModel
from strands.vended_plugins.context_offloader import ContextOffloader, FileStorage
from tools import generate_large_data

model = BedrockModel(
    model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",
    region_name="us-east-1",
)

offloader = ContextOffloader(
    storage=FileStorage("./artifacts"),
    max_result_tokens=2_500,
    preview_tokens=1_000,
)

agent = Agent(
    model=model,
    tools=[generate_large_data],
    plugins=[offloader],
    system_prompt="あなたはデータ分析アシスタントです。ユーザーの質問に日本語で回答してください。",
)

result = agent(
    "generate_large_dataツールを使って200件のユーザーデータを取得し、部署ごとの人数を集計してください。"
)

for i, cycle in enumerate(result.metrics.agent_invocations[0].cycles):
    print(f"サイクル{i+1}: input={cycle.usage['inputTokens']}, output={cycle.usage['outputTokens']}")
print(f"合計: {result.metrics.agent_invocations[0].usage}")

使い方としてはContextOffloaderFileStorage を import して、plugins に渡すだけです。シンプルですね。

設定項目は以下の通りにしました。今回はローカルのファイルとして結果を保存してみます。
また、全文を取得するための retrieve_offloaded_content ツールはデフォルトで有効になっているため、その挙動も確認してみます。

設定項目 デフォルト値 説明
storage (必須) ストレージバックエンドのインスタンス
max_result_tokens 2,500 オフロードを実行するトークン閾値
preview_tokens 1,000 コンテキストに残すプレビューのトークン数
include_retrieval_tool True retrieve_offloaded_content ツールを自動登録するか

準備完了です!2つを比較しつつ挙動を確認していきます!

動作確認

Context Offloader なしの場合

まずはContext Offloader なしの場合を試してみます。

実行コマンド
uv run python without_offloader.py
実行結果
generate_large_dataツールを使って200件のユーザーデータを取得します。
Tool #1: generate_large_data
完璧です!200件のユーザーデータを取得できました。部署ごとに人数を集計します。

## 部署ごとの人数集計結果

取得した200件のユーザーデータを部署別に集計すると、以下のようになります:

| 部署 | 人数 |
|------|------|
| **Engineering** | 40名 |
| **Sales** | 40名 |
| **Marketing** | 40名 |
| **Support** | 40名 |
| **HR** | 40名 |
| **合計** | **200名** |

問題なく回答できていますね! サイクルごとのトークン使用量を確認してみます。

サイクルごとのトークン使用量
サイクル1: input=897,   output=80 プロンプト送信、ツール呼び出しを決定
サイクル2: input=32,615, output=422 85KBのJSON全体がコンテキストに入る
合計: inputTokens=33,512, outputTokens=502

サイクル2で inputTokens が 32,615 に跳ね上がっています。200件のユーザーデータ(約85KB)がまるごとコンテキストウィンドウに入っているためですね。

Context Offloader ありの場合

次にContext Offloader ありの場合を試してみます。

実行コマンド
uv run python with_offloader.py
実行結果
200件のデータを取得して、部署ごとの人数を集計します。
Tool #1: generate_large_data
データが大きいため、オフロードされています。参照を使用して完全なデータを取得します。
Tool #2: retrieve_offloaded_content
完全なデータが取得できました。200件のデータから部署ごとの人数を集計いたします。

## 部署ごとの人数

| 部署 | 人数 |
|------|------|
| **Sales** | 40名 |
| **Marketing** | 40名 |
| **Support** | 40名 |
| **HR** | 40名 |
| **Engineering** | 40名 |
| **合計** | **200名** |

データから5つの部署に均等に配置されており、各部署に40名ずつ配置されています。

同じ回答が得られました。 Tool #2: retrieve_offloaded_content が呼ばれていますね。
エージェントがプレビューだけでは集計に足りないと判断して、自分でオフロード先からデータを取得しています。

サイクルごとのトークン使用量も見てみます。

サイクルごとのトークン使用量
サイクル1: input=1,018,  output=80 プロンプト送信、ツール呼び出しを決定
サイクル2: input=2,747,  output=121 プレビューのみ!エージェントが全文取得を判断
サイクル3: input=34,506, output=358 retrieve_offloaded_content で全文取得
合計: inputTokens=38,271, outputTokens=559

サイクル2は32,615トークン → 2,747トークンと削減されていますね。Context Offloader がツール結果をファイルに退避して、プレビュー(先頭約1,000トークン)と参照情報だけをコンテキストに残してくれているのがわかります。

エージェントが実際に見ているプレビュー

サイクル2でエージェントに渡されたツール結果を会話履歴から覗いてみると、以下のような内容になっていました。

オフロード後にエージェントが受け取ったツール結果
[Offloaded: 1 blocks, ~21,301 tokens]
Tool result was offloaded to external storage due to size.
Use the preview below to answer if possible.
Use your available tools to selectively access the data you need.
You can also use retrieve_offloaded_content with a reference to get the full content.

{
  "total": 200,
  "users": [
    {
      "id": 1,
      "name": "User_1",
      "email": "user1@example.com",
      "department": "Sales",
      ...
    },
    ...(先頭約1,000トークン分のプレビュー)
  ]
}

[Stored references:]
  artifacts/1778588187704_1_tooluse_xxx_0.txt (text, 85,201 chars)

85,201文字のJSON全体ではなく、オフロードされた旨のガイダンス + 先頭のプレビュー + ストレージ参照だけが渡されています。エージェントはこの情報を見てプレビューだけで回答できるか、全文を取得する必要があるかを判断している流れです。

ただし、今回のケースでは部署ごとの人数を集計してというタスクに全データが必要だったため、エージェントが retrieve_offloaded_content ツールを使って全文を取得しました。その結果、サイクル3で34,506トークンとなり、トータルではオフロードなしの場合より多くなっています。

指示やユースケースによって使い分けないとトークン数が多くなるケースもありそうですね。銀の弾丸のように、何にでも使うのはあまりよろしくなさそうです。

オフロードされたファイルの確認

./artifacts ディレクトリにオフロードされたデータが保存されているのでこちらも確認してみます。

実行コマンド
ls -la ./artifacts/
実行結果
-rw-r--r--  70B  .metadata.json
-rw-r--r--  85K  1778588187704_1_tooluse_4CxnHQahp8G9LyDp6HfRFe_0.txt

85KBのデータがファイルとして保存されていますね! .metadata.json にはコンテンツタイプの情報が記録されています。

.metadata.json
{"1778588187704_1_tooluse_4CxnHQahp8G9LyDp6HfRFe_0.txt": "text/plain"}

オフロードされたファイルの中身も覗いてみます。

1778588187704_1_tooluse_4CxnHQahp8G9LyDp6HfRFe_0.txt(抜粋)
{
  "total": 200,
  "users": [
    {
      "id": 1,
      "name": "User_1",
      "email": "user1@example.com",
      "department": "Sales",
      "role": "Senior",
      "salary": 51000,
      "projects": [
        "Project_1",
        "Project_2"
      ],
      "skills": [
        "Skill_1",
        "Skill_2",
        "Skill_3",
        "Skill_4"
      ],
      "location": "Osaka",
      "joined_date": "2021-02-02"
    },
    ...
  ]
}

ツールが返したJSONがそのままファイルに保存されています。ファイル名にはタイムスタンプとtoolUseIdが含まれていて、どのツール呼び出しの結果かが追跡できるようになっています。

プレビューだけで回答できるケースも試してみましょうか。

プレビューだけで回答できるケース

プレビューだけで十分回答できる質問を投げてみます。フォーマットを返してとお願いしてみます。

preview_only.py
agent = Agent(
    model=model,
    tools=[generate_large_data],
    plugins=[offloader],
    system_prompt="あなたはデータ分析アシスタントです。ユーザーの質問に日本語で簡潔に回答してください。",
)

result = agent(
    "generate_large_dataツールで200件のデータを取得して、データのフォーマット(どんなフィールドがあるか)を教えてください。"
)

for i, cycle in enumerate(result.metrics.agent_invocations[0].cycles):
    print(f"サイクル{i+1}: input={cycle.usage['inputTokens']}, output={cycle.usage['outputTokens']}")
print(f"合計: {result.metrics.agent_invocations[0].usage}")
実行結果
200件のデータを取得して確認します。
Tool #1: generate_large_data
200件のデータを取得しました。以下がデータのフォーマットです:

## ユーザーデータのフィールド構成

生成されたデータには、以下の10個のフィールドが含まれています:

1. id (数値) - ユーザーの一意識別子
2. name (文字列) - ユーザー名(例:User_1)
3. email (文字列) - メールアドレス
4. department (文字列) - 所属部門
   - 例:Sales、Marketing、Support、HR、Engineering
5. role (文字列) - 職位
   - 例:Senior、Junior、Lead、Intern、Manager
6. salary (数値) - 給与金額
7. projects (配列) - 関連プロジェクト一覧
8. skills (配列) - 所有スキル一覧
9. location (文字列) - 勤務地
   - 例:Tokyo、Osaka、Nagoya、Fukuoka、Sapporo
10. joined_date (日付文字列) - 入社日

このデータは従業員情報の管理や分析に適した構造になっています。

エージェントはプレビュー(先頭約1,000トークン)だけを見て、フォーマットの説明に十分なデータが揃っていると判断して回答してくれていますね!データフォーマットは最初のトークン部分だけでもわかりますもんね。

サイクルごとのトークン使用量
サイクル1: input=1,029, output=70 プロンプト送信、ツール呼び出しを決定
サイクル2: input=2,748, output=343 プレビュー + 会話コンテキストのみで回答
合計: inputTokens=3,777, outputTokens=413, totalTokens=4,190

エージェントが全データが必要かどうかを自分で判断して、必要なときだけ全文を取得することを体験できましたね。このケースでは取得しませんでした。
ケースによってはトークン削減につながりそうです。

今回の検証を踏まえると、ツール結果が大きくてもプレビューだけで回答できるケースでは効果的だなと感じました。複数回にわたるツール利用が行われる会話かつプレビューで完結するなら有用な印象です。

おわりに

プレビューだけで回答できるケースはトークン削減が実際に確認できてユースケースによっては活用できそうだなと感じました。

一方で、今回の部署ごとに集計するような全データが必要なタスクでは、retrieve_offloaded_content で結局全文を取得するため、トータルのトークン消費は逆に増えるケースもありました。タスクの性質に応じて使用するかどうか、プレビューでのトークン数をどれだけ採用するかなどチューニングすることも大事なポイントですね。

なお、Context Offloader は個々の大きなツール結果を外部に逃がすための仕組みであり、会話全体の履歴肥大化を解決するものではありません。長い複数やり取りに及ぶような会話では、会話管理の仕組みと組み合わせて考える必要があります。履歴をどう持たせるかは検討の余地が多くて、奥が深いですね。

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

この記事をシェアする

関連記事