ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用LangGraph打造AI模拟面试Agent:状态机驱动的多轮对话实战

用LangGraph打造AI模拟面试Agent:状态机驱动的多轮对话实战 1. 项目整体设计与思路拆解1.1 “码上面试”到底想解决什么问题先把这个项目说清楚。“码上面试”是一个基于大模型的 AI 模拟面试 Agent核心场景就一个让求职者随时能和一名“不知疲倦的面试官”对话练习而不是对着题库刷题、背八股。我自己是做大模型应用开发的平时经常被朋友问“算法题刷了不少但一开摄像头面试就紧张怎么办”。这个痛点其实很普遍候选人缺的不是知识储备而是真实的面试节奏、追问压力、以及“被问倒之后如何临场调整”的能力。真人模拟面试约不到人、不好意思开口、费用也不低而普通聊天式 AI 工具又没有面试官该有的流程感——上来就答题答完也不追问更不会区分“你是在背题还是真的理解”。所以我才想做一个 Agent让它承担面试官角色先根据岗位和简历出题等你答完它会像真人一样追问、深挖、甚至打断最后给出评分和反馈。一句话总结这个项目不是“AI 题库”而是一个带流程、带状态、带记忆、带反馈的面试陪练智能体。1.2 为什么必须用 Agent 架构而不是堆 Prompt这是项目立项时最重要的一次技术判断。当时有两种方案摆在面前方案 A写一个超长的 System Prompt把所有面试规则、追问逻辑、评分标准全塞进去然后一次性调用大模型让它自己输出“问题追问评分”。方案 B用 Agent 架构把面试过程拆成多个阶段、多个节点每个节点单独调用模型节点之间通过状态流转。方案 A 看着简单实际根本跑不通。我试过把面试规则拆成十条写进 Prompt让模型“像面试官一样连续提问、追问、给反馈”结果输出质量极其不稳定。模型经常在一个回合里既出题又给答案追问完全随机评分的分数标准前后不一致。更可怕的是多轮对话一长模型会遗忘自己刚才问过什么问题同一个知识点换个说法又问一遍。方案 B 的思路完全不同。面试的本质是一个有明确状态机的多轮对话先筛选简历重点再出题然后根据回答决定追问还是切换方向最后统一评分。Agent 架构的优势就是把这种流程拆成可控的节点每个节点只干一件事节点的输入输出由程序接管。这样做的直接好处有三个第一状态可控模型永远知道现在处于面试的哪个阶段第二模型职责单一出题的模型不负责给答案评分的模型不负责提问幻觉率和漂移问题大幅下降第三整个流程可以被观测和记录哪个环节出问题直接定位。所以这个项目从一开始就确定了不允许让大模型“一步到位”而是让多个模型调用、多个环节拆解通过 Agent 编排协同完成面试闭环。1.3 技术选型为什么用了 LangGraph选型阶段我把主流框架都过了一遍。当时几个候选LangChain、LangGraph、MetaGPT、AutoGen还有后来火起来的各种产品级 Agent 平台。结论是如果是做生产级流程型 AgentLangGraph 比 LangChain 更合适比 MetaGPT/AutoGen 更可控。说几个我的实际观察。LangChain 本质上还是“链式调用”思维适合固定的顺序流程但面试场景里有分支、有循环、有条件跳转比如追问节点可能需要连续执行三次才结束LangChain 表达这种逻辑很别扭。MetaGPT 和 AutoGen 更像多智能体协作框架设计哲学是“多个角色互相聊天”——听起来很美但生产的不可控性太高。你没法保证两个 Agent 聊到第几轮会偏题也没法精确控制消息在什么时候算“聊完”。面试场景最忌讳的就是失控。LangGraph 的价值在于它把 Agent 流程建模成图结构StateGraph节点是你要执行的动作边是状态转移条件。翻译成人话就是你定义好“出题节点”“追问节点”“评分节点”再定义好“什么情况下从出题走到追问”“什么情况下直接结束”LangGraph 帮你把状态流转跑起来并且自动维护共享状态。这对于面试流程来说简直是对症下药。另外我选 LangGraph 还有一个私心它天然支持状态持久化和断点恢复。也就是说面试到一半服务重启了可以从最近的节点继续跑不用整轮重来。这点对后续做生产部署太重要了。2. 核心细节解析与实操要点2.1 面试流程的状态机设计整个 Agent 核心逻辑其实就是一个面试状态机。我没有一开始就写编码而是先用纸笔画状态流转。这一步非常关键画清楚状态再写代码和边写边想完全是两种效率。我的第一版状态机设计如下IDLE空闲Agent 等待用户输入岗位、工作年限、技术栈、简历要点。只有信息齐了才进入下一状态。KEYWORD_SCREEN关键词筛选从岗位描述和用户简历中提取核心技术关键词比如“Java 并发”“MySQL 索引”“项目难点”形成候选面试知识点池。QUESTIONING出题从候选知识池里选知识点生成一道面试题。这里有个小心思不是随机抽题而是参考用户自评薄弱项和简历经历优先出题。FOLLOW_UP追问深挖用户答题后进入。根据回答内容判断是继续追问当前题目还是换一个新方向的问题。追问有深度限制防止无限钻牛角尖。DONE本轮结束达到预设问题数量默认 10 个或用户主动结束。此时进入评分反馈阶段。IDLE - KEYWORD_SCREEN - QUESTIONING - FOLLOW_UP - QUESTIONING | DONE状态流转的触发条件才是灵魂。我定义了几个关键的边IDLE 到 KEYWORD_SCREEN用户提交岗位和基本信息后自动触发。QUESTIONING 到 FOLLOW_UP模型生成题目后等待用户回答用户提交回答内容后进入。FOLLOW_UP 内部循环模型判断回答质量——如果回答不完整或面试官认为可以再挖掘就继续追问最多追问 3 轮。FOLLOW_UP 到 QUESTIONING追问次数耗尽或模型判定该问题已没有价值切换到下一题。任何时候用户输入“结束面试/今天就到这”等指令直接进入 DONE。实际写代码时我建议把状态机设计文档先写清楚而不是直接在 LangGraph 里堆节点。因为 LangGraph 的图结构很容易越写越复杂如果没有状态定义文档约束等你加了第五个第六个节点时基本就谁也看不懂了。2.2 记忆机制短期上下文和长期画像分离Agent 项目一旦涉及多轮对话“记忆”就是绕不开的坎。面试场景的记忆需求其实分两层短期记忆当前这一面中面试官问过哪些问题、候选人的回答原文、追问过几轮。这部分必须完整保留在上下文中因为追问和评分都依赖它。我直接把这个放在 LangGraph 的 State 里维护本质上是一个消息列表节点间共享。长期记忆这个用户和 Agent 的所有历史面试记录。比如上周面试时他在“Redis 持久化”上答得不好这周再来练习时面试官应该主动再次考察这个点。这部分不能塞进上下文一是 Token 成本扛不住二是历史数据太多会影响当前回答质量。我用的方案是向量数据库存长期记忆只把检索出来的“用户画像”注入上下文。具体做法是每次面试结束 DONE 状态里把面试表现、薄弱知识点的总结写入向量库下次 IDLE 结束后先做一次相似度检索把用户的历史薄弱点提取出来追加到 KEYWORD_SCREEN 阶段的关键词池里。这个设计帮我解决了一个真实的问题面试 Agent 最怕“每次来都是新面试官”。没有长期记忆用户第三次练习时还在被问第一次就答过的问题体验极差。有了长期画像Agent 才有“这个候选人我逐渐了解”的感觉。2.3 出题质量这是面试 Agent 的生命线整个项目里最折磨我的不是 Agent 编排而是模型出题质量不稳定。这个问题不做 Agent 的人根本感受不到——你说“生成一道 Java 并发题”模型第一轮可能生成“讲讲 synchronized 原理”第二轮可能生成“讲讲 volatile 的内存语义”看起来没问题但第三轮它就突然出一道“请写一个使用 CompletableFuture 的异步编排示例”难度骤升毫无逻辑。这是我后来摸索出来的经验核心是把出题过程拆成两步第一步先让模型从知识点池里选方向输出是结构化的比如“Java 并发 - 锁机制 - synchronized 和 ReentrantLock 对比”。这一步不生成题目文本只做决策模型负担小稳定性高。第二步再让模型基于选定的方向生成题目内容。两步其实用一次 LangGraph 节点里的两次模型调用就能实现代价很小但质量提升非常明显。逻辑很简单决策和生成分开决策错不了生成就不会偏。而知识点池的来源也有讲究。我没有先定义一个固定题库而是用系统提示词让模型根据岗位描述现场提取 结合历史记忆中的薄弱点合成。固定题库根本覆盖不了用户千奇百怪的项目经验和岗位要求。3. 实操过程与核心环节实现3.1 环境准备与项目结构这是我实际的目录结构你可以直接参考code-interview-agent/ ├── agent/ │ ├── graph.py # LangGraph 状态图和节点编排 │ ├── nodes.py # 各节点函数定义 │ ├── state.py # State 数据结构定义 │ ├── prompts.py # 各环节的提示词模板 │ └── memory.py # 长期记忆的向量存取封装 ├── data/ │ └── user_profile.json # 当前用户的简历、岗位信息 ├── tests/ │ └── test_flow.py # 端到端流程测试 └── requirements.txt依赖方面我用了langgraph、langchain-openai、chromadb本地向量库开发期够用、pydantic定义结构化输出。模型默认用 OpenAI 兼容接口实际开发时可以随意切到本地模型或国内模型LangChain 的封装比较方便。建项目时有个坑要提醒环境依赖版本一定要锁死。LangGraph 这种框架还在快速迭代一个月内 API 能变三四次。我中途踩过一次大坑——某个版本里StateGraph初始化参数变了导致整个图构建报错排查了很久。建议装完依赖立刻pip freeze requirements.lock保存版本快照。3.2 核心代码定义状态和面试节点代码层面最关键的是 State 数据结构和节点实现。我先贴上核心代码再逐段解释。# agent/state.py from typing import TypedDict, Annotated, List from langgraph.graph.message import add_messages class InterviewState(TypedDict): # 用户输入信息 role: str # 目标岗位 experience: str # 工作年限描述 tech_stack: str # 技术栈 resume_points: List[str] # 简历重点提取 # 流程控制 stage: str # 当前状态IDLE/KEYWORD_SCREEN/QUESTIONING/FOLLOW_UP/DONE keyword_pool: List[str] # 知识点关键词池 question_count: int # 已问题目数量 follow_up_count: int # 当前题目的追问次数 max_questions: int # 总问题上限默认10 # 面试内容 current_question: str # 当前题目 current_module: str # 当前知识点模块 messages: Annotated[List, add_messages] # 对话历史 # 结果反馈 scores: List[dict] # 每一题的回答评分Annotated[List, add_messages]是 LangGraph 的语法糖自动把各节点返回的消息追加到全局消息列表里省去了手动拼接上下文的麻烦。这个设计是 LangGraph 的核心优势——你不用在节点里反复构造“现在上下文是什么”框架会维护。再来看节点实现# agent/nodes.py 节选 from agent.state import InterviewState from agent.prompts import QUESTION_SELECT_PROMPT, QUESTION_GENERATE_PROMPT from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o, temperature0.3) structured_llm llm.with_structured_output(QuestionSelectSchema) def keywords_screen_node(state: InterviewState): 从岗位和简历中提取候选知识点 res llm.invoke( KEYWORD_SCREEN_PROMPT.format( rolestate[role], tech_stackstate[tech_stack], resume_points; .join(state[resume_points]) ) ) # 假设输出格式是 关键词1,关键词2,... keywords [k.strip() for k in res.content.split(,) if k.strip()] return {keyword_pool: keywords, stage: QUESTIONING} def questioning_node(state: InterviewState): 选知识点 - 生成面试题 # 第一步决策选哪个知识点 select_res structured_llm.invoke( QUESTION_SELECT_PROMPT.format( keyword_poolstate[keyword_pool], historyformat_questions(state[messages]) ) ) # 第二步基于选定知识点生成题目 gen_res llm.invoke( QUESTION_GENERATE_PROMPT.format( moduleselect_res.module, keywordselect_res.keyword, rolestate[role], difficultyselect_res.difficulty ) ) return { current_question: gen_res.content, current_module: f{select_res.module}:{select_res.keyword}, stage: FOLLOW_UP, }这里有个容易忽视的细节questioning_node里我没有让模型直接说“开始答题吧”之类的废话而是把生成的问题作为纯文本返回。这保证了前端拿到的就是题目本身不用再做二次清洗。再来看追问和评分节点。追问节点是最容易失控的环节因为模型天然爱话痨。我的处理方式是强制追问使用结构化输出严格限定字段。def follow_up_node(state: InterviewState): 判断是否继续追问并生成追问内容 # 语义化判断answer_quality 是 low/medium/high judge_res structural_llm.invoke( FOLLOW_UP_JUDGE_PROMPT.format( questionstate[current_question], answerstate[messages][-1].content, follow_up_countstate[follow_up_count] ) ) if judge_res.action stop or state[follow_up_count] 3: # 当前题目结束切下一题 return {stage: QUESTIONING, follow_up_count: 0} return { current_question: judge_res.follow_up_question, follow_up_count: state[follow_up_count] 1, stage: FOLLOW_UP }结构化的核心意义在于把“模型自由发挥”变成“模型做选择题”。模型只需要判断 action 是 continue 还是 stop再输出一个追问问题。即使追踪失败也不会出现模型自问自答的情况。3.3 把节点组装成图节点写完后LangGraph 组装就非常直观了# agent/graph.py from langgraph.graph import StateGraph, END from agent.nodes import ( keywords_screen_node, questioning_node, follow_up_node, final_review_node, entrance_node ) graph StateGraph(InterviewState) # 添加节点 graph.add_node(entrance, entrance_node) graph.add_node(keywords_screen, keywords_screen_node) graph.add_node(questioning, questioning_node) graph.add_node(follow_up, follow_up_node) graph.add_node(final_review, final_review_node) # 设置入口 graph.set_entry_point(entrance) # 定义状态转移边 graph.add_edge(entrance, keywords_screen) graph.add_edge(keywords_screen, questioning) graph.add_edge(questioning, follow_up) # 条件转移追问结束后根据是否达到题目上限决定下一步 graph.add_conditional_edges( follow_up, lambda state: final_review if state[question_count] state[max_questions] or state[stage] DONE else questioning, {final_review: final_review, questioning: questioning} ) graph.add_edge(final_review, END) app graph.compile()这段代码有一个地方特别注意我在questioning_node返回时把question_count加 1 了吗没有。它是在follow_up_node里当决定“当前题目结束切下一题”时才把question_count加 1。这样统计逻辑才是正确的——每个题目的多轮追问只算一题而不是每个追问回合都算一次。3.4 提示词设计少讲规则多给范例我用了大量篇幅来写提示词因为 Agent 项目的质量几乎完全取决于提示词设计和结构化约束。分享两个核心提示词的思路。第一个是出题方向选择的结构化提示词你是一名资深技术面试官。请根据给定的知识点池中选择一个最值得考察的知识点。 选择原则 1. 优先选择候选人简历中出现过的技术点 2. 优先选择历史面试中表现薄弱的领域 3. 避免连续两题考察同一个大类知识点 请严格输出以下 JSON 结构 { module: 知识大类, keyword: 具体知识点, reason: 选择理由20字以内, difficulty: easy/medium/hard }第二个是追问判断的提示词这个比较反直觉。我没有让模型“进行追问”而是让它先“评估回答质量”再根据评估决定是否追问、追问什么。你是一名严格的面试官。根据用户的回答判断是否需要继续追问。 判断标准 - 如果回答含糊不清、没有结合项目实例、只给了结论没给推导过程请追问加深难度。 - 如果回答完整、有结构、有项目案例支撑请停止追问给出简短的点评并准备下一题。 - 如果已经追问了3轮仍未得到满意回答也已经给出了足够的考察机会请停止追问。 输出格式 { action: continue 或 stop, follow_up_question: 仅当 action 为 continue 时输出追问的具体问题, quality_summary: 用一句话概括用户回答的质量 }之所以让模型先输出action再做判断是因为结构化输出天然逼迫模型“先决策后生成”这跟人的思考逻辑一致模型输出会更稳定。3.5 先跑通表层流程再优化细节第一版在本地跑通端到端流程花了大概一个周末。强烈建议你在这个阶段不要追求完美先把“开始的问好→出题→答题→追问→下一题→结束评分”这个主链路跑通。哪怕追问逻辑写得糙一点哪怕提示词只写 30 个字先把流程串起来后面替换和优化都会很快。我第一版跑通时的用户体验其实很简陋没有前端直接命令行输文字。但正是这个简陋版本让我验证了最核心的问题——Agent 架构的流程编排是否成立。当时我盯着终端看到模型按顺序出题、追问、评分心里的石头才真正落地。做 Agent 项目第一版跑通的价值远大于第一版做好。先证明流程是通的再回来填坑效率最高。4. 常见问题与排查技巧实录4.1 模型出题重复和偏题这是初期最头疼的问题。症状是前几题正常第五题开始就突然复制第一题的题目或者完全偏离用户的技术栈。我的排查思路分三层第一层是不是上下文太长导致模型“遗忘”了早期的问题模型的注意力天然集中在最近的 token 上历史问题列表太长时重复概率显著上升。我在questioning_node里加了历史问题摘要把已经问过的题目浓缩成“已考察Java 并发锁机制、MySQL 索引优化、Redis 缓存一致性”再传给生成 Prompt。这个改动效果立竿见影。第二层是不是知识点池太窄如果只提取出五六个关键词模型来回选当然容易重复。我后来把知识点池规模从 5 个扩到 15 个以上并在关键词筛选取了岗位描述中更细粒度的词。第三层模型 temperature 设置过高导致随机性太强。我在出题节点用 temperature 0.3在追问节点用 0.5评分类节点用 0各节点温度分开控制。4.2 追问失控模型自己问自己答这个 Bug 特别有意思。现象是用户答完题后模型输出的内容是“对于您的回答我有一个追问balabala您认为呢”。看起来像在追问实际上模型把预设答案也带出来了——有时候追问里直接包含了答案那这个面试就失去了练习意义。原因是 LangGraph 的messages累计了所有模型输出模型在生成下一轮内容时看到自己的回答以为那是面试官的话于是开始“配合演出”。解决方案有两步第一在follow_up_node传入 Prompt 时只传最后一条问答对和全局摘要不传全部历史第二在追问提示词里显式声明“这是上一轮候选人的回答请不要替候选人回答只输出你的追问”。这里也体现了一个 Agent 开发的通用原则不要无脑把所有上下文全塞给模型。不同节点有不同需求传送必要上下文既能省 Token又能防模型上下文污染。4.3 并发压力开发期看不出上线前必须想清楚很多人在开发阶段根本遇不到并发问题——本地一个用户测试单机请求响应再慢也无所谓。“AI Agent 怎么扛并发”这个问题我在这个项目中也是不得不面对后才真正理解的。面试场景一旦上线哪怕是几个朋友同时试用每个面试过程都是多轮模型调用而不是单次请求。一个用户一轮面试要调用十几次 LLM10 个用户同时面试后端就是上百次模型请求这个并发压力对架构有很大的挑战。我的实际经验是先用队列和限流保护服务再谈扩展。本地开发时无所谓但部署阶段第一步是做并发控制——用 Redis 做请求队列控制同时进行的面试会话数量。第二是模型调用层做好超时和重试。LangGraph 节点里调用 LLM 时如果底层模型服务超时整个面试线程会卡死用户看到的就是“面试官消失了”。我在所有 LLM 调用外面包了一层带超时和重试的工具函数超时重试 2 次还失败就返回“请重试”的系统消息而不是让线程挂起。第三是考虑模型服务的可扩展性。如果你用的是自家部署的模型服务多个工作节点并排加载模型副本是个方向如果你用的是 API 类模型则主要依赖服务商的并发配额注意控制单个用户接口的 QPS。并发问题没有银弹但有一条原则我强烈建议Agent 的编排层要做成无状态的会话状态放到外部存储Redis/数据库这样任务水平扩展时才能把不同用户的会话分发到不同实例上而不丢上下文。4.4 安全与输出规范Agent 收不住是事故Agent 项目的另一大坑就是模型可能生成不合规内容。面试场景里虽然不太会出现极端内容但“面试官语言怼人”“出现不礼貌的点评”这类问题还是真实存在的。“Agent 安全”这四个字看着虚实际指的就是这些具体问题。我做了三层防护。第一层System Prompt 里强制约束面试官必须专业、中立、友好禁止任何人身评价禁止贬损用户能力。第二层所有模型输出在返回给用户前过一道合规校验。我在final_review_node生成的评语会走一个小型文本判断如果发现极端负面词汇就重写。第三层最有用——在评分 Prompt 里明确规定评价的结构。比如“优点最多写两点改进建议必须写具体可操作的措施禁止抽象评价”。结构限定远比“请友好”这类抽象指令可靠。另外一个容易被忽略的安全点是隐私。面试记录包含用户的简历和回答内容这些数据比普通聊天记录敏感得多。长期记忆存向量库时我没有存简历原文只存“技术点表现评估”这类脱敏摘要用户可随时要求清空。这些设计都体现了 Agent 从“Demo”走向“可用”必须付出的额外成本。5. 项目落地后的反思和下一步能做深的地方现在回头复盘这个第一版项目的最大收获不是技术选型多正确而是我摸清了一个多轮对话 Agent 从设计到落地要经过哪些坎。对我个人而言这个项目最超出预期的点是 LangGraph 的调试体验。它自带的可视化面板LangSmith 或 LangGraph Studio能把每次节点流转、状态变化、模型调用都呈现出来。当你的 Agent 逻辑越来越复杂时这种可视化带来的排查效率提升远大于自己打印日志慢慢看。下一步我准备扩展的方向有三个。第一是引入多 Agent 分工把“面试官”拆成“面试官 Agent 评分专家 Agent 追问策略 Agent”。目前是一个节点干多件事模型调用次数越多越容易出岔子拆分后每个 Agent 专职一个环节可调性和稳定性都会更好。第二是接入Agent Skill 机制比如让面试官能调用一个“代码执行工具”来预设一些需要实际运行的代码场景。第三是给长期记忆加更丰富的画像维度不止记录弱点还记录用户偏好的学习节奏、擅长的表达风格真正做到千人千面的面试体验。玩 Agent 项目这一年多我最大的体会是Agent 不难在“调模型”难在“设计可控的流程”和“管理不可控的输出”。这也是为什么很多简单 Demo 很惊艳但一上生产就翻车的根本原因。希望这份学习记录能给你一些借鉴。
返回列表