AIエージェント 2026年9月29日

AIエージェントの申し送りメモが145KBに育っていた。いちばん新しい申し送りは、読み込み上限の外にあった話

XECIN Claudeドキュメント運用技術検証

先週の金曜日、社内で定期実行しているAIエージェントの作業ログを眺めていて、手が止まりました。

エージェントが自分の申し送りメモを読み込んだ直後に、こんな注記が出ていたんです。「このファイルは大きいので、1〜47行目だけを表示しています。続きは読み込み位置を指定して読んでください」。メモは全部で195行あります。

恥ずかしながら、僕はそれまで「申し送りメモは増えれば増えるほど、エージェントは賢くなる」と思っていました。作業が終わるたびに、つまずいたことや次回への注意を書き足していく。3か月ちょっとで、メモは立派に育っていました。育ちすぎて、いちばん新しい申し送りが最初の1ページに入らなくなっていたことには、まったく気付いていなかったんですよね。

Claude Code の自動メモリや CLAUDE.md を育てている人は多いと思うので、測ったことと直したことを残しておきます。

メモを測ってみた。1ページ目に入るのは47行だけだった

最初は全然分からなくて、「注記が出ているなら、エージェントはちゃんと続きも読んでいるはず」くらいに考えていました。なので、まずは仕組みの確認からです。

僕たちの環境では、エージェントの記憶は2段になっています。

  • 起動のたびに必ず読み込まれる「索引」(MEMORY.md)。1行1項目で、どのファイルに何が書いてあるかを並べたもの
  • 索引から辿って、必要なときにだけ読む「トピックファイル」。定期タスクごとに1本あって、手順のコツや過去の実行記録が入っている

索引のほうは、起動時に読み込まれるのが先頭200行か25KBのどちらか先に届いたところまで。トピックファイルは、エージェントがファイルを読むための道具で開くんですが、この道具にも1回あたりの上限(うちの環境では25,000トークン)があります。冒頭の注記は、この上限に引っかかったときのものでした。

問題のトピックファイルは、2〜3日に1回のペースで回している定型タスクのものです。1回終わるごとに「実績」を1項目、末尾に追記する運用にしていました。どれくらい育っていたのか、簡単なスクリプトで測りました。

# notes_measure.py : 申し送りメモの大きさと、1ページ目に入る範囲を測る
import re
import sys
from pathlib import Path

PAGE_END = 47  # 読み込みの道具が1回目に返してきた最後の行

path = Path(sys.argv[1])
raw = path.read_bytes()
lines = raw.decode("utf-8-sig").split("\n")

# 実行記録は「- 実績:」などで始まる行。行内の日付を拾う
records = [(no, re.search(r"20\d\d-\d\d-\d\d", line).group())
           for no, line in enumerate(lines, 1)
           if line.startswith(("- 実績:", "- **Run", "- **20"))]
dates = [d for _, d in records]
first_page = sum(len((line + "\n").encode()) for line in lines[:PAGE_END])
notes = [no for no, line in enumerate(lines, 1) if "**状態" in line]

print(f"bytes={len(raw):,} lines={len(lines)} records={len(records)}")
print(f"first page: L1-L{PAGE_END} = {first_page:,} bytes ({first_page / len(raw):.0%})")
print("newest record in first page:", max(d for no, d in records if no <= PAGE_END))
print("newest record overall     :", max(dates))
print("out of order:", sum(1 for a, b in zip(dates, dates[1:]) if b < a))
print(f"next-time notes: {len(notes)} (last one at L{notes[-1]})")

# 実行結果(並べ替える前):
# bytes=145,333 lines=195 records=41
# first page: L1-L47 = 54,927 bytes (38%)
# newest record in first page: 2026-08-08
# newest record overall     : 2026-09-27
# out of order: 2
# next-time notes: 29 (last one at L194)

数字を表にすると、こうなります。

項目実測意味
ファイルの大きさ145,333バイト / 195行道具の数え方で約6万トークン。1回の上限の2.4倍
実行記録41回分(6月15日〜9月27日)1回ごとに末尾へ追記していた
1ページ目に入る範囲1〜47行目(バイト数で38%)上限に届いたところで区切られる
1ページ目で最も新しい記録8月8日最新の記録(9月27日)の7週間前
次回への申し送り29か所最新の1つは194行目、つまりファイルの最後
日付の順番が崩れた箇所2か所あとから古い記録を途中に差し込んだ跡

1ページ目だけを読んで作業を始めると、7週間前の「次回はこうする」を現在地だと思い込むことになります。実際、1ページ目の最後の申し送りには「次はこの2つのどちらかを選ぶ」と書いてあったんですが、その2つはどちらも、その後の実行でとっくに片付いていました。

最初は「道具が続きを読めと言ってくれているんだから、続きをちゃんと読めば済む話」だと思っていました。先輩に聞いたら、「読めるかどうかより、どっちから読むかが問題なんだよ」と言われて。

調べてみたところ、確かに続きは読めます。読み込み位置を指定すれば、2ページ目も3ページ目も普通に返ってくる。でも、このメモは古いものほど上、新しいものほど下に積まれていて、読み手は上から読みます。途中で止まったとき、手元に残るのはいつも古いほうなんです。

しかも先頭近くには、6月時点の事実がそのまま残っていました。並べ替える前の19行目に「ビルドは約5秒・68ページ」とあって、今のビルドは119ページです。訂正は123行目に、別の実行記録の一文として書き足されていました。追記しかしない運用だと、古い事実は消えずに上に残り、訂正は下に溜まっていく。上から素直に読む人ほど間違える構造だったりします。

書いてあるのに、同じつまずきを繰り返していた

もう一つ、測ってみて気付いたことがあります。同じ教訓が、何度も書き足されていたんです。

# notes_repeat.py : 同じ教訓が何回の実行記録に出てくるかを数える
import re
import sys
from bisect import bisect_right
from pathlib import Path

LESSONS = {
    "gh pr create に --head": r"--head",
    "BOM は utf-8-sig": r"BOM",
    "要素数の上限超え": r"初稿\s*[6-9]",
}

lines = Path(sys.argv[1]).read_text(encoding="utf-8-sig").split("\n")
starts = [no for no, line in enumerate(lines, 1)
          if line.startswith(("- 実績:", "- **Run", "- **20"))]

for label, pattern in LESSONS.items():
    hits = [no for no, line in enumerate(lines, 1) if re.search(pattern, line)]
    # その行がどの実行記録に属するかを、直前の記録の開始行で決める
    runs = {starts[bisect_right(starts, no) - 1] for no in hits if no >= starts[0]}
    print(f"{label:<20} lines={len(hits):>2} runs={len(runs):>2} first=L{hits[0]} last=L{hits[-1]}")

# 実行結果(並べ替える前):
# gh pr create に --head lines= 6 runs= 6 first=L34 last=L186
# BOM は utf-8-sig      lines= 6 runs= 4 first=L87 last=L186
# 要素数の上限超え             lines=10 runs=10 first=L34 last=L187

いちばん分かりやすいのが3行目です。この定型タスクには「成果物のある要素は5個まで」というルールがあって、エージェントは初稿でそれを6〜8個にしてしまう。この超過が、最初に記録されてから9回、記録の上だけで合計10回起きていました。

どの回も提出前に直っているので、実害は出ていません。でも、メモには毎回「次は書き始める前に割り当てを決めること」と書き足されていて、次の回もまた超えている。--head の件も、BOM付きファイルの件も同じで、気付いた回ごとに、その回の記録の中へ書き直されていました。

ぶっちゃけ、これは申し送りではなく「再発の記録」になっていました。

先輩と話して、原因を2つに分けました。1つは、教訓が実行記録の長い段落の途中に埋まっていること。読んでいても、段落の中の1文は行動を変えません。もう1つは、「気をつける」とは書いてあっても、「何で確かめるか」が書いていないことです。

今思えば、変化があったのは9月に入って「書き終えたら、このコマンドで数える」と一行で書いてからでした。それでも初稿での超過はその後2回起きていて、どちらもコマンドで数えた時点で気付いて直しています。教訓が行動を変えたというより、確かめる手順が捕まえている、というのが正直なところです。

毎回読み込まれる索引も、25KBが先に来る

トピックファイルだけの話かと思ったら、起動のたびに読み込まれる索引のほうも危なかったです。

索引は21行しかありません。200行の上限から見ればまだまだ余裕がある、と思いますよね。僕もそう思っていました。

ところがバイト数を測ると20,469バイトで、25KBの上限の82%でした。1行あたり平均で約975バイト、いちばん長い行は2,734文字。21行中13行が150文字を超えています。索引の1行が「このファイルには何が書いてあるか」の案内ではなく、そのタスクのつまずきの要約をどんどん抱え込んで、小さな実行記録になっていたんです。

このペースだと、あと5項目足らずで25KBに届きます。ドキュメントには「200行または25KB」と並べて書いてあるけど、1行が長い使い方をしていると、行数の上限は飾りで、バイト数の上限が先に来る。僕は索引にバイト数の上限があること自体を知りませんでした。しかも上限を超えた分の通知は、うちのように無人で回していると、人が目にする機会がほとんどありません。

並べ方と書き方を変えた

直したことは3つです。まず、置き場所ごとに役割を決め直しました。

置き場所書くもの更新のしかた読まれ方
現在地(トピックファイルの先頭)最新の状態、次回の候補、係争中のもの毎回上書き。10行前後に収める必ず1ページ目に入る
毎回の点検(現在地の直後)繰り返したつまずきを、確かめ方つきで1行ずつ2回書いたら昇格。直ったら消す必ず1ページ目に入る
履歴(その下)実行ごとの記録追記のみ。古い順のまま必要なときに検索で引く
索引(MEMORY.md)ファイル名と、いつ読むかを1行で150文字以内。中身は書かない起動のたびに全文が読み込まれる

1つ目は、現在地をファイルの先頭に移したことです。末尾に追記していた「次回への申し送り」をやめて、先頭の「現在地」の節を毎回上書きする。同じファイルの中で、上書きする場所と追記する場所を分けるイメージです。

## 現在地(毎回ここを上書きする。最終更新: 2026-09-29)
- 対象リポジトリの最新: 9daed88。開いたままの作業は30本超(着手前に一覧を取り直す)
- 候補から外すもの: 3件(外した理由つき。再開するときは人から連絡が来る)
- 直近の選択: 09-25 A → 09-27 A → 09-29 B。次回の薄い候補は C

## 毎回の点検(書き終えたら、コマンドで確かめる)
- 要素は5個まで: grep -c で数えて半分にする
- PR 作成は --head を明示する(push 済みでも失敗することがある)
- 集計は utf-8-sig で読む(BOM 付きのファイルが混ざっている)

## 履歴(追記のみ・古い順。上の2節と食い違ったら上が正)

最後の一文は地味に効きます。履歴の中に古い事実が残っていても、上の2節と食い違ったら上を信じる、と明記しておけば、古い行をすべて掃除し終わるまで待たなくて済むので。

2つ目は、繰り返したつまずきを「点検」に昇格させたことです。基準は単純で、同じことを2回書いたら点検項目に上げる。点検項目には必ず「何で確かめるか」を書く。直った項目や前提が消えた項目は消す。ここは好みが分かれるところですが、僕は「消す」ルールがいちばん大事だと思っています。消さない点検リストは、すぐにまた長い文章に戻っていくので。

3つ目は、大きさを機械で見張るスクリプトです。索引のバイト数、索引1行の長さ、トピックファイルの大きさ、そして先頭40行に「現在地」の節があるか。この4つだけを見ます。

# memo_gate.py : 申し送りメモの点検(読むだけ。書き換えはしない)
import sys
from pathlib import Path

INDEX_LIMIT = 25_000      # 索引が毎回読み込まれる上限(バイト)
WARN_RATIO = 0.8          # 上限の8割で警告
LINE_LIMIT = 150          # 索引1行の文字数の目安
TOPIC_LIMIT = 40_000      # トピックファイル1本の目安(バイト)
HEAD_LINES = 40           # 「現在地」を置く範囲(先頭からの行数)

memo_dir = Path(sys.argv[1])
problems = []

index = memo_dir / "MEMORY.md"
raw = index.read_bytes()
rows = [r for r in raw.decode("utf-8-sig").splitlines() if r.strip()]
print(f"index: {len(rows)} lines / {len(raw):,} bytes ({len(raw) / INDEX_LIMIT:.0%} of limit)")
if len(raw) > INDEX_LIMIT * WARN_RATIO:
    problems.append("index is close to the byte limit")
long_rows = [r for r in rows if len(r) > LINE_LIMIT]
if long_rows:
    problems.append(f"{len(long_rows)} index lines exceed {LINE_LIMIT} chars")

for topic in sorted(memo_dir.glob("*.md")):
    if topic.name == "MEMORY.md":
        continue
    size = topic.stat().st_size
    head = topic.read_text(encoding="utf-8-sig").splitlines()[:HEAD_LINES]
    has_now = any(line.startswith("## 現在地") for line in head)
    if size > TOPIC_LIMIT or not has_now:
        flag = "large" if size > TOPIC_LIMIT else ""
        flag += "" if has_now else " no-current-state"
        print(f"  {topic.name}: {size:,} bytes {flag.strip()}")
        if size > TOPIC_LIMIT:
            problems.append(f"{topic.name} is large")

print(f"problems: {len(problems)}")
for p in problems:
    print(f"  - {p}")
sys.exit(1 if problems else 0)

# 実行結果(並べ替えた後。ファイル名は伏せ、順番を入れ替えて一部省略):
# index: 21 lines / 20,469 bytes (82% of limit)
#   task-a.md: 147,606 bytes large
#   task-b.md: 111,784 bytes large no-current-state
#   ...(ほかに19本。どれも no-current-state で、うち4本は large)
# problems: 8
#   - index is close to the byte limit
#   - 13 index lines exceed 150 chars
#   ...(large のファイルが6本)

正直なところ、このスクリプトには改善の余地があります。しきい値(150文字、40,000バイト、先頭40行)に根拠はなく、今の値を眺めて決めただけです。「現在地」も見出しの文字列で探しているので、書き方が少しずれると見逃します。定期タスクの手順にも組み込めていなくて、今は僕が手で回しています。

並べ替えたあと、同じ道具でもう一度メモを開くと、1ページ目の11行目から「現在地」、18行目から「毎回の点検」が始まるようになりました。ファイルそのものは147,606バイトとまだ大きいままで(先頭に節を足した分、少し増えています)、履歴を別ファイルに切り出す作業には手を付けられていません。点検結果のとおり、ほかの20本のトピックファイルにも「現在地」の節はまだありません。

振り返って、これからやりたいこと

学んだことを箇条書きにしておきます。

  • 追記だけのメモは、新しいものほど読まれにくい位置に溜まる。「現在地」は上書きで先頭に置く
  • 上限は行数とバイト数の早いほう。1行が長い索引では、行数はあてにならない
  • 教訓は文章で残すより、確かめ方とセットの点検項目にする。そして直ったら消す
  • 切り捨ては無人の運用だと誰も見ていない。大きさは機械で見張る

これからやりたいのは、まず履歴の切り出しです。41回分の実行記録を別ファイルに移して、トピックファイル本体は「現在地」と「点検」と手順のコツだけにしたい。索引も、1行150文字に収まるように書き直します。見張りのスクリプトは定期タスクの最初の手順に組み込んで、上限の8割を超えたら、作業の前に整理を挟むようにしたいと思っています。

メモを育てることばかり考えていましたが、減らす仕組みのほうがずっと難しいんですよね。先輩の言葉を借りると、「ログとメモは別物。ログは積むもの、メモは削るもの」。しばらくは、それを合言葉にやってみます。