00 · 项目总览与技术选型
一句话总结
我们要做的不是"让大模型扮演面试官", 而是做一个有知识来源、有评分标准、有流程控制、有质量数字的面试系统 —— 大模型在里面只干它最擅长的那一件事: 判断候选人有没有讲到某个技术要点。其他所有事 (出什么题、分数怎么算、下一步问什么、什么时候结束) 都由代码决定。这一章讲清整体架构和每个技术选择背后的理由, 后面 13 章都是在填这张图。
本章你能学到什么
- 成品到底是什么样: 一次真实面试从开始到出报告的完整过程
- 整体架构: 一次"提交答案"的请求穿过哪几层, 每层负责什么
- 十个关键技术决策的理由和代价 —— 包括我们主动放弃了什么
- 为什么"让模型直接给分数"是这类项目最常见也最致命的错误
- 整个项目的目录结构, 以及为什么这么分层
一、成品长什么样
1.1 一次面试的真实过程
下面这段是在终端里跑 python -m app.cli --offline --auto --questions 3 的真实输出 (--auto 表示用内置的假答案自动作答, 方便演示三种情况):
====================================================================
[第 1/3 题] 四大组件/Activity生命周期 (难度 2)
面试官: onSaveInstanceState 在什么时机被调用? 用户按返回键退出会调用吗?
你: 在 onStop 之前调用。只在系统主动销毁时调用, 用户主动按返回键退出不调用。恢复可在 onCreate 的 savedInstanceState 或 onRestoreInstanceState 读。
得分 10.0/10 掌握程度 expert (0 ms)
讲到了: 在 onStop 之前调用; 只在系统主动销毁时调用, 用户主动按返回键退出不调用; 恢复可在 onCreate 的 savedInstanceState 或 onRestoreInstanceState 读
点评: [规则判分] 命中 3/3 个要点
====================================================================
[第 2/3 题] Kotlin/协程 (难度 2)
面试官: 协程的挂起和线程的阻塞有什么本质区别?
你: 大概知道一点, 好像和生命周期有关系。
得分 0.0/10 掌握程度 blank (0 ms)
漏掉了: 挂起不阻塞线程, 挂起时线程被释放去执行其他任务; 阻塞是线程占着资源什么都不做; 协程是线程之上的调度单位, 一个线程可承载大量协程
点评: [规则判分] 命中 0/3 个要点
====================================================================
[第 3/3 题] Kotlin/协程 (难度 2)
面试官: 为什么说协程比线程省资源? 它省在哪?
你: (交白卷)
得分 0.0/10 掌握程度 blank (0 ms)
漏掉了: 协程是线程之上的调度单位, 一个线程可承载大量协程; 挂起时把线程让出去, 而不是占着线程等待; 挂起恢复靠 CPS 变换出的状态机, 不需要额外线程栈
点评: 没有作答
注意第 2 题答砸之后, 第 3 题还是 Kotlin/协程 —— 这不是巧合, 是"薄弱知识点优先重考"的规则在起作用。
最后自动出一份报告:
# 面试复盘报告
- 会话: `e42eb6ce9cd2`
- 题数: 3
- 均分: **3.33 / 10**
- 水平判定: **待加强**
- 总耗时: 0 ms
## 分知识点表现
| 知识点 | 均分 | 掌握程度 |
|---|---|---|
| Kotlin/协程 | 0.0 | blank |
| 四大组件/Activity生命周期 | 10.0 | expert |
## 强项
- 四大组件/Activity生命周期 (均分 10.0)
## 薄弱项
- Kotlin/协程 (均分 0.0)
## 下一步学习建议
1. [Kotlin/协程] 补: 挂起不阻塞线程, 挂起时线程被释放去执行其他任务
2. [Kotlin/协程] 补: 阻塞是线程占着资源什么都不做
请注意报告里的三件事, 它们是这个项目的灵魂:
- 均分 3.33 是算出来的, 不是模型说的。 三道题分别 10 / 0 / 0, 除以 3 等于 3.33。这个数字可以手算验证。
- "漏掉了"的内容来自题目自带的评分要点, 不是模型现编的批评。所以它可以直接变成学习计划。
- "待加强"这个判定有明确规则 (均分 >= 8 高级, >= 6 中级, >= 4 初级, 其余待加强), 写在代码里, 谁都能查。
1.2 网页版
第 8 章会做一个单文件的网页前端: 点"开始面试"出题, 打字回答后评分先到、下一题随后到 (流式推送), 侧边栏能看历史面试和错题本。
1.3 它还能当知识库问答用
同一套检索, 换一个接口就是问答模式:
curl -X POST localhost:8000/api/ask -H 'Content-Type: application/json' \
-d '{"question":"内存泄漏怎么排查","top_k":3}'
返回命中的资料原文和它们的检索得分。这个接口刻意不调大模型 —— 理由在第 5 章讲。
二、架构: 一次请求穿过几层
┌──── 离线阶段 (跑一次就行, 不在 Web 进程里) ────────────────────┐
│ corpus/*.md ──▶ 按标题切块 ──▶ bge 向量化 ──▶ Chroma 持久化 │
│ │ │ │
│ └──▶ YAML 元数据(topic/难度/tags)┘ │
│ └──▶ BM25 内存索引(启动时重建)│
└─────────────────────────────────────────────────────────────────┘
▲
─────────────────────────────────────── │ ────────────────────────
│
POST /api/interview/answer │
│ │
▼ │
鉴权 (X-API-Key) ──401/403──▶ 拒绝 │
▼ │
状态机 SELECT ─── 选题 ────────────────┘
│ (黄金题库优先, 不够了才让 LLM 基于检索资料生成)
▼
状态机 GRADE ── LLM 只判"要点命中没有" ──▶ 代码按权重算分
▼
状态机 BRANCH ── 代码决定: 追问 / 提示纠错 / 换题 / 结束
▼
SSE 逐条推送 (grade 先到, 下一题随后到)
▼
落库 SQLite (会话 / 问答明细 / 错题本 / 配额)
▼
状态机 REPORT ── 纯计算, 不调模型 ──▶ 报告
请留意这张图里两个"反常识"的地方, 它们是本项目和普通 demo 的分水岭:
- LLM 出现在流程里只有两个位置: 判要点命中、以及题库不够时生成新题。分数、分支、报告全都不经过模型。
- 报告生成完全是纯计算。很多人会让模型"总结一下这场面试", 结果同一份数据两次生成的报告结论不一样。我们的报告是把 turns 列表做分组求平均, 一个字都不用模型。
2.1 目录结构
android_interviewer/
├── app/
│ ├── core/
│ │ ├── config.py 配置: 只从环境变量读, 一个 Settings 走全程
│ │ ├── llm.py LLM 客户端: chat / chat_json, 带重试与 JSON 修复
│ │ ├── db.py SQLite: 4 张表 (会话/明细/错题本/配额)
│ │ └── auth.py API Key 鉴权 + 按天配额
│ ├── rag/
│ │ ├── loader.py 读 Markdown + YAML frontmatter
│ │ ├── chunker.py 按标题切块, 整句重叠
│ │ ├── bm25.py 中文 BM25 (自己实现, 不用 jieba)
│ │ ├── embedding.py bge-small-zh + 离线 HashEmbedder 兜底
│ │ ├── index.py Chroma 持久化 + BM25 内存索引
│ │ └── retriever.py 两路召回 + RRF 融合 + 精排 + 阈值
│ ├── agent/
│ │ ├── schemas.py Pydantic 数据契约 (LLM 输出的质检员)
│ │ ├── selector.py 出题: 黄金集优先, 五档降级
│ │ ├── grader.py 判分: LLMGrader + RuleGrader
│ │ ├── fsm.py 六状态机: SELECT/ASK/GRADE/BRANCH/REPORT/DONE
│ │ └── report.py 报告: 纯计算
│ ├── service.py 服务层: 把 FSM / DB / 错题本粘起来
│ ├── api.py HTTP 接口 (SSE 在这里)
│ ├── main.py FastAPI 入口 + /healthz
│ └── cli.py 命令行面试 (先在终端跑通再上 Web)
├── corpus/ 语料: 7 篇 Markdown
├── eval/
│ ├── golden.yaml 15 题黄金评测集 (带人工 rubric)
│ ├── grader_cases.yaml 15 条判分标注 (人工打的分)
│ ├── eval_retrieval.py 检索评测: Recall@K / MRR / nDCG
│ ├── eval_grader.py 判分器评测: MAE / 一致率 / 相关系数
│ └── eval_dialog.py 对话级 LLM Judge (双向消偏)
├── finetune/export_traces.py 导出微调数据 (复用生产提示词)
├── scripts/
│ ├── check_golden.py 评测集自检 (权重和/id 重复/gold_chunks 存在性)
│ ├── gate_grader.py 判分器质量闸门: 指标越线就非零退出, 让 CI 变红
│ └── smoke_api.py 冒烟脚本: 轮换三档答案走遍 FSM 分支
├── tests/ 32 个测试, 全部离线
├── docker/ Dockerfile / compose / nginx.conf / entrypoint.sh
├── static/index.html 单文件前端
└── .github/workflows/ci.yml
分层的判断标准只有一条: 下层不许知道上层的存在。fsm.py 不 import 数据库, retriever.py 不 import FastAPI。这样第 7 章写完的状态机, 到第 8 章接 Web 时一行都不用改。
三、十个技术决策
每个决策都写清"为什么这么选"和"代价是什么"。看技术选型只看优点是学不到东西的。
3.1 分数由代码算, 模型只判命中
决策: 模型输出 hit_points (命中了哪些要点), 分数 = 命中要点的权重和 / 总权重 × 10。
为什么: 让模型直接给分数, 你会遇到三个问题 —— 同一份答案两次给 6 分和 9 分; 答案写得长且辞藻华丽的分数偏高; 你完全无法解释"为什么是 7 分"。把算术交给代码后, 分数可复现、可解释、可手算验证。
代价: 必须为每道题写人工 rubric (评分要点 + 权重)。15 道题写了大半天。这是这个项目最贵的人力成本, 也是它质量的来源。
3.2 显式状态机, 不用大提示词
决策: 六个状态 SELECT → ASK → GRADE → BRANCH → REPORT → DONE, 状态流转写在 Python 里。
为什么: "你是一个面试官, 请出 5 道题, 每道题评分, 答得好就追问……" 这种提示词会漂: 忘记评分、重复出同一道题、追问追到第 8 层出不来。更要命的是出问题你不知道是哪一步坏了。显式状态机的每个状态都能单独测试。
代价: 代码量多了大概 200 行, 而且新增行为要改代码而不是改提示词。
3.3 混合检索 (BM25 + 向量), 不是只用向量
决策: 两路召回, RRF 融合。
为什么: 向量擅长语义 ("旋转屏幕数据丢了" 能找到 "配置变更"), BM25 擅长精确词 (onSaveInstanceState 这种 API 名字向量常常糊掉)。Android 面试题里全是 API 名字, 只用向量会漏。第 6 章会量出来: BM25 单路 Recall@3 = 0.9333, 向量单路 1.0000, 融合后 MRR 从 0.9333/0.9556 提到 0.9667。
代价: 多一套索引要维护, 检索代码复杂度翻倍。
3.4 Chroma, 不是 Qdrant / Milvus / pgvector
决策: Chroma 的 PersistentClient, 数据就是本地一个目录。
为什么: 这个项目的知识库只有 25 个块。25 个块用 Milvus 是拿高射炮打蚊子 —— 你要多起一个容器、学一套 schema、调一堆参数, 换来的是 25 条数据的毫秒级检索。Chroma 零运维, pip install 完就能用。
代价: 数据量上到几十万块会吃力, 到时要换。换的时候只改 index.py 一个文件 —— 这也是为什么要分层。
3.5 SQLite, 不是 Postgres
决策: 一个 .db 文件, check_same_thread=False + 模块级线程锁。
为什么: 单机部署、零运维、备份就是复制一个文件。Docker 里挂一个卷就完事。
代价: 写入是全库锁, 并发写多了会排队。真有并发压力时换 Postgres, 只改 db.py。
3.6 SSE, 不是 WebSocket
决策: text/event-stream 流式响应。
为什么: 我们的需求是"服务器单向推多条消息", 请求本身还是普通 POST。SSE 是纯 HTTP, 过 Nginx 只要关掉 buffering 就行。WebSocket 要额外处理连接管理、心跳、重连, 是为双向通信设计的, 我们用不上。
代价: 浏览器原生的 EventSource 不支持 POST 也不支持自定义 header, 所以前端要手写一个 SSE 解析器 (第 8 章给完整代码, 大约 20 行)。
3.7 bge-small-zh-v1.5, 不是 OpenAI embedding
决策: 本地跑 512 维的中文小模型。
为什么: 100MB 大小, Mac 上 CPU 就能跑, 中文效果好, 而且免费且离线。灌 25 个块几秒钟。用 API 的话每次重建索引都要花钱, 而且改一次切分参数就要重灌一次。
代价: 首次要下载 100MB (国内走 hf-mirror.com 镜像), 而且加载模型让服务冷启动慢十几秒。第 11 章会把模型烤进 Docker 镜像解决这个问题。
3.8 精排默认关掉
决策: cross-encoder 精排代码写了, 但 USE_RERANK=false。
为什么: 因为量过之后发现它在这个评测集上没有变好。第 6 章会贴完整的对照表, 简单说: hybrid 的 MRR 是 0.9667, 加上精排变成 0.9333 —— 变差了。
这件事本身就是这套文档最想教的东西: 精排是"通常有用"的技术, 但在你的数据上有没有用, 只有量了才知道。 默认开着它, 你会白白多花几百毫秒换来更差的结果。
代价: 无。省了钱和延迟。
3.9 CI 全程离线
决策: GitHub Actions 里 OFFLINE=true, 用规则判分器 + Hash 向量。
为什么: 三个理由 —— 不用往 Secrets 里放 API Key (少一个泄漏面); 不花钱也不受第三方 API 抖动影响 (CI 红了永远是自己的问题); 快 (32 个用例 1 秒内跑完)。
代价: "LLM 判分的真实质量" CI 测不了。这部分由第 9 章的评测脚本手动跑。这是有意识的取舍, 不是遗漏 —— 面试时被问到"你的 CI 怎么保证 LLM 质量", 这个回答比"我们全量跑真模型"更专业。
3.10 单 worker
决策: uvicorn --workers 1。
为什么: 活跃会话 (SessionState) 存在进程内存里。多 worker 时, 用户第一次请求进了 worker A, 第二次被路由到 worker B, B 里没有这个会话 —— 用户会看到"会话不存在"。
代价: 吞吐上不去。要扩容就先把会话搬到 Redis, service.py 里已经标注了迁移点。第 11 章讲具体怎么做。
四、这个项目会用到的模型, 一共三个
| 用途 | 模型 | 跑在哪 | 花钱吗 |
|---|---|---|---|
| 文本向量化 (检索用) | BAAI/bge-small-zh-v1.5 |
本地 CPU, 100MB | 不花 |
| 重排 (可选, 默认关) | BAAI/bge-reranker-base |
本地 CPU, 1.1GB | 不花 |
| 判分与出题 | deepseek-chat 或任意 OpenAI 兼容模型 |
云端 API | 花, 全程十几元 |
离线模式下三个都不需要: 向量走 HashEmbedder (哈希假向量), 判分走 RuleGrader (关键词覆盖率)。整条链路照样跑通, 只是质量差。这个设计让你可以在完全没有 API Key 的情况下学完前 8 章。
踩坑与专家提示
- 不要一上来就写 Web 界面。 正确顺序是: 语料 → 检索 → 检索评测 → 命令行面试 → 评测判分器 → 才上 Web。界面是最不重要的一层, 但最容易让人误以为"项目做完了"。
- 不要在没有评测集的情况下调参。 "我把 chunk_size 从 300 改成 500, 感觉好了一些" —— 这句话里的"感觉"就是这个项目要消灭的东西。第 3 章先写评测集, 第 4 章才开始切块, 顺序是故意的。
- temperature 判分时必须设 0。 默认值通常是 1.0, 同一份答案两次判分差 3 分, 你后面所有对照实验都在比较噪声。
- 报告不要让模型生成。 一份"AI 生成的总结"读起来很流畅, 但两次运行结论会不一样, 而且你没法解释任何一个数字的来源。
- 给自己的项目留一个离线模式。 这一条的收益远超预期: CI 能跑、断网能演示、新人 clone 下来五分钟能看到效果、写文档时能贴真实输出。代价只是多写一个 80 行的规则判分器。
面试视角: 本章高频五问
Q1: 你为什么不用 LangChain / LlamaIndex?
因为这个项目的每一层都需要"我知道它具体在做什么"。RAG 部分总共 6 个文件不到 600 行, 用框架能省掉大概 200 行, 代价是检索质量出问题时要去读框架源码。而且框架的 RetrievalQA 这类高层封装会把提示词藏起来, 而提示词是本项目质量的核心。生产项目里我会用框架, 但学的时候必须手写一遍。
Q2: 你的 Agent 体现在哪? 这不就是个 RAG 加个循环?
体现在它会根据观察结果改变后续行为。答得好就追深一层, 说错了就先纠正再重问, 连续两题答得好就升难度、连续两题答得差就降难度, 上一场的薄弱知识点这一场优先重考。这些是决策, 不是流程。反过来说, 如果一个"Agent"的行为序列是固定的, 那它就只是一个工作流。
Q3: 分数由代码算, 那模型的价值在哪?
在"语义等价判断"。候选人说"挂起的时候线程会被让出去干别的活", 参考要点写的是"挂起不阻塞线程, 挂起时线程被释放去执行其他任务" —— 字面上几乎没有重合, 但语义完全命中。这件事关键词匹配做不到, 正则做不到, 只有语言模型做得到。我们让它只做这一件事, 是因为这是它做得比代码好的地方。
Q4: 怎么证明你的系统是好的?
三层数字。检索层: Recall@3 / MRR / nDCG@3, 四种召回方式的对照表。判分层: 15 条人工标注, MAE 0.833、顺序一致率 0.8、皮尔逊相关 0.9377。对话层: LLM Judge 两两对比, 每轮正反各跑一次消除位置偏见。第 6 和第 9 章各自完整讲。
Q5: 追问深度为什么要封顶?
不封顶的话模型会一直往下钻: 问完协程问 CPS 变换, 问完 CPS 问字节码, 问完字节码问 JVM —— 面试变成拷问, 而且一场面试的 6 道题只覆盖了 1 个知识点, 报告失去意义。MAX_FOLLOWUP_DEPTH=2 是一个产品决策, 不是技术限制。
划重点
- 大模型在这个项目里只干两件事: 判要点命中、题库不够时生成新题。分数、分支、报告全是代码。
- 分数 = 命中要点的权重和 / 总权重 × 10。可复现、可解释、可手算。
- 显式状态机的价值不在优雅, 在可测试和可定位。
- 混合检索的理由是 Android 面试题里全是 API 名字, 向量对精确词不敏感。
- 精排在这个数据集上量出来是变差的。没量过的优化不是优化。
- 每个技术选型都要能说出代价。只说优点的选型报告是不可信的。
- 一定要有离线模式: CI 能跑、断网能演示、文档能贴真实输出。
下一章预告
下一章从一个空文件夹开始: 装 Python 和 uv、建项目骨架、写配置分层、准备 .env 和 .gitignore。做完之后你会有一个能 python -m app.core.config 打印出配置的项目 —— 虽然还什么功能都没有, 但地基是正的。