本文记录我做的项目 OfferAI------一个面向求职者的 AI 模拟面试系统。它把简历解析、检索增强生成(RAG)、LangGraph 面试编排、实时语音交互、技能掌握度建模串成一条完整的产品链路。下面从架构到核心模块,讲清楚每个技术决策背后的"为什么"。
一、项目背景:为什么要做这个
求职者准备面试时有两个痛点:不知道自己会被问什么 ,以及答完之后不知道自己差在哪。市面上的"AI 面试官"大多只做"问答机器人"------你把简历丢进去,它随便问几个问题,给个模糊评价就结束。
OfferAI 想做的是一个能闭环的产品:
- 简历解析 → 把 PDF 简历变成可检索、可诊断的结构化上下文;
- 匹配诊断 → 拿简历和目标岗位 JD 比对,告诉你缺哪些技能;
- 模拟面试 → 用 LangGraph 编排一场有状态、能追问、能打分的面试;
- 评分复盘 → 每轮评分,并用 BKT(贝叶斯知识追踪)建模你的技能掌握度;
- 训练计划 → 面试后多智能体产出短板分析和针对性训练题;
- 成长追踪 → 技能掌握度随每次面试更新,形成成长曲线。
这不是"调一个 ChatGPT 接口"就能搞定的东西,它本质是一个带状态的多智能体系统。下面拆开讲。
二、整体架构

整体分五层:
- 用户层:Web 前端(Vue3);
- 接入层:FastAPI 网关、JWT 鉴权、WebSocket 实时通道;
- 业务服务层:简历解析、匹配诊断、模拟面试编排、评分复盘、训练计划;
- AI 编排层:LangGraph 状态机,负责面试的"流程大脑",状态通过 checkpoint 持久化;
- 数据层:PostgreSQL 做数据真源,Zilliz Cloud 存向量,Redis 做缓存 / 限流 / 鉴权黑名单;
- 外部模型与服务:LLM(Qwen)、Embedding(text-embedding-v3)、Reranker(gte-rerank-v2)、MinerU(PDF 解析)、ASR(语音识别)。
一个设计原则贯穿全文:确定性规则兜底 + LLM 增值。凡是能确定性算的(覆盖率、完整性、关键词命中),用规则;需要语言理解的部分,才交给 LLM。这样系统既可控、可复现,又有智能。
三、核心链路一:简历解析与 RAG 检索
模拟面试的"灵魂"是追问要基于候选人的真实简历,不能让模型凭空编问题。这就依赖 RAG:把简历切块存进向量库,每轮根据候选人回答检索相关段落,再让 LLM 基于命中内容出题 / 评分。
3.1 简历解析:PDF → Markdown
简历是 PDF,直接喂模型不行(版式乱、表格多)。我用 MinerU 把 PDF 解析成带标题层级的 Markdown。Markdown 保留了"工作经历 / 项目经历 / 技能"这样的章节结构,正是后面分块和检索要依赖的语义边界。
3.2 语义分块:规则驱动,不用模型
分块是检索质量的地基。这里我没有上任何模型,而是纯规则切分:
- 按 Markdown 标题层级 (
#/##/###)识别章节边界------"工作经历""项目经历""技能"各成一块,一个项目就是一块、一段工作经历就是一块; - 遇到超过 900 字符 的单块,在块尾 100 字符窗口 里找最近的句号 / 换行做切点,块与块之间保留 100 字符重叠,保证切点不切断上下文。
之所以叫"语义分块",是指按语义边界(章节结构)切,而不是按固定长度硬切,不是"用大模型理解语义"。分块要每篇都跑、要快、要可复现,规则比 LLM 更合适、更省。
3.3 向量化:稠密 + 稀疏一起进 Zilliz
每块生成两路向量,写入 Zilliz Cloud:
-
稠密向量 :调用阿里 text-embedding-v3(1024 维),负责"语义相似";
-
稀疏向量 :借力 Milvus 内置的 BM25 Function ,服务端根据
chunk_text自动分词、算权重,负责"关键词命中"。稠密向量 ← text-embedding-v3(客户端算好写入)
稀疏向量 ← BM25 Function(Zilliz 服务端按 chunk_text 自动生成)
两者同属一条记录,为后续混合检索铺路
稠密管语义、稀疏管关键词,两条路互补:候选人说"做过高并发项目",稠密向量能召回语义相近的经历,稀疏向量能精准命中"高并发""Redis"这类关键词。
3.4 在线检索:Query 改写 → 混合召回 → 精排

候选人每答完一轮,经过质量检测后触发检索。完整链路:
① Query 改写(确定性规则,非 LLM)
query 就是候选人这一轮回答的原文------口语、有指代、有废话,直接检索效果差。
Query 改写我做了双层 。第一层是规则------把口语回答套成检索意图模板、挂上白名单技能线索,零延迟零成本,兜住大部分常见情况。第二层是门控的 LLM 扩展:当回答过短、或者规则一层没匹配到任何简历技能线索时,我才调一次 LLM,把回答扩写成
简历已有词汇里的同义词 ------比如候选人说"集群",LLM 扩成"redis 集群、分布式",而且我约束它只许用简历里出现的词,防幻觉。LLM 那层带缓存,所以只在长尾触发,成本和延迟都可控。
② 混合召回(稠密 + 稀疏)
改写后的 query 同时喂给两路:稠密向量做 ANN 检索,稀疏向量走 BM25 检索,各召回一批候选。
③ 融合(WeightedRanker)
两路结果用加权融合:
final_score = 0.65 × dense_score + 0.35 × sparse_score
稠密权重更高,因为语义匹配更准;稀疏保底关键词命中,解决"同义词 / 专有名词"召回。
④ 精排(gte-rerank-v2)
融合后的 Top-30 候选送 DashScope gte-rerank-v2 (Cross-Encoder)做精细重排,取 Top-5 作为最终上下文。Reranker 比融合排序更准,因为它能看到 query 和文档的真实交互。
⑤ 生成
Top-5 块作为 grounding,喂给 LLM 生成下一轮追问或本轮评分。因为所有内容都来自检索命中的原文块,模型只对着给定上下文作答,不凭记忆发挥------这本身就是一种防幻觉机制。
3.5 简历解析的另一路消费:岗位匹配诊断
解析出的简历 Markdown 不只是给 RAG 用,它还和目标岗位 JD 一起做匹配诊断------告诉候选人"你和这个岗位差在哪"。这一步和评分一样,也走双轨:
最终匹配分 = 0.35 × 规则分 + 0.65 × LLM 分
规则分 = 0.65 × 关键词覆盖度 + 0.35 × ATS 可读性
- 关键词覆盖度(确定性):从 JD 抽取要求的技能词表,在简历 Markdown 文本里做词表命中(要求技能被简历覆盖的比例)。纯规则、零 token;
- ATS 可读性(确定性):模拟 HR 的 ATS 系统解析简历的友好度------标题是否规范、有没有乱码表格、关键信息是否可被机器抽取。也是规则检查;
- LLM 分(语义):理解层判断------简历项目与岗位的相关度、经验深度、行业匹配。
输出是匹配度分数 + 缺口清单("你缺 Kafka 实战""分布式经验不足"),直接喂给模拟面试决定"该多问你哪块"。同样,规则算确定性部分、LLM 做增值判断,可复现、可解释。
四、核心链路二:LangGraph 面试编排
模拟面试不是一个"一问一答"的线性脚本,它是一个有状态的状态机:要记录当前轮次、已问过的问题、历史评分、技能掌握度,还要支持用户断线重连后从断点继续。

我用 LangGraph 的 StateGraph 而不是 ReAct Agent,原因是面试流程是确定性的节点编排,不是"让模型自己决定下一步干啥"------让 LLM 自由决策会让面试失控、不可复现。
主链路节点:
| 节点 | 作用 |
|---|---|
load_context |
载入简历、岗位、历史 RAG 上下文 |
generate_question |
LLM 基于上下文出下一道题 |
wait_answer |
等待用户作答(挂起,释放资源) |
evaluate_answer |
双轨评分(规则 0.35 + LLM 0.65) |
bkt_update |
用 BKT 更新该技能掌握度 |
decide_next |
决定继续追问 or 结束 |
summary |
面试总结 |
checkpoint 持久化 :状态每步落盘到 checkpointer,支持断点续传 (进程重启可恢复对话)和 thread_id 版本隔离 (同一场面试独立状态)。checkpointer 是可插拔的抽象------生产环境接 PostgreSQL 持久化,开发环境用内存实现跑得快。
reconcile 三态 :用户重连或恢复时,系统比对本地状态和服务端 checkpoint,给出 resume(直接续)/ restart(重开)/ conflict(状态冲突需裁决)三种处理,保证并发安全。
状态机细节(不止节点表)
- State Schema(TypedDict) :图在跑的不是"字符串",而是一个结构化状态对象------装着
current_round(当前轮次)、asked_questions(已问问题列表)、history(每轮的问答 + 评分)、skill_mastery(各技能掌握度)、bkt_state、resume_context/jd_context/candidate_profile(注入的上下文)、next_action(路由决策)。所有节点读它、改它,这就是"有状态"的载体。 - 条件边路由 :
decide_next节点不硬跳,而是返回一个路由值,add_conditional_edges把它映射到continue(继续追问)/summary(进入总结)/end(结束)。流程走向由数据驱动,而非写死。 - compile + interrupt :
builder.compile(checkpointer=...)编译;wait_answer节点用interrupt_before挂起,释放资源等用户作答,用户回来后从断点invoke续跑------这就是人机协作断点,也是为什么 WebSocket 重连能无缝继续。 - 流式透出 :
astream_events把generate_question/evaluate_answer的 LLM token 实时推到 WebSocket,前端逐字渲染,用户感知到低延迟。
五、实时通道:WebSocket
面试是实时双向 的:用户说话(ASR 转写)→ 后端评测 → 模型追问 → 流式返回。HTTP 轮询做不到低延迟,所以用 WebSocket 作为主通道:
- JWT 子协议鉴权:连接建立时校验 token;
- 心跳保活:定时 ping/pong,探测死连接;
- 并发锁:同一面试同一时刻只允许一个活跃连接,防止重复提交;
- 断线重连:配合 LangGraph checkpoint,重连后从断点继续,不丢进度。
六、评分与 BKT:技能掌握度怎么建模
每轮回答用双轨评分:
final_score = 0.35 × rule_score + 0.65 × llm_score
- 规则分(0.35):确定性检查,比如"是否答到了岗位要求的技能点""有没有量化成果";
- LLM 分(0.65):语义层面的表达质量、深度、逻辑。
这套双轨和 RAG 的"确定性兜底 + LLM 增值"是同一套哲学。
更有意思的是技能掌握度的长期建模 ------用 BKT(贝叶斯知识追踪)。每看到一次"答对 / 答错",就更新该技能的后验掌握概率:
P(L_t) = P(L_{t-1})·(1 - P_slip)
───────────────────────────────────────────
P(L_{t-1})·(1 - P_slip) + (1 - P(L_{t-1}))·P_guess
观察到一次"正确回答"后,用上式把该技能的掌握度向上修正;如果观察到的是"答错",则用对应的错误后验式(分子换成 P_slip、分母第二项换成 1 - P_guess):
P(L_t | 错) = P(L_{t-1})·P_slip
─────────────────────────────────────────────
P(L_{t-1})·P_slip + (1 - P(L_{t-1}))·(1 - P_guess)
两步之后还要叠一次"学习":把后验再往前推一步 P(L_t) = P(L_t|obs) + (1 - P(L_t|obs))·P_learn,表示"这次表现之后,候选人可能又学到了一点"。四个参数我定为:
| 参数 | 含义 | 取值 |
|---|---|---|
P_learn |
一次练习后从"不会"变"会"的概率 | 0.12 |
P_guess |
不会却蒙对的概率 | 0.20 |
P_slip |
会却答错(口误 / 紧张)的概率 | 0.10 |
P(L₀) |
初始掌握度先验 | 0.35 |
为什么是 BKT 而不是 Elo / 滑动平均? 面试场景有两个噪声:候选人可能蒙对 、也可能懂却口误 。Elo 和滑动平均只盯着"对错"二值,会把蒙对误判成掌握、把口误误判成不会。BKT 用 P_guess 和 P_slip 把这两种噪声显式建模 进贝叶斯更新里,掌握度估计更抗噪;而且它是 per-skill 的二值观测模型,和"每轮一个问题对应一个技能点"的面试结构天然契合。这样跨多次面试,系统能画出每个人的技能成长曲线,而不是每次都从零评价。
七、核心链路三:面试后分析(多智能体)

面试结束不是终点。我把"面试后"拆成三个子图协作:
- 主图:整理整场面试的上下文(问答、评分、BKT 变化);
- 报告子图 :
WeaknessAnalysis Agent做短板分析,结合双轨评分和 BKT,产出结构化面试报告; - 训练子图 :
QuestionBank(ReAct 模式)从题库检索 / 生成针对性练习,TrainingPlanner产出个性化训练计划。
输出是面试报告 + 训练计划两份交付物。
工程上两个要点:
- 灰度 rollout:新报告链路先小流量验证再全量,降低 LLM 不可控带来的风险;
- 确定性兜底:覆盖率、量化检查等仍用规则算,LLM 只做增值判断,结果可复现。
八、工程亮点与取舍
(1) 确定性兜底 + LLM 增值(全链路贯穿)
分块用规则、query 改写用规则、评分有规则分、匹配有规则分。LLM 只在"需要语言理解"的地方增值。这套设计让系统在 LLM 抖动 / 超时时不至于整体崩,且结果可复现、可调试。
(2) 全链路 fail-open 降级
外部依赖(LLM / Embedding / Reranker / Redis / Zilliz)全部 fail-open------核心原则是底层数据真源 fail-closed、上层增强能力 fail-open:PostgreSQL 不可丢,但 LLM、向量检索这类"锦上添花"的能力挂了不能拖垮业务。一张降级对照表:
| 故障点 | 正常路径 | 降级路径 | 业务影响 |
|---|---|---|---|
| LLM 主模型超时 | Qwen 生成追问 / 评分 | 重试 → 降级小模型 / 兜底模板 | 体验降级,流程不中断 |
| Embedding 失败 | 稠密向量 ANN 召回 | 退回纯稀疏 BM25 召回 | 语义召回变弱,关键词仍可用 |
| Reranker 失败 | gte-rerank-v2 精排 | 退回 WeightedRanker 融合分排序 | 排序略粗,不阻塞 |
| Redis 挂 | 缓存 / 限流 / 黑名单 | 直查 PG + 限流 / 黑名单放行 | 丢"加速"与"保护",业务正常 |
| Zilliz 不可用 | 向量检索 | 退回 PG 全文关键词检索 | 召回变窄,面试仍可继续 |
系统永远不把"增强能力不可用"变成"业务不可用"------这也是为什么 Redis、Reranker 这些都被设计成可插拔、可降级。
(3) Redis 是可插拔增强层,不是真源
Redis 用在四处:JWT 黑名单(注销撤销)、接口限流(Lua 滑动窗口,多实例集中)、LLM / 查询缓存、面试遥测计数。它默认关闭、可降级,挂了只丢"加速"和"保护",不丢业务。这也是为什么它不进 PostgreSQL 的原因------黑名单和限流要的是毫秒级 + 自动过期 + 原子操作,Redis 的 TTL / INCR / Lua 天然契合。
(4) checkpoint 可插拔
LangGraph 的 checkpointer 抽象让"断点续传 / 版本隔离"可以随部署环境切换后端,不绑定某一存储。
九、总结与反思
OfferAI 给我最大的收获,不是"用了一个新模型",而是把一个开放式的 AI 对话,收敛成一个可控、可恢复、可评估的状态机系统。几个关键认知:
- 能规则就不要 LLM:分块、query 改写这些高频、要稳定的环节,规则比模型更合适;
- RAG 的"混合"不是炫技:稠密 + 稀疏 + 重排,每一层都在补上一层的短板;
- 状态比聪明更重要:面试这种长流程,LangGraph 的持久化状态机比"一个超长 prompt"可靠得多;
- 可评估才能可优化:双轨评分 + BKT,让"面试表现"变成可量化、可追踪的数字,而不是一句"你表现不错"。
如果你也在做 AI Agent / RAG 相关的项目,希望这篇踩坑与取舍的记录对你有用。欢迎交流。