12 CI 建设与质量闸门: 让仓库自己拒绝变差的代码
一句话总结: 上一章那三十几条注意事项, 三个月后你不会记得 —— 而不跑的检查等于不存在的检查。CI 的作用是把"我记得要跑一遍"变成"不跑就不许合并", 而质量闸门的关键设计是只用那些在 CI 环境里稳定、免费、且真的能反映问题的指标, 剩下的诚实地留在手动流程里。
本章你能学到什么
- CI 到底该拦住什么 —— 不是"跑测试", 是具体列出你怕什么
- GitHub Actions 的结构:
on/env/concurrency/jobs各自解决什么 - 为什么 CI 全程
OFFLINE=true, 以及这个决定的三个收益和一个必须承认的代价 - 便宜的检查放前面 (
ruff1 秒) —— 快速失败的实际收益 - 单元测试之外为什么还要跑一次 CLI 端到端 (它抓到过测试全绿但启动就崩)
- 哪些评估层能当门禁, 哪些只能当"脚本不报错" —— 用实测数字说明为什么
- 一个真实的坑: CI 环境里检索指标和你本地完全不同, 拿它当门禁必然误伤
- 判分器一致性 (MAE / 顺序一致率) 为什么反而可以当门禁
- 阈值该留多少余量, 以及"分辨率"如何决定阈值的下限
- 镜像构建验证: 为什么 build 成功不等于能跑, 以及 CI 里怎么起容器验一次
- 依赖版本钉死、缓存、
timeout-minutes、needs这些省钱又省心的细节 - 一份可以直接抄的完整
ci.yml, 每一行都讲清楚为什么
一、先决定 CI 要拦住什么
1.1 不要从"配置 CI"开始想
大部分人配 CI 的方式是: 抄一个 ci.yml, 让 pytest 跑起来, 完事。这样配出来的 CI 只能拦住"代码语法错了"这一类问题, 而那类问题你本地跑一次就发现了。
正确的起点是列出你具体怕什么。我们这个项目走到第 12 章, 怕的是这些:
| 我怕什么 | 具体场景 | 谁能拦住 |
|---|---|---|
| 改了检索代码, 召回悄悄变差 | 调了 RRF 的 k 值, Recall@3 从 1.0 掉到 0.87 | 检索评测 (但有坑, 见第四节) |
| 改了判分逻辑, 和人工标注拉远了 | 改了关键词表, MAE 从 0.833 涨到 3.0 | 判分器一致性评测 |
| 改了状态机, 某条分支崩了 | 追问深度的边界条件改错, 白卷不换题了 | 17 个 FSM 测试 |
| 改了接口, 前端对不上 | 事件名从 grade 改成 score, 前端不认了 |
15 个 API 测试 |
| 语料/评测集写坏了 | rubric 权重和不等于 10, gold_chunks 指向不存在的文档 | check_golden.py |
| 改了 Dockerfile, 镜像起不来 | 漏了一个 COPY, 容器启动就 ImportError |
镜像构建 + 起容器健康检查 |
| 代码风格漂移 | import 顺序乱、未使用的变量堆积 | ruff |
| 改了语料但忘了重灌库 | 第 11 章第 7.3 节那个坑 | CI 拦不住, 只能写文档 |
最后一行很重要: 明确知道 CI 拦不住什么, 比以为它什么都能拦更安全。
1.2 CI 的三个硬约束
CI 环境和你的开发机有三个本质区别, 它们决定了什么能放进去:
| 约束 | 后果 |
|---|---|
| 没有密钥 (除非你主动往 Secrets 里放) | 任何要调 LLM 的检查都不能进 CI, 除非你愿意承担泄漏面和账单 |
| 必须确定性 | 结果有抖动的检查会造成"红了但不是我的问题", 而假警报会训练团队忽略 CI —— 这比没有 CI 更糟 |
| 必须快 | 每个 PR 都跑。跑 20 分钟的 CI 会让人不等结果就合并 |
这三条直接推出本章最重要的一个决定。
二、CI 全程 OFFLINE=true
env:
OFFLINE: "true"
一行环境变量, 作用于所有 job。它让判分器换成 RuleGrader (80 行关键词加权覆盖, 不调模型), embedding 在装不了真模型时降级到 HashEmbedder。
2.1 三个收益
| 收益 | 具体 |
|---|---|
| 少一个泄漏面 | 不需要往 GitHub Secrets 里放 LLM API Key。Secrets 泄漏的途径比你想的多 —— fork 的 PR、日志里的意外打印、第三方 action |
| 不花钱, 也不受第三方抖动影响 | CI 红了永远是自己代码的问题。这一条的价值被严重低估: 一个会因为上游 API 抖动而随机变红的 CI, 三周之后就没人看了 |
| 快 | 规则判分 + HashEmbedder 让 32 个用例十秒内跑完 |
2.2 一个必须承认的代价
"LLM 判分的真实质量" CI 测不了。
第 9 章那三层评估里, 对话层 (LLM Judge) 完全不能进 CI —— 它花钱、慢、而且有抖动。判分层用真模型跑也不行, 同样的三个原因。
所以真实质量的把关只能是手动流程: 改了 prompt 或换了模型之后, 人工跑一次 eval_grader.py 和 eval_dialog.py, 把数字贴进 PR 描述。
这是有意识的取舍, 不是遗漏。而写清楚"这一项由人工负责", 比假装 CI 覆盖了它安全得多 —— 因为前者会有人去做, 后者不会。
三、workflow 的骨架
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
OFFLINE: "true"
jobs:
lint-and-test:
runs-on: ubuntu-latest
timeout-minutes: 15
# ...
docker-build:
runs-on: ubuntu-latest
timeout-minutes: 30
needs: lint-and-test
# ...
3.1 on: 什么时候触发
push: branches: [main]—— 只在 main 上的 push 触发。不写 branches 的话每个分支的每次 push 都跑一遍, 而分支上的 PR 已经会触发pull_request了 —— 等于同一个提交跑两次, 白烧一倍额度。pull_request—— 这是真正的门禁入口。workflow_dispatch—— 允许在网页上手动点一下触发。调 CI 配置的时候极其有用: 不然你只能靠 push 空提交来试, 一次调试污染十个提交。
3.2 concurrency: 省额度的一行
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
同一分支的新 push 会取消上一次还在跑的任务。
场景: 你连着推了三个 commit 修同一个问题。不配这个的话, 三次 CI 同时在跑, 而前两次的结果你根本不关心。配了之后只有最后一次跑完。
免费额度有限, 而这一行能省掉一大半浪费。
3.3 timeout-minutes: 防止一个卡住的 job 烧掉全部额度
lint-and-test 给 15 分钟, docker-build 给 30 分钟 (它要构建 torch 那么大的依赖)。
不设的话默认 6 小时。一个因为网络问题卡在 pip install 的 job 能一次性烧掉你整月的免费额度。
3.4 needs: 测试没过就不构建镜像
docker-build:
needs: lint-and-test
镜像构建要 20 分钟 (torch 很大)。测试没过的代码不值得花这 20 分钟。
但要注意这个决定的另一面: 它让 CI 变成串行的, 总时长是两个 job 之和。如果你的镜像构建很快, 或者你更在意"一次拿到所有失败信息", 那就去掉 needs 让它们并行。
我们选串行是因为镜像构建太贵。这是一个成本换反馈速度的权衡, 没有普适答案。
四、第一个 job: lint 和测试
4.1 便宜的检查放最前面
- name: ruff 静态检查
run: uv run ruff check .
- name: pytest
run: uv run pytest -q --cov=app --cov-report=term-missing
ruff 一秒出结果, pytest 十秒。顺序放对了, 一个 import 顺序错误让你一秒后就知道, 而不是等十秒。
这个原则叫 fail fast, 推广开来是: 检查按"耗时从短到长"排列, 就像第 11 章 Dockerfile 里"按变化频率从低到高"排列一样。
✅ Checkpoint 1: 本地跑一遍 CI 的前两步
OFFLINE=true python -m ruff check .
真实输出:
All checks passed!
OFFLINE=true DB_PATH=/tmp/ck12_db/app.db python -m pytest
真实输出:
................................ [100%]
32 passed in 10.47s
32 = 17 (FSM, 第 7 章) + 15 (API, 第 8 章)。十秒跑完, 而且不花一分钱 —— 这就是 OFFLINE=true 换来的。
注意我传了 DB_PATH=/tmp/ck12_db/app.db: 测试会写库, 别让它污染你的真实数据。第 8 章的 conftest 已经在模块顶层设了环境变量, 这里再显式给一个隔离路径是双保险。
4.2 ruff 的配置里有三条值得讲的 ignore
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = [
"E501", # 行长交给 formatter, lint 不重复报
"B008", # FastAPI 的 Depends()/Header() 就是写在默认参数里的, 这是框架规定的写法
"UP042", # str+Enum 而不是 StrEnum: Pydantic 序列化行为更明确, 且兼容 3.10
]
[tool.ruff.lint.per-file-ignores]
"eval/*.py" = ["E402"]
"finetune/*.py" = ["E402"]
"scripts/*.py" = ["E402"]
"tests/*.py" = ["E402"]
每一条 ignore 都必须写明理由。没有理由的 ignore 会不断增长, 最后 lint 形同虚设。
B008—— flake8-bugbear 认为"函数调用不该出现在默认参数里", 通常是对的 (可变默认值的经典坑)。但 FastAPI 的Depends()/Header()就是这么设计的。这是规则和框架的冲突, 不是代码问题。E402(import 不在文件顶部) 对eval/、scripts/这些脚本开放, 因为它们要先sys.path.insert再 import 本项目模块 —— 顺序不能调。E501关掉是因为行长交给 formatter 管, 让 lint 也报一遍是重复噪声。
4.3 单元测试之外, 再跑一次端到端
- name: 离线跑一遍 CLI 面试
run: |
uv run python -m app.rag.index corpus
uv run python -m app.cli --offline --auto --questions 3
为什么 32 个测试之外还要这一步?
因为单元测试用的是 TestClient 和 fixture, 它们绕过了真实的启动路径。这一步真的执行 python -m app.cli, 走完整的 import 链、真的灌一次库、真的跑三道题。
这一步曾经抓到过"测试全绿但 CLI 启动就崩" —— 一个 import 顺序问题, 只在真实启动时暴露。
✅ Checkpoint 2: 端到端冒烟
OFFLINE=true CHROMA_DIR=/tmp/ck12_chroma python -m app.rag.index corpus
真实输出:
入库 25 块, collection.count()=25
BM25 词表大小=904, 平均块长(token)=69.2
OFFLINE=true CHROMA_DIR=/tmp/ck12_chroma DB_PATH=/tmp/ck12_db/app.db \
python -m app.cli --offline --auto --questions 3
真实输出的报告部分:
- 会话: `265bd96e8990`
- 题数: 3
- 均分: **3.33 / 10**
- 水平判定: **待加强**
- 总耗时: 0 ms
## 分知识点表现
| 知识点 | 均分 | 掌握程度 |
|---|---|---|
| Kotlin/协程 | 0.0 | blank |
| 四大组件/Activity生命周期 | 10.0 | expert |
## 强项
- 四大组件/Activity生命周期 (均分 10.0)
## 薄弱项
- Kotlin/协程 (均分 0.0)
## 下一步学习建议
1. [Kotlin/协程] 补: 挂起不阻塞线程, 挂起时线程被释放去执行其他任务
2. [Kotlin/协程] 补: 阻塞是线程占着资源什么都不做
这一步要确认的不是分数, 是"整条链路都活着": 灌库成功 (25 块)、选题成功、判分成功 (10.0 和 0.0 两个极端都出现了)、报告聚合成功、学习建议能从 rubric 里提取出来。
--auto 是第 7 章加的自动作答模式 (轮换好答案和白卷), 所以这一步不需要人交互 —— --auto 那个开关当时看起来只是方便调试, 现在它是 CI 能跑端到端的前提。
注意 总耗时: 0 ms。离线模式下判分是纯本地关键词匹配, 快到计时器测不出来。这个 0 是正常的, 不是 bug。
五、质量闸门: 这一章最需要想清楚的地方
5.1 评测集自检: 最便宜的闸门
在跑任何检索之前, 先确认标注本身是合法的:
- name: 评测集自检
run: uv run python -m scripts.check_golden
它检查五件事: 必填字段齐全、id 不重复、rubric 权重和必须等于 10、要点数在 2~5 个之间、gold_chunks 里的 doc_id 在语料里真的存在。
✅ Checkpoint 3: 评测集自检
OFFLINE=true python -m scripts.check_golden
真实输出:
评测集 15 题, 语料 7 篇
Jetpack/ViewModel -> 3 题
Kotlin/协程 -> 3 题
四大组件/Activity生命周期 -> 3 题
四大组件/Fragment生命周期 -> 1 题
四大组件/Service -> 1 题
性能优化/ANR -> 2 题
性能优化/内存泄漏 -> 2 题
难度分布: {2: 10, 3: 5}
平均要点数: 3.00
带追问的题: 10 / 15
校验通过: 无问题
这个脚本的价值在第 9 章说过一次, 值得再说一遍: "校验前置的价值不是防崩溃, 是防止你去修一个不存在的 bug。"
具体来说: 如果 gold_chunks 里写了一个不存在的 doc_id, 检索评测会报告 Recall 变差 —— 然后你会去查检索代码, 而问题在标注文件里。这个脚本让那半天不会被浪费。
它同时打印分布 (难度分布: {2: 10, 3: 5}、带追问的题: 10 / 15), 这样评测集慢慢长大时你能看到它有没有跑偏 —— 比如全是难度 2 的题。
5.2 检索评测能不能当门禁? 有一个必须知道的坑
直觉上应该能: Recall@3 掉了就拦住, 多合理。
但 CI 环境里的检索指标和你本地完全不同。看实测。
本地 (真 embedding 模型 BAAI/bge-small-zh-v1.5):
| 模式 | Recall@3 | MRR | nDCG@3 |
|----------------|-----------|-----------|-----------|
| bm25 | 0.9333 | 0.9333 | 0.9066 |
| vector | 1.0000 | 0.9556 | 0.9560 |
| hybrid | 1.0000 | 0.9667 | 0.9647 |
CI 环境 (装不了 sentence-transformers, 降级到 HashEmbedder):
[embedding] 真模型不可用(ImportError), 降级到 HashEmbedder: No module named 'sentence_transformers'
| 模式 | Recall@3 | MRR | nDCG@3 |
|----------------|-----------|-----------|-----------|
| bm25 | 0.9333 | 0.9333 | 0.9066 |
| vector | 0.7333 | 0.7333 | 0.7333 |
| hybrid | 0.9333 | 0.8889 | 0.8839 |
✅ Checkpoint 4: 两个环境的检索指标对比
把两张表并排读:
| 模式 | 本地 (真模型) | CI (HashEmbedder) | 差 |
|---|---|---|---|
| bm25 | 0.9333 | 0.9333 | 0 |
| vector | 1.0000 | 0.7333 | -0.2667 |
| hybrid | 1.0000 | 0.9333 | -0.0667 |
三个结论:
第一, bm25 完全一致 (0.9333 / 0.9333 / 0.9066 三个指标全同)。因为 BM25 是纯词频统计, 不依赖任何模型 —— 它是唯一在 CI 里可复现的检索路径。
第二, vector 掉了 0.2667, 也就是四题。HashEmbedder 是把词哈希成向量的假 embedder, 没有语义能力。这个数字不是"检索变差了", 是"用了一个不同的 embedder"。
第三, 因此检索评测不能当门禁。如果你把 Recall@3 >= 0.95 写成闸门, CI 里 vector 模式的 0.7333 会让每一个 PR 都红, 包括完全没碰检索的 PR。而这种误伤会迅速训练团队去忽略 CI。
所以 CI 里这一步的定位是:
- name: 离线跑检索评测
# 评测数字不作为门禁 (离线 embedder 召回本来就差), 只保证脚本不报错。
# 真实门禁数字要用真模型跑, 见第 6 篇。
run: uv run python -m eval.eval_retrieval --k 3
只保证脚本不报错。这仍然有价值 —— 它能抓住"评测脚本本身被改坏了"、"检索接口签名变了"、"索引结构不兼容了"。但具体数字要人工用真模型跑。
这是本章最重要的一个判断: 一个指标能不能当门禁, 取决于它在 CI 环境里是否可复现, 而不是它有多重要。检索质量当然重要, 但它在 CI 里不可复现, 所以只能人工守。
5.3 判分器一致性: 这个反而可以当门禁
和检索相反, 判分器一致性在 CI 里完全可复现:
RuleGrader是 80 行关键词加权覆盖, 不调任何模型, 纯确定性;grader_cases.yaml里的 15 条人工标注是固定的;- 不调任何模型, 不依赖网络。
所以同一份代码在 CI 里跑一万次都是 MAE 0.833 / 一致率 0.8。可复现 = 可以当门禁。
评测脚本 eval_grader.py 只负责如实打印数字, 不做通过/拒绝的判断。闸门单独写一个脚本 scripts/gate_grader.py:
"""判分器质量闸门: 跑一遍一致性评测, 指标越线就以非零码退出, 让 CI 变红。
单独写一个脚本而不是给 eval_grader.py 加 --gate:
评测脚本的职责是"如实报告数字", 闸门的职责是"根据数字做通过/拒绝的决定"。
阈值会随项目演进而调整, 混在一起改的时候容易把两件事搞混。
"""
核心部分复用评测脚本的函数, 不重新实现:
from app.agent.grader import get_grader
from app.agent.selector import load_golden_questions
from eval.eval_grader import load_cases, pair_agreement
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--cases", default="eval/grader_cases.yaml")
ap.add_argument("--golden", default="eval/golden.yaml")
ap.add_argument("--offline", action="store_true")
ap.add_argument("--max-mae", type=float, default=2.5)
ap.add_argument("--min-agree", type=float, default=0.70)
args = ap.parse_args()
cases = load_cases(args.cases)
qs = {q.qid: q for q in load_golden_questions(args.golden)}
grader = get_grader(offline=args.offline)
human, machine = [], []
for c in cases:
q = qs.get(c["qid"])
if q is None:
continue
human.append(float(c["human_score"]))
machine.append(grader.grade(q, c["answer"]).score)
n = len(human)
mae = sum(abs(h - m) for h, m in zip(human, machine, strict=True)) / n
agree, pairs = pair_agreement(human, machine)
print(f"判分器 {type(grader).__name__} 样本 {n} 条 样本对 {pairs}")
print(f"MAE = {mae:.3f} (闸门: <= {args.max_mae})")
print(f"顺序一致率 = {agree:.4f} (闸门: >= {args.min_agree})")
fails = []
if mae > args.max_mae:
fails.append(f"MAE {mae:.3f} > {args.max_mae}")
if agree < args.min_agree:
fails.append(f"一致率 {agree:.4f} < {args.min_agree}")
if fails:
print("\n闸门未通过:")
for f in fails:
print(f" - {f}")
return 1
print("\n闸门通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())
四个设计点:
from eval.eval_grader import load_cases, pair_agreement—— 复用而不是重写。闸门和评测用同一个函数算一致率, 否则两处会慢慢漂移, 而"报告说 0.8 但闸门说 0.7"这种事无从排查。raise SystemExit(main())—— 返回值变成进程退出码。这一行是整个闸门机制的关键: CI 只认退出码。- 阈值做成命令行参数 (
--max-mae/--min-agree) 而不是硬编码 —— 这样调阈值改的是ci.yml一行, 而不是 Python 代码。阈值是策略, 不是逻辑。 - 默认值取第 9 章的红线 (
MAE > 2.5或一致率 < 0.7), 而不是那条更严的参考线 (MAE <= 1.5且一致率 >= 0.85)。为什么这样选, 下一小节讲。
✅ Checkpoint 5: 闸门通过
OFFLINE=true python -m scripts.gate_grader --offline
echo "exit=$?"
真实输出:
判分器 RuleGrader 样本 15 条 样本对 105
MAE = 0.833 (闸门: <= 2.5)
顺序一致率 = 0.8000 (闸门: >= 0.7)
闸门通过
exit=0
✅ Checkpoint 6: 闸门真的会拦人
一个只会通过的闸门等于没有闸门。必须验证它在指标变差时真的返回非零码 —— 用收紧阈值来模拟"指标变差":
OFFLINE=true python -m scripts.gate_grader --offline --max-mae 0.5 --min-agree 0.9
echo "exit=$?"
真实输出:
判分器 RuleGrader 样本 15 条 样本对 105
MAE = 0.833 (闸门: <= 0.5)
顺序一致率 = 0.8000 (闸门: >= 0.9)
闸门未通过:
- MAE 0.833 > 0.5
- 一致率 0.8000 < 0.9
exit=1
exit=1 就是 CI 变红的全部机制。
两个细节值得注意:
- 两条都失败时两条都打印出来了, 而不是遇到第一条就退出。闸门要一次报告所有问题 —— 否则你修了 MAE 再跑一次, 才发现一致率也不过关, 白等一轮 CI。
- 失败信息里带着实际值和阈值 (
MAE 0.833 > 0.5)。只说"闸门未通过"的日志毫无用处 —— 看日志的人第一个问题一定是"差多少"。
5.4 阈值该留多少余量
现在的实测值是 MAE 0.833 / 一致率 0.8000, 而闸门是 MAE <= 2.5 / 一致率 >= 0.7。余量看起来大得离谱: MAE 差三倍。为什么不收紧到 1.0 / 0.85?
第 9 章给过两条线, 它们是两回事:
| 线 | 数字 | 含义 |
|---|---|---|
| 参考线 | MAE <= 1.5 且 一致率 >= 0.85 |
"这个判分器可以用来做对照实验了" |
| 红线 | MAE > 2.5 或 一致率 < 0.7 |
"别调别的了, 先修判分器" |
闸门用红线, 不用参考线。因为这两条线要回答的问题不同:
- 参考线回答"我现在能不能信这个判分器的结论" —— 这是你自己看数字时的标准;
- 红线回答"这个改动是不是把判分器搞坏了" —— 这是拦别人的合并的标准。
拦人的标准必须比自我要求宽。闸门收得太紧的后果是: 一个和判分器完全无关的 PR (比如改了个前端文案), 因为一致率恰好在 0.85 边缘抖了一下就被拦住, 然后作者会去找有权限的人 override, 再过两周就没人认真看闸门了。这和第 1.2 节"假警报会训练团队忽略 CI"是同一个失效模式。
而且注意现在的一致率就是 0.8000 —— 它本来就在参考线 0.85 之下。把闸门设成 0.85 等于第一天就红。
分辨率决定了阈值的下限
15 条样本两两配对是 105 个样本对, 所以一致率的最小变化单位是 1/105 = 0.0095。
这意味着"一致率 >= 0.805"这种阈值是自欺欺人 —— 你根本没有能力区分 0.8000 和 0.8048, 中间一个可比对都放不下。阈值的精度不能高于指标的分辨率。
同理 MAE: 15 个样本, 每个分差是 0.5 分的整数倍, 所以 MAE 的最小变化单位是 0.5/15 = 0.033。写 --max-mae 2.5 是合理的, 写 --max-mae 2.51 就是在假装精确。
这一节的结论: 闸门的作用是拦住明显的退化, 不是逼近最优。想推进质量靠人看评测报告, 靠闸门只能守住底线。
六、第二个 job: 镜像构建 + 起容器验一次
6.1 构建成功不等于能跑起来
- name: 构建镜像
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: false
load: true
tags: android-interviewer:ci
cache-from: type=gha
cache-to: type=gha,mode=max
四个参数值得讲:
| 参数 | 为什么 |
|---|---|
push: false |
CI 只验证能构建, 不推仓库。推镜像需要 registry 凭证, 而这一章的原则是 CI 里不放密钥。真要发版, 单独开一个由 tag 触发的 workflow |
load: true |
把构建结果加载进本地 docker daemon, 下一步才能 docker run 它。不写这一行, 镜像只存在于 buildx 的缓存里, docker run 会报 image not found |
cache-from/to: type=gha |
用 GitHub Actions 的缓存存层。第 11 章按变化频率排 COPY 的收益, 只有配了这个才在 CI 里兑现 —— 否则每次 PR 都从零装一遍 torch |
mode=max |
缓存所有中间层, 而不是只缓存最终层。多阶段构建必须用 max, 否则 builder 阶段 (装依赖那一层, 也就是最贵的那层) 根本不进缓存 |
6.2 起容器 + 轮询健康检查
- name: 起容器做健康检查
# 构建成功 != 能跑起来。这一步验证的是: 模型真的在镜像里、
# entrypoint 真的灌上了库、/healthz 真的返回 ok。
run: |
docker run -d --name itv -p 8000:8000 -e OFFLINE=true android-interviewer:ci
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/healthz | grep -q '"status":"ok"'; then
echo "健康检查通过"
curl -s http://127.0.0.1:8000/healthz
exit 0
fi
echo "等待启动... ($i/30)"
sleep 5
done
echo "启动超时, 打印日志:"
docker logs itv
exit 1
- name: 失败时导出日志
if: failure()
run: docker logs itv || true
这一步抓的是第 11 章那一整节踩坑: 漏了一个 COPY 导致 ImportError、模型没进镜像导致启动去下载然后超时、entrypoint.sh 的灌库逻辑写错导致 chunks: 0。这些错误全都能构建成功。
五个细节:
grep -q '"status":"ok"'而不是只看 HTTP 200 —— 和第 11 章 HEALTHCHECK 同一个理由:/healthz是健康报告, 降级运行时它照样返回 200。只看状态码等于没检查。-e OFFLINE=true—— 容器里也要离线。镜像里HF_HUB_OFFLINE=1已经挡住了模型下载, 这里挡住的是 LLM 调用。- 30 次 × 5 秒 = 最多等 150 秒。为什么这么长? 第 11 章测出首次启动要灌库,
start_period给的是 90 秒。轮询上限必须大于start_period, 否则你在容器还没准备好的时候就判它死了。 for循环里sleep 5而不是sleep 1—— 启动本来就要几十秒, 每秒探一次只是在日志里刷 90 行噪声。- 超时分支先
docker logs itv再exit 1—— 这一行是排查的全部依据。没有它你只能看到"红了", 而容器已经随 runner 一起消失了。
if: failure() 那个额外步骤是双保险: 如果失败发生在其他地方 (比如 docker run 本身就失败了), 上面那个 for 循环里的 docker logs 根本没机会执行。|| true 是为了容器压根没起来时这一步别再报一个次生错误, 盖住真正的原因。
⚠️ 这一节没有 Checkpoint
第 11 章的镜像构建在本机没有跑通 (装 torch 的镜像太大, 本地环境不具备), 所以本节的 docker-build job 我没有实测过。
给出的配置来自官方文档和逐行推理, 逻辑是自洽的, 但我不能声称它跑过。你自己第一次开这个 job 时请预期要调几轮, 尤其是 load: true 和缓存那两行。
第 11 章那句原则在这里继续适用: 凡是没量过的, 就说没量过。
七、依赖安装那一步的三个决定
- name: 安装 uv
uses: astral-sh/setup-uv@v7
with:
version: "0.11.32"
enable-cache: true
- name: 建虚拟环境并装依赖
run: |
uv venv
uv pip install -r pyproject.toml --extra dev
uv pip list | head -30
7.1 version: "0.11.32" —— 连工具版本都钉死
不写 version 就是"用最新的 uv"。那意味着某天早上你的 CI 会因为一个你没做的改动而红。
这和第 2 章 pyproject.toml 里"版本全部钉死"是同一条原则, 只是延伸到了 CI 工具本身。CI 的价值建立在"红了就是我的问题"上, 任何浮动版本都在削弱这个前提。
actions/checkout@v5、docker/setup-buildx-action@v3 这些同理 —— 至少钉到大版本。
7.2 enable-cache: true —— 缓存的是下载, 不是安装
它缓存 uv 的下载目录, 所以第二次跑不用重新从 PyPI 拉 wheel。装还是要装一遍, 但省掉的网络时间通常是大头。
缓存 key 由 uv.lock 的哈希决定, 所以依赖没变就命中, 依赖变了就自动失效 —— 你不需要手动清缓存。这一点比自己写 actions/cache 省心得多。
7.3 uv pip list | head -30 —— 一行诊断
装完打印一遍装了什么。平时没用, 出问题时它是唯一能告诉你"CI 里到底是哪个版本"的地方。
head -30 是因为完整列表有一百多行, 而你关心的只是前面几个直接依赖。
一个必须说清的点: 我们没有刻意不装 chromadb。app/rag/index.py 在 KnowledgeIndex.__init__ 里直接 import chromadb, 不装就跑不了灌库那一步 —— 第 11 章 §6.4 那个 ModuleNotFoundError 就是这么来的。真正靠 fallback 跳过的是 sentence-transformers (它拖着 torch, 几百兆), get_embedder() 会捕获 ImportError 降级到 HashEmbedder。
所以 CI 里那张"vector 只有 0.7333"的表, 根源就在这个取舍上: 我们主动选择了"不装 torch, 换十秒的 CI", 代价是检索指标不可复现。
八、完整的 ci.yml
把前面所有决定合起来。这份可以直接抄:
# CI: 每次 push 和 PR 都跑一遍 lint + 测试, 再验证镜像能不能构建。
#
# 关键决定: CI 里全程 OFFLINE=true。
# 理由有三个 ——
# 1. 不需要往 GitHub Secrets 里放 LLM API Key, 少一个泄漏面
# 2. 不花钱, 也不受第三方 API 抖动影响 (CI 红了永远是自己代码的问题)
# 3. 快。规则判分 + HashEmbedder 让 32 个用例十秒内跑完
#
# 代价是"LLM 判分的真实质量"CI 测不了 —— 那个由 eval/ 下的评测脚本手动跑, 见第 9 篇。
# 这是有意识的取舍, 不是遗漏。
name: CI
on:
push:
branches: [main]
pull_request:
# 允许手动触发, 调 CI 的时候不用靠 push 空提交
workflow_dispatch:
# 同一分支的新 push 取消上一次还在跑的任务, 省 CI 额度
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
OFFLINE: "true"
jobs:
lint-and-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- name: 安装 uv
uses: astral-sh/setup-uv@v7
with:
version: "0.11.32"
enable-cache: true
- name: 建虚拟环境并装依赖
# chromadb 必须装 (index.py 直接 import 它)。
# sentence-transformers 不装 —— 它拖着 torch, 靠 HashEmbedder fallback 跳过。
run: |
uv venv
uv pip install -r pyproject.toml --extra dev
uv pip list | head -30
- name: ruff 静态检查
run: uv run ruff check .
- name: pytest
run: uv run pytest -q --cov=app --cov-report=term-missing
- name: 评测集自检
# 最便宜的闸门: 先确认标注本身合法, 再谈指标。
run: uv run python -m scripts.check_golden
- name: 离线跑一遍 CLI 面试
# 单元测试之外再做一次端到端冒烟: 状态机、选题、报告全链路真跑一遍。
# 这一步曾经抓到过"测试全绿但 CLI 启动就崩"(import 顺序问题)。
run: |
uv run python -m app.rag.index corpus
uv run python -m app.cli --offline --auto --questions 3
- name: 离线跑检索评测
# 评测数字不作为门禁 (离线 embedder 召回本来就差), 只保证脚本不报错。
# 真实门禁数字要用真模型跑, 见第 6 篇。
run: uv run python -m eval.eval_retrieval --k 3
- name: 判分器一致性闸门
# 这个可以当门禁: RuleGrader 纯确定性, CI 里跑一万次都是同一个数。
# 阈值取第 9 篇的红线而不是参考线, 理由见本篇 5.4。
run: uv run python -m scripts.gate_grader --offline --max-mae 2.5 --min-agree 0.70
docker-build:
runs-on: ubuntu-latest
timeout-minutes: 30
# 测试没过就不用浪费 20 分钟构建镜像了
needs: lint-and-test
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: 构建镜像
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: false
load: true
tags: android-interviewer:ci
# GitHub Actions 的层缓存。第一次慢, 之后只要依赖没变就秒过。
cache-from: type=gha
cache-to: type=gha,mode=max
- name: 起容器做健康检查
# 构建成功 != 能跑起来。这一步验证的是: 模型真的在镜像里、
# entrypoint 真的灌上了库、/healthz 真的返回 ok。
run: |
docker run -d --name itv -p 8000:8000 -e OFFLINE=true android-interviewer:ci
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/healthz | grep -q '"status":"ok"'; then
echo "健康检查通过"
curl -s http://127.0.0.1:8000/healthz
exit 0
fi
echo "等待启动... ($i/30)"
sleep 5
done
echo "启动超时, 打印日志:"
docker logs itv
exit 1
- name: 失败时导出日志
if: failure()
run: docker logs itv || true
8.1 步骤顺序就是本章的全部逻辑
从上往下读这个 job, 它是一条严格的"耗时递增 + 依赖递增"链:
| 顺序 | 步骤 | 耗时 | 拦的是什么 |
|---|---|---|---|
| 1 | ruff |
约 1 秒 | 风格、未使用变量、import 顺序 |
| 2 | pytest |
10.47 秒 (实测) | 32 个用例: FSM 分支 + API 契约 |
| 3 | check_golden |
0.05 秒 (实测) | 标注文件本身写坏了 |
| 4 | CLI 端到端 | 十几秒 | 真实启动路径崩了 |
| 5 | 检索评测 | 十几秒 | 评测脚本/检索接口被改坏 (不看数字) |
| 6 | 判分器闸门 | 0.02 秒 (实测) | 判分逻辑退化 (看数字) |
| 7 | 镜像构建 + 起容器 | 约 20 分钟 | Dockerfile 漏文件、entrypoint 写错 |
标"实测"的是本机跑出来的数, 其余是量级估计 —— CI 机器比本机慢, 而装依赖那一步 (缓存命中时) 本身就要几十秒, 所以整个 job 的实际墙上时间是两三分钟, 不是这一列的和。
为什么 check_golden 排在 pytest 后面而不是最前?它只要 0.05 秒, 比 ruff 还便宜, 按"耗时递增"该排第一。但如果单元测试都不过, 标注文件是否合法根本不重要。依赖关系优先于耗时 —— fail fast 的排序原则要让位给"先报告更根本的问题"。
8.2 这份 CI 的能力边界
| 层 | CI 能做的 | 只能人工做的 |
|---|---|---|
| 代码 | 全部 (lint + 32 测试 + 端到端) | —— |
| 检索 | 脚本不报错 | 指标数字 (要真 embedding 模型) |
| 判分 | 规则判分器的一致性闸门 | LLM 判分的真实质量 |
| 对话 | —— | 全部 (LLM Judge 花钱、慢、有抖动) |
| 语料 | 标注文件合法性 | 改语料后重灌库 |
| 部署 | 镜像能构建、容器能起来 | 真实流量、HTTPS、SSE 穿代理 |
这张表就是本章的诚实版总结。右列不是待办事项, 是已经决定由人负责的部分 —— 把它写在文档里, 是为了让"人负责"这件事真的有人做。
九、踩坑与专家提示
CI 配置本身
on: push:不写branches等于每个提交跑两遍。分支上的 push 触发一次, 同一个提交的 PR 再触发一次。免费额度就是这么烧完的。- 不配
concurrency时连推三个 commit 会同时跑三个 CI, 而你只关心最后一个。配上cancel-in-progress: true能省掉一大半浪费。 timeout-minutes不设的默认值是 6 小时。一个卡在pip install的 job 能一次性烧掉你整月额度。每个 job 都写。workflow_dispatch不加, 调 CI 就只能靠 push 空提交。一次调试污染十个提交, 而且每次都得等排队。needs让 CI 串行, 总时长变成两个 job 之和。这是"省构建成本"换"反馈变慢"。镜像构建快的项目应该去掉它并行跑。- action 版本要钉。
astral-sh/setup-uv@v7+version: "0.11.32"。不钉的后果是某天早上 CI 因为一个你没做的改动而红 —— 而这直接摧毁"红了就是我的问题"这个前提。
闸门设计
- 一个只会通过的闸门等于没有闸门。必须像 Checkpoint 6 那样, 用收紧阈值验证它真的会返回非零码。你从没见它红过, 就不知道它红了会长什么样。
- 闸门要一次报告所有失败, 不要遇到第一条就退出。否则你修了 MAE 才发现一致率也不过关, 白等一轮 CI。
- 失败信息必须带实际值和阈值。只打印"闸门未通过"的日志毫无用处 —— 看日志的人第一个问题永远是"差多少"。
- 闸门脚本和评测脚本分开写。评测的职责是如实报告数字, 闸门的职责是根据数字做决定。混在一起时你会在调阈值的过程中不小心改了统计逻辑。
- 但统计函数必须复用。
from eval.eval_grader import pair_agreement—— 两处各写一遍必然漂移, 而"报告说 0.8 闸门说 0.7"这种事无从排查。 - 阈值做成命令行参数, 不要硬编码。阈值是策略, 会随项目演进调整; 逻辑不是。调阈值应该只改
ci.yml一行。 - 拦人的阈值必须比自我要求宽。闸门用红线 (
MAE > 2.5), 不用参考线 (MAE <= 1.5)。收太紧的结果是无关 PR 被误伤, 然后闸门被 override, 然后没人看了。 - 阈值精度不能高于指标分辨率。105 个样本对意味着一致率的最小变化是
1/105 = 0.0095, 写>= 0.805是在假装精确。 raise SystemExit(main())是整个闸门机制的关键一行。CI 只认退出码, 打印得再漂亮都不会让它变红。
哪些指标能当门禁
- 判断标准是"在 CI 环境里可不可复现", 不是"重不重要"。检索质量当然比代码风格重要, 但它在 CI 里不可复现, 所以只能人工守。
- CI 里的
vectorRecall@3 是 0.7333, 本机是 1.0000。因为 CI 装不了sentence-transformers, 降级到了HashEmbedder。拿这个数当门禁会让每一个 PR 都红, 包括完全没碰检索的 PR。 bm25在两个环境里三个指标完全相同 (0.9333 / 0.9333 / 0.9066), 因为它是纯词频统计。它是唯一在 CI 里可复现的检索路径 —— 真要给检索加门禁, 只能给它加。- 假警报比没有 CI 更糟。一个会随机变红的 CI, 三周之后就没人看了, 而这时候你连"CI 是绿的"这个信号都失去了。
- 明确写下 CI 拦不住什么。"改语料后忘了重灌库"这个坑 CI 拦不住, 写在文档里比假装覆盖了安全得多 —— 因为前者会有人去做。
测试与端到端
- 单元测试之外必须跑一次真实启动。
TestClient+ fixture 绕过了真实 import 链和真实灌库。这一步曾抓到过"32 个测试全绿但python -m app.cli启动就崩"。 - 端到端跑测试时给一个隔离的
DB_PATH。DB_PATH=/tmp/ck12_db/app.db—— 测试会写库, 别让它污染你的真实数据。 --auto那个开关是 CI 能跑端到端的前提。第 7 章加它时看起来只是方便调试。给交互式程序留一个非交互入口, 是让它未来可测的最低成本投资。- 端到端这一步要看的不是分数, 是"链路都活着": 灌库 25 块、选题成功、判分出现了 10.0 和 0.0 两个极端、报告聚合成功、建议能从 rubric 提取出来。
总耗时: 0 ms不是 bug。离线判分是纯本地关键词匹配, 快到计时器测不出来。看到 0 先想清楚它是"没跑"还是"太快", 别急着当异常。
lint 配置
- 每一条
ignore都必须写明理由。没有理由的 ignore 会不断增长, 最后 lint 形同虚设。 B008必须关, 因为 FastAPI 的Depends()/Header()就是设计成写在默认参数里的。这是规则和框架的冲突, 不是代码问题。E402用per-file-ignores局部开放, 不要全局关。eval/、scripts/、finetune/、tests/需要先sys.path.insert再 import 本项目模块, 顺序不能调; 但app/下没有这个理由。E501(行长) 交给 formatter, lint 不重复报。两个工具都报同一件事就是噪声。- 新写的脚本第一件事是过一遍
ruff check .。gate_grader.py第一次就吃了个I001(import 未排序),ruff check --fix一下就好 —— 但如果直接推上去, 你的 CI 会红在一件毫无信息量的事上。
镜像 job
push: false—— CI 只验证能构建, 不推仓库。推镜像需要 registry 凭证, 而这一章的原则是 CI 里不放密钥。发版单独开一个由 tag 触发的 workflow。load: true不写, 下一步docker run会报 image not found。不加载进本地 daemon, 镜像只存在于 buildx 缓存里。- 多阶段构建的缓存必须
mode=max。默认只缓存最终层, 而装依赖那一层 (最贵的那层) 在 builder 阶段, 根本不进缓存。 - 轮询上限必须大于
start_period。第 11 章测出首次启动要灌库,start_period是 90 秒; 这里 30 × 5 = 150 秒才够。上限设小了你是在容器没准备好的时候判它死。 - 健康检查要
grep '"status":"ok"', 不能只看 HTTP 200。/healthz是健康报告不是断言, 降级运行时照样返回 200。 - 超时分支一定要先
docker logs再exit 1。容器会随 runner 一起消失, 那几行日志是你唯一的排查依据。 if: failure()那个额外的docker logs是双保险。如果docker run本身就失败了, 循环里的日志导出根本没机会执行。|| true防止它自己再报一个次生错误盖住真因。
环境与依赖
chromadb在 CI 里必须装。app/rag/index.py在KnowledgeIndex.__init__里直接import chromadb。第 11 章 §6.4 那个ModuleNotFoundError就是漏装它。- 真正靠 fallback 跳过的是
sentence-transformers(它拖着 torch, 几百兆)。get_embedder()捕获 ImportError 降级到HashEmbedder, 并打印一行[embedding] 真模型不可用(...)。这行日志出现在 CI 里是预期的, 不是警报。 uv pip list | head -30平时没用, 出问题时它是唯一能告诉你"CI 里到底是哪个版本"的地方。一行成本, 留着。enable-cache: true的 key 由uv.lock哈希决定 —— 依赖没变就命中, 变了自动失效, 不用手动清。比自己写actions/cache省心。
十、面试视角
Q1: 你们 CI 里跑什么?
分两个 job。第一个是 lint 加测试:
ruff、32 个 pytest 用例、评测集自检、一次 CLI 端到端冒烟、检索评测、判分器一致性闸门 —— 按耗时从短到长排, 最贵的检查放最后。第二个 job 构建镜像并起容器验一次/healthz, 用needs依赖第一个, 因为测试没过的代码不值得花 20 分钟构建镜像。全程
OFFLINE=true, 不调任何外部模型。
Q2: 为什么 CI 里不调真模型? 那不是测不到真实质量吗?
是测不到, 这是有意识的取舍。三个理由: 一是不用往 Secrets 里放 API Key, 少一个泄漏面; 二是不花钱且不受第三方抖动影响, 所以 CI 红了永远是自己代码的问题; 三是快, 规则判分让 32 个用例十秒跑完。
代价是 LLM 判分质量和整个对话层评估进不了 CI。我们的做法是明确写下这一项由人工负责: 改了 prompt 或换了模型之后手动跑
eval_grader.py和eval_dialog.py, 数字贴 PR 描述。写清楚比假装覆盖了安全 —— 因为前者会有人去做。
Q3: 什么样的指标适合做 CI 门禁?
判断标准是在 CI 环境里是否可复现, 而不是它有多重要。
我们项目里有个正反对照。检索指标不能当门禁: CI 装不了
sentence-transformers, 降级到HashEmbedder之后 vector 模式的 Recall@3 从本机的 1.0 掉到 0.7333, 拿 0.95 当阈值会让每个 PR 都红。判分器一致性反而可以:RuleGrader是纯关键词匹配, 标注集固定, 不碰网络, 所以 CI 里跑一万次都是MAE 0.833 / 一致率 0.8。顺带一个有意思的点:
bm25在两个环境里三个指标完全相同, 因为它是纯词频统计。真要给检索加门禁, 只能给 bm25 加。
Q4: 阈值怎么定?
我们的实测是 MAE 0.833, 闸门设 2.5, 余量三倍。不是因为懒, 是因为拦人的标准必须比自我要求宽。评估体系里有两条线: 参考线
MAE <= 1.5 且一致率 >= 0.85回答"我能不能信这个判分器", 红线MAE > 2.5 或一致率 < 0.7回答"是不是搞坏了"。闸门用红线。收太紧的失效模式很具体: 一个改前端文案的 PR 因为指标在边缘抖动被拦, 作者去找人 override, 两周后没人认真看闸门了。这和"假警报会训练团队忽略 CI"是同一件事。
还有个技术下限: 15 条样本两两配对是 105 对, 一致率的最小变化单位是 1/105 也就是 0.0095, 所以写
>= 0.805这种阈值是自欺欺人。阈值精度不能高于指标分辨率。
Q5: 单元测试都过了, 为什么还要跑一遍 CLI?
因为单元测试用
TestClient和 fixture, 绕过了真实启动路径。这一步真的执行python -m app.cli, 走完整 import 链、真灌一次库、真跑三道题。它抓到过实际问题: 32 个测试全绿, 但 CLI 启动就崩, 一个 import 顺序问题只在真实启动时暴露。
前提是程序有非交互入口。我们第 7 章加的
--auto当时只是为了方便调试, 现在它是这一步能跑的唯一原因。给交互式程序留非交互入口, 是让它未来可测的最低成本投资。
Q6: 怎么保证 CI 不变成"永远绿的装饰"?
三件事。第一, 验证闸门真的会红 —— 我们故意用
--max-mae 0.5 --min-agree 0.9跑一遍, 确认它打印两条失败原因并返回exit=1。一个你从没见它红过的闸门, 你不知道它红了会长什么样。第二, 消除假警报。所有会抖动的检查都不当门禁, 只保证"脚本不报错"。
第三, 明确写下 CI 拦不住什么。比如"改语料后忘了重灌库"这个坑, CI 拦不住, 就写进文档和上线清单。
Q7: docker-build 那个 job 为什么不只 build 就完?
因为构建成功不等于能跑起来。漏一个
COPY导致 ImportError、模型没进镜像导致启动去下载然后超时、entrypoint 灌库逻辑写错导致chunks: 0—— 这些错误全都能构建成功。所以要
docker run起来轮询/healthz, 而且必须grep '"status":"ok"'而不是只看 200, 因为/healthz是健康报告不是断言, 降级时照样返回 200。轮询上限要大于start_period的 90 秒, 所以给了 30 次 × 5 秒。失败时先docker logs再退出 —— 容器会随 runner 消失, 那是唯一的排查依据。
十一、划重点
- 配 CI 的起点是"列出你具体怕什么", 不是"抄一个 ci.yml"。抄来的 CI 只能拦住"语法错了", 而那类问题你本地跑一次就发现。
- 明确知道 CI 拦不住什么, 比以为它什么都能拦更安全。
- CI 的三个硬约束: 没有密钥、必须确定性、必须快。它们决定了什么能放进去。
- 假警报会训练团队忽略 CI, 这比没有 CI 更糟。
OFFLINE=true换来三件事: 少一个泄漏面、不花钱且不受第三方抖动影响、快。- "CI 红了永远是自己代码的问题"是 CI 全部价值的地基。任何浮动的东西 (上游 API、未钉的 action 版本) 都在挖这个地基。
OFFLINE=true的代价是 LLM 判分质量和对话层评估测不了 —— 这一项明确由人工负责。on: push: branches: [main], 否则每个提交跑两遍。concurrency+cancel-in-progress省掉一大半浪费。timeout-minutes必写, 默认 6 小时能一次烧掉整月额度。workflow_dispatch让调 CI 不用推空提交。- 检查按耗时从短到长排 (fail fast), 但依赖关系优先于耗时。
- 每一条 lint
ignore都要写明理由, 否则 lint 迟早形同虚设。 B008关掉是因为 FastAPI 的Depends()就是这么设计的 —— 规则和框架冲突, 不是代码问题。- 单元测试之外必须跑真实启动路径, 它抓到过"测试全绿但启动就崩"。
--auto这种非交互入口是端到端能进 CI 的前提。- 端到端要确认的是"链路都活着", 不是分数。
- 最便宜的闸门是评测集自检。校验前置的价值不是防崩溃, 是防止你去修一个不存在的 bug。
- 本章最重要的判断: 一个指标能不能当门禁, 取决于它在 CI 环境里是否可复现, 而不是它有多重要。
- 实测: CI 环境 vector Recall@3 = 0.7333, 本机 = 1.0000, 差 0.2667 (四道题)。这不是"检索变差了", 是"用了一个不同的 embedder"。
bm25在两个环境里三个指标完全相同, 因为它不依赖任何模型 —— 它是唯一在 CI 里可复现的检索路径。- 检索评测在 CI 里的定位是"只保证脚本不报错"。这仍有价值: 能抓住评测脚本被改坏、接口签名变了、索引结构不兼容。
- 判分器一致性可以当门禁, 因为
RuleGrader+ 固定标注集 + 不碰网络 = 完全确定性。 - 闸门脚本和评测脚本分开写, 但统计函数必须复用, 否则两处会漂移。
raise SystemExit(main())是整个闸门机制的关键一行 —— CI 只认退出码。- 阈值做成命令行参数: 阈值是策略, 不是逻辑。
- 一个只会通过的闸门等于没有闸门。必须实测它在指标变差时返回
exit=1。 - 闸门要一次报告所有失败, 并带上实际值和阈值。
- 拦人的阈值必须比自我要求宽: 闸门用红线, 不用参考线。
- 阈值精度不能高于指标分辨率 (105 个样本对 → 一致率最小变化 0.0095)。
- 闸门的作用是拦住明显退化, 不是逼近最优。推进质量靠人看报告。
- 构建成功不等于能跑起来 —— 所以要起容器验一次。
- 健康检查要
grep '"status":"ok"', 不能只看 HTTP 200。 - 轮询上限必须大于
start_period。 push: false—— CI 只验构建不推仓库, 因为推镜像需要凭证。- 多阶段构建的缓存必须
mode=max, 否则最贵的那层不进缓存。 - 连 CI 工具的版本也要钉死 (
uv钉到0.11.32), 这是"红了就是我的问题"的延伸。 - 实测数字汇总:
ruff全绿;pytest32 passed in 10.47s;check_golden0.05 秒通过 (15 题 / 7 篇); 灌库 25 块 / BM25 词表 904;gate_grader0.02 秒,MAE 0.833 / 一致率 0.8000, 通过时exit=0, 故意收紧后exit=1。 docker-buildjob 本机没跑过, 文档里明确标了。凡是没量过的, 就说没量过。
十二、下一章预告
CI 建起来了, 镜像能构建, 闸门会拦人。到这一步, 这个项目从零到上线的完整链路已经走完: 语料 → 检索 → 面试官 → 服务 → 评估 → 微调 → 容器 → 门禁。
但整个过程里真正花掉时间的不是这些正路, 是那些"看起来该能跑但就是不行"的时刻: SSE 在 nginx 后面不流了、LoRA 把格式修好了却把判分修坏了、fuse 之后模型行为变了、测试全绿而 CLI 启动就崩。
第 13 章讲踩坑手册与面试话术: 把这十二章里真实踩过的坑集中成一份可检索的清单 (每一条都带"症状 → 原因 → 怎么发现 → 怎么修"), 然后回答另一个问题 —— 怎么把这个项目讲成一个亮点, 而不是讲成一份功能清单。
后者比你想的重要: 面试官不关心你用了 RAG, 关心的是你在哪里做了取舍、凭什么数据做的、哪些地方你诚实地承认没做到。而这十二章里每一个"我们选 A 不选 B, 因为实测……", 都是一个可以讲三分钟的素材。