0
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?

Edit の成功率は 99.0% だった。そのうち半分は、自分がさっき書いた文字列を書き直していた

0
Posted at

3行で

  • 手元のセッションログ 389 本から Edit 1,239 件を取り出した。失敗は 12 件、1.0%。ほぼ全部通っている。
  • だが 50.0% は、同じセッションの中でエージェント自身が書いた文字列を書き直したものだった。
  • しかもその大半は直後の修正ではない。書いてから直すまで中央値 16 手。1手以内は 3.4% しかない。

成功率は「確定率」ではない

Edit の結果は二値で返る。置換対象が見つかれば成功、見つからなければ String to replace not found in file. で失敗。この成功率を測ると 99.0% になる。

ログ 389 ファイル / Edit 1239 件
  失敗 12 = 1.0%
       5 <tool_use_error>String to replace not found in file.
       2 <tool_use_error>This agent is isolated in the worktree /User
       2 <tool_use_error>File has not been read yet. Read it first be
       1 Permission for this action was denied by the Claude Code aut
       1 <tool_use_error>File does not exist. Note: your current work
       1 <tool_use_error>Found 2 matches of the string to replace, bu

12 件しか落ちていない。しかもその半分は編集の失敗ですらない(worktree の隔離、未読ファイル、権限)。「置換対象が見つからなかった」は 5 件、0.4%。

この数字は「エージェントの編集はほぼ確実に成功する」と読める。実際、置換操作としては成功している。だが成功した編集が、正しい編集だったかは何も言っていない。

「自分が書いたものを書き直した」を数える

そこで別の測り方をした。各 Editold_string(これから消す文字列)が、同じセッションの中で、それより前に自分が書いた文字列かどうかを見る。そうなら、その編集は新しい仕事ではなく、やり直しだ。

自分が書いた文字列を、あとで書き直した割合
  [緩] 過去の new_string に含まれる             627 =  50.6%
  [厳] さらに、その時の old_string には無い     619 =  50.0%

半分だ。

緩い判定と厳しい判定を両方出した理由

素朴に「old_string が過去の new_string に含まれるか」だけを見ると、過大評価する

Editnew_string には、置換した部分だけでなく変えなかった前後の文脈も入る。一意に特定するために必要だからだ。

old_string = "def run(x):\n    return x"
new_string = "def run(x):\n    validate(x)\n    return x"
#             ^^^^^^^^^^^^^^^^^^^^^^^^^ この行は元からあった

このあと誰かが def run(x): を書き換えたら、それは new_string に含まれている。だがエージェントが書いた行ではない。元のファイルにあった行だ。

なので、厳しい方は条件を1つ足した。「過去の new_string に含まれ、かつ、その時の old_string には含まれない」。つまり、その編集で新しく現れた文字列だけを「自分が書いた」と数える。

for idx, pold, pnew in reversed(hist[p]):
    if old not in pnew:
        continue
    if lo is None:
        lo = idx
    # new_string には「変えなかった前後の文脈」も入る。
    # その時の old_string にも在った文字列は、元からあった
    # ものなので「自分が書いた」には数えない。
    if old not in pold:
        so = idx
        break

結果は 50.6% → 50.0%。ほとんど動かなかった。

期待は「厳しくしたら大幅に減って、緩い判定は罠だった」だったが、外れた。差は 8 件。やり直しの大半は、文脈の一致ではなく、本当に自分が書いた本体を書き直している。

判定を厳しくして数字が動かないことは、動くことより価値がある。壊そうとして壊れなかった数字だけが使える。

16 手あとに戻ってくる

書いてから直すまでの手数を測った。

書いてから直すまでの手数
  中央値 16 / 平均 29 / 最大 259
    1 手以内    21 =   3.4%
    5 手以内   158 =  25.5%
   10 手以内   243 =  39.3%
   30 手以内   425 =  68.7%

1手以内は 3.4% しかない。

これが一番はっきり出た。もし「書いた直後に typo に気づいて直す」が主なら、分布は 1〜2 手に集中するはずだった。そうなっていない。中央値は 16 手で、3割以上は 30 手より先だ。

16 手のあいだに何が起きるか。ファイルを書き、実行し、出力を読み、別のファイルを触り、また実行する。そのどこかで、さっき書いたものが違うと分かる。 実行して初めて分かるからだ。

書いた時点では、それは合理的な判断だった。動かして初めて情報が来る。だから戻る。

やり直しの半分は無駄ではなく、書く前には得られなかった情報が入ってきた結果だと考えるほうが実態に合う。ただし、その情報が入ってくるのが 16 手あとだということは、16 手ぶんの作業がその前提の上に積まれているということでもある。

1ファイルに 122 回

編集された(ファイル×セッション)の組 175
  1回で終わった 55 = 31.4%
  中央値 2 回 / 最大 122 回

1回の編集で終わったファイルは 31.4% しかない。中央値は 2 回。そして最大は 1つのファイルに 122 回だ。

上位を見ると、コードではなく文書(見積書、日報、HTML、README、スキル定義)が並んでいた。コードは実行すれば合否が返るが、文書は返らない。判定してくれるものが無いファイルほど、編集が終わらない。

これは前に書いた「拘束力の階層」と同じ形をしている。落ちる仕組みが無いものは、いつまでも確定しない。

測るスクリプト

#!/usr/bin/env python3
"""Edit ツールの「やり直し率」を測る。

    python3 edit_rework.py <ログのルート>

同じセッションの中で、エージェント自身が書いた文字列を、後からもう一度
書き直している割合を出す。成功率ではなく、確定率を見るための指標。
"""
import sys, os, json, glob, collections, statistics as st


def blocks(path):
    """1ファイル分の tool_use / tool_result を順番に返す。"""
    names = {}
    for ln in open(path, errors="replace"):
        try:
            d = json.loads(ln)
        except json.JSONDecodeError:
            continue
        c = (d.get("message") or {}).get("content")
        if not isinstance(c, list):
            continue
        for b in c:
            if not isinstance(b, dict):
                continue
            if b.get("type") == "tool_use":
                names[b.get("id")] = b.get("name")
                yield b.get("name"), b.get("input") or {}, None
            elif b.get("type") == "tool_result":
                yield names.get(b.get("tool_use_id")), None, b


def text(b):
    ct = b.get("content")
    if isinstance(ct, list):
        return "".join(x.get("text", "") for x in ct if isinstance(x, dict))
    return ct if isinstance(ct, str) else ""


def main(root):
    files = glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)
    tot = err = loose = strict = 0
    errmsg = collections.Counter()
    gap = []
    per = collections.Counter()          # (ファイル, セッション) -> 編集回数

    for f in files:
        # path -> [(手番, old_string, new_string)]
        hist = collections.defaultdict(list)
        n = 0
        for name, inp, res in blocks(f):
            if res is not None:
                if name == "Edit" and res.get("is_error"):
                    err += 1
                    errmsg[text(res).strip().split("\n")[0][:60]] += 1
                continue
            p = inp.get("file_path")
            if not p:
                continue
            if name == "Write":
                # Write も「自分が書いた」に数える。中身は全部が新規。
                hist[p].append((n, "", inp.get("content", "") or ""))
                n += 1
            elif name == "Edit":
                n += 1
                tot += 1
                per[(f, p)] += 1
                old = (inp.get("old_string", "") or "").strip()
                lo = so = None
                # 短い断片は偶然一致するので数えない
                if len(old) >= 20:
                    for idx, pold, pnew in reversed(hist[p]):
                        if old not in pnew:
                            continue
                        if lo is None:
                            lo = idx
                        # new_string には「変えなかった前後の文脈」も入る。
                        # その時の old_string にも在った文字列は、元からあった
                        # ものなので「自分が書いた」には数えない。
                        if old not in pold:
                            so = idx
                            break
                if lo is not None:
                    loose += 1
                if so is not None:
                    strict += 1
                    gap.append(n - so)
                hist[p].append((n, old, inp.get("new_string", "") or ""))

    if not tot:
        print("Edit が1件も無い")
        return 1

    print(f"ログ {len(files)} ファイル / Edit {tot}")
    print(f"  失敗 {err} = {100 * err / tot:.1f}%")
    for m, c in errmsg.most_common(6):
        print(f"    {c:4d} {m}")
    print()
    print("自分が書いた文字列を、あとで書き直した割合")
    print(f"  [緩] 過去の new_string に含まれる           {loose:5d} = {100 * loose / tot:5.1f}%")
    print(f"  [厳] さらに、その時の old_string には無い   {strict:5d} = {100 * strict / tot:5.1f}%")
    print()
    print("書いてから直すまでの手数")
    print(f"  中央値 {st.median(gap):.0f} / 平均 {st.mean(gap):.0f} / 最大 {max(gap)}")
    for k in (1, 5, 10, 30):
        c = sum(1 for g in gap if g <= k)
        print(f"  {k:3d} 手以内 {c:5d} = {100 * c / len(gap):5.1f}%")
    print()
    v = sorted(per.values(), reverse=True)
    one = sum(1 for x in v if x == 1)
    print(f"編集された(ファイル×セッション)の組 {len(v)}")
    print(f"  1回で終わった {one} = {100 * one / len(v):.1f}%")
    print(f"  中央値 {st.median(v):.0f} 回 / 最大 {v[0]}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else
                  os.path.expanduser("~/.claude/projects")))

引数にログのルートを渡す。

python3 edit_rework.py ~/.claude/projects

この記事の限界

  • 1人分のログである。389 ファイル・Edit 1,239 件。他人の使い方では分布が変わる。
  • **Write を「自分が書いた」に数えている。**新規作成したファイルを編集すると、当然すべてがやり直し判定になる。既存ファイルだけに絞れば数字は下がるはずだが、そこは分けていない。これは 50.0% を上振れさせる方向の要因で、最大の弱点だと思う。
  • **20 文字未満の old_string は数えていない。**短い断片は偶然一致するため除いた。除いた分だけ実際のやり直しを取りこぼしている可能性がある。
  • **「やり直し = 無駄」ではない。**実行して情報が入った結果の修正と、単に間違えた修正を、このスクリプトは区別できない。手数の分布(1手以内が 3.4%)は前者が多いことを示唆するが、証明ではない。
  • 同一セッション内しか見ていない。セッションをまたいだ書き直しは全部「新規の編集」として数えている。

まとめ

  • Edit の成功率は 99.0%。だが「置換対象が見つかった」以上のことは意味しない。
  • **50.0% は自分が書いた文字列の書き直し。**判定を厳しくしても 50.6% → 50.0% しか動かなかった。
  • 直後の修正は 3.4% だけ。中央値 16 手あとに戻ってくる。実行して初めて分かるからだ。
  • 1回で終わるファイルは 31.4%。判定してくれる仕組みが無い文書ほど、編集が終わらない。

エージェントの作業量を測るなら、ツールの成功率ではなく同じ場所に何回戻ったかを見たほうがいい。前者はほぼ 100% に張り付いていて、何も区別しない。


関連

「判定してくれる仕組みが無い文書ほど、編集が終わらない」と書きました。規約そのものが、まさにその文書です。書いても、守られたかどうかを返してくれるものが無い。

守られているかをどう測るかと、効かなくなる4つのパターンをまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

0
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
0
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?