全栈开发知识库

13 踩坑手册与面试话术: 把十二章的疤痕变成资产

一句话总结: 前面十二章按顺序讲"怎么做对", 这一章按症状重排 —— 你出问题时手上只有一个现象, 而不是一个章节号。后半章回答另一个问题: 同一个项目, 有人讲成"我做了个 RAG 面试机器人", 有人讲成一段让面试官追问二十分钟的对话, 差别不在项目, 在你有没有把取舍和数字讲出来。

本章你能学到什么

  1. 这个项目里 20 个真实踩过的坑, 每条都给"症状 → 原因 → 怎么发现 → 怎么修"
  2. 按症状查, 不按章节查 —— 出问题时你搜的是"不报错但结果不对", 不是"第 4 章"
  3. 一条贯穿全书的规律: 最贵的 bug 都不报错, 以及它们为什么长得都一样
  4. 三个高频误判模式: 把环境差异当代码退化、把 loss 下降当成功、把一题之差当优化生效
  5. 简历上这个项目该怎么写 —— 一个反面版本和一个正面版本的逐句对比
  6. 一分钟版和三分钟版的项目讲述稿, 以及为什么三分钟版必须从取舍讲起
  7. 一份必须背下来的数字清单 (13 个数, 每个都能引出一段追问)
  8. 面试官最可能问的 12 个追问, 以及回答里应该出现什么
  9. 怎么诚实地讲"没做到的部分" —— 这一段的加分幅度超过任何炫技
  10. 上线前和面试前两份检查清单

一、这份手册怎么用

1.1 按症状索引, 不按章节索引

前面十二章是按开发顺序排的, 那是学习的顺序。但出问题时你的处境是相反的: 手上只有一个现象, 不知道它属于哪一层。

所以这一章的 20 个坑按症状类型分成五组:

组 症状长什么样 有几个
A. 静默失效 不报错、日志正常, 但结果是错的 6
B. 环境差异 本地好的, 换个环境就不对 4
C. 状态与顺序 只在特定分支/特定时序下出现 4
D. 指标误读 数字变了, 但你把原因归错了 3
E. 崩溃类 直接报错, 但错误信息指向错误的地方 3

E 组只有 3 个, 这不是巧合。会报错的 bug 你半小时就修完了, 它们不值得写进手册。真正吃掉整天的是 A 和 B。

1.2 每条坑的四个字段

  • 症状 —— 你实际观察到的现象, 用你会说的话写
  • 原因 —— 机制层面到底发生了什么
  • 怎么发现 —— 这是最有价值的一栏。"运气好"不算方法, 这里写的是可复用的定位手段
  • 怎么修 —— 具体改法, 以及为什么不用另一种改法

二、A 组: 静默失效 (6 个)

坑 1: 两套向量空间, 检索结果是纯噪声

  • 症状: 检索能返回结果, 数量也对, 但返回的块和问题毫无关系。没有任何报错。
  • 原因: 建 Chroma collection 时漏了 embedding_function=None。Chroma 会用它自带的默认英文小模型算文档向量, 而你查询时用的是 bge-small-zh。两套向量在不同的空间里, 算出来的距离是随机数。
  • 怎么发现: 拿语料里的一句原文当 query 去检索。正常情况它必须排第一 —— 这是检索系统最低的自检标准。如果连原文都检索不出来, 问题一定在 embedding 一侧, 而不是在切分或排序。
  • 怎么修: client.get_or_create_collection(name, embedding_function=None, metadata={"hnsw:space": "cosine"})。向量必须全部由你自己算, 库只负责存和搜。

这是全书最贵的一个坑, 因为它把"检索质量差"伪装成了"模型不够好", 你会去换 embedding 模型、调 chunk_size、加精排, 全都无效。

坑 2: BM25 的 idf 全部算错

  • 症状: BM25 单路的 Recall 比预期低一截, 但完全不报错。混合检索因为有向量兜着, 掉得更不明显。
  • 原因: 统计文档频率时写成 df.update(t) 而不是 df.update(set(t))。一个词在一篇文档里出现十次, df 就加了 10。于是常见词的 idf 被压到接近 0, 罕见词反而权重虚高。
  • 怎么发现: 打印 df 里最大的几个值, 和总文档数比。df > 文档总数 是数学上不可能的 —— 文档频率的定义就是"包含它的文档数"。这个一致性检查两行就能写。
  • 怎么修: 加上 set()。更普适的做法是: 任何"频率"统计写完, 先断言它的取值范围。

坑 3: 查询侧和文档侧用了不同的编码方式

  • 症状: 检索结果"还行但不太对", 相似度整体比预期低几个点。
  • 原因: bge 系列模型的查询侧要求加指令前缀 (为这个句子生成表示以用于检索相关文章:), 文档侧不加。混用之后向量还在同一个空间里, 只是系统性偏移了。
  • 怎么发现: 这个坑的隐蔽度高于坑 1, 因为它不是完全失效, 只是变差。定位方法是把同一句话分别用 encode_query 和 encode_docs 编码, 算两个向量的余弦相似度 —— 如果显著小于 1, 说明两条路径确实不等价, 那就必须搞清楚哪一侧该用哪个。
  • 怎么修: encode_query / encode_docs 两个方法分开, 入库固定用后者, 检索固定用前者。接口上就不给人混用的机会。

坑 4: 模型改写了 rubric 一个字, 3 分变 0 分

  • 症状: 判分偏低。同一个知识点, 有的答案明明答对了却记成漏项。
  • 原因: 判分器让 LLM 返回"命中了哪几条要点", 然后用精确字符串匹配去 rubric 里查权重。模型把 "A 要点" 写成 "A要点" (少一个空格), 匹配不上, 那 3 分就丢了。
  • 怎么发现: 第 9 章的逐条对照表。总 MAE 是 0.833 看着不错, 但逐条表的下半张全是 0.0 vs 人工的 5~7 分 —— 误差集中在一侧就说明它是结构性的, 不是精度问题。
  • 怎么修: 我们刻意没有加固。三个候选修法 (返回下标 / 模糊匹配 / 归一化) 都会让"已经量过的 MAE 0.833"失效, 而现在的做法是在提示词里写死"原样抄回, 不要改写一个字"。一个已经量过的缺陷, 比一个没量过的加固更让人安心。

这条坑的处理方式本身就是面试素材: 发现缺陷之后选择不修, 并且能说清代价, 比闷头修掉更能体现判断力。

坑 5: 裁判看到了标准答案

  • 症状: LLM Judge 的评分看起来合理, 但它评的不是你想评的东西。完全不报错。
  • 原因: 渲染对话轨迹给裁判时, Turn.model_dump() 里的 question 是一个嵌套 dict, 直接 f"{t['question']}" 会把整个 dict 连 rubric 和标准答案一起塞进 prompt。裁判看到答案之后, 它评的就不再是"提问质量"了。
  • 怎么发现: 读裁判返回的 reason。如果理由里出现了标准答案里的措辞, 说明它看到了不该看的东西。这也是为什么裁判必须被要求输出理由 —— 理由不是给程序看的, 是给你核对它有没有在按你的维度评。
  • 怎么修: 写一个 _q_text 只取 question["text"]。原则: 送给模型的每一个字段都要是你主动选进去的, 不能是 model_dump() 顺带过来的。

坑 6: 内存里的活跃会话永久增长

  • 症状: 服务跑几天之后内存慢慢涨, 没有任何错误。
  • 原因: "开了面试然后关掉浏览器"的会话永远不会走到 finished, 所以永远不会被清理。
  • 怎么发现: 这个坑只能靠观察内存曲线或者主动想到它, 没有巧妙的定位方法。这也是为什么它值得写下来 —— 对于"只会随时间变坏"的问题, 唯一的防御是提前列在清单里。
  • 怎么修: evict_finished(keep=N) 加上上限兜底。诚实说明: 本项目里这个方法还没有自动调用, 是一个已知待办。面试里被问到"你的服务能跑多久", 这就是标准答案 —— 承认它是待办, 比说"没问题"可信。

三、B 组: 环境差异 (4 个)

坑 7: 本地流式正常, 上了 Nginx 变成一次性返回

  • 症状: 用户点了提交, 界面卡住十几秒, 然后所有内容一次性全部出现。本地开发从来没有这个问题。
  • 原因: Nginx 默认要攒够一块 (4KB) 才转发, 而 SSE 的本质是"每来一小段就要立刻推给前端"。
  • 怎么发现: 这是本书最难定位的坑, 因为它的复现条件极其苛刻:
环境 流式是否正常
uvicorn 直连 :8000 ✅ 正常
TestClient 单元测试 ✅ 正常 (它根本不走网络)
smoke_api.py 打 :8000 ✅ 正常
浏览器经 Nginx 访问 :80 ❌ 一次性返回

测试全绿, 冒烟全绿, 只有真实用户看得见。而症状 ("界面卡住") 会把你送到前端去查 —— 而前端是无辜的。 - 怎么修: 两处都做。Nginx 侧 proxy_buffering off; proxy_cache off; proxy_set_header Connection ""; proxy_http_version 1.1; proxy_read_timeout 300s;; 应用侧响应头加 X-Accel-Buffering: no。写在响应头里的好处是部署方不需要知道这件事。

附带一个更隐蔽的兄弟坑: 千万不要把 text/event-stream 放进 gzip_types。gzip 有自己的缓冲区, 它会悄悄抵消掉 proxy_buffering off, 而你会怀疑一切, 就是不会怀疑 gzip 那一行。

坑 8: CI 里检索指标暴跌, 但代码没问题

  • 症状: 本地 vector 模式 Recall@3 = 1.0000, CI 里同一份代码只有 0.7333。
  • 原因: CI 环境装不了 sentence-transformers (它拖着几百兆的 torch), get_embedder() 捕获 ImportError 降级到了 HashEmbedder —— 一个把词哈希成向量的假 embedder, 没有语义能力。
  • 怎么发现: 日志里有一行 [embedding] 真模型不可用(ImportError), 降级到 HashEmbedder。这行日志出现在 CI 里是预期的, 不是警报。关键的判别证据是: bm25 模式在两个环境里三个指标完全相同 (0.9333 / 0.9333 / 0.9066), 因为它是纯词频统计不依赖模型。一路变一路不变, 就说明差异来自模型而不是代码。
  • 怎么修: 不修, 而是改变这个指标在 CI 里的定位 —— 只保证脚本不报错, 不看数字。如果你把 Recall@3 >= 0.95 写成闸门, 每一个 PR 都会红, 包括完全没碰检索的 PR。

这条坑推出了第 12 章最重要的判断: 一个指标能不能当门禁, 取决于它在 CI 环境里是否可复现, 而不是它有多重要。

坑 9: 容器无限重启, 而日志看起来完全正常

  • 症状: docker compose ps 显示服务反复 restarting。docker logs 里每次都是干净的启动日志: "正在灌库…"。没有任何错误。
  • 原因: HEALTHCHECK 的 start_period 给得太短。首次启动要灌库要几十秒, 而健康检查在第 10 秒就开始判失败, 连续失败到阈值 → Docker 认为容器不健康 → 重启 → 又从灌库开始 → 又超时。这是一个完美的循环, 而每一圈的日志都是正常的。
  • 怎么发现: 看 docker inspect 里的 Health.Status 和重启计数, 而不是看日志。日志正常但容器在重启, 就一定要去查 healthcheck 而不是查应用。
  • 怎么修: start_period 设成实测启动时间的两倍 (本项目 90 秒)。

同一个位置还有两个连锁坑: - 镜像里没装 curl, 于是 healthcheck 命令永远失败, 容器状态卡在 starting 或直接 unhealthy, 而 compose 的 depends_on: service_healthy 会永久等待。 - healthcheck 只看 HTTP 200。/healthz 是健康报告不是断言, 知识库为空时它照样返回 200 (body 里是 degraded)。必须 grep '"status":"ok"'。

坑 10: 容器停止时留下 SQLite 的 -wal 残留

  • 症状: docker compose down 之后 data 目录里多出 app.db-wal、app.db-shm。偶尔下次启动读到不完整的数据。
  • 原因: entrypoint.sh 最后没写 exec "$@"。于是 PID 1 是 sh, 而 sh 不会把 SIGTERM 转发给它的子进程。Docker 等 10 秒没等到进程退出, 直接 SIGKILL —— uvicorn 没有机会执行任何清理。
  • 怎么发现: docker compose stop 的时候掐表。如果每次都恰好等 10 秒才停下, 那就是超时被 SIGKILL 了, 而不是优雅退出。优雅退出通常一秒内完成。
  • 怎么修: entrypoint 最后一行必须是 exec "$@"。exec 让 uvicorn 替换掉 shell 进程从而成为 PID 1, 直接收到信号。

四、C 组: 状态与顺序 (4 个)

坑 11: 答得越好, 面试越短

  • 症状: --questions 6 跑出来的面试只问了 3 道主问题就出报告了。而且答得好的时候更容易出现。
  • 原因: 用 len(st.turns) 当题号。追问也是一个 turn。答得好触发两次追问, 三道主问题就凑够 6 个 turn 了。
  • 怎么发现: --auto 模式跑一遍, 数输出里 [第 x/6 题] 出现的次数。给自己造一个可重复的自动模式, 是能做这种计数验证的前提。
  • 怎么修: 单独维护 main_count, 只在计分轮递增。

坑 12: 答得越好, 分数越低

  • 症状: 一道满分题, 均分显示 5.0。
  • 原因: 追问轮没有独立的 rubric, 判分必然 0 分, 而它被计入了均分。一道 10 分的题加一次追问 → (10 + 0) / 2 = 5.0。更糟的是符号搞反了: 答得越好触发的追问越多, 分数越低。
  • 怎么发现: 构造一个"应该拿满分"的输入, 看实际拿了多少。这是定位这一整类 bug 的通用方法 —— 比盯着代码看有效得多。
  • 怎么修: Turn 上加 scored: bool, 均分只对 scored=True 的轮次求平均。注意超时兜底产生的 GradeResult 也要正确设置这个字段。

坑 13: 数据库里的问答错位一行

  • 症状: 落库的问答记录, 问题和答案对不上, 错开一行。只在触发追问的时候出现。
  • 原因: turn_before 在循环内部取。而 fsm.submit() 会往 st.turns 追加新轮次, 所以循环一开始 st.current 就已经变了。
  • 怎么发现: 极难。它需要你对着数据库逐行核对, 而且只在追问分支出现。真正的防御是: 冒烟脚本要轮换好答案/含糊答案/白卷三档, 把追问、提示、白卷三条分支都走一遍 —— 只发好答案只证明 happy path 通, 而线上出问题的从来是分支。
  • 怎么修: 在循环之前取 turn_before。原则: 要写进日志/数据库的"改动前状态", 必须在改动发生之前就抓下来。

坑 14: 测试通过率取决于执行顺序

  • 症状: 单跑每个测试都过, 一起跑就有一个失败。换个顺序换一个失败。
  • 原因: 两个测试 client 用了同一个 test-key, 也就是同一个 user_key, 于是 usage_log 的配额计数会串。
  • 怎么发现: pytest 加 -p no:randomly 和随机顺序各跑一遍对比; 或者直接单跑失败的那个 —— 单跑过、一起跑不过, 100% 是共享状态。
  • 怎么修: 需要干净配额的测试自己先 DELETE FROM usage_log。共享状态的测试必须自己清理前置数据, 不能指望别人不脏。

同一层还有一个更早的坑: conftest.py 里设 DB_PATH / CHROMA_DIR 必须在模块顶层, 放 fixture 里就晚了 —— 因为 settings 是 import 时构造的。症状是跑一遍测试, 你的真实历史记录里多出十几场假面试。


五、D 组: 指标误读 (3 个)

坑 15: 把 loss 下降当成微调成功

  • 症状: MLX LoRA 训练日志显示 val loss 3.331 → 0.040。看起来非常成功。
  • 原因: 训练集只有 16 条、验证集 3 条。loss 低到 0.040 是异常低, 它的含义是"背下来了", 不是"学会了"。
  • 怎么发现: 拿测试集真的跑一遍前后对比, 同一个 prompt、temp=0.0、只换 adapter_path。实测结果是: 格式全修好了 (围栏 5/5 → 0/5, 原样抄回 rubric 0/5 → 3/5), 但正确性没修 —— gold 是 1 个命中 / surface, 微调后输出 hit_points: [] / depth: "blank"。
  • 怎么修: 三个真实原因都在动手前就能预判 —— 模型太小 (0.5B)、数据太少 (16 条, 而经验下限是 200)、训练目标是机器判的分。最后一条最要紧: RuleGrader 的实测系统性偏差是 -0.833 (偏严), 模型把这个偏严学会了并且放大了。

这条坑的结论值得单独记住: 微调不会创造能力, 它只会放大你数据里已有的倾向。

坑 16: 把一题之差当成优化生效

  • 症状: 改了个参数, MRR 从 0.9333 涨到 0.9667, 于是你写进文档"优化提升 3%"。
  • 原因: 评测集只有 15 题。0.0334 的差距不到一题。同理一致率是 105 个样本对, 最小变化单位 1/105 = 0.0095。
  • 怎么发现: 把分辨率算出来 print 在报告里, 而不是写在文档角落。半年后翻到这张表的人不会去读 README, 但他一定会看到输出里那行 1/15 = 0.0667。
  • 怎么修: 差异小于一题时唯一诚实的结论是"在这个数据集上分辨不出", 而不是"没用"也不是"有提升"。cross-encoder 精排就是这么被判定的 —— 实测 MRR 从 0.9667 掉到 0.9333, 所以 USE_RERANK 默认 false, 但文档里保留这张表并注明"差异不到一题"。

坑 17: fuse 之后模型行为变了

  • 症状: 用 adapter 挂载着评估的结果很好, mlx_lm fuse 合并成单个模型之后, 同样的输入输出不一样了 —— 而且是变差: 又开始把同一个字符串同时塞进 hit_points 和 missed_points。
  • 原因: 重量化。base 是 4bit 量化的, LoRA 权重是 16 位。合并时必须重新量化回 4bit, 而损失落在哪里是不可预测的。一个本来就在临界点上的模型 (0.5B + 16 条数据) 会被推过去。
  • 怎么发现: 同一个输入、temp=0.0、各跑两次。两次自身都一致 (所以不是采样随机性), 但两者互不相同 —— 这个交叉验证是区分"随机波动"和"模型真的变了"的唯一方法。
  • 怎么修: 两条。一是在 16 位 base 上 fuse 然后只量化一次, 而不是"往 4bit 上加再压回 4bit"。二是流程上: 实验期挂着 adapter 跑, 只在发版前 fuse, 并且 fuse 之后必须重新评估一遍。

推广出来的原则: 评估必须跑在你真正要部署的那个产物上。换了打包方式就等于换了被测对象。


六、E 组: 崩溃类, 但错误信息指向错误的地方 (3 个)

坑 18: No module named app.core, 而路径完全没问题

  • 症状: ModuleNotFoundError: No module named 'app.core'。你反复检查目录结构, 一切正确。
  • 原因: 漏了 __init__.py。
  • 怎么发现: 报错信息完全不提这件事, 它长得像路径问题, 于是你会去调 sys.path、改工作目录、试相对导入。
  • 怎么修: 建项目时一次性 touch 齐所有 __init__.py, 别边写边加。

同一族的兄弟: ModuleNotFoundError: No module named 'pydantic' —— 十次里九次是用了系统 Python 而不是 uv run。

坑 19: ModuleNotFoundError: No module named 'chromadb', 报在一个奇怪的位置

  • 症状: 启动服务时报错, 堆栈指向 app/rag/index.py:39, 然后 ERROR: Application startup failed. Exiting.
  • 原因: import chromadb 写在 KnowledgeIndex.__init__ 里面, 不在文件顶部。所以 import app.rag.index 本身不需要 chromadb, 但构造一个索引对象就需要。
  • 怎么发现: 这是本次写文档时真实撞上的 —— 用项目 .venv 起 uvicorn 崩了, 换一个装了完整依赖的环境就好。报错行号指向构造函数而不是文件顶部的 import 区, 这本身就是"延迟导入"的标志。
  • 怎么修: 环境里装上 chromadb。注意不要误以为"离线模式就不需要它" —— 离线模式换掉的是判分器和 embedder, 不是向量库。CI 里也必须装它, 真正靠 fallback 跳过的只有 sentence-transformers。

坑 20: 冒烟脚本报 401, 而 /healthz 是通的

  • 症状: httpx.HTTPStatusError: Client error '401 Unauthorized' for url '.../api/interview/start', 但同一个服务的 /healthz 返回正常。
  • 原因: 服务没配 API_KEYS, 或者脚本没带 --key。
  • 怎么发现: 一眼可见。但重点不在定位, 在于这个 401 说明鉴权设计是对的: /healthz 豁免 (否则监控探针要带凭证), 业务接口拦住。
  • 怎么修: 起服务时 API_KEYS=smoke-key, 脚本传 --key smoke-key。

这条坑列进来的理由是反的: 如果这里返回 200, 那才是真正的问题。冒烟脚本第一次跑就 401, 是个好消息。


七、一条贯穿全书的规律

把 A 组和 B 组连起来看, 十个坑的形状是同一个:

坑 静默失效的具体形式
向量空间不匹配 检索照常返回结果, 只是结果无意义
BM25 idf 算错 排序照常, 只是排得不对
查询/文档编码混用 相似度照常算出, 只是整体偏低
rubric 字符串不匹配 判分照常给分, 只是漏了权重
裁判看到标准答案 评测照常出数字, 只是评的不是那件事
CI 里降级 embedder 评测照常跑通, 只是数字换了含义
SSE 被缓冲 请求照常 200, 只是不流了
healthcheck 太严 日志照常干净, 只是在无限重启

规律: 这个系统的每一层都有一个"看起来正常"的降级状态。而降级不报错, 是因为它在代码层面是合法的 —— 向量还是向量, 分数还是分数, 200 还是 200。

这直接推出全书的方法论: 你需要的不是更小心, 是更多的"如果错了会立刻显形"的机制。这十二章里所有看起来"多余"的东西, 都是为了这一件事:

机制 让哪种静默失效显形
黄金评测集 + Recall/MRR/nDCG 检索层的一切静默降级
人工标注 + MAE / 顺序一致率 判分层的一切静默降级
逐条对照表 (不只看总分) 结构性误差 vs 精度误差
系统性偏差 (不只看 MAE) "要校准"还是"要重做"
分辨率打印 把一题之差误读成优化生效
双向位置交换 LLM Judge 的位置偏置
check_golden.py 标注文件自己写坏了
CLI 端到端 (在单元测试之外) 真实启动路径崩了
起容器验 /healthz 镜像构建成功但跑不起来
闸门必须验证它会红 闸门自己失效
凡是没量过的就说没量过 你自己记忆的静默失效

最后一行是根。前面十条都是工具, 最后一条是态度 —— 而工具是可以补的, 态度不行。


八、怎么把这个项目讲成亮点

8.1 先看一个反面版本

简历上大部分 AI 项目是这么写的:

智能面试助手 (个人项目) - 基于 LangChain + Chroma 实现 RAG 知识库问答, 支持文档切分与向量检索 - 使用大模型实现智能出题与自动评分, 提升面试效率 - 采用 FastAPI 提供接口, Docker 容器化部署, 配置 CI/CD 流水线 - 技术栈: Python / FastAPI / LangChain / Chroma / Docker / GitHub Actions

这段话的问题不是写得不好, 是它和另外三百份简历一模一样。逐句看:

原句 面试官读到的
"基于 LangChain + Chroma 实现 RAG" 你跟着教程做了一遍
"支持文档切分与向量检索" 这是 RAG 的定义, 不是你的工作
"提升面试效率" 没有数字的"提升"等于没说
"配置 CI/CD 流水线" 大概是抄了个 ci.yml 让 pytest 跑起来
技术栈罗列 这些词的信息量约等于零 —— 每份简历都有

最致命的是: 这段话里没有一个数字, 也没有一个取舍。面试官无法从里面找到任何可以往下问的东西, 于是这个项目在面试里只会占用 90 秒。

8.2 正面版本

Android 模拟面试 Agent (个人项目, 从零到上线) - 自建知识库 + 混合检索: 7 篇结构化语料切 25 块, BM25 与向量双路召回后 RRF 融合。在自建的 15 题黄金评测集上, Recall@3 从单路 BM25 的 0.9333 提到混合的 1.0000, MRR 0.9333 → 0.9667 (指标定义: Recall@K / MRR / nDCG@K) - 判分与模型解耦: LLM 只负责"命中了哪几条要点"这种离散抽取, 分数由代码按 rubric 权重计算。因此同一份答案两次判分完全同分, 报告里每个数字都能追到出处 - 三层评估体系: 检索层用黄金集量指标; 判分层用 15 条人工标注量与人的一致性 (规则判分器 MAE 0.833、顺序一致率 0.8、系统性偏差 -0.833); 对话层用 LLM Judge 做双向位置交换消偏, 不一致即记平 - 可复现性优先的 CI: GitHub Actions 全程离线跑 (规则判分 + Hash embedder), 32 个用例 10.5 秒。判分器一致性做成退出码闸门挂在 PR 上 (MAE <= 2.5、一致率 >= 0.7); 检索指标刻意不做闸门 —— CI 装不了真 embedding 模型, 同一份代码 vector Recall@3 会从 1.0 掉到 0.7333, 拿它当门禁会误伤每个 PR - 上线: 多阶段构建镜像 (模型在构建期下载、HF_HUB_OFFLINE=1 锁死)、Nginx 为 SSE 关缓冲、容器起来后轮询 /healthz 校验 "status":"ok" 而非只看 200 - 微调实验并给出否定结论: Mac 上用 MLX 跑 LoRA (可训练参数 0.297%), 修好了输出格式 (代码围栏 5/5 → 0/5), 但没有修好判分正确性。原因是训练目标来自机器判的分, 而规则判分器本身偏严 0.833 分 —— 微调放大了这个偏差。结论是不上线该模型 - 技术栈: Python 3.12 / FastAPI + SSE / Chroma / bge-small-zh / SQLite / MLX LoRA / Docker Compose + Nginx / GitHub Actions

8.3 两个版本差在哪

不是差在字数, 是差在每一句都能被追问。

正面版本里的一句 面试官可以追问什么 而你有答案
"Recall@3 从 0.9333 到 1.0000" 15 题够吗? 不够做技术选型, 够做回归。0.0667 就是一题
"分数由代码按权重算" 为什么不让模型直接打分? 实测同一答案两次差 2 分, 模型对连续数值不稳定
"MAE 0.833" 怎么标的? 五档覆盖 + 埋了同义改写陷阱题, 先标完再跑机器
"系统性偏差 -0.833" 这个数有什么用? 区分"要校准"还是"要重做"。同样的 MAE, 全体偏低改阈值就行
"双向位置交换" 位置偏置有多大? 效应量能到 10~20 个点, 而你想测的效果可能只有 5 个点
"检索指标刻意不做闸门" 那检索质量谁保证? 人工用真模型跑, 数字贴 PR。而且 bm25 是唯一 CI 可复现的路径
"微调没修好正确性" 那你为什么还写上来? 因为它是三个能提前预判的原因导致的, 而且给出了否定结论

最后一行是整份简历里最反直觉、也最值钱的一句。大多数人不敢写失败的实验。但一个能说清"我做了实验、拿到否定结果、分析出三个原因、因此决定不上线"的候选人, 展示的是工程判断力; 而只写成功项的候选人, 展示的是运气。

8.4 一分钟版

用在自我介绍或者"简单介绍下你的项目"这种开场:

我做了一个 Android 模拟面试 Agent, 从建语料一直做到上线和 CI。

它和"套个提示词让模型扮演面试官"最大的区别是每一层都有数字: 检索有黄金评测集 (Recall@3 从 0.9333 到 1.0000), 判分有人工标注对照 (MAE 0.833、顺序一致率 0.8), 对话层有 LLM Judge 做双向消偏。

另一个特点是分数不是模型给的 —— 模型只负责判断"命中了哪几条要点"这种离散抽取, 分数由代码按 rubric 权重算。所以同一份答案两次判分必定同分, 报告里每个数字都能追到出处。

最后判分器一致性做成了 CI 闸门, 指标退化到红线就不许合并。

四句话, 每句都埋了一个钩子: 数字、解耦、可复现、门禁。面试官接哪句都行。

8.5 三分钟版必须从取舍讲起

一分钟版讲"是什么", 三分钟版讲"为什么这么选"。如果三分钟版还在讲功能, 你就浪费了这三分钟。

推荐这个结构:

第一段 (30 秒): 我怕什么。

这个项目真正想解决的问题不是"能不能出题", 是"我怎么知道它变好了还是变差了"。所以整个开发顺序是刻意反常的: 先写评测集, 再写切分。因为"我把 chunk_size 从 300 改成 500, 感觉好了一些"这句话里的"感觉", 就是这个项目要消灭的东西。

第二段 (60 秒): 三个具体取舍。

第一个取舍是判分不让模型给分数。模型做分类和抽取, 代码做算术 —— 因为实测同一份答案让模型直接打分, 两次能差 2 分, 那我后面所有对照实验都在比较噪声。

第二个是报告不让模型生成。分数、薄弱项、漏掉的要点都是已经算出来的确定数据, 让模型改写一遍只会引入不一致。而报告是用户唯一会认真读的输出 —— 越靠近用户的环节, 越应该用确定性代码。

第三个是给自己留了一个离线模式。多写一个 80 行的规则判分器, 换来的是 CI 能跑、断网能演示、新人 clone 下来五分钟看到效果、写文档能贴真实输出。这一条的收益远超预期。

第三段 (60 秒): 一个具体的坑, 讲全过程。

选 SSE 那个 (见 8.6 的模板), 或者向量空间不匹配那个。要讲的不是"我修好了", 是"我怎么定位的"。

第四段 (30 秒): 没做到的部分。

有几个地方我知道没做到。会话存在进程内存里, 所以只能单 worker, 要扩就得先把会话搬到 Redis; evict_finished 写了但还没接自动调用, 是个已知待办; 微调实验拿到的是否定结果, 那个模型没有上线; 还有一个 CI 拦不住的坑 —— 改了语料忘记重灌库, 只能写在上线清单里靠人。

这一段是整个三分钟里最加分的部分。它传达的信息是: 这个人清楚自己系统的边界。而"边界感"是区分工程师和会写代码的人的分界线。

8.6 讲坑的模板

面试官问"你遇到过最难查的 bug 是什么", 这是最高价值的问题, 因为它的答案最难编。用这个四段式:

1. 症状 (要具体到让人能想象出画面)

用户点提交之后界面卡住十几秒, 然后所有内容一次性全部出现。而本地开发从来没有这个问题。

2. 为什么难查 (这一段决定了这个故事的分量)

难在它的复现条件非常苛刻: uvicorn 直连正常、单元测试正常 (TestClient 根本不走网络)、我自己写的冒烟脚本打 8000 端口也正常 —— 只有浏览器经 Nginx 访问的时候才出现。

也就是说测试全绿、冒烟全绿, 只有真实用户看得见。而症状 "界面卡住" 会把人送到前端去查, 前端是无辜的。

3. 怎么定位的 (要给出可复用的方法, 不能是"我猜的")

我的做法是把四个环境列成一张表, 逐个确认哪些通哪些不通。表列出来之后差异一眼可见: 唯一的变量是"有没有经过 Nginx"。定位到组件之后再查它的默认行为, 就发现 Nginx 默认要攒够 4KB 才转发, 而 SSE 的前提恰恰是"每来一小段就立刻推"。

4. 怎么修的, 以及修完之后学到了什么

修法是两处都做: Nginx 侧 proxy_buffering off 加上另外四行, 应用侧响应头加 X-Accel-Buffering: no —— 后者的好处是部署方不需要知道这件事。

还发现一个兄弟坑: text/event-stream 绝对不能进 gzip_types, 因为 gzip 有自己的缓冲区, 它会悄悄抵消掉 proxy_buffering off。

学到的是: 测试环境和生产环境之间少了一层组件, 你的测试就少一整类覆盖。这类 bug 不是靠更小心避免的, 是靠让测试路径和生产路径长得一样。

注意第 3 段。"我列了一张表逐个排除"是方法, "我搜了半天发现是 Nginx"是运气。面试官在这个问题上想听的就是前者。


九、必须背下来的 13 个数字

面试里说不出数字, 前面所有取舍都变成了观点。这些数都在前面章节的 Checkpoint 里, 全部是真跑出来的:

# 数字 出处 它能引出什么追问
1 7 篇语料 → 25 块, 块长 55~291 token, 平均 69.2 第 4 章 为什么不定长切? 块长不均匀是语义边界的正常结果, 整齐不是质量指标
2 BM25 词表 904 第 4 章 语料规模的直观量级
3 Recall@3: bm25 0.9333 / vector 1.0000 / hybrid 1.0000 第 6 章 三路对照, 消融表
4 MRR: bm25 0.9333 / vector 0.9556 / hybrid 0.9667 第 6 章 hybrid 赢在排序而不是召回
5 精排后 MRR 从 0.9667 掉到 0.9333 第 6 章 为什么 USE_RERANK 默认 false, 以及"差异不到一题"这个限定
6 1/15 = 0.0667 (检索指标分辨率) 第 6 章 什么时候该说"分辨不出"
7 判分器 MAE 0.833, 15 条标注 第 9 章 怎么标的? 五档覆盖 + 陷阱题 + 先标后跑
8 顺序一致率 0.8, 105 个样本对 第 9 章 分辨率 1/105 = 0.0095
9 系统性偏差 -0.833 (偏严) 第 9 章 区分"要校准"和"要重做"
10 32 个测试 10.47 秒 第 12 章 17 FSM + 15 API; 为什么这么快 (离线)
11 CI 里 vector Recall@3 = 0.7333, 本地 1.0000 第 12 章 为什么检索不能当闸门
12 LoRA 可训练参数 0.297% (1.466M / 494.033M) 第 10 章 这一个数就是 LoRA 的定义
13 微调后代码围栏 5/5 → 0/5, 原样抄回 rubric 0/5 → 3/5, 但正确性未改善 第 10 章 修了格式没修对错, 三个原因

用法上有一条纪律: 数字必须带上它的样本量和分辨率。"Recall@3 达到 1.0" 是吹牛, "15 题上 Recall@3 = 1.0, 而 1/15 = 0.0667 意味着这个评测集分辨不出小于一题的差异" 是工程。后者听起来更保守, 但可信度是前者的十倍。


十、面试视角: 12 个高频追问

Q1: 为什么不直接用 GPT 扮演面试官?

能跑, 但有三个问题我在意。题目是模型现编的, 每次不一样而且可能编错; 分数是模型说 7 分就 7 分, 同一份答案两次能差 2 分; 追问几层、什么时候降难度全靠模型自觉, 没法调。

我的做法是把这三件事都拿回代码里: 题目来自自建知识库 + 黄金题库, 答案要点有出处; 模型只判"命中了哪几条要点", 分数按 rubric 权重算; 追问深度和难度自适应是一个显式状态机。模型只做它擅长的语义判断, 其余交给确定性代码。

Q2: 为什么用混合检索而不是只用向量?

因为量出来 hybrid 更好, 而且赢的位置很具体: Recall@3 上 vector 已经是 1.0000, hybrid 也是 1.0000 —— hybrid 赢在 MRR (0.9556 → 0.9667), 也就是赢在排序而不是召回。

还有一个 CI 之后才发现的额外收益: bm25 是纯词频统计不依赖模型, 所以它是唯一在 CI 环境里可复现的检索路径。

Q3: RRF 为什么不用加权求和?

RRF 只用排名不用分数。BM25 的分数和余弦相似度在不同量纲上, 加权求和之前必须归一化, 而归一化方式的选择会实质影响结果 —— 那就多了一个需要调、而且我没有数据支撑怎么调的参数。RRF 只有一个 k, 而且对它不敏感。

Q4: 15 道题的评测集是不是太少了?

是少, 所以我明确区分它的两个用途。做回归测试够: 我改坏了检索代码, 指标立刻会掉。做技术选型不够: 1/15 = 0.0667, 任何小于这个的差异都是一题之差, 唯一诚实的结论是"分辨不出"。

我们把这个 1/n 直接 print 在评测报告里而不是写在文档角落 —— 因为半年后翻到那张表的人不会去读 README。

Q5: 判分器的一致性怎么量的?

15 条人工标注, 四个指标。MAE 0.833 看整体误差, 系统性偏差 -0.833 看是否单向偏移, 误差 ≤ 2 分占比 0.8667 看分布, 顺序一致率 0.8 看排序能力。

标注有三条纪律: 五档都要覆盖 (只有满分和零分的话, 判分器只要会区分"有没有内容"就能拿高分); 要埋陷阱题 (我埋了一道同义改写的, 直接产出一条 -5.0 的误差, 暴露了字符串精确匹配的缺陷); 必须先标完再跑机器, 否则锚定效应让一致率虚高。

Q6: MAE 0.833 算好还是不好?

单看这个数不错, 但只看它会漏掉最重要的信息。逐条对照表显示下半张全是 0.0 对人工的 5~7 分 —— 误差是结构性的, 不是精度问题。根因是 rubric 要点用精确字符串匹配, 模型改写一个字整条权重就丢了。

只打印总分的评测脚本无法指导任何修改动作, 这是我在这个项目里学到最有用的一件事。

Q7: 那个字符串匹配的缺陷为什么不修?

三个候选修法 (让模型返回下标 / 模糊匹配 / 文本归一化) 都会让"已经量过的 MAE 0.833"失效, 而这个数字是我后面所有对照实验的基准。现在的处理是提示词里写死"原样抄回, 不要改写一个字", 并且把这个缺陷显式写进文档。

一个已经量过的缺陷, 比一个没量过的加固更让人安心。要修的话正确顺序是: 先扩标注集, 再改, 再重新量一遍基准。

Q8: LLM Judge 怎么保证公平?

三个去偏。双向位置交换是核心: 同一对轨迹跑两次, 第二次交换 A/B 位置, 两次结论不一致就记平局 —— 因为不一致恰恰说明这一票纯由位置决定, 不含优劣信息。位置偏置的效应量能有 10~20 个百分点, 而我想测的效果可能只有 5 个点。

第二, 裁判换一家的模型, 避免自我偏好。第三, 统一轨迹渲染长度上限, 并且把平均字数打在 Win Rate 前面 —— 自动化消不掉的偏置, 至少要让它可见。

Q9: CI 里跑什么? 为什么不调真模型?

全程 OFFLINE=true。三个理由: 不用往 Secrets 放 API Key; 不花钱且不受第三方抖动影响, 所以 CI 红了永远是自己代码的问题; 快, 32 个用例 10.5 秒。

代价是 LLM 判分质量和整个对话层测不了。我的处理是明确写下这一项由人工负责: 改了 prompt 或换模型之后手动跑评测脚本, 数字贴 PR 描述。写清楚比假装覆盖了安全 —— 因为前者会有人去做。

Q10: 什么指标适合做 CI 闸门?

判断标准是在 CI 环境里是否可复现, 而不是它有多重要。

我项目里有个正反对照。检索不能: CI 装不了 sentence-transformers, 降级到 Hash embedder 之后 vector Recall@3 从 1.0 掉到 0.7333, 拿 0.95 当阈值每个 PR 都会红, 包括完全没碰检索的 PR。判分器一致性可以: 规则判分器纯确定性, 标注集固定, 不碰网络, CI 里跑一万次都是同一个数。

而且阈值我用的是红线 (MAE <= 2.5) 不是参考线 (<= 1.5), 因为拦人的标准必须比自我要求宽 —— 收太紧的结果是无关 PR 被误伤、被 override、然后没人看闸门了。

Q11: 微调那部分, 你自己怎么评价?

结论是否定的, 而且三个原因都能提前预判。模型太小 (0.5B)、数据太少 (16 条, 经验下限是 200)、最关键的是训练目标是机器判的分 —— 规则判分器实测偏严 0.833 分, 模型把这个偏严学会了还放大了, 把该算命中的判成漏项。

它确实修好了格式: 代码围栏从 5/5 降到 0/5, 原样抄回 rubric 从 0/5 到 3/5。但正确性没修。

过程里还有一个意外发现: mlx_lm fuse 合并 adapter 不是无损的。base 是 4bit, LoRA 权重是 16 位, 合并要重新量化回 4bit, 我实测同一输入 temp=0.0 各跑两次, 各自一致但两者不同 —— fused 版本在字段互斥性上退化了。所以评估必须跑在你真正要部署的那个产物上。

Q12: 这个项目有什么没做好的?

会话存在进程内存里, 所以只能 --workers 1, 多 worker 会让大约一半请求随机报"会话不存在"。要扩容的正确顺序是先把会话搬到 Redis, 再加 worker —— 同样的理由也让 K8s 副本数只能是 1。

evict_finished 写了但还没接自动调用, 所以"开了面试就关浏览器"的会话会一直留在内存里, 这是已知待办。

微调模型没有上线。Nginx 还没配 HTTPS。还有一个 CI 拦不住的坑: 改了语料忘记重灌库 —— 而重灌是全量重建, 会改变 chunk_id, 用户错题本里的引用会指向不存在的块。这一条只能写在上线清单里靠人。


十一、两份清单

11.1 上线前 (10 项)

  1. .env 权限 chmod 600, 且不在 git 里
  2. API_KEYS 换成真的随机串, 不是 test-key
  3. OFFLINE=false 且 LLM API Key 有余额
  4. 数据卷两个都挂 (/data 下的 SQLite 和 Chroma) —— 漏挂一个等于数据只丢了一半
  5. docker compose config --quiet 退出码为 0
  6. docker compose exec nginx nginx -t 通过
  7. 容器起来后 /healthz 返回的是 "status":"ok" 而不是 degraded, 且 chunks 不为 0
  8. 浏览器经 Nginx 真跑一场面试, 确认答案是逐段出现而不是一次性出现
  9. docker compose stop 一秒内停下 (不是等 10 秒被 SIGKILL)
  10. 改过语料的话, 手动跑一次 python -m app.rag.index /app/corpus

11.2 面试前 (8 项)

  1. 13 个数字能不看稿说出来, 且每个都带样本量
  2. 一分钟版能说完整, 四个钩子都在
  3. 三分钟版从"我怕什么"开始, 不从功能开始
  4. SSE 那个坑能按四段式讲完, 第 3 段是方法不是运气
  5. 准备好回答"15 题够吗" —— 答案是"回归够, 选型不够"
  6. 准备好回答"微调为什么失败" —— 三个原因加一个 fuse 的意外发现
  7. 准备好回答"有什么没做好" —— 至少说出单 worker、evict 待办、语料重灌三件事
  8. 本地能真的把服务跑起来演示一遍 (离线模式也行, 断网也能演)

十二、划重点

  1. 最贵的 bug 都不报错。这个系统每一层都有一个"看起来正常"的降级状态 —— 向量还是向量, 分数还是分数, 200 还是 200。
  2. 所以你需要的不是更小心, 是更多"如果错了会立刻显形"的机制。评测集、逐条对照表、系统性偏差、分辨率打印、双向交换、退出码闸门, 全都是为了这一件事。
  3. embedding_function=None 是全书最贵的一行。漏了它, Chroma 用它的默认模型算文档向量, 你用 bge 算查询向量, 检索结果是纯噪声而且不报错。
  4. 检索系统的最低自检: 拿语料原文当 query, 它必须排第一。
  5. 任何"频率"统计写完先断言取值范围。df > 文档总数 是数学上不可能的, 两行代码就能抓住 BM25 那个 idf bug。
  6. 一路变一路不变, 说明差异来自模型而不是代码。CI 里 bm25 三个指标完全没变、vector 掉 0.2667, 这个对照直接排除了"代码退化"。
  7. 构造一个"应该拿满分"的输入, 看它实际拿了多少。这是定位"答得越好分数越低"这类 bug 的通用方法。
  8. 要写进数据库的"改动前状态", 必须在改动发生之前抓下来。
  9. 单跑过、一起跑不过, 100% 是共享状态。
  10. conftest.py 里设环境变量必须在模块顶层, 放 fixture 里就晚了 —— settings 是 import 时构造的。
  11. 测试路径和生产路径少一层组件, 就少一整类覆盖。SSE 那个坑在四个环境里三个正常, 只有真实用户看得见。
  12. 日志正常但容器在重启, 一定去查 healthcheck 而不是查应用。
  13. docker compose stop 恰好等 10 秒才停, 就是被 SIGKILL 了。优雅退出一秒内完成, 差别是 entrypoint 里那一行 exec "$@"。
  14. loss 下降永远不能当成微调成功的证据。16 条数据上 val loss 掉到 0.040 是背下来了, 不是学会了。
  15. 微调不会创造能力, 它只会放大你数据里已有的倾向。训练目标是偏严 0.833 分的机器判分, 模型就学会了偏严。
  16. fuse 不是无损的, 因为要重新量化。评估必须跑在你真正要部署的那个产物上。
  17. 同一输入各跑两次, 是区分"随机波动"和"模型真的变了"的唯一方法。
  18. 差异小于一题时, 唯一诚实的结论是"分辨不出" —— 不是"没用", 也不是"有提升"。
  19. 一个已经量过的缺陷, 比一个没量过的加固更让人安心。
  20. 简历上的项目要写数字和取舍, 不要写技术栈。技术栈罗列的信息量约等于零, 因为每份简历都有。
  21. 每一句都要能被追问, 而你要有答案。这是好的项目描述和平庸的项目描述唯一的区别。
  22. 敢写失败的实验。"我做了实验、拿到否定结果、分析出三个原因、决定不上线"展示的是判断力; 只写成功项展示的是运气。
  23. 三分钟版从"我怕什么"讲起, 不从功能讲起。如果三分钟还在讲功能, 这三分钟就浪费了。
  24. 讲坑时, 第三段"怎么定位的"决定了故事的分量。"我列了一张表逐个排除"是方法, "我搜了半天发现是 Nginx"是运气。
  25. 数字必须带样本量和分辨率。听起来更保守, 可信度是十倍。
  26. 主动讲边界。单 worker、evict 待办、微调没上线、语料重灌靠人 —— 边界感是工程师和会写代码的人的分界线。
  27. 越靠近用户的环节, 越应该用确定性代码。报告是用户唯一会认真读的输出, 所以它一个字都不让模型生成。
  28. 模型做分类和抽取, 代码做算术。
  29. 给自己留一个离线模式。多写 100 行规则判分器, 换来 CI 能跑、断网能演示、新人五分钟看到效果、写文档能贴真实输出。
  30. 凡是没量过的, 就说没量过; 量出来不好看的, 也照原样贴。前面 29 条都是工具, 这一条是根 —— 工具可以补, 态度不行。

十三、写在最后

十三章走完, 这个项目的规模其实不大: app/ 下不到 2700 行 Python, 7 篇语料切成 25 块, 15 道评测题, 32 个测试。

但它和一个"跟着教程做完的 RAG demo"的差别, 不在这些行数里。差别在于:

  • 每一个技术选择都有一个"因为实测……"跟在后面, 包括那些实测结果是否定的 (精排变差了、微调没修好正确性、检索指标不能当闸门)
  • 每一层都有一个能回答"我变好了还是变差了"的数字, 而每个数字都标了它的样本量和分辨率
  • 每一个已知的缺陷都写下来了, 而不是等着别人发现
  • 最贵的十个坑都留下了"怎么发现的"记录, 而不只是"怎么修的"

这四条加起来, 就是"做过一个项目"和"做过一个可以拿出去讲的项目"的全部区别。

如果你照着这十三章真的做了一遍, 你手上会有一个能跑的服务、一份能贴到简历上的数字清单、和一堆真的自己撞过的坑。其中第三样最值钱 —— 因为前两样别人能抄, 第三样不能。

最后重复第 6 章那句话, 它值得作为整套文档的结尾:

学会区分"我知道"、"我以为"和"我量了但样本不够", 比多学一个框架值钱。

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