# Day 09 - 改 code 為什麼用字串替換

> Coding agent 該整檔重寫，還是只替換需要修改的片段？這篇實作 old_string 到 new_string 的編輯合約，處理找不到與重複匹配，避免改錯地方。

Published: 2026-08-09
URL: https://kaochenlong.com/edit-code-with-string-replacement

---

前幾篇文章把「看」的能力補齊了，現在 KeSi 會讀檔、列目錄、找檔名、搜尋內容，但 coding agent 的正職不是只回答 code 在哪裡而是動手修改，這篇文章要來做的是可以「編輯」檔案的工具。

前面四個工具雖然不會改檔，仍可能讀到不該讀的檔案，不算是零風險，但從這篇文章開始會再多一種更刺激的風險，模型一旦判斷錯誤，檔案真的會被改掉。

先說結論，「字串替換」是 Claude Code 之類的 coding agent 常見的編輯手段之一，指定舊文字然後把它換成新文字，不過實務上也有人用整檔重寫或是從語法下手的 AST 或 LSP。

GitHub Repo：&lt;https://github.com/kaochenlong/KeSi&gt;

## 整檔重寫不行嗎？

最直覺的做法是整檔重寫，也就是把檔案內容給模型，然後讓模型吐出修改後的完整版本再整個覆蓋舊檔。不過這樣即使只改幾行模型仍要回傳整份檔案，成本有點太高，而且速度也比較慢。以 KeSi 使用的 Haiku 4.5 來說，[Claude API 的基本費率](https://platform.claude.com/docs/en/about-claude/pricing)是每百萬 input token 1 美元、output token 5 美元。重點在 output 比 input 貴五倍，整檔重寫表示就算沒有變動的部份也會用比較貴的價格算錢，但事實上局部替換通常只需輸出新內容就好。不過實際花費還是要看檔案大小、`old_string` 帶了多少上下文、tokenizer 與工具 schema，不能光憑行數喊出固定倍數。但基本上方向很明確，檔案越大、改動越小，這種整個檔案重寫的成本就越高。

整檔重寫除了錢錢比較貴之外還有別的問題，因為重寫的過程是讓每個字元都重新經過模型生成，而模型可能不小心漏掉 `import` 或是順手整理註解，甚至用「其餘不變」代替沒有抄完的內容。這些問題不一定會發生，萬一發生也能靠 diff、測試與版本控制抓到，只是局部替換一開始就先縮小了可能被改動的範圍。

還有一條路是丟 diff 給模型，這是對工程師來說應該不陌生，但這對模型不太友善。標準的 unified diff 每一段變更前面都有一行像 `@@ -12,7 +12,9 @@` 的標頭，意思是「從第 12 行開始，原本 7 行，改完之後變 9 行」。這行標頭排在變更內容的前面，模型得先把改動算完，才寫得對這幾個數字，算錯了這份 diff 就可能套不上去。[Anthropic 的 agent 工具設計文章](https://www.anthropic.com/engineering/building-effective-agents#appendix-2-prompt-engineering-your-tools)有提到這件事，也建議工具的格式不要讓模型還得花力氣算行數。真的要走 diff 這條路也不是不行，只是那行標頭最好由工具自己算完填上去，模型專心寫改動的內容就好。不然就是換一種根本不用算行數的格式，例如直接給舊文字跟新文字，也就是今天要做的這種。

從「語法」下手的 AST 或 LSP 也是不錯，抽象語法樹 AST（Abstract Syntax Tree）是把程式碼交給對應語言的 parser 解析之後會得到的一棵樹，函式、變數、判斷式各自是樹上的一個「節點」。工具動的是節點而不是文字，所以分得出 `count` 這幾個字什麼時候是變數，什麼時候只是註解裡剛好出現的一個詞。語言伺服器協定 LSP（Language Server Protocol）講的是編輯器要怎麼跟語言分析工具溝通。當你在 VS Code 裡按著 Ctrl 或 Cmd 點一下函式名稱跳到它的定義，或是重新命名變數然後讓好幾個有用到它的地方一起改，這些都是語言功能。改一個名字、把 method 搬到別的類別、做這類重構的時候，這種懂語法的工具往往比一個字一個字比對可靠，代價是得先有對應語言的 parser、language server 與操作定義。

成熟的 coding agent 可以混用多種編輯方式，KeSi 目前則先拿跨語言的字串替換打底，之後有機會再把這些加進來。

## old_string 與 new_string

替換式編輯的介面只有三個參數：檔案、舊字串、新字串。參數就這麼少，模型送什麼進來就得管得嚴一點，所以 KeSi 會給 `old_string` 訂兩條規矩，首先是它得跟檔案裡的內容一字不差，另外是它在整份檔案裡只能出現一次。

先講第一條，Anthropic 的 `str_replace_based_edit_tool` 文件規定，`old_str`[ 必須連空白與縮排都完全一致](https://platform.claude.com/docs/en/agents-and-tools/tool-use/text-editor-tool#str_replace)。KeSi 也採同一條規矩，四個空白不等於 tab，`\n` 不等於 `\r\n`，大小寫當然也不能混。

「換行」有點麻煩，因為 Python 自己就會偷偷動它。`Path.read_text()` 跟文字模式的 `open()` 預設值是 `newline=None`，在這個模式下讀進來的 `\r` 跟 `\r\n` 都會被一律轉成 `\n`，寫出去的時候 `\n` 又可能被轉成當前系統慣用的行尾。

也就是說，我們才剛跟模型說好換行要一字不差，結果光是把檔案讀進來這一步，換行就已經被換掉了。所以順手把第 6 天寫的那個 `read_file` 也一起改掉，讓它先把檔案讀成 bytes，再明確用 UTF-8 解碼：

```python
return target.read_bytes().decode(&quot;utf-8&quot;), False
```

`edit_file` 讀檔寫檔也都改用同樣的方式。舉個例子，先寫一個行尾是 `\r\n` 的檔案，再用兩種方式讀回來：

```plaintext
&gt;&gt;&gt; from pathlib import Path
&gt;&gt;&gt; Path(&quot;demo.txt&quot;).write_bytes(b&quot;x = 1\r\ny = 2\r\n&quot;)
14
&gt;&gt;&gt; Path(&quot;demo.txt&quot;).read_text()
&#39;x = 1\ny = 2\n&#39;
&gt;&gt;&gt; Path(&quot;demo.txt&quot;).read_bytes().decode(&quot;utf-8&quot;)
&#39;x = 1\r\ny = 2\r\n&#39;
```

同一個檔案 `read_text()` 讀回來的 `\r\n` 已經被換成 `\n` 了，但 `read_bytes().decode()` 才是檔案裡本來的樣子。

讀進來的字串不一樣，這件事會在寫回去的時候變成問題。假設 KeSi 用 `read_text()` 讀進來然後改完再用文字模式寫回去，明明只動了 `x = 1` 那一行，整個檔案的行尾卻會從 `\r\n` 變成執行 KeSi 這台電腦慣用的行尾，`y = 2` 那行根本沒被碰到也一起變了。如果專案沒有透過 `.gitattributes` 或 Git 設定統一行尾，這種改動送進 Git 之後，可能會看到整份檔案都有異動，真正改哪一行反而找不出來。

改用 bytes 讀寫之後，這個問題就不會發生了。`read_bytes()` 不會去管檔案內容是什麼意思，磁碟上是哪些 byte 就原封不動給你哪些 byte，`\r` 就是 `\r`，不會被當成換行順手改掉；寫回去用的 `write_bytes()` 也一樣，你給它什麼 byte 它就寫什麼 byte。上面那段 REPL 就看得到了，同一個檔案用它讀出來是 `&#39;x = 1\r\ny = 2\r\n&#39;`，跟磁碟上一個 byte 都不差。

不過換行的差異並沒有因此消失，`old_string` 的換行要是帶錯一樣會找不到而失敗，只是失敗歸失敗，檔案裡沒被換掉的地方會保持原樣。

這樣是不是太嚴格了？縮排差一個空白，幫模型猜一下不行嗎？也可以啦，但只要開始猜就得再回答兩個問題，一個是萬一有兩個地方都很像，要猜改哪一個？還有，要不要在真的寫下去之前，先把它打算怎麼改印出來給人看一眼？這兩件事現在的 KeSi 都沒做，所以乾脆嚴格到底，差一個字元就報錯，讓模型自己重讀一次、多帶一點前後文再送一次。

至於「修改前應先讀」這句話，它寫在工具的說明書裡，是講給模型聽的建議，`edit_file` 這個函式本身並不會去檢查模型到底讀過沒有。

舉個例子，模型如果剛才讀過 `app.py`，接著在同一段對話裡想再改一個地方，這時候檔案內容還留在對話紀錄裡，模型不必再讀一次，直接送 `old_string` 過來也會成功。反之如果你在這中間自己開編輯器把那一段改掉了（我還滿常幹這件事的），模型手上那段文字就過期了，`edit_file` 會找不到，替換就失敗。

所以這裡並沒有真的「強制先讀」，只是內容對不上就過不了關而已。真的想要求模型每次動手之前都先讀一次，程式得另外記住「這個檔案上次讀到的時候長什麼樣」，動手前再比對一次才行。

## 為什麼要唯一？

第二條規矩是唯一匹配，`old_string` 在檔案裡只能找得到一個開頭的位置。

找不到表示模型手上那段文字跟現在的檔案對不上。但如果找到很多個也有點危險，例如檔案裡有三個地方都寫著 `count += 1`，工具根本沒辦法知道模型想改的是哪一個，這時候萬一模型自作主張挑第一個，等於是猜一個答案然後回報成功。

所以 KeSi 的規矩是找不到就報錯，找到不只一個也報錯，只有剛好一個才動手。Anthropic 的[文字編輯工具實作建議](https://platform.claude.com/docs/en/agents-and-tools/tool-use/text-editor-tool#implement-the-text-editor-tool)裡，實作時要記得的四件事，最後一件就是這個：

&gt; Unique matching: Make sure replacements match exactly one location to avoid unintended edits.

注意最後那個 `unintended edits`，它擔心的不是改不到，是改到了但改的不是模型原本想改的那一處。一次找到好幾處符合的時候，回給模型的錯誤訊息會順便把解法講出來，「請加長 `old_string`、納入更多前後文，使它在檔案中唯一。」，意思就是把函式名稱或前後幾行一起放進去，放到那段文字在檔案裡只剩一處為止。同一份文件示範的訊息也是同一個方向，「Error: Found 3 matches for replacement text. Please provide more context to make a unique match.」，找到幾處講清楚，下一步該做什麼也講清楚。

這裡還有個容易忽略的地方，Python 的 `str.count()` 計算的是「不重疊」的次數，`&quot;aaa&quot;.count(&quot;aa&quot;)` 會回答 1，可是 `aa` 從第 0 個字元開始能算一次，從第 1 個字元開始又能算一次，明明有兩個地方對得上。要決定改哪裡，這就是兩個候選位置，所以實作不能只靠 `count()`。找到第一處之後，得從下一個字元繼續往後找第二處。

另外兩種不合理的輸入也先擋掉：

- `old_string` 是空字串，因為空字串在哪裡都對得上，根本沒辦法拿來指位置。
- `old_string` 跟 `new_string` 一模一樣，這種替換不會讓檔案有任何改變但卻會被回報成功，模型還以為事情辦好了。

這些檢查都會寫在工具程式裡而不是只放在 description 交代一句就算了。會造成實際損害、或是讓模型誤以為成功的問題就該由程式強制擋下來，不能指望模型每次都記得。

## 動手寫 edit_file

底下是 `edit_file` 工具的實作：

```python
def edit_file(file_path, old_string, new_string):
    if not all(
        isinstance(value, str)
        for value in (file_path, old_string, new_string)
    ):
        return &quot;錯誤：file_path、old_string、new_string 都必須是字串。&quot;, True
    if not old_string:
        return &quot;錯誤：old_string 不可為空。&quot;, True
    if old_string == new_string:
        return &quot;錯誤：old_string 與 new_string 相同，沒有內容需要修改。&quot;, True

    target = safe_path(file_path)
    if target is None:
        return f&quot;錯誤：找不到或不允許存取檔案 {file_path}&quot;, True
    if is_ignored(target):
        return &quot;錯誤：這個檔案不開放修改。&quot;, True
    if not target.is_file():
        return f&quot;錯誤：不是可以修改的文字檔 {file_path}&quot;, True

    try:
        content = target.read_bytes().decode(&quot;utf-8&quot;)
    except UnicodeDecodeError:
        return f&quot;錯誤：檔案不是 UTF-8 文字檔 {file_path}&quot;, True
    except OSError as exc:
        return f&quot;錯誤：無法讀取檔案 {file_path}：{exc}&quot;, True

    first = content.find(old_string)
    if first == -1:
        return (
            &quot;錯誤：在檔案裡找不到要替換的文字。old_string 必須與檔案內容&quot;
            &quot;完全一致（包含空白、縮排與換行），請先用 read_file 確認目前內容。&quot;,
            True,
        )
    if content.find(old_string, first + 1) != -1:
        return (
            &quot;錯誤：要替換的文字在檔案裡不只出現一次，無法確定要改哪一處。&quot;
            &quot;請加長 old_string、納入更多前後文，使它在檔案中唯一。&quot;,
            True,
        )

    prefix = content[:first]
    line_no = (
        prefix.count(&quot;\n&quot;)
        + prefix.count(&quot;\r&quot;)
        - prefix.count(&quot;\r\n&quot;)
        + 1
    )
    new_content = (
        content[:first] + new_string + content[first + len(old_string):]
    )
    try:
        encoded = new_content.encode(&quot;utf-8&quot;)
        target.write_bytes(encoded)
    except UnicodeEncodeError:
        return &quot;錯誤：new_string 含有無法寫成 UTF-8 的內容。&quot;, True
    except OSError as exc:
        return f&quot;錯誤：無法寫入檔案 {file_path}：{exc}&quot;, True
    return f&quot;替換完成（從第 {line_no} 行開始）。&quot;, False
```

不管走到哪一個 `return`，回傳的都是 `(output, is_error)`，這樣才能接得上之前寫好的 `run_agent()`。如果成功的時候只回一個字串，`run_agent()` 裡的 `output, is_error = func(...)` 就拆不出正確的兩個值，結果就會變成工具明明寫入成功但最後卻被當成失敗處理。

路徑檢查沿用前兩天的 `safe_path()` 與 `is_ignored(target)`。黑名單一定要拿解開之後的真實路徑去比對，不能只看模型送進來的那個名字，否則工作目錄裡要是有一個符號連結指向 `.env`，名字看起來沒踩到黑名單但實際上寫進去的還是禁區。

不過這只能擋下不小心的錯誤操作，擋不住存心找漏洞的人，這終究只是 KeSi 自己在程式裡做的判斷，不是作業系統層級的隔離。像是硬連結（hard link，同一份檔案內容同時掛在兩個不同的路徑底下），或是趁著檢查通過、還沒寫進去的那一小段空檔把路徑換掉，這些都要更底層的手段才防得住。

替換時不用 `replace()`，而是拿已經確認唯一的位置把內容切成三段再接起來。拿一小段內容做一次就知道了：

```plaintext
&gt;&gt;&gt; content = &quot;total = 0\ncount += 1\nprint(total)\n&quot;
&gt;&gt;&gt; first = content.find(&quot;count += 1&quot;)
&gt;&gt;&gt; first
10
&gt;&gt;&gt; content[:first]
&#39;total = 0\n&#39;
&gt;&gt;&gt; content[first + len(&quot;count += 1&quot;):]
&#39;\nprint(total)\n&#39;
&gt;&gt;&gt; content[:first] + &quot;count += 2&quot; + content[first + len(&quot;count += 1&quot;):]
&#39;total = 0\ncount += 2\nprint(total)\n&#39;
```

`first` 是 10，表示 `count += 1` 從第 10 個字元開始。前面那一段留著，中間那一段丟掉，後面那一段接回來，中間補上 `new_string`，這就是上面程式碼裡 `content[:first] + new_string + content[first + len(old_string):]` 做的事。

那為什麼不乾脆用 `replace()`？因為位置早就算出來了。前面檢查唯一性的時候就已經拿到 `first`，等一下回報行號也要用它，直接拿來切最省事，`replace()` 反而是再從頭把整份內容掃一遍，找的還是同一段文字。

`new_string` 給空字串的話，中間那一段就直接消失，這樣就變成刪除的效果。剛才的例子把 `&quot;count += 2&quot;` 換成 `&quot;&quot;`，結果是這樣：

```plaintext
&gt;&gt;&gt; content[:first] + &quot;&quot; + content[first + len(&quot;count += 1&quot;):]
&#39;total = 0\n\nprint(total)\n&#39;
```

`count += 1` 這幾個字不見了，但那一行的換行還在，所以留下一個空行。想連空行一起清掉，`old_string` 就要把後面那個 `\n` 也一起帶進來。`old_string` 跟 `new_string` 也都可以跨好幾行，所以同一個函式也拿得來換掉一整個區塊，不是只能換一行。

成功訊息回報的是「從第幾行開始」，上面那段內容存成 `counter.py` 再交給 `edit_file` 得到的回應會是：

```plaintext
替換完成（從第 2 行開始）。
```

這個行號指的是 `old_string` 的起點，如果這次替換跨了十行，它也不會假裝只動到一行。算行號的時候 LF、CR 跟 CRLF 三種行尾都認得，其中 CRLF 只算一個換行。

這個工具還是有它的限制，這只確認舊文字在哪裡，不會檢查新內容語法對不對、邏輯通不通。`write_bytes()` 也不是那種寫到一半可以整個退回去的寫法，中途出錯就留下一個改到一半的檔案，目前還沒有自動備份的設計。還有，如果在 KeSi 讀完檔案、還沒寫回去的這段空檔裡有別的程式也動了同一個檔案，那些修改可能會被 KeSi 寫回去的內容蓋掉。重要的檔案還是先放進版本控制或自己留一份備份，之後可以再補上先寫暫存檔再整個換過去的做法、檔案版本檢查，以及改完自動跑測試。唯一匹配這條規則只降低了改錯位置的機會，不等於寫檔案的風險都處理完了。

## 把工具接進迴圈

工具 schema 跟前幾天一樣的寫法：

```python
    {
        &quot;name&quot;: &quot;edit_file&quot;,
        &quot;description&quot;: &quot;修改既有 UTF-8 文字檔：把 old_string 替換成 new_string。&quot;
        &quot;old_string 必須與檔案現有內容完全一致（含空白、縮排與換行），&quot;
        &quot;而且在檔案中只出現一次；不唯一時請加長前後文。&quot;
        &quot;修改前應先用 read_file 讀取目前內容。&quot;,
        &quot;strict&quot;: True,
        &quot;input_schema&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {
                &quot;file_path&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;相對於工作目錄的既有檔案路徑&quot;,
                },
                &quot;old_string&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;要被替換的原文，需完全一致、非空且唯一&quot;,
                },
                &quot;new_string&quot;: {
                    &quot;type&quot;: &quot;string&quot;,
                    &quot;description&quot;: &quot;替換後的新內容，可為空字串以刪除原文&quot;,
                },
            },
            &quot;required&quot;: [&quot;file_path&quot;, &quot;old_string&quot;, &quot;new_string&quot;],
            &quot;additionalProperties&quot;: False,
        },
    },
```

最後 `TOOL_FUNCS` 這張對照表也記得加一條：

```python
TOOL_FUNCS = {
    &quot;read_file&quot;: read_file,
    &quot;list_files&quot;: list_files,
    &quot;glob&quot;: glob_files,
    &quot;grep&quot;: grep,
    &quot;edit_file&quot;: edit_file,
}
```

今天完整的 `kesi.py` 一樣可以在 GitHub Repo 取得。

## 驗收工具！

等等，先別急著跟模型聊天，先確認工具能正常運作再說。`edit_file()` 裡面有型別檢查、路徑檢查、唯一性判斷好幾層，先確認它自己沒問題，等一下模型出狀況的時候才分得清是誰的責任。何況模型會不會先讀、`old_string` 會帶幾行或是遇到錯誤之後怎麼重試，每次跑都不見得一樣，工具這一層倒是每次都該有一樣的反應。

做法跟第 8 天一樣，在 `history = []` 前面暫時插幾行，測完再刪掉。要餵給它的情況至少有這些：

- `old_string` 在檔案裡剛好對上一處，替換要成功，`is_error` 是 `False`，而且只有那一段被換掉，檔案其他地方原封不動。
- `old_string` 找不到，或是一次找到好幾個，這都要算錯誤，而且檔案不能有任何變動。
- `old_string` 給空字串，或是跟 `new_string` 一模一樣，這個也不行。
- `old_string` 跨好幾行的時候要能換掉一整塊，`new_string` 給空字串則要變成刪除。
- 不是 UTF-8 的檔案、目錄、工作目錄以外的路徑，還有名字看起來正常、符號連結解開卻落進禁區的那種，全都要拒絕。
- LF、CRLF 跟單獨一個 CR 這三種行尾，回報的起始行號都要對。CRLF 的檔案裡 `old_string` 要帶 CRLF 才對得上，誤用 LF 要失敗，而且沒改到的地方行尾不能被動。

其中兩個特別值得注意，因為它們就算寫錯了，表面上也看不出來。先是重疊匹配。前面提過 `str.count()` 只算不重疊的次數：

```plaintext
&gt;&gt;&gt; &quot;aaa&quot;.count(&quot;aa&quot;)
1
```

Python 說只有一處，但 `aa` 從第 0 個字元能開始一次，從第 1 個字元又能開始一次，其實有兩個地方對得上。開一個裝著 `aaa` 的檔案，把 `aa` 換成 `X` 試試看：

```python
Path(&quot;work.txt&quot;).write_text(&quot;aaa&quot;)
output, is_error = edit_file(&quot;work.txt&quot;, &quot;aa&quot;, &quot;X&quot;)
print(is_error)
print(output)
print(Path(&quot;work.txt&quot;).read_text())
```

`Path` 在 `kesi.py` 開頭就 import 過了，所以這裡直接拿來用。跑出來是這樣：

```plaintext
True
錯誤：要替換的文字在檔案裡不只出現一次，無法確定要改哪一處。請加長 old_string、納入更多前後文，使它在檔案中唯一。
aaa
```

擋下來了，最後那行 `aaa` 也確認檔案沒被動過。這是因為我們的實作是找完第一處之後，從下一個字元繼續找第二處，沒有靠 `count()` 數數字。再來是 CRLF，一樣插在同一個地方。開一個行尾是 `\r\n` 的檔案，把 `x = 1` 那一行改成 `x = 9`，`old_string` 用兩種寫法分別送進去：

```python
Path(&quot;crlf.txt&quot;).write_bytes(b&quot;x = 1\r\ny = 2\r\n&quot;)
output, is_error = edit_file(&quot;crlf.txt&quot;, &quot;x = 1\n&quot;, &quot;x = 9\n&quot;)
print(is_error)
output, is_error = edit_file(&quot;crlf.txt&quot;, &quot;x = 1\r\n&quot;, &quot;x = 9\r\n&quot;)
print(is_error)
print(Path(&quot;crlf.txt&quot;).read_bytes())
```

```plaintext
True
False
b&#39;x = 9\r\ny = 2\r\n&#39;
```

`old_string` 誤用 LF 就是找不到（第一個 `True` 是錯誤），帶對 CRLF 才成功。注意最後那串 bytes，沒被改到的 `y = 2\r\n` 行尾原封不動，這就是前面把 `read_text()` 換成 `read_bytes().decode()` 的理由。

這幾行測完記得刪掉，順手也把 `work.txt` 跟 `crlf.txt` 這幾個檔案刪掉。工具層過關後，我再準備一個 `greet.py` 做個實驗：

```python
print(&quot;helo&quot;)
```

啟動 KeSi，請它「把 greet.py 的 helo 改成 hello」。如果模型照說明先讀再改，工具的呼叫過程會像這樣：

```plaintext
你 &gt; 把 greet.py 的 helo 改成 hello
  [執行工具] read_file({&#39;file_path&#39;: &#39;greet.py&#39;})
  [執行工具] edit_file({&#39;file_path&#39;: &#39;greet.py&#39;, &#39;old_string&#39;: &#39;print(&quot;helo&quot;)&#39;, &#39;new_string&#39;: &#39;print(&quot;hello&quot;)&#39;})
```

這條走法是先讀再改，old_string 帶了一整行，唯一性沒問題。不過前面說過，KeSi 並沒有規定非得先讀不可，模型只要拿得出精確又唯一的原文，KeSi 一樣會放行。

如果有多個一樣的呢？準備一個有兩個 `count += 1` 的檔案然後請它只改最後那一個，其中一種合理的走法會像這樣：

```plaintext
你 &gt; counter.py 裡有兩個 count += 1，只把最後那一個改成 count += 2
  [執行工具] read_file({&#39;file_path&#39;: &#39;counter.py&#39;})
  [執行工具] edit_file({&#39;file_path&#39;: &#39;counter.py&#39;, &#39;old_string&#39;: &#39;total = 0\ncount += 1\nprint(total)\ncount += 1&#39;, &#39;new_string&#39;: &#39;total = 0\ncount += 1\nprint(total)\ncount += 2&#39;})
```

這個示意裡，模型沒有先送一個 `count += 1` 過去試，而是第一次就把整整四行包進 `old_string`，只動最後一行。前後文一次帶足，就沒被擋下來。至於模型為什麼可能這樣做？不知道，光看工具呼叫紀錄只看得到它送了什麼過來，但不知道它心裡在想什麼。

我原本想得到的走法只有兩種，一種是先送一個短的被拒絕、看到錯誤訊息再加長重送，另一種是乖乖先重讀一遍再送。結果它走了第三條，第一次就繞過去了。不過也不是每次都這麼聰明，工具該負責的是把錯誤講清楚，至於模型下一步會不會自己修正那就不是工具能保證的事了。

## 讀完之後檔案被改了？

精確匹配確實能發現內容過期了，但管得到的範圍只有要換掉的那段文字。假設模型讀過：

```python
title = &quot;Hello&quot;
count = 1
```

接著我開編輯器把 `title` 改成 `&quot;Hi&quot;`，但模型要換的 `count = 1` 仍然存在而且唯一。KeSi 會用目前的檔案內容來進行替換，`title = &quot;Hi&quot;` 也留著。這是局部替換本來就該有的行為，不相干的變動沒必要擋。

但如果我改的剛好就是要替代的部份，例如我把它改成 `count = 2`，模型拿舊的 `count = 1` 來換就會失敗。所以 `old_string` 比較像是「這一小塊內容還長這樣」的證據，不是「整份檔案從上次讀完到現在都沒被動過」的憑證。讀取跟寫入之間還有一小段空檔，萬一別的程式真的剛好在這時候動了檔案 KeSi 是不知道的。要把這件事做滿，得先幫檔案內容算一個指紋（雜湊）或另外存一個版本，真的要寫下去的摩門特再比對一次，而且比對跟寫入這兩個動作中間可能得要鎖起來，不能讓別人插隊。

這裡有兩個很容易混在一起的東西，一個是前面提過的 `str_replace_based_edit_tool`，那是 Anthropic 替 API 使用者準備好的工具規格，除了替換還能看檔案、建新檔、插入內容，但實際行為得自己寫，跟我們今天做 `edit_file` 是同一回事。另一個是 Claude Code 這個產品內建的 Edit，你打開 Claude Code 叫它改程式碼，實際動手的就是它。

Claude Code 這邊的規矩比較細。截至 2026 年 8 月 3 日，[Claude Code 工具文件](https://code.claude.com/docs/en/tools-reference#edit-tool-behavior)是這樣寫的：

&gt; A file that changed on disk after Claude last read it can still be edited when `old_string` matches the current content exactly and unambiguously and Claude Code can read the file without prompting.

意思是檔案在讀完之後被改過並不會直接被擋掉，還要看兩件事，`old_string` 跟現在的內容對不對得上，以及 Claude Code 讀這個檔案需不需要另外跳出權限確認。同一份文件也寫了這是新版才有的行為：

&gt; The relaxed handling of unread and changed files requires Claude Code v2.1.208 or later; before that, Claude Code refused any edit to a file it hadn&#39;t read in the conversation or that changed on disk after the read.

`refused any edit` 管得很硬，在 v2.1.208 以前只要沒在這段對話裡讀過、或讀完之後檔案被動過，一律不給改。KeSi 這兩套都沒照抄，只做今天講的這幾條簡單的。

Claude Code 目前的 Edit 同樣做精確、唯一匹配，不用 regex 也不做模糊比對，真的需要把所有匹配到的地方全改掉時，還能明確送一個 `replace_all: true`。我們現在的 KeSi 沒有這個選項，不能只送 `old_string=&quot;count&quot;` 就把所有相符位置一次換掉。它可以逐一修改，每次帶足前後文讓舊文字唯一；如果 10 個位置都落在一段可以唯一辨識的較大區塊，也可以一次替換整個區塊。是說像這種同一個名字如果散在好幾個檔案裡，那可能更適合交給前面提過的 LSP 或各語言自己的重構工具，而不是無條件的全域字串替換。

## 四個工具串起來

把這幾天做的工具排起來，`glob` 找出可能的檔案，`grep` 縮小到哪幾行，`read_file` 把目前的內容拿回來，`edit_file` 再用精確的原文把改動限制在那一小塊整個一條龍。模型不一定每次都會用到這四個，但前一個工具的結果都在替下一步鋪路。

編輯成功也不代表任務成功，KeSi 只知道檔案已經寫下去了，它不知道新的程式碼跑不跑得起來、測試會不會過，也不會替你審查需求。等之後接上執行指令，才會有「修改、測試、讀錯誤、再修改」的循環。而且就算測試都是全綠燈，還是得有人看一眼更大範圍的需求有沒有被誤解。

最後補一件第 5 天文章沒講到的事。Messages API 一次回應確實可以帶好幾個 `tool_use` 區塊，模型可以一口氣說「我要改 A 檔，也要改 B 檔」。但 KeSi 目前在處理這塊是用 `for block in resp.content` 迴圈寫的，這會跑完一個才跑下一個，沒有「同時」進行這回事。

而執行的順序是有差的，假設模型在同一份回應裡對同一個檔案提了兩個 edit，第二個 `edit_file` 讀到的已經是第一個改完之後的檔案。如果第一個 edit 讓第二個 `old_string` 消失或不再唯一，第二次替換就會失敗；光是兩段內容重疊，還不能斷定一定會失敗。就算改的是不同檔案，能一個一個改完，也不代表它們可以分開看，像是改了 schema，呼叫它的地方通常也要跟著改，只改一邊，跑起來大概就不對了。

將來真要讓這些工具同時跑，要處理的事比想像中多。例如同一個檔案不能讓兩邊同時寫，得有辦法讓它們排隊，而且每一次寫下去之前要再確認一次內容沒被別人動過。最麻煩的是一組修改如果要跨三個檔案，改到第二個失敗了怎麼辦，已經改好的那一個要不要退回去？這些都想清楚了，才輪得到把 `for` 換成 `asyncio.gather`。

## 小結

今天 KeSi 拿到第一個真的會動到檔案的工具。它的重點不在字串替換，而是三個會被擋下來的情況，找不到不猜、找到好幾個不挑、輸入不合理不假裝成功。精確的 `old_string` 限制了改動範圍，解析過符號連結的路徑檢查限制了能寫的地方，`(output, is_error)` 則讓結果能正確回到 agent 迴圈裡。

不過這三條擋的都是「改錯地方」，不是「改錯內容」。新內容可能有錯，做一半出錯目前不會自動復原，`old_string` 也只表示 `edit_file` 讀到的那一版內容裡，這一小塊精確而且唯一，不能證明它從上次讀完之後都沒被動過。即使工具回報成功，這些事情之後仍然要靠測試或其他檢查驗證。

第一段工具還剩最後一個：建立新檔案。聽起來只是開檔、寫入、關檔幾個簡單動作，但要做的事可能會比你想像中的多一點。

咱們下集見 :)

