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 不要行號,定位交給運行時搜上下文。空白和標點可以逐級放寬,位置不猜。單檔案對不上就不寫盤;跨多個檔案時,已經寫下的檔案會留在磁盤上。