はじめに
私たちは複数のAIエージェントを業務(ブログ運営・SNS・デプロイ・QC)に常駐させて6ヶ月以上運用しています。最初にぶつかった壁は「AIは次のセッションで全部忘れる」こと。そして次にぶつかった壁は「会話履歴を保存しても記憶にはならない」ことでした。
この記事では、実運用を通して固まった長期記憶アーキテクチャを、Claude API(Python SDK)での実装コードと、実際に踏んだ失敗・解決策込みで解説します。
- 対象読者: AIエージェントに「前回の続き」をやらせたい人
- 使うもの:
anthropicPython SDK、Markdownファイル、git - やらないこと: ベクトルDB必須論(後述しますが、運用初期には不要でした)
なぜ会話履歴の保存では足りないか
最初は素朴に「会話ログを全部保存して次回に読み込めばいい」と考えました。これは3つの理由で破綻します。
1. コンテキストウィンドウとコスト
1セッションの会話ログは平気で数万トークンになります。10セッション分を毎回注入すると、入力コストが線形に膨らみ、肝心の作業用コンテキストが圧迫されます。
2. ログには「結論」と「過程」が混在している
ログの大半は試行錯誤の過程です。次のセッションで本当に必要なのは「最終的にどう決めたか」「何を二度とやらないか」だけ。過程を毎回読み直すのはノイズです。
3. 検証されていない情報が混ざる
会話中の仮説(後に間違いと判明したもの)もログには残ります。これをそのまま注入すると、AIが古い仮説を事実として扱う事故が起きます。実際に、撤回済みの設計方針を別セッションのAIが「決定事項」として参照し、手戻りが発生しました。
つまり長期記憶に必要なのは履歴ではなく、蒸留された状態です。
全体アーキテクチャ
行き着いたのは次の3層構成です。データストアは「ただのMarkdownファイル+git」。これが一番運用しやすかった。
memory/
├── MEMORY.md # インデックス(毎セッション全文注入)
├── feedback_*.md # 振る舞いへの修正指示(1ファイル=1事実)
├── project_*.md # 進行中プロジェクトの状態
├── reference_*.md # 手順書・URL・外部情報へのポインタ
└── archive/ # 古い記憶の墓場(消さずに退避)
設計原則は4つです。
- 1ファイル = 1事実。粒度を揃えると、更新・廃止・検索の単位が安定する
-
インデックスは1行サマリのみ。
MEMORY.mdには各記憶の1行ポインタだけを書き、本文は持たせない -
削除しない、アーカイブする。間違いだった記憶も「なぜ間違えたか」ごと
archive/へ移す - gitで管理する。記憶は資産。後述しますが、これを怠って実害が出ました
記憶ファイルの実物はこんな形です。
---
name: deploy-verify-with-image-tag
description: デプロイ反映の検証はHTTP 200でなくDockerイメージタグで行う
type: feedback
created: 2026-04-22
status: confirmed
---
HTTP 200やcache HITはデプロイ反映の証拠にならない。
旧コンテナが生きていても200は返る。
**Why:** 反映前のコンテナを見て「デプロイ完了」と誤報告した。
**How to apply:** デプロイ後はイメージタグの更新とバンドル内の
変更文字列をgrepで確認してから完了報告する。
frontmatterの status がポイントで、draft(仮説)→ confirmed(検証済み)→ deprecated(廃止)の状態機械として扱います。AIには「confirmed 以外は裏取りせずに事実扱いするな」と指示します。
Claude APIへの注入実装
セッション開始時に「インデックス全文+関連記憶の本文」をsystemプロンプトに注入します。ここで重要なのがprompt cachingです。記憶ブロックは毎ターン同一なので、キャッシュに載せないと毎ターン全額課金されます。
import anthropic
from pathlib import Path
client = anthropic.Anthropic() # ANTHROPIC_API_KEY は環境変数で
MEMORY_DIR = Path("memory")
def build_memory_block() -> str:
"""インデックス + confirmed記憶の本文を結合して返す"""
index = (MEMORY_DIR / "MEMORY.md").read_text(encoding="utf-8")
bodies = []
for f in sorted(MEMORY_DIR.glob("*.md")):
if f.name == "MEMORY.md":
continue
text = f.read_text(encoding="utf-8")
if "status: confirmed" in text:
bodies.append(text)
return index + "\n\n---\n\n" + "\n\n".join(bodies)
def create_message(user_input: str, history: list) -> anthropic.types.Message:
return client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=[
{
"type": "text",
"text": "あなたは当社の業務エージェントです。記憶に従って作業してください。",
},
{
"type": "text",
"text": build_memory_block(),
# 記憶ブロックは毎ターン同一 → キャッシュ境界を置く
"cache_control": {"type": "ephemeral"},
},
],
messages=history + [{"role": "user", "content": user_input}],
)
cache_control: {"type": "ephemeral"} を記憶ブロックの末尾に置くことで、2ターン目以降はこのブロックがキャッシュ読み込み(通常入力の1/10のコスト)になります。
実際に踏んだ罠①: キャッシュTTLは5分
prompt cacheのデフォルトTTLは5分です。私たちはcron起動のエージェントを15分間隔で動かしていたため、毎回キャッシュミスして全額課金されていました。コストグラフに謎のスパイクが出て調査した結果がこれです。
対策は2つあります。
- 起動間隔を5分以内に寄せる(キャッシュを温め続ける)
-
"cache_control": {"type": "ephemeral", "ttl": "1h"}で1時間キャッシュを使う(書き込み単価は上がるが、間隔が空くワークロードでは総額が下がる)
どちらが安いかは「1時間あたりの起動回数」で決まります。うちは対話型エージェントは5分TTL、cron型は1時間TTLに分けました。
記憶の書き込み: tool useで実装する
記憶の保存はAI自身にやらせます。「重要な決定や修正指示を受けたら保存する」をtool useで実装します。
import json, re, datetime
MEMORY_TOOL = {
"name": "memory_store",
"description": (
"再利用価値のある事実を長期記憶に保存する。"
"ユーザーからの修正指示、確定した設計判断、失敗の再発防止策が対象。"
"会話の一時的な内容や、コードを読めば分かることは保存しない。"
),
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "kebab-caseのスラッグ"},
"description": {"type": "string", "description": "1行サマリ(200字以内)"},
"type": {"type": "string", "enum": ["feedback", "project", "reference"]},
"body": {"type": "string", "description": "本文。Why/How to applyを含める"},
},
"required": ["name", "description", "type", "body"],
},
}
def handle_memory_store(args: dict) -> str:
name = re.sub(r"[^a-z0-9-]", "", args["name"].lower())
path = MEMORY_DIR / f"{args['type']}_{name}.md"
today = datetime.date.today().isoformat()
path.write_text(
f"---\nname: {name}\ndescription: {args['description']}\n"
f"type: {args['type']}\ncreated: {today}\nstatus: draft\n---\n\n"
f"{args['body']}\n",
encoding="utf-8",
)
# インデックスにも1行追記
with open(MEMORY_DIR / "MEMORY.md", "a", encoding="utf-8") as f:
f.write(f"- [{args['description']}]({path.name})\n")
return json.dumps({"saved": str(path)}, ensure_ascii=False)
注目してほしいのは、新規保存が必ず status: draft で入る点です。AIが自分で書いた記憶を即confirmed扱いしない。人間(または別のレビュアーエージェント)が確認して昇格させます。これで「AIの思い込みが記憶として固定される」事故を防ぎます。
実際に踏んだ罠②: 保存しすぎ問題
導入初期、AIは何でも保存しました。「今日は晴れ」「ユーザーがありがとうと言った」レベルのものまで。インデックスが3週間で300行を超え、注入トークンが逆に膨張しました。
効いた対策は、toolのdescriptionに保存しない条件を明記することです(上のコード参照)。「コードを読めば分かることは保存しない」「この会話でしか意味を持たないことは保存しない」。tool descriptionは実質プロンプトなので、ここの書き込みが挙動を一番変えます。
加えてインデックスに「1行200字以内」の機械的バリデーションを入れ、超過したらCIで弾くようにしました。
recall: 検索はgrepで始めて十分だった
「長期記憶=ベクトル検索」と思い込んでいましたが、運用初期の記憶数(〜数百件)ではfrontmatterのdescriptionに対する全文grepで実用になりました。
import subprocess
def recall(query_terms: list[str], memory_dir: str = "memory") -> list[str]:
"""description行に対するOR検索。ヒットしたファイルパスを返す"""
pattern = "|".join(query_terms)
res = subprocess.run(
["grep", "-l", "-iE", pattern, "--include=*.md", "-r", memory_dir],
capture_output=True, text=True,
)
return res.stdout.splitlines()
ポイントはdescription(1行サマリ)の品質です。検索にヒットするかはdescriptionの語彙で決まるので、保存時に「将来の自分がどんな単語で探すか」を意識させる。これはベクトル検索に移行しても同じく効くので、無駄になりません。
件数が千を超えたあたりで初めてembedding検索を検討すれば良い、というのが現時点の結論です。
実際に踏んだ罠③: バックアップ復旧で記憶が消えた
一番痛かった事故です。サーバー障害からのリストア時に、リストア対象に記憶ディレクトリの一部が含まれておらず、日次自動化スクリプト3本と数日分の記憶が消失しました。さらに悪いことに、バックアップのgit pushが容量超過で15日間silentに失敗し続けていたことが後から判明しました。
ここからの再発防止策はそのまま設計指針になりました。
- 記憶ディレクトリはgit管理し、リモートにpushする(ローカルバックアップだけでは復旧事故に巻き込まれる)
- push失敗を通知に出す。バックアップは「成功している前提」が一番危ない
# バックアップスクリプト末尾の例: push失敗をWebhookで通知
if ! git push origin main 2>/tmp/push_err.log; then
curl -s -X POST "$DISCORD_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{\"content\": \"🔴 memory backup push failed: $(head -c 500 /tmp/push_err.log | sed 's/\"/ /g')\"}"
fi
- 復元・再実装の前に、まず記憶を読む。消失後の復旧作業で、仕様を記録した記憶が残っていたのに参照せず、劣化版を再実装してしまったことがあります。記憶は「残すこと」と同じくらい「読む運用」が大事です。
まとめ: 長期記憶は検索システムではなく状態管理
6ヶ月運用して固まった結論です。
- 会話履歴の保存は長期記憶にならない。蒸留された状態を持つ
- 1ファイル1事実 + 1行インデックス + status状態機械。ストアはMarkdown+gitで十分始められる
- 注入はprompt cachingとセットで設計する(TTL 5分を知らないとコストが跳ねる)
- 保存は
draftで入れて検証後に昇格。AIの自己申告を即信用しない - 記憶はgitでリモート管理し、バックアップの失敗を通知する
長期記憶の本質は「たくさん覚えること」ではなく、「次の行動を間違えないための最小の状態を、信頼度付きで持つこと」でした。次回はこの記憶ストアをMCPサーバー化して、複数のAIクライアントから共有する設計を書きます。