AI-LECTURER 速報課|2026-10-19|約 25 分鐘

AI 寫的程式你看得懂嗎?Sphinx 在你按下 git push 前,先考你一題

📍 真實場景
阿宏,28 歲,接案網站工程師,正在幫一家小型花店做線上訂購系統

他請 Claude Code 串接 Stripe 收款,AI 一口氣產出 300 行,測試也全綠,他正準備 git push 上線

😖 卡住的地方:客戶問他:「Stripe 重複通知同一筆訂單時,客人會不會被扣兩次款?」阿宏發現自己完全答不出來,因為那段程式是 AI 寫的,他只是按了「接受」
💡 這課帶你認識 Sphinx 這個工具,並親手做一個迷你版:推送前被問一題,答對才放行。你會學到「用了卻不懂」為什麼是 AI 時代最大的隱性風險,以及怎麼用工程手段把理解變成流程的一部分

🧭 這到底是什麼(白話版)

Sphinx 是一個免費、開源(FOSSFree and Open Source Software,免費且原始碼公開的軟體,任何人都能檢查與修改)的工具,專門給用 AI 寫程式的人。它的核心是一個 Git hookGit 在特定動作(如提交、推送)發生前後自動執行的小程式,可以當作關卡:你(或你的 AI 助理)執行 git push 時,Git 會先暫停,由 Sphinx 讀你這次改了什麼,然後問你一個問題。

它問的不是「這行程式是什麼意思」這種背書題,而是「出錯時會怎樣」的情境題。素材裡的例子是:你的 webhook別人的系統在事件發生時,主動呼叫你網址通知你的機制,例如 Stripe 在客人付款後通知你 處理器現在會先存下事件 ID 再呼叫 charge()(收款函式)。Stripe 逾時後又送來同一個事件,這時會怎樣?如果 charge() 失敗了,下一次重送又會怎樣?答案藏在程式的執行順序裡:先記 ID 再收款,收款失敗後 ID 已被記下,重送就被當成『處理過了』而跳過,客人永遠不會被收款。

回答的流程像一場小對話:答錯,審查者告訴你為什麼錯,再給你一個新情境;答對,推送才繼續。審查者是另一個 LLM大型語言模型,也就是 ChatGPT、Claude 這類會讀寫文字的 AI,而且它『沒看過你和 AI 助理的聊天紀錄』,只看最終的 diff(diff兩個版本之間的差異清單,標示哪些行被新增、哪些被刪除)。這很關鍵,後面的工程視角會說明原因。

它可搭配 Codex、Claude Code、Cursor、OpenCode、Pi,或純 Git 使用;用 npm install -g sphinx-understand 安裝,再執行 sphinx install 把 Git 與你的 AI 工具接起來。官方也承諾:你的答案不會回傳給寫程式的 AI,.env 與金鑰檔不會被打開。趕時間可用 sphinx yolo 放行,但每次略過都會被記錄。

🎯 為什麼值得你花時間

理解債比技術債更隱形AI 讓程式產得比你讀得快。測試全綠只代表『已想到的情況』沒問題,不代表你懂它。Sphinx 的標語說得直白:你可以外包思考,但外包不了理解。出事時,負責的人是你,不是 AI。
把「理解」變成一個會擋人的流程靠自覺在推送前多看兩眼,忙起來一定忘。Sphinx 把檢查放在 Git 這條必經之路上,不問完不放行,不依賴意志力。
問「出錯時」,才會逼出真正的盲點正常路徑誰都看得懂,bug 都住在失敗、逾時、重送、並發的角落。素材的例子就是:重複扣款與永遠漏收款,只差一行的順序,兩種都是金錢事故。

⚙️ 它是怎麼運作的

1
你或你的 AI 執行 git pushGit 在被你標記為『受保護』的分支上暫停,並交給 Sphinx,這時程式還沒離開你的電腦。
▼
2
不知情的審查者讀 diff另一個模型只看這次的變更內容(它沒讀過你和 AI 的對話),判斷這段改動『最容易在哪裡出錯』。
▼
3
出一道情境題不是考語法,而是『如果 Stripe 重送同一事件,會怎樣?』這種讓你推演執行過程的題目。
▼
4
你回答,系統判分答錯會解釋為什麼,並換一個新情境再問(避免你只是背答案);答對就放行,推送繼續。
▼
5
趕時間可以 yolo,但留下紀錄sphinx yolo 讓推送直接通過,每一次略過都被記錄,事後可以回頭檢討。
三種「推送前把關」方式比較
做法誰在檢查擅長發現主要缺點
直接信 AI、測試全綠就推沒有人已被測試覆蓋的問題你完全不理解自己上線的東西
傳統人工 code review同事設計與風格問題個人專案或接案常常沒有同事可審
Sphinx 推送前提問不知情的 AI 審查者+你本人你『不懂』的失敗情境多花幾十秒;題目品質取決於審查模型

🔍 程式碼漫遊(點有 ● 的行看白話講解)

這是我們在實作中做的迷你版 Sphinx 的核心。它不用 AI,只用關鍵字規則出題,目的是讓你看清楚『Git 關卡+提問+放行或擋下』的骨架。

●def added_lines():
💬 取出『這次新增了哪些程式行』,因為只有被改動的部分才值得拿來出題。
● r = subprocess.run(['git', 'diff', 'HEAD~1', 'HEAD', '--unified=0'], capture_output=True, text=True, encoding='utf-8', cwd=ROOT)
💬 叫 Git 比較最新一次提交和前一次的差異;--unified=0 表示不附帶前後文,只留變更行。encoding 明寫 utf-8,避免 Windows 把中文讀成亂碼。
● return ' '.join(l[1:] for l in r.stdout.splitlines() if l.startswith('+') and not l.startswith('+++'))
💬 diff 裡以 + 開頭的是新增行;+++ 是檔名標頭要排除。
●def ask(prompt):
💬 負責『向你提問並讀取答案』。
● tty = open('CONIN$' if os.name == 'nt' else '/dev/tty', encoding='utf-8')
💬 重點行:git push 時,Git 已把標準輸入拿去傳推送資訊,一般的 input() 讀不到你打的字,所以要直接開啟『終端機本身』。Windows 叫 CONIN$,Mac/Linux 叫 /dev/tty。
● except OSError: return ''
💬 開不了終端機(例如在無人值守的自動流程中)就回傳空答案,等於答錯而擋下。寧可誤擋也不要默默放行,這叫『失敗時關閉』(fail closed)。
● if os.environ.get('SPHINX_YOLO') == '1':
💬 逃生口:環境變數設為 1 就跳過檢查。這是刻意設計的,因為完全不能繞過的關卡,最後只會被人整個關掉。
● f.write(datetime.datetime.now().isoformat() + ' 略過檢查\n')
💬 重點行:放行可以,但一定要留下時間紀錄。這讓『趕時間』變成可檢討的事,而不是無聲消失。
● rule = next((r for r in rules if r['keyword'] in code), None)
💬 在題庫裡找第一條『關鍵字有出現在新增程式中』的規則;找不到就是 None。真正的 Sphinx 在這裡改由 AI 審查者看 diff 動態出題。
● if ask('你的答案(輸入數字):') == str(rule['answer'] + 1): return 0 return 1
💬 重點行:程式的結束碼(exit code)就是關卡開關。0 代表通過,Git 繼續推送;非 0 代表失敗,Git 中止推送。整個機制就靠這個數字。

🛠️ 動手做:動手做:自製迷你 Sphinx,攔住一個會漏收款的 webhook

這是一個真實小專案:下載(或複製)檔案,照步驟在你電腦上跑起來。

📄 .githooks/pre-push ⬇ 下載
#!/bin/sh
# Git 會把推送資訊從標準輸入灌進來,我們用不到,先丟掉;提問改由 Python 直接讀終端機
cat > /dev/null
python .githooks/mini_sphinx.py
📄 .githooks/mini_sphinx.py ⬇ 下載
import datetime, json, os, pathlib, subprocess, sys

sys.stdout.reconfigure(encoding='utf-8')  # Windows 主控台預設不是 UTF-8
HERE = pathlib.Path(__file__).resolve().parent
ROOT = HERE.parent


def added_lines():
    r = subprocess.run(['git', 'diff', 'HEAD~1', 'HEAD', '--unified=0'], capture_output=True, text=True, encoding='utf-8', cwd=ROOT)
    return ' '.join(l[1:] for l in r.stdout.splitlines() if l.startswith('+') and not l.startswith('+++'))


def ask(prompt):
    # git 把 stdin 拿去傳推送資訊了,所以直接讀終端機
    try:
        tty = open('CONIN$' if os.name == 'nt' else '/dev/tty', encoding='utf-8')
    except OSError:
        return ''  # 讀不到終端機就當答錯,寧可誤擋也不默默放行
    print(prompt, end='', flush=True)
    return tty.readline().strip()


def main():
    if os.environ.get('SPHINX_YOLO') == '1':
        with (ROOT / 'skip_log.txt').open('a', encoding='utf-8') as f:
            f.write(datetime.datetime.now().isoformat() + ' 略過檢查\n')
        print('YOLO 模式:放行,並已記錄這次略過')
        return 0
    rules = json.loads((HERE / 'questions.json').read_text(encoding='utf-8'))
    code = added_lines()
    rule = next((r for r in rules if r['keyword'] in code), None)
    if rule is None:
        print('這次變更沒有可出題的內容,放行')
        return 0
    print()
    print('【迷你 Sphinx】推送前請先回答:')
    print(rule['question'])
    for i, opt in enumerate(rule['options'], 1):
        print(f'  {i}. {opt}')
    if ask('你的答案(輸入數字):') == str(rule['answer'] + 1):
        print('答對了,放行推送')
        return 0
    print('不對。' + rule['explain'])
    return 1


if __name__ == '__main__':
    sys.exit(main())
📄 .githooks/questions.json ⬇ 下載
[
  {
    "keyword": "seen_ids.add",
    "question": "這段程式先把事件 ID 記進 seen_ids,才呼叫 charge()。如果 charge() 這次丟出錯誤,Stripe 稍後重送同一個事件,會發生什麼事?",
    "options": [
      "會再收款一次,客人被重複扣款",
      "會直接回 200 跳過,客人永遠沒被收款",
      "會自動重試 charge() 直到成功"
    ],
    "answer": 1,
    "explain": "ID 在收款之前就被記下了,所以重送時程式以為『處理過了』直接回 200,但那次收款其實失敗了,錢永遠收不到。修法:收款成功之後才記 ID,或把『記 ID』與『收款』放進同一個交易。"
  }
]
📄 payment_webhook.py ⬇ 下載
def charge(customer, amount):
    print(f'向 {customer} 收款 {amount} 元')


def handle_event(event):
    charge(event['customer'], event['amount'])
    return 200
📄 payment_webhook_v2.py ⬇ 下載
seen_ids = set()


def charge(customer, amount):
    print(f'向 {customer} 收款 {amount} 元')


def handle_event(event):
    if event['id'] in seen_ids:
        return 200
    seen_ids.add(event['id'])  # 先記下 ID,用來擋重複通知
    charge(event['customer'], event['amount'])
    return 200
  1. 需求:已安裝 Git for Windows 與 Python 3,並已設定過 git config user.name 與 user.email。以下全部在 PowerShell 執行。
  2. 建立實驗資料夾並進入:mkdir sphinx_lab; cd sphinx_lab
  3. 把下載的 5 個檔案放進 sphinx_lab:pre-push、mini_sphinx.py、questions.json 放在子資料夾 .githooks 裡,payment_webhook.py 與 payment_webhook_v2.py 放在根目錄。
  4. 初始化並啟用關卡:git init; git config core.hooksPath .githooks
  5. 建立一個本機的假遠端倉庫當推送目標:git init --bare ../remote.git; git remote add origin ../remote.git
  6. 建立基準提交(第一版沒有去重邏輯):git add .; git commit -m "chore: baseline"
  7. 模擬 AI 幫你加上『去重』功能:Copy-Item payment_webhook_v2.py payment_webhook.py -Force; git commit -am "feat: dedupe webhook events"
  8. 推送並被攔下:git push -u origin HEAD。畫面會出題,先故意輸入 1(答錯),推送會被中止,並看到解釋。
  9. 再推一次:git push -u origin HEAD,這次輸入 2,答對後推送成功。
  10. 測試逃生口:git commit --allow-empty -m "chore: retry"; $env:SPHINX_YOLO='1'; git push; Remove-Item Env:SPHINX_YOLO; Get-Content skip_log.txt。你會看到推送直接通過,且 skip_log.txt 留下了時間紀錄。
  11. 挑戰:照 questions.json 的格式再新增一條規則(例如關鍵字 except 或 retry),自己寫一道『出錯時會怎樣』的題目,再提交一個會觸發它的變更,確認關卡會攔下。

🧠 工程思維透鏡(資深工程師看到的是什麼)

🔭 為什麼把關卡放在 git push,而不是寫程式的當下或 IDE 裡?
資深工程師會找『不可逆的前一刻』設卡。寫程式時一直被打斷,會讓人把外掛關掉;而 push 之後程式就進入共享、可能上線的狀態,代價急遽升高。Git 又是你和 AI 工具都必須經過的共同路口,不管用 Codex、Cursor 還是純手打,都繞不過去。取捨:多一道摩擦,換來『在最便宜的時間點發現盲點』。
🔭 審查者刻意『沒看過你的聊天紀錄』,這有什麼好處?
如果審查者讀過你和 AI 的對話,會吸收相同的假設與敘事,容易跟著『合理化』同一個錯誤。只看最終 diff,等於模擬一位全新的接手同事:他只能從程式本身推理。這是一種獨立性設計,類似請不同的人做 code review 而非自己審自己。代價是審查者可能缺乏業務背景而問出不夠貼切的題目,所以需要『答錯後換情境再問』來補強。
🔭 素材裡的『先記 ID 再收款』錯在哪?這反映什麼設計取捨?
這是分散式系統經典的 at-most-once(最多一次,寧可漏做)與 at-least-once(至少一次,寧可重複)之爭。先記 ID 再做事=最多一次,失敗就永遠漏掉;先做事再記 ID=至少一次,記錄失敗時可能重複。金錢系統通常選至少一次,再讓收款動作本身具備『冪等性』(冪等同一個操作重複執行多次,結果和執行一次相同),例如把事件 ID 當作收款請求的唯一鍵。重點不是記住答案,而是養成『每個副作用的前後,崩潰會留下什麼狀態』的思考習慣。
🔭 為什麼 yolo 模式選擇『放行並記錄』,而不是乾脆禁止略過?
任何無法繞過的安全機制,在緊急情況(線上當機、深夜修復)下一定會被整個關掉或拆除,結果比有逃生口更糟。留逃生口但記錄,是把『可見性』當作約束:略過不是禁止,而是要付出『被看見』的代價。這與現實中的 break-glass 緊急權限同一個思路。風險是紀錄若沒人看就形同虛設,所以要搭配定期回顧。

📝 隨堂考(點選答案,立即回饋)

Q1. Sphinx 在流程中的哪個時間點介入?
✅ 它是 Git hook,掛在推送這個動作之前。程式還沒離開你的電腦,所以此時發現問題成本最低。
Q2. 素材中的 webhook 處理器『先存事件 ID,再呼叫 charge()』。若 charge() 失敗,Stripe 重送同一事件,最可能的結果是?
✅ ID 在收款前就被記下了,重送時被視為『處理過』而直接回 200,但那次收款其實失敗,造成漏收。
Q3. 為什麼 Sphinx 的審查者刻意不讓看你和 AI 的聊天紀錄?
✅ 獨立性是重點:沒讀過對話的審查者不會被相同的敘事帶著走,更容易挑出盲點。
Q4. 迷你版 hook 靠什麼讓 Git 知道要『擋下』這次推送?
✅ Git hook 以結束碼判斷:0 代表通過,非 0 代表失敗並中止動作。畫面文字只是給人看的。
Q5. sphinx yolo 這個設計體現了什麼取捨?
✅ 完全不能繞過的關卡在緊急時會被拆掉;放行並記錄,兼顧彈性與事後檢討。

🃏 翻牌記憶卡(先想答案,再點開對答)

Sphinx 的核心主張是什麼?點我翻面
你可以外包思考,但外包不了理解。在 AI 的程式碼推送前,先確認你真的懂它出錯時會怎樣。
Git hook 如何決定放行或中止?點我翻面
看 hook 程式的結束碼:0 放行,非 0 中止。
至少一次(at-least-once)與最多一次(at-most-once)各自的風險?點我翻面
至少一次可能重複執行(如重複扣款),需要冪等設計;最多一次可能完全漏做(如漏收款)。
為什麼要問『出錯時會怎樣』而不是『這行是什麼意思』?點我翻面
正常路徑誰都看得懂,真正的 bug 住在失敗、逾時、重送、並發等角落,這些才是理解的試金石。
Sphinx 如何保護隱私?點我翻面
你的答案不會傳給寫程式的 AI;.env 與金鑰檔不會被打開。

✅ 離開前自測(全勾=這課真的學會了)

0%