可运行的小项目 · Python 3.12 标准库 · 最近核对:2026-10。用六道虚构问题演示评估报告,不调用模型,不需要 API key。输入里的答案与通过标签都是为讲解手写的。
你改了一个问答系统,六道题从答对四道变成五道。看总分,似乎该上线了。点开结果却发现:原来会老实说“不知道”的一道题,现在开始编答案。
这篇不再加一个复杂的评估框架。我们做个小工具:读入两次结果,逐题对比,生成能直接打开的 HTML;如果碰到事先约定不能接受的错误,就返回失败状态,让 CI 拦下来。
先看这一处变化
题目来自虚构的图书馆问答。有些给出了材料,有些故意没有。比如 q5:“材料没有提供周日的开放安排,图书馆周日开门吗?”预期是说明材料不足,而不是猜一个时间。
| 看哪里 | 旧版本 | 新版本 | 发生了什么 |
|---|---|---|---|
| 六道题整体 | 4/6 | 5/6 | 提高约 16.7 个百分点 |
有材料的四题 evidence |
2/4 | 4/4 | q1、q2 从错变对 |
无材料的两题 no-evidence |
2/2 | 1/2 | q5 开始无依据地断言周日开门 |
改善两题、退步一题,总共多对一题。 “提高 16.7 个百分点”和“相对提高 25%”说的是不同分母;这里直接显示 4/6 → 5/6,先把数量讲清楚。
这次先定两条规则:关键题必须全部通过,整体答对的题数不能下降。q5、q6 被我们标为关键题,因此新版本虽然总分更高,仍会被拦下。真实项目要按需求定规则,不要照搬这六道题或临时改门槛让结果好看。
CI 的绿灯,和模型真的变好,是两回事
把 q5 修好后,这套规则会放行。它告诉我们的是“这六道题满足了约定”,不是“真实用户遇到的问题都会答得更好”。
| 你想确认什么 | 这份报告能告诉你什么 | 还需要什么 |
|---|---|---|
| 已知的关键错误有没有再出现 | q5、q6 这次是否通过 | 有代表性的新问题、遗漏的失败类型 |
| 这批结果哪里变了 | 两题改善、一题退步,以及原始答案 | 检查 passed 标签是否判对 |
| 换一批问题还能不能保持提升 | 六题不足以支持这个结论 | 未用于调试的数据,以及合适的配对分析 |
即使暂时把六题当作独立同分布的样本,“改善 2 题、退步 1 题”的双侧精确 McNemar 检验也得到 p = 1。这不是说新旧模型一样,而是这点数据不足以据此区分;本例的题目还是手写的,更不能当真实部署分布的测量。推导和另外两组对照放在同样涨分的两种情况,不需要先学完才能把项目跑起来。
所以比较器不自动套一个 p 值门槛。先拦住已经知道不能接受的错误,再单独研究整体收益,两个问题分别回答。
三份输入,分别负责什么
- 固定题目suite.jsonID、题目、预期行为、分类与关键题标记。
- 旧结果baseline.json每道题的输出与 passed 标签,作为对照。
- 新结果candidate.json同一套题的新输出与标签,按 ID 配对,不按行号。
- 比较结果HTML + JSON看总数、分类和具体变化,再判断是否满足规则。
代码在 eval_report.py,数据在 eval-fixtures。一条结果很短:
{"id": "q5", "passed": false, "output": "Yes, it opens every Sunday."}
passed 不是程序读完 output 后推断的。这个练习把判分和比较分开:比较器相信你给的标签,所以错误标签也会得到一份格式漂亮、结论错误的报告。接真实系统时,要记录输出来源、模型与 prompt 版本、judge 或人工判分规则;这里的 run 只起名字标识作用,不证明模型真的运行过。
先跑一次会被拦下的结果
从仓库根目录运行,不用安装额外包。以下是 macOS / Linux 命令;Windows 可以指定一个新的本地输出目录。
LAB=practice/web-lab/code
RUN_DIR=$(mktemp -d)
python3 "$LAB/eval_report.py" \
--suite "$LAB/eval-fixtures/suite.json" \
--baseline "$LAB/eval-fixtures/baseline.json" \
--candidate "$LAB/eval-fixtures/candidate.json" \
--out-dir "$RUN_DIR/blocked" --lang zh
它会打印 BLOCKED,并以 1 退出。这是预期结果,不是脚本坏了。打开刚打印路径里的 report.html;报告不依赖服务器,也不会往外发请求。退步的 q5 默认展开,其他题可以点击或按 Enter 展开,比较两边答案。
接着保持题集和旧结果不变,换成修正后的教学结果:
python3 "$LAB/eval_report.py" \
--suite "$LAB/eval-fixtures/suite.json" \
--baseline "$LAB/eval-fixtures/baseline.json" \
--candidate "$LAB/eval-fixtures/candidate-fixed.json" \
--out-dir "$RUN_DIR/fixed" --lang zh
这次是 PASS,退出码 0,新结果为 6/6。只是例子里把 q5 的输出和标签一起改好了,不是训练了一轮模型。--lang en 生成英文界面;题目和答案作为原始数据保留,不自动翻译。
每次用一个新的输出目录,脚本拒绝覆盖已有目录,免得把上次的报告误认成这次。report.json 保存逐题结果、规则判断,以及三份输入的 SHA-256。哈希能帮你核对输入有没有变化,不能替你判断答案对不对。
遇到这些情况,不要悄悄算一个分数
| 试着改什么 | 工具应该怎么做 | 为什么 |
|---|---|---|
| 删掉新结果中的 q5 | 退出 2,不生成新报告 | 缺题不能通过取交集被藏起来 |
| 复制一条相同 ID 的结果 | 退出 2 | 不能任意保留其中一条 |
把 false 写成字符串 "false" 或数字 0 |
退出 2 | 明确只接受 JSON 布尔值,不靠隐式转换 |
| 把结果列表倒过来 | 结果不变 | 配对依据是 ID,不是文件中的位置 |
把答案写成 <script>…</script> |
报告只显示文字 | 模型输出是不可信数据,不能当 HTML 执行 |
| 改动题集的版本名,却不重跑两边结果 | 退出 2 | 拦住明显的题集版本错配 |
版本名检查不防止有人忘改或故意复用名称;输入哈希也不证明结果确实来自该题集。真正接入 pipeline 时,应让生成阶段记录实际题集内容哈希,而不是让人手填一个相同的名字。
脚本还有 2 MiB / 文件、最多 1,000 道题的教学上限,不是大规模评估系统。输入无效时不会创建输出目录;遇到磁盘写入错误则以 2 退出,可能留下不完整目录,不把它当作有效报告。先用单元测试把这些边界跑一遍:
python3 -m unittest discover -s site/tests -p 'test_eval_report.py'
接进 CI:失败时,也要能看到报告
在自己的 fork 中,把下面保存为 .github/workflows/eval-lab.yml。它只运行比较器,不发布网站。本例用固定结果演示流程;不是在每次 PR 上真的运行一遍 LLM。真实评估要在比较前增加独立的生成和判分步骤,并保持测试集、baseline 与门槛受审阅保护。
name: Eval lab
on: [pull_request, workflow_dispatch]
permissions:
contents: read
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Compare supplied results
run: |
python practice/web-lab/code/eval_report.py \
--suite practice/web-lab/code/eval-fixtures/suite.json \
--baseline practice/web-lab/code/eval-fixtures/baseline.json \
--candidate practice/web-lab/code/eval-fixtures/candidate.json \
--out-dir eval-output
- name: Keep the report, including a failed comparison
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: eval-report
path: eval-output/
if-no-files-found: error
比较步骤退出 1,job 就会失败;上传报告仍尝试执行,但不会把红灯改绿。下载 artifact 后打开 HTML,可以知道是哪个例子触发了规则。输入错误导致没有报告时,上传步骤也会报错,先看前面的具体错误。退出 0 / 非 0 的含义和 !cancelled() 的行为见 GitHub 退出码说明及状态条件。
这些 action 标签用于说明配置,不宣称是最新版本;正式使用时按仓库策略固定完整 commit SHA 并定期更新。artifact 文档说明了文件上传和保留方式。报告也是数据副本:别把真实用户对话、密钥或未授权数据放进去,也不要自动公开部署。这个例子没有新 workflow 写入本站,只是一份供 fork 练习的配置。
为什么第一版不用数据库、图表库和 LLM judge?
| 选择 | 先得到什么 | 暂时做不到什么 |
|---|---|---|
| 固定 JSON,不接数据库 | 能把同一份输入交给别人重跑 | 没有多用户、结果查询和大批量存储 |
| Python 生成静态 HTML | 无服务、无前端依赖,失败报告也能带走 | 不提供在线刷新或编辑判分 |
| 人工写好的通过标签 | 先把配对、计数和门槛讲清 | 不会自动评判开放式回答 |
| 原始输出转义后展示 | 避免把回答执行成网页代码 | 不负责删除输出里本就包含的敏感信息 |
HTML 转义调用 Python 标准库的 html.escape;JSON 解析额外拒绝重复字段和非标准数值,避免默默覆盖,见 json 的相关行为。先看清数据,再决定是否值得接更多组件。
跑通以后,再问两个问题
第一,报告显示“没变”的题,是两边都对,还是两边都错?这里只说通过标签没变,不是两个答案文本相同;展开后还要看输出。关键题如果一直错,本工具仍会拦下,不会因为“没退步”就放行。
第二,非关键的一类题退步,但其他题改善抵消了它,会不会放行?可能会。 当前门槛没有要求每个分类都不下降,分类表先把问题呈现出来。如果这不符合你的产品要求,就在看到结果前约定分组规则,再补测试;不要看到哪版占优才选哪种指标。
接下来可以用 LLM-as-a-Judge 讨论标签怎么来,或用评估协议处理数据划分、重复试验和不确定性。这个小项目解决的是“怎么让结果可检查”,不是“怎么证明模型全面更好”。