Capstone 1 · Build Your Own Mini LLM Evaluation Harness
Guided Capstone:starter + 15 个 Step(79 个分步测试从红变绿)——Adapter × Task × Runner × Reports、并发+重试+SQLite 缓存、bad case 分类、自动报告与真实 C3/XCOPA 评测记录——Checkpoint C。
对应课件:Job-Ready Track(新增)
第 27 章 · Capstone 1:Build Your Own Mini LLM Evaluation Harness
评测岗的面试官不会问你「accuracy 怎么算」——他们会问「你做过评测系统吗?bad case 怎么分类?并发怎么控?缓存 key 怎么设计?」这一章你要亲手写出来:Checkpoint C。
| 层级 | 你获得什么 | 在哪里 |
|---|---|---|
| Level 1 · Learn | Adapter / Task / Parser / Metric / Runner / Cache / Retry / Bad case 的设计原理 | 27.3 参考手册 + 各 Step 知识盒 |
| Level 2 · Guided Build | 不完整的 harness starter + 15 步任务 + 79 个分步测试 | projects/llm-eval/starter/(27.2 Lab) |
| Level 3 · Reference Solution | 完整评测工程 + 真实 C3/XCOPA 评测记录 | projects/llm-eval/(27.5) |
完成的定义:starter 的 79 个测试全绿 + run_eval.py 能对 mock 与(可选)真实模型产出三件套报告 + 能回答 Step 14 的 Final Checkpoint。
27.1 本章怎么学
第 23 章教会了你 PPL、pass@k、置信区间、LLM-as-a-Judge。但真实岗位要的是这个:
新模型训完了 → 它到底比上一版好还是坏?
↑ 你需要一个能重复运行、能比较两个模型、能自动找出失败案例的系统
这个系统就是你要亲手构建的东西。它的每一层都解决一个真实的工程问题:
| 问题 | 你要实现的答案 | 在 Step |
|---|---|---|
| 模型五花八门(本地 HF / API / 不同服务商) | ModelAdapter 统一接口 |
4 / 7 / 9 |
| 任务五花八门(选择题 / 问答 / 生成) | EvalTask 统一四件套 |
3 |
| 模型输出脏("答案是 C。") | 专门设计的 parser |
1 |
| API 会超时、限流、失败 | 重试 + 退避 + 并发上限 | 10 / 11 |
| 评测跑一次太贵太慢 | 响应缓存 | 8 |
| 跑完一堆数字看不懂 | 自动报告 + bad case 分类 | 6 / 12 |
| 分数虚高但不知道错在哪 | 结构化 bad case 分析 | 14 |
27.2 项目:llm-eval(Guided Build)
架构(你要亲手把每一块变成现实):
run_eval.py(CLI)
│
▼
Runner ──────────────────────────────────────────────
│ 对每个 Task 的每个 Item: │
│ ① 查缓存 → ② 需要则调 Adapter(重试+限流) │
│ ③ task.parse_answer → ④ task.score │
│ ⑤ 收集 bad case │
▼
Reports:results.json / summary.md / badcases.jsonl
starter/src/llm_eval/
├── adapters/ ModelAdapter:怎么向模型要答案(mock / hf / openai)
├── tasks/ EvalTask:怎么问、怎么判分(c3 / xcopa / qa)
├── parsers.py 模型自由文本 → 规范答案
├── metrics.py accuracy / EM / F1
├── runner.py 并发 + 重试 + 缓存 + badcase
├── cache.py SQLite 响应缓存
└── reports.py 结果落盘
参考实现的真实运行记录(你做完后对标):
Qwen2.5-0.5B-Instruct,128 题(C3 60 + XCOPA 60 + QA 8),CPU 32.3 秒
c3: accuracy 55.0%(随机 25%)| parse_failure 0 | api_error 0 | 19.2s
xcopa: accuracy 55.0%(随机 50%)| parse_failure 0 | api_error 0 | 8.2s
qa: EM 0% / F1 21.9% | parse_failure 0 | api_error 0 | 4.9s
缓存复跑:同一评测第二遍,评测阶段 32.3s → 0.0s(128 条全部命中)
pytest(15 步)与 mock CLI 🖥 Mac / ☁ Server 都可以;真实 HF 评测 ☁ Server 优先;API 评测需联网环境pip install 和 pytest 必须在同一台机器上执行;Step 7 额外装 transformers/torchpwd 确认)echo $HF_HOME)outputs/run_*/(已 gitignore,小型 summary 可拷进你自己的仓库)export OPENAI_API_KEY=...),绝不提交;示例里不出现真实 keygit status;只提交 src/ tests/ run_eval.py README 等小文件Starter:先让 77 个测试红给你看
步骤 0在一台明确的机器上跑通 starter(并确认当前目录正确),接受「77 failed, 2 passed」这个起点, 并理解为什么这个数字是设计好的。
这是整个 Track 最大的项目。一上来就 77 个红色很容易让人想放弃—— 所以先做一个心理建设:它们不是错误,是任务清单。你会按 Step 一组一组把它们变绿。
执行位置规则与前两章完全一致(第 25 章 Step 0 有完整讲解):
pip install 和 pytest 必须在同一台机器;GitHub 网页不是命令执行环境;
Mac 和服务器上的两份 clone 通过 GitHub 同步。
starter/ ├── README.md # Step → 测试表(先读) ├── requirements.txt # httpx + pytest(Step 7 再装 transformers/torch) ├── run_eval.py # CLI 骨架(已给出;只有 build_adapter 留给你) ├── data/mini_*.jsonl # 教学夹具:c3 12 / xcopa 12 / qa 8 ├── src/llm_eval/ # ← 你要写代码的地方 └── tests/ # 79 个用例,按 Step 拆成 15 个文件
# 【确认你在哪】提示符 yourname@MacBook ~ % = Mac;user@ubuntu:~$ = 服务器
hostname
pwd
# 【回到 repo root】已有 clone 的情况(还没有 clone 就按根 README Quick Start 做一次)
cd "$(git rev-parse --show-toplevel)"
pwd
# 【进入 starter】
cd projects/llm-eval/starter
pwd # 必须以 llm-course/projects/llm-eval/starter 结尾;不是就先别继续
# 【装依赖 + 第一次运行】在哪台机器跑 pytest,就在哪台机器装
pip install -r requirements.txt
pytest -q
77 failed, 2 passed in 3.6s
(真实记录。2 个 passed 断言的是已给出代码的配置项——比如 supports_concurrency 的默认值。)
- 大量
ImportError而不是NotImplementedError→ 你的 Python 环境没装 httpx(pip install -r requirements.txt); collected 0 items→ 目录不对;- 想知道每步变绿多少 → 看 README 的 Step 表,一次只跑一个文件。
Hint 1
不要一次修 77 个。课程每一步对应一个测试文件,例如 Step 1 只需要
pytest -q tests/test_step1a_parser_basic.py。
Hint 2
从 Step 1(parser)开始,因为它是纯字符串逻辑、零依赖、最快得到正反馈; Runner/并发那些「难」的部分都在后面,那时你已经热身完毕。
你知道 77 个失败被拆成了 15 步,并且跑过至少一个单文件测试命令。
- 为什么这个 starter 不把测试文件合并成一个?
- 「77 failed」里,有多少是 import 层面的失败、多少是行为层面的失败?为什么这个区别重要?
- 你现在这个 starter 的完整路径是什么(
pwd输出)?如果要在另一台机器继续做,需要做哪两件事? - 评测产出的
outputs/run_*/要不要提交 GitHub?summary.md这类小报告和模型缓存分别该怎么处理?
Choice Parser:accuracy 的隐形杀手
步骤 1实现 parse_choice() / strip_special():从模型五花八门的自由文本里可靠地提取选项字母。
模型不会乖乖输出一个字母。它可能输出 "C"、"c"、"答案是 C"、"**D**"、"C) 因为……"。
解析失败的题目会被判错——parser 的 bug 会伪装成「模型能力差」。
所以 parser 是重点测试对象,也是面试里区分「用过评测框架」和「写过评测框架」的第一个问题。
要改的文件:src/llm_eval/parsers.py
"C" "c" "C." "答案是 C"
"我认为选 B" "**D**" "C) 因为……" "The correct answer is C."
"答案:D" "(C)" "[B]"
你来写(parsers.py):
- TODO 1:
strip_special——去掉<|im_end|>之类的特殊 token; - TODO 2:
parse_choice(text, valid="ABCD")的第一版:只处理"C"/"c"/"C."(Step 1a 的测试); - TODO 3:升级到鲁棒版(Step 1b 的测试):大小写、去 markdown 符号(
*`_)、 「答案是 X」「我认为选 X」「X」「X)」「(X)」等模式; - TODO 4:
valid之外的结果必须返回 None(评测里"E"对 4 选 1 是无效答案)。
pytest -q tests/test_step1a_parser_basic.py # 第一版:5 passed
pytest -q tests/test_step1b_parser_robust.py # 鲁棒版:15 passed
5 passed
15 passed
- 把「C) 因为天气原因」解析成 None → 你的模式没有覆盖「字母 + 右括号 + 解释」;
- 把「The correct answer is C.」解析成 None → 需要英文模式(大小写不敏感);
- 把
"E"当成有效答案 →valid检查没做。
Hint 1
清洗步骤:re.sub(r"[*_]", "", text.strip()).upper()`——先去掉 markdown 符号,再统一大写。
Hint 2
多级模式从严格到宽松逐个尝试(先试「答案:X」,再试「X)」,最后试「单独的字母」); 任何一个命中就返回;全部失败返回 None。不要用「找到第一个 A-H 字母」这种贪心策略—— 它会把「As we know…」解析成 A。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
_PATTERNS = [
r"(?:正确答案|答案|正确选项|应选|选择|选)\s*(?:是|为|:|:)?\s*[((\[]?\s*([A-H])\s*[))\]]?",
r"(?:correct\s+answer|answer)\s*(?:is|:)?\s*[((\[]?\s*([A-H])\b",
r"\b([A-H])\s*[)).、]\s*",
r"[((\[]\s*([A-H])\s*[))\]]",
r"^\s*[((\[]?\s*([A-H])\s*[))\]]?\s*[.。!!??,,]*\s*$",
]
def parse_choice(text: str, valid: str = "ABCDEFGH") -> str | None:
if not text:
return None
cleaned = re.sub(r"[*`_]", "", text.strip()).upper()
for pattern in _COMPILED:
m = pattern.search(cleaned)
if m and m.group(1).upper() in valid:
return m.group(1).upper()
return None
1a 与 1b 全绿;你能说出至少 5 种真实模型输出格式和你对应的处理。
- 为什么「解析失败」要单独统计为 parse_failure,而不是和答错共用一种错误?
- 如果你的 parser 贪心地匹配「第一个出现的 A-H 字母」,举一个真实句子说明它会怎么错。
Metrics:accuracy / exact_match / F1
步骤 2实现 accuracy / exact_match / f1_score 与文本归一化。
指标是评测的语言。写错指标比写错模型更可怕:它让所有结论静默失真。 第 3 章与第 23 章的指标数学在这里第一次变成生产代码。
要改的文件:src/llm_eval/metrics.py(normalize_text 与 _tokenize 已给出,其余你来写)
先回答这三个边界题,再写代码:
accuracy(0, 0)应该是0还是nan?为什么?exact_match("Hello, World!", ["hello world"])是 0 还是 1?f1_score("模型记住了噪声", "模型记住了训练噪声")大概是多少?(提示:中文按字切分)
accuracy = n_correct / n_total,n_total == 0返回nan(不是 0——没评测过 ≠ 全错);exact_match:归一化后相等(小写、去中英标点、压缩空白;再比较一次「去全部空格」的版本);f1_score:token 级 precision/recall 的调和平均;中文按字切分、英文按词切分(朴素但够用);- 空预测 → EM 0、F1 0。
你来写(metrics.py):
- TODO 1:
accuracy(n_correct, n_total); - TODO 2:
exact_match(prediction, golds)——任一 gold 匹配即 1.0; - TODO 3:
f1_score(prediction, gold)——用给出的_tokenize,注意多重集合(词频)匹配。
pytest -q tests/test_step2_metrics.py
12 passed
accuracy(0,0)返回 0 → 你的实现漏了空集合分支(测试会明确检查 nan);- F1 精确匹配时不是 1.0 → precision/recall 的除零保护有问题;
- EM 对「不 知道」/「不知道」判 0 → 空格归一化没做。
Hint 1
nan 的写法:float("nan");判断用 v != v(NaN 不等于自身)。
Hint 2
F1 用词频匹配:先数 gold 的 token 计数,遍历 pred 时命中就减一,最后 common 是匹配数。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def accuracy(n_correct: int, n_total: int) -> float:
return (n_correct / n_total) if n_total else float("nan")
def exact_match(prediction, golds) -> float:
pred = normalize_text(prediction or "")
if not pred:
return 0.0
pred_ns = pred.replace(" ", "")
for g in golds:
gold_ns = normalize_text(g)
if pred == gold_ns or pred_ns == gold_ns.replace(" ", ""):
return 1.0
return 0.0
def f1_score(prediction, gold) -> float:
pred_tokens = _tokenize(prediction or "")
gold_tokens = _tokenize(gold)
if not pred_tokens or not gold_tokens:
return 0.0
common, gold_count = 0, {}
for t in gold_tokens:
gold_count[t] = gold_count.get(t, 0) + 1
for t in pred_tokens:
if gold_count.get(t, 0) > 0:
common += 1
gold_count[t] -= 1
if common == 0:
return 0.0
precision = common / len(pred_tokens)
recall = common / len(gold_tokens)
return 2 * precision * recall / (precision + recall)
12 passed;你能解释为什么 accuracy(0,0) 是 nan 而 EM 空预测是 0。
- 为什么评测报告必须同时给 n 与指标,而不能只给百分比?
- 为什么 EM 适合 C3/XCOPA 但不适合开放问答?(对照 QA 任务的 F1)
EvalTask:先只做 C3,再做 XCOPA
步骤 3实现任务四件套协议:build_prompt(已给)→ parse_answer / score / _to_items(你来写),并复制模式到 XCOPA。
「任务」在评测系统里是一个可插拔单元:加一个新数据集 = 加一个新 Task,而不是改核心逻辑。 这是 harness 可扩展性的来源,也是面试里「如果明天加 GSM8K 你要改哪几个文件」的标准答案。
要改的文件:src/llm_eval/tasks/c3.py 与 src/llm_eval/tasks/xcopa.py
(EvalTask 基类与 EvalItem 已给出;qa.py 已给出作多指标参考)
class EvalTask(ABC):
def load(self) -> list[EvalItem]: ... # 读数据 → 已构造好的 prompt
def build_prompt(self, row) -> str: ... # 每种任务的提问格式(已给)
def parse_answer(self, raw) -> Optional[str]: ...# 原始输出 → 规范答案
def score(self, prediction, item) -> dict: ... # 打分(支持多指标)
EvalItem(id, prompt, gold, meta)——meta 保留原始信息,bad case 分析时用得上。
你来写:
- TODO 1(C3):
parse_answer(复用 Step 1 的parse_choice(raw, valid="ABCD"))、score(accuracy 1.0/0.0)、_to_items(构造EvalItem,gold 取row["answer"].upper()); - TODO 2(XCOPA):同样的四件套,
valid="AB"——先自己写,再对照 C3; - TODO 3:跑测试时注意夹具答案的均衡性断言(防止全 A / 全 B 造成假高分——这是测试的自我拷问)。
pytest -q tests/test_step3a_c3_task.py # 6 passed
pytest -q tests/test_step3b_xcopa_task.py # 4 passed
6 passed
4 passed
gold是小写字母 → 忘了.strip().upper();- XCOPA 的 prompt 里没有「原因/结果」 →
QUESTION_ZH映射用错(模板已给,检查你是否改动了它); _to_items的 prompt 没有用build_prompt(row)→ 数据与提问脱节。
Hint 1
_to_items 就是把每行 dict 变成 EvalItem(id=str(row["id"]), prompt=self.build_prompt(row), gold=..., meta={...})。
Hint 2
XCOPA 的 question 字段是 cause / effect 两个英文值,prompt 里要转成中文提问——
映射表 QUESTION_ZH 已在模板代码里给出。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
class C3Task(EvalTask):
name = "c3"
primary_metric = "accuracy"
def parse_answer(self, raw: str) -> Optional[str]:
return parse_choice(raw, valid="ABCD")
def score(self, prediction, item) -> dict[str, float]:
return {"accuracy": 1.0 if prediction == item.gold else 0.0}
def _to_items(self, rows: list[dict]) -> list[EvalItem]:
return [
EvalItem(
id=str(row["id"]),
prompt=self.build_prompt(row),
gold=row["answer"].strip().upper(),
meta={"question": row["question"], "options": row["options"], "context": row.get("context", "")},
)
for row in rows
]
10 passed(3a + 3b);你能说出「加一个新任务需要改哪几个文件」。
- 为什么
score()返回 dict 而不是 float?(对照 QA 任务的 EM + F1) - 任务定义与数据解耦的好处:
mini_c3.jsonl换成 10000 题的真实 C3,需要改代码吗?
Mock ModelAdapter:先不要碰真实模型
步骤 4实现 MockAdapter.generate(),用假模型把整条评测管线先打通。
为什么测试系统不能每次真的加载 Qwen? 三个理由:慢、贵、不确定。 Mock 让「Runner 的逻辑」独立于「模型的推理」被测试——这正是 Adapter 模式的核心价值。
要改的文件:src/llm_eval/adapters/mock.py(base.py 已给出完整接口)
class ModelAdapter(ABC):
name: str = "base"
supports_concurrency = False
@abstractmethod
def generate(self, prompts: list[str]) -> list[str]: ...
async def agenerate(self, prompts: list[str]) -> list[str]:
return await asyncio.to_thread(self.generate, prompts) # 默认丢线程池
MockAdapter(policy=..., ...):policy(prompt) -> response 决定假模型的输出;
calls 计数器让测试可以断言「模型被调用了几次」(缓存/重试测试全靠它)。
你来写(mock.py):
- TODO 1:
generate(prompts)——self.calls += len(prompts); - TODO 2:返回
[self.policy(p) for p in prompts](默认 policy 返回"A")。
pytest -q tests/test_step4_mock_adapter.py
4 passed
calls计数不对 → 注意按 prompt 个数累加(batch 算多次调用)。
Hint 1
MockAdapter 总共不到 5 行代码——它的价值不在实现,而在它支撑的所有测试。
Hint 2
policy 让每个测试定制模型行为:返回 "A"(全对)/ 返回乱文本(parse_failure)/
按题号返回不同答案(部分正确)——后面 Step 6 会用到。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def generate(self, prompts: list[str]) -> list[str]:
self.calls += len(prompts)
return [self.policy(p) for p in prompts]
4 passed;你能解释「测试系统为什么不每次加载真实模型」。
- MockAdapter 不能代替真实评测——那它到底在测试什么?(提示:管线正确性 vs 模型能力)
- 如果 Mock 的 policy 永远返回 "A",为什么 C3 夹具的答案还要设计成均衡的?
Runner:把 Task → Adapter → Parser → Score 串起来
步骤 5实现 evaluate_task() / evaluate_tasks() 的第一版:先不管缓存、并发、重试,把管线跑通。
Runner 是评测系统的发动机。第一版故意写「笨」一点:一个个题生成、一个个判分—— 先正确,再更快。看懂这一版,后面所有优化都只是往里插模块。
要改的文件:src/llm_eval/runner.py(TaskReport / RunReport 已给出)
先预测,再运行:
MockAdapter(policy=lambda p: "A") 评测 c3(12 题,答案均衡 3A/3B/3C/3D)
accuracy 会接近 25%(随机水平)还是更高?为什么?
你来写(runner.py):
- TODO 1:
evaluate_task——items = task.load()[:cfg.limit]; 收集 prompts →await adapter.agenerate(prompts)→ 对每个输出strip_special→task.parse_answer→task.score; - TODO 2:累计
metrics(分数求和,最后除以 n);统计parse_failures; - TODO 3:
evaluate_tasks——循环任务、构造RunReport、finally里关闭 cache 与 adapter (优先await adapter.aclose(),否则adapter.close())。
pytest -q tests/test_step5_runner.py
4 passed
- accuracy 是 1.0 或 0.0 → 你忘了除以 n(或答案均衡夹具下恰好全对/全错,检查 policy);
RuntimeError: Event loop is closed→evaluate_tasks没有在 finally 里正确关闭连接;- 指标是累加值而不是平均值 → 除以
len(items)的步骤漏了。
Hint 1
骨架:
raw_outputs = await adapter.agenerate([item.prompt for item in items])
for item, raw in zip(items, raw_outputs):
cleaned = strip_special(raw)
prediction = task.parse_answer(cleaned)
scores = task.score(prediction, item)
...
Hint 2
为什么函数是 async 的?这一版是串行的——但接口先定成异步,
Step 10 加并发时调用方一行都不用改。这就是接口设计的价值。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
async def evaluate_tasks(adapter, tasks, cfg) -> RunReport:
run = RunReport(adapter=adapter.name, model=cfg.model, config=cfg.__dict__.copy())
run.started_at = datetime.now().isoformat(timespec="seconds")
t0 = time.time()
cache = ResponseCache(cfg.cache_path) if cfg.use_cache else None
try:
for task in tasks:
run.tasks.append(await evaluate_task(adapter, task, cfg, cache))
finally:
if cache:
cache.close()
aclose = getattr(adapter, "aclose", None)
if aclose is not None:
await aclose()
else:
adapter.close()
run.duration_s = time.time() - t0
run.finished_at = datetime.now().isoformat(timespec="seconds")
return run
4 passed;你能画出 Runner 的数据流(谁调用谁、每一步的输入输出类型)。
- 为什么先写「串行版」再优化?如果不先写它,Step 8/10/11 会出现什么问题?
RunReport.to_dict()直接asdict——这对报告格式意味着什么?
Bad Case:从「错了几个」到「错在哪」
步骤 6让每个失败样本都落一条结构化记录:error_type + gold + prediction + raw_output。
只统计 accuracy 的评测系统是没用的:你只知道「错了 45%」,不知道「该修 parser、修 prompt、还是修模型」。 bad case 分类把「失败」变成「可行动项」。
要改的文件:src/llm_eval/runner.py(在 evaluate_task 里构建 badcase 列表)
| error_type | 含义 | 该修什么 |
|---|---|---|
parse_failure |
无法从输出里提取答案 | parser / prompt 格式 |
wrong_answer |
解析成功但答错 | 模型 / 数据 |
empty_output |
模型返回空 | 服务 / 参数 |
api_error |
请求最终失败(重试耗尽) | 并发 / 超时 / 限流配置 |
low_f1 |
问答 F1 < 0.5 | 回答风格 / 指标设计 |
你来写(runner.py):
- TODO 1:非满分样本(
scores中任一 < 1.0)进report.badcases; - TODO 2:每条记录包含
task / id / prompt / gold / prediction / raw_output / error_type / scores; - TODO 3:
error_type的判定顺序:生成失败(raw == "")→api_error;否则调用task.error_type(raw, prediction, item); - TODO 4:生成失败时同时累加
report.api_errors。
pytest -q tests/test_step6_badcase.py
3 passed
- badcase 数量对但
raw_output是空 → 你保存的是清理前的变量或清理后的变量(要保存清理前的原始输出,供事后分析); - error_type 全是 wrong_answer →
parse_failure分支没接上task.error_type。
Hint 1
task.error_type(raw, prediction, item) 的默认实现已给出:
空输出 → empty_output;预测 None → parse_failure;否则 wrong_answer。
Hint 2
badcase 的 raw_output 字段写原始返回(可以很长)——它就是你事后 debug parser 的证据。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
full_credit = all(v >= (1.0 - 1e-12) for v in scores.values())
if not full_credit:
badcase = {
"task": task.name,
"id": item.id,
"prompt": item.prompt,
"gold": item.gold,
"prediction": prediction,
"raw_output": raw,
"error_type": "api_error" if failed else task.error_type(raw, prediction, item),
"scores": scores,
}
report.badcases.append(badcase)
3 passed;你能对每一类 error_type 说出「下一步该修什么」。
- 为什么 parse_failure 和 wrong_answer 必须分开统计?两个模型的 accuracy 相同,bad case 分布不同,说明什么?
- bad case 里为什么要存 prompt?(提示:复现与归类)
HuggingFace Adapter:接回第 26 章
步骤 7实现 HuggingFaceAdapter:chat template + left padding + batch 生成,让本地模型进入评测系统。
到这里,前六步的系统已经可以为任意「会说话的模型」打分了。现在把第 26 章学到的 HF 推理代码包装成 Adapter——你会看到「换模型 = 换 Adapter」这句话的真实含义。
要改的文件:src/llm_eval/adapters/hf.py(Step 7 需要 pip install transformers torch)
第 23/26 章的知识在这里全部汇合:
apply_chat_template(任务 prompt 会以 user 消息进入模型);padding_side = "left";batch_size分块生成(CPU 上 4 条一批);do_sample=False贪心(评测要可复现);- 可选
peft_adapter(Capstone 3 会用它评测微调前后)。
你来写(hf.py):
- TODO 1:
__init__——加载 tokenizer(pad 兜底)+ 模型(dtype映射);如传peft_adapter则PeftModel.from_pretrained; - TODO 2:
_generate_batch——chat template → 批量编码(left padding)→generate→ 只 decode 新 token; - TODO 3:
generate——按self.batch_size分块调用。
# 【当前目录】.../llm-course/projects/llm-eval/starter
pwd
pip install transformers torch # 在哪台机器跑测试,就在哪台机器装
pytest -q tests/test_step7_hf_adapter.py
2 passed
(测试用 tiny 模型;RUN_MODEL_TESTS=0 时跳过。)
- 生成的回答包含问题本身 → 切片
output[:, input_len:]漏了; - 中文输出混着特殊 token →
skip_special_tokens=True漏了; - 两个不同长度 prompt 的结果错乱 → padding_side 不是 left。
Hint 1
self.name = f"hf:{model_name}"(有 peft 时追加后缀)——这个名字会进缓存 key 与报告。
Hint 2
CPU 上 batch_size=4 比 1 快很多;但别设太大,内存吃紧。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def _generate_batch(self, prompts: list[str]) -> list[str]:
chats = [
self.tokenizer.apply_chat_template([{"role": "user", "content": p}],
tokenize=False, add_generation_prompt=True)
for p in prompts
]
batch = self.tokenizer(chats, return_tensors="pt", padding=True, truncation=True, max_length=1024)
batch = {k: v.to(self.model.device) for k, v in batch.items()}
gen_kwargs = dict(max_new_tokens=self.max_new_tokens, pad_token_id=self.tokenizer.pad_token_id)
if self.temperature and self.temperature > 0:
gen_kwargs.update(do_sample=True, temperature=self.temperature)
else:
gen_kwargs.update(do_sample=False)
with torch.no_grad():
out = self.model.generate(**batch, **gen_kwargs)
new_tokens = out[:, batch["input_ids"].shape[1]:]
return self.tokenizer.batch_decode(new_tokens, skip_special_tokens=True)
2 passed(或 RUN_MODEL_TESTS=0 时跳过);你能说出 Adapter 内封装了哪些「模型细节」。
- 为什么
temperature=0时改用do_sample=False?(评测的可复现性) - 如果明天换成本地 vLLM 服务,你的 Adapter 应该长什么样?(先想,Step 9 类似)
Cache:第一次运行 vs 第二次运行
步骤 8实现 ResponseCache(sqlite)并接入 Runner;先故意写一个「少参数」的 cache key,让测试教你错在哪。
评测迭代的真实循环是「改 prompt/parser → 重跑」。如果每次都重新调模型,迭代速度会被模型推理拖死。 缓存让「只重算变化的样本」成为可能——参考实现里第二次运行 32.3s → 0.0s。
要改的文件:src/llm_eval/cache.py + src/llm_eval/runner.py(缓存接入)
先做这个实验(顺序很重要,别跳):
# 第一版:故意只写 model + prompt(社区里最常见的错误)
@staticmethod
def make_key(model, prompt, params):
payload = json.dumps({"model": model, "prompt": prompt})
return hashlib.sha256(payload.encode()).hexdigest()
问题:
- 这个 key 有什么问题?什么时候会「命中旧答案」?
- 改动
max_new_tokens或temperature之后,同一个 prompt 应该命中缓存吗?
pytest -q tests/test_step8_cache.py
7 failed(第一版 key)
测试会明确指出 max_new_tokens / temperature 变化时必须得到不同的 key。修好之后:
7 passed
- key 少参:改了
max_new_tokens却命中旧答案 → 结果错得毫无察觉; - 缓存永不失效:模型 checkpoint 更新后要换 key(用版本号/内容哈希命名);
- 失败也被缓存:空响应绝不能写缓存,否则一次网络抖动永久污染结果(测试明确检查)。
你来写:
- TODO 1(
cache.py)make_key——sha256(json.dumps({"model", "prompt", "params"}, sort_keys=True)); - TODO 2:
get(命中hits += 1/ 未命中misses += 1)/put(INSERT OR REPLACE)/close; - TODO 3(
runner.py)评测前查缓存(命中直接用)、评测后put——空响应不写; - TODO 4:
report.cache_hits用「本任务内 cache.hits 的增量」统计(多个任务连续评测时不串账)。
test_second_run_hits_cache失败 → 缓存没有真正接入 Runner,或 key 每次不同;test_empty_response_not_cached失败 → 你把""也写进去了;- cache_hits 数字叠加 → 用了累计值而不是增量。
Hint 1
params = {"max_new_tokens": cfg.max_new_tokens, "temperature": cfg.temperature} —— 凡会影响输出的参数都要进 key。
Hint 2
命中缓存的样本不需要进 missing_prompts;miss 的样本才生成,生成完后按原顺序回填结果数组。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
@staticmethod
def make_key(model: str, prompt: str, params: dict) -> str:
payload = json.dumps({"model": model, "prompt": prompt, "params": params},
ensure_ascii=False, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def get(self, key: str) -> str | None:
row = self.conn.execute("SELECT response FROM responses WHERE key = ?", (key,)).fetchone()
if row is None:
self.misses += 1
return None
self.hits += 1
return row[0]
7 passed;你能说出缓存 key 的设计原则,并举出「少一个参数」造成错误命中的具体场景。
- 「第二次运行为什么会快」——从 SQLite 查询到指标复算,整条链路发生了什么?
- 如果模型重新训练了但名字没变,缓存会怎样误导你?工程上怎么防?
API Adapter:任何 OpenAI 兼容服务
步骤 9实现 OpenAICompatibleAdapter:httpx 异步请求 + Bearer 鉴权 + 状态码语义(429/5xx 可重试,400/401 致命)。
真实实习里你评测的往往是 API 模型(公司网关、vLLM、Ollama 都提供 /v1/chat/completions)。
只依赖通用接口,不绑定特定收费服务——这是这一段代码的工程立场。
要改的文件:src/llm_eval/adapters/openai_compat.py
| 类别 | 例子 | 处理 |
|---|---|---|
| 可重试 | 超时 / 429 限流 / 500 / 502 / 503 / 504 | 指数退避后重试 |
| 不可重试(致命) | 400 参数错误 / 401 鉴权失败 | 立即放弃(FatalAdapterError) |
你来写(openai_compat.py):
- TODO 1:
_headers——Content-Type+ 可选Authorization: Bearer <key>; - TODO 2:
_payload——model / messages / temperature / max_tokens; - TODO 3:
_one——POSTbase_url + "/v1/chat/completions"; 429/5xx 与超时 →AdapterError(可重试);400/401 →FatalAdapterError; - TODO 4:
agenerate(复用长连接httpx.AsyncClient)+aclose(在事件循环内关闭)。
pytest -q tests/test_step9_api_adapter.py
5 passed
(测试用本地 HTTP 假服务端验证协议,不访问收费 API。)
- 400/401 被当成可重试 → 你的错误分类放在了
raise_for_status的统一 except 里,需要先判状态码; - 假服务端断言失败 → 检查 Authorization 头和你发送的 JSON 字段名。
Hint 1
resp.status_code in (429, 500, 502, 503, 504) → 抛 AdapterError(可重试);
400 <= status < 500 的其他码 → 抛 FatalAdapterError(立即失败)。
Hint 2
超时用 httpx.TimeoutException 捕获——它在客户端超时和连接失败时都会抛。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
async def _one(self, client, prompt: str) -> str:
try:
resp = await client.post(self.base_url + "/v1/chat/completions",
json=self._payload(prompt), headers=self._headers())
if resp.status_code in (429, 500, 502, 503, 504):
raise AdapterError(f"HTTP {resp.status_code}(可重试)")
if resp.status_code in (400, 401):
raise FatalAdapterError(f"HTTP {resp.status_code}(不可重试)")
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except httpx.TimeoutException as e:
raise AdapterError(f"请求超时:{e}") from e
5 passed;你能把「哪些错误该重试、哪些不该」讲给面试官。
- 为什么不设超时的
httpx请求在生产环境等于定时炸弹? - 复用
AsyncClient与每次新建连接,性能和资源上差在哪?
Async + Semaphore:并发不能「全飞」
步骤 10把 Runner 的串行生成改成受控并发:Semaphore(cfg.concurrency),并让非并发适配器保持批量串行。
评测 5000 题的墙钟时间主要由「模型调用」决定。全并发会把服务端和你自己一起打爆—— 并发 + 限流才是标准答案。这是第 25 章 asyncio 知识的生产级应用。
要改的文件:src/llm_eval/runner.py(生成阶段)
用假服务端做实验:
- 9 个请求、每个耗时 0.3s:串行 ≈ 2.7s,并发 3 ≈ 0.9s,全并发 9 ≈ 0.3s。
- 问题:为什么不能直接开 9(或 10000)个全并发?服务端和客户端分别会发生什么?
- 服务端有 QPS 限制:超了直接 429,重试风暴更糟;
- 客户端资源有限(连接数、内存);
- 本地 HF 模型瓶颈是 CPU/GPU 而不是网络 →
supports_concurrency=False时保持批量串行。
你来写(runner.py):
- TODO 1:
_generate_missing(adapter, prompts, cfg)——prompts为空直接返回[]; - TODO 2:
adapter.supports_concurrency == True时:sem = asyncio.Semaphore(cfg.concurrency), 每题一个 task(async with sem:),asyncio.gather收集; - TODO 3:
False时:await asyncio.to_thread(lambda: adapter.generate(prompts))(批量一次调用); - TODO 4:把 Step 5 的「一次性 agenerate」替换为「先查缓存 → 生成缺失 → 回填」。
pytest -q tests/test_step10_async.py
3 passed
关键是第二个断言:max_inflight > 1——一个串行 for 循环会让它失败。
max_inflight恒为 1 → 你还在串行(或 semaphore 包错了位置,把整批请求包成了 1 个);max_inflight超过 cfg.concurrency → semaphore 创建在循环内部(每次都是新的);- 非并发分支卡死 →
asyncio.to_thread忘了 await。
Hint 1
semaphore 要在循环外创建一次:
sem = asyncio.Semaphore(max(1, cfg.concurrency))
async def one(prompt):
async with sem:
...
results = await asyncio.gather(*(one(p) for p in prompts))
Hint 2
supports_concurrency 是适配器类属性:Mock/OpenAI 为 True,HF 保持 False(它有自己的 batch 逻辑)。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
async def _generate_missing(adapter, prompts, cfg):
if not prompts:
return []
if getattr(adapter, "supports_concurrency", False):
sem = asyncio.Semaphore(max(1, cfg.concurrency))
async def one(prompt: str) -> str:
async with sem:
try:
return (await adapter.agenerate([prompt]))[0]
except AdapterError as e:
logger.error("请求最终失败:%s", e)
return ""
return list(await asyncio.gather(*(one(p) for p in prompts)))
try:
return await asyncio.to_thread(lambda: adapter.generate(prompts))
except AdapterError as e:
logger.error("批量生成最终失败:%s", e)
return [""] * len(prompts)
3 passed;你能解释「10000 个请求不能全部一起飞」的两个层面(服务端 / 客户端)。
- Semaphore 的
concurrency=8与「8 个线程」有什么本质区别? - 为什么本地 HF 模型反而不该走 Semaphore 并发?
Retry:区分「值得重试」与「重试无用」
步骤 11实现指数退避重试:只重试 AdapterError(超时/429/5xx),FatalAdapterError(400/401)立即放弃。
网络失败是常态。但重试不是万能的:401 重试一万次还是 401。 把「可恢复」与「不可恢复」分开,是分布式系统的第一课,也是面试高频题。
要改的文件:src/llm_eval/runner.py(_async_with_retry)
- 同一个 500 错误重试 2 次、每次退避 1.5s × 2^n,总等待时间是多少?
- 401 应该重试吗?为什么错误分类要放在适配器里而不是重试逻辑里?
你来写(runner.py):
- TODO 1:
_async_with_retry(fn, cfg)——最多cfg.retries + 1次尝试; - TODO 2:只捕获
AdapterError;FatalAdapterError属于它但立即重新抛出(或用类型判断跳过重试); - TODO 3:退避
await asyncio.sleep(cfg.retry_backoff_s * (2 ** attempt)); - TODO 4:把两个生成分支(并发/批量)都包进重试。
pytest -q tests/test_step11_retry.py
3 passed
- 前 4 次失败的适配器最终成功(
api_errors == 0); FatalAdapterError只被调用 1 次(不重试)。
- 致命错误被重试 → 你的
except AdapterError把子类也吞了,需要在里面if isinstance(e, FatalAdapterError): raise; - 重试次数对不上 → 循环边界写成
range(cfg.retries)而不是retries + 1(首次尝试不算重试)。
Hint 1
结构:
last = None
for attempt in range(cfg.retries + 1):
try:
return await fn()
except FatalAdapterError:
raise
except AdapterError as e:
last = e
if attempt < cfg.retries:
await asyncio.sleep(cfg.retry_backoff_s * (2 ** attempt))
raise last
Hint 2
退避用 2 ** attempt:1.5s → 3s → 6s。为什么是「翻倍」而不是「固定 1s」?
(提示:重试风暴 = 所有客户端同时对服务端施加相同压力)
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
async def _async_with_retry(fn, cfg):
last = None
for attempt in range(cfg.retries + 1):
try:
return await fn()
except FatalAdapterError:
raise
except AdapterError as e:
last = e
if attempt < cfg.retries:
wait = cfg.retry_backoff_s * (2 ** attempt)
logger.warning("调用失败(第 %d 次):%s;%.1fs 后重试", attempt + 1, e, wait)
await asyncio.sleep(wait)
raise last
3 passed;你能回答「哪些错误该重试」并解释指数退避的作用。
- 为什么 400 参数错误不应该重试?(提示:确定性 vs 瞬时性)
- 重试会引入重复请求吗?对评测任务为什么无所谓,对下单接口为什么致命?
Reports:让结果自动落盘
步骤 12实现 write_reports():results.json(机读)/ summary.md(人读)/ badcases.jsonl(逐条分析)。
评测的最终交付物是报告,不是终端输出。三件套分别服务三类读者: 脚本(json)、你/面试官(md)、后续分析(jsonl)。这是「跑完一堆数字看不懂」问题的标准解法。
要改的文件:src/llm_eval/reports.py
你来写(reports.py):
- TODO 1:
results.json——json.dumps(run.to_dict(), ensure_ascii=False, indent=2); - TODO 2:
badcases.jsonl——逐条一行 JSON(所有 task 的 badcase 汇总); - TODO 3:
summary.md——标题、环境信息(adapter/model/时间/并发/重试/缓存)、 结果总览表(任务 / 样本数 / 指标百分比 / parse_failure / api_error / 用时)、bad case 示例、复现命令; - TODO 4:返回
{"results": Path, "summary": Path, "badcases": Path}。
pytest -q tests/test_step12_reports.py
4 passed
- summary 里百分比不对 → 指标是 0–1 的小数,展示时要 × 100;
- results.json 中文乱码 → 少了
ensure_ascii=False; - badcases.jsonl 空文件 → 你写的是 badcases 列表的字符串而不是逐行 JSON。
Hint 1
run.tasks 里每个 TaskReport 都有自己的 metrics / badcases / duration;
汇总表一行一个任务。
Hint 2
复现命令就写你实际跑的那条,例如:
python run_eval.py --adapter mock --model mock --tasks c3 --limit 12 --output outputs/run。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def write_reports(run, cfg, out_dir: Path) -> dict[str, Path]:
out_dir.mkdir(parents=True, exist_ok=True)
results_path = out_dir / "results.json"
results_path.write_text(json.dumps(run.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
badcases_path = out_dir / "badcases.jsonl"
with badcases_path.open("w", encoding="utf-8") as f:
for t in run.tasks:
for case in t.badcases:
f.write(json.dumps(case, ensure_ascii=False) + "\n")
... # summary.md 拼接(表头 + 每任务一行 + bad case 示例 + 复现命令)
4 passed;你能说出三件套各自的读者是谁。
- 如果要把结果接入 CI(比如 F1 低于阈值就让 build 失败),你会读哪个文件?
- summary.md 里为什么必须带上 parse_failure 与 api_error 两列?
收口 CLI 与真实数据
步骤 13补全 build_adapter();先用 mock 跑通 run_eval.py,再用真实数据切片(C3 / XCOPA)跑一次小评测。
到这一步,所有模块都已经写好——但「能 import」不等于「能交付」。 CLI 是用户唯一看见的界面:参数、退出码、输出目录,缺一不可。
要改的文件:run_eval.py(只有 build_adapter 是空的)
你来写(run_eval.py):
- TODO 1:
build_adapter(cfg)——mock →MockAdapter();hf →HuggingFaceAdapter(...); openai →OpenAICompatibleAdapter(...);未知类型抛ValueError; - TODO 2:先跑通 mock:
python run_eval.py --adapter mock --tasks c3 --limit 12 --no-cache; - TODO 3(可选,需要网络):拉真实数据切片:
python scripts/fetch_data.py --task xcopa --n 60 --out data/xcopa_zh_60.jsonl; - TODO 4(可选,需要 transformers/torch):真实模型评测——
python run_eval.py --adapter hf --model Qwen/Qwen2.5-0.5B-Instruct --tasks c3 --data c3=data/c3_dialog_60.jsonl --limit 60 --output outputs/run_real。
# 【当前目录】.../llm-course/projects/llm-eval/starter
pwd
# 单元测试 + 全量
pytest -q tests/test_step13_cli.py
pytest -q # 全量:79 passed
# mock 评测(不加载任何真实模型)——产出落在 outputs/run_mock/(gitignore)
python run_eval.py --adapter mock --tasks c3 xcopa qa --limit 12 --output outputs/run_mock
ls outputs/run_mock
scripts/fetch_data.py拉取 C3/XCOPA 切片:任意能联网的机器(Mac 或 Server)都可以,产出是小型 JSONL,可以留在 repo 的data/;--adapter hf真实模型评测:推荐服务器(模型进 HF 缓存,echo $HF_HOME查看);Mac 也能跑,注意磁盘;--adapter openaiAPI 评测:任意能联网环境;key 只放环境变量(export OPENAI_API_KEY=...),绝不写进代码或提交。
2 passed
79 passed
outputs/run_mock/ 里出现 results.json summary.md badcases.jsonl
参考实现的真实评测记录(你的数字应与它同量级):
c3 n=60 accuracy 55.0% | parse_failure 0 | api_error 0 | 19.2s
xcopa n=60 accuracy 55.0% | parse_failure 0 | api_error 0 | 8.2s
qa n=8 EM 0% / F1 21.9% | 4.9s
第二次同参数运行:评测阶段 0.0s(128 条全部命中缓存)
--json之类参数报错 → 检查 argparse 定义与 cfg 覆盖的优先级(命令行 > JSON 配置 > 默认值);- mock 跑出
parse_failure一大堆 → 正常:mock 的 policy 返回的不是标准字母(换 policy 或忽略); - 真实数据下载失败 → 网络问题,可继续用 mini 夹具;不要伪造数据。
Hint 1
build_adapter 里注意把 CLI 参数(concurrency/max_new_tokens/temperature/base_url/api_key)透传给适配器构造函数。
Hint 2
真实数据切片的分数字段:C3 的行格式 {"id","context","question","options","answer"},
XCOPA {"id","premise","question","choice1","choice2","answer"}——与你的 _to_items 对齐检查一遍。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def build_adapter(cfg: EvalConfig) -> ModelAdapter:
if cfg.adapter == "mock":
return MockAdapter()
if cfg.adapter == "hf":
from llm_eval.adapters.hf import HuggingFaceAdapter
return HuggingFaceAdapter(cfg.model, max_new_tokens=cfg.max_new_tokens,
temperature=cfg.temperature, peft_adapter=cfg.peft_adapter or None)
if cfg.adapter == "openai":
from llm_eval.adapters.openai_compat import OpenAICompatibleAdapter
return OpenAICompatibleAdapter(cfg.model, base_url=cfg.base_url, api_key=cfg.api_key,
max_new_tokens=cfg.max_new_tokens, temperature=cfg.temperature)
raise ValueError(f"未知 adapter:{cfg.adapter}")
79 passed + 你的 outputs/run_mock/ 里躺着三件套报告。
- CLI 的优先级链(命令行 > 配置文件 > 默认值)是怎么实现的?
- 为什么「先 mock、后真实模型」是评测系统开发的标准顺序?
分析结果:不是「accuracy = xx,完成」
步骤 14打开你的报告,回答四个问题——把「分数」变成「结论」。
这是整个 Capstone 的最后一课:评测工程师的价值不在跑分,而在解释分数。 面试官会盯着你的 bad case 分析问:这些错误里,哪些是系统问题?哪些才是模型能力?
要改的文件:新建 outputs/run_real/analysis.md(或在你自己的实验仓库里写;本节不写代码)
你来写(分析报告,4 个问题):
- Q1:parser failure 有多少条?如果大于 0,它们是系统问题还是模型问题?
- Q2:wrong answer 有多少条?抽 3 条看原文——模型是「完全不会」还是「差一点」?
- Q3:API error 有多少条?(本地模型通常是 0;API 模型要关注)
- Q4:哪些错误是系统问题(parser/prompt/并发配置),哪些才是模型能力?
# 从你的报告里统计(示例)
python - <<'EOF'
import json
cases = [json.loads(l) for l in open("outputs/run_mock/badcases.jsonl", encoding="utf-8")]
from collections import Counter
print(Counter(c["error_type"] for c in cases))
EOF
参考实现的真实分析(128 题那个 run):
c3 / xcopa 的 parse_failure 与 api_error 都是 0 —— 说明出错的全是模型能力问题;
qa 的 F1 21.9% 低,不是「模型不会回答」,而是回答风格与指标不匹配:
模型输出长句,标准答案是短语。
对照实验:max_new_tokens 从 16 加到 64 → F1 反而降到 17.6%(回答更长,不匹配的代价更大)
结论:瓶颈在指标与回答风格的匹配,不在生成长度。
→ 评测工程的第一纪律:先怀疑配置,再怀疑模型。
完成后你的评测目录里应该有你自己跑出来的:
outputs/run_*/
├── results.json # 机读结果(模型 / 任务 / 指标 / 计数)
├── summary.md # 人读报告(n、parse_failure、api_error、用时)
├── badcases.jsonl # 逐条 bad case(含 error_type 分类)
└── analysis.md # 你的分析:系统问题 vs 模型能力
如果你要把它写进简历,你必须能解释:Adapter/Task 为什么这样拆(A)、 parser 为什么影响 accuracy(B)、cache key 的设计(C)、 以及 bad case 里哪些是系统问题、哪些才是模型问题(D)。这是评测岗面试的第一个深水区。
Final Checkpoint —— 合上代码,回答:
ModelAdapter为什么存在?没有它,评测两个模型的成本是什么?EvalTask为什么存在?加 GSM8K 要改哪几处?- parser 为什么影响 accuracy?你测过哪些输出格式?
- cache key 怎么设计?什么参数必须进 key?失败为什么不能缓存?
- async 为什么要 semaphore?全并发会发生什么?
- retry 为什么不能重试 401?哪些错误可重试?
- bad case 为什么要结构化?五类 error_type 各自的修法是什么?
- 第二次运行为什么会快?缓存命中链路上发生了什么?
这 8 题就是 Checkpoint C 的口试清单。全部答上 = 你完成了一个可以写进简历的评测项目。
查看参考实现(完成后对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
完整实现:projects/llm-eval/(21 个测试函数 / 32 个用例 + 真实 C3/XCOPA 运行记录)。
对照重点:
runner.py的「查缓存 → 生成缺失 → 回填 → 判分」主循环;openai_compat.py的状态码分类;reports.py的 summary.md 结构。
27.3 Learn 参考手册:评测系统的七块拼图(做项目时回来查)
27.3.1 为什么不能写成一坨
eval harness 最容易被写成一坨:
for q in questions:
answer = model.generate(q)
if answer == gold:
correct += 1
一坨代码的问题是:换模型要改动核心逻辑、加并发会乱、缓存没处放、bad case 只能靠 print。
拆成 Adapter/Task 之后:
- 换模型 = 换 Adapter(HF → OpenAI 兼容 → 任何服务);
- 加任务 = 加 Task(C3 → XCOPA → 自研 QA → 以后加 GSM8K);
- 评测逻辑(并发/缓存/重试)只写一次。
27.3.2 ModelAdapter:统一「要答案」的接口
class ModelAdapter(ABC):
@abstractmethod
def generate(self, prompts: list[str]) -> list[str]: ...
async def agenerate(self, prompts: list[str]) -> list[str]:
return await asyncio.to_thread(self.generate, prompts) # 默认:同步实现丢线程池
| 适配器 | 用途 | 关键细节 |
|---|---|---|
HuggingFaceAdapter |
本地模型(第 26 章) | chat template + left padding + batch |
OpenAICompatibleAdapter |
任何 /v1/chat/completions 服务 |
httpx 异步 + Bearer 鉴权 + 429/5xx 归类为可重试 |
MockAdapter |
测试/冒烟 | 确定性输出;可模拟「前 N 次失败」测重试 |
只依赖通用接口,不绑定特定收费服务——本地 vLLM、Ollama、公司网关都是同一个适配器。
27.3.3 EvalTask:load / build_prompt / parse_answer / score
| 任务 | 数据 | 问法 | 判分 |
|---|---|---|---|
C3Task |
中文阅读理解(C3 dialog,4 选 1) | 「只回答一个字母」 | accuracy |
XCOPATask |
因果推理(XCOPA zh,2 选 1) | cause/effect 转中文提问 | accuracy |
QATask |
自定义问答 | 「尽量简洁」 | EM + F1 |
数据加载双通道:默认读仓库内的教学夹具(data/mini_*.jsonl),也可以 --data c3=你的文件.jsonl 换成任意数据。任务定义与数据解耦——这是能接真实数据集的关键。
27.3.4 Parser:accuracy 的隐形杀手
无法解析的样本单独计为 parse_failure——它和「答错」是两种问题:前者修 parser / 改 prompt,后者修模型。
def parse_choice(text: str, valid: str = "ABCD") -> Optional[str]:
cleaned = re.sub(r"[*`_]", "", text.strip()).upper() # 去 markdown、统一大小写
for pattern in _COMPILED: # 多级模式,从严格到宽松
m = pattern.search(cleaned)
if m and m.group(1) in valid:
return m.group(1)
return None # 无法解析 → parse_failure
27.3.5 Runner:并发 + 重试 + 缓存
并发(API 场景):
sem = asyncio.Semaphore(cfg.concurrency) # 例:8
async def one(prompt):
async with sem: # 最多 8 个请求同时在飞
return await with_retry(prompt)
await asyncio.gather(*(one(p) for p in prompts))
为什么不能 10000 个一起打出去?429 限流 → 重试风暴 → 更慢。本地 HF 模型则是批量串行(concurrency=1),因为瓶颈是 CPU/GPU 而不是网络。
重试(指数退避):
for attempt in range(cfg.retries + 1):
try: return call()
except AdapterError:
time.sleep(cfg.retry_backoff_s * (2 ** attempt)) # 1.5s → 3s → 6s
关键设计:适配器把「可重试」(超时、429、5xx)和「不可重试」(400 参数错、401 鉴权)分开了——后者重试多少次都没用。
缓存(key 的设计):
key = sha256(model + prompt + max_new_tokens + temperature) # 影响输出的参数必须全部进 key
实测效果:同一评测跑第二遍,评测阶段从 32.3s 降到 0.0s。调参重跑只重算变化的样本。
27.3.6 Bad case:五类 error_type
| error_type | 含义 | 该修什么 |
|---|---|---|
parse_failure |
无法从输出里提取答案 | parser / prompt 格式 |
wrong_answer |
解析成功但答错 | 模型 / 数据 |
empty_output |
模型返回空 | 服务 / 参数 |
api_error |
请求最终失败(重试耗尽) | 并发 / 超时 / 限流配置 |
low_f1 |
问答 F1 < 0.5 | 回答风格 / 指标设计 |
每个失败样本都落一条结构化记录:
{
"task": "c3", "id": "c3-dialog-m14-1-0",
"gold": "A", "prediction": "B", "raw_output": "B",
"error_type": "wrong_answer",
"prompt": "阅读下面的材料,回答问题。……"
}
报告自动生成三件套:results.json(机读)、summary.md(人读)、badcases.jsonl(逐条分析)。
27.4 真实运行结果与使用方式(Reference Solution 实测)
Qwen2.5-0.5B-Instruct,真实数据切片(scripts/fetch_data.py 从 HuggingFace 拉取):
| 任务 | 数据 | n | 指标 | parse_failure | api_error | 用时 |
|---|---|---|---|---|---|---|
| c3 | C3 dialog 切片 | 60 | accuracy 55.0%(随机 25%) | 0 | 0 | 19.2s |
| xcopa | XCOPA zh 切片 | 60 | accuracy 55.0%(随机 50%) | 0 | 0 | 8.2s |
| qa | 教学夹具 | 8 | EM 0% / F1 21.9% | 0 | 0 | 4.9s |
怎么读这些数字(第 23 章纪律):
- C3 高于随机基线 25pt、XCOPA 高于随机 5pt——0.5B 小模型的合理水平;n=60 的置信区间很宽(±12pt 量级),不能下强结论;
- QA 的 F1 低不是因为「模型不会回答」,而是回答风格与指标不匹配:模型输出长句,标准答案是短语。做对照实验——
max_new_tokens从 16 加到 64 后 F1 反而降到 17.6%(回答更长,不匹配的代价更大)→ 结论:瓶颈在指标与回答风格的匹配,不在生成长度。这是评测工程里典型的一课:先怀疑配置,再怀疑模型。
缓存实测:第二次同参数运行,评测阶段 0.0s,128 条全部命中缓存。
使用方式(参考实现):
cd projects/llm-eval
pip install -r requirements.txt
# 拉真实数据(可选)
python scripts/fetch_data.py --task xcopa --n 60 --out data/xcopa_zh_60.jsonl
python scripts/fetch_data.py --task c3 --n 60 --out data/c3_dialog_60.jsonl
# 跑真实评测
python run_eval.py --model Qwen/Qwen2.5-0.5B-Instruct --tasks c3 xcopa qa \
--data c3=data/c3_dialog_60.jsonl --data xcopa=data/xcopa_zh_60.jsonl \
--limit 60 --output outputs/run_001
# 对 API 模型并发评测
python run_eval.py --adapter openai --model qwen2.5 --base-url http://localhost:8000 \
--tasks c3 xcopa --concurrency 8
# 测试
pytest -q # 32 passed(21 个测试函数)
27.5 Level 3 · Reference Solution
完整工程:projects/llm-eval/
| 你写的文件 | 参考实现对应 | 差异点 |
|---|---|---|
parsers.py |
同名 | 模式列表更全(可对照) |
metrics.py |
同名 | 多实现了 pass_at_k / percentile(本 Lab 不需要) |
runner.py |
同名 | 参考实现把重试同时用于并发与批量两个分支 |
cache.py |
同名 | 一致 |
adapters/* |
同名 | 参考实现多一个 _sync_with_retry |
reports.py |
同名 | 一致 |
27.6 面试复盘:Eval Harness
不能翻资料,逐条回答(对照 projects/llm-eval/ 源码):
- parser 为什么会影响 accuracy?你测过哪些输出格式?
- async 怎么控制并发?Semaphore 放在哪一层?
- retry 会不会导致重复请求?什么错误该重试、什么不该?
- cache key 怎么设计?缓存什么时候必须失效?
- bad case 怎么分类?分类之后各自的修法是什么?
- 怎么保证评测两个模型时是公平比较?(同一数据、同一 prompt、同一解析、同一指标)
- 评测结果里为什么必须报告 n 和 parse_failure 数?
- 如果明天要加 GSM8K(生成式数学题),你的框架需要改哪几个文件?
- (新版) 你的 harness 里,哪一部分是「系统问题」可能引入的错误?你怎么用 bad case 区分「系统坏了」和「模型不行」?
- (新版) 描述你的缓存 key 里包含哪些参数,以及你如何验证「第二次运行真的快」。
显示答案与解析
正确答案:B
评测工程的第一纪律是「分数必须带不确定性」。n=500 时 95% CI 约 ±3.9pt——1pt 的差异完全在噪声范围内。C 是 cherry-picking;D 是指标操纵。| 站点 | 核心结论 |
|---|---|
| 架构 | Adapter(要答案)× Task(问/判)× Runner(并发/重试/缓存)× Reports |
| Parser | 解析失败会伪装成模型变差;parse_failure 要单独统计 |
| 并发 | asyncio + Semaphore;全并发 = 429 重试风暴 |
| 缓存 | key 必须包含全部影响输出的参数;失败不缓存 |
| Bad case | 五类 error_type,各有不同的修法 |
| 纪律 | 报告必须带 n 与不确定性;先怀疑配置再怀疑模型 |
| 学习模式 | Learn(手册)→ Guided Build(starter 15 步)→ Reference(完整工程) |