全栈开发知识库

12 CI 建设与质量闸门: 让仓库自己拒绝变差的代码

一句话总结: 上一章那三十几条注意事项, 三个月后你不会记得 —— 而不跑的检查等于不存在的检查。CI 的作用是把"我记得要跑一遍"变成"不跑就不许合并", 而质量闸门的关键设计是只用那些在 CI 环境里稳定、免费、且真的能反映问题的指标, 剩下的诚实地留在手动流程里。

本章你能学到什么

  1. CI 到底该拦住什么 —— 不是"跑测试", 是具体列出你怕什么
  2. GitHub Actions 的结构: on / env / concurrency / jobs 各自解决什么
  3. 为什么 CI 全程 OFFLINE=true, 以及这个决定的三个收益和一个必须承认的代价
  4. 便宜的检查放前面 (ruff 1 秒) —— 快速失败的实际收益
  5. 单元测试之外为什么还要跑一次 CLI 端到端 (它抓到过测试全绿但启动就崩)
  6. 哪些评估层能当门禁, 哪些只能当"脚本不报错" —— 用实测数字说明为什么
  7. 一个真实的坑: CI 环境里检索指标和你本地完全不同, 拿它当门禁必然误伤
  8. 判分器一致性 (MAE / 顺序一致率) 为什么反而可以当门禁
  9. 阈值该留多少余量, 以及"分辨率"如何决定阈值的下限
  10. 镜像构建验证: 为什么 build 成功不等于能跑, 以及 CI 里怎么起容器验一次
  11. 依赖版本钉死、缓存、timeout-minutes、needs 这些省钱又省心的细节
  12. 一份可以直接抄的完整 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 配置本身

  1. on: push: 不写 branches 等于每个提交跑两遍。分支上的 push 触发一次, 同一个提交的 PR 再触发一次。免费额度就是这么烧完的。
  2. 不配 concurrency 时连推三个 commit 会同时跑三个 CI, 而你只关心最后一个。配上 cancel-in-progress: true 能省掉一大半浪费。
  3. timeout-minutes 不设的默认值是 6 小时。一个卡在 pip install 的 job 能一次性烧掉你整月额度。每个 job 都写。
  4. workflow_dispatch 不加, 调 CI 就只能靠 push 空提交。一次调试污染十个提交, 而且每次都得等排队。
  5. needs 让 CI 串行, 总时长变成两个 job 之和。这是"省构建成本"换"反馈变慢"。镜像构建快的项目应该去掉它并行跑。
  6. action 版本要钉。astral-sh/setup-uv@v7 + version: "0.11.32"。不钉的后果是某天早上 CI 因为一个你没做的改动而红 —— 而这直接摧毁"红了就是我的问题"这个前提。

闸门设计

  1. 一个只会通过的闸门等于没有闸门。必须像 Checkpoint 6 那样, 用收紧阈值验证它真的会返回非零码。你从没见它红过, 就不知道它红了会长什么样。
  2. 闸门要一次报告所有失败, 不要遇到第一条就退出。否则你修了 MAE 才发现一致率也不过关, 白等一轮 CI。
  3. 失败信息必须带实际值和阈值。只打印"闸门未通过"的日志毫无用处 —— 看日志的人第一个问题永远是"差多少"。
  4. 闸门脚本和评测脚本分开写。评测的职责是如实报告数字, 闸门的职责是根据数字做决定。混在一起时你会在调阈值的过程中不小心改了统计逻辑。
  5. 但统计函数必须复用。from eval.eval_grader import pair_agreement —— 两处各写一遍必然漂移, 而"报告说 0.8 闸门说 0.7"这种事无从排查。
  6. 阈值做成命令行参数, 不要硬编码。阈值是策略, 会随项目演进调整; 逻辑不是。调阈值应该只改 ci.yml 一行。
  7. 拦人的阈值必须比自我要求宽。闸门用红线 (MAE > 2.5), 不用参考线 (MAE <= 1.5)。收太紧的结果是无关 PR 被误伤, 然后闸门被 override, 然后没人看了。
  8. 阈值精度不能高于指标分辨率。105 个样本对意味着一致率的最小变化是 1/105 = 0.0095, 写 >= 0.805 是在假装精确。
  9. raise SystemExit(main()) 是整个闸门机制的关键一行。CI 只认退出码, 打印得再漂亮都不会让它变红。

哪些指标能当门禁

  1. 判断标准是"在 CI 环境里可不可复现", 不是"重不重要"。检索质量当然比代码风格重要, 但它在 CI 里不可复现, 所以只能人工守。
  2. CI 里的 vector Recall@3 是 0.7333, 本机是 1.0000。因为 CI 装不了 sentence-transformers, 降级到了 HashEmbedder。拿这个数当门禁会让每一个 PR 都红, 包括完全没碰检索的 PR。
  3. bm25 在两个环境里三个指标完全相同 (0.9333 / 0.9333 / 0.9066), 因为它是纯词频统计。它是唯一在 CI 里可复现的检索路径 —— 真要给检索加门禁, 只能给它加。
  4. 假警报比没有 CI 更糟。一个会随机变红的 CI, 三周之后就没人看了, 而这时候你连"CI 是绿的"这个信号都失去了。
  5. 明确写下 CI 拦不住什么。"改语料后忘了重灌库"这个坑 CI 拦不住, 写在文档里比假装覆盖了安全得多 —— 因为前者会有人去做。

测试与端到端

  1. 单元测试之外必须跑一次真实启动。TestClient + fixture 绕过了真实 import 链和真实灌库。这一步曾抓到过"32 个测试全绿但 python -m app.cli 启动就崩"。
  2. 端到端跑测试时给一个隔离的 DB_PATH。DB_PATH=/tmp/ck12_db/app.db —— 测试会写库, 别让它污染你的真实数据。
  3. --auto 那个开关是 CI 能跑端到端的前提。第 7 章加它时看起来只是方便调试。给交互式程序留一个非交互入口, 是让它未来可测的最低成本投资。
  4. 端到端这一步要看的不是分数, 是"链路都活着": 灌库 25 块、选题成功、判分出现了 10.0 和 0.0 两个极端、报告聚合成功、建议能从 rubric 提取出来。
  5. 总耗时: 0 ms 不是 bug。离线判分是纯本地关键词匹配, 快到计时器测不出来。看到 0 先想清楚它是"没跑"还是"太快", 别急着当异常。

lint 配置

  1. 每一条 ignore 都必须写明理由。没有理由的 ignore 会不断增长, 最后 lint 形同虚设。
  2. B008 必须关, 因为 FastAPI 的 Depends() / Header() 就是设计成写在默认参数里的。这是规则和框架的冲突, 不是代码问题。
  3. E402 用 per-file-ignores 局部开放, 不要全局关。eval/、scripts/、finetune/、tests/ 需要先 sys.path.insert 再 import 本项目模块, 顺序不能调; 但 app/ 下没有这个理由。
  4. E501 (行长) 交给 formatter, lint 不重复报。两个工具都报同一件事就是噪声。
  5. 新写的脚本第一件事是过一遍 ruff check .。gate_grader.py 第一次就吃了个 I001 (import 未排序), ruff check --fix 一下就好 —— 但如果直接推上去, 你的 CI 会红在一件毫无信息量的事上。

镜像 job

  1. push: false —— CI 只验证能构建, 不推仓库。推镜像需要 registry 凭证, 而这一章的原则是 CI 里不放密钥。发版单独开一个由 tag 触发的 workflow。
  2. load: true 不写, 下一步 docker run 会报 image not found。不加载进本地 daemon, 镜像只存在于 buildx 缓存里。
  3. 多阶段构建的缓存必须 mode=max。默认只缓存最终层, 而装依赖那一层 (最贵的那层) 在 builder 阶段, 根本不进缓存。
  4. 轮询上限必须大于 start_period。第 11 章测出首次启动要灌库, start_period 是 90 秒; 这里 30 × 5 = 150 秒才够。上限设小了你是在容器没准备好的时候判它死。
  5. 健康检查要 grep '"status":"ok"', 不能只看 HTTP 200。/healthz 是健康报告不是断言, 降级运行时照样返回 200。
  6. 超时分支一定要先 docker logs 再 exit 1。容器会随 runner 一起消失, 那几行日志是你唯一的排查依据。
  7. if: failure() 那个额外的 docker logs 是双保险。如果 docker run 本身就失败了, 循环里的日志导出根本没机会执行。|| true 防止它自己再报一个次生错误盖住真因。

环境与依赖

  1. chromadb 在 CI 里必须装。app/rag/index.py 在 KnowledgeIndex.__init__ 里直接 import chromadb。第 11 章 §6.4 那个 ModuleNotFoundError 就是漏装它。
  2. 真正靠 fallback 跳过的是 sentence-transformers (它拖着 torch, 几百兆)。get_embedder() 捕获 ImportError 降级到 HashEmbedder, 并打印一行 [embedding] 真模型不可用(...)。这行日志出现在 CI 里是预期的, 不是警报。
  3. uv pip list | head -30 平时没用, 出问题时它是唯一能告诉你"CI 里到底是哪个版本"的地方。一行成本, 留着。
  4. 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 消失, 那是唯一的排查依据。


十一、划重点

  1. 配 CI 的起点是"列出你具体怕什么", 不是"抄一个 ci.yml"。抄来的 CI 只能拦住"语法错了", 而那类问题你本地跑一次就发现。
  2. 明确知道 CI 拦不住什么, 比以为它什么都能拦更安全。
  3. CI 的三个硬约束: 没有密钥、必须确定性、必须快。它们决定了什么能放进去。
  4. 假警报会训练团队忽略 CI, 这比没有 CI 更糟。
  5. OFFLINE=true 换来三件事: 少一个泄漏面、不花钱且不受第三方抖动影响、快。
  6. "CI 红了永远是自己代码的问题"是 CI 全部价值的地基。任何浮动的东西 (上游 API、未钉的 action 版本) 都在挖这个地基。
  7. OFFLINE=true 的代价是 LLM 判分质量和对话层评估测不了 —— 这一项明确由人工负责。
  8. on: push: branches: [main], 否则每个提交跑两遍。
  9. concurrency + cancel-in-progress 省掉一大半浪费。
  10. timeout-minutes 必写, 默认 6 小时能一次烧掉整月额度。
  11. workflow_dispatch 让调 CI 不用推空提交。
  12. 检查按耗时从短到长排 (fail fast), 但依赖关系优先于耗时。
  13. 每一条 lint ignore 都要写明理由, 否则 lint 迟早形同虚设。
  14. B008 关掉是因为 FastAPI 的 Depends() 就是这么设计的 —— 规则和框架冲突, 不是代码问题。
  15. 单元测试之外必须跑真实启动路径, 它抓到过"测试全绿但启动就崩"。
  16. --auto 这种非交互入口是端到端能进 CI 的前提。
  17. 端到端要确认的是"链路都活着", 不是分数。
  18. 最便宜的闸门是评测集自检。校验前置的价值不是防崩溃, 是防止你去修一个不存在的 bug。
  19. 本章最重要的判断: 一个指标能不能当门禁, 取决于它在 CI 环境里是否可复现, 而不是它有多重要。
  20. 实测: CI 环境 vector Recall@3 = 0.7333, 本机 = 1.0000, 差 0.2667 (四道题)。这不是"检索变差了", 是"用了一个不同的 embedder"。
  21. bm25 在两个环境里三个指标完全相同, 因为它不依赖任何模型 —— 它是唯一在 CI 里可复现的检索路径。
  22. 检索评测在 CI 里的定位是"只保证脚本不报错"。这仍有价值: 能抓住评测脚本被改坏、接口签名变了、索引结构不兼容。
  23. 判分器一致性可以当门禁, 因为 RuleGrader + 固定标注集 + 不碰网络 = 完全确定性。
  24. 闸门脚本和评测脚本分开写, 但统计函数必须复用, 否则两处会漂移。
  25. raise SystemExit(main()) 是整个闸门机制的关键一行 —— CI 只认退出码。
  26. 阈值做成命令行参数: 阈值是策略, 不是逻辑。
  27. 一个只会通过的闸门等于没有闸门。必须实测它在指标变差时返回 exit=1。
  28. 闸门要一次报告所有失败, 并带上实际值和阈值。
  29. 拦人的阈值必须比自我要求宽: 闸门用红线, 不用参考线。
  30. 阈值精度不能高于指标分辨率 (105 个样本对 → 一致率最小变化 0.0095)。
  31. 闸门的作用是拦住明显退化, 不是逼近最优。推进质量靠人看报告。
  32. 构建成功不等于能跑起来 —— 所以要起容器验一次。
  33. 健康检查要 grep '"status":"ok"', 不能只看 HTTP 200。
  34. 轮询上限必须大于 start_period。
  35. push: false —— CI 只验构建不推仓库, 因为推镜像需要凭证。
  36. 多阶段构建的缓存必须 mode=max, 否则最贵的那层不进缓存。
  37. 连 CI 工具的版本也要钉死 (uv 钉到 0.11.32), 这是"红了就是我的问题"的延伸。
  38. 实测数字汇总: ruff 全绿; pytest 32 passed in 10.47s; check_golden 0.05 秒通过 (15 题 / 7 篇); 灌库 25 块 / BM25 词表 904; gate_grader 0.02 秒, MAE 0.833 / 一致率 0.8000, 通过时 exit=0, 故意收紧后 exit=1。
  39. docker-build job 本机没跑过, 文档里明确标了。凡是没量过的, 就说没量过。

十二、下一章预告

CI 建起来了, 镜像能构建, 闸门会拦人。到这一步, 这个项目从零到上线的完整链路已经走完: 语料 → 检索 → 面试官 → 服务 → 评估 → 微调 → 容器 → 门禁。

但整个过程里真正花掉时间的不是这些正路, 是那些"看起来该能跑但就是不行"的时刻: SSE 在 nginx 后面不流了、LoRA 把格式修好了却把判分修坏了、fuse 之后模型行为变了、测试全绿而 CLI 启动就崩。

第 13 章讲踩坑手册与面试话术: 把这十二章里真实踩过的坑集中成一份可检索的清单 (每一条都带"症状 → 原因 → 怎么发现 → 怎么修"), 然后回答另一个问题 —— 怎么把这个项目讲成一个亮点, 而不是讲成一份功能清单。

后者比你想的重要: 面试官不关心你用了 RAG, 关心的是你在哪里做了取舍、凭什么数据做的、哪些地方你诚实地承认没做到。而这十二章里每一个"我们选 A 不选 B, 因为实测……", 都是一个可以讲三分钟的素材。

本页由 Markdown 自动渲染 · 共 628 篇笔记