模拟面试智能体开发实战 04:面试聊到哪算哪?用状态机把整场面试编排起来

0 阅读20分钟

七环节面试流程链

前两篇把面试官的"嘴"和"人设"都调好了,我把整套东西接进服务端做内测,第一位同事聊了三分钟就退出了。我看他的会话日志,看到了三个事故现场:

第一,他刚做完自我介绍,面试官直接甩了一道场景题,技术基础一行都没问。因为我把整场面试的流程指令全塞进了一个大 prompt:请按"开场、自我介绍、技术、项目、场景"的顺序来。模型点头答应得很好,然后随缘执行。

第二,他中途打了一行"我想先问你个问题",面试官回了一句"好的,请继续做自我介绍"。因为流程意图全靠模型自己脑补,它根本没接到"用户想反问"这个信号该怎么处理的指令。

第三,他面到第七轮,App 切后台太久,服务把连接断了。重连之后面试官说"你好,请先做个自我介绍"——前面七轮全忘了,因为我把对话状态存在请求处理函数的局部变量里,请求一断,状态陪葬。

那天晚上我把日志翻了一遍,还发现第四个问题藏在更深处:所有轮次的处理逻辑长在同一个函数里,一个 if 套一个 if,我想知道"第三轮为什么跳去了场景题",只能从第一行开始人肉追踪变量的值。

这四个问题其实是同一个病:整场面试的控制流,没有被当成一个工程对象来对待。 这一篇我用 LangGraph 把它重写成一张状态机——环节推进变成条件边,"聊到哪算哪"变成可断言的路由规则,断线续面变成一个 thread_id。写完之后,上面四个事故每一个都有一条测试看着。

一、先扫盲:流程图和状态机差在哪

多数人画过流程图,但没用过状态机。流程图是给人看的,状态机是给程序走的:它在运行时真的持有"我现在在哪个环节",每走一步都改这个状态,而且每一步去哪是由"当前状态 + 刚发生的事"算出来的。

面试为什么天然是状态机?你看"下一问去哪"这个决策的输入:

  • 当前在哪个环节(technical?project?)
  • 这个环节的题数用完没有
  • 候选人刚说的这句话是不是在反问
  • 整场问了多少题,有没有碰到总闸

没有一条是写死的顺序,全是读完上下文才能做的判断。这正是状态机的条件边要干的事。

LangGraph 把状态机拆成三件套,术语先记住:

概念是什么在本篇里
State图中流动的那份数据,所有节点共享读写InterviewState:对话历史、环节、轮数、错误、路径
Node一个函数:进 State,返回"部分更新"七个节点:入口守卫 + 六个环节关口
Edge节点之间怎么走固定边(说完话就结束本轮)+ 条件边(读完状态才决定去哪)

本篇有两个重头戏。

条件边(add_conditional_edges)。 普通边写死"A 完了去 B";条件边挂一个路由函数,每次都拿当前 State 问它"下一步去哪"。环节推进、中途反问跳转、总轮次熔断,全是它。还有个容易被略过的细节:它的第三个参数是张映射表,路由函数返回什么字符串就走表里对应的去处,表里没登记的返回值当场报错。函数里的 if 漏写一个 else 只会静默跳过,图里则立刻爆炸——我更喜欢后者。

checkpoint(MemorySaver + thread_id)。 编译图时挂一个 MemorySaver,每次运行用 thread_id 标记,LangGraph 自动把每一步的 State 存档。同一个 thread_id 再 invoke,先捞出上次的接着算;换一个 thread_id 就是一场全新面试。断线续面、服务重启不作废,靠的都是它。

条件边决策

还有一件事必须先掰清:State 不是"上帝变量"。 上帝变量是一个谁都能写、什么时候被改了没人知道的大字典。State 是一份有主人的契约——每个字段写明归谁管、新值是替换还是追加。原则就一句:每个节点只写自己那几个字段。

二、面试环节图与工程目录

整场面试七个环节,按顺序推进:

开场 → 自我介绍 → 技术基础 → 项目深挖 → 场景/设计题 → 候选人反问 → 结束

每个环节一道关卡,各环节题数可配置(默认技术 2、项目 2、场景 1、反问 2)。其中"开场"和"自我介绍"由同一个节点接续完成:面试官说完开场白顺手把话筒递给自我介绍,候选人接话;"结束"环节说收尾语并预告逐题点评和综合报告。

04-面试流程状态机/
├── src/
│   ├── settings.py   # 复用第 1 篇:硅基流动 base_url + DeepSeek-V3
│   ├── llm.py        # 复用第 1 篇:真模型 / 离线假模型 两条通路
│   ├── state.py      # InterviewState:整场面试唯一的数据契约
│   ├── nodes.py      # 七个节点纯函数 + build_nodes(llm) 工厂
│   ├── graph.py      # 装配:入口守卫、条件边推进规则、MemorySaver
│   └── runner.py     # run_interview(候选人这轮的话, llm, thread_id)
├── tests/            # 56 个用例,离线全绿
├── demo.py           # 正常推进 / 中途反问 / checkpoint 续面
└── pytest.ini / requirements.txt / README.md / .env.example / .gitignore

分层一句话:state 定契约,nodes 干活,graph 定路线,runner 把结果和路径一起交出来。

这个设计里有个关键决定要先说:一次 invoke = 一轮对话。 候选人一句、面试官一句。面试官说完话本轮就到 END;候选人下一句话作为下一次 invoke 的输入进来,过入口守卫,由条件边决定这轮进哪个环节。"面到一半停下"不是异常,是这个模型的常态——这也正是断线续面能做得这么自然的原因。

三、先写契约:state.py

图还没搭,我先写 State。七环节的推进规则、题数配置、总轮次上限,全部落在这一份结构里:

# 每个考察环节默认题数:面试官在本环节开口的次数
DEFAULT_MAX_TURNS_PER_STAGE: dict[str, int] = {
    STAGE_TECHNICAL: 2,       # 技术基础
    STAGE_PROJECT: 2,         # 项目深挖
    STAGE_SCENARIO: 1,        # 场景/设计题
    STAGE_QUESTIONS: 2,       # 候选人反问
}

# 整场面试的总轮次上限:不管各环节怎么配,绝不让面试无限问下去
MAX_TOTAL_QUESTIONS = 8


class InterviewState(TypedDict):
    """一场面试在图中流动的全部数据。"""

    # ── 累加字段(Annotated + add)──
    messages: Annotated[list[BaseMessage], add]   # 对话历史,只追加
    errors:   Annotated[list[str], add]           # 节点异常,谁出事谁追加
    trace:    Annotated[list[str], add]           # 走过的节点名,按顺序累积

    # ── 环节与推进(覆盖语义)──
    stage: str                    # 当前环节,换环节就整体替换
    stage_turns: int              # 本环节面试官已开口次数
    questions_asked: int          # 整场已提问总数,总闸的计数器
    current_question: str         # 面试官最近一次说的话
    max_turns_per_stage: dict[str, int]  # 各环节题数,调用方可覆盖
    finished: bool                # 收尾标志,True 后同 thread 再说话直接结束
    error: str                    # 入口守卫判死的硬错误,非空即短路

重点讲覆盖 vs 累加,这是 LangGraph 状态合并的核心规则:没写 Annotated 的字段一律覆盖,写了 Annotated[..., add] 的按 reducer 合并。

哪些该覆盖?stage、stage_turns 这些环节推进字段——环节推进就是"换成新值",绝不能和旧值混在一起。哪些该累加?messages、trace、errors——对话历史、节点路径、错误记录只许追加,谁也不许清空别人的。要是把 trace 写成覆盖语义,你会发现日志里永远只剩最后一个节点,等于没有日志。

配套的 empty_state() 给每个字段安全初始值。原因很小但很疼:累加字段的初始值必须是空列表,覆盖字段给空字符串和 0——别让第一轮对话就在 None 上炸给你看。

四、七个节点:nodes.py

节点只有三条硬规矩:入 State、出"部分更新" dict;内部调模型必须 try/except 转 errors 字段;节点自己不知道下一步是谁。

先看入口守卫。手机端最容易误触发送,空输入不该烧一次模型调用:

def check_input(state: InterviewState) -> dict:
    """入口守卫:空输入和误触发送,挡在模型调用之前。

    第一轮(开场前)候选人可以不说话——面试官本来就该先开口。
    """
    stage = state.get("stage") or STAGE_OPENING
    messages: list[BaseMessage] = state.get("messages") or []
    if stage == STAGE_OPENING and not messages:
        return {"error": "", "trace": ["check_input"]}

    last = messages[-1] if messages else None
    if not isinstance(last, HumanMessage):
        # 面试官说完话,列表末尾是 AIMessage——这轮候选人什么都没说
        return {"error": "候选人这一轮没有说话。", "trace": ["check_input"]}
    text = str(last.content).strip()
    if len(text) < MIN_REPLY_LEN:   # 一个字的"嗯"是误触
        return {"error": f"候选人输入太短({len(text)} 字),请展开说说。",
                "trace": ["check_input"]}
    return {"error": "", "trace": ["check_input"]}

这里有个容易写错的地方:判"这轮说没说话",看的不是最后一条 HumanMessage,而是消息列表的最后一条。面试官说完话后,末尾是 AIMessage;候选人这轮说了话,他这句就是新的末尾。拿"最后一条 HumanMessage"来判断,空输入时会错拿上一轮的发言——这个坑我踩过,第五节细说。

再看六个环节节点共用的骨架。每个节点的差异只有"这一轮的指令"和"自己名下的推进字段":

def _speak(state, *, node_name: str, instruction: str, **updates: object) -> dict:
    """节点共用的骨架:拼提示词 → 调模型 → 出部分更新。"""
    history = "\n".join(
        f"{'候选人' if isinstance(m, HumanMessage) else '面试官'}:{m.content}"
        for m in (state.get("messages") or [])[-12:]
    )
    try:
        reply = llm.invoke([SystemMessage(INTERVIEWER_SYSTEM),
                            HumanMessage(f"对话记录:\n{history}\n\n本轮任务:{instruction}")])
        answer = str(reply.content).strip()
    except Exception as exc:  # 异常一律转字段,不抛到图外
        # 失败时不写 stage 等推进字段:条件边下次还会把候选人送回同一环节,
        # 相当于"这个问题没问出口,重新问一遍",而不是整场面试作废。
        return {"trace": [node_name],
                "errors": [f"{node_name} 调用失败:{type(exc).__name__}: {exc}"]}
    result: dict = {"messages": [AIMessage(answer)],   # 面试官的话进历史,只追加
                    "trace": [node_name], "current_question": answer}
    result.update(updates)   # 各节点自己负责的字段写在这里
    return result

三个设计决定值得单说。

第一,异常转字段,且失败不推进。 模型超时、Key 失效都是运行时常态,抛出去整场面试当场崩。转成 errors 字段之后还有一层:失败分支故意不写 stage——环节没推进,下一次 invoke 条件边还会把候选人送回同一环节,天然重试。八百行函数时代一个 try 包住全部流程,出了错你根本不知道是第几步炸的;现在每个节点自己兜自己。

第二,人设来自第 3 篇。 INTERVIEWER_SYSTEM 是四层人设的精简版,保留最影响行为的几条:一次只问一个、不鼓励不安慰、追问必须挂钩上一条回答。完整版在那一篇的工程里,两篇的人设是同一套思路的两个实例。

第三,环节轮数靠"我是不是刚进这个环节"来算。 节点不需要知道条件边为什么把自己送来:

def _enter_or_continue(state: InterviewState, stage: str) -> int:
    """接续就 +1,新进环节就从 1 起数。"""
    if state.get("stage") == stage:
        return state.get("stage_turns", 0) + 1
    return 1

ask_technical、probe_project、scenario_question 三个节点长一个样,只是环节名和指令不同;allow_candidate_questions 多一个分岔——候选人这条消息带反问关键词("我想问""想请教"……)就认真回答他的问题,不带就主动发起反问环节,把提问权交出去。close_interview 最简单:说收尾语,把 finished 拨成 True。

所有节点包在 build_nodes(llm) 工厂里:测试传假模型,生产传真模型,节点代码一行不改。这个习惯从第 1 篇沿用到今天。

五、接线:graph.py

节点是散的,图把它们串起来。先看条件边的大脑——路由函数,推进规则从上到下就是优先级:

def route_after_check_input(state: InterviewState) -> str:
    """本轮的核心路由:读完整 State,决定这轮进哪个环节。"""
    if state.get("error"):
        return GO_ABORT                                  # 1. 硬错误 → 短路
    if state.get("finished"):
        return GO_DONE                                   # 2. 已收尾 → 结束
    stage = state.get("stage") or STAGE_OPENING
    turns = state.get("stage_turns", 0)

    if state.get("questions_asked", 0) >= MAX_TOTAL_QUESTIONS:
        return "close_interview"                         # 3. 总闸 → 强制收尾
    if stage == STAGE_OPENING:
        return "open_interview"                          # 4. 第一轮 → 开场白
    if stage != STAGE_QUESTIONS and is_asking(last_candidate_text(state)):
        return "allow_candidate_questions"               # 5. 候选人在反问 → 跳过去
    if stage == STAGE_SELF_INTRO:
        return "ask_technical"                           # 6. 介绍完 → 进技术

    if stage in (STAGE_TECHNICAL, STAGE_PROJECT, STAGE_SCENARIO):
        if turns < _budget(state, stage):
            return _STAGE_NODE[stage]                    # 7. 题数没用完 → 原地追问
        next_stage = _NEXT_STAGE[stage]
        return _STAGE_NODE[next_stage]                   # 8. 用满了 → 下一环节

    if stage == STAGE_QUESTIONS:
        if not is_asking(last_candidate_text(state)) and turns >= 1:
            return "close_interview"                     # 9. 说"没有了" → 收尾
        if turns >= _budget(state, STAGE_QUESTIONS):
            return "close_interview"                     # 10. 反问轮数用完 → 收尾
        return "allow_candidate_questions"

    return "close_interview"                             # 11. 兜底:宁可提前结束

两个条件必须当成一对看:第 7 行的 turns < _budget(...) 是"还有没有必要留在这个环节",第 3 行的总闸是"整场还有没有预算"。少了总闸,把各环节题数配成 99,候选人再配合一点,图就永远转下去——LangGraph 自带的 recursion limit 会在几千步之后报错,那等于白烧几千轮 token,生产上不能指望框架替你刹车。

装配本体短得像声明:

builder = StateGraph(InterviewState)
for name in NODE_NAMES:
    builder.add_node(name, nodes[name])

builder.set_entry_point("check_input")
# 条件边:环节推进规则全部在这张映射表后面
builder.add_conditional_edges("check_input", route_after_check_input, ROUTE_MAP)

# 每个环节节点说完话,本轮就结束,等候选人的下一句话
for name in NODE_NAMES:
    if name != "check_input":
        builder.add_edge(name, END)

return builder.compile(
    checkpointer=checkpointer if checkpointer is not None else MemorySaver(),
)

读法从上到下:七个节点挂进图;入口钉在守卫上;一条条件边管全部去向(六个环节节点 + 短路结束);每个环节节点有一条固定边通向 END——面试官说完话,本轮收工。ROUTE_MAP 是那张映射表,把路由函数所有可能的返回值显式列了一遍,拼错一个字符串当场 KeyError。

build_graph(llm) 做成工厂,默认送一个 MemorySaver。想模拟"断开再续上",只要把同一个 checkpointer 传进新建的图——这个用法在 demo 案例三里你会看到。另送一个白嫖福利 get_mermaid(graph):一行代码把当前拓扑导成 mermaid 文本,图改了文档不用手动画。

六、运行入口:runner.py

排障时你要问的从来不是"面试官最后说了什么",而是"它到底走了哪几步、在哪一步拐的弯"。所以 run_interview 把路径和状态一起交出来:

def run_interview(message: str, llm, thread_id: str = DEFAULT_THREAD_ID,
                  *, checkpointer: object | None = None) -> dict[str, Any]:
    """跑一轮面试:候选人这轮说的话 → 面试官回话 + 全场状态。

    想模拟"服务重启后续面",就传同一个 checkpointer 进来——
    图对象是新造的,档案还是原来那份。
    """
    compiled = build_graph(llm, checkpointer=checkpointer)
    final = compiled.invoke(make_input(message), config=_config(thread_id))
    return {
        "path": list(final.get("trace", [])),      # 全场走过的节点顺序
        "stage": final.get("stage", ""),           # 停在哪个环节
        "stage_turns": final.get("stage_turns", 0),
        "questions_asked": final.get("questions_asked", 0),
        "current_question": final.get("current_question", ""),
        "finished": bool(final.get("finished", False)),
        "error": final.get("error", ""),
        "errors": list(final.get("errors", [])),
    }

thread_id 就是 checkpoint 的钥匙:同一个 thread_id 再 invoke,LangGraph 先把上次的 State 捞出来接着算;换一个就是全新面试。path 的形状一眼可辨:一段一段 check_input → 某环节 是正常对话,突然出现 allow_candidate_questions 是候选人反问过,末尾是 close_interview 说明收了尾。

checkpoint 续面

七、跑起来:真实输出

python demo.py,全程离线:面试官台词来自 build_llm 的离线通路(预置脚本,和真实模型走同一条代码路径),候选人台词写在用例里。真实跑法只是把 offline_responses 换成 .env 里的硅基流动 Key,图的代码一行不改。

案例一,正常推进,注意每轮的环节和轮数怎么自己走:

候选人:(先不开口,等面试官说)
面试官:你好,我是今天的面试官。请先用一分钟做个自我介绍。
  本轮节点:check_input → open_interview|环节 self_intro 第 0 轮|全场已问 0 题

候选人:GIL 是全局解释器锁,同一时刻只有一个线程执行字节码。
面试官:接着上面:那你会怎么绕开 GIL 做并行?
  本轮节点:check_input → ask_technical|环节 technical 第 2 轮|全场已问 2 题

候选人:幂等靠唯一请求号加数据库唯一索引兜底。
面试官:换个场景:大促零点流量翻十倍,履约链路哪里会先崩?
  本轮节点:check_input → scenario_question|环节 scenario 第 1 轮|全场已问 5 题

候选人:没有了,谢谢面试官。
面试官:今天的面试就到这里,逐题点评和综合报告稍后发给你。
  本轮节点:check_input → close_interview|环节 closing 第 1 轮|全场已问 7 题

九轮对话走完,最后打出来的全场路径里没有一处需要人肉解释的跳转:

全场走过的节点:check_input → open_interview → check_input → ask_technical
→ check_input → ask_technical → check_input → probe_project → check_input
→ probe_project → check_input → scenario_question → check_input
→ allow_candidate_questions → check_input → allow_candidate_questions
→ check_input → close_interview
面试已收尾:True,共问 7 题

案例二,技术环节聊到第二轮,候选人一句"我想问你几个问题:贵司技术栈是什么?加班多吗?"——路由读到反问关键词,当场跳环节:

  本轮节点:check_input → allow_candidate_questions|环节 candidate_questions 第 1 轮
候选人:没有了,谢谢面试官。
  本轮节点:check_input → close_interview|环节 closing 第 1 轮

案例三,checkpoint 续面。同一个 thread_id 先面两轮,"服务重启"(图对象全部重建,只有 checkpointer 还是原来那份),第三轮接着问:

【第二段】重建图对象 + 同一份 checkpointer + 同一个 thread_id
候选人:GIL 是全局解释器锁……
面试官:接着上面:你会怎么绕开 GIL 做并行?
  本轮节点:check_input → ask_technical|环节 technical 第 2 轮|全场已问 2 题

续面后走过的节点:check_input → open_interview → check_input → ask_technical
→ check_input → ask_technical
(前四步是中断前的记录——trace 没丢,环节计数也在接着数)

对照组也在这条链路里:同一个 checkpointer 换 thread_id,打出来的是一场全新面试,环节回到开场、已问 0 题。

八、真实 pytest 输出

$ python -m pytest --capture=no -p no:cacheprovider -q
........................................................
56 passed in 50.59s

56 个用例全绿,全程离线。按文件分:路由分支 16、图级集成 12、模型工厂 4、节点纯函数 16、状态契约 8。这组测试里我最想让你注意四条:

test_total_question_cap_never_exceeded:把各环节题数故意配成 99,候选人一句接一句"我继续答",断言总闸到 8 必须强制收尾。防死循环不是口头承诺,是一条会红的测试。

test_rebuilt_graph_with_same_checkpointer_continues_interview:重建一个全新的图对象、换一个全新的模型,只保留同一个 checkpointer——断言环节轮数接着数、trace 只增不清。续面这件事,图对象不是关键,档案才是。

test_llm_failure_does_not_kill_the_interview:让假模型在第二轮抛异常,断言错误进了 errors、环节停在自我介绍没被推进;下一轮模型恢复,面试照常继续。异常转字段的价值,一条测试验证完。

test_nodes_do_not_mutate_input_state:深拷贝一份 State 传进每个节点,跑完比较是否原样。节点保持纯函数,能一行 build_nodes(假模型)["节点名"](state) 直接单测——这正是当初把 llm 做成工厂参数的回报。

九、踩坑清单

  1. 骨架里忘了把面试官的话追加进 messages:第一版 _speak 只写了 current_question,演示时发现"接着上面"的追问变得没头没脑——对话历史里根本没有面试官自己刚说过的话。messages 是累加语义,每个开口的节点必须显式带上 AIMessage。
  2. 空输入判错了对象:守卫最初用"最后一条 HumanMessage"判断这轮说没说话,结果空输入时拿到的是上一轮的发言,误触发送照样烧了一次模型调用。改成看消息列表的最后一条是不是 HumanMessage——"这轮说了什么"和"最近一次说过什么"是两件事。
  3. 反问关键词一开始放了"问一下":候选人正常答题里带这个词,路由直接把他跳去反问环节,技术题问了一半断了。关键词表收窄成"我想问/问你几个/想请教/反问",并且这条规则写成常量挂测试——它迟早要换成真正的意图识别,别让它散在路由里。
  4. finished 短路忘了放路由最前面:面试收尾后候选人又发了句"谢谢",面试官回了句"你好,请先做个自我介绍"。路由函数的判断顺序就是优先级,"已收尾"必须排在所有环节判断之前——状态机的每一个分支都要问一句"我在什么前提下才轮得到执行"。
  5. 失败时也推进了环节:最初把 stage 更新写在 try 外面,模型一超时,环节照样往前跳,候选人莫名其妙从技术题跳到了场景题。修正成失败分支只返回 trace 和 errors——不写推进字段,下次 invoke 条件边自然把候选人送回原环节。
  6. from __future__ import annotations 让测试断言落空:state.py 开了延迟求值,TypedDict 的注解全变成字符串,测试里 hasattr(annotation, "__metadata__") 永远是 False,"累加语义"的断言形同虚设。用 get_type_hints(InterviewState, include_extras=True) 解析出真正的 Annotated 元数据才测得到。另提醒一句:MemorySaver 存在内存里,进程真重启(不是重建图对象)档案就没了,生产要换 SqliteSaver/PostgresSaver,编译参数一换就行。

十、本篇收尾

回看开头那三个事故现场:开场白直接跳场景题,是因为环节推进靠模型自觉;"我想问个问题"被无视,是因为没有一条边负责"候选人要反问"这个信号;断线全作废,是因为状态活在函数的局部变量里。现在它们分别是路由函数里的三行判断、映射表里的一项、以及一个 thread_id——每一个都有测试看着。

整场面试从"一个大 prompt 里的祈祷"变成了可推进、可续面、可单测的状态机。但你会发现 ask_technical 问的题全靠模型临场发挥,问什么、多难、问过什么,没有记忆——同一候选人练两次,题目八成重样。下一篇解决它:岗位题库与 RAG 检索,把"问什么"从模型的想象力变成可配置、可去重的题库。


最后一句:状态机不负责把面试聊得多好,它只负责一件事——让"下一步问什么"从此有据可查,谁做的决定,翻一行日志就能对质。