从 0 到 1 构建 AI 模拟面试系统:RAG + LangGraph 的工程实践

本文记录我做的项目 OfferAI------一个面向求职者的 AI 模拟面试系统。它把简历解析、检索增强生成(RAG)、LangGraph 面试编排、实时语音交互、技能掌握度建模串成一条完整的产品链路。下面从架构到核心模块,讲清楚每个技术决策背后的"为什么"。


一、项目背景:为什么要做这个

求职者准备面试时有两个痛点:不知道自己会被问什么 ,以及答完之后不知道自己差在哪。市面上的"AI 面试官"大多只做"问答机器人"------你把简历丢进去,它随便问几个问题,给个模糊评价就结束。

OfferAI 想做的是一个能闭环的产品

  1. 简历解析 → 把 PDF 简历变成可检索、可诊断的结构化上下文;
  2. 匹配诊断 → 拿简历和目标岗位 JD 比对,告诉你缺哪些技能;
  3. 模拟面试 → 用 LangGraph 编排一场有状态、能追问、能打分的面试;
  4. 评分复盘 → 每轮评分,并用 BKT(贝叶斯知识追踪)建模你的技能掌握度;
  5. 训练计划 → 面试后多智能体产出短板分析和针对性训练题;
  6. 成长追踪 → 技能掌握度随每次面试更新,形成成长曲线。

这不是"调一个 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_stateresume_context / jd_context / candidate_profile(注入的上下文)、next_action(路由决策)。所有节点读它、改它,这就是"有状态"的载体。
  • 条件边路由decide_next 节点不硬跳,而是返回一个路由值,add_conditional_edges 把它映射到 continue(继续追问)/ summary(进入总结)/ end(结束)。流程走向由数据驱动,而非写死。
  • compile + interruptbuilder.compile(checkpointer=...) 编译;wait_answer 节点用 interrupt_before 挂起,释放资源等用户作答,用户回来后从断点 invoke 续跑------这就是人机协作断点,也是为什么 WebSocket 重连能无缝继续。
  • 流式透出astream_eventsgenerate_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_guessP_slip 把这两种噪声显式建模 进贝叶斯更新里,掌握度估计更抗噪;而且它是 per-skill 的二值观测模型,和"每轮一个问题对应一个技能点"的面试结构天然契合。这样跨多次面试,系统能画出每个人的技能成长曲线,而不是每次都从零评价。


七、核心链路三:面试后分析(多智能体)

面试结束不是终点。我把"面试后"拆成三个子图协作:

  • 主图:整理整场面试的上下文(问答、评分、BKT 变化);
  • 报告子图WeaknessAnalysis Agent 做短板分析,结合双轨评分和 BKT,产出结构化面试报告;
  • 训练子图QuestionBank(ReAct 模式)从题库检索 / 生成针对性练习,TrainingPlanner 产出个性化训练计划。

输出是面试报告 + 训练计划两份交付物。

工程上两个要点:

  1. 灰度 rollout:新报告链路先小流量验证再全量,降低 LLM 不可控带来的风险;
  2. 确定性兜底:覆盖率、量化检查等仍用规则算,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 相关的项目,希望这篇踩坑与取舍的记录对你有用。欢迎交流。


相关推荐
空堂与归1 小时前
分类问题怎么建模?用逻辑回归实现概率预测
人工智能·机器学习·分类·逻辑回归
ShineWinsu2 小时前
对于C++:布隆过滤器(bloomfilter)的详细解析
c++·面试·位图·位运算·布隆过滤器·海量数据·比特位
VisualComponents2 小时前
SEJFO依托Visual Components仿真工具,实现自动化业务销售全面升级
人工智能·机器人·工业自动化·工厂仿真·机器人离线编程
面包狗AI4S2 小时前
GitHub AI4S 项目观察(2026-08-07—2026-08-13)
人工智能·github
AI办公探索者2 小时前
多智能体协作架构:从单Agent到多Agent协同的技术演进
人工智能·ai·架构
kyriewen2 小时前
我把 AI 写的并发请求控制器手写了一遍——3 个语义我当时根本讲不清
前端·javascript·面试
V哥AI增长3 小时前
宠物行业GEO机制:AI如何推荐医院与产品
人工智能·搜索引擎·宠物
baidu_259339573 小时前
智慧消防管理系统平台(基于物联网与大数据的城市消防安全解决方案)
大数据·人工智能·物联网·云计算·智慧消防·力安科技·gdliontech.cn