一套以 YOLO 區域偵測 + 視覺-LLM OCR + 思考型 LLM 批改 為核心的自動批改系統,支援:
- Moodle 作業批改流程(從 CSV 成績表配對學生)
- 獨立紙本考試批改流程(從 xlsx 名單配對學生)
- 多後端 LLM:OpenAI / Gemini / 本地 vLLM
- 多 Agent 架構:布林代數 / 心得報告 / 紙本考卷,各自獨立可註冊
本文件主要說明 exam_agent(紙本考卷批改) 與 boolean_local(布林函數批改,本地 vLLM 兩階段) 兩個 Agent,並完整解釋系統中兩種資料夾命名格式的差異(這是這套系統最容易踩坑的地方)。
📁 另有架構優化版:
modular/
modular/底下是exam_agent的模組化重構版本——把 prompt 抽到prompts/、 設定抽到configs/、模型呼叫抽到agents/backends/的ModelBackend介面。它的批改行為經過逐項驗證,與本目錄的部署版一致: prompt 逐位元組相同、token/thinking 設定相同、對 69 份真實批改紀錄重播後 報告與 JSON 解析結果完全相同(
python modular/test_parity.py,27 項檢查)。但它尚未實際部署過——從未對真實 vLLM 端點跑完一輪。 本目錄的單檔版才是實際批出過成績的那一份。 詳見
modular/README.md。
┌────────────────────────────────────────────────────────────────────┐
│ integrated_workflow.py │
│ (整合工作流程的主進入點) │
└──────┬─────────────────────────────────────────────────────────────┘
│
├─► 步驟 1: PDF 處理 + 學號提取 (所有需要處理的考卷皆放在 Extractd_PDF)
│ ├─ Moodle 模式 → complete_pdf_processing_system.py
│ │ (PDFProcessingSystem,讀 Moodle格式 CSV)
│ └─ Exam 模式 → exam_pdf_processing_system.py
│ (ExamPDFProcessingSystem,讀一般課程格式 xlsx)
│
├─► 步驟 2: 將 PDF 處理之輸出資料夾複製到各 Agent 的 student/ 目錄
│
└─► 步驟 3: 呼叫 Agent 批改
├─ exam_agent (考卷批改)
├─ boolean_local (布林函數批改,本地 vLLM)
├─ boolean_gemini (布林函數批改,Gemini)
├─ essay (心得報告批改)
└─ ...
Agent 註冊機制(agent_registry.py):
agents/ 目錄下的所有 .py 在系統啟動時自動掃描並註冊。
| 進入點 | 用途 |
|---|---|
integrated_workflow.py |
建議的主進入點。一條龍跑完 PDF 處理 → 結構轉換 → 批改 → CSV 匯出 |
main.py |
只跑批改階段(假設 student/ 目錄已備好) |
run_agent.py |
單一 Agent 獨立執行,不經過 registry |
python integrated_workflow.py -h
# 列出所有可用 Agent
python integrated_workflow.py --list-agents
# Moodle 模式(從 CSV)
python integrated_workflow.py <agent_id> <assignment_id>
# Exam 模式(從 xlsx)
python integrated_workflow.py <agent_id> <assignment_id> --exam
# 跳過 PDF 處理("CSV名稱"的學號處理輸出資料夾 已準備好時)
python integrated_workflow.py <agent_id> <assignment_id> --skip-pdf
# 強制重新生成標準答案
python integrated_workflow.py <agent_id> <assignment_id> --force-regenerate設計目的:批改大規模紙本考試(例如期中考、隨堂考),題目中可能包含手寫公式、數字計算、文字敘述。
學生考卷 PDF/PNG
│
├─[1]──► YOLO 區域偵測 (~99% mAP50)
│ 擷取 header(學號區) + calculation(作答區)
│
├─[2]──► 視覺 LLM OCR
│ 辨識手寫內容,輸出 LaTeX/JSON
│
├─[3]──► 思考型 LLM 批改
│ 對照標準答案,給每題分數 + 評語
│
└─[4]──► 輸出 CSV 成績表 + 個別評分檔
- 多端點動態切換:可在多個 vLLM 服務(不同 port / 不同模型)間選擇
max_model_len動態解析:啟動時打/v1/models取得真實模型 ID 跟最大 context,自動計算最佳max_tokens跟thinking_budget- 思考模式自動切換:
max_model_len > 100K時自動開啟 thinking,否則用enable_thinking=False加速 - 思考預算兩種方案可選:
fixed(固定 4096)或ratio(max_tokens × 0.8,思考:回答 ≈ 4:1);後者為實測準確度最高的組合(與boolean_local同一套公式),詳見下方 - 完整截斷診斷:當
finish_reason=length時,精確指出是 thinking 用完還是 content 用完,並依當前方案給出對應的調整建議
max_tokens 一律 = max_model_len × 0.7,兩案只差在思考額度怎麼算:
| 方案 | thinking_budget |
特性 |
|---|---|---|
fixed |
THINKING_BUDGET = 4096(寫死,不隨 context 放大) |
思考短、回答空間大,單題較快 |
ratio |
int(max_tokens × THINKING_BUDGET_RATIO)(0.8) |
實測批改準確度較 fixed 高約 3%;代價是思考變長、單題 latency 上升,較容易吃到 PER_REQUEST_TIMEOUT |
class ExamGradingAgent(BaseGradingAgent):
MAX_TOKENS_RATIO = 0.7 # max_tokens = max_model_len × 0.7
MAX_TOKENS_FALLBACK = 8192 # 抓不到 max_model_len 時的備援值
THINKING_CONTEXT_THRESHOLD = 100000 # max_model_len > 此值才開思考
THINKING_BUDGET = 4096 # 方案 fixed 用
THINKING_BUDGET_RATIO = 0.8 # 方案 ratio 用(思考:回答 ≈ 4:1)
THINKING_BUDGET_MODE = "fixed" # 程式化建構的預設(互動式選單預設 ratio)三種選法(任選一種,皆會保證 budget < max_tokens,答案一定有空間可吐):
# 1) 互動式:run_grading 沒指定 endpoints 時會跳選單,直接 Enter = ratio
# 2) 自動化:kwargs 指定
agent.run_grading("midterm", endpoints=[...], thinking_budget_mode="ratio")
# 3) 建構時指定 / 事後切換
agent = ExamGradingAgent(thinking_budget_mode="ratio")
agent.set_thinking_budget_mode("fixed")實際採用的方案會寫進 <assignment_id>_exam_grade.json 的 meta.token_config,方便事後比對兩案的準確度差異。
# 紙本考試流程(從 xlsx 名單)
python integrated_workflow.py exam midterm --exam執行時會跳出互動式選單,讓你選擇要用哪個 vLLM 端點:
可選的 vLLM 端點(掃描中...):
1) http://192.168.1.175:8000/v1
✅ 線上 模型: Qwen/Qwen3.5-35B-A3B-NVFP4 max_model_len=131072
2) http://192.168.1.175:8001/v1
✅ 線上 模型: Qwen/Qwen3.6-35B-A3B-NVFP4 max_model_len=32768
3) http://192.168.1.175:8080/v1
❌ 離線或無模型
請選擇端點 [1/2/3, 預設 1]: 1
設計目的:在不依賴雲端 API 的情況下,用本地 vLLM 進行布林代數作業批改。透過兩階段架構讓「視覺辨識能力」跟「批改思考能力」可以分別評估與分別配置。
學生手寫布林代數作業 (圖片)
│
▼
┌───────────────────────────────────┐
│ Stage 1: OCR (視覺 LLM) │ ← 用視覺模型(如 Qwen3-VL)
│ - 辨識手寫 LaTeX │ 僅在這階段引入「外援模型」
│ - 標註學生用的定理代號 │
└───────────────────────────────────┘
│
▼ (純文字 LaTeX)
┌───────────────────────────────────┐
│ Stage 2: 結構化 + 批改 (思考 LLM) │ ← 受測批改模型
│ - 解析題號分組 │ 完整評估其端到端能力
│ - 對照標準答案 │
│ - 給分 + 評語 │
└───────────────────────────────────┘
│
▼
評分 JSON + 評語
核心原則:讓評估只反映「批改模型本身的能力」
許多批改模型(例如純文字大型語言模型)不具備視覺能力,無法直接看圖。如果讓每個批改模型自己做 OCR,則:
- 視覺能力強的模型「分數」會虛高(其實是 OCR 準度差異)
- 視覺能力弱或沒有的模型直接被排除在比較之外
因此這套系統把 OCR 強制外包給統一的視覺模型(干擾),所有受測批改模型拿到相同品質的 OCR 輸出,後續的結構化解析、定理判斷、給分推理,全部歸入批改模型自身的表現。這樣才能公平比較不同批改模型的能力。
具體分工:
| 階段 | 任務 | 歸類 | 程式碼中的 stage 參數 |
|---|---|---|---|
| Stage 1 | 視覺 OCR | 外援(干擾源) | stage="ocr" |
| Stage 2a | 結構化解析(把 OCR 結果整理成 JSON) | 批改模型能力的一部分 | stage="grading" |
| Stage 2b | 對照標準答案、給分 | 批改模型核心能力 | stage="grading" |
註:Stage 2a 雖然名為「結構化解析」,看似 OCR 後處理,但本質是「理解題號分組、推測用了什麼定理」,屬於 reasoning 任務,應算進批改模型的能力範圍。
boolean_local 啟動時會跳出兩次選單,讓你分別決定:
- OCR 階段用哪個 vLLM 端點(通常選掛載視覺模型的)
- 批改階段用哪個 vLLM 端點(可以選不同的純文字推理模型)
兩階段可以指向 同一個 端點(同模型一條龍),也可以分開(各自用最適合的模型)。
class BooleanAgentLocal(BaseGradingAgent):
PORT_OPTIONS = [
("1", "http://192.168.1.175:8000/v1"),
("2", "http://192.168.1.175:8001/v1"),
("3", "http://192.168.1.175:8080/v1"),
]
DEFAULT_URL = "http://192.168.1.175:8001/v1"
MAX_TOKENS_RATIO = 0.7 # max_tokens = max_model_len × 0.7
MAX_TOKENS_FALLBACK = 8192 # 無法解析時的保守值
THINKING_CONTEXT_THRESHOLD = 100000 # max_model_len > 此值才開思考
THINKING_BUDGET_RATIO = 0.8 # thinking_budget = max_tokens × 0.8# 互動式(每次啟動跳選單)
python integrated_workflow.py boolean_local 1.1
# 批次模式(跳過選單,用 __init__ 預設端點)
set BOOLEAN_LOCAL_SKIP_PROMPT=1 # Windows
export BOOLEAN_LOCAL_SKIP_PROMPT=1 # Linux/macOS
python integrated_workflow.py boolean_local 1.1由 PDFProcessingSystem(complete_pdf_processing_system.py)從 CSV 成績表 產生。
命名格式:
{姓名}_{Participant編號}_assignsubmission_file
實例:
適筠 劉_0015_assignsubmission_file
郁婕 温_0019_assignsubmission_file
Claire Lee_001_assignsubmission_file
特徵:
- 姓名在前,編號(Moodle Participant 序號,不是學號)在後
- 編號通常是 3-4 位數
- 從 Moodle 「Grade-課程名.csv」(Moodle 匯出的標準格式)中讀取
由 ExamPDFProcessingSystem(exam_pdf_processing_system.py)從 xlsx 名單 產生。
命名格式:
{學號}_{姓名}_assignsubmission_file
實例:
1100331_張家維_assignsubmission_file
1103730_陳威宇_assignsubmission_file
特徵:
- 學號在前,姓名在後(跟 Moodle 順序完全相反)
- 學號是學校的 7 位學號
- 從 xlsx(欄位:「學號」、「姓名」)中讀取
| 項目 | Moodle 模式 | Exam 模式 |
|---|---|---|
| 觸發指令 | (預設) | --exam |
| 處理類別 | PDFProcessingSystem |
ExamPDFProcessingSystem |
| 名單來源 | Grades-*.csv |
*.xlsx |
| 資料夾格式 | 姓名_編號_assignsubmission_file |
學號_姓名_assignsubmission_file |
| 「編號」含義 | Moodle Participant 序號 | (無此概念) |
| 輸出資料夾名 | CSV 檔名 去掉 Grades- 前綴 |
xlsx 檔名 完整保留 |
output_folder 處理方式不同:
# Moodle (complete_pdf_processing_system.py 第 1174 行)
self.output_folder = os.path.splitext(...)[0].replace('Grades-', '')
# Grades-DLD-TEST.csv → DLD-TEST ← 砍掉前綴
# Exam (exam_pdf_processing_system.py 第 293-294 行)
base_name = os.path.splitext(os.path.basename(self.xlsx_file_path))[0]
self.output_folder = base_name
# Grades-DLD-TEST.xlsx → Grades-DLD-TEST ← 保留原名如果你的 csv 跟 xlsx 同名(Grades-DLD-TEST.csv vs Grades-DLD-TEST.xlsx),輸出資料夾名稱會不一樣。建議統一檔名規範,或明確記住自己用的是哪個模式。
base_agent.py 第 102-128 行的解析邏輯永遠假設「第一段是姓名、第二段是學號」:
clean_name = folder_name.replace('_assignsubmission_file', '')
parts = clean_name.split('_')
if len(parts) >= 2:
name = parts[0] # 永遠是第一段
student_number = parts[1] # 永遠是第二段對兩種格式的影響:
| 模式 | parts[0] (name) |
parts[1] (student_number) |
評估 |
|---|---|---|---|
| Moodle | Claire Lee ✅ |
001 |
姓名對,但 student_number 抓到 Participant 編號(非真正學號) |
| Exam | 1100331 ❌ |
張家維 ❌ |
完全顛倒 — 學號被當姓名,姓名被當學號 |
Exam 模式跑下去,匯出的 CSV 成績表會出現「學號欄塞姓名、姓名欄塞學號」的情況,需要手動修正 base_agent._parse_student_info 或在匯出時加判斷。
# 偽碼:自動偵測格式
if parts[0].isdigit() and len(parts[0]) in (6, 7):
# Exam 格式:學號在前
name = parts[1]
student_number = parts[0]
else:
# Moodle 格式:姓名在前
name = parts[0]
student_number = parts[1]如果你要長期同時支援兩種模式,強烈建議先做這個修補,再大規模跑批改。
# 1. 工作目錄下放:
# - Grades-EEB111.csv (Moodle 匯出的成績表)
# - icp01-B_1.pdf, icp01-B_2.pdf, ... (學生繳交的 PDF)
# 2. 啟動流程
python integrated_workflow.py boolean_local 1.1預期流程:
=== 整合工作流程開始 ===
步驟1: 執行PDF處理和學號提取
----------------------------------------
✓ 使用 Moodle 模式(CSV 成績表)
=== 步驟 2.1: 設定學號提取器 ===
1. gpt-4o-mini ...
6. Qwen3.6-35B-A3B (vLLM Qwen3.6 本地推論)
選擇 (1-6, 默認1): 6
✓ 學號提取器建立成功
... (學號提取進行中)
步驟2: 轉換資料夾結構 → base/boolean_local/student/
已複製學生資料夾: Claire Lee_001_assignsubmission_file
... (103 個學生)
步驟3: 執行 BOOLEAN_LOCAL Agent 自動批改
----------------------------------------
可選的 vLLM 端點(掃描中...):
...
請選擇 [Stage1-OCR] 要使用的端點 [1/2/3, 預設 1]: 1
請選擇 [Stage2-批改] 要使用的端點 [1/2/3, 預設 1]: 2
開始執行 boolean_local 的批改流程...
[LaTeX 提取] 檢查學生作業...
→ [Stage1-OCR] http://...:8000/v1 (Qwen3-VL-30B) ...
✓ 回傳 856 字元
→ [Stage2-批改] http://...:8001/v1 (Qwen3-30B) 🧠 思考 ...
✓ 回傳 2384 字元
...
CSV 成績表已匯出到: base/reports/boolean_local_1.1_grades_YYYYMMDD_HHMMSS.csv
# 1. 工作目錄下放:
# - Grades-DLD-MIDTERM.xlsx (學生名單 xlsx,欄位:學號、姓名)
# - midterm-001.pdf, midterm-002.pdf, ... (掃描的考卷)
# 2. 啟動流程
python integrated_workflow.py exam midterm --exam注意:--exam 旗標必加,否則會走 Moodle 模式,程式會去找 csv 而不是 xlsx。
boolean_local 在批改流程開始時會lazy 觸發互動選單(實際觸發點是第一次呼叫 load_standard_answer)。
agent_registry 註冊階段
└─ 只 call get_agent_info()
└─ 不會跳出選單 ✅
run_grading 開始
└─ load_standard_answer() 開頭呼叫 _ensure_runtime_configured()
├─ 旗標未設 → 跳出選單,儲存使用者選擇
└─ 旗標已設 → 直接跳過
| 方法 | 適用場景 |
|---|---|
設環境變數 BOOLEAN_LOCAL_SKIP_PROMPT=1 |
批次跑、cron 排程 |
在 __init__ 明確傳入 ocr_endpoint 跟 grading_endpoint |
程式內部呼叫 |
| stdin 沒有 TTY(EOFError) | CI/CD pipeline |
直接在 __init__ 觸發互動會踩到一個陷阱:agent_registry 在註冊階段會建一個 temp instance 拿 get_agent_info(),如果 __init__ 跳選單,程式還沒進入批改流程就會卡住問你。lazy 機制確保只有真的要批改時才會問。
確認:
- xlsx 檔案是否在當前工作目錄(不是 agent base/ 目錄)
- xlsx 是不是用
~$開頭(Excel 暫存檔會被過濾) - xlsx 中是否有「學號」跟「姓名」兩個欄位
通常是資料夾名稱沒有 _assignsubmission_file 後綴,或格式分段不足 2 段。檢查 base/{agent_id}/student/ 下實際資料夾名。
你用了 Exam 模式,但 base_agent._parse_student_info 的解析假設是 Moodle 格式。參考兩種資料夾格式那節,加上自動偵測邏輯。
互動選單沒被觸發 — 確認:
BooleanAgentLocal.__init__結尾有設self._runtime_configured = (...)_ensure_runtime_configured()方法存在load_standard_answer()開頭有 callself._ensure_runtime_configured()
詳細排查見 boolean_agent_local.py 開發筆記。
學號提取器初始化失敗。常見原因:
setup_extractor()中沒設self.extractor = ...(歷史 bug,應已修復)OPENAI_API_KEY環境變數未設定- 選了選項 5 (LM Studio) 但 LM Studio 沒啟動 — 目前選項 5 已停用,請改選其他
兩種狀況:
- content 為空 + 偵測到 thinking → thinking 用完了還沒開始寫答案。調高
THINKING_BUDGET_RATIO,或在 vLLM 啟動時提高--max-model-len - content 有東西但被切掉 → 答案本身太長。調高
MAX_TOKENS_RATIO
- Python 3.10+
- vLLM 服務(at least 1 個運行中)
- 套件:
requests,Pillow,pandas,openpyxl,pdf2image,ultralytics(YOLO) - (可選)OpenAI/Gemini API keys for cloud LLM backends
automatic_grading_sys/
├─ integrated_workflow.py ← 主進入點
├─ main.py ← 純批改進入點
├─ run_agent.py ← 單一 Agent 進入點
├─ complete_pdf_processing_system.py ← Moodle PDF 處理
├─ exam_pdf_processing_system.py ← Exam PDF 處理
├─ base_agent.py ← Agent 抽象基類
├─ agent_registry.py ← Agent 動態註冊
├─ agents/
│ ├─ boolean_agent.py
│ ├─ boolean_agent_local.py ← 本地 vLLM 兩階段
│ ├─ boolean_agent_gemini.py
│ ├─ essay_agent.py
│ ├─ exam_agent.py ← 紙本考卷批改
│ └─ NS_agent_local.py
└─ base/ ← 各 Agent 的資料目錄
├─ boolean_local/
│ ├─ student/ ← 學生作業
│ └─ teacher/ ← 標準答案
├─ exam/
│ ├─ student/
│ └─ teacher/
└─ reports/ ← CSV 成績匯出
- 系統設計為多 Agent 可擴充:新增批改類型只需在
agents/下新增繼承BaseGradingAgent的類,registry 會自動掃描 - 雲端跟本地後端走同樣的 OpenAI 相容 API,差別只在
base_url - 大規模批次跑請設
BOOLEAN_LOCAL_SKIP_PROMPT=1避免被互動選單卡住 - 兩種資料夾格式不一致是已知議題,長期應在
_parse_student_info加自動偵測
Last updated: 2026-06