iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

Day 21:沙盒 (下)

昨天把執行器寫出來、測試全綠、還拿題庫的十題跑過一遍。
今天要接上去的時候,順手搜了一下:

$ 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 秒。
而這正是最需要快速回饋的那種學生。

15 秒:先想清楚不能用什麼修

最直覺的兩個修法我都沒用:

  • 第一組失敗就不跑了(線上評測系統常這樣)——但那樣就沒有逐組結果了。
    這個系統的評測不是為了打分數,是為了讓導師知道「哪些組、出了什麼事」。
  • 把 timeout 從 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 照實標成推測。
整個系統還是能用,只是誠實地弱一點。

今天學到的

  1. 模組做完 ≠ 功能做完。 一行 grep 就能戳破,但我昨天沒 grep。
  2. 誠實是雙向的。 把推測說成事實不行;把事實說成推測,也在騙人。
  3. prompt 裡的事實陳述有保存期限。 「我們沒有執行程式碼」這句話,
    在你寫下它的那一刻就開始倒數。
  4. 十分鐘前寫的守門,可能十分鐘後就抓到你自己。
  5. 慢的時候先問是不是在排隊,再考慮砍功能或調參數。前者不用付代價,
    後者兩個都要拿正確性去換。

測試從 281 支增加到 291 支,全綠。下一步是把 LLM 的角色換掉——
它不再需要猜過不過了,該讓它專心做一件它真正擅長的事:
把 traceback 翻譯成一句學生看得懂的話。


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

尚未有邦友留言

立即登入留言