昨天把執行器寫出來、測試全綠、還拿題庫的十題跑過一遍。
今天要接上去的時候,順手搜了一下:
$ grep -rn "sandbox" --include=*.py .
sandbox.py:23: 不能因為它叫 sandbox 就當它安全……
tests/test_sandbox.py:14: import sandbox
只有它自己和它自己的測試。 整個系統沒有任何一行程式用到它。
昨天那篇結尾我還寫了「終於讓評測真的執行」——其實沒有。
我只是做了一個能執行的模組,然後把它放在那裡。app.py 送出程式碼時走的仍然是 Gemini 猜。
這個落差很好笑,但也很典型:模組做完、測試綠了,感覺上就「做完了」,
可是使用者路徑上一步都沒變。
def make_grade_node(client, runner=sandbox.run_all):
def grade_node(state):
out = None
if runner is not None:
out = grade_by_running(problem=problem, code=code, runner=runner)
if not out.parsed_ok: # 根本沒跑成才退回推測
out = None
if out is None:
out = grade_code(client, problem=problem, code=code)
真正花時間的是接完之後冒出來的一連串問題。
這個專案從第一天就有一條規則:沒做到的事不要宣稱做到。
UI 上那句「⚠️ 這是 AI 推測,系統並未實際執行測資」就是為它寫的。
接上執行器之後我才發現,這條規則有一個我沒想過的反面:
真的跑過了,還說「這是推測」,也是謊。
而且是更難察覺的那種——它聽起來很謙虛,很安全。
但對學生來說,「系統推測你第 2 組不會過」和「你第 2 組沒過」是兩件事。
前者他可以不當一回事,後者他得去看。把事實講成推測,等於把一份確鑿的
證據打了折。
所以判定結果多了一個欄位,只做一件事:說清楚它是怎麼來的。
EXECUTED = "executed"
INFERRED = "inferred"
@dataclass
class GradeResult:
...
source: str = INFERRED # 預設是推測
預設值刻意是 INFERRED。 要宣稱自己是事實,得由產生它的程式明講一次。
靠欄位遺漏預設成「跑過了」,是這個系統最不該犯的錯——
舊的 checkpoint 裡根本沒有這個欄位,一但預設成事實,那些舊資料就會
集體變成假的事實。
改完 UI,我想起導師的 prompt 裡有這麼一段:
parts += ["", "【評測結果】以下是 AI 推測,並沒有實際執行程式碼。"]
...
parts.append("引導時可以順著這個推測問下去,但不要向學生斷言測試「跑過了」。")
這在昨天以前是對的。今天它變成:叫導師去否認一件真的發生過的事。
if executed:
parts += ["", "【評測結果】以下是**實際執行**學生的程式、逐組比對輸出的結果。"]
said = "已確認"
else:
parts += ["", "【評測結果】以下是 AI 推測,並沒有實際執行程式碼。"]
said = "推測"
這件事提醒我:一句寫死的事實陳述,會在你改變系統的那天變成假的。
prompt 裡每一句「我們沒有做 X」都是一顆定時炸彈,因為你遲早會做 X。
寫 graph 那層的測試時,我讓假執行器回三筆結果:
node = make_grade_node(client, runner=_fake_runner(PASS, WRONG_OUTPUT, PASS))
assert out["grade"]["source"] == "executed"
結果拿到 inferred。找了一下才發現 —— 那題的測資只有 2 組,我給了 3 筆,
於是這段守門把它擋掉了:
if len(results) != len(tests):
return GradeResult(error=f"執行器回傳 {len(results)} 筆結果,但有 {len(tests)} 組測資", ...)
錯的是我的測試,不是程式。但這個守門是我十分鐘前才寫的,寫的時候還想
「這大概永遠不會發生」——結果它第一個抓到的就是我。
失敗是用組號回報的,結果數量對不上就代表組號會錯位,學生會被叫去看
一組根本沒失敗的測資。寧可什麼都不宣稱。
拿真題庫(UVa 10035,3 組測資)實際跑四種程式:
| 學生的程式 | source | statuses | 摘要 |
|---|---|---|---|
| 正解 | executed | pass, pass, pass | (無) |
| 少印一個句點 | executed | wrong-output, pass, pass | 第 1 組輸出與預期不符。 |
while True: pass |
executed | timeout ×3 | 第 1、2、3 組逾時被中止(程式一直沒有結束)。 |
raise ValueError |
executed | crash ×3 | 第 1、2、3 組執行時崩潰:ValueError: 我壞了。 |
第一版的摘要不長這樣。無窮迴圈那列原本是:
第 1 組逾時被中止(程式一直沒有結束);第 2 組逾時被中止(程式一直沒有結束);第 3 組逾時被中止(程式一直沒有結束)。
同一句講三遍。這段字是要餵給導師引用的,塞一份流水帳進去只是雜訊,
所以失敗方式一模一樣的組會合併成一句。崩潰的話則比對 traceback 最後一行——ValueError 和 KeyError 是不同的毛病,那種不能合併。
第二件事是速度:
infinite loop wall clock: 15.1s for 3 cases
fast wrong answer: 0.31s
寫出無窮迴圈的學生要等 15 秒——因為每組測資各自逾時 5 秒。
而這正是最需要快速回饋的那種學生。
最直覺的兩個修法我都沒用:
真正的原因其實不是「5 秒太久」,是排隊。每組測資本來就互不相干
(Day 20 就讓它們各自跑在獨立的暫存目錄、獨立的子行程),
排隊唯一的效果就是把等待乘上組數。
with ThreadPoolExecutor(max_workers=workers) as pool:
return list(pool.map(
lambda case: run_case(code, case["input"], case["output"], timeout=timeout),
tests,
))
用執行緒而不是行程池,是因為等待期間幾乎不吃 CPU——都在等子行程,subprocess.run 會放掉 GIL。
15.1 秒 → 5.1 秒,而四種情況的 statuses 和判定跟並行前一模一樣:
換掉的是閒置的等待,不是任何一組的時限。
有三條測試守著這件事,其中兩條在寫的當下就是綠的——它們不是用來驅動
這次修改的,是用來擋住我以後想「再快一點」的:
def test_every_case_still_gets_the_full_timeout():
"""並行不能靠縮短時限換來——那會誤殺跑得慢但正確的程式。"""
def test_results_keep_the_order_of_the_test_cases():
"""先跑完的先回傳的話,組號就全錯位了——UI 會叫學生去看錯的那一組。"""
第二條特別要緊:ThreadPoolExecutor.map 保證輸出順序跟著輸入走,
不是跟著誰先跑完。整個回報機制是用組號串起來的,順序一亂,
學生就會被指到一組根本沒失敗的測資。
sandbox 不是安全沙箱(Day 20 講過原因:Windows 沒有 resource 模組)。
所以接線的地方我把 runner 明寫出來,而不是靠預設值:
# 明寫 runner,不靠預設值:這一行決定要不要在這台機器上執行學生的程式。
# `sandbox` 不是安全沙箱,**公開部署前必須改成 runner=None**,
# 否則任何訪客都能執行任意程式碼。
grade_node=make_grade_node(get_genai_client(), runner=sandbox.run_all),
runner=None 不是「壞掉的狀態」,是一個正當的部署選項:
雲端上不執行任何學生程式,評測退回 LLM 推測,UI 照實標成推測。
整個系統還是能用,只是誠實地弱一點。
grep 就能戳破,但我昨天沒 grep。測試從 281 支增加到 291 支,全綠。下一步是把 LLM 的角色換掉——
它不再需要猜過不過了,該讓它專心做一件它真正擅長的事:
把 traceback 翻譯成一句學生看得懂的話。