朝の棚卸しスクリプトが、いつもより静かでした。
私は個人開発で、数本のアプリの運用をひとつの Managed Agents セッションに任せております。夜間にクロール状況・課金・問い合わせを見て、朝に要約を置いておく。そのために、プロジェクトごとのメモリストアへ「前回の判断」「触ってはいけない設定」を書き溜めております。
毎朝、そのメモリを一覧して差分を眺めるのが習慣でした。ところがある朝、一覧が拾ってくる件数が減っていました。前日は全プロジェクト合わせて 68 件だったものが、その朝は 39 件。ほぼ半分です。エラーは出ていません。例外も、警告も、何も。ただ静かに、いくつかのプロジェクトのメモリだけが結果から消えていました。
原因は、前夜に上げたベータヘッダーひとつでした。agent-memory-2026-07-22。
メモリストアの一覧を、なぜ自前で叩くのか
Managed Agents のメモリストアは、セッションのコンテナに /mnt/memory/ としてマウントされ、エージェントは通常のファイルツールで読み書きします。ここまでは、エージェントに任せておけば済む話です。
一覧 API を人間側から叩くのは、別の目的のためです。エージェントが書いたものをレビューする。誤った記憶を訂正する。定期的にエクスポートして棚卸しする。私の場合は毎朝の差分確認で、memories.list を path_prefix で絞り込みながらプロジェクト単位に巡回しておりました。
メモリは 1 件あたり 100 kB(およそ 25,000 トークン)が上限で、公式にも「大きな数本ではなく、小さく焦点を絞った多数のファイルに分けよ」と書かれています。私はこれに従い、/projects/alpha/decisions/2026-07.md のように、プロジェクト名を第1セグメントに置く木構造で運用しておりました。この設計が、今回の変更でちょうど急所を突かれた形になります。
agent-memory-2026-07-22 で変わった3点
ベータヘッダーを agent-memory-2026-07-22 に上げると、memories.list の挙動が3点変わります。順に見ていきます。
項目 変更前 変更後(agent-memory-2026-07-22)
一覧の順序 order_by / order で制御サーバ定義の安定順に固定。order_by / order は無視
depth の値任意の整数を受け付けていた 0・1・省略のみ。それ以外は 400
path_prefix の一致部分文字列(substring)一致 末尾スラッシュ必須。パスセグメント単位 の一致
順序固定と depth 制限は、読めば身構えられます。困るのは3つ目です。挙動が「エラーではなく、静かに違う結果」に振れるからです。
直感に反した発見 — 部分一致に頼っていた自分に気づく
私の棚卸しスクリプトは、プロジェクトを跨いで拾うために、こう書いておりました。
# 変更前。これで /projects/alpha/... も /projects/alpha_archive/... も拾えていた
memories = client.beta.memory_stores.memories.list(
memory_store_id = store_id,
path_prefix = "/projects/alpha" , # ← 末尾スラッシュが無い
view = "basic" ,
betas = [ "managed-agents-2026-04-01" ],
)
変更前の path_prefix は部分文字列一致でした。"/projects/alpha" は "/projects/alpha/decisions/2026-07.md" の接頭辞であると同時に、"/projects/alpha_archive/old.md" の接頭辞でもあります。私は当時それを「プロジェクトと、そのアーカイブをまとめて拾える便利な挙動」として、半ば無自覚に頼っておりました。
agent-memory-2026-07-22 では、path_prefix は末尾スラッシュが必須になり、パスセグメント単位で一致します。すると次のことが起きます。
第一に、末尾スラッシュの無い "/projects/alpha" は、もはや意図した範囲を拾いません。セグメント境界に合わないためです。第二に、正しく "/projects/alpha/" と書いた場合、それは /projects/alpha_archive/ を含みません 。alpha と alpha_archive は別セグメントだからです。
つまり、以前は「1回の呼び出しで本体とアーカイブを両方拾えていた」ものが、変更後は「本体しか返らない」に変わります。件数が半減して見えた正体は、これでした。エラーが出なかったのは、path_prefix の指定自体は文法的に正しく、ただ一致範囲だけが静かに狭まったからです。
ここが今回いちばん学びになった点です。破壊的変更のうち最も危ういのは、例外を投げるものではなく、黙って結果が変わるもの です。前者は必ず気づけますが、後者は監視していなければ見過ごします。
破壊的変更に耐えるアクセス層を書き直す
対処方針は3つです。プレフィックスを正規化する。順序に依存しない。木を明示的に歩く。これを踏まえて、棚卸し層を次のように書き直しました。実際に毎朝走っているコードから、鍵などを伏せて抜き出したものです。
from anthropic import Anthropic
client = Anthropic() # ANTHROPIC_API_KEY は環境変数から
BETAS = [ "managed-agents-2026-04-01" , "agent-memory-2026-07-22" ]
def normalize_prefix (prefix: str ) -> str :
"""path_prefix は必ず末尾スラッシュで終える。ルートは "/" に統一。"""
if not prefix.startswith( "/" ):
prefix = "/" + prefix
if not prefix.endswith( "/" ):
prefix = prefix + "/"
return prefix
def list_all_memories (store_id: str , prefix: str = "/" ) -> list[ dict ]:
"""
prefix 配下のメモリを全件返す。
- 返却順に依存しない(呼び出し側で必ずソートする)
- depth は 0/1/省略のみ許されるため、木は自分で再帰的に歩く
"""
prefix = normalize_prefix(prefix)
collected: list[ dict ] = []
cursor: str | None = None
while True :
page = client.beta.memory_stores.memories.list(
memory_store_id = store_id,
path_prefix = prefix,
depth = 1 , # 直下の子だけを取り、サブツリーは再帰で辿る
view = "basic" , # メタデータのみ。本文は必要な時だけ retrieve
after_id = cursor,
betas = BETAS ,
)
for item in page.data:
if item.type == "memory_prefix" :
# ディレクトリ相当のノード。1 段掘り下げる
collected.extend(list_all_memories(store_id, item.path))
else :
collected.append({ "id" : item.id, "path" : item.path})
if not getattr (page, "has_more" , False ):
break
cursor = page.last_id
# サーバ順は保証されないので、比較・差分のために自前で安定ソートする
collected.sort( key =lambda m: m[ "path" ])
return collected
ここでの要点を、コードの外側で補足しておきます。
まず depth=1 に固定し、memory_prefix(ディレクトリ相当のノード)に当たったら自分で再帰する形にしました。以前は大きな depth を渡して一気に取っていましたが、その値はもう 400 になります。木を自分で歩けば、深さの制限に縛られず、しかも各段で件数を把握できます。
次に、順序を一切あてにしていません。取得後に path で安定ソートしています。前日との差分は「順番」ではなく「集合」として比較すべきで、そう組んでおけばサーバ順が変わっても壊れません。
そして本文は view="basic" で取りません。棚卸しに要るのはパスと ID だけです。中身が要る時だけ、対象を絞って memories.retrieve します。100 kB ×多数を毎朝ダウンロードするのは、時間の面でも料金の面でも無駄でした。
「本体とアーカイブをまとめて」を、意図として書き直す
以前の部分一致に頼っていた「本体もアーカイブも拾う」挙動は、便利ではありましたが、意図が暗黙でした。移行を機に、これを明示的な列挙へ書き直しました。
def list_project_including_archive (store_id: str , project: str ) -> list[ dict ]:
"""プロジェクト本体とアーカイブを、意図的に別プレフィックスとして合算する。"""
live = list_all_memories(store_id, f "/projects/ { project } /" )
archive = list_all_memories(store_id, f "/projects/ { project } _archive/" )
return live + archive
書いてみて、以前のコードがいかに危うかったかがよく分かりました。alpha と名の付くプロジェクトが増えれば、部分一致は alpha2 や alpha_experimental まで巻き込んでいたはずです。セグメント一致は不便になったのではなく、私の曖昧な意図を明示させてくれたのだと、今は受け止めております。
安全な訂正 — content_sha256 プリコンディション
棚卸しの目的の半分は「誤った記憶の訂正」です。ここは仕様変更とは別に、最初から気をつけるべき点があります。エージェントと人間が同じメモリを触るため、上書き競合が起こり得ることです。
memories.update には楽観的並行制御があります。読んだ時の content_sha256 を渡し、保存側のハッシュがまだ一致する時だけ適用させます。
def safe_correct (store_id: str , memory_id: str , new_content: str ) -> bool :
current = client.beta.memory_stores.memories.retrieve(
memory_store_id = store_id, memory_id = memory_id, betas = BETAS ,
)
try :
client.beta.memory_stores.memories.update(
memory_store_id = store_id,
memory_id = memory_id,
content = new_content,
precondition = {
"type" : "content_sha256" ,
"content_sha256" : current.content_sha256,
},
betas = BETAS ,
)
return True
except Exception :
# ハッシュ不一致 = 誰か(多くはエージェント自身)が先に書いた。
# 黙って上書きせず、再読込して判断し直す
return False
夜間にエージェントが同じファイルへ追記していることは、実際にありました。プリコンディション無しで訂正していた頃は、その追記をたまに踏み潰していたはずです。すべての変更は不変の memory version(memver_...)として残るため、事後に気づけはしますが、踏まないに越したことはありません。
移行前に、影響範囲を手元で数える
半減に気づけたのは、前日の件数を手元に残していたからでした。裏を返せば、残していなければ気づけていません。
同じ思いをしないための手当ては、思ったより簡単でした。メモリの path 一覧さえ手元にあれば、API を叩かずに影響範囲は数えられます。旧来の部分一致と新しいセグメント一致を、同じパス集合へ並べて適用するだけです。
"""prefix_audit.py — 旧 substring 一致と新 segment 一致の差を、API を叩かずに数える。"""
import json, sys
def substring_match (paths, prefix): # 変更前の挙動
return [p for p in paths if p.startswith(prefix)]
def segment_match (paths, prefix): # 変更後の挙動
if not prefix.endswith( "/" ):
return [] # 末尾スラッシュ無しは一致しない
return [p for p in paths if p.startswith(prefix)]
def prefix_nodes (paths):
"""depth=1 で再帰する際に踏む、ディレクトリ相当ノードの数。"""
nodes = set ()
for p in paths:
parts = p.strip( "/" ).split( "/" )[: - 1 ]
for i in range ( len (parts)):
nodes.add( "/" + "/" .join(parts[: i + 1 ]) + "/" )
return nodes
if __name__ == "__main__" :
paths = json.load( open (sys.argv[ 1 ])) # memories.list から集めた path 一覧
print ( f "total memories: { len (paths) } " )
n = len (prefix_nodes(paths))
print ( f "prefix nodes : { n } -> depth=1 recursion needs { n + 1 } list calls \n " )
print ( f " { 'path_prefix' :<28 }{ 'substring' :>10 }{ 'as-written' :>12 }{ 'normalized' :>12 }{ 'lost' :>7 } " )
for pf in sys.argv[ 2 :]:
old = len (substring_match(paths, pf))
raw = len (segment_match(paths, pf))
new = len (segment_match(paths, pf if pf.endswith( "/" ) else pf + "/" ))
print ( f " { pf :<28 }{ old :>10 }{ raw :>12 }{ new :>12 }{ old - new :>7 } " )
私の運用と同じ形の木(プロジェクト6本・メモリ68件)を再現して流すと、こう出ます。
total memories: 68
prefix nodes : 15 -> depth=1 recursion needs 16 list calls
path_prefix substring as-written normalized lost
/projects/alpha 34 0 20 14
/projects/alpha/ 20 20 20 0
/projects/beta 16 0 16 0
/projects/ 68 68 68 0
列の意味は3つです。substring が変更前の一致件数、as-written が末尾スラッシュを補わずそのまま送った場合、normalized が正規化して送った場合。
並べてみると、壊れ方が2種類あることが見えてきます。
/projects/alpha は 34 件から 20 件へ、41% 目減りします。差の 14 件は alpha_archive と alpha2 で、私の意図が暗黙だった箇所です。一方 /projects/beta は substring も normalized も 16 件で、件数だけ見れば無傷。ところが as-written は 0 です。末尾スラッシュを忘れたまま本番へ出せば、無傷どころか一件も返りません。
lost が 0 の行こそ危ない 、というのが数えてみて分かったことでした。差分の出ない箇所は「影響なし」と判断され、手が入らないまま残るためです。
もうひとつ、出力の2行目。木を歩くノードが 15 個あるため、depth=1 の再帰では list 呼び出しが 1 回から 16 回、つまり 16 倍に増えます。毎朝1度の棚卸しなら誤差の範囲ですが、分単位で回すなら効いてきます。私は浅い段だけを毎回歩き、深い段は上の段の件数が動いた時にだけ辿る形にしております。なおこの 16 という見積もりには誤りがありました。次節で実際に走らせて訂正します。
実測 — 15回の再帰は何秒になり、素朴な並列化はどこで詰まるか
前節で「16 倍に増える」と書きましたが、倍率だけでは判断できません。1 回あたりが 5 ms なら 16 倍でも 80 ms です。手元で測ることにしました。
memories.list と同じ形の応答を返す最小のサーバをローカルに立て、1 呼び出しあたり 45 ms の遅延を注入します。木は前節の監査出力とまったく同じ形(メモリ 68 件・prefix ノード 15)に揃えました。Python 3.10.12、3 回実行の中央値です。
走らせてまず出てきたのは、自分の監査スクリプトの誤りでした。
prefix nodes : 15 -> depth=1 recursion needs 16 list calls
実際の走査 : list calls = 15
prefix_nodes() はルートの /projects/ をすでに集合に含めています。そこへ n + 1 としたため、ルートを二重に数えていました。1 回の差ですが、見積もりの根拠が間違っていたことに変わりはありません。前節の「16 回」は 15 回が正しい値です。
そのうえで、逐次の depth-first 再帰にかかった時間がこちらです。
逐次 depth-first items=68 698.6 ms
15 回 × 45 ms = 675 ms。ほぼ理論値どおりで、時間のほとんどは待っているだけの往復です。ならば並列化すれば縮むはずだと考え、ThreadPoolExecutor を渡して再帰の中で ex.map を呼ぶ形にしました。ここで詰まりました。
プール内で再帰 workers=2 5秒経っても返らない(デッドロック)
プール内で再帰 workers=4 5秒経っても返らない(デッドロック)
プール内で再帰 workers=8 完了 items=68 286.0 ms
親のタスクがワーカーを1つ掴んだまま、子の完了を待ちます。ワーカーが 4 つなら、ルート 1 つとプロジェクト 3 つで埋まり、4 つ目のプロジェクトが永久に順番待ちになります。エラーは出ません。ただ返ってこないだけです。workers=8 で通ったのは、木の形がたまたま収まっただけで、プロジェクトが 1 本増えれば同じように止まります。
再帰と共有プールの組み合わせが原因ですから、再帰をやめて段ごとに広げる形へ変えました。
from concurrent.futures import ThreadPoolExecutor
def fetch_level (store_id: str , prefix: str ) -> list[ dict ]:
"""prefix 直下の 1 段だけを取る。ページングは全て消化して返す。"""
items, cursor = [], None
while True :
page = client.beta.memory_stores.memories.list(
memory_store_id = store_id, path_prefix = prefix,
depth = 1 , view = "basic" , after_id = cursor, betas = BETAS ,
)
items += [{ "path" : i.path, "type" : i.type} for i in page.data]
if not getattr (page, "has_more" , False ):
return items
cursor = page.last_id
def list_level_parallel (store_id: str , prefix: str = "/" , workers: int = 8 ) -> list[ str ]:
"""木を段ごとに下る。プールの中で再帰しないため、ワーカー数に関わらず詰まらない。"""
out: list[ str ] = []
frontier = [normalize_prefix(prefix)]
with ThreadPoolExecutor( max_workers = workers) as ex:
while frontier:
next_level: list[ str ] = []
for page in ex.map( lambda p: fetch_level(store_id, p), frontier):
for item in page:
if item[ "type" ] == "memory_prefix" :
next_level.append(item[ "path" ])
else :
out.append(item[ "path" ])
frontier = next_level
return sorted (out)
同じ木で測り直した結果です。
走査方式 所要時間(中央値) 逐次比
逐次 depth-first 698.6 ms 1.00x
段ごと並列 workers=2 376.2 ms 1.86x
段ごと並列 workers=4 238.9 ms 2.92x
段ごと並列 workers=8 147.8 ms 4.73x
段ごと並列 workers=16 147.9 ms 4.72x
ワーカーを 8 から 16 へ倍にしても、147 ms 台から動きません。この木は /projects/ → プロジェクト → サブディレクトリの 3 段で、段ごとに待つ以上 3 × 45 ms = 135 ms より速くはなりません。実測の 147.8 ms は、その下限のすぐ上にいます。
つまり並列化で効くのは木の幅 であって、深さ ではありません。棚卸しの所要時間は、メモリの件数ではなく木の段数でおおよそ決まります。プロジェクト名を第1セグメントに置き、その下を 2 段に収めていた設計は、意図せずこの下限を低く保っていたことになります。深い階層を切りたくなった時に、コストの形として思い出せる指標です。
45 ms という値は私が注入したもので、実際の API の応答時間ではありません。ここで見たかったのは絶対値ではなく、倍率の付き方と天井の位置です。ご自身の環境で判断される際は、1 呼び出しの実測値を測ってから、この表の形に当てはめていただければと思います。
状況別の指針
移行にあたって迷いやすい判断を、状況別に整理しておきます。
状況 推奨
プレフィックスで範囲を絞っている すべての path_prefix を末尾スラッシュ付きに正規化。部分一致に頼っていた箇所を洗い出す
order_by で並びを制御していた取得後に自前でソート。ページングは after_id ベースへ寄せ、順序前提のロジックを外す
深い木を一括取得していた depth=1 + 再帰へ。memory_prefix ノードを辿る形に統一
人間とエージェントが同じ store を書く 訂正系は必ず content_sha256 プリコンディション。参照専用の store は read_only で attach
最後にひとつ。ベータヘッダーを上げた直後は、件数の総量を1度スナップショットしておくことをお勧めします。私が半減に気づけたのは、たまたま前日の件数を手元に残していたからでした。静かに変わるものは、静かに観測しておくしかありません。
メモリの仕様変更は、長期運用ほど効いてきます。今回の path_prefix の件も、木構造を素直に設計していた運用ほど深く刺さりました。設計思想の背景をもう少し掘りたい方は、Claude エージェントのメモリ運用でつまずいた点と対処 も合わせてご覧いただければと思います。
私自身、まだ手探りの部分が多い領域です。同じように静かな件数の変化に戸惑った方の、確認の手がかりになれば嬉しく思います。お読みいただき、ありがとうございました。