讓 AI 全面 review 我 vibe code 出來的 production 專案:20 個問題,其實只有兩種病

我有一個 side project:一個台股行情資料的爬蟲系統,每天開盤自動啟動、收盤停止、晚上全量重抓,跑在雲端、透過 Telegram 推播訊號。它是我一路 vibe coding 迭代出來的——功能一直長,但我從來沒有系統性地檢視過整個 codebase。它能跑,而且跑了好幾個月。

某天我讓 AI 對整個專案做一次全面 code review(針對實際的部署與排程流程,而不是抽象的 code style)。結果找到 20 個問題,從「會停機」到「會給出錯誤交易訊號」都有。但真正有價值的收穫不是那 20 條清單,而是把它們排開之後發現:幾乎全部可以歸成兩種病

病一:fire-and-forget 沒有兜底

第一類問題的共同形狀是:發起一個非同步操作,然後不管它的死活。

最典型的展品是爬蟲的生命週期。啟動端點長這樣:

ts
app.post('/api/start', async (c) => {
  startCrawler(mode) // 沒有 await,也沒有 .catch
  return c.json({ message: 'Crawler started' })
})

startCrawler 內部把 isRunning 設成 true,正常結束時設回 false。問題是「不正常結束」的路徑:只要資料庫抖一次、任何一個 await 拋出例外,函式就中途退出,isRunning 永遠卡在 true。之後每天排程器打 /api/start,都只會得到一句 "Already running"——而且 HTTP 回應永遠是 200,排程系統看到的永遠是綠燈。系統不是壞掉,是安靜地變成殭屍。

修法的原則很無聊但有效:把主流程抽成獨立函式,外層用 try/finally 統一清理,狀態還原寫在 finally 裡,讓任何退出路徑(正常、例外、中斷)都走同一個出口。fire-and-forget 的呼叫端補上 .catch 記錄錯誤1

同一類病還包括:背景服務由 setInterval 呼叫的 async 函式,其中一段 DB 查詢在 try/catch 之外——只要它 reject,就是一個 unhandled rejection,在新版 Node.js 預設會直接讓 process 崩潰1

病二:靜默失效

第二類更陰險:程式不會報錯,但輸出已經是錯的。

展品一:先捨入、才相除。 系統有個「爆量偵測」訊號:今日成交金額除以過去 N 天平均,超過門檻就告警。實作時為了顯示方便,金額先被四捨五入成「萬」為單位,然後才拿去相除:

ts
// Bug:round 之後才算倍數
const ratio = round(todayAmount) / round(avgAmount)

後果有兩層。低基期的標的(平均金額不到最小顯示單位)被 round 成 0,倍數變成 Infinity 或 NaN 被過濾掉——最應該告警的「平常沒量、今天突然爆大量」整類漏報。而壓在門檻邊緣的案例,2.99 倍被捨入推成 3.0 倍,誤觸發。修法一行:用原始金額計算,捨入只用於顯示。

展品二:isToday 寫死 true 訊號分析函式有個參數叫 isToday,決定要不要拿「即時股價」參與計算。它被寫死成 true——平常沒事,因為平常確實在算今天。直到某天用歷史日期重放驗證策略,系統拿「現在的股價」配「三個月前的成交金額」,產生了一批看起來煞有介事的假訊號。修法:isToday = date === getTodayDate(),讓它由資料本身決定。

展品三:假日表只有今年。 判斷是否休市靠一張手工維護的假日表,裡面只有今年的日期。明年元旦起,所有假日都會被當成交易日:爬蟲照常啟動、抓到前一個交易日的殘留資料、寫進一個不存在的交易日。資料庫裡憑空多出「幽靈交易日」,而以歷史資料為基準的訊號統計全部被污染。修法是 fail fast:查表查不到該年度就直接 throw,讓系統大聲死掉,而不是安靜地做錯事。

展品四:WebSocket 連線洩漏。 清理函式只在連線狀態是 OPEN 時呼叫 close()。但 timeout 發生時連線往往還在 CONNECTING——這時 close() 沒有效果,連線在背景完成握手後就成了沒有任何引用的活連線。修法:一律 terminate()(強制斷線),並在 close handler 裡補 clearTimeout2

兩種病的共同根源

回頭看,這兩類病能在一個「天天正常運作」的系統裡存活這麼久,原因是一樣的:它們都只在特定條件下發作。fire-and-forget 的洞要等依賴出包才爆;靜默失效要等資料走到邊界情況(低基期、跨年度、歷史重放)才現形。日常使用給你的「它能跑」的信心,對這兩類問題完全沒有檢驗力。

這也解釋了為什麼 AI review 抓得到它們:這些問題不需要執行程式,從 code 的結構就能推理出來——「這個 async 呼叫的錯誤會流去哪?」「這個表只有 2026 年,2027 年會發生什麼?」是純粹的紙上推演。20 個問題裡沒有任何一個需要「跑跑看才知道」。

對 vibe coding 的誠實結論

這次 review 之後我對 vibe coding 的看法是:它積累的債務不是 code 品質,是你不知道哪裡會壞。一路 vibe 出來的系統可以結構清楚、命名合理、甚至模式一致(AI 寫的 code 通常如此),但「所有失敗路徑都有人接」這件事不會自然發生——因為你每次對話的注意力都在讓功能動起來,而失敗路徑在 demo 裡永遠不會被走到。

定期讓 AI 用「找出所有會壞的方式」的立場重讀整個專案,是我目前找到最划算的還債方式。這次 review 的 20 個問題,修復加驗證大約花了一個週末。

後日談:修完這 20 個問題的兩週後,這個系統還是在盤中死掉了——凍結整整 12 小時,怎麼按停止都沒反應。那次事故教的東西,是任何 code review 都抓不到的另一份清單,我寫在下一篇:〈timeout 不等於 abort:一個資料庫 black-hole 殺死我爬蟲三次的覆盤〉。

環境資訊

  • Node.js: v22.x
  • TypeScript: 5.x
  • Runtime: Fly.io + 外部 cron 排程服務

本文撰寫時間:2026 年 7 月,技術版本可能隨時間更新,請以官方文件為準。

Reference

  1. Process | Node.js Documentation — Event: 'unhandledRejection' — 未處理的 promise rejection 自 Node.js 15 起預設行為是終止 process。 2
  2. ws - WebSocket client documentationterminate() 立即強制斷線,與 close() 的優雅關閉握手不同,是清理 CONNECTING 狀態連線的正確手段。