![[アップデート] AgentCore Memory の長期記憶でカスタムメタデータフィルタを使ってみた](https://images.ctfassets.net/ct0aopd36mqt/7M0d5bjsd0K4Et30cVFvB6/5b2095750cc8bf73f04f63ed0d4b3546/AgentCore2.png?w=3840&fm=webp)
[アップデート] AgentCore Memory の長期記憶でカスタムメタデータフィルタを使ってみた
はじめに
こんにちは、スーパーマーケットが大好きなコンサル部の神野(じんの)です。
気づいたらGWももう終わりですね・・・早い・・・
前回の記事では、AgentCore Memory の短期記憶のカスタムメタデータフィルタを実装してみました。
今回は長期記憶のメタデータもどう設定して、フィルタリングするのか確認してみます。
公式ドキュメントを眺めていると、長期記憶側の RetrieveMemoryRecords にも metadataFilters というパラメータが用意されています。直近のアップデートで追加された様子です。
MetadataFilters — The metadata filters to apply. You can only specify filter keys that are declared as indexed keys in the memory resource.
ドキュメントを見ると「宣言された indexed key だけがフィルタに使える」と書いてあるのですが、短期記憶のイベントに付けたキーがそのまま長期記憶側でも連携されるのか、それとも Memory 作成時に何か宣言が必要なのか、ドキュメントだけでは少し読み取りづらいですね・・・そこで実際に手を動かして理解を深めていきます!
前提
今回の検証で使用した環境やバージョンです。
| 項目 | バージョン |
|---|---|
| Python | 3.13 |
| boto3 / botocore | 1.43.2 |
| AWSリージョン | us-east-1 |
uv でセットアップする
依存関係の管理は uv で進めていきます。プロジェクトディレクトリで初期化して、必要なパッケージを追加します。
# プロジェクト初期化
uv init --python 3.13
# 依存パッケージの追加(uv add すると仮想環境とロックファイルは自動で整います)
uv add boto3
古い SDK だと新しいパラメーターが反映されていないので、uv add boto3 で最新化しておくと安心です。
実装
比較用の Memory を 2 つ用意する
ここでポイントになるのが indexedKeys と metadataSchema という 2 つの設定です。
indexedKeys でフィルタに使えるキーを定義し、metadataSchema でどんなキーをどう抽出してメタデータとして記憶するかを定義する、という分担になっています。
| 設定 | スコープ | 役割 |
|---|---|---|
indexedKeys |
Memory リソース全体 | フィルタに使えるキーを定義する設定。RetrieveMemoryRecords の metadataFilters で指定できるのは、ここに登録したキー(と AWS 側が付けるシステムキー)だけになります |
metadataSchema |
長期記憶の戦略 | 短期記憶のイベントから、どのキーを抽出して長期記憶レコードのメタデータに反映するかを LLM の抽出ルールごと定義する設定。indexedKeys に登録していないキーも定義できますが、その場合は metadataFilters での絞り込みには使えません |
この 2 つを設定して、長期記憶側のフィルタが機能するようになります。
そこで挙動の違いを見るために、これらを何も設定しない素の Memory(構成 A)と、両方を設定した Memory(構成 B)の 2 パターンを用意して比べていきます。
構成 A — 素の Memory(宣言なし)
import boto3
control = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
res = control.create_memory(
name="MetadataFilterBlogMinimal",
eventExpiryDuration=30,
memoryStrategies=[
{"userPreferenceMemoryStrategy": {
"name": "BlogUserPreferenceMinimal",
"namespaces": ["/users/{actorId}/preferences"],
}},
],
)
print(res["memory"]["id"])
uv run create_memory_minimal.py
MetadataFilterBlogMinimal-XXXXXXXXXX
戦略を定義しているだけの最小構成です。
構成 B — indexedKeys + metadataSchema を宣言
今回検証で扱う destination はユーザーの旅行・出張の目的地を表すメタデータキーです。
indexedKeys と metadataSchema でも同じ destination を抽出対象として定義することで、Memory と戦略の両方が destination を使用する作りにしています。
import boto3
control = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
destination_schema_entry = {
"key": "destination",
"type": "STRING",
"extractionConfig": {
"llmExtractionConfig": {
"definition": "Travel destination as a lowercase city code.",
"llmExtractionInstruction": "LATEST_VALUE",
"validation": {
"stringValidation": {"allowedValues": ["kyoto", "tokyo"]},
},
}
},
}
res = control.create_memory(
name="MetadataFilterBlogIndexed",
eventExpiryDuration=30,
indexedKeys=[{"key": "destination", "type": "STRING"}],
memoryStrategies=[
{"userPreferenceMemoryStrategy": {
"name": "BlogUserPreferenceIndexed",
"namespaces": ["/users/{actorId}/preferences"],
"memoryRecordSchema": {"metadataSchema": [destination_schema_entry]},
}},
],
)
print(res["memory"]["id"])
uv run create_memory_indexed.py
MetadataFilterBlogIndexed-XXXXXXXXXX
スキーマ設計の定義を memoryRecordSchema といったフィールドで行います。
配下の metadataSchema にメタデータフィールドを MetadataSchemaEntry として具体的な定義を記載します。MetadataSchemaEntry 自体は key / type / extractionConfig の 3 つで構成されており、抽出ルールの本体はさらにその下の llmExtractionConfig に集約されています。
llmExtractionConfig で設定するのは definition(必須)/llmExtractionInstruction/validation の 3 つで、この設定でどのようにメタデータが抽出されるのかが変わります。今回のスキーマで指定した内容と対応させると下記のようになります。
| フィールド | 役割 |
|---|---|
key / type |
レコード側に載せるキー名と型(STRING / STRINGLIST / NUMBER) |
definition |
LLM に何を抽出すべきかを伝える自然言語の定義(必須) |
llmExtractionInstruction |
値の決定方法。ビルトインは LATEST_VALUE(後述)、もしくは自然言語のカスタム指示を渡す |
validation.stringValidation.allowedValues |
STRING 用の許容値リスト。今回は kyoto と tokyo のみ設定を許可する |
validation.stringListValidation.allowedValues / maxItems |
STRINGLIST 用の許容値リストと最大要素数 |
validation.numberValidation.minValue / maxValue |
NUMBER 用の値域 |
ところでLATEST_VALUEって何でしょう・・・?解説していきます。
LATEST_VALUE って何
LATEST_VALUE の挙動は公式ガイドの「How the LLM resolves conflicts across events」で説明されています。同じセッションの複数イベントから同じキーが抽出されて値が衝突したとき、直近イベントの値をそのままレコードに残す、というコンフリクト解決ルールです。
公式の例で言うと、最初のイベントから会話の優先度である priority=low が抽出されたあと、後続のイベントで priority=critical に上がった場合、LATEST_VALUE を指定したレコードには critical が残ります。最新の値で上書きしていく、シンプルなルールです。
これ以外の振る舞いが欲しいときは、自然言語のカスタム指示で表現できます。たとえば「Keep the highest severity reported during the session(セッションで報告された最も高い深刻度を残す)」のように、ドメインに合わせたコンフリクト解決ルールを記述する形となります。
用語が多く難しいですね・・・ざっと理屈がわかったところで早速試してみましょう。
イベントを投入する
両方の Memory に同じ 4 件のイベントを CreateEvent で書き込みます。同じスクリプトを使い回したいので、MEMORY_ID は環境変数で受け取るようにしておきます。
import os
import boto3
from datetime import datetime, timezone
MEMORY_ID = os.environ["MEMORY_ID"]
ACTOR_ID = "blog-actor-001"
SESSION_ID = "blog-session-001"
client = boto3.client("bedrock-agentcore", region_name="us-east-1")
events = [
("旅行の宿は和室が好きです。京都の和菓子も巡りたい。",
{"category": "travel", "destination": "kyoto", "priority": "high"}),
("出張で東京に行くときは新幹線のグリーン車を選びがちです。",
{"category": "travel", "destination": "tokyo", "priority": "medium"}),
("コーヒーは深煎りのブラックが好みです。",
{"category": "food", "topic": "coffee", "priority": "low"}),
("ホテルは静かなクラブフロアが好きで、朝食は和食派です。",
{"category": "travel", "destination": "kyoto", "priority": "high"}),
]
for text, md in events:
ev = client.create_event(
memoryId=MEMORY_ID, actorId=ACTOR_ID, sessionId=SESSION_ID,
eventTimestamp=datetime.now(timezone.utc),
payload=[{"conversational": {"content": {"text": text}, "role": "USER"}}],
metadata={k: {"stringValue": v} for k, v in md.items()},
)
print("created", ev["event"]["eventId"])
MEMORY_ID=MetadataFilterBlogMinimal-XXXXXXXXXX uv run create_events.py
MEMORY_ID=MetadataFilterBlogIndexed-XXXXXXXXXX uv run create_events.py
created 0000001XXXXXXXXXXXX#xxxxxxxx
created 0000001XXXXXXXXXXXX#xxxxxxxx
created 0000001XXXXXXXXXXXX#xxxxxxxx
created 0000001XXXXXXXXXXXX#xxxxxxxx
抽出される時間として1〜2 分ほど待って、長期記憶を検索してみます。
長期記憶(RetrieveMemoryRecords)で絞り込む
retrieve_memory_records にも metadataFilters というパラメータがあるのですが、ここで許容されるキーはMemory に宣言した indexed key とシステムメタデータだけです。自動抽出では、戦略の metadataSchema に載っているキーだけがレコードに伝播します。
つまり、構成 A と構成 B で結果が変わってくるわけです。順番に試してみましょう。
構成 A で試す
短期記憶と同じ要領で destination=kyoto でクエリを実行してみます。
import boto3
from botocore.exceptions import ClientError
# 構成 A(indexedKeys なし)の Memory ID を指定
MEMORY_ID = "MetadataFilterBlogMinimal-XXXXXXXXXX"
ACTOR_ID = "blog-actor-001"
client = boto3.client("bedrock-agentcore", region_name="us-east-1")
ns = f"/users/{ACTOR_ID}/preferences"
try:
res = client.retrieve_memory_records(
memoryId=MEMORY_ID, namespace=ns,
searchCriteria={
"searchQuery": "好み", "topK": 10,
"metadataFilters": [
{"left": {"metadataKey": "destination"},
"operator": "EQUALS_TO",
"right": {"metadataValue": {"stringValue": "kyoto"}}},
],
},
maxResults=10,
)
print("hits =", len(res.get("memoryRecordSummaries", [])))
except ClientError as e:
print(type(e).__name__, ":", e)
uv run retrieve_minimal.py
botocore.errorfactory.ValidationException: Filter key 'destination' is not a valid filter key
エラーになりましたね。indexedKeys で宣言していないキーをフィルタに渡すと、こうしてバリデーションエラーになります。ちなみに list_memory_records で確認しても、メタデータに設定されていたのは x-amz-agentcore-memory-* 系のシステムキーだけでした。
次の構成 B で試しましょう。
構成 B で試す
続いて構成 B の Memory に対しても確認していきます。先ほど MEMORY_ID=MetadataFilterBlogIndexed-XXXXXXXXXX で create_events.py を実行済みなので、こちらも 1〜2 分待って、抽出されたレコードに destination が付与されているか、まず list_memory_records で確認してみます。
import boto3
# 構成 B(indexedKeys + metadataSchema を宣言した Memory)の ID を指定
MEMORY_ID = "MetadataFilterBlogIndexed-XXXXXXXXXX"
ACTOR_ID = "blog-actor-001"
client = boto3.client("bedrock-agentcore", region_name="us-east-1")
ns = f"/users/{ACTOR_ID}/preferences"
res = client.list_memory_records(
memoryId=MEMORY_ID, namespace=ns, maxResults=20,
)
for r in res.get("memoryRecordSummaries", []):
print(r)
uv run list_records.py
返ってきたレコードのうち 1 件目を抜粋するとこんな形です。
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーが明示的に旅行の宿は和室が好きだと述べています。\",\"preference\":\"旅行の宿は和室が好き\",\"categories\":[\"旅行\",\"宿泊\"]}"
},
"namespaces": ["/users/blog-user-xxxxxxxxx/preferences"],
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "kyoto"},
"x-amz-agentcore-memory-createdAt": {"stringValue": "Wed May 06 05:08:15 UTC 2026"},
"x-amz-agentcore-memory-updatedAt": {"stringValue": "Wed May 06 05:08:15 UTC 2026"}
}
}
おお、metadata.destination にもしっかり kyoto が登録されていますね!
今度は検索してみます。 retrieve_memory_records を metadataFilters 付きで呼び出して、destination=kyoto のレコードだけを取り出してみます。
import boto3
import json
# 構成 B の Memory ID を指定(list_records.py と同じ ID)
MEMORY_ID = "MetadataFilterBlogIndexed-XXXXXXXXXX"
ACTOR_ID = "blog-actor-001"
client = boto3.client("bedrock-agentcore", region_name="us-east-1")
ns = f"/users/{ACTOR_ID}/preferences"
res = client.retrieve_memory_records(
memoryId=MEMORY_ID, namespace=ns,
searchCriteria={
"searchQuery": "旅行", "topK": 10,
"metadataFilters": [
{"left": {"metadataKey": "destination"},
"operator": "EQUALS_TO",
"right": {"metadataValue": {"stringValue": "kyoto"}}},
],
},
maxResults=10,
)
print("hits =", len(res.get("memoryRecordSummaries", [])))
for r in res.get("memoryRecordSummaries", []):
pref = json.loads(r["content"]["text"])["preference"]
dest = r["metadata"]["destination"]["stringValue"]
print(f" {pref} / destination = {dest}")
uv run retrieve_indexed.py
content.text を json.loads してパースし、preference フィールドだけ抜き出して並べて表示しています。
hits = 6
旅行の宿は和室が好き / destination = kyoto
京都の和菓子巡りに興味がある / destination = kyoto
ホテルは静かなクラブフロアが好き / destination = kyoto
コーヒーは深煎りのブラックが好き / destination = kyoto
朝食は和食派 / destination = kyoto
コーヒー豆はエチオピア産が好み / destination = kyoto
destination=kyoto のレコードが 6 件返ってきました!フィルタが効いて、すべての metadata.destination が kyoto になっています。
コンテンツに無関係でも metadata が埋まってしまうケース
ところでヒットしたレコードをよく見ると、コーヒー関連の好み(「深煎りのブラックが好き」「エチオピア産が好み」)まで destination=kyoto で引っかかっていますね。
スキーマの definition には "Travel destination as a lowercase city code." と書いてあるので、てっきり無関係なコンテンツでは destination は埋まらないだろう・・・と期待してしまうのですが、実際は違いました。
気になったので、コーヒーの会話だけのセッションで切り出して再現させてみます。構成 B の Memory(indexedKeys + metadataSchema 宣言済み)に対し、新しい actor / session でコーヒーの発話 3 件のみを投入し、抽出を待ったうえで list_memory_records でレコードのメタデータを確認するスクリプトです。
import boto3
import time
from datetime import datetime, timezone
# 構成 B(indexedKeys + metadataSchema を宣言した Memory)の ID を指定
MEMORY_ID = "MetadataFilterBlogIndexed-XXXXXXXXXX"
ACTOR_ID = "blog-actor-coffee"
SESSION_ID = "blog-session-coffee"
client = boto3.client("bedrock-agentcore", region_name="us-east-1")
events = [
("コーヒーは深煎りのブラックが好きです。", {"category": "food", "topic": "coffee"}),
("豆はエチオピア産が好みです。", {"category": "food", "topic": "coffee"}),
("朝はマグカップ一杯から始めることが多いです。", {"category": "food", "topic": "coffee"}),
]
for text, md in events:
client.create_event(
memoryId=MEMORY_ID, actorId=ACTOR_ID, sessionId=SESSION_ID,
eventTimestamp=datetime.now(timezone.utc),
payload=[{"conversational": {"content": {"text": text}, "role": "USER"}}],
metadata={k: {"stringValue": v} for k, v in md.items()},
)
# LLM 抽出はバックグラウンドで走るので待機(環境次第で 1〜2 分ほど)
time.sleep(120)
ns = f"/users/{ACTOR_ID}/preferences"
res = client.list_memory_records(memoryId=MEMORY_ID, namespace=ns, maxResults=20)
records = res.get("memoryRecordSummaries", [])
with_destination = [r for r in records if "destination" in (r.get("metadata") or {})]
values = [r["metadata"]["destination"]["stringValue"] for r in with_destination]
print(f"records={len(records)}, records_with_destination={len(with_destination)}")
print(f" destination values observed: {values}")
uv run probe_destination.py
destination のメタデータも、旅行を示す文脈も一切ないコーヒー会話だけのセッションです。
records=2, records_with_destination=2
destination values observed: ['kyoto']
見事にコーヒーの好みにも destination=kyoto が付与されていますね・・・!
おそらくは validation.stringValidation.allowedValues で ["kyoto", "tokyo"] に絞ったことで、LLM が内容に直接の手がかりがなくても、許可されたいずれかの値を埋めにいく挙動になっているようです。
unknown を allowedValues に追加して再検証
逃げ道として unknown を allowedValues に追加すればいいのかな?と思ったので、もう一段検証してみました。同じコーヒー会話セッションを使って、下記 3 パターンを比較します。
| パターン | allowedValues |
llmExtractionInstruction |
|---|---|---|
| ベースライン(再現) | ["kyoto", "tokyo"] |
LATEST_VALUE |
unknown 追加 |
["kyoto", "tokyo", "unknown"] |
LATEST_VALUE |
unknown + カスタム指示 |
["kyoto", "tokyo", "unknown"] |
カスタム自然言語(後述) |
3 パターン目のカスタム指示は下記を渡しています。
Only output 'kyoto' or 'tokyo' when the user explicitly mentions a travel destination matching that city. Otherwise output 'unknown'.
検証スクリプトはパターンごとに Memory を作成し、コーヒーの発話 3 件を投入したうえで、抽出後の destination 値を集計しています。
import boto3
import time
from datetime import datetime, timezone
REGION = "us-east-1"
control = boto3.client("bedrock-agentcore-control", region_name=REGION)
data = boto3.client("bedrock-agentcore", region_name=REGION)
# 検証する 3 パターン
PATTERNS = [
("BaselineLATESTVALUE",
["kyoto", "tokyo"],
"LATEST_VALUE"),
("WithUnknown",
["kyoto", "tokyo", "unknown"],
"LATEST_VALUE"),
("WithUnknownAndInstruction",
["kyoto", "tokyo", "unknown"],
"Only output 'kyoto' or 'tokyo' when the user explicitly mentions a travel destination matching that city. Otherwise output 'unknown'."),
]
EVENTS = [
"コーヒーは深煎りのブラックが好きです。",
"豆はエチオピア産が好みです。",
"朝はマグカップ一杯から始めることが多いです。",
]
def create_memory(label, allowed_values, instruction):
schema_entry = {
"key": "destination",
"type": "STRING",
"extractionConfig": {
"llmExtractionConfig": {
"definition": "Travel destination as a lowercase city code.",
"llmExtractionInstruction": instruction,
"validation": {"stringValidation": {"allowedValues": allowed_values}},
}
},
}
res = control.create_memory(
name=f"ProbeUnknown{label}",
eventExpiryDuration=30,
indexedKeys=[{"key": "destination", "type": "STRING"}],
memoryStrategies=[{
"userPreferenceMemoryStrategy": {
"name": f"ProbeStrat{label}",
"namespaces": ["/users/{actorId}/preferences"],
"memoryRecordSchema": {"metadataSchema": [schema_entry]},
}
}],
)
return res["memory"]["id"]
def probe(memory_id, label):
actor = f"probe-actor-{label}"
session = f"probe-session-{label}"
for text in EVENTS:
data.create_event(
memoryId=memory_id, actorId=actor, sessionId=session,
eventTimestamp=datetime.now(timezone.utc),
payload=[{"conversational": {"content": {"text": text}, "role": "USER"}}],
)
# LLM 抽出待ち
time.sleep(120)
ns = f"/users/{actor}/preferences"
res = data.list_memory_records(memoryId=memory_id, namespace=ns, maxResults=20)
values = [
r["metadata"]["destination"]["stringValue"]
for r in res.get("memoryRecordSummaries", [])
if "destination" in (r.get("metadata") or {})
]
print(f"[{label}] destination values: {values}")
if __name__ == "__main__":
memories = []
for label, allowed, instruction in PATTERNS:
mid = create_memory(label, allowed, instruction)
print(f"created {label}: {mid}")
memories.append((mid, label))
# Memory が ACTIVE になるまで待機(実測で 2〜3 分)
print("Memory ACTIVE 待ち")
time.sleep(180)
for mid, label in memories:
probe(mid, label)
uv run probe_destination_with_unknown.py
結果はこんな感じになりました。
[BaselineLATESTVALUE] destination values: ['tokyo', 'tokyo', 'tokyo']
[WithUnknown] destination values: ['unknown', 'unknown', 'unknown']
[WithUnknownAndInstruction] destination values: ['unknown', 'unknown']
まずベースラインですが、先ほどの検証では kyoto を返してきたのに対し、今回は tokyo を返してきました。LLM が許可値の中から無理やり選んでいるので、どちらに倒れるかは実行ごとに変わりそうですね。
一方、unknown を allowedValues に追加した時点で、LLM はあっさりとコーヒーの好みに destination=unknown を返すようになりました。llmExtractionInstruction をカスタム自然言語にしたパターンも同じく unknown に揃っています。許可値に「該当なし」を意味する選択肢をひとつ入れておくだけで挙動が落ち着くのは嬉しいですね!
validation.allowedValues で値を絞るときは、該当しない場合の逃げ道(unknown / none 等)が必要かどうか考慮しておく必要がありそうですね。
validation を外して、指示だけで制御してみる
公式ガイドの例を眺めていると、agent_type のように validation 無しで definition + llmExtractionInstruction だけで運用しているスキーマが出てきます。
"agent_type": "Prefer the most specialized agent type. Hierarchy: specialist > tier3 > tier2 > tier1 > bot."
allowedValues を付けずに、選択肢を指示文の中にそのまま列挙している形ですね。旅行に関する会話と無関係な会話を混ぜたセッションでどう値が設定されるか試してみます。
投入するイベントは下記の 4 件です。
| 発話 | 期待される destination |
|---|---|
| 金閣寺と清水寺を巡る旅をしてみたい | kyoto(地名は出ていないがランドマークから推論) |
| 次の出張は東京タワー近くのホテルに泊まる予定 | tokyo |
| 週末はコーヒーを淹れて読書するのが好き | (旅行と無関係) |
| 在宅勤務だと運動不足になりがち | (旅行と無関係) |
スキーマは validation を外したうえで、llmExtractionInstruction を 3 パターン用意します。
| パターン | llmExtractionInstruction |
|---|---|
| A | LATEST_VALUE(指示なし相当) |
| B | カスタム指示(旅行先のみ抽出するよう指示) |
| C | カスタム指示(公式 agent_type 例のように、kyoto / tokyo / osaka の選択肢を指示文に列挙) |
C の指示はこんな形です。
Choose exactly one of the following lowercase city codes that the user explicitly mentions as a travel destination: kyoto, tokyo, osaka. If the user does not mention any of these cities as a travel destination, do not produce a value for this field.
結果はこちら。
[A: LATEST_VALUE のみ] records=3 destination_values=['kyoto', 'kyoto', 'tokyo']
[B: カスタム指示] records=4 destination_values=['kyoto', 'tokyo']
[C: 選択肢を明示] records=4 destination_values=['tokyo', 'kyoto']
各レコードの destination がどう付与されたかを表でまとめると、こんな感じです。
| 発話 | A | B | C |
|---|---|---|---|
| 金閣寺と清水寺を巡る旅 | kyoto | kyoto | kyoto |
| 東京タワー近くのホテル | tokyo | tokyo | tokyo |
| コーヒーを淹れて読書 | kyoto(巻き込み) | (空) | (空) |
| 在宅勤務で運動不足 | レコード化されず | (空) | (空) |
A のように LATEST_VALUE だけだと、旅行と無関係なコーヒーの会話にまで destination=kyoto が紐づいてしまいました。同じセッションに京都の会話があったため、巻き込まれたみたいですね。
一方、B と C のように「旅行先以外は値を出力しない」と指示文で明確に伝えると、旅行の会話だけが正しく kyoto / tokyo で埋まり、無関係な会話は空のまま残りました。特に C は公式のサンプルと同じ「指示文に選択肢を列挙する」スタイルですが、allowedValues 無しでも狙い通り動いています。
なので validation を外すかどうかは指示文をきちんと書くかどうかとセットで考えるとよさそうですね。
逆に最低限の definition だけだと表記揺れや意図せぬタグを付与するケースもありそうなので要注意です。
各レコードの実行ログ(抜粋)
パターン A は無関係なコーヒー読書の発話にまで destination=kyoto が紐づいているのに対し、B / C ではそのレコードの metadata から destination キー自体が消えているのが見て取れます。
[
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは金閣寺と清水寺を巡る旅をしてみたいと明示的に述べています。これらは京都の観光名所であり、京都への旅行と寺院観光の好みを示しています。\",\"preference\":\"金閣寺と清水寺を巡る旅行に興味がある\",\"categories\":[\"旅行\",\"観光\",\"寺院\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "kyoto"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは週末にコーヒーを淹れて読書するのが好きだと明示的に述べています。\",\"preference\":\"週末はコーヒーを淹れて読書することを好む\",\"categories\":[\"趣味\",\"飲み物\",\"読書\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "kyoto"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは東京タワー近くのホテルに泊まる予定だと明示的に述べています。これは東京への旅行の好みを示しています。\",\"preference\":\"東京タワー近くのホテルに宿泊することを好む\",\"categories\":[\"旅行\",\"宿泊\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "tokyo"}
}
}
]
[
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは金閣寺と清水寺を巡る旅をしてみたいと明確に述べており、京都への旅行の意向を示しています。\",\"preference\":\"金閣寺と清水寺を巡る旅をしてみたい\",\"categories\":[\"旅行\",\"観光\",\"寺院\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "kyoto"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは次の出張で東京タワー近くのホテルに泊まる予定であると明確に述べており、東京への訪問予定を示しています。\",\"preference\":\"東京タワー近くのホテルに泊まる予定\",\"categories\":[\"旅行\",\"出張\",\"宿泊\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "tokyo"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは週末にコーヒーを淹れて読書することを好むと明確に述べています。\",\"preference\":\"週末はコーヒーを淹れて読書するのが好き\",\"categories\":[\"趣味\",\"飲み物\",\"読書\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは在宅勤務による運動不足を懸念していると明確に述べています。\",\"preference\":\"在宅勤務だと運動不足になりがちで困っている\",\"categories\":[\"健康\",\"運動\",\"仕事\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"}
}
}
]
[
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは金閣寺と清水寺を巡る旅をしてみたいと明示的に述べています。これらは京都の有名な観光地です。\",\"preference\":\"金閣寺と清水寺を巡る旅に興味がある\",\"categories\":[\"旅行\",\"観光\",\"寺院\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "kyoto"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは次の出張で東京タワー近くのホテルに泊まる予定であることを明示的に述べています。\",\"preference\":\"次の出張では東京タワー近くのホテルに宿泊予定\",\"categories\":[\"旅行\",\"宿泊\",\"出張\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"},
"destination": {"stringValue": "tokyo"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは週末にコーヒーを淹れて読書することが好きだと明示的に述べています。\",\"preference\":\"週末はコーヒーを淹れて読書するのが好き\",\"categories\":[\"趣味\",\"読書\",\"飲み物\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"}
}
},
{
"memoryRecordId": "mem-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"content": {
"text": "{\"context\":\"ユーザーは在宅勤務により運動不足になることに困っていると明示的に述べています。\",\"preference\":\"在宅勤務による運動不足が懸念事項\",\"categories\":[\"健康\",\"運動\",\"仕事\"]}"
},
"metadata": {
"x-amz-agentcore-memory-recordType": {"stringValue": "BASE"}
}
}
]
検証スクリプト(probe_no_validation_with_travel.py)
import json
import time
from datetime import datetime, timezone
import boto3
REGION = "us-east-1"
SUFFIX = str(int(time.time()))
def run_probe(instruction: str, label: str) -> dict:
control = boto3.client("bedrock-agentcore-control", region_name=REGION)
data = boto3.client("bedrock-agentcore", region_name=REGION)
# validation は意図的に未指定
destination_schema_entry = {
"key": "destination",
"type": "STRING",
"extractionConfig": {
"llmExtractionConfig": {
"definition": "Travel destination as a lowercase city code (for example: kyoto, tokyo, osaka).",
"llmExtractionInstruction": instruction,
}
},
}
created = control.create_memory(
name=f"TravelProbe{label}{SUFFIX}",
eventExpiryDuration=30,
indexedKeys=[{"key": "destination", "type": "STRING"}],
memoryStrategies=[{
"userPreferenceMemoryStrategy": {
"name": f"{label}Strat{SUFFIX}",
"namespaces": ["/users/{actorId}/preferences"],
"memoryRecordSchema": {"metadataSchema": [destination_schema_entry]},
}
}],
)
memory_id = created["memory"]["id"]
# Memory が ACTIVE になるまで待機
while control.get_memory(memoryId=memory_id)["memory"]["status"] != "ACTIVE":
time.sleep(5)
actor_id = f"travel-{label}-{SUFFIX}"
session_id = f"session-{label}-{SUFFIX}"
events = [
"金閣寺と清水寺を巡る旅をしてみたいです。",
"次の出張は東京タワー近くのホテルに泊まる予定です。",
"週末はコーヒーを淹れて読書するのが好きです。",
"在宅勤務だと運動不足になりがちで困っています。",
]
for text in events:
data.create_event(
memoryId=memory_id, actorId=actor_id, sessionId=session_id,
eventTimestamp=datetime.now(timezone.utc),
payload=[{"conversational": {"content": {"text": text}, "role": "USER"}}],
)
# LLM 抽出待ち
ns = f"/users/{actor_id}/preferences"
summaries = []
for _ in range(60):
time.sleep(15)
lr = data.list_memory_records(memoryId=memory_id, namespace=ns, maxResults=20)
summaries = lr.get("memoryRecordSummaries") or []
if len(summaries) >= 3:
break
populated = [
((s.get("metadata") or {}).get("destination") or {}).get("stringValue")
for s in summaries
]
populated = [v for v in populated if v]
control.delete_memory(memoryId=memory_id)
return {"label": label, "records": len(summaries), "destination_values": populated}
if __name__ == "__main__":
custom_instruction = (
"Extract a lowercase city code when the user explicitly mentions a travel "
"destination (a city they want to visit, are visiting, or have visited). "
"Use lowercase ASCII (for example: kyoto, tokyo, osaka). "
"If the user does not mention any travel destination, do not produce a value."
)
explicit_choices_instruction = (
"Choose exactly one of the following lowercase city codes that the user "
"explicitly mentions as a travel destination: kyoto, tokyo, osaka. "
"If the user does not mention any of these cities as a travel destination, "
"do not produce a value for this field."
)
results = [
run_probe("LATEST_VALUE", "A"),
run_probe(custom_instruction, "B"),
run_probe(explicit_choices_instruction, "C"),
]
for r in results:
print(json.dumps(r, ensure_ascii=False))
短期記憶と長期記憶のメタデータフィルタの違い
ここまで検証してきた内容をふまえて、短期記憶と長期記憶のメタデータフィルタを比べておきます。メタデータという観点では同じですが、
-
短期記憶
CreateEventに付けたメタデータがそのままフィルタに使える
-
長期記憶
indexedKeysで宣言したキーだけがフィルタ対象、しかも値は戦略のmetadataSchemaに従って LLM が抽出して載せるので、入力イベントのメタデータと 1:1 にはならない
という、違いがありました。
長期記憶でも CreateEvent に付けたキーがそのままインデックスされると思い込みやすいですが、実際には明示的な宣言+LLM 抽出というワンクッションが入る作りになっています。なるほど・・・
おわりに
長期記憶と短期記憶のメタデータは違うんだなーと勉強になりました。最初は短期記憶で設定したものが連携されると思っていましたが、事前にメタデータのスキーマを定義してLLMで自動抽出される形なんですね。ここを整備しておくと記憶抽出の精度向上や体験が良くなりそうなので使いこなせると便利そうですね。私も検証し続けていきたいです!
本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!
補足:公式ガイドのベストプラクティス/アンチパターンまとめ
公式ガイドの末尾に「Best practices」と「Anti-patterns to avoid」が載っており、スキーマ設計の指針としてとても参考になるので、要点を整理して紹介します。原文はこちらをご参照ください。
結構書いてあることは大事かつ、理解するのに難易度が高いものもありますね・・・
実際に運用してみてまた知見はブログに反映したいですね。
ベストプラクティス
インデックスキーは 3〜5 個から始める
インデックスキーは Memory ごとに最大 10 個までで、ストレージ容量も消費します。まずは検索品質に直接影響するキーを 3〜5 個から始めて、必要になった時点で追加していくのが推奨されています。
definition は具体的に書く
definition は LLM が抽出時のよりどころにするフィールドです。曖昧な記述ではなく、「顧客への影響度に基づく優先度。値は critical(最も深刻)から low(最も軽微)まで」のように、値域や判断基準まで踏み込んで書くのが推奨されています。詳細な抽出ロジックは llmExtractionInstruction 側に指示を書きます。
validation.allowedValues で表記揺れを抑える
validation.allowedValues によるバリデーションは重要です。これがないと LLM は同じ概念に対して "High" / "high" / "HIGH" のように表記揺れを起こし、完全一致のフィルタが通らなくなります。
コンフリクト解決ルールはドメインに合わせる
コンフリクト解決は、同じセッション内の複数イベントから同じキーが抽出されたときに、最終的にレコードに残す値をどう選ぶかという話です。
たとえば 優先度priority の場合、最初の発話で low が抽出され、後の発話で critical に上がった、という流れがあったとします。LATEST_VALUE を指定していれば直近の critical がそのまま残ります。値の重要度が時系列で素直に上がっていく priority のようなフィールドであれば、LATEST_VALUE で十分なことが多いです。
一方で、サポート窓口の対応エージェント種別 agent_type のようにセッション内で一度でも上位エージェント(specialist など)が登場したら、その後 bot に戻っても specialist のまま記録として残したいといったケースだと、LATEST_VALUE(直近の値)では困るケースがあります。こういう場合は自然言語のカスタム指示で「Hierarchy: specialist > tier3 > tier2 > tier1 > bot のうち、セッション中に登場した最上位の値を保持」と書いてあげる方が、ドメインの意図に値が反映される形になります。
会話コンテンツはイベント駆動の経路で取り込む
会話コンテンツの取り込みは基本的にイベント駆動の経路を使い、抽出とコンフリクト解決は LLM に任せるのが推奨です。バッチ API は「メタデータの値が事前に確定している一括インポート」のケースに限定する運用が望ましいとされています。
スキーマは戦略単位で計画する
スキーマ設計は戦略単位で計画できるので、同じキーでも semantic 戦略と summary 戦略で異なる definition や抽出方針を当てることが可能です。
バッチ作成時の memoryStrategyId の扱いに注意する
BatchCreateMemoryRecords でレコードを直接作成するときの話です。ここで memoryStrategyId を指定するかどうかで、メタデータの保存挙動が変わります。
memoryStrategyId を指定すると、その戦略の metadataSchema に定義されているキーだけが保存され、それ以外のキーは破棄されます。LLM 抽出で作られるレコードと同じキー構成を維持したいときに使う形ですね。
逆に memoryStrategyId を省略すると、ペイロードに入れたメタデータがそのまま保存されます。
フィルタしないキーは indexedKeys から外す
metadataSchema に書いたキーと indexedKeys に書いたキーは、必ずしも一致させる必要はありません。スキーマには書くが indexedKeys に登録しないキーは、LLM が会話から抽出してレコードに値は登録されますが、metadataFilters での絞り込みには使えないという状態になります。
これが何の役に立つかというと、たとえば sentiment(顧客の感情)や summary_notes(要約メモ)のようなキーは、フィルタ条件として使うことはあまりなく、レコードを取得した後に「このレコードの sentiment は negative だったな」という形でアプリ側のロジック(表示や次のアクション判断)に使うことが多いです。こうしたキーをわざわざ indexedKeys に登録するとインデックスキー枠(最大 10 個)を消費してしまうので、metadataSchema だけに載せておくと枠を温存できます。
避けるべきアンチパターン
高カーディナリティの自由記述フィールドをインデックスにしない
説明文や氏名のような高カーディナリティの自由記述フィールドはインデックスに含めないでください。フィルタ境界としてほぼ機能しないのに、インデックスだけが肥大化してしまいます。
操作のたびに変わる値にメタデータを使わない
やり取りのたびに値が変わるような項目はメタデータに向きません。メタデータが効果を発揮するのは、安定している属性や変化がゆるやかな属性です。
テナント分離をメタデータだけに頼らない
ネームスペース分離なしの tenant_id メタデータは、フィルタを 1 箇所でも付け忘れた時点で破綻します。「誰の」はネームスペース、「何を/いつ/どれくらい急ぎか」はメタデータ、という棲み分けが推奨されています。
決定論が必要な値を LLM 抽出に任せない
長期記憶は「会話から LLM が自動でメタデータを拾い上げてくれる」イベント駆動の抽出経路となります。とはいえ、ticket_id(例: TKT-5001)や account_number のように「1 文字違えば別物」になるフィールドを LLM 抽出に任せるのは推奨されていません。LLM 抽出は確率的なので、会話本文に正確な ID が登場していても、桁を読み違えたり、ハイフンの位置を変えたり、似た別の番号にすり替えてしまうリスクがあります。
こうした「決定論が要る値」をどう載せるかというと、もうひとつのレコード作成経路として BatchCreateMemoryRecords / BatchUpdateMemoryRecords が用意されています。公式ガイドの「Direct record creation with Batch APIs」では、
This bypasses LLM extraction entirely — the caller controls the metadata values.
と明記されていて、Batch API ルートは LLM 抽出を完全にスキップして、呼び出し側が渡したメタデータをそのままレコードに書き込みます。利用ケースとしては「ナレッジベースのインポート」「セルフマネージド戦略」「前処理済みコンテンツの取り込み」などが挙げられており、外部システムから持ってきた確定値(チケット番号、口座番号など)を載せたいときのルートになります。
ポイントは、この使い分けはレコード単位だという点です。1 つのレコード内でpriority は LLM 抽出で、ticket_id は Batch API からというハイブリッドにはなりません。会話文脈から抽出したい属性のレコードは CreateEvent 経由でイベント駆動の抽出に任せ、外部システム由来の確定値が中心のレコードは Batch API で直接書き込む、という形でレコードの作成経路自体を分けて運用する形になります。
ここはなかなか難しそうな話ですね。無理やりハイブリッドにするなら、非決定的な項目は自前のロジックでLLMに抽出して、決定的な項目と合わせて登録するっていう経路も考えられますね。










