生き残る行と、必ず消える行
次に、先頭 10 万文字に何行が収まるかを数えました。
| コマンド | 全行数 | 先頭10万文字に残る行 | 生存率 |
find ... -exec wc -c {} + | 1,654 | 1,105 | 66.8% |
grep -rn 'claude' ... | 5,595 | 466 | 8.3% |
生存率は 8.3% から 66.8% まで大きくばらつきます。ここまでは想像の範囲でした。
問題は、この数字が実務上ほとんど意味を持たないことです。
find ... -exec wc -c {} + の最終行、つまり 1,654 行目はこうなっています。
658964 total
私が知りたかったのは、この行と、サイズ順に並べたときの上位数件でした。そして最終行は、定義上、先頭カットで必ず失われます。
先頭 10 万文字の最後に残っていたのは、こういう行でした。
9377 content/articles/en/claude-code/claude-code-gate-and...
アルファベット順で en/claude-code/ のあたりにいる、何の変哲もない 1 ファイルです。ここでプレビューは途切れます。
ここが、事前の予想と逆だった点です。私は「大きい出力は一部しか見えない」と理解していました。実際には「大きい出力は、いちばんどうでもいい部分だけが見える」のです。
CLI の慣習では、結論は末尾に置かれます。wc の total、git の N files changed、テストランナーの N passing, M failing、ビルドツールの終了サマリ。詳細の生存率が 8% でも 67% でも、要約行の生存率はどちらのケースでも 0% でした。先頭カットは、出力の中で最も情報密度の高い一行を、構造的に狙い撃ちにします。
エージェントが間違ったファイル名を答えたのは、幻覚ではありませんでした。渡された素材の中で、いちばんもっともらしい答えを返していただけです。
単位を取り違えていました — 境界は文字数、wc -c が返すのはバイト数
公開したあとで上の 2 つの表を自分で作り直していて、数え方が揃っていないことに気づきました。
grep と find と ls -R の行は wc -c で数えています。これはバイト数です。articles.json の行だけは Python の len(raw) で数えていて、こちらは文字数です。そして退避が起きる境界は、バイトではなく 10 万「文字」で引かれています。
日本語を含む出力では、この 2 つが一致しません。同じリポジトリで両方を数え直しました(記事執筆時から日本語記事が 791 本から 803 本に増えているため、絶対値は当時と少しずれます)。
| コマンド | バイト数(wc -c) | 文字数(wc -m) | バイト/文字 | 文字数での倍率 |
grep -rn 'claude' content/articles/ja --include='*.mdx' | 1,218,722 | 945,213 | 1.29 | 9.45x |
cat 記事メタデータJSON | 978,702 | 822,582 | 1.19 | 8.23x |
find content -name '*.mdx' -exec wc -c {} + | 152,216 | 152,216 | 1.00 | 1.52x |
ls -R content | 88,611 | 88,611 | 1.00 | 0.89x |
find と ls -R はファイルパスしか吐かないので、バイト数と文字数が完全に一致します。ずれるのは本文が混ざる grep と、日本語の title・description を抱えた JSON です。UTF-8 の日本語は 1 文字 3 バイトなので、日本語の比率がそのまま倍率の差に出ます。
影響は 2 段階で出ました。
1 つ目は倍率です。grep の出力を「境界の 12.01 倍」と書きましたが、境界と同じ単位で数えれば 9.45 倍でした。29% ほど大きく見積もっていたことになります。
2 つ目は生存率で、こちらの方が実害があります。先頭 10 万「バイト」で切ると 466 行、先頭 10 万「文字」で切ると 607 行が残りました。実際にモデルへ渡るのは後者です。
| コマンド | 全行数 | 先頭10万バイトで残る行 | 先頭10万文字で残る行 | 訂正後の生存率 |
find ... -exec wc -c {} + | 1,678 | 1,103 | 1,103 | 65.7% |
grep -rn 'claude' ... | 5,671 | 466 | 607 | 10.7% |
grep の生存率として 8.3% と書いた数字は、正しくは 10.7% でした。find の側は ASCII 出力なので 66.8%(再測で 65.7%)のまま変わりません。
方向としてはバイト数の方が安全側に振れます。実際より境界に近く見えるので、越えているものを見落とすことはありません。逆に振れるのは「まだ 0.9 倍だから大丈夫」と判断するときです。日本語の比率が高い出力ほど、バイト数は実際の位置より手前を指します。
対処は 1 行です。wc -c ではなく wc -m を使います。
# 誤: バイト数。日本語混じりの出力では境界より大きく出る
grep -rn 'claude' content/articles/ja --include='*.mdx' | wc -c # 1218722
# 正: 文字数。退避の境界と同じ単位
grep -rn 'claude' content/articles/ja --include='*.mdx' | wc -m # 945213
ただし wc -m はロケール依存で、LC_ALL=C の下では wc -c と同じ値を返します。ここでの計測は LANG=C.UTF-8 で行いました。CI やコンテナのようにロケールが素の環境で回す場合は、wc -m を信じる前に LANG を確認するか、Python の len() で数える方が確実でした。
このあと出てくる封筒のヘッダも、同じ取り違えを引き継いでいます。"bytes": len(raw) というキーは、実際には文字数を入れています。修正版では chars と bytes を両方載せ、境界との比較には chars を使うようにしました。
文字単位のカットは構造を壊す
もう一つ、行指向でない出力を試しました。記事メタデータをまとめた articles.json です。先ほどの単位で言えば 822,582 文字、978,702 バイトあります。
先頭 10 万文字だけを取り出して、パースを試みます。
import json, pathlib
raw = pathlib.Path("src/data/articles.json").read_text()
print(f"bytes={len(raw)} ({len(raw)/100_000:.2f}x)") # bytes=822582 (8.23x)
cut = raw[:100_000]
try:
json.loads(cut)
print("先頭100Kのみ: パース成功")
except json.JSONDecodeError as e:
print(f"先頭100Kのみ: パース失敗 -> {e}")
print(f"カット位置の直前40文字: ...{cut[-40:]!r}")
実行結果です。
bytes=822582 (8.23x)
先頭100Kのみ: パース失敗 -> Expecting property name enclosed in double quotes: line 3087 column 6
カット位置の直前40文字: ...'ull,\n "author": "Claude Lab",\n '
カットは行でもレコードでもなく、文字数で入ります。オブジェクトの途中、キーとキーの隙間で切れました。
行指向のテキストは、削られても劣化が緩やかです。残った 466 行はそれぞれ独立した意味を持ち続けます。構造化データにはその余地がありません。8.23 倍のうち 12% が残ったのではなく、単に壊れたテキストが残っただけです。
JSON を素で吐くツールをエージェントに握らせている場合、境界を越えた瞬間に出力は全損する、と考えて設計するのが安全でした。
境界は動く — 今日通っているコマンドが二ヶ月後に通らない
表の最後、ls -R content は 87,135 バイトで、境界の 0.87 倍でした。今日は退避が起きません。
このリポジトリは 1 記事あたり平均 110.2 バイトずつ、この出力を太らせます。
# 現在値と、境界に到達するまでの残り本数を出す
now=$(ls -R content | wc -c)
ja=$(find content/articles/ja -name '*.mdx' | wc -l)
python3 -c "
n, a = $now, $ja
per = n / a
need = (100_000 - n) / per
print(f'現在 {n} bytes / JA {a} 本 / 1本あたり {per:.1f} bytes')
print(f'到達まで残り約 {need:.0f} 本(日英2本/日なら約 {need/2:.0f} 日)')"
現在 87135 bytes / JA 791 本 / 1本あたり 110.2 bytes
到達まで残り約 117 本(日英2本/日なら約 58 日)
約 2 ヶ月後、このコマンドは黙って向こう側へ渡ります。プロンプトもツール定義も、コードも一行たりとも変わっていないのに、エージェントが受け取る情報の性質が変わる。
私はこの種の劣化がいちばん厄介だと考えています。回帰テストは通ります。ツール呼び出しは成功を返します。変わるのは、答えの正しさだけです。しかも劣化は連続的ではなく、境界をまたいだ日に一気に来ます。
公開から 11 日が経ったので、この見積もりを答え合わせしました。
| 時点 | ls -R content の文字数 | JA記事数 |
| 2026-07-28(記事公開時) | 87,135 | 791 |
| 2026-08-08(再測) | 88,611 | 803 |
12 本増えて 1,476 文字。平均 110.2 文字/本から出した予測値は 88,457 文字で、実測との差は 154 文字、0.17% でした。11 日というごく短い区間ではありますが、この用途に線形の見積もりで足りることは確かめられました。
ただし増分そのものを見ると、1 本あたり 123.0 文字です。平均の 110.2 より 12% 高い。平均には、ディレクトリ行だけがあって記事が薄かった頃の期間が混ざっているためでした。境界までの残りを出すときは、平均ではなく直近の増分で割る方が保守的です。
| 使うレート | 1本あたり | 到達まで | 日英2本/日での日数 |
| 平均 | 110.2 文字 | 103 本 | 約 52 日 |
| 直近の増分 | 123.0 文字 | 93 本 | 約 46 日 |
到達の予想日で言えば、増分レートで 9 月下旬、平均レートで 9 月末。当初の「約 58 日後」がおよそ 9 月下旬を指していたので、11 日ぶんの実データを足しても着地点はほとんど動きませんでした。見積もりの精度より、見積もりを持っていること自体が効いています。
出力封筒 — 判断材料を先頭へ押し出す
対処の方針は単純でした。先頭カットと戦うのをやめて、先頭カットを前提に出力を組み直します。
判断に必要なものを先頭に置き、詳細は後ろに流し、全文は自分でファイルへ書いてパスを渡す。ツールと モデルの間に挟む「封筒」です。
#!/usr/bin/env python3
"""outbox.py — ツール出力を、判断材料が先頭に来る封筒に詰め直す。
使い方: <command> | python3 outbox.py --label build --tail 20
標準入力を受け取り、ヘッダ・シグナル行・末尾行・先頭抜粋を stdout へ出す。
全文は必ずファイルへ書き、そのパスをヘッダに載せる。
"""
import sys, os, re, json, hashlib, pathlib, argparse
SPILL_LIMIT = 100_000 # 全文がファイルへ退避される閾値
HEADER_BUDGET = 2_000 # 判断用ヘッダに割く上限(文字)
EXCERPT_BUDGET = 12_000 # 先頭抜粋に割く上限(文字)
# 既定のシグナル。テストランナーやビルドツールの語彙に合わせて --signal で差し替える
ERROR_RE = re.compile(r"\b(error|failed|failure|fatal|panic|exception)\b", re.I)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--label", default="tool-output")
ap.add_argument("--dir", default=os.environ.get("OUTBOX_DIR", "/tmp/outbox"))
ap.add_argument("--signal", default=None, help="重要行を拾う正規表現(既定はエラー語)")
ap.add_argument("--tail", type=int, default=20, help="末尾から常に残す行数")
args = ap.parse_args()
raw = sys.stdin.read()
lines = raw.splitlines()
# 全文を先に保存する。ここを後回しにすると、途中で落ちたときに何も残らない
digest = hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:12]
outdir = pathlib.Path(args.dir)
outdir.mkdir(parents=True, exist_ok=True)
full = outdir / f"{args.label}-{digest}.txt"
full.write_text(raw, encoding="utf-8", errors="replace")
pat = re.compile(args.signal, re.I) if args.signal else ERROR_RE
hits = [(i, l) for i, l in enumerate(lines, 1) if pat.search(l)]
header = {
"label": args.label,
"bytes": len(raw),
"lines": len(lines),
"spill_ratio": round(len(raw) / SPILL_LIMIT, 2),
"signal_hits": len(hits),
"full_output": str(full), # 全文が要るときはここを読ませる
"tail_lines_included": min(args.tail, len(lines)),
}
parts = ["<<<OUTBOX-HEADER",
json.dumps(header, ensure_ascii=False, indent=2),
"OUTBOX-HEADER"]
if hits:
parts.append(f"--- signal lines (first 40 of {len(hits)}) ---")
parts += [f"{i}: {l[:300]}" for i, l in hits[:40]]
# CLI が結論を置く場所。ここを落とすと封筒の意味がなくなる
parts.append(f"--- tail {header['tail_lines_included']} lines ---")
parts += lines[-args.tail:]
body = "\n".join(parts)
if len(body) > HEADER_BUDGET + EXCERPT_BUDGET:
body = body[: HEADER_BUDGET + EXCERPT_BUDGET] + "\n--- excerpt truncated; read full_output ---"
sys.stdout.write("\n".join([
body,
f"--- head excerpt ({EXCERPT_BUDGET} chars max) ---",
raw[:EXCERPT_BUDGET],
]))
if __name__ == "__main__":
main()
同じ 2 コマンドを封筒に通した実測です。
| コマンド | 素の出力 | 封筒あり | 比率 | 要約行の生存 |
find ... -exec wc -c {} + | 149,838 | 17,182 | 11.5% | あり |
grep -rn 'claude' ... | 1,201,154 | 31,279 | 2.6% | (要約行なし) |
find の側は 11.5% に畳まれ、658964 total が末尾ブロックに残りました。エージェントは合計値を見た上で答えられます。
grep の側で起きたことの方が、実務上は効きました。1,201,154 バイト、境界の 12 倍あった出力が 31,279 バイトになり、退避そのものが発生しなくなります。ファイルを読み直す往復が消え、全文はいつでもパス経由で取りに行ける状態が残ります。
封筒が結論行を落とすとき — 予算は後ろから配ってはいけない
この封筒をしばらく回してから、欠陥に気づきました。いちばん守りたかった行を、いちばん守りたい場面で落とします。
組み立ての順番を追うと分かります。ヘッダ、シグナル行(最大 40 件・各 300 文字)、末尾ブロックの順に parts へ積み、最後に全体を 14,000 文字で切っています。切るのは末尾からです。つまり parts の最後に置いた末尾ブロック — 結論行が入っている唯一のブロック — が真っ先に削られます。
シグナル行が多く、かつ 1 行が長いときにこれが起きます。エラーが大量に出た本番のビルドログやデプロイログが、まさにその形です。
1,200 行の疑似ビルドログ(最終行に BUILD FAILED: 3 errors, 12 warnings in 48.2s、エラー行 40 件)を作り、シグナル行の長さだけを変えて通しました。
| シグナル行の長さ | 封筒の出力(文字) | 末尾ブロックの残存 | 結論行 |
| 200 | 24,414 | 20 / 20 | 残る |
| 220 | 25,594 | 20 / 20 | 残る |
| 240 | 26,084 | 18 / 20 | 消える |
| 260 | 26,084 | 13 / 20 | 消える |
| 280 | 26,084 | 10 / 20 | 消える |
| 300 | 26,084 | 6 / 20 | 消える |
220 と 240 の間に境目があります。これより長い行が並ぶと、末尾ブロックが後ろから静かに削れていきます。エラーが増えるほど、結論が見えなくなる設計でした。
同時に、出力が 26,084 文字で頭打ちになっている点にも目を留めてください。予算として書いた 14,000 文字は body にしか効いていません。そのあとに先頭抜粋 12,000 文字が無条件で足されるため、実際の出力は約 26,000 文字です。
末尾の結論行を削りながら、先頭抜粋には 12,000 文字を割いている。「先頭カットは最も情報密度の高い一行を狙い撃ちにする」と書いた当の記事が、その構造を封筒の中で作り直していたことになります。
直し方は、予算を後ろからではなく先に確保することでした。
#!/usr/bin/env python3
"""outbox2.py — 末尾ブロックを先に確保してから、残りを配る封筒。"""
import sys, os, re, json, hashlib, pathlib, argparse
SPILL_LIMIT = 100_000
TOTAL_BUDGET = 14_000 # 先頭抜粋まで含めた出力全体の上限
TAIL_BUDGET = 3_000 # 末尾ブロックに先に取り置く分
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--label", default="tool-output")
ap.add_argument("--dir", default=os.environ.get("OUTBOX_DIR", "/tmp/outbox"))
ap.add_argument("--signal", default=None)
ap.add_argument("--tail", type=int, default=20)
args = ap.parse_args()
raw = sys.stdin.read()
lines = raw.splitlines()
digest = hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:12]
outdir = pathlib.Path(args.dir)
outdir.mkdir(parents=True, exist_ok=True)
full = outdir / f"{args.label}-{digest}.txt"
full.write_text(raw, encoding="utf-8", errors="replace")
pat = re.compile(args.signal, re.I) if args.signal else re.compile(
r"\b(error|failed|failure|fatal|panic|exception)\b", re.I)
hits = [(i, l) for i, l in enumerate(lines, 1) if pat.search(l)]
# 1. 末尾ブロックを先に確保する。ここが削られたら封筒の意味がない
tail_lines, used = [], 0
for l in reversed(lines[-args.tail:]):
if used + len(l) + 1 > TAIL_BUDGET:
break
tail_lines.insert(0, l)
used += len(l) + 1
tail_block = [f"--- tail {len(tail_lines)}/{min(args.tail, len(lines))} lines ---"] + tail_lines
header = {
"label": args.label,
"chars": len(raw), # 境界と同じ単位。比較にはこちらを使う
"bytes": len(raw.encode("utf-8")),
"lines": len(lines),
"spill_ratio_chars": round(len(raw) / SPILL_LIMIT, 2),
"signal_hits": len(hits),
"full_output": str(full),
"tail_lines_included": len(tail_lines),
"tail_lines_dropped": min(args.tail, len(lines)) - len(tail_lines),
}
head_block = ["<<<OUTBOX-HEADER",
json.dumps(header, ensure_ascii=False, indent=2),
"OUTBOX-HEADER"]
# 2. 残った予算をシグナル行へ。入りきらない分は件数で伝える
fixed = len("\n".join(head_block)) + len("\n".join(tail_block)) + 2
sig_budget = max(0, TOTAL_BUDGET - fixed)
sig, used, shown = [], 0, 0
for i, l in hits:
e = f"{i}: {l[:300]}"
if used + len(e) + 1 > sig_budget:
break
sig.append(e)
used += len(e) + 1
shown += 1
sig_block = ([f"--- signal lines ({shown} of {len(hits)} shown) ---"] + sig) if hits else []
if hits and shown < len(hits):
sig_block.append(f"--- {len(hits) - shown} more signal lines omitted; read full_output ---")
# 3. 先頭抜粋は最後。余りがあるときだけ載せる
body = "\n".join(head_block + sig_block + tail_block)
room = max(0, TOTAL_BUDGET - len(body))
out = [body]
if room > 500:
out += [f"--- head excerpt ({room} chars) ---", raw[:room]]
sys.stdout.write("\n".join(out))
if __name__ == "__main__":
main()
同じ入力を新旧で通した実測です。
| シグナル行の長さ | 旧: 出力 | 旧: 結論行 | 新: 出力 | 新: 結論行 | 新: シグナル行の表示 |
| 120 | 19,694 | 残る | 14,035 | 残る | 41 / 41 |
| 240 | 26,084 | 消える | 14,034 | 残る | 41 / 41 |
| 280 | 26,084 | 消える | 13,813 | 残る | 37 / 41 |
| 300 | 26,084 | 消える | 13,839 | 残る | 35 / 41 |
結論行はすべての条件で残り、出力は 14,000 文字前後に収まりました。シグナル行が入りきらない場合は「41 件中 35 件を表示」と件数を明示し、残りは full_output を読ませます。全部見えているとモデルに誤解させないことが、ここでは末尾を守るのと同じくらい大事でした。
実データでも確かめています。
| コマンド | 素の出力 | 旧封筒 | 新封筒 | total 行 |
find ... -exec wc -c {} + | 152,216 | 17,888 | 14,035 | 新旧とも残存 |
grep -rn 'claude' ... | 945,213 | 23,240 | 13,924 | (要約行なし) |
この 2 つでは旧封筒も結論行を落としていません。欠陥がこの 2 コマンドで表面化しなかったからこそ、公開時点で気づけなかったのだと思います。封筒を検証するときは、普段どおりの出力ではなく、シグナル行が密で 1 行が長い「壊れたときのログ」を通してください。正常系だけで確かめた安全機構は、異常系で外れます。
構造化データには別の封筒が要る
JSON にはテキスト用の封筒が使えません。末尾 20 行を足しても妥当な JSON にはならないからです。
構造化データの封筒は、要約を「値」ではなく「形」で作ります。件数・キー一覧・忠実度を落とさない少数のサンプル。そして出力自体が必ずパースできることを、封筒の中で自己検証します。
#!/usr/bin/env python3
"""json_outbox.py — 大きなJSONを、妥当なJSONのまま畳む封筒。"""
import json, sys, hashlib, pathlib, collections
def envelope(path, keep=8, outdir="/tmp/outbox"):
raw = pathlib.Path(path).read_text()
data = json.loads(raw)
# トップレベルが配列でなくても、最初に見つかった配列を対象にする
items = data if isinstance(data, list) else next(
(v for v in data.values() if isinstance(v, list)), [])
digest = hashlib.sha256(raw.encode()).hexdigest()[:12]
outdir = pathlib.Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
full = outdir / f"json-{digest}.json"
full.write_text(raw)
# 全件走査すると大きなファイルで無駄に遅い。形の把握には先頭2000件で十分だった
keys = collections.Counter()
for it in items[:2000]:
if isinstance(it, dict):
keys.update(it.keys())
body = {
"header": {
"source": str(path),
"bytes": len(raw),
"item_count": len(items),
"keys": [k for k, _ in keys.most_common()],
"full_output": str(full),
"sample_policy": f"first {keep} items, full fidelity",
},
"sample": items[:keep],
}
return json.dumps(body, ensure_ascii=False, indent=1), len(raw)
if __name__ == "__main__":
out, n = envelope(sys.argv[1])
json.loads(out) # 自己検証: 封筒の出力は必ずパースできること
print(f"raw={n} envelope={len(out)} ratio={len(out)/n*100:.2f}% parse=OK")
raw=822582 envelope=5688 ratio=0.69% parse=OK
822,582 文字が 5,688 文字、0.69% になりました。しかも出力は妥当な JSON です。素の先頭カットが JSONDecodeError を返していたのと対照的でした。
ここで二つ目の、予想と違った発見がありました。
最初はサンプル件数を 50 件ほど入れていました。件数を減らすと情報が落ちる、という思い込みです。実際に使ってみると、8 件と 50 件で判断の質が変わりませんでした。エージェントがこの封筒から取り出しているのは「どんなキーがあるか」「全部で何件か」で、個々のレコードの中身ではなかったのです。
サンプルは形を示すためのもので、内容を伝えるためのものではありませんでした。件数を 8 に落として空いた予算は、キー一覧の網羅性に回した方が効きます。
運用に落とすときの判断
すべてのツールに封筒をかける必要はありません。私はこの基準で切り分けています。
- 封筒をかける: 出力サイズが対象の規模に比例して伸びるもの。
grep / find / git log / ログ取得 / API のリストエンドポイント。今日 0.5 倍でも、半年後に 2 倍を超えます。
- かけない: 出力が定数サイズに収まるもの。設定値の取得、単一レコードの参照、ステータス確認。封筒のヘッダの方が中身より大きくなり、かえって読みにくくなります。
- 順序に意味がある出力は
--tail を厚くする: 時系列ログは末尾が現在です。既定の 20 行では足りない場面があり、デプロイログでは 60 行に上げました。
もう一点、spill_ratio をヘッダに載せているのは、境界への距離を運用中に可視化するためです。0.9 を超えた値がログに出てきたら、そのツールは近いうちに向こう側へ渡ります。到達してから気づくより、二ヶ月前に気づける方がずっと楽でした。
個人開発で自動化を回していると、この手の「静かに壊れる」経路を一つ潰すたびに、任せられる範囲が目に見えて広がります。エラーを出して止まってくれる不具合より、成功を返しながら間違える不具合の方に、時間を取られてきました。
手を動かすなら、まず自分のツール群を一度そのまま流して、文字数を数えるところからをお勧めします。
for c in "grep -rn TODO ." "find . -type f -exec wc -c {} +" "git log --stat -50"; do
n=$(eval "$c" 2>/dev/null | wc -m) # -c ではなく -m。境界は文字数で引かれている
printf "%-40s %10d %5.2fx\n" "${c:0:38}" "$n" "$(python3 -c "print($n/100000)")"
done
1.0 を超えている行があれば、そのツールの出力からは要約行が消えています。1.0 に届いていない行は、いつ届くかを見積もる番です。
私自身まだ測りきれていない領域が残っていますが、境界の位置を数字で把握しておくだけで、エージェントの答えを疑う勘所がずいぶん変わりました。お読みいただきありがとうございました。
参考リンク