1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

エージェントを止めたのは1.19%だけ。でも止めたときの61%は、そのまま会話を閉じていた

1
Posted at

エージェントが変な方向に走り出したとき、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: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?