Amazon Bedrock AgentCore Memoryのパーサーを実装した話
はじめに
こんにちは。
最近、Amazon Bedrock AgentCore(Short-term Memory)を使ったフルスタックWebアプリケーションの開発に携わっていたところ、会話履歴の取得で問題に遭遇しました。
aws-samplesのリポジトリにプルリクエストを作成していた際、AgentCore Memoryから取得した会話履歴のレスポンス形式が場合によって異なることに気づいたのです。
本記事では、その調査結果と、両形式に対応したTypeScriptのパーサーの実装方法を共有します。
問題の発見
AgentCore Memoryから会話履歴を取得すると、イベントのペイロードに2種類の形式が存在することがわかりました。
AWS SDKのListEvents API(Boto3 / AWS SDK for JavaScript)を使って会話履歴を取得すると、各イベントのpayload配列内にconversationalまたはblobのいずれかが含まれています。
conversational形式の例
短いメッセージはconversational形式で返されます。
{
"memoryId": "runtime_memory-qahoKzGelo",
"actorId": "7a8b9c0d-1234-5678-90ab-cdef12345678",
"sessionId": "b3c4d5e6-7890-12ab-cdef-567890abcdef",
"eventId": "0000001769229133174#4a877f8b",
"eventTimestamp": "2026-01-24T13:32:13.174000+09:00",
"payload": [
{
"conversational": {
"content": {
"text": "{\"message\": {\"role\": \"assistant\", \"content\": [{\"text\": \"【ここにAIエージェントまたはユーザーのメッセージが入ります】\"}]}, \"message_id\": 0, \"redact_message\": null, \"created_at\": \"2026-01-24T04:32:13.174830+00:00\", \"updated_at\": \"2026-01-24T04:32:13.174834+00:00\"}"
},
"role": "ASSISTANT"
}
}
],
"branch": {
"name": "main"
}
}
※content.text内のJSONをパースするとmessage.content[0].textにメッセージが格納されています。
blob形式の例
長いメッセージはblob形式で返されます。
{
"memoryId": "runtime_memory-qahoKzGelo",
"actorId": "7a8b9c0d-1234-5678-90ab-cdef12345678",
"sessionId": "b3c4d5e6-7890-12ab-cdef-567890abcdef",
"eventId": "0000001769229124052#c9532449",
"eventTimestamp": "2026-01-24T13:32:04.052000+09:00",
"payload": [
{
"blob": "[\"{\\\"message\\\": {\\\"role\\\": \\\"user\\\", \\\"content\\\": [{\\\"text\\\": \\\"【ここにAIエージェントまたはユーザーのメッセージが入ります】\\\"}]}, \\\"message_id\\\": 0, \\\"redact_message\\\": null, \\\"created_at\\\": \\\"2026-01-24T04:32:04.052828+00:00\\\", \\\"updated_at\\\": \\\"2026-01-24T04:32:04.052831+00:00\\\"}\", \"user\"]"
}
],
"branch": {
"name": "main"
}
}
※blobキー配下の文字列は二重エスケープされたJSON配列です。パースするとmessage.content[0].textにメッセージが格納されています。
conversational形式とblob形式の違い
| 項目 | conversational形式 | blob形式 |
|---|---|---|
| アクセス方法 | payload[0].conversational |
payload[0].blob |
| データ構造 | オブジェクト(contentとrole) |
文字列(二重エスケープJSON配列) |
どのような条件でblob形式になるのかについては、AWS公式ドキュメントのListEvents API Referenceにも、Developer Guideにも、payload内のcontent | blobという2つの形式については詳細な説明が見つかりませんでした。
2つの形式の切り替わりの条件を確認するために調査を開始しました。
調査方法
メッセージサイズによって形式が変わるという仮説を立て、3段階の調査を実施しました。
はじめに2KB~10KBの範囲を500バイト刻みでスキャンし、おおよその閾値範囲を特定しました。
次に8.0KB~9.0KBを100バイト刻みで精密にスキャンし、最後に8.6KB~8.7KBを10バイト刻みでスキャンすることで最終的な閾値を確認しました。
調査の段階ではStrands Agentsはローカル環境で動かしており、AgentCore Runtimeは使用していません。
検証用コード
調査には以下の処理を組み合わせました。ソースコードの抜粋を掲載します。
1. Shell scriptによるStrands Agents呼び出し部分
#!/bin/bash
# ランダムな文字列を生成してAgentCore Memoryに保存
for size in 8000 8100 8200 8300 8400 8500 8600 8700 8800 8900 9000; do
# ランダムプロンプト生成
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c $size > prompt_${size}b.txt
# Strands Agentsに送信
cat prompt_${size}b.txt | python3 strands_agent.py > output_${size}b.json
sleep 2
done
# 会話履歴を取得
./get_conversation_history.sh -a $ACTOR_ID -s $SESSION_ID -o conversation_history.json
2. Strands AgentsでのAgentCore Memory読み書き
from strands import Agent
from strands_tools import retrieve
from strands.models import BedrockModel
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager
AGENTCORE_MEMORY_ID = "【AgentCore Memory IDを記載】"
MODEL_ID = "us.anthropic.claude-sonnet-4-20250514-v1:0"
def create_agent(session_id: str, actor_id: str) -> Agent:
model = BedrockModel(model_id=MODEL_ID)
agentcore_memory_config = AgentCoreMemoryConfig(
memory_id=AGENTCORE_MEMORY_ID,
session_id=session_id,
actor_id=actor_id,
)
session_manager = AgentCoreMemorySessionManager(
agentcore_memory_config=agentcore_memory_config,
)
agent = Agent(
model=model,
tools=[retrieve],
system_prompt="You're a helpful assistant.",
session_manager=session_manager,
)
return agent
3. tiktokenによるトークン数計算
import tiktoken
def count_tokens(text: str) -> int:
encoding = tiktoken.get_encoding("cl100k_base")
tokens = encoding.encode(text)
return len(tokens)
調査結果
プロンプトサイズによる閾値(実測値)
3段階の調査により閾値を特定できました。
| テストサイズ | バイト数 | トークン数 | ペイロード形式 | 備考 |
|---|---|---|---|---|
| 8.4KB | 8419 bytes | 6034 tokens | conversational | |
| 8.5KB | 8519 bytes | 6158 tokens | conversational | |
| 8.6KB | 8619 bytes | 6173 tokens | conversational | 最大のconversational形式 |
| 8.7KB | 8719 bytes | 6265 tokens | blob | 最小のblob形式 |
| 8.8KB | 8819 bytes | 6298 tokens | blob | |
| 8.9KB | 8919 bytes | 6384 tokens | blob |
今回の調査により、conversational形式からblob形式に切り替わる閾値は、バイト数で8619~8719 bytesの間、トークン数で6173~6265 tokensの間であることを確認しました。
8.6KB(6173 tokens)まではconversational形式、8.7KB(6265 tokens)以上になるとblob形式で保存されます。
パーサーの実装
調査結果を基に両形式に対応したTypeScriptのパーサーを実装しました。
本パーサーはWebフロントエンドでAgentCore Memoryの会話履歴を表示することを目的としています。
はじめに、全体の処理フローを説明します。
パーサー関数本体
パーサー関数本体では、引数でAWS SDK for JavaScriptのEvent型の配列を受け取り、内部でパース処理を呼び出します。
import { Event } from '@aws-sdk/client-bedrock-agentcore';
export interface Message {
type: 'user' | 'agent';
content: string;
timestamp: Date;
}
export function parseSessionEvents(events: Event[]): Message[] {
return [...events]
.reverse() // 昇順でソート
.map(parseEvent) // 各イベントをパース
.filter((msg): msg is Message => msg !== null) // パース結果からnullのレコードを除外
.reduce(mergeConsecutiveMessages, []); // メッセージをマージ
}
この関数は以下の4ステップで処理します。
ステップ1: イベントを古い順に並び替え
AgentCore Memoryは新しい順にイベントを返すため、.reverse()で配列を反転させて古い順に並び替えます。
ステップ2: 各イベントをパース
パース処理本体です。ここが最も複雑な処理となります。
2つのペイロード形式への対応
conversational/blob、どちらの形式が来ても処理できるようにキー名を用いて処理を分岐させます。
function parseEvent(event: Event): Message | null {
if (!event.payload?.[0]) {
return null;
}
const timestamp = event.eventTimestamp
? new Date(event.eventTimestamp)
: new Date();
const payloadEntry = event.payload[0];
// conversational形式を処理
if ('conversational' in payloadEntry && payloadEntry.conversational) {
return parseConversationalPayload(payloadEntry.conversational, timestamp);
}
// blob形式を処理
if ('blob' in payloadEntry && payloadEntry.blob) {
if (typeof payloadEntry.blob !== 'string') {
return null;
}
return parseBlobPayload(payloadEntry.blob, timestamp);
}
return null;
}
次に、各形式に対応したパース処理を呼び出します。
conversational形式のパース
conversational形式は値部分がJSON文字列のため、そのままパース可能です。
function parseConversationalPayload(
conversational: Conversational,
timestamp: Date
): Message | null {
try {
// JSONパース
const messageData: ParsedMessageData = JSON.parse(
conversational.content?.text || '{}'
);
// roleをマッピング(USER/ASSISTANT → user/agent)
const messageType = mapRoleToMessageType(conversational.role || '');
if (!messageType) {
return null;
}
return buildMessage(messageData, messageType, timestamp);
} catch (err) {
console.warn('Failed to parse conversational payload:', err);
return null;
}
}
function mapRoleToMessageType(role: string): 'user' | 'agent' | null {
const normalizedRole = role.toUpperCase();
if (normalizedRole === 'USER') return 'user';
if (normalizedRole === 'ASSISTANT') return 'agent';
return null;
}
blob形式のパース
blob形式は二重にエスケープされたJSON文字列になっています。
そのため、2回パース処理を実行する必要があります。
function parseBlobPayload(blobString: string, timestamp: Date): Message | null {
try {
// 1. 外側の配列をパース
const blobArray: unknown = JSON.parse(blobString);
// 2. 配列構造を検証
if (!Array.isArray(blobArray) || blobArray.length < 2) {
return null;
}
// 3. 最初の要素(messageJson)を取得
const firstElement = blobArray[0];
if (typeof firstElement !== 'string') {
return null;
}
const messageJsonString: string = firstElement;
// 4. messageJsonをパース
const messageDataRaw: unknown = JSON.parse(messageJsonString);
if (typeof messageDataRaw !== 'object' || messageDataRaw === null) {
return null;
}
const messageData = messageDataRaw as ParsedMessageData;
// 5. 2番目の要素(role)を取得
const secondElement = blobArray[1];
if (typeof secondElement !== 'string') {
return null;
}
const role: string = secondElement;
// 6. roleをマッピング
const messageType = mapRoleToMessageType(role);
if (!messageType) {
return null;
}
// 7. フィルタリングして返却
return buildMessage(messageData, messageType, timestamp);
} catch (err) {
console.warn('Failed to parse blob payload:', err);
return null;
}
}
blob形式の構造を図解:
blobString (文字列)
│
└─ JSON.parse()
│
├─ blobArray[0] (文字列) ← これが二重エスケープされたmessageJson
│ │
│ └─ JSON.parse()
│ │
│ └─ messageData (オブジェクト)
│ └─ message.content[]
│
└─ blobArray[1] (文字列) ← role ("user" or "assistant")
toolUse/toolResultのフィルタリング
各パース処理が完了したら、フロントエンドへの表示では不要となるtoolUse/toolResultの除外処理を呼び出します。
ListEvents API内のEventの構造:
Event(会話履歴)
├─ USER: プロンプト → 表示する
├─ ASSISTANT: toolUse → 除外
│ └─ toolUse.name
│ └─ toolUse.input
├─ USER: toolResult → 除外
│ └─ toolResult.toolUseId
│ └─ toolResult.content or toolResult.blob
└─ ASSISTANT: レスポンス → 表示する
└─ text
ステップ3: 空のレコード(null)を除外
.filter((msg): msg is Message => msg !== null)により、パースに失敗したイベント(null)を除外します。
ステップ4: 連続する同一送信者のメッセージをマージ
.reduce(mergeConsecutiveMessages, [])により、連続する同一送信者(user または agent)のメッセージを1つにまとめます。
retrieveなどのツールを使用すると、最初にLLMが「~について調べます。」→Tool use実行→結果を返却という流れになります。この時、「~について調べます。」と結果の返却は本来1つのレスポンスとして表示されるべきところ、Tool useのレコードにより分割されるため、マージ処理を実装しました。
この処理がないとStrands Agentsから直接受け取ったレスポンスと、AgentCore Memoryのレスポンスで表示に差異が生じます。
補足: Tool Resultによるblob形式
Strands Agentsのstrands_toolsからretrieveをインポートして使用すると、Knowledge Base検索結果がで長大な文字列が返ることで、Tool Result部分がblob形式で保存されるケースを確認しました。
from strands_tools import retrieve
agent = Agent(
model=model,
tools=[retrieve],
...
)
3回retrieveツールを呼び出した際の実測値:
- 66,077 bytes(約47,198 tokens)
- 72,572 bytes(約51,837 tokens)
- 80,999 bytes(約57,856 tokens)
注意: これらの値は雑多なドキュメントが含まれたKnowledge Baseでの検索結果であり、環境によって大きく異なります。
まとめ
本記事では、Amazon Bedrock AgentCore Memoryから会話履歴を取得する際に遭遇した、conversational形式とblob形式という2種類のペイロード形式について調査しました。
調査の結果、メッセージサイズが6173~6265 tokens(8619~8719 bytes)を境にconversational/blob形式が切り替わることを特定しました。
また、両形式に対応したTypeScriptのパーサーを実装しました。
Discussion