3行で
- セッションログ 9,964 件のツール結果をターン位置で分けたら、エラー率が 5.8% → 1.6% ときれいに単調減少した。「エージェントは会話が進むほど安定する」という結論が書けそうだった。
- **書けなかった。**最後のバケットに寄与しているセッションは 333 本中 14 本しかなく、その 68.5% はうち 3 本分だった。
- 同じセッションの中で前半と後半を比べると、後半が低い 26 本 / 高い 20 本、符号検定 p = 0.46。下がっているのはセッション内のエラー率ではなく、エラーの多いセッションが早く終わっているだけだった。
きれいなグラフが出たとき
エージェントの挙動を測っていて、「ターンが進むにつれてエラー率がどう変わるか」を見た。結果がこれ。
① 素朴なバケット(全セッションをプールし、ターン位置で分ける)
1-10: 134/2329 = 5.8%
11-30: 57/2306 = 2.5%
31-60: 28/1360 = 2.1%
61-100: 16/771 = 2.1%
101+: 51/3198 = 1.6%
単調減少。サンプル数もそれなりにある(一番少ないバケットで 771 件)。「序盤は探索的なので失敗が多く、文脈が溜まると安定する」という説明もすぐ思いつく。
「長い会話ほどエージェントは劣化する」という通説とは逆の結果でもあるので、そこそこ面白い。このまま書けば記事になる。
バケットごとに、何本のセッションが寄与しているかを数えるまでは、そう思っていた。
分母に何が入っているか
1-10: 134/2329 = 5.8% 寄与セッション 333
11-30: 57/2306 = 2.5% 寄与セッション 175
31-60: 28/1360 = 2.1% 寄与セッション 68
61-100: 16/771 = 2.1% 寄与セッション 24
101+: 51/3198 = 1.6% 寄与セッション 14
333 → 175 → 68 → 24 → 14。
一番右のバケットは 3,198 件と、一番多くのデータを持っている。だがそれは 14 本のセッションから出ている。しかもそのうち 68.5% は上位 3 本(ツール結果 927 件・839 件・671 件の3本)が出したものだった。
つまりこの 1.6% は「101 手目以降のエラー率」ではなく、**「特定の 3 本のセッションのエラー率」**だ。
そして左右のバケットは、そもそも違うセッションの集合を見ている。左端は 333 本全部、右端は 14 本。同じ母集団の時間変化ではない。
なぜ左が高いのか
バケット 1-10 が 5.8% と高いのは、「序盤だから」なのか。
セッションの長さ別に、そのセッション全体のエラー率を見るとこうなる。
② セッションの長さ別に、そのセッション『全体』のエラー率を見る
1-10手 158本 平均 10.92% 中央値 0.00%
11-20手 68本 平均 4.00% 中央値 0.00%
21-40手 54本 平均 1.17% 中央値 0.00%
41-100手 39本 平均 3.48% 中央値 2.86%
101+手 14本 平均 1.59% 中央値 1.50%
10 手以内で終わったセッションは、全体を通してエラー率 10.92%。
そしてこの 158 本は、バケット 1-10 にしか現れない。11 手目が存在しないからだ。
つまり①の勾配は、こう読める。
- バケット 1-10 には、エラー率の高い短命なセッション 158 本が丸ごと入っている。
- 右のバケットに行くほど、それらは脱落していく。
- 残るのは、最後まで走り切ったセッションだけ。
エラー率が下がっているのではなく、エラー率の低いセッションだけが残っている。 生存者バイアスそのものだった。
(平均 10.92% に対して中央値 0.00% なのは、短いセッションの大半はエラー 0 で、一部が「1手打って失敗して終わり」= 100% になっているため。分母が小さいと比率は跳ねる。)
同じセッションの中で比べる
正しい問いは「ターン位置とエラー率に関係があるか」ではなく、**「同じセッションの中で、後半のほうがエラー率が低いか」**だ。セッションをまたいで比べる限り、脱落の影響は消えない。
40 手以上のセッション 54 本について、前半 20 手と 21 手目以降をセッションごとに対応させて比べた。
③ 同じセッションの中で前半20手と後半を比べる(40手以上の 54 本)
プールすると 前半 3.98% 後半 2.08%
対応をとると 前半 中央値 5.00% 後半 中央値 1.78%
後半が低い 26 / 高い 20 / 同じ 8 符号検定 p = 0.4614
ここが面白いところで、プールすると効果があるように見える(3.98% → 2.08% でほぼ半減)。中央値で見ても 5.00% → 1.78% で下がっている。
だがセッション 1 本を 1 票として数えると、26 対 20。8 本は変わらず。符号検定で p = 0.46。偶然と区別がつかない。
プールが効果を作っている理由も同じで、手数の多いセッションの後半が、後半側の分母を支配している。①でやったことを、規模を小さくしてもう一度やっているにすぎない。
中央値の差(5.00% → 1.78%)も見かけほど強くない。前半は 20 手しかないので、取りうる値が 0%, 5%, 10%… と 5% 刻みになる。「エラー 1 回」で 5% になる。後半は手数が多いぶん細かい値を取る。粒度の違う2つの中央値を比べている。
結局、何が言えるのか
言えるのは、これだけ。
- セッション内で、後半のエラー率が下がるとは言えない。(p = 0.46)
- 短いセッションはエラー率が高い。(10 手以内で平均 10.92%、101 手以上で 1.59%)
- ただしこれも因果ではない。失敗したから短く終わったのか、短く終わる用件だから失敗が目立つのかは、この計測では分けられない。
「長い会話でエージェントが劣化する/しない」という問いに対しては、このデータでは答えが出ない。それが正直な結論になる。
サブエージェントも同じ罠を持っている
同じコーパスで、もうひとつ分母の問題があった。
assistant 発話 18,462 / サブエージェント 5,373 = 29.1%
ツール結果 9,841 / サブエージェント 3,093 = 31.4%
エラー率: 親 3.48% サブ 1.45%
ツール結果の 31.4% はサブエージェントが出したもので、そのエラー率は親の 4 割強しかない。
サブエージェントは、仕様が固まったタスクを受け取って、それだけをやって終わる。探索がない。だからエラーが少ない。
これを混ぜたまま「全体のエラー率は 2.9%」と言うと、性質の違う 2 つの母集団を 1 つの数字にしていることになる。しかも混ぜると数字は下がる方向に動く。
短いセッションはエラー率が高く、サブエージェントはエラー率が低い。どちらも「全体」に混ざっている。「全体の何%」という数字を出す前に、その中に何本の、どういう性質のものが入っているかを数える。
確かめるスクリプト
3通りの数え方を並べて出す。①がきれいに見えて③で消えるなら、それは生存者バイアスだ。
#!/usr/bin/env python3
"""「ターンが進むほどエラー率が下がる」が本当かを、3通りの数え方で確かめる。
python3 bucket_trap.py [~/.claude/projects]
"""
import sys, os, glob, json, collections, statistics
from math import comb
BUCKETS = [(1, 10), (11, 30), (31, 60), (61, 100), (101, 10 ** 9)]
def label(lo, hi):
return f"{lo}-{hi}" if hi < 10 ** 9 else f"{lo}+"
def sessions(root):
"""1ファイル = 1つの流れ。ツール結果の成否を出た順に並べる。"""
out = {}
for path in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
seq = []
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
try:
ev = json.loads(line)
except ValueError:
continue
content = (ev.get("message") or {}).get("content")
if not isinstance(content, list):
continue
for blk in content:
if isinstance(blk, dict) and blk.get("type") == "tool_result":
seq.append(bool(blk.get("is_error")))
if seq:
out[path] = seq
return out
def sign_test(pairs):
dec = sum(1 for a, b in pairs if b < a)
inc = sum(1 for a, b in pairs if b > a)
m, k = dec + inc, min(dec, inc)
p = sum(comb(m, i) for i in range(k + 1)) / 2 ** m * 2 if m else 1.0
return dec, inc, len(pairs) - dec - inc, min(p, 1.0)
def main():
root = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/projects")
S = sessions(root)
if not S:
print("ツール結果が見つからない。パスを確認する。")
return 1
seqs = list(S.values())
print(f"セッション {len(seqs)} 本 / ツール結果 {sum(len(v) for v in seqs):,} 件\n")
# ① 素朴なバケット。全セッションを混ぜて、ターン位置で分ける。
print("① 素朴なバケット(全セッションをプールし、ターン位置で分ける)")
tot = collections.Counter()
err = collections.Counter()
contrib = collections.Counter()
for seq in seqs:
for i, e in enumerate(seq, 1):
for lo, hi in BUCKETS:
if lo <= i <= hi:
tot[(lo, hi)] += 1
err[(lo, hi)] += e
break
for lo, hi in BUCKETS:
if len(seq) >= lo:
contrib[(lo, hi)] += 1
for lo, hi in BUCKETS:
k = (lo, hi)
if tot[k]:
print(f" {label(lo, hi):>7}: {err[k]:>4}/{tot[k]:<6} = {err[k] / tot[k] * 100:>5.1f}%"
f" 寄与セッション {contrib[k]}")
print(" → 右のバケットほど、寄与しているセッションが減っていく。")
# ② セッションの長さと、そのセッション全体のエラー率
print("\n② セッションの長さ別に、そのセッション『全体』のエラー率を見る")
for lo, hi in [(1, 10), (11, 20), (21, 40), (41, 100), (101, 10 ** 9)]:
g = [sum(v) / len(v) for v in seqs if lo <= len(v) <= hi]
if g:
print(f" {label(lo, hi):>7}手 {len(g):>4}本 平均 {statistics.mean(g) * 100:>5.2f}%"
f" 中央値 {statistics.median(g) * 100:>5.2f}%")
print(" → 短いセッションほどエラー率が高い。そして短いセッションは")
print(" 左のバケットにしか現れない。①の勾配はこれで説明がつく。")
# ③ 同じセッションの中で、前半と後半を比べる(対応あり)
HALF = 20
long_ = [v for v in seqs if len(v) >= HALF * 2]
if long_:
pairs = [(sum(v[:HALF]) / HALF, sum(v[HALF:]) / (len(v) - HALF)) for v in long_]
dec, inc, tie, p = sign_test(pairs)
E = [a for a, _ in pairs]
L = [b for _, b in pairs]
pe = sum(sum(v[:HALF]) for v in long_) / (HALF * len(long_))
pl = (sum(sum(v[HALF:]) for v in long_)
/ sum(len(v) - HALF for v in long_))
print(f"\n③ 同じセッションの中で前半{HALF}手と後半を比べる"
f"({HALF * 2}手以上の {len(long_)} 本)")
print(f" プールすると 前半 {pe * 100:.2f}% 後半 {pl * 100:.2f}%")
print(f" 対応をとると 前半 中央値 {statistics.median(E) * 100:.2f}%"
f" 後半 中央値 {statistics.median(L) * 100:.2f}%")
print(f" 後半が低い {dec} / 高い {inc} / 同じ {tie} 符号検定 p = {p:.4f}")
print(" → p が大きければ、セッションの中で下がっているとは言えない。")
return 0
if __name__ == "__main__":
sys.exit(main())
この記事の限界
- 私1人のコーパスで、セッション 333 本・ツール結果 9,964 件。40 手以上のセッションは 54 本しかない。検定の力が弱く、p = 0.46 は「効果が無い」の証明ではない。「このデータでは言えない」までしか言えない。
- **符号検定は効果量を見ない。**方向だけを数えている。効果があっても小さければ検出しにくい。
- セッションの区切りは1ファイル単位にした。再開や分岐で1つの作業が複数ファイルに分かれると、そこは正しく数えられていない。
-
「エラー」の定義は
is_errorフラグそのまま。別の記事で書いたとおり、このフラグは承認ゲートも「出力はあるのに exit≠0」も同じ 1 として数えるので、意味の異なるものが混ざっている。
まとめ
きれいな単調減少が出たら、各点に何本のセッションが寄与しているかを数える。
- 減っていたのはエラー率ではなく、生き残っているセッションの本数だった(333 → 14)。
- 一番データの多いバケットが、一番少ないセッションから出ていた。しかもその 68.5% は 3 本分。
- 同じセッションの中で対応をとると、26 対 20、p = 0.46。効果は消えた。
- プールした比較は、手数の多い個体に支配される。個体を 1 票として数える。
グラフの縦軸を疑う前に、横軸の各点の分母を見る。
関連
「規約を入れたらエラーが減った」も、同じ形で崩れます。入れた前後で母集団が入れ替わっていれば、効果はいくらでも出せます。
測定結果をどう読むかと、規約が効かなくなる4つのパターンをまとめた本があります。
→ AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)
第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。