13 踩坑手册与面试话术: 把十二章的疤痕变成资产
一句话总结: 前面十二章按顺序讲"怎么做对", 这一章按症状重排 —— 你出问题时手上只有一个现象, 而不是一个章节号。后半章回答另一个问题: 同一个项目, 有人讲成"我做了个 RAG 面试机器人", 有人讲成一段让面试官追问二十分钟的对话, 差别不在项目, 在你有没有把取舍和数字讲出来。
本章你能学到什么
- 这个项目里 20 个真实踩过的坑, 每条都给"症状 → 原因 → 怎么发现 → 怎么修"
- 按症状查, 不按章节查 —— 出问题时你搜的是"不报错但结果不对", 不是"第 4 章"
- 一条贯穿全书的规律: 最贵的 bug 都不报错, 以及它们为什么长得都一样
- 三个高频误判模式: 把环境差异当代码退化、把 loss 下降当成功、把一题之差当优化生效
- 简历上这个项目该怎么写 —— 一个反面版本和一个正面版本的逐句对比
- 一分钟版和三分钟版的项目讲述稿, 以及为什么三分钟版必须从取舍讲起
- 一份必须背下来的数字清单 (13 个数, 每个都能引出一段追问)
- 面试官最可能问的 12 个追问, 以及回答里应该出现什么
- 怎么诚实地讲"没做到的部分" —— 这一段的加分幅度超过任何炫技
- 上线前和面试前两份检查清单
一、这份手册怎么用
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.0vs 人工的 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 项)
.env权限chmod 600, 且不在 git 里API_KEYS换成真的随机串, 不是test-keyOFFLINE=false且 LLM API Key 有余额- 数据卷两个都挂 (
/data下的 SQLite 和 Chroma) —— 漏挂一个等于数据只丢了一半 docker compose config --quiet退出码为 0docker compose exec nginx nginx -t通过- 容器起来后
/healthz返回的是"status":"ok"而不是degraded, 且chunks不为 0 - 浏览器经 Nginx 真跑一场面试, 确认答案是逐段出现而不是一次性出现
docker compose stop一秒内停下 (不是等 10 秒被 SIGKILL)- 改过语料的话, 手动跑一次
python -m app.rag.index /app/corpus
11.2 面试前 (8 项)
- 13 个数字能不看稿说出来, 且每个都带样本量
- 一分钟版能说完整, 四个钩子都在
- 三分钟版从"我怕什么"开始, 不从功能开始
- SSE 那个坑能按四段式讲完, 第 3 段是方法不是运气
- 准备好回答"15 题够吗" —— 答案是"回归够, 选型不够"
- 准备好回答"微调为什么失败" —— 三个原因加一个 fuse 的意外发现
- 准备好回答"有什么没做好" —— 至少说出单 worker、evict 待办、语料重灌三件事
- 本地能真的把服务跑起来演示一遍 (离线模式也行, 断网也能演)
十二、划重点
- 最贵的 bug 都不报错。这个系统每一层都有一个"看起来正常"的降级状态 —— 向量还是向量, 分数还是分数, 200 还是 200。
- 所以你需要的不是更小心, 是更多"如果错了会立刻显形"的机制。评测集、逐条对照表、系统性偏差、分辨率打印、双向交换、退出码闸门, 全都是为了这一件事。
embedding_function=None是全书最贵的一行。漏了它, Chroma 用它的默认模型算文档向量, 你用 bge 算查询向量, 检索结果是纯噪声而且不报错。- 检索系统的最低自检: 拿语料原文当 query, 它必须排第一。
- 任何"频率"统计写完先断言取值范围。
df > 文档总数是数学上不可能的, 两行代码就能抓住 BM25 那个 idf bug。 - 一路变一路不变, 说明差异来自模型而不是代码。CI 里 bm25 三个指标完全没变、vector 掉 0.2667, 这个对照直接排除了"代码退化"。
- 构造一个"应该拿满分"的输入, 看它实际拿了多少。这是定位"答得越好分数越低"这类 bug 的通用方法。
- 要写进数据库的"改动前状态", 必须在改动发生之前抓下来。
- 单跑过、一起跑不过, 100% 是共享状态。
conftest.py里设环境变量必须在模块顶层, 放 fixture 里就晚了 ——settings是 import 时构造的。- 测试路径和生产路径少一层组件, 就少一整类覆盖。SSE 那个坑在四个环境里三个正常, 只有真实用户看得见。
- 日志正常但容器在重启, 一定去查 healthcheck 而不是查应用。
docker compose stop恰好等 10 秒才停, 就是被 SIGKILL 了。优雅退出一秒内完成, 差别是 entrypoint 里那一行exec "$@"。- loss 下降永远不能当成微调成功的证据。16 条数据上 val loss 掉到 0.040 是背下来了, 不是学会了。
- 微调不会创造能力, 它只会放大你数据里已有的倾向。训练目标是偏严 0.833 分的机器判分, 模型就学会了偏严。
fuse不是无损的, 因为要重新量化。评估必须跑在你真正要部署的那个产物上。- 同一输入各跑两次, 是区分"随机波动"和"模型真的变了"的唯一方法。
- 差异小于一题时, 唯一诚实的结论是"分辨不出" —— 不是"没用", 也不是"有提升"。
- 一个已经量过的缺陷, 比一个没量过的加固更让人安心。
- 简历上的项目要写数字和取舍, 不要写技术栈。技术栈罗列的信息量约等于零, 因为每份简历都有。
- 每一句都要能被追问, 而你要有答案。这是好的项目描述和平庸的项目描述唯一的区别。
- 敢写失败的实验。"我做了实验、拿到否定结果、分析出三个原因、决定不上线"展示的是判断力; 只写成功项展示的是运气。
- 三分钟版从"我怕什么"讲起, 不从功能讲起。如果三分钟还在讲功能, 这三分钟就浪费了。
- 讲坑时, 第三段"怎么定位的"决定了故事的分量。"我列了一张表逐个排除"是方法, "我搜了半天发现是 Nginx"是运气。
- 数字必须带样本量和分辨率。听起来更保守, 可信度是十倍。
- 主动讲边界。单 worker、evict 待办、微调没上线、语料重灌靠人 —— 边界感是工程师和会写代码的人的分界线。
- 越靠近用户的环节, 越应该用确定性代码。报告是用户唯一会认真读的输出, 所以它一个字都不让模型生成。
- 模型做分类和抽取, 代码做算术。
- 给自己留一个离线模式。多写 100 行规则判分器, 换来 CI 能跑、断网能演示、新人五分钟看到效果、写文档能贴真实输出。
- 凡是没量过的, 就说没量过; 量出来不好看的, 也照原样贴。前面 29 条都是工具, 这一条是根 —— 工具可以补, 态度不行。
十三、写在最后
十三章走完, 这个项目的规模其实不大: app/ 下不到 2700 行 Python, 7 篇语料切成 25 块, 15 道评测题, 32 个测试。
但它和一个"跟着教程做完的 RAG demo"的差别, 不在这些行数里。差别在于:
- 每一个技术选择都有一个"因为实测……"跟在后面, 包括那些实测结果是否定的 (精排变差了、微调没修好正确性、检索指标不能当闸门)
- 每一层都有一个能回答"我变好了还是变差了"的数字, 而每个数字都标了它的样本量和分辨率
- 每一个已知的缺陷都写下来了, 而不是等着别人发现
- 最贵的十个坑都留下了"怎么发现的"记录, 而不只是"怎么修的"
这四条加起来, 就是"做过一个项目"和"做过一个可以拿出去讲的项目"的全部区别。
如果你照着这十三章真的做了一遍, 你手上会有一个能跑的服务、一份能贴到简历上的数字清单、和一堆真的自己撞过的坑。其中第三样最值钱 —— 因为前两样别人能抄, 第三样不能。
最后重复第 6 章那句话, 它值得作为整套文档的结尾:
学会区分"我知道"、"我以为"和"我量了但样本不够", 比多学一个框架值钱。