iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

昨天把執行器接上之後,評測裡的 Gemini 其實已經失業了。

它原本的工作是「讀學生的程式碼,猜每組測資會不會過」。
現在執行器真的跑過了,all_passed 有子行程的結束碼撐著,
再叫模型猜一次,只是多花一次額度去產生一個會被丟掉的答案。

但把它整個拿掉也不對。執行器說得出發生了什麼:

statuses : ['crash', 'crash', 'crash']
事實     : 第 1、2、3 組執行時崩潰:ValueError: not enough values to unpack (expected 3, got 2)

說不出為什麼。而對一個剛學程式的人來說,
not enough values to unpack 這行字幾乎等於沒說。

所以今天做的事是換工作,不是裁員:讓模型去讀 traceback,寫一句人話。

這句話不能跟事實放在同一個欄位

第一版我差點就把解讀直接寫進 summary——反正欄位已經在那裡,UI 和導師
都已經在讀它了,一行就改完。

停下來的原因是 Day 21 那條剛立好的規則:分得出來才誠實。

  • summary 是執行結果排出來的字,每個字都有子行程的輸出撐著,不可能編
  • 解讀是另一個模型讀完之後的判斷,它可能是錯的

兩句話放在一起唸起來一樣確定,但它們不是同一個等級的東西。
所以是兩個欄位:

@dataclass
class GradeResult:
    summary: str = ""        # 發生了什麼(執行時:事實)
    explanation: str = ""    # 為什麼(模型的解讀)

一路標到底。導師的 prompt:

parts.append(f"- 摘要:{grade['summary']}")
if grade.get("explanation"):
    parts.append(f"- 助教的解讀(僅供參考,不是執行結果):{grade['explanation']}")

UI 也是:

st.info(f"🤔 AI 助教的解讀(不是執行結果):{grade['explanation']}")

然後模型當場示範了為什麼要這樣分

單元測試全用假 client,跑得快,但它保證不了「這句話有沒有用」。
所以寫了一支真連線的 smoke,餵三種有代表性的壞程式,把事實與解讀並排印出來:

■ 崩潰(以為每行有三個數)
  事實 : 第 1、2、3 組執行時崩潰:ValueError: not enough values to unpack (expected 3, got 2)。
  解讀 : 程式在解析每行輸入時,嘗試將分割後的結果解包成三個變數(a, b, c),
         但實際上每行只有兩個數字,導致數量不符而崩潰。

■ 無窮迴圈(忘了讀下一行)
  事實 : 第 1、2、3 組逾時被中止(程式一直沒有結束)。
  解讀 : 程式在讀取第一行輸入後進入了無限迴圈,且沒有在迴圈內繼續讀取後續的
         輸入行,導致不斷印出固定的結果而超時。

這兩個都很好。翻譯精準,而且沒有偷渡解法——沒有一句「你應該改成
for line in sys.stdin」,那會把導師整套蘇格拉底式引導一秒毀掉。

第三個是這樣:

■ 輸出格式(少了複數 s)
  事實 : 第 1、2 組輸出與預期不符。
  解讀 : 當進位次數大於 1 時,程式輸出了單數的 operation 而沒有加上複數的 s。
         此外,當對齊補零時 rjust 的長度取法有誤,導致字串長度較短的一方
         沒有正確補齊到最長位數。

第一句完全正確。第二句是編的。

那段程式是這樣寫的:

a, b = a.rjust(len(b), "0"), b.rjust(len(a), "0")

看起來很可疑——右邊用了左邊要改的變數。但 Python 的 tuple assignment 會先
把右側整個算完,兩個 len() 取到的都是原長度,所以兩邊都會被補到
max(len(a), len(b))。跑一次就知道:

'12'   '3456' -> '0012' '3456'   same len: True
'3456' '12'   -> '3456' '0012'   same len: True

沒有錯。而且這題的測資三組都是等長的數字,那段程式碼根本沒被觸發過。
模型看著一份沒有證據的程式碼,補了一個不存在的 bug。

我沒有去改 prompt 壓制它(雖然「只根據看得到的證據說話」已經在規則裡了)。
這件事真正的意義是:它示範了為什麼那句話不能放進 summary。

想像一下如果合在一欄:學生會看到一句「第 1、2 組輸出與預期不符,
而且你的補零邏輯有問題」,然後花二十分鐘去修一段本來就對的程式。
分成兩欄之後,畫面上長這樣:

  • 事實:第 1、2 組輸出與預期不符。
  • 🤔 AI 助教的解讀(不是執行結果):……

錯的那半句仍然會出現,但它被標成了「解讀」。這個差別不是修辭,
是學生決定要不要照著改的依據。

幾個小決定

全過就不呼叫。 沒有失敗就沒有東西要解讀,省一次額度,
也少一次讓模型自由發揮的機會。

if all(result.passed for result in results):
    return ""

解讀壞掉不能拖垮判定。 額度爆了、網路斷了,就是少一句話而已,
事實還在:

try:
    explanation = explainer(problem=problem, code=code, results=results)
except Exception:
    explanation = ""      # 判定已經成立了,不該被拖下水

送出去的輸出要截斷。 學生在迴圈裡狂印是家常便飯,
整份 50000 字丟給模型只會爆掉,而且對解讀一點幫助也沒有。

一條舊測試的前提被我改掉了

Day 21 寫過這條:

assert not client.models.calls, "有執行器就不該再去問模型"

今天它紅了——因為模型確實又被呼叫了,只是去解讀,不是去判定。

這種時候最容易做錯的事,是把斷言直接刪掉讓它變綠。
但那條測試要守的東西還在,只是描述得太粗。所以改成分辨兩種呼叫:

def _grading_calls(client):
    """判定要結構化輸出(response_schema),解讀是純文字。"""
    return [c for c in client.models.calls
            if getattr(c.get("config"), "response_schema", None) is not None]

assert not _grading_calls(client), "有執行器就不該再叫模型判定"

測試紅了,先問它原本想守什麼。 前提變了就把它寫得更精確,
而不是拿掉——拿掉的話,哪天有人手滑讓模型又回去猜過不過,
就再也沒有東西會擋。

今天學到的

  1. 一個工具不會用之後,先問它還擅長什麼。 模型不必再判對錯,
    但「把 traceback 翻成人話」是它真正強的地方。
  2. 可能出錯的東西,要跟不可能出錯的東西分開放。 不是為了好看,
    是因為使用者要據此決定行動。
  3. smoke test 抓得到單元測試抓不到的東西。 假 client 永遠不會憑空
    發明一個 bug 給你看。
  4. 測試紅了先問它想守什麼,再決定是改程式還是改斷言。

測試 291 → 314 支,全綠。


上一篇
Day 21:沙盒 (下)
下一篇
Day 23:一條新規則
系列文
基於 Multi-Agent 協作之蘇格拉底式程式學習與自動化評測系統 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言