![[アップデート] Strands Agents に Storage インターフェースが追加されました](https://images.ctfassets.net/ct0aopd36mqt/4o8n2qvRpfnsx0yGmKgDZr/1b05f3211bdf64deb5322bf5be42b202/StrandsAgents.png?w=3840&fm=webp)
[アップデート] Strands Agents に Storage インターフェースが追加されました
はじめに
コンサルティング部の神野です。
Strands Agents のドキュメントを眺めていたら、Storage というページに New マークが付いていました。
エージェント関連のデータを保存するための共通インターフェースで、Session Management や Context Offloader、Memory といったプラグインが裏でこの Storage を使う形になっています。開発者はバックエンドを 1 回選ぶだけです。
これを見て、以前 Context Offloader を検証したときにも同じ名前のストレージを触っていたことを思い出しました。
あのときは Context Offloader プラグインが自前で持っているストレージでした。
それが SDK 共通のインターフェースとして切り出された、ということのようです!
今回はこのアップデートで何が変わったのかを確認しつつ、InMemoryStorage と S3Storage を実際に動かして、何がどこに保存されるのかを見ていきます。
前提
検証環境は下記のとおりです。
| 項目 | バージョン・設定 |
|---|---|
| Python | 3.13.11 |
| strands-agents | 1.48.0 |
| モデル | Amazon Bedrock の Claude Haiku 4.5 |
| リージョン | us-east-1 |
Bedrock でモデルを呼べる状態と、S3 バケットを作成できる AWS 認証情報が手元にある前提で進めます。
uv init --python 3.13
uv add strands-agents boto3
Context Offloader そのものの仕組みは以前の記事で書いたので、本記事では省略します。ざっくり言うと、ツールの結果がしきい値を超えたらコンテキストに入れる前に外部ストレージへ退避して、会話履歴にはプレビューと参照キーだけを残してくれるプラグインです。
共通 Storage で何が変わったか
まず気になっていた差分から確認していきます。
以前の記事で使っていたのは、Context Offloader パッケージが持つ FileStorage でした。これは今も同じ場所に残っています。
from strands.vended_plugins.context_offloader import FileStorage
今回のドキュメントに出てくるのは、SDK 直下の共通 Storage です。バックエンドは InMemoryStorage / LocalFileStorage / S3Storage の 3 つが用意されています。
from strands.storage import InMemoryStorage, LocalFileStorage, S3Storage
紛らわしいことに、どちらのパッケージにも InMemoryStorage と S3Storage という同名のクラスが存在します。
違いはインターフェースで、前者は store と retrieve、後者は write / read / delete / list を持ちます。プラグイン側はメソッドの有無で両者を見分けていて、実装では以前のストレージが _LegacyStorage と呼ばれていました。互換のために残されている側ということですね。
インターフェースを表で並べるとこうなります。
| 以前の FileStorage | 共通 Storage | |
|---|---|---|
| インポート元 | vended_plugins.context_offloader | strands.storage |
| メソッド | store / retrieve | write / read / delete / list |
| 扱うデータ | bytes + Content-Type | bytes のみ |
| 使えるプラグイン | Context Offloader 専用 | Session Management や Memory とも共通 |
保存されるものを並べて比べる
説明だけだとピンとこないので、ストレージだけを差し替えた同じエージェントを両方で動かして、できあがったファイルを見比べます。使ったツールは 1,000 人分のユーザー JSON を返すもので、中身は次のセクションで載せます。
70 bytes ./artifacts-legacy/.metadata.json
189270 bytes ./artifacts-legacy/1785117571419_1_tooluse_uAzZomKfAU1SlfKN6CTgfD_0.txt
189282 bytes ./artifacts-unified/offloader/tooluse_b209HdLjoMKbt9KAtNZA2J_0
お、何か違いがありますね。
以前の FileStorage は .metadata.json という管理ファイルを別に持っていて、そこに Content-Type を記録していました。ファイル名にはタイムスタンプとサイクル数が入り、Content-Type に応じた拡張子も付きます。
対する共通の LocalFileStorage は、offloader/ というディレクトリの下にツール実行 ID そのままのファイルが 1 つあるだけです。管理ファイルはありません。共通 Storage は bytes を預かるだけのインターフェースなので、Content-Type を記録する場所がなく、代わりにプラグインがデータの先頭に埋め込む方式になりました。ファイルサイズが 189,270 バイトから 189,282 バイトへちょうど 12 バイト増えているのは、この埋め込み分のようです。
ストレージ側を薄くして、必要な情報はプラグインが自分で運ぶといった複数のプラグインから利用できるよう、Storage 側の責務を絞った構成だと理解しました!
4 メソッドと namespace
共通 Storage の使い方も確認します。
扱うのは bytes で、キーはスラッシュ区切りの文字列です。list はプレフィックス検索で、存在しないキーの read は例外ではなく None が返ります。
storage = InMemoryStorage()
await storage.write("users/alice.json", b'{"name": "Alice"}')
await storage.read("users/alice.json") # b'{"name": "Alice"}'
await storage.read("users/carol.json") # None
await storage.list("users/") # ['users/alice.json']
await storage.delete("users/alice.json") # 存在しないキーでもエラーにならない
# namespace: キーの先頭にプレフィックスを付けたビューを作る
ns = storage.namespace("tenant-a/")
await ns.write("config.json", b"{}") # 実体は tenant-a/config.json
Python では Storage は特定のクラスを継承する必要がなく、この 4 メソッドを async で実装したクラスを渡せば自前のバックエンドとしてそのまま動きます。TypeScript では interface として定義されているので、同じく 4 メソッドを実装すれば OK です。
InMemoryStorage で動かす
ここからは Context Offloader に渡して使ってみます。
検証に使うツールは、ユーザー 1,000 人分の JSON を返すものです。そのうち 2 人だけ admin ロールを混ぜて、ID 742 と 987 という後ろのほうに置きました。プレビューの範囲には収まらないので、エージェントは退避先を取りにいかざるをえない状況を作ります。
"""検証用に、わざと大きな結果を返すツール。"""
import json
from strands import tool
ROLES = ["user", "viewer", "editor", "user", "viewer"]
@tool
def list_users() -> str:
"""社内システムのユーザー一覧を取得する。"""
users = [
{
"id": i,
"name": f"user{i:03d}",
"email": f"user{i:03d}@example.com",
"role": "admin" if i in (742, 987) else ROLES[i % len(ROLES)],
"department": f"dept-{i % 12}",
"note": "このユーザーは社内システムの標準的な利用者です。",
}
for i in range(1, 1001)
]
return json.dumps({"users": users}, ensure_ascii=False)
エージェント側は Storage を作って渡すだけです。
"""ContextOffloader + InMemoryStorage で大きなツール結果を退避させる。"""
import asyncio
from strands import Agent
from strands.models import BedrockModel
from strands.storage import InMemoryStorage
from strands.vended_plugins.context_offloader import ContextOffloader
from tools import list_users
async def main() -> None:
storage = InMemoryStorage()
agent = Agent(
model=BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0"),
tools=[list_users],
plugins=[ContextOffloader(storage=storage)],
)
result = await agent.invoke_async("ユーザー一覧から、admin ロールのユーザーを全員挙げてください。")
print("最終回答:", result)
for key in await storage.list():
data = await storage.read(key)
print(f" {key}: {len(data):,} bytes")
if __name__ == "__main__":
asyncio.run(main())
uv run 02_offloader_inmemory.py
Tool #1: list_users
Tool #2: retrieve_offloaded_content
Tool #3: retrieve_offloaded_content
最終回答: 全体を見ると、adminロールのユーザーは **2名** です:
| ID | 名前 | メールアドレス | 部門 |
|-----|------|--------------|------|
| 742 | user742 | user742@example.com | dept-10 |
| 987 | user987 | user987@example.com | dept-3 |
offloader/tooluse_uEtG8LApvokM29jkIojFsa_0: 189,282 bytes
大きなツール結果が退避され、エージェントは retrieve_offloaded_content で退避したデータを読み直し、admin の 2 名を回答できています。storage には 189,282 バイトが保存されていますね。
S3Storage に差し替える
検証用のバケットを作ります。
export OFFLOAD_BUCKET=strands-offload-demo-$(aws sts get-caller-identity --query Account --output text)
aws s3api create-bucket --bucket "$OFFLOAD_BUCKET" --region us-east-1
aws s3api put-public-access-block --bucket "$OFFLOAD_BUCKET" \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
S3Storage の実行時に必要な IAM 権限は s3:PutObject、s3:GetObject、s3:DeleteObject、s3:ListBucket の 4 つです(バケットの作成や削除は別途必要です)。
コード側の変更は、InMemoryStorage を S3Storage に差し替えるだけです。
- from strands.storage import InMemoryStorage
+ from strands.storage import S3Storage
- storage = InMemoryStorage()
+ storage = S3Storage(BUCKET, prefix="tool-results", region_name="us-east-1")
agent = Agent(
model=BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0"),
tools=[list_users],
- plugins=[ContextOffloader(storage=storage)],
+ plugins=[ContextOffloader(storage=storage, evict_after_cycles=None)],
)
バックエンドの生成箇所を差し替えるだけで済むのは、write / read / delete / list という共通インターフェースで揃えてあるからこそですね。InMemoryStorage も S3Storage も同じ 4 メソッドを持っているので、プラグイン側は具体的な実装を知らずとも扱えます。
evict_after_cycles=None を付けたのは、退避したデータをあとから S3 の中身として確認したかったためです。デフォルトでは保存から 20 サイクルを超えた退避データは自動削除されます。
実行して、S3 側を見てみます。
uv run 03_offloader_s3.py
aws s3 ls "s3://$OFFLOAD_BUCKET/" --recursive --human-readable
2026-07-27 08:27:43 184.8 KiB tool-results/offloader/tooluse_84QjVEktPe9TtPT56xnGWu_0
ちゃんとオブジェクトになっていますね。指定した prefix の下にプラグインが付けた offloader/ が続き、その先はツール実行 ID とコンテンツブロックの番号です。ローカルファイルのときとまったく同じキー構造になっています。
別プロセスから読み直す
S3 に保管しているので、別のプロセスからも参照可能です。
別のスクリプトから退避データを読み直してみます。
"""エージェントを動かした別プロセスから、S3 に残った退避データを読み直す。"""
import asyncio
import json
import os
from strands.storage import S3Storage
BUCKET = os.environ["OFFLOAD_BUCKET"]
def unframe(frame: bytes) -> tuple[bytes, str]:
"""先頭 2 バイトの長さ + Content-Type を剥がして本体を取り出す。"""
ct_len = int.from_bytes(frame[:2], "big")
content_type = frame[2 : 2 + ct_len].decode("utf-8")
return frame[2 + ct_len :], content_type
async def main() -> None:
storage = S3Storage(BUCKET, prefix="tool-results", region_name="us-east-1")
for key in await storage.list():
frame = await storage.read(key)
body, content_type = unframe(frame)
users = json.loads(body)["users"]
print(f"{key}")
print(f" content-type : {content_type}")
print(f" users : {len(users)}")
print(f" admins : {[u['name'] for u in users if u['role'] == 'admin']}")
if __name__ == "__main__":
asyncio.run(main())
S3Storage を同じバケットと prefix で作り直して、list と read を呼ぶだけです。unframe は前のセクションで見た 12 バイトのフレームを剥がす処理で、Context Offloader 内部の保存形式に依存しています。保存形式は内部実装なので、アプリケーションから直接依存しないでください。エージェントからは retrieve_offloaded_content ツールで退避データを取得できます。
uv run 04_read_from_s3.py
offloader/tooluse_84QjVEktPe9TtPT56xnGWu_0
content-type : text/plain
users : 1000
admins : ['user742', 'user987']
退避された 1,000 件がそのまま読めましたね!
後片付け
検証用のバケットは消しておきます。
aws s3 rm "s3://$OFFLOAD_BUCKET" --recursive
aws s3api delete-bucket --bucket "$OFFLOAD_BUCKET"
どのプラグインが共通 Storage を使えるのか
公式ドキュメントの Storage ページでは Context Offloader、Session Management、Memory が挙がっていますが、実際にコードを確認したところ、状況は Python と TypeScript で異なりました。
| プラグイン | Python(1.48.0) | TypeScript |
|---|---|---|
| Context Offloader | 共通 Storage を受け取れる | 共通 Storage を受け取れる |
| Session Management | 専用クラス(FileSessionManager / S3SessionManager) | 共通 Storage を受け取れる |
| Memory | 独自インターフェース(MemoryStore) | 独自インターフェース(MemoryStore) |
Python で共通 Storage を渡せるのは、今のところ Context Offloader だけです。Session Management は FileSessionManager / S3SessionManager という専用クラスを使う形になっています。正直なところ、Python だけで開発している場合は現時点で共通 Storage の恩恵を感じる場面は限られるかもしれません。TypeScript 版では SessionManager が共通 Storage を受け取れるようになっているので、Python 側もそのうち追いつくかもしれませんね。
Memory は Python も TypeScript も、search / add / add_messages という独自のインターフェースで動いています。扱うデータの性質(ベクトル検索やメタデータ付きの記憶)が write / read / delete / list とは合わない気がするので、こちらが共通 Storage に統合されるのかわからないですね・・・気になります・・・
おわりに
以前の検証ではプラグイン専用のストレージでしたが、共通の Storage に切り出されました!
Python では今のところ Context Offloader だけですが、TypeScript では Session Management もすでに対応しています。
Storage は特定のクラスを継承しなくても、4 メソッドさえ揃っていれば自前のバックエンドとして使えるので、DynamoDB あたりで作ってみるのも面白そうです。
本記事が少しでも参考になりましたら幸いです。
最後までご覧いただきありがとうございました!










