全栈开发知识库

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/协程] 补: 阻塞是线程占着资源什么都不做

请注意报告里的三件事, 它们是这个项目的灵魂:

  1. 均分 3.33 是算出来的, 不是模型说的。 三道题分别 10 / 0 / 0, 除以 3 等于 3.33。这个数字可以手算验证。
  2. "漏掉了"的内容来自题目自带的评分要点, 不是模型现编的批评。所以它可以直接变成学习计划。
  3. "待加强"这个判定有明确规则 (均分 >= 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 章。


踩坑与专家提示

  1. 不要一上来就写 Web 界面。 正确顺序是: 语料 → 检索 → 检索评测 → 命令行面试 → 评测判分器 → 才上 Web。界面是最不重要的一层, 但最容易让人误以为"项目做完了"。
  2. 不要在没有评测集的情况下调参。 "我把 chunk_size 从 300 改成 500, 感觉好了一些" —— 这句话里的"感觉"就是这个项目要消灭的东西。第 3 章先写评测集, 第 4 章才开始切块, 顺序是故意的。
  3. temperature 判分时必须设 0。 默认值通常是 1.0, 同一份答案两次判分差 3 分, 你后面所有对照实验都在比较噪声。
  4. 报告不要让模型生成。 一份"AI 生成的总结"读起来很流畅, 但两次运行结论会不一样, 而且你没法解释任何一个数字的来源。
  5. 给自己的项目留一个离线模式。 这一条的收益远超预期: 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 是一个产品决策, 不是技术限制。


划重点

  1. 大模型在这个项目里只干两件事: 判要点命中、题库不够时生成新题。分数、分支、报告全是代码。
  2. 分数 = 命中要点的权重和 / 总权重 × 10。可复现、可解释、可手算。
  3. 显式状态机的价值不在优雅, 在可测试和可定位。
  4. 混合检索的理由是 Android 面试题里全是 API 名字, 向量对精确词不敏感。
  5. 精排在这个数据集上量出来是变差的。没量过的优化不是优化。
  6. 每个技术选型都要能说出代价。只说优点的选型报告是不可信的。
  7. 一定要有离线模式: CI 能跑、断网能演示、文档能贴真实输出。

下一章预告

下一章从一个空文件夹开始: 装 Python 和 uv、建项目骨架、写配置分层、准备 .env 和 .gitignore。做完之后你会有一个能 python -m app.core.config 打印出配置的项目 —— 虽然还什么功能都没有, 但地基是正的。

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