OpenAI Codex · 程式碼模式

apply-patch,給模型設計一種 diff

模型填一份沒有行號的補丁,人看的是事後算出來的 unified diff。同一處修改,兩套格式各自會在哪一步翻車。

課程目標讀完能說清兩件事:給模型的 diff 為什麼只寫一行上下文錨點,不寫 @@ -l,s +l,s 那四個數字;以及上下文對不上時,單檔案為什麼整份不落盤,跨多個檔案時這個保證為什麼不成立。
先玩一遍 · 同一處修改,兩種寫法
greet 裡的 pass 換成 return 123
磁碟上的檔案
切到插了兩行,左邊四個數字會偏。切到錨點丟了,右邊會停筆。
unified diff四個數字要填對
等待開始。
apply-patch一行錨點現搜
@@ def greet():
等待開始。
邏輯軌跡 · 動畫每一步對應原始碼裡的哪一段
  1. 文法裡的 @@ 只有錨點,沒有起始行和跨度parser.rs L20
  2. 更新檔案的 chunks 必須按在檔案中出現的先後排列parser.rs L74
  3. change_context 時,從 line_index 往下搜這一行file_update.rs L99
  4. 先整行精確比,再抹掉行尾空白,再兩邊 trimseek_sequence.rs L40
  5. 錨點找不到,立刻報 Failed to find context,不按附近行猜file_update.rs L109
  6. 單檔案全部 chunk 在記憶體裡算完,才呼叫 write_filelib.rs L695
  7. 跨檔案失敗時帶著已經提交的 delta 返回,沒有回滾lib.rs L453
  8. 給人看的 unified diff 是事後用 TextDiff 另算的file_update.rs L328
點播放,看同一處修改在兩種 diff 寫法下怎麼定位。
四個數字unified diff 的 @@ 頭要同時填對舊起始、舊跨度、新起始、新跨度。檔案上面插兩行,這四個數字一起廢。
一行錨點apply-patch 只寫 @@ def greet():,執行時現搜。插兩行也能對上,因為行號根本沒進這份格式。
對不上就停錨點行被改掉時,右邊報 Failed to find context,這個檔案保持原樣。左邊那種格式會在行號附近 fuzz,有可能貼到鄰近函式。
教學示意:檔案行塊與行號是課程化設定,用來對照兩種格式的定位方式。邏輯軌跡右側行號對應 openai/codex 倉庫 commit 4f39251a01。
思路一 · 模型填一份格式,人看另一份
它解決什麼問題

你讓模型改一個函式:把 greet 裡的 pass 換成 return 123。它吐出標準 unified diff,頭一行寫成 @@ -47,3 +47,3 @@

檔案剛才被另一處編輯插了兩行,greet 已經在第 49 行。模型是在帶行號的摘錄裡數的,寫補丁時還要自己加算起始行和跨度。這四個數字一起錯,是常態。

patch(1) 會按行號去找,找不到就 fuzz。fuzz 再失敗,整份補丁作廢。更麻煩的是它可能把變更貼到鄰近的另一個函式上。測試還是綠的,只是改錯了函式。

思路是什麼

Codex 把填寫和閱讀拆開。模型填的那份沒有行號。更新一段時,頭一行只寫成 @@ def greet():。單獨一個 @@ 表示從當前位置繼續搜。定位交給執行時的 seek_sequence

人看變更時,介面上再另外用 similar::TextDiff 生成一份標準 unified diff。工具參數裡那份 Codex 格式到這裡已經用完了。

出處:codex-rs/apply-patch/src/file_update.rs 第 328 至 329 行

這份文法把沒有行號寫進了產生式。兩種 @@ 寫法都只帶文字錨點:

codex-rs/apply-patch/src/parser.rs第 20 至 22 行
//! change_context: ("@@" | "@@ " /(.+)/) LF
//! change_line: ("+" | "-" | " ") /(.+)/ LF
//! eof_line: "*** End of File" LF
原始碼快照說明:依據本地倉庫 openai/codex,核對檔案 codex-rs/apply-patch/src/parser.rs,commit 4f39251a01,核對日期 2026-08-22。程式碼塊保留原始碼原文,這三行就是給模型的 diff 頭:有錨點,沒有行號。

發給模型的說明書把這套語言寫成「stripped-down, file-oriented diff format designed to be easy to parse and safe to apply」。Add、Delete、Move 在文法裡是三種標記,解析器按標記分發。模型不用記 ---+++/dev/null 和 rename 頭怎麼拼。

模型填寫 模型 寫一份補丁 apply-patch @@ 錨點,沒有行號 seek_sequence 在磁碟上現搜,算出新內容 人閱讀 已經算好的新舊文字 工具參數裡那份格式到此用完 TextDiff 事後生成 unified diff 介面 給人看的那一份
教學化結構圖:同一處修改,模型填錨點,人看行號。
為什麼長期成立

座標靠加算,內容靠識別。模型數行號這件事,換一個模型、換一種語言都好不到哪去。把找到哪一段從填寫時的算術,改成應用時的字串搜尋,這個分工不依賴 Rust,也不依賴 unified diff 這個具體格式。

思路二 · 空白可以放寬,位置不猜
它解決什麼問題

模型寫補丁時,行尾多一個空格,或者檔案裡是 en-dash、它寫成了減號,都是高頻事故。每次都整份失敗,模型只能重寫。按行號附近再試幾行,又會回到 fuzz 貼錯函式的老路。

思路是什麼

seek_sequence 按四檔從緊到松搜。第一檔整行精確相等。第二檔去掉行尾空白再比。第三檔兩邊都 trim()。第四檔把常見 Unicode 短橫和彎引號收成 ASCII。四級都失敗就返回空,報「Failed to find context」或「Failed to find expected lines」。沒有按附近幾行再試這個迴圈。

出處:codex-rs/apply-patch/src/seek_sequence.rs 第 40 至 114 行

早期有一次事故,專門為奇怪的 Unicode 字元加了第四級。它只放寬空白和標點,不放寬位置。中文全形引號不在歸一化表裡,模型寫了全形左引號,檔案裡是半形引號,四級都會失敗。

精確相等 trim_end 兩邊 trim normalise 四級都失敗,返回空 沒有按行號上下挪幾行再試 立刻報錯
教學化流程圖:空格和短橫可以過,行號偏移不在這四級裡。
為什麼長期成立

容錯要分清兩類差異。行尾空格、彎引號是無意義的位元組差,可以歸一。行號偏了兩行,是貼錯地方,應該報錯讓模型重寫。這個分界換語言重寫也成立。

思路三 · 單檔案算完再寫,跨檔案沒有事務
它解決什麼問題

一份補丁裡有兩個 chunk。第二個對不上,第一個已經改進去了,檔案會變成半成品。排查的人看到的是一份應用成功了一半的檔案,比整份失敗更難修。

思路是什麼

單檔案內部,compute_replacements 把每個 chunk 先收成替換列表,某一個對不上就立刻返回錯誤,還沒走到 write_file。這個檔案保持原樣。

出處:codex-rs/apply-patch/src/file_update.rs 第 109 至 113 行

跨檔案是另一回事。apply_hunks_to_files 按 hunk 順序寫盤,失敗時帶著已經提交的 AppliedPatchDelta 返回,迴圈裡沒有回滾。測試 015 把這個釘死了:先成功新增 created.txt,再更新一個不存在的檔案,磁碟上 created.txt 還在。

出處:codex-rs/apply-patch/src/lib.rs 第 453 行,以及第 504 行起的 hunk 迴圈
同一個檔案裡的兩個 chunk chunk 1 在記憶體裡算完 chunk 2 對不上,返回 write_file 還沒走到,檔案原樣 兩個檔案級 hunk Add File 已經寫盤 Update 一個不存在的路徑 delta 留下,created.txt 還在
教學化對照:沒有部分成功這個保證,只對單個檔案成立。
模型填錨點。人看行號。單檔案對不上就不寫。
為什麼長期成立

算完再提交的範圍,要和你能原子處理的單位對齊。一個檔案可以先在記憶體裡算完全部替換再寫一次。多個檔案已經落到磁碟上,回滾就要再寫一遍,還要處理 Move 這種源和目標都動過的半成功。要不要跨檔案事務,是產品選擇,不是格式本身的承諾。寫給模型的說明裡,別把單檔案的保證說成全域保證。

橫向對比 · 同一道題的另一種答法

DeepSeek Harness:先讀過,才能改

DSH 的 editIntent 查的是這個 session 有沒有觀測過這個檔案。沒觀測過就拋 FS_NOT_OBSERVED。觀測記錄的是 dev:ino:size:mtimeNs:ctimeNs 拼出來的版本,不是內容 hash。

即便過了這道門,applyLiteralEdit 預設還要求 old_string 只出現一次,多處命中就拋 FS_AMBIGUOUS_EDIT。防錯掛在事件門禁和字面量唯一上。Codex 沒有先讀約束,定位資訊寫在補丁裡,執行時現搜;old_lines 出現兩次時取第一處,沒有歧義報錯。

兩側均已核對原始碼 · 2026-08-22 · DSH · 檔案編輯的工程學

Claude Code:沒讀過就拒絕,多處命中也拒絕

FileEditTool 同時要兩件事。檔案必須先讀過,沒讀過報 errorCode 6,原文是 File has not been read yet。 old_string 在檔案裡多於一處且 replace_all 為假時,報 errorCode 9,要求補更多上下文,把這一處單獨標出來。

模糊只覆蓋引號。findActualString 先精確搜,再把彎引號收成直引號搜。沒有 Codex 那種行尾空白三級,也沒有短橫歸一化。想少一次工具往返,抄 Codex 的格式;想對模型錯誤資訊更具體,抄這幾條帶 errorCode 的拒絕文案。

兩側均已核對原始碼 · 2026-08-22
課堂練習
01

兩段相同的 old_lines,改哪一段

檔案裡有兩段完全相同的 old_lines,模型只想改第二段,卻沒有給足夠的 @@ 錨點。seek_sequence 會改哪一段,為什麼?

進階一問:若希望單獨命中第二段,錨點應該寫在哪一行前面?同一份補丁裡若先成功新增一個檔案,再更新一個不存在的路徑,磁碟上會留下什麼?

Takeaway:給模型的 diff 不要行號,定位交給執行時搜上下文。空白和標點可以逐級放寬,位置不猜。單檔案對不上就不寫盤;跨多個檔案時,已經寫下的檔案會留在磁碟上。