模組 07 · 第 3 課

一個靠得住的 AI 程式設計工作流

先寫驗收標準和測試,再讓 AI 動手,用測試的結果而不是 AI 的自述判斷做完沒有。用一個真實的小任務跑一遍這個流程,再講怎麼審查 AI 寫的程式碼,以及什麼時候不該用 AI。

  • 約 40 分鐘
  • 難度:進階
  • 實測:2026-09-14 deepseek-flash,pytest 9.1

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

用 AI 寫程式碼,最常見的失敗不是它寫不出來,而是它寫出來了、說"已經完成"了,你也信了,結果上線後才發現有問題。

原因往往出在兩頭:開頭沒說清楚"做完"是什麼樣子,結尾沒有客觀地檢查它是不是真的做完了。中間讓 AI 寫程式碼的那一段,反而是最不容易出問題的。

這一課講一個簡單的工作流,把兩頭都補上。它不繫結任何工具,用補全、對話、智慧體都一樣。

先寫"做完的標準"

在讓 AI 動手之前,先回答一個問題:改完之後,我怎麼知道它做對了?

最好的答案是一組測試。測試把"做完"變成了一個可以執行、會給出明確結果的東西:全部通過就是做完了,有一個沒通過就是沒做完。不需要看 AI 怎麼說,也不需要你憑感覺判斷。

用一個小任務來演示。httpx 答疑助手在呼叫 API 時可能收到 429(被限流),響應頭裡的 Retry-After 告訴你過多久再重試。它有兩種寫法:一個秒數(120),或者一個 HTTP 日期(Wed, 21 Oct 2026 07:28:00 GMT)。我們需要一個函式,把它解析成"還要等幾秒"。

先寫測試,把我能想到的情況都列出來:

from datetime import datetime, timezone

from retry_after import parse_retry_after

NOW = datetime(2026, 10, 21, 7, 0, 0, tzinfo=timezone.utc)


def test_seconds():
    assert parse_retry_after("120", NOW) == 120.0


def test_seconds_with_spaces():
    assert parse_retry_after("  30 ", NOW) == 30.0


def test_http_date_in_future():
    assert parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT", NOW) == 28 * 60.0


def test_http_date_in_past_means_no_wait():
    assert parse_retry_after("Wed, 21 Oct 2026 06:00:00 GMT", NOW) == 0.0


def test_negative_seconds_is_invalid():
    assert parse_retry_after("-5", NOW) is None


def test_decimal_seconds_is_invalid():
    # 标准规定秒数是非负整数,"1.5" 不合法
    assert parse_retry_after("1.5", NOW) is None


def test_garbage_is_invalid():
    assert parse_retry_after("soon", NOW) is None

(完整的 10 個測試在 code/07-ai-coding/test_retry_after.py。)

寫測試的過程本身就在幫你想清楚需求。寫到"日期已經過去了怎麼辦",你得決定是返回 0 還是返回負數;寫到"1.5 秒算不算合法",你得去查一下標準。這些問題,如果不先想清楚,AI 就會替你隨便決定一個。

測試之外,再寫一份簡短的任務說明(TASK.md),把測試沒法表達的要求寫進去:只能用標準庫、不要修改測試檔案、不要針對測試裡的具體值寫特殊判斷。最後一條很重要:沒有這條,一個"聰明"的 AI 可能寫出"如果輸入是 120 就返回 120.0"這種專門騙過測試的程式碼。

讓 AI 動手,用測試檢查

code/07-ai-coding/ai_coding_loop.py 把這個流程寫成了一個小程式:把任務說明和測試交給模型,讓它寫 retry_after.py,執行測試;沒通過就把測試的輸出交回給它修改,最多 3 輪:

for round_ in range(1, 4):
    reply = client.chat.completions.create(model=MODEL, messages=messages).choices[0].message.content
    TARGET.write_text(extract_code(reply))
    code, output = run_tests()
    summary = output.strip().splitlines()[-1] if output.strip() else ""
    print(f"第 {round_} 轮:退出码 {code},{summary}")
    if code == 0:
        print("测试全部通过。生成的代码:\n")
        print(TARGET.read_text())
        break
    messages += [{"role": "assistant", "content": reply},
                 {"role": "user", "content": f"测试没有通过,输出如下。修改代码,再给出完整的 retry_after.py:\n\n{output}"}]
else:
    print("3 轮都没有通过,停下来交给人看。最后一次的测试输出:\n" + output)

判斷"做完沒有"的,是 pytest 的退出碼:0 表示全部通過,非 0 表示有失敗。不是模型說的"我已經完成了"。

這其實就是 Claude Code、Codex 這類程式設計智慧體在做的事的一個縮影:寫程式碼、跑測試、看結果、再改。區別在於它們能自己決定什麼時候跑測試、跑哪些測試。所以給它們一份寫好測試命令的規則檔案(上一課),它們就能自己驗證。

真實的結果

我運行了很多次,分兩種情況。

模型能看到完整的任務說明和測試時,4 次執行全部第一輪就通過了。

只給模型一句話的需求、不給它看測試時(加上 --vague 參數,測試留在我手裡做驗收),11 次執行裡,10 次第一輪就通過了,1 次第一輪有 1 個測試沒通過,把測試的輸出交給它之後,第二輪通過:

第 1 轮:退出码 1,1 failed, 9 passed in 0.01s
第 2 轮:退出码 0,10 passed in 0.00s

(那一次我的指令碼還沒有列印失敗的測試名,所以不知道具體是哪一條。現在的指令碼會把 FAILED 開頭的行打印出來。)

這是最後一次執行生成的程式碼,一字未改:

from datetime import timezone
from email.utils import parsedate_to_datetime


def parse_retry_after(value, now):
    if not isinstance(value, str):
        return None

    value = value.strip()
    if not value:
        return None

    if value.isascii() and value.isdigit():
        try:
            return float(int(value))
        except (ValueError, OverflowError):
            return None

    try:
        retry_time = parsedate_to_datetime(value)
    except (TypeError, ValueError, OverflowError):
        return None

    if retry_time is None:
        return None

    if retry_time.tzinfo is None:
        retry_time = retry_time.replace(tzinfo=timezone.utc)

    return max(0.0, (retry_time - now).total_seconds())

有一個細節值得注意:value.isascii() and value.isdigit()。只用 isdigit() 是不夠的,它對阿拉伯文數字、上標數字這些非 ASCII 字元也返回 True。模型多寫的這個 isascii(),恰好堵上了一個我的測試裡沒有覆蓋的漏洞。反過來說,如果它沒寫,我的 10 個測試也發現不了。

老實說,這個任務對現在的模型來說不難:Retry-After 的格式在 HTTP 標準裡寫得很清楚,模型很熟悉,標準庫裡也有現成的 parsedate_to_datetime 可以解析 HTTP 日期。

但這恰恰是這個工作流的意義:你事先不知道這一次它會不會錯。11 次裡有 1 次,只給一句話需求時,它漏掉了某個細節。如果沒有測試,你拿到的就是那份有問題的程式碼,而模型會告訴你"已經完成"。有了測試,這一次失敗被自動發現、自動修好了,你甚至不用去看它錯在哪。

小步走

上面的例子只有一個函式。真實的任務往往大得多:"給 RepoBot 加上使用者登入"。這種任務交給 AI,最常見的結果是:它一口氣改了十幾個檔案,幾百行的改動,你看不過來,只能選擇全部接受或者全部放棄。

更好的做法是把大任務拆成小步,每一步都滿足三個條件:

  • 只做一件事。"加一個使用者表"是一步,"寫登入介面"是另一步,"前端加登入框"又是一步。
  • 改動小到你能在幾分鐘內看完。如果一次的 diff 大到你不想看,說明這一步太大了。
  • 有自己的驗收方式。最好是測試,至少也是一個你能手動檢查的結果。

每完成一步,就用 git 提交一次。出了問題,可以退回到上一個好的狀態,而不是面對一大堆混在一起的改動無從下手。

動手之前,還可以先讓 AI 只出計劃不動手(上一課和第 1 課提到的計劃模式或只讀模式)。它的計劃裡要寫清楚:打算改哪些檔案、每一步做什麼、怎麼驗證。你看過計劃,覺得方向對了,再讓它開始改。方向錯了,在計劃階段發現,只浪費了幾分鐘。

審查 AI 的改動

測試通過了,也不代表萬事大吉。測試只能檢查你想到的情況。合併之前,把 AI 的改動看一遍,重點看這些地方:

  • 它有沒有改測試。AI 為了讓測試通過,可能會修改測試本身,或者刪掉失敗的測試。這是最需要警惕的。
  • 有沒有針對測試的特殊處理。程式碼裡出現和測試用例裡一模一樣的具體值,要打個問號。
  • 有沒有順手改了別的地方。你讓它修一個 bug,它順便"最佳化"了三個不相關的函式。這些改動沒有測試覆蓋,也不在你的預期之內。
  • 邊界情況和錯誤處理。空值、超長的輸入、網路失敗、併發。上面的 parse_retry_aftertry/except 處理了日期解析失敗的情況,這一點就是審查時要確認的。
  • 安全問題。拼接 SQL、執行命令、處理使用者上傳的檔案、列印或者記錄金鑰。第 05 模組第 8 課和第 06 模組第 5 課講過的問題,在 AI 寫的程式碼裡一樣會出現。
  • 有沒有引入新的依賴。AI 很喜歡"順手"裝一個包來解決問題。每一個新依賴都是一份長期的維護負擔,也是一個潛在的安全風險。
  • 你能不能看懂。如果一段程式碼你看不懂,就不要合併它。等它出了問題,你得能修得了。

什麼時候不要用 AI

  • 你自己都沒想清楚要什麼。AI 會很樂意替你做決定,但那些決定不一定對。先想清楚,寫下驗收標準,再動手。
  • 你沒法驗證結果。你不懂的領域、沒有測試也沒法手動檢查的程式碼,AI 寫得再像樣,你也不知道它對不對。
  • 改動的代價很高而且不可逆。資料庫遷移、刪除資料、生產環境的配置。這類操作可以讓 AI 幫你寫,但一定要你自己審查,並且在測試環境裡先驗證。
  • 你想學會這個東西。用 AI 寫練習題,就像這門課第一課說的,是請人替你去健身房。

把整個流程串起來

1. 想清楚:做完是什么样子?写成测试或者可检查的验收标准
2. 拆小:一步只做一件事
3. 计划:让 AI 先说它打算怎么做,你确认方向
4. 动手:让 AI 改,改动控制在你能看完的范围
5. 验证:跑测试,看退出码,不看 AI 的自述
6. 审查:看它改了什么,重点看测试、边界、安全、依赖
7. 提交:git commit,然后开始下一步
8. 复盘:它犯过的错,写进项目的规则文件(上一课)

這個流程看起來比"直接讓 AI 寫"麻煩。但真正花時間的從來不是寫程式碼,而是發現問題、定位問題、修復問題。這個流程把發現問題的時間提前了,也把出了問題之後的損失控制在了一小步之內。

練習

  1. 執行 ai_coding_loop.pyai_coding_loop.py --vague 各幾次,記錄每次幾輪通過。
  2. test_retry_after.py 里加一個測試:parse_retry_after("Wed, 21 Oct 2026 07:28:00 +0800", NOW) 應該返回什麼?先查一下 HTTP 標準對日期格式的要求,決定你的答案,再看 AI 寫的程式碼是否滿足。
  3. 在你自己的專案裡選一個小功能,按本課的流程完整走一遍:先寫測試,再讓 AI 實現,跑測試,審查,提交。記下你在審查時發現了什麼問題。

自測

1. 為什麼要在讓 AI 動手之前先寫測試?

測試把"做完"變成了一個可以執行、結果明確的標準。有了它,判斷任務是否完成靠的是測試的結果,而不是 AI 說"已經完成"。寫測試的過程還會逼你想清楚需求裡那些模糊的地方(比如日期過期了怎麼辦、小數算不算合法),不把這些決定留給 AI 隨便處理。

2. 測試全部通過了,還需要審查 AI 的程式碼嗎?審查時重點看什麼?

需要。測試只能檢查你想到的情況。審查時重點看:它有沒有修改或刪除測試、有沒有針對測試用例寫特殊處理、有沒有順手改了無關的地方、邊界情況和錯誤處理、安全問題、有沒有引入新的依賴,以及你能不能看懂這些程式碼。

3. 為什麼要把大任務拆成小步,每一步提交一次?

大任務一次交給 AI,改動多到沒法認真審查,只能全盤接受或者全盤放棄,出了問題也難以定位。拆成小步後,每一步的改動都小到能看完、有自己的驗收方式;每步提交一次,出問題時可以退回到上一個好的狀態。