エージェントが変な方向に走り出したとき、ESC で止める。あれを何回やったか数えた。
ログには [Request interrupted by user] という印が残る。5,958本の会話を全部見た。
止めた回数
=== 会話 5,958 本 / 人間の発話 10,993 回
中断された会話 121 = 2.0%
中断の総数 131 = 人間の発話の 1.19%
中断された会話1本あたり 中央値 1 / 最大 7
1.19%。 打った10,993回のうち131回。会話の本数で見ても2.0%。
思っていたより少ない。体感ではもっと止めている気がしていた。印象に残るからだろう。
止めた瞬間、何をしていたか
=== 何をしている最中に止めたか
考えている途中で止めた 107 = 81.7%
ツール実行中に止めた 24 = 18.3%
=== 止めた瞬間に走っていたツール 上位8
(ツールなし=文章の途中) 29 = 27.9%
Bash 24 = 23.1%
Read 20 = 19.2%
Grep 18 = 17.3%
AskUserQuestion 6 = 5.8%
Agent 4 = 3.8%
Edit 1 = 1.0%
Skill 1 = 1.0%
81.7%は、ツールを実行していない最中に止めている。「危ないコマンドを実行しようとしたから止めた」ではない。方針が違うと分かった時点で止めている。
裏付けとして、Edit の最中に止めたのは1回だけ、Write はゼロ。書き換えが始まってから止めたことはほぼ無い。 止めるのは Read / Grep / Bash(27.9+23.1+19.2+17.3 = 87.5%)、つまり調べている段階だ。
AskUserQuestion を止めたのが6回あるのは、選択肢そのものが的外れだった回だと思う。
止めたあと
ここが数えてみて一番意外だった。
=== 止めたあとどうしたか
そのまま会話を捨てた 80 = 61.1%
指示を打ち直した 51 = 38.9%
打ち直した文字数 中央値 47 字
捨てた 80 件のうち、中断印がファイルの最終行だった 68 = 85.0%
61.1%は、止めたあと何も打っていない。しかもそのうち85.0%は、中断の印がログファイルの最終行だった。ESC を押して、そのまま閉じている。
止めるのは「軌道修正」だと思っていた。実際は打ち切りのほうが多かった。
会話を続けた38.9%のほうも見ておく。打ち直した指示は中央値47字。普段のプロンプトは中央値26字だから、約1.8倍長い。止めたあとは、説明を足している。
止めているのは、ほとんど人間ではなかった
別に数えたツールのエラー4,290件の中に、こういうものがあった。
811 This command changes directory before running git, which can execute untrusted h
389 Permission for this action was denied by the Claude Code auto mode classifier
合計1,200件。これは仕組みが自動で止めた回数だ。
- 人間が自分の手で止めた: 131回
- 承認ゲートが止めた: 1,200回
9.2倍。 止める仕事のほとんどはゲートがやっていて、人間の手はその1割強しか動いていない。
そしてゲートが止めたときは会話が続く(許可を出すか、書き方を変えて再実行する)のに対し、人間が止めたときは6割が終わる。同じ「止める」でも、意味が正反対だった。
スクリプト
#!/usr/bin/env python3
"""ユーザーがエージェントを止めた回数を数える。
~/.claude/projects/**/*.jsonl の user ロールのうち、本文が
「[Request interrupted by user」で始まるレコードが中断の印。
何を実行中に止められたか、止めた直後に人間が何をしたかまで見る。
出力は件数と比率のみ。コマンド本体やパスは出さない。
"""
import json, os, collections, statistics
ROOT = os.path.expanduser("~/.claude/projects")
MARK = "[Request interrupted by user"
def text_of(msg):
"""message.content から素のテキストだけを取り出す。"""
c = msg.get("content")
if isinstance(c, str):
return c
if isinstance(c, list):
return "".join(b.get("text", "") for b in c
if isinstance(b, dict) and b.get("type") == "text")
return ""
def tools_of(msg):
c = msg.get("content")
if not isinstance(c, list):
return []
return [b.get("name") for b in c
if isinstance(b, dict) and b.get("type") == "tool_use"]
files = []
for d, _, fs in os.walk(ROOT):
files += [os.path.join(d, f) for f in fs if f.endswith(".jsonl")]
sessions = 0 # 会話の本数
interrupted_sess = 0 # 1回でも止められた本数
n_int = 0 # 中断の総数
kinds = collections.Counter() # 中断の種類(ツール中か否か)
before = collections.Counter() # 直前に走っていたツール
after = collections.Counter() # 中断直後の人間の一手
per_sess = [] # 会話1本あたりの中断回数
human_turns = 0 # 人間の発話数(中断印を除く)
after_len = [] # 中断後に打ち直した文字数
tail = [] # 捨てた場合、中断印の後ろに残るレコード数
for p in files:
recs = []
try:
with open(p, encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line or not line.startswith("{"):
continue
try:
recs.append(json.loads(line))
except Exception:
pass
except Exception:
continue
if not recs:
continue
sessions += 1
hits = 0
for i, r in enumerate(recs):
m = r.get("message") or {}
if r.get("type") != "user" and m.get("role") != "user":
continue
t = text_of(m)
if not t.startswith(MARK):
if t and not t.startswith("<") and m.get("role") == "user":
human_turns += 1
continue
hits += 1
n_int += 1
kinds["ツール実行中に止めた" if "for tool use" in t[:60]
else "考えている途中で止めた"] += 1
# 直前のアシスタント発話で走っていたツール
for j in range(i - 1, max(-1, i - 6), -1):
am = recs[j].get("message") or {}
if am.get("role") == "assistant":
names = tools_of(am)
if names:
before[names[-1]] += 1
else:
before["(ツールなし=文章の途中)"] += 1
break
# 中断のあと、人間がもう一度何か打ったか(最後まで探す)
nxt = None
for j in range(i + 1, len(recs)):
nm = recs[j].get("message") or {}
if nm.get("role") != "user":
continue
nt = text_of(nm).strip()
if not nt or nt.startswith(MARK) or nt.startswith("<"):
continue
nxt = nt
break
if nxt is None:
after["そのまま会話を捨てた"] += 1
tail.append(len(recs) - i - 1)
else:
after_len.append(len(nxt))
after["/ で始まるコマンド" if nxt.startswith("/")
else "指示を打ち直した"] += 1
if hits:
interrupted_sess += 1
per_sess.append(hits)
print(f"=== 会話 {sessions:,} 本 / 人間の発話 {human_turns:,} 回")
print(f" 中断された会話 {interrupted_sess:6,} = "
f"{interrupted_sess/sessions*100:5.1f}%")
print(f" 中断の総数 {n_int:6,} = 人間の発話の "
f"{n_int/max(1,human_turns)*100:.2f}%")
if per_sess:
print(f" 中断された会話1本あたり 中央値 {statistics.median(per_sess):.0f} / "
f"最大 {max(per_sess)}")
print("\n=== 何をしている最中に止めたか")
for k, v in kinds.most_common():
print(f" {k:24} {v:5,} = {v/n_int*100:5.1f}%")
print("\n=== 止めた瞬間に走っていたツール 上位8")
for k, v in before.most_common(8):
print(f" {str(k):24} {v:5,} = {v/max(1,sum(before.values()))*100:5.1f}%")
print("\n=== 止めたあとどうしたか")
tot = sum(after.values())
for k, v in after.most_common():
print(f" {k:24} {v:5,} = {v/max(1,tot)*100:5.1f}%")
if after_len:
print(f" 打ち直した文字数 中央値 {statistics.median(after_len):.0f} 字")
if tail:
print(f" 捨てた {len(tail)} 件のうち、中断印がファイルの最終行だった "
f"{tail.count(0)} = {tail.count(0)/len(tail)*100:.1f}%")
この計測の限界
- ESC を押しても印が残らない場合がある。押した瞬間にストリームが切れると、レコード自体が書かれずに終わることがありうる。131件は下限で、実際にはもっと押している可能性が高い。
- 「そのまま会話を捨てた」の中には、別の会話を開いて続きをやったケースが含まれる。会話をまたいだ追跡はしていないので、これを全部「諦めた」とは読めない。
- 30日ぶんのログしか残っていない。
- 中断の直後に人間が打ったものは、
<...>で始まるシステム由来のものを除外して探している。除外の判定を間違えていれば、捨てた/続けたの比率はずれる。
結論
止めた回数は1.19%と少なかった。少ないのは、うまくいっているからではなく、止める前に仕組みが止めているからだった(9.2倍)。
そして人間が自分で止めたときは、その6割がそこで終わっていた。ESC は修正のボタンではなく、終了のボタンとして使われていた。
関連
人間が止めた回数の9.2倍を、仕組みのほうが止めていました。どこに門を置くかで、人間が画面を見ている必要があるかが変わります。
止まる場所の設計と、規約が効かなくなる4つのパターンをまとめた本があります。
→ AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)
第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。