模拟面试智能体开发实战 05:给面试官接上题库和检索,题目结构化、大纲可溯源,AI 不再凭印象出题

0 阅读15分钟

给面试官接上题库和检索

第 3 篇我把那个"客气客服"改造成了有规矩的考官,第 4 篇又给它装了状态机,开场问什么、答错怎么追问、问到第几题收尾都有了章法。我拿这套跑了几场模拟面试,发现一个新问题:它每次问的题都太"随机"了。

有时候连着两轮都问到"讲讲 HashMap",有时候又突然蹦出来一道跟候选人投的岗位八竿子打不着的"说说你怎么做性能压测"。我问它是怎么选题的,它说"根据面试进度选一道合适的题"——翻译成人话就是:它凭印象出题,题库在它脑子里,考务逻辑全靠临场发挥。

这比"客气"更危险。客气顶多是练不出真本事,凭印象出题是练偏了还不知道自己练偏了:你以为在练并发,它给你塞了一堆八股;你以为在刷算法,它问你怎么写 CSS 居中。练面试的人最怕的不是题难,是不知道自己在练什么。

这一篇就把它从"凭印象出题"变成"按考纲出题"。核心两件事:

  1. 结构化题库:每道题带上岗位、难度、考察知识点,AI 只能"三维定位"抽题,不能自己拍脑袋。
  2. 大纲检索(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、不联网。

九、踩坑清单

  1. 双模块身份导致 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,两种导入模式指向同一个模块对象。
  2. pick_question 把 tag 透传漏了降级:最初直接把 tag 丢给 filter_questions,tag 不匹配就返回空 None,方向降级逻辑没接上。改成"先严格 tag,空了再丢 tag 按岗位+难度兜底",与 compose_question 行为对齐。
  3. 用了内置 hash() 做向量化:受 PYTHONHASHSEED 影响,同一段文字换个进程向量就变,"可复现"作废。换成 hashlib.blake2b 才稳——这是上一个系列里实打实踩过的坑。
  4. 切片用框架默认英文分隔符:整段中文被按字符硬切,片段读不通。中文必须自己加 。;, 句读分隔符。
  5. 把 min_score 阈值丢了:向量库默认"永远返回 k 条",检索不到方向的 query 也会硬塞 k 条,RAG 层就拿不到"该不该编题"的空信号。加上阈值后,召回为空才是正确的"无依据"信号。

十、本篇收尾

现在这个面试官手里有了一份结构化题库,和一份带出处的考察大纲。它不再凭印象出题:想练并发,就真的出并发的题;想刷前端,召回的就是前端大纲;同一道题绝不会重复出现;题问完了它老老实实说"没了",而不是编一道凑数。练面试的人第一次能清楚看到"我为什么被问这个、对应考纲哪一节"。

下一篇我们让它"会追问":第 6 篇写追问引擎——答得好怎么顺着挖深,答得偏怎么拉回来,答得错怎么当头点破,每一条追问策略都可测试、可复现。


最后一句:面试官的价值不在"问了一道题",而在"问的每道题都有据可查、每场面试都练到了该练的地方"——题库和检索,就是把这份价值从模型的"感觉"里夺回来。