带过四十人以上项目制课程的人,大概都经历过同一个噩梦:学生代码收上来了,却不知道从哪一份开始看。有人少提交一个文件,有人把依赖写死在绝对路径里,有人压根没跑起来,有人跑起来了但是输出格式跟题目要求差三个空格。你往群里喊一声“重交”,第二天又有五六个人来问“老师我的环境哪里不对”。我在这套EPGF教学环境里做验收与评分自动化,最直接的感受就是:这件事不该靠助教肝,也不该靠学生自觉,它应该是一个确定性的流程。
这篇内容写给三类人:正在给多个班级或大量学生上机课程做作业验收的助教;被“环境不一致”折磨到想写脚本但不知道从哪下手的讲师;以及想把作业评判标准从“感觉像对了”变成“有据可查”的课程管理者。EPGF在这里承担两件事:验收——确认学生交付的环境和代码真的能跑起来;评分——根据既定的规则自动产出可解释的分数。你不一定完整部署这套框架,但理解它的设计逻辑,能直接改善你自己的教学流程。
1. 为什么教学环境的“验收”必须抢在评分之前自动化
1.1 手动验收的两个致命问题
先说痛点。传统作业批改流程里,助教拿到的是一个压缩包,但压缩包背后是一个完整的、不可见的环境:学生用了什么版本的Python、装没装某个包、代码是在macOS上写的还是Windows上写的、路径是不是写死了。你把压缩包解压到自己机器上,第一步不是看代码,而是先把环境试出来。一次两次行,几十份上百份就完全失控。我见过最高纪录是一个助教一天只批完六份作业,剩下的时间全部花在装依赖、调版本、问学生“你这个库在哪装的”。
手动验收的第二个致命问题,是标准不统一。同一个助教早上面下午面都可能手抖,更别说一组助教各批各的。有人说“能跑就行”,有人说“格式不对扣五分”,还有人说“结果差不多就给过”。这不是评分标准的问题了,是验收环节就失效了。你后来发现学生A被扣了十分,学生B同样的错误拿满分,再想去纠正就要面对一连串“为什么”的追问。
所以验收必须自动化,而且要抢在评分之前。你只有先让机器回答“这份代码在可控环境里能不能按预期跑完”,评分才有讨论的必要。
1.2 EPGF 是怎么界定“验收”的
EPGF对验收的定义不是“代码不报错”,而是三件事同时成立:
- 环境可达:学生声明的依赖和运行时版本,在一个干净的容器里能被正确安装和调用。
- 交付完整:作业目录里该有的文件都有,没有缺失,没有被改名。
- 行为符合预期:跑完之后,输出内容能通过预先定义的检查点,而不是只看有没有输出。
这三件事对应三个层次的检查,缺一不可。只有环境可达,可能代码一跑就崩;只有交付完整,可能文件都在但内容完全不对;只有行为符合,可能环境里装了别的东西、代码根本不可复现。EPGF把这三层拆成可独立执行的步骤,任何一层失败,都会直接阻断后续的评分动作。
注意:验收和评分是两个环节。验收回答“这个作业有没有资格被评分”,评分回答“这份作业值多少分”。在验收没跑通之前就评分,等于允许学生在不可控的运气因素上拿分。
明白这个区分之后,再看EPGF的做法就比较顺理成章了。它会把每次提交的作业放进一个临时环境,自动执行环境准备、依赖解析、样例运行、结果比对,最后生成一份验收报告。这个报告不是简单告诉你过没过,而是告诉你卡在哪一步、原因是权限、依赖、格式还是逻辑。助教只需要看报告,就能决定要不要让这个学生重交,或者是不是已经可以进入评分节点。
2. EPGF 验收自动化的骨架:目录规范与配置声明
2.1 一个最小可用的 assignment 结构
任何自动化都依赖约定。EPGF对作业目录的约定非常朴素,基本原则是“让学生少想一步,让机器少猜一次”。每个作业在课程仓库里对应一个独立目录,结构大概是:
assignments/ └── assignment-02/ ├── problem.md ├── config.yaml ├── samples/ │ ├── sample1_input.txt │ └── sample1_expected.txt └── tests/ ├── test_basic.py └── test_edge.pyproblem.md是题目说明,学生能看到,但验收系统不依赖它;config.yaml是验收和评分规则的唯一事实来源;samples是公开样例,学生能自己跑;tests是隐藏的判分测试,学生看不到。这个设计我们后面解释,先说好处:助教不再需要给每个学生单独发说明,学生提交到固定入口,所有东西对齐同一套配置。
学生的提交目录也有约定。EPGF要求每个学生一个文件夹,文件夹名对应学号或用户名,里面就是他们交付的所有内容。验收系统拿到这个目录之后,先检查存在的文件是否与config.yaml里声明的required_files一致,再决定执行后续流程。这一步看起来简单,但非常实用,因为“该交的没交齐”在所有缺勤原因里占比最高的。
2.2 验收配置的核心字段与判定逻辑
config.yaml是这套自动化里最重要的文件,它的作用不是写死规则,而是把规则变成可见的声明。以一个小作业为例:
assignment: assignment-02 lang: python3.10 runtime: python3 required_files: - main.py prepare_cmd: "pip install -r requirements.txt" timeout_sec: 60 checks: - name: "Program runs without error" cmd: "python3 main.py < samples/sample1_input.txt" expect: exit_code: 0 - name: "Produce expected output on sample1" cmd: "python3 main.py < samples/sample1_input.txt" expect: stdout_contains: "total=42"字段的含义都比较直白。lang和runtime声明环境规格;required_files做交付完整性检查;prepare_cmd是在容器里准备依赖的命令;checks是一个个验证点,每个检查都可以声明执行的命令和期望结果,比如退出码、标准输出、文件生成情况。执行器按顺序读取配置,先生成容器,再执行prepare_cmd,然后逐个执行检查项,碰到失败项就记录并停止,避免后面的检查全被环境问题带崩。
这里我最想提醒的是timeout_sec。很多第一次配置的人会忽略它,结果学生代码里有一个死循环,执行器挂半小时,整个队列都被卡住。EPGF的默认值是60秒,但实际配置时要根据作业类型自己定:CPU密集型任务给长一点,I/O型给短一点,宁可写成宽松值,也不要让单个任务阻塞整个批次的验收队列。
2.3 验收结果为什么要四态而不是“对/错”
我见过不少团队把验收做成“红绿判断”:跑了就是绿,没跑就是红。这是最省事但也是最容易误伤的做法。EPGF把验收结果分成四态:PASS、FAIL、WARN、UNKNOWN。
这四态各有用途。PASS代表全部检查通过,可以直接进入评分流程;FAIL代表至少有一个硬性检查不通过,比如必备文件缺失、主程序无法启动;WARN代表通过了硬性检查,但存在可疑情况,比如某个边缘用例没生成预期文件、某个输出带了额外日志但主结果正确;UNKNOWN代表执行过程本身出了问题,比如超时、容器创建失败、依赖安装卡住。
为什么要区分后两种状态?因为处理动作完全不同。FAIL通常让学生重交或者直接判零分;WARN往往可以放行进评分,但要在后期随机复核时重点看;UNKNOWN则最像“裁判自己出了故障”,此时不能判定学生,要重新执行或人工排查。四态的价值在于,它把“学生的问题”和“流程的问题”分开处理,避免把所有异常都归罪于代码。
我在这上面吃过亏。有一批作业全部显示FAIL,整个班炸了锅,结果排查下来是容器镜像更新后某个系统包版本冲突,跟学生代码没有任何关系。从那以后,EPGF配置里我坚持把环境准备阶段单独作为一项检查,任何非代码因素都先记录为UNKNOWN,而不是直接归为FAIL。
3. 从验收跨到评分:EPGF 自动评分流水线怎么搭
3.1 测试用例与评分项的映射关系
验收过了,接下来才是真正给分。很多人以为自动化评分就是“跑一下测试、统计通过率”,但教学场景没那么简单。你通常需要分题型、分知识点给分,一个测试挂了未必说明学生完全不会,可能只是某个边界条件没处理好。EPGF的做法是把评分项和测试用例独立映射。
举个实际例子:某个作业要求学生实现一个统计函数,接口是analyze(data) -> dict,要求返回总量、均值、最大值三个字段。评分配置可能是这样的:
scoring: - item: "basic_correctness" weight: 60 tests: - test_basic.py::test_normal_input - test_basic.py::test_negative_values - item: "edge_cases" weight: 25 tests: - test_edge.py::test_empty_list - test_edge.py::test_single_element - item: "code_style" weight: 15 tests: - test_style.py::test_no_global_vars - test_style.py::test_function_docstring每个评分项有自己的权重和关联的测试用例。执行时,EPGF会逐项统计小分,再乘权重求和。这跟“所有测试等权平均”的最大区别在于:它能体现你的教学侧重点。如果你这周重点讲边界条件,就把edge_cases权重调高;如果重点是代码风格,就加大对应项权重。而且因为映射关系是显式的,学生问“为什么这道题扣了分”,你可以直接指出是哪一项权重扣了哪条,而不是给一个笼统的“测试没过”。
3.2 动态扣分、权重复核和成绩表生成
除了固定比例,评分流水线还要考虑动态扣分。EPGF支持“扣分触发器”(penalty trigger):当检测到特定情况时,从总分里扣掉固定分数,而不是在权重中体现。举例来说,验收阶段的WARN如果被放行到评分阶段,可以配置一个penalty,比如“未提交README,扣5分”。这比把代码功能判错更公平,因为它扣的是“交付规范性”,不影响学生对功能本身的理解判断。
成绩表生成是最后一公里。EPGF会把评分结果、验收结果、测试明细合并成一张成绩表,按班级和学号排列,每个学生一行。成绩表的每条记录保留三个层级的信息:总分、分项得分、测试级日志。总分方便录入系统,分项得分方便答疑,测试级日志方便复核。
这里说一个容易被忽视的配置:grade_export_encoding。中文成绩如果导出时编码不对,Excel打开全是乱码。我们踩过一次之后,统一设置输出为UTF-8带BOM,Excel直接识别,再用grade.py --version脚本转成CSV或者直接对接课程平台的成绩接口。
3.3 一套案例:从样例代码到最终分数
再给一个完整的小案例。假设一个Python入门作业,题目是“统计一段文本中单词出现的次数,输出前十个高频词”。学生的实现逻辑可能正确,但输出格式多了括号或引号。逐条手工看会很累,但评分流水线可以自动判定核心功能和高阶要求的达成情况。
配置里,basic_correctness权重60,测试输入是固定文本、期望输出是“word: count”的严格匹配;performance权重20,测试用一篇长文本,要求执行时间小于3秒;code_quality权重20,检查是否有函数定义、是否有if __name__入口。所有测试跑完后,流水线先汇总各评分项的得分,再执行一次“总分配置里的惩罚项”:比如文件里包含了绝对路径就扣5分,注释里出现拷贝粘贴痕迹扣3分。最后把结果落成表格。
这个案例最值得学习的不是测试怎么写,而是评分结构要分维度。学生不会因为一个输出空格问题丢掉整道题的分,但你也不该因为他功能全对就忽略交付质量问题。多层映射就是为了让这两种情况各自落地,不混在一起。
4. 助教规模化实操:批量处理、异常分类和人工兜底
4.1 批量拉取结果与异常分类的日常姿势
配置好验收和评分之后,助教的日常就变了,从“逐个跑学生代码”变成“批量看报告”。EPGF的命令行接口支持一次拉取整个班级的验收与评分结果:
epgf report --course cs101 --assignment assignment-02 --batch batch-01这条命令会生成一个汇总目录,包含三样东西:一个总览表格(谁过了、谁没过、谁待定)、一个按学生分组的详情目录(每个人的日志、输出、测试报告),以及一个异常摘要(所有非PASS状态的结果汇总)。助教的工作不是从零开始检查代码,而是从异常摘要里做分类。
批量异常分类是我的日常。我习惯在拿到摘要后按顺序先看状态:FAIL的先看卡在哪个环节,是缺文件、装依赖失败、还是输出不符合预期;UNKNOWN的先看执行日志,确认是不是环境问题;WARN的先看警告原因,记下来集中在复评环节处理。没有特殊情况的PASS,我基本不会打开看,除非到了随机复核阶段。
4.2 随机复核与公平性兜底机制
说到自动化,有一个最常见的质疑:机器评的分数,万一误判怎么办?我的回答是:机器误判的概率远低于人手误判,但你依然需要兜底机制。EPGF里我比较推荐“随机复核”加“分层复核”的组合。
随机复核最简单:每次作业里抽10%到20%的学生,由助教人工重跑,验证机器判定是否合理。这相当于给整个流水线做一个抽检,抽检覆盖率不需要多高,但能有效防止“整个评分规则写错但无人发现”的灾难性情况。我见过一次配置里把expect字段写反,导致所有反向结果自动判过,直到随机复核才暴露。
分层复核更精细:对WARN状态的所有结果、对成绩分布极端的样本、对分数波动超过往次作业阈值的学生,进行二次检查。EPGF虽然没有内置十分智能的异常检测模型,但它会把每个人的历次成绩存进一个本地数据库,助教可以用简单的SQL或者脚本找出“上次60分、这次突然100分”之类的可疑样本。
提示:评分自动化之后,人工复核不能省。但人工复核的对象不再是全部作业,而是机器主动标记出来的少数样本。这才能实现“规模化”而不仅仅是“自动化”。
4.3 规模化以后,助教时间都花在哪了
我经常被问一个问题:用这套东西是不是助教就没事干了?我的体验恰恰相反:助教工作占比变了,活反而更有意义了。
自动化之前,助教60%的时间在跑环境、调依赖、跟学生对线,剩下40%才在看代码质量。自动化之后,跑环境和判分交给机器,助教的时间重新分配为:30%处理异常摘要和人工复核,30%深入分析学生典型错误并产出教学反馈,20%优化作业题目和测试用例,剩下20%和学生答疑。同样是40个学生的课程,助教投入总时长下降了一半,但给学生的反馈质量和个性程度反而提升了。
这背后还有一个隐性收益:学生的体验更稳定了。以前学生交完作业等一周都不知道结果,现在验收结果几小时内就能自动反馈给他们,评分结果一键导出。学生能更快决定要不要重交或者问问题,助教也不需要反复处理“老师我交了吗”这种查询。规模化不是把人换成机器,而是把机器的确定性注入流程,让人的精力集中在判断和反馈上。
5. EPGF 验收与评分自动化里的常见坑和排查路径
5.1 坑一:容器权限导致样例依赖装不完
这是我在EPGF里遇到最多的坑,而且非常隐蔽。容器启动之后,prepare_cmd执行的是pip install -r requirements.txt,但很多学生的requirements.txt里包含只允许当前用户安装的包,或者设计上就写成需要wheel和系统依赖。在默认的沙箱环境里,如果执行用户是一个非root的受限用户,安装会在某个包上失败,整个验收状态就会变成FAIL。
后来我在配置里增加了prepare_as_root: false和install_user: "student"之类的字段,但核心经验不是这个配置项,而是排查思路:一旦看到大量任务卡在同一个依赖上且报错都是权限相关,不要急着怀疑学生,先看容器镜像和安装用户。这个教训让我养成了一个习惯:每次更新作业配置之前,先跑一遍“干净容器演练”,把所有步骤在理想环境里过一遍,再考虑权限和系统差异。
5.2 坑二:浮动误差与随机数据让评分结果抖动
第二个高频坑是评分结果不稳定。特别是涉及数值计算、统计分析的作业,学生代码本身没问题,但测试期望值和实际输出因为浮点误差差了0.000001。第一次遇到时全班一半人挂了同一道题,但随便挑一个学生的代码手动跑,又完全正常。
问题出在测试用例的断言方式。EPGF默认支持精确匹配,但你可以用正则或者模糊匹配来定义期望。数值类作业我们统一改用范围断言:期望值不是42,而是[41.9, 42.1]。同时我们会要求测试数据固定随机种子,不靠运气跑结果。这个坑的关键教训是:测试用例也要像代码一样被审查,不然自动化评分会放大人为误差。
5.3 坑三:并发执行时端口与资源竞争
作业里如果一个项目启动了后端服务,开一个端口监听;如果多个任务同时在一个宿主机上执行,端口就互相冲突,学生代码本身没问题,但验收会报“端口被占用”。
EPGF的设计是为每个任务分配独立容器,但容器仍然共享宿主机的网络资源。我踩过一次之后,统一在验收配置里声明network: none或者针对需要网络的作业设置随机端口映射。更通用的一些做法是:所有本地服务型作业优先让代码读取环境变量指定的端口号,避免硬编码。注意,这个坑不是EPGF特有的,任何做批量执行的教学环境都会遇到,只是越早把它当成默认规则,后面越省事。
5.4 一条可复用的排查链路
如果你在EPGF里遇到了未知问题,我的建议是顺着“日志→容器→配置→数据”这条链路排查,而不是直接向学生要代码或让他们换环境。
先看执行日志。EPGF会把每个检查点的标准输出、标准错误和退出码都保留下来,这些日志通常能直接告诉你这个作业是缺文件还是运行超时还是输出比对失败。日志不解决问题时,手动进入容器复现:用EPGF的调试模式单独执行这一份作业,加--interactive参数,进入容器后手动跑一遍学生代码,基本能确认是不是代码逻辑问题。再往上,就是检查配置本身:expect字段是否写反、required_files是否错误地排除了某些合法文件、权重字段是不是被脚本解析出错。最后才追究数据问题:输入样例是不是包含空行、文件名是不是用了全角字符、编码是不是不一致。
这套链路我推荐直接写在助教手册里。新助教上手EPGF时,很容易一看到FAIL就直接私聊学生重交;但跟着这个路径走,80%的异常在半小时内能定位到真实原因,剩下的才需要升级处理。我自己也从“忙得脚不沾地”变成“每天花半小时看一下异常摘要就行”,这大概就是我坚持这套做法的理由。
EPGF这套教学环境的验收与评分自动化,帮我处理了整整两个学期的助教与课程管理工作,最有价值的收获是:标准一旦变成配置,执行就不再需要消耗情绪。机器判定得不准的地方,你看日志能找到原因;机器判定得准的地方,学生也挑不出毛病。如果你也想在自己的教学环境里尝试,建议从小处开始:先固定一个最小作业的目录结构,配上三到五个检查项,跑通一次验收,再逐步扩展评分维度。后面你再面对几十份作业时,你会感谢当时写下第一份config.yaml那个下午的自己。