iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
ChatGPT & Codex

ChatGPT + Codex 打造高效能 AI 開發工作流系列 第 16

Day 16: Codex 自我修復機制:建立 Self-Correction Loop 讓 AI 自動跑 Test 並修復 Bug

  • 分享至 

  • xImage
  •  

Day 16: Codex 自我修復機制:建立 Self-Correction Loop 讓 AI 自動跑 Test 並修復 Bug (Codex Self-Correction Loop)

本日核心價值 (Core Focus): 用外層 Orchestrator 包住 codex exec:pytest 失敗就讀失敗訊息、做最小修正、再跑測試,最多三輪,並用 git diff 大小當保險絲,避免模型無限改檔。

概念說明與實戰情境 (Overview)

單元測試能指出「哪裡壞了」,但不會自己修好。人在迴圈裡反覆看 traceback、改程式、再跑 pytest,成本高且容易改過頭。Self-Correction Loop 把這件事變成可重現的工作流:測試當 oracle,Codex 當修補器,外層腳本當守門人。官方行為很明確:codex exec 預設是 read-only sandbox,要改檔必須加上 --sandbox workspace-write。CI 裡不要用 danger-full-access。迴圈不是讓模型自由發揮,而是「失敗 → 讀錯誤 → 最小修正 → 重跑 → 三輪後停」。

關鍵操作與範例 (Implementation & Example)

先確認 Codex CLI 已安裝,且工作目錄是 Git repo(codex exec 預設要求 Git,避免在非版本控制目錄做破壞性變更)。修補迴圈拆成兩層:

層級 職責 硬限制
內層 Prompt 跑測試、讀失敗、最小修正、再跑 不重構無關檔、不加依賴
外層 Orchestrator 呼叫 codex exec、檢查 exit code max_iters=3、diff 行數上限
Sandbox 控制能否寫檔 本機修補用 workspace-write;CI 禁止 danger-full-access

官方要記的三件事:

  1. codex exec 預設 read-only,只讀不寫。
  2. 需要改工作區時才加 --sandbox workspace-write。舊的 codex exec --full-auto 仍能跑,但是相容路徑,會印警告。
  3. 非互動環境用 --ask-for-approval never,不要在 CI 開 --sandbox danger-full-access--yolo

為什麼要外層腳本,而不是只對 Codex 說「修到 pytest 全綠為止」?因為 codex exec 是一次非互動行程:你給 Prompt、它在 sandbox 裡做完就結束。把「最多三輪」只交給模型,等於把停止條件建立在不可靠的指令遵從上。外層只認兩件事:pytest 的 exit code(成功條件),以及 git diff --numstat 的加總行數(變更預算)。MAX_DIFF_LINES=120 不是魔法數字——小型函式錯誤通常幾十行;一次改超過 120 行,幾乎一定夾帶格式化或無關重構。門檻可依倉庫調整,但必須是數字,而且超標要還原工作區,而不是帶著超大 diff 進入下一輪。

內層 Prompt 必須把「停止條件」寫死,否則模型會一直「再試一次」。建議固定如下:

Run pytest -q.
If tests fail: read the failure output, apply a minimal fix, re-run pytest -q.
Do not refactor unrelated files. Do not add new dependencies.
Stop after this iteration even if tests still fail; the outer loop will retry.

外層不要把「最多三輪」只寫在 Prompt 裡;模型可能忽略。用腳本強制截斷,並在每次 codex exec 後量 git diff。超過門檻就還原工作區,當作失敗退出。

Bash 版(Linux / WSL / Git Bash):

#!/usr/bin/env bash
set -euo pipefail
MAX_ITERS=3
MAX_DIFF_LINES=120
PROMPT='Run pytest -q. If tests fail, read the failure, apply a minimal fix, re-run pytest -q. Do not refactor unrelated files. Stop after this iteration.'

if pytest -q; then
  echo "tests already pass"
  exit 0
fi

for i in $(seq 1 "$MAX_ITERS"); do
  echo "=== iteration ${i}/${MAX_ITERS} ==="
  # Default exec is read-only; workspace-write is required to edit files.
  codex exec --sandbox workspace-write --ask-for-approval never "$PROMPT"
  changed=$(git diff --numstat | awk '{s+=$1+$2} END {print s+0}')
  if [ "$changed" -gt "$MAX_DIFF_LINES" ]; then
    echo "diff too large: ${changed} lines > ${MAX_DIFF_LINES}" >&2
    git checkout -- .
    exit 2
  fi
  if pytest -q; then
    echo "tests pass after self-correction"
    exit 0
  fi
done

echo "still failing after max_iters=3" >&2
exit 1

等價 Python 版,方便加日誌與之後接到 CI:

#!/usr/bin/env python3
"""pytest -> Codex exec -> git diff size guard. max_iters=3."""
from __future__ import annotations

import subprocess
import sys

MAX_ITERS = 3
MAX_DIFF_LINES = 120
PROMPT = (
    "Run pytest -q. If tests fail, read the failure, apply a minimal fix, "
    "re-run pytest -q. Do not refactor unrelated files. Stop after this iteration."
)


def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
    return subprocess.run(cmd, text=True, capture_output=True)


def pytest_ok() -> bool:
    result = run(["pytest", "-q"])
    sys.stdout.write(result.stdout)
    sys.stderr.write(result.stderr)
    return result.returncode == 0


def diff_line_count() -> int:
    result = run(["git", "diff", "--numstat"])
    total = 0
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
            total += int(parts[0]) + int(parts[1])
    return total


def main() -> int:
    if pytest_ok():
        print("tests already pass")
        return 0

    for i in range(1, MAX_ITERS + 1):
        print(f"=== iteration {i}/{MAX_ITERS} ===")
        exec_result = subprocess.run(
            [
                "codex",
                "exec",
                "--sandbox",
                "workspace-write",
                "--ask-for-approval",
                "never",
                PROMPT,
            ]
        )
        if exec_result.returncode != 0:
            print("codex exec failed", file=sys.stderr)
            return exec_result.returncode

        changed = diff_line_count()
        if changed > MAX_DIFF_LINES:
            print(
                f"diff too large: {changed} lines > {MAX_DIFF_LINES}",
                file=sys.stderr,
            )
            run(["git", "checkout", "--", "."])
            return 2

        if pytest_ok():
            print("tests pass after self-correction")
            return 0

    print("still failing after max_iters=3", file=sys.stderr)
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

本機先用一個會失敗的測試驗證迴圈:例如把 assert add(1, 1) == 2 寫成 == 3,確認 Codex 只改計算函式、不順便重排整個套件。Windows 原生環境若 sandbox 行為與 Linux 不同,優先在 WSL 跑同一支腳本,避免「本機能改、CI 不能改」的落差。

三輪之後仍紅燈,不要自動 git commit。正確收尾是留下 pytest 輸出與(未超標的)diff,開 issue 或交給人看。常見不是模型不夠聰明,而是測試在描述錯誤行為、或缺環境變數;這時再改產品程式,只會把錯誤規格寫死。Self-Correction 應接在 Day 11 的測試產出與 Day 13 的 stack trace 對齊之後:先有穩定、決定性的測試,迴圈才有資格當修補器。若測試會打真實資料庫或外部 API,先改成 fixture,否則模型可能「修」成跳過測試或改 timeout。CODEX_API_KEY 只包住單次 codex exec,不要 export 成整個 shell 的環境變數,這點與明天的 GitHub Actions 相同。

若要把迴圈接進 Makefile 或 pre-push hook,記得它會花 API 額度與時間:本機對失敗測試跑最多三輪是合理的;對「每次存檔」觸發則不合理。先手動確認 pytest 真的紅、失敗訊息穩定,再啟動腳本。Orchestrator 本身用一般 Python / Bash 測試即可(mock codex exec 回傳碼與假 diff),不必為了測迴圈而每次打真實模型。

把迴圈當成「有預算的修補器」,不要當成無人值守的自動合併。第一輪失敗時先讀 pytest 最後數十行,分辨是 assertion 還是 import/環境問題;後者應停下來補 fixture,而不是讓模型改產品程式。第二輪若 diff 仍集中在同一個函式,通常才是真修補;若開始改測試名稱或刪掉 assertion,立刻 git checkout -- . 丟掉。第三輪仍紅就輸出「未修復」報告:失敗測試名稱、三輪各自的 numstat、最後一次 traceback。這份報告可接到明天的 CI artifact,而不是把半成品推進 main。Windows 上若 sandbox 支援不完整,改在 WSL 跑同一條 bash,不要為了「能改檔」改開 danger-full-access

注意事項與常見失敗 (Pitfalls)

  • 忘記加 --sandbox workspace-writecodex exec 預設 read-only,看起來有跑、檔案卻沒變,pytest 會一直紅。修法:修補任務顯式開 workspace-write;純審查維持 read-only
  • max_iters 只寫在 Prompt:模型可能連跑十次或中途放棄。修法:外層 for 迴圈硬停在 3,Prompt 只負責「這一輪」。
  • 沒有 diff 大小 guard:一次失敗被修成跨檔重構,review 成本比手動修還高。修法:--numstat 加總超過門檻就 git checkout -- . 並以非零結束。
  • CI 使用 danger-full-access:拿掉 sandbox 等於讓模型指令碰到 runner 上的 secrets 與網路。修法:CI 預設 read-only;需要寫檔時最多 workspace-write,而且金鑰不要設成 job 層環境變數。
  • 把 flaky test 當修補目標:非決定性失敗會讓迴圈「修到綠」但其實只是重跑運氣好。修法:先穩定測試,再交給 Self-Correction Loop。
  • 工作區有未提交髒檔:diff guard 量到的是全部變更,不是這一輪修補。修法:進迴圈前 git status --porcelain 必須是空的,或先 stash。

本日總結 (Takeaways)

  • Self-Correction 的 oracle 是 pytest,不是模型自我感覺;綠燈才算結束。
  • codex exec 預設 read-only;要改檔用 --sandbox workspace-write,CI 不要用 danger-full-access
  • max_iters=3git diff 行數上限必須由外層腳本執行,不能只寫在 Prompt。
  • 每一輪 Prompt 只要求最小修正;無關重構、新依賴、格式化整棵樹都視為失敗。
  • 先在乾淨 Git 工作區重現失敗,再啟動迴圈,否則 guard 無法判斷「這一輪改了多少」。

明日預告 (Next)

明天把同一套「測試當閘門、Codex 當檢視器」搬進 CI/CD Pipeline:用 GitHub Actions 在 Pull Request 上自動跑 pytest,並以 read-only 的 codex exec 產出程式碼檢視摘要。


上一篇
Day 15: 多 Agent 協同工作流 (Multi-Agent Workflows):觀念介紹與架構設計
下一篇
Day 17: CI/CD Pipeline 整合:將 AI 程式碼檢視與測試自動化嵌入 GitHub Actions
系列文
ChatGPT + Codex 打造高效能 AI 開發工作流21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言