先週の金曜日、社内で定期実行している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割を超えたら、作業の前に整理を挟むようにしたいと思っています。
メモを育てることばかり考えていましたが、減らす仕組みのほうがずっと難しいんですよね。先輩の言葉を借りると、「ログとメモは別物。ログは積むもの、メモは削るもの」。しばらくは、それを合言葉にやってみます。