0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude APIでAIエージェントの長期記憶を実装する完全ガイド

0
Posted at

はじめに

私たちは複数のAIエージェントを業務(ブログ運営・SNS・デプロイ・QC)に常駐させて6ヶ月以上運用しています。最初にぶつかった壁は「AIは次のセッションで全部忘れる」こと。そして次にぶつかった壁は「会話履歴を保存しても記憶にはならない」ことでした。

この記事では、実運用を通して固まった長期記憶アーキテクチャを、Claude API(Python SDK)での実装コードと、実際に踏んだ失敗・解決策込みで解説します。

  • 対象読者: AIエージェントに「前回の続き」をやらせたい人
  • 使うもの: anthropic Python 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事実。粒度を揃えると、更新・廃止・検索の単位が安定する
  2. インデックスは1行サマリのみMEMORY.md には各記憶の1行ポインタだけを書き、本文は持たせない
  3. 削除しない、アーカイブする。間違いだった記憶も「なぜ間違えたか」ごと archive/ へ移す
  4. 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に失敗し続けていたことが後から判明しました。

ここからの再発防止策はそのまま設計指針になりました。

  1. 記憶ディレクトリはgit管理し、リモートにpushする(ローカルバックアップだけでは復旧事故に巻き込まれる)
  2. 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
  1. 復元・再実装の前に、まず記憶を読む。消失後の復旧作業で、仕様を記録した記憶が残っていたのに参照せず、劣化版を再実装してしまったことがあります。記憶は「残すこと」と同じくらい「読む運用」が大事です。

まとめ: 長期記憶は検索システムではなく状態管理

6ヶ月運用して固まった結論です。

  • 会話履歴の保存は長期記憶にならない。蒸留された状態を持つ
  • 1ファイル1事実 + 1行インデックス + status状態機械。ストアはMarkdown+gitで十分始められる
  • 注入はprompt cachingとセットで設計する(TTL 5分を知らないとコストが跳ねる)
  • 保存は draft で入れて検証後に昇格。AIの自己申告を即信用しない
  • 記憶はgitでリモート管理し、バックアップの失敗を通知する

長期記憶の本質は「たくさん覚えること」ではなく、「次の行動を間違えないための最小の状態を、信頼度付きで持つこと」でした。次回はこの記憶ストアをMCPサーバー化して、複数のAIクライアントから共有する設計を書きます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?