(接 上篇)
5 復盤與 Copilot 的交互過程
前面兩篇文章分別涵蓋了掃雷游戲的問題分解和代碼實現過程,不知道各位是否會有代碼一氣呵成的錯覺?實際上,為了達到最終效果(如下所示),我和 GitHub Copilot
進行了多次正面交鋒,其間也走了很多彎路,這一篇就來和大家聊聊看似簡單的 AI
輔助編程暗含的陷阱和我實戰時踩過的坑。
先說說 Copilot
的優點吧。由于看過書中作者和 Copilot Chat
的交互過程十分低效,我用得最多的仍然是代碼實時補全功能。Copilot
在回答很具體的小微型問題時是非常給力的,比如 utils.js
工具模塊的通用函數提示、函數 jsdoc
文檔的生成以及周邊單元格的邊界討論方面都非常出彩,幾乎不用二次修改。這可能跟 GitHub Copilot
底層大模型的訓練數據有關——掃雷游戲開發已經是一個爛大街的練手項目了,跟平時經常刷到的吃豆子、貪吃蛇、俄羅斯方塊等屬于同一個級別的編程問題,因此數據質量是有保證的,效果也的確不錯。
但是對于一些有難度的處理邏輯,Copilot
就有點力不從心了。本例中的典型代表,當屬單元格遞歸檢索部分的代碼實現。先上代碼:
cell.onmousedown = ({ target, which }) => {/*...*/if (which === 1) { // 左擊// 1. 如果已插旗,則不處理if (cellObj.flagged) return;// 2. 踩雷,游戲結束:if (cellObj.isMine) {/*...*/return;}// 3. 若為安全區域,標記為已檢查searchAround(cellObj, target, lv.col, mines);// 4. 查看是否勝利/*...*/}
});function searchAround(curCell, curDom, colSize, mines) {curCell.checked = true;// Render the current cellcurDom.classList.add('number', `mc-${curCell.mineCount}`);curDom.innerHTML = curCell.mineCount;// 如果是空白單元格,則遞歸顯示周圍的格子,直到遇到非空白單元格if (curCell.mineCount === 0) {curDom.innerHTML = '';curCell.neighbors.forEach(nbId => {const nbCell = mines[nbId - 1];const nbDom = $(`[data-id="${getIJ(nbId, colSize)}"]`);if(!nbCell.checked && !nbCell.flagged && !nbCell.isMine) {searchAround(nbCell, nbDom, colSize, mines);}});}
}
上述遞歸子函數 searchAround()
中,最核心的 L29-L37
其實是我自己寫的,因為在此之前我讓 Copilot
嘗試了不下五次都沒能給出最正確的版本。
這一部分的原始版本其實是 Copilot
根據我的注釋內容補全的,當時它用的是 MouseEvent
實例,將周邊單元格的狀態計算通過重新觸發一次鼠標點擊來實現,看上去是那么的人畜無害:
if(cellObj.mineCount > 0) {// 如果不是空白單元格,則顯示數字target.classList.add('number', `mc-${cellObj.mineCount}`);target.innerHTML = cellObj.mineCount;} else {// 如果是空白單元格,則遞歸顯示周圍的格子target.classList.add('number', 'mc-0');target.innerHTML = '';const colSize = getCurrentLevel(cfgs).col;cellObj.neighbors.forEach(nbId => {const nbCell = mines[nbId - 1];const nbDom = $(`[data-id="${getIJ(nbId, colSize)}"]`);if (!nbCell.checked) {nbDom.dispatchEvent(new MouseEvent('mousedown', { which: 1 }));}});}
結果換到中高級難度時,偶爾就會出現堆棧溢出的情況:
【圖 3 利用 Copilot 補全的代碼出現的堆棧溢出的情況截圖】
雖然報錯代碼定位在了 L14
;即便這樣,但憑借對前面問題分解的過分自信,我還是沒往 Copilot
提示錯誤的方向思考,而是認定遺漏了某個邊界條件。再一捋,還真被我找到一個看似合理的解釋:隨機分布地雷時安全邊界未完全閉合,導致算過的區域又竄到另一塊區域重復計算(如圖 6 所示):
【圖 4:對比 Windows 掃雷游戲發現的邊界不閉合問題(左上第一個框中區域)】
為了驗證這個假設,我還特意試了試 Windows
自帶的掃雷游戲,邊界果然都是完全閉合的:
【圖 5:觀察 Windows 自帶的掃雷游戲看到的完全閉合邊界】
抱著懷疑的態度,我又問了 Copilot
是否是這個原因導致的,它說“很有可能”。這樣一來,假設就得到了“多方驗證”,接下來就是大刀闊斧地重構代碼了:先確定邊界完全閉合的判定條件,然后在初始化雷區時逐一判定,發現一處就重新隨機生成,直到邊界完全閉合。改了一大堆代碼,這是其中兩個核心邏輯:
// Check if the mine distribution is valid
function checkInvalidCorner(mineCells, col) {return mineCells.filter(({ isMine, mineCount }) => !isMine && mineCount === 0).reduce((acc, cell) => {const { id, neighbors } = cell, idLeft = id - 1, idRight = id + 1, idTop = id - col, idBottom = id + col;const cornerChecker = checkCorner(neighbors, mineCells, col);const [foundTL, ij1] = cornerChecker([idTop, idLeft], arr => Math.min(...arr) - 1 - 1);const [foundTR, ij2] = cornerChecker([idTop, idRight], arr => Math.min(...arr) + 1 - 1);const [foundBL, ij3] = cornerChecker([idBottom, idLeft], arr => Math.max(...arr) - 1 - 1);const [foundBR, ij4] = cornerChecker([idBottom, idRight], arr => Math.max(...arr) + 1 - 1);if (foundTL || foundTR || foundBL || foundBR) {const coordinates = [ij1, ij2, ij3, ij4].filter(Boolean).map(c => `(${c})`);acc.push(...coordinates);}return acc;}, []);
}function checkCorner(neighbors, mineCells, col) {return (group, indexCb) => {const nbs = neighbors.filter(nb => group.includes(nb));const inPair = nbs.length === 2;if (!inPair) {return [false];}const bothNearMine = nbs.every(nbId => {const target = mineCells[nbId - 1];return (!!target) && (target.mineCount > 0);});if(!bothNearMine) {return [false];}// 檢查:左上角單元格存在且 mineCount > 0const cornerIndex = indexCb(nbs);const cornerCell = mineCells[cornerIndex];const invalid = (!!cornerCell) && cornerCell.mineCount === 0;if(!invalid) {// 為有效單元格,跳過return [false];}const ij = getIJ(cornerCell.id, col);return [invalid, ij];};
}
如此折騰下來,堆棧溢出的問題明顯少了很多,后臺也能看到重新生成的次數,下一步就是繼續探索新的邊界條件了:
【圖 6:根據安全邊界完全閉合的說法重構的游戲界面與控制臺提示信息截圖】
正當我為自己的階段性勝利沾沾自喜時,老天似乎都看不下去了,特意讓我在一次 Windows
原生掃雷游戲中看到了一次邊界也有問題的 特例:
【圖 7:Windows 掃雷游戲也出現了不完全閉合的安全邊界】
聰明的你沒有看錯,這是剛開局不久第一次探雷的結果:即便框中部分的邊界并沒有“完全”閉合,也絲毫不影響安全區域的最終擴散。之前自信心爆棚的假設驗證環節就這樣不攻自破了。我也才猛然醒悟 Copilot
那句代碼的真正問題:四周的八個單元格依次觸發 mousedown
事件,到最后一個鄰近區域時如果周邊還是沒有地雷,就又會以該點為中心,把此前計算過的區域劃為下一輪計算目標,由此導致循環往復。這說明在遞歸查詢時還應該補充一個狀態位,檢查過的單元格就不要再算下去了,這樣才能從源頭上控制溢出。
順著這個思路,我讓 Copilot
自行生成對應的遞歸實現,結果問了好幾次都不成功:無論使用什么樣的提示詞,無論怎么完善前置信息,Copilot 始終不能跳出當前的代碼邏輯,幫我抽象出一個滿足遞歸調用的新版本:
【圖 8:多次卡住 GitHub Copilot 的“高難度”待重構代碼片段】
最終只能我自己動手修復了這個終極 Bug
:
function searchAround(curCell, curDom, colSize, mines) {curCell.checked = true;// Render the current cellcurDom.classList.add('number', `mc-${curCell.mineCount}`);curDom.innerHTML = curCell.mineCount;// 如果是空白單元格,則遞歸顯示周圍的格子,直到遇到非空白單元格if (curCell.mineCount === 0) {curDom.innerHTML = '';curCell.neighbors.forEach(nbId => {const nbCell = mines[nbId - 1];const nbDom = $(`[data-id="${getIJ(nbId, colSize)}"]`);if(!nbCell.checked && !nbCell.flagged && !nbCell.isMine) {searchAround(nbCell, nbDom, colSize, mines);}});}
}
經此一役,Copilot
在我心中的地位也直線下滑,成功實現了 AI
輔助編程“祛魅”。
種種跡象再次印證了當前 AI
的一個突出問題:無法真正理解補全代碼的具體含義。
按理說,掃雷游戲的開源代碼不算少了,但為什么 Copilot
屢試屢敗呢?這還是跟具體的訓練數據有關,至少采用我這樣遞歸查詢算法的掃雷實現方案明顯不足。到 GitHub
隨手一搜,就看到一段沒有使用遞歸檢索的核心邏輯:
this.reveal1 = function() {/*...*/var row, col;var curCell, nbCell;var stack = [];stack.push(this);this.pushed = true;while (stack.length > 0) {curCell = stack.pop();if (!curCell.isRevealed() && !curCell.isFlagged()) {if (curCell.isMine()) {return false}curCell.setClass(`square open${curCell.getValue()}`);curCell.setRevealed(true);if(!curCell.isHidden()) {if (--remainingSafeCells == 0) {handleGameWinning();return true}if (curCell.getValue() == 0) {// Recursive reveal of neighborsfor (row = -1; row <= 1; row++) {for (col = -1; col <= 1; col++) {nbCell = gameGrid[curCell.getRow() + row][curCell.getCol() + col];if (!nbCell.pushed && !nbCell.isHidden() && !nbCell.isRevealed()) {stack.push(nbCell); // push the neighbor cell to the stacknbCell.pushed = true}}}}}}}/*...*/
}
看吧,人家都是自行維護調用棧,根本不會出現堆棧溢出的情況。
類似的例子還有很多,就不一一引用了,反正承認自己的版本非常小眾且弱雞就是了。
因此,想要真正讓 AI
輔助編程大放異彩,至少現階段還是困難重重:因為它理解不了代碼的真正含義,所以可供選擇的平替方案非常有限:
- 要么依靠高質量的精準數據定向投喂,發揮
AI
的相關性推斷優勢; - 要么從算法層面再次突圍:可惜不是所有公司都叫
DeepSeek
; - 要么就只能像文中的我,自己動手豐衣足食了。
現在再看第八章作者的吐血推薦,真是感覺字字珠璣——
… Last, always, and we mean always, test every function you write.
(……最后,重要的事情說三遍,務必要測一測寫出的每一個函數。)
姜,果然還是老的辣。