第 3 篇我把那个"客气客服"改造成了有规矩的考官,第 4 篇又给它装了状态机,开场问什么、答错怎么追问、问到第几题收尾都有了章法。我拿这套跑了几场模拟面试,发现一个新问题:它每次问的题都太"随机"了。
有时候连着两轮都问到"讲讲 HashMap",有时候又突然蹦出来一道跟候选人投的岗位八竿子打不着的"说说你怎么做性能压测"。我问它是怎么选题的,它说"根据面试进度选一道合适的题"——翻译成人话就是:它凭印象出题,题库在它脑子里,考务逻辑全靠临场发挥。
这比"客气"更危险。客气顶多是练不出真本事,凭印象出题是练偏了还不知道自己练偏了:你以为在练并发,它给你塞了一堆八股;你以为在刷算法,它问你怎么写 CSS 居中。练面试的人最怕的不是题难,是不知道自己在练什么。
这一篇就把它从"凭印象出题"变成"按考纲出题"。核心两件事:
- 结构化题库:每道题带上岗位、难度、考察知识点,AI 只能"三维定位"抽题,不能自己拍脑袋。
- 大纲检索(RAG):把岗位考察大纲切成片段、向量化,出题时带上出处,让 AI 知道"这道题考的是大纲里哪一节",也让练的人知道"我为什么被问这个"。
一、先扫盲:为什么题目必须是结构化数据,而不是一坨自然语言
我一开始也想过偷懒:直接把几百道题写进 prompt,让模型自己挑。后来被自己否了,原因有三个,全是工程上的硬伤:
1. 模型挑题是"玄学",不是"逻辑"
"Java 后端 + 中级 + 并发方向"这种三维定位,只能靠字段过滤。让模型在一堆自然语言文本里自己挑,它挑错一次,整场面试的方向就歪了。而我没法测试"它挑得对不对"——自然语言意图没法断言。结构化字段 + 一个 filter_questions() 函数,挑题逻辑就是三行 if,可断言、可单测。
2. 防重复必须靠数据结构,不靠模型自觉
同一场面试里问过的题,永远不能再出现——这是防"摸套路"的地基。靠模型"记住别重复"等于没防,它记性随缘。真正的做法是:维护一个已问题 id 的集合 asked,每次出题前先扣掉。这个集合是确定的数据结构,测起来一句话的事。
3. 出处要能溯源,不溯源就是在耍流氓
练面试的人看到一道题,第一反应一定是"这题考我什么、对应哪块知识"。如果 AI 只甩一道题不告诉出处,等于把考察大纲藏起来,练的人只能盲人摸象。把大纲切片、带出处召回,每道题都能翻回"大纲第几节",这才是可复盘的练习。
一句话:题目是数据,出题是逻辑,这两样都不能交给模型的"感觉"。
二、工程结构:数据、检索、编排三层分离
05-题库与RAG/
├── data/
│ ├── 大纲-Java后端.md # 考察大纲语料(切片段、向量化)
│ ├── 大纲-前端.md
│ ├── 大纲-Python与算法.md
│ └── questions.jsonl # 32 道结构化面试题
├── src/
│ ├── embeddings.py # HashingEmbeddings:离线、确定性、零依赖的向量化
│ ├── index.py # 切片 → 向量化 → 内存向量库 → 带过滤的相似检索
│ ├── quiz_bank.py # 结构化题库:加载、校验、三维过滤、防重复
│ ├── rag.py # 出题编排:召回大纲 + 选题干 + 组装上下文
│ └── sources.py # 出处卡:题目 → 知识点 + 参考答案摘要 + 出处
├── tests/ # 52 个用例,全程离线
└── demo.py
核心思想一句话:题库决定"考什么",向量库决定"凭什么考",出题编排把两者拼成面试官每轮看到的上下文;AI 只负责用考官口吻把题问出来,不负责决定问什么。
三、结构化题库:一道题就是一条记录
questions.jsonl 里每行一道题,七字段缺一不可:
{
"id": "java-005",
"question": "volatile 关键字做了什么?它能保证线程安全吗?",
"position": "Java后端",
"level": "中级",
"tags": ["并发", "volatile", "JMM"],
"reference_answer": "保证可见性和禁止指令重排,不保证原子性……",
"source": "模拟题库 v1"
}
字段设计上有几个故意的约束:
- 岗位四维、难度三档:
POSITIONS = ("Java后端", "前端", "Python", "算法"),LEVELS = ("初级", "中级", "高级")。写错一个就在加载时当场raise ValueError,不静默兜底——题库是数据文件,错了必须炸出来,不能带病进面试。 tags是考察知识点列表:这是"方向"维度的载体,后面出题按 tag 定位。reference_answer不出现在题干里:那是泄题。它随题目走到第 7 篇的评分环节,供逐题点评对照,绝不在出题阶段泄露。id全局唯一:重复 id 加载时直接报错,去重机制的地基先打牢。
filter_questions() 就是三维定位的核心,逻辑干净到能直接念出来:
def filter_questions(questions, *, position=None, level=None,
tag=None, asked=None):
asked = asked or set()
return [
q for q in questions
if (position is None or q.position == position)
and (level is None or q.level == level)
and (tag is None or tag in q.tags)
and q.id not in asked
]
岗位、难度、知识点、已问集合,四个条件一与,出来的就是"该问的那道题的候选池"。
四、大纲向量化与召回:给 AI 一个"考纲"
题库解决了"出哪道题",但 AI 还需要知道"这道题对应大纲哪一节、考察大纲里怎么说的"。这就是 RAG 的部分——把考察大纲变成可检索的知识。
1. 为什么自己写一个 Embeddings,而不是直接用硅基流动的
本篇有 50 多个用例要断言"检索回来的就是那一节大纲"。如果向量化依赖外部服务,测试结果跟着服务端走——模型升级、服务抖动,测试就红了。离线、确定性的向量化器,是"可测试的检索层"的前提。
所以我写了 HashingEmbeddings:把文字拆成字符 n-gram,用 hashlib(不是内置 hash(),后者受 PYTHONHASHSEED 影响,换个进程值就变)映射成向量,L2 归一化。它只认字面重叠、不认语义,边界我写进了文档——生产环境请换成硅基流动的 OpenAIEmbeddings。
关键在设计:检索层只依赖 LangChain 的 Embeddings 抽象。换实现时只改 build_index 传进来的对象,索引层和编排层一行不动——这就是依赖倒置。
def build_index(chunks, embeddings=None):
store = InMemoryVectorStore(embedding=embeddings or HashingEmbeddings())
store.add_documents(chunks)
return store
测试里我用一个 CountingEmbeddings 把真实实现包一层计数,断言"传进来的自定义向量化真的被用了"——如果 build_index 偷偷 new 了默认实现,计数永远是 0,测试当场翻车。这条用例专门钉死"依赖倒置真的成立"。
2. 切片:中文必须自己加句读
框架默认的递归切分分隔符是 ["\n\n", "\n", " "]——给英文设计的。整段无换行的中文会被按字符硬切,切出来的片段没法读。我按同样算法(参考 RecursiveCharacterTextSplitter)写了一份零依赖实现,中文分隔符换成 ("。", ";", ",", " ")。
两个参数有讲究:chunk_size = 300(一个考察模块的知识密度大概 200-400 字,小了劈成两半、大了塞两三个模块),chunk_overlap = 60(块间留 20% 重叠,同一句话被切在两边时至少有一边能完整召回)。切片要做到不丢正文——每个碎片完整保留在某一块里,测试专门断言"每一节正文都是切片拼接结果的子序列"。
3. 检索的三个不能省的设计
def similarity_search(store, query, k=3, filter=None, min_score=0.05):
query_vector = store.embeddings.embed_query(query)
scored = []
for order, record in enumerate(store.store.values()):
if filter and not all(record["metadata"].get(k) == v
for k, v in filter.items()):
continue # 岗位/来源不匹配,直接不参与排序
score = cosine_similarity(query_vector, record["vector"])
if score >= min_score:
scored.append((score, order, document))
scored.sort(key=lambda r: (-r[0], r[1]))
return [d for _, _, d in scored[:k]]
- 自己算余弦,不调框架的
similarity_search_with_score:那条路内部硬依赖 numpy,本环境没装;片段量小,纯 Python 毫不费力。 filter按 metadata 精确匹配,岗位过滤先做:前端方向绝不召回 Java 大纲。方向错了,分数再高也不要。min_score阈值:向量库"永远返回 k 条"很危险,检索不到方向的 query 必须能返回空——这正是下一节"不编题"信号的来源。
五、出题编排:双路汇流,但有三道硬规则
rag.compose_question() 是这一篇的大脑。它做两件确定的事:从向量库召回大纲片段(带出处),从题库选出该问的题;然后把两者拼成一段上下文,交给第 3 篇的面试官人设去"用考官口吻问出来"。
最关键的不是"怎么汇流",而是三道硬规则——它们把"不乱出、不重出、不瞎编"钉死在逻辑里:
规则一:防重复,靠 asked 集合
出题成功后,调用方把 question.id 加进 asked。下一轮过滤自动排除。想摸套路?先把整个题库摸完。测试里我连出 5 题断言"无重复":
asked = set()
for _ in range(3):
ctx = compose_question("Java后端", "中级", "并发", store, asked, bank=bank)
asked.add(ctx.question.id)
assert len(picked) == len(set(picked)) == 3
规则二:方向降级,但如实告知
先严格按 tag 匹配考察方向;tag 没命中时,降级为"岗位+难度"出题,但 matched_focus 记 False,结果里如实告诉调用方方向没对上——不许默默当命中。
strict = filter_questions(bank, position, level, tag=focus, asked=asked)
if strict:
chosen = strict[0]
else:
fallback = filter_questions(bank, position, level, asked=asked)
if not fallback:
return ComposedQuestion(question=None, refused_reason=NO_QUESTION_REASON, ...)
chosen = fallback[0]
matched_focus = False
题库的 pick_question() 也有同样逻辑:tag 不命中先丢掉 tag 只按岗位+难度兜底,仍无候选才返回 None。
规则三:无题给明确信号,绝不编一道
该岗位该难度问完,compose_question 返回 question=None + refused_reason,绝不编一道不存在的题凑数。调用方按信号分流(换方向 / 降级 / 收尾)。测试断言:问完返回 None 且原因非空,不会拿别岗位的题来凑。
组装出来的上下文长这样(这是面试官 LLM 真正看到的片段):
【本次出题依据】
岗位:前端|难度:中级|考察方向:性能优化
【考察大纲参考片段】
[1](大纲-前端.md · 前端面试考察大纲)
性能是前端面试的必考方向。首屏加载优化先量化再动手……
【题库题目(请基于此题向候选人提问,可换措辞但不得更换考点)】
首屏加载慢,你会从哪些方向优化?
【出题要求】
- 按原题考点提问,可以用面试官口吻展开一两句背景
- 不要在提问中泄露参考答案
- 候选人答完后,参考答案要点将由评分环节使用
"问什么"由题库钉死,"凭什么问"由大纲支撑,AI 只负责用考官口吻展开——角色边界又一次划清了。
这里有一处我特意写进模板的约束值得单拎出来说:"可换措辞但不得更换考点"。模型有个坏习惯,遇到自己拿不准的考点,会悄悄把难题换成简单题——你明明在考"volatile 的可见性语义",它绕一圈问成了"什么是变量"。这道约束就是把这条路堵死:题型可以包装,考点一个字不许动。配合 reference_answer 始终不进出题上下文(只进第 7 篇评分环节),模型既偷不了懒、也偷不到答案,整场面试的"含金量"才保得住。
也正是因为这层约束,题库和向量库必须分离:题库管"考点的实体"(一道题是什么),向量库管"考点的语境"(这道题对应大纲哪一节)。把两者揉进一个 prompt 让模型自己分配,它一定会把语境当摆设、把考点当建议。拆开、各自可测、最后拼装,AI 才真正成了一个"被编排的考官",而不是"自作主张的聊天框"。
六、出处卡:每道题都能翻回考纲
sources.build_source_card() 把一道题变成一张"出处卡":题目、知识点 tags、参考答案摘要(超长截断到 60 字加省略号,不泄露完整答案)、出处文件。第 7 篇逐题点评时,这张卡就是"这道题考得对不对"的对照基准。
card = build_source_card(question)
# {"id": "java-005", "position": "Java后端", "level": "中级",
# "tags": ("并发", "volatile", "JMM"), "answer_excerpt": "保证可见性……",
# "source": "模拟题库 v1"}
七、跑起来看真实效果
python demo.py 全程离线(HashingEmbeddings 不联网、不需要 Key),三个场景验证"接上题库与检索的面试官不再摸套路":
题库加载:32 题|大纲切片入向量库:27 片
场景一:Java 后端 · 中级 · 考察方向「并发」
召回 3 段大纲片段:
[1] 大纲-Java后端.md · Java 后端面试考察大纲
[2] 01-Java后端面试考察大纲.md · Java 后端面试考察大纲(模拟面试官出题依据)
[3] 01-Java后端面试考察大纲.md · Java 后端面试考察大纲(模拟面试官出题依据)
题目(java-005):volatile 关键字做了什么?它能保证线程安全吗?
方向命中:是
场景二:Java 后端 · 中级 连出 5 题(已问集合防重复)
第 1 轮:java-006(方向命中) 线程池的核心参数有哪些?……
第 2 轮:java-007(方向命中) ConcurrentHashMap 在 JDK 1.8 里是怎么保证并发安全的?
第 3 轮:java-008(方向未命中(降级出岗位+难度题)) 数据库的隔离级别有哪些?……
第 4 轮:java-009(方向未命中(降级出岗位+难度题)) Redis 缓存和数据库的一致性怎么维护?……
第 5 轮:题库中已没有符合该岗位/难度/方向的未出之题
本场共出 4 题,无重复:True
场景三:前端 · 中级 · 考察方向「性能优化」
召回 3 段大纲片段(全部来自前端大纲)
题目(fe-005):首屏加载慢,你会从哪些方向优化?
同一个向量库,方向一换,召回的大纲文件和题目都换了。
注意场景二第 3、4 轮:并发 方向的中级题问完后,系统没有硬凑并发题,而是如实降级到 Java 后端中级的其它题,并在结果里标了"方向未命中";问完第 5 轮直接给"无题信号"收尾。这就是三道硬规则在真实运行里长什么样。
八、测试覆盖:52 个用例把三种翻车钉死
tests/test_embeddings.py 8 passed # 维度固定、可复现、归一化、相似度行为
tests/test_index.py 14 passed # 不丢字、参数约束、召回准确、岗位过滤、依赖倒置
tests/test_quiz_bank.py 13 passed # 加载校验、三维过滤、去重、方向降级
tests/test_rag.py 10 passed # 成功路径、去重、方向切换、空召回不编题
tests/test_sources.py 7 passed # 出处卡字段、截断、批量
─────────────────────────
52 passed in 1.93s
覆盖到什么程度:HashingEmbeddings 的确定性与归一化、切片不丢字、k 与 min_score 真的生效、岗位过滤真的排除其它大纲、依赖倒置真的可换实现、连出无重复、错误方向降级、matched_focus 如实标记、问完返回 None 不编题。全部离线,不需要 Key、不联网。
九、踩坑清单
- 双模块身份导致
isinstance判 False:测试用from embeddings import(conftest 把src加进sys.path),而index.py里写from src.embeddings import,Python 认为这是两个不同身份的HashingEmbeddings类,断言isinstance(store.embeddings, HashingEmbeddings)直接 False。修法是 src 内部统一"顶层优先、包导入兜底"的 try/except,两种导入模式指向同一个模块对象。 pick_question把 tag 透传漏了降级:最初直接把tag丢给filter_questions,tag 不匹配就返回空None,方向降级逻辑没接上。改成"先严格 tag,空了再丢 tag 按岗位+难度兜底",与compose_question行为对齐。- 用了内置
hash()做向量化:受PYTHONHASHSEED影响,同一段文字换个进程向量就变,"可复现"作废。换成hashlib.blake2b才稳——这是上一个系列里实打实踩过的坑。 - 切片用框架默认英文分隔符:整段中文被按字符硬切,片段读不通。中文必须自己加
。;,句读分隔符。 - 把
min_score阈值丢了:向量库默认"永远返回 k 条",检索不到方向的 query 也会硬塞 k 条,RAG 层就拿不到"该不该编题"的空信号。加上阈值后,召回为空才是正确的"无依据"信号。
十、本篇收尾
现在这个面试官手里有了一份结构化题库,和一份带出处的考察大纲。它不再凭印象出题:想练并发,就真的出并发的题;想刷前端,召回的就是前端大纲;同一道题绝不会重复出现;题问完了它老老实实说"没了",而不是编一道凑数。练面试的人第一次能清楚看到"我为什么被问这个、对应考纲哪一节"。
下一篇我们让它"会追问":第 6 篇写追问引擎——答得好怎么顺着挖深,答得偏怎么拉回来,答得错怎么当头点破,每一条追问策略都可测试、可复现。
最后一句:面试官的价值不在"问了一道题",而在"问的每道题都有据可查、每场面试都练到了该练的地方"——题库和检索,就是把这份价值从模型的"感觉"里夺回来。