昨天把執行器接上之後,評測裡的 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 組輸出與預期不符,
而且你的補零邏輯有問題」,然後花二十分鐘去修一段本來就對的程式。
分成兩欄之後,畫面上長這樣:
錯的那半句仍然會出現,但它被標成了「解讀」。這個差別不是修辭,
是學生決定要不要照著改的依據。
全過就不呼叫。 沒有失敗就沒有東西要解讀,省一次額度,
也少一次讓模型自由發揮的機會。
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), "有執行器就不該再叫模型判定"
測試紅了,先問它原本想守什麼。 前提變了就把它寫得更精確,
而不是拿掉——拿掉的話,哪天有人手滑讓模型又回去猜過不過,
就再也沒有東西會擋。
測試 291 → 314 支,全綠。