
前两篇把面试官的"嘴"和"人设"都调好了,我把整套东西接进服务端做内测,第一位同事聊了三分钟就退出了。我看他的会话日志,看到了三个事故现场:
第一,他刚做完自我介绍,面试官直接甩了一道场景题,技术基础一行都没问。因为我把整场面试的流程指令全塞进了一个大 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)。其中"开场"和"自我介绍"由同一个节点接续完成:面试官说完开场白顺手把话筒递给自我介绍,候选人接话;"结束"环节说收尾语并预告逐题点评和综合报告。
bash
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。七环节的推进规则、题数配置、总轮次上限,全部落在这一份结构里:
python
# 每个考察环节默认题数:面试官在本环节开口的次数
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 字段;节点自己不知道下一步是谁。
先看入口守卫。手机端最容易误触发送,空输入不该烧一次模型调用:
python
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"来判断,空输入时会错拿上一轮的发言------这个坑我踩过,第五节细说。
再看六个环节节点共用的骨架。每个节点的差异只有"这一轮的指令"和"自己名下的推进字段":
python
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 是四层人设的精简版,保留最影响行为的几条:一次只问一个、不鼓励不安慰、追问必须挂钩上一条回答。完整版在那一篇的工程里,两篇的人设是同一套思路的两个实例。
第三,环节轮数靠"我是不是刚进这个环节"来算。 节点不需要知道条件边为什么把自己送来:
python
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
节点是散的,图把它们串起来。先看条件边的大脑------路由函数,推进规则从上到下就是优先级:
python
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,生产上不能指望框架替你刹车。
装配本体短得像声明:
python
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 把路径和状态一起交出来:
python
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 说明收了尾。

七、跑起来:真实输出
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 题
九轮对话走完,最后打出来的全场路径里没有一处需要人肉解释的跳转:
python
全场走过的节点: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 输出
ini
$ 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 做成工厂参数的回报。
九、踩坑清单
- 骨架里忘了把面试官的话追加进
messages:第一版_speak只写了current_question,演示时发现"接着上面"的追问变得没头没脑------对话历史里根本没有面试官自己刚说过的话。messages是累加语义,每个开口的节点必须显式带上AIMessage。 - 空输入判错了对象:守卫最初用"最后一条 HumanMessage"判断这轮说没说话,结果空输入时拿到的是上一轮的发言,误触发送照样烧了一次模型调用。改成看消息列表的最后一条是不是 HumanMessage------"这轮说了什么"和"最近一次说过什么"是两件事。
- 反问关键词一开始放了"问一下":候选人正常答题里带这个词,路由直接把他跳去反问环节,技术题问了一半断了。关键词表收窄成"我想问/问你几个/想请教/反问",并且这条规则写成常量挂测试------它迟早要换成真正的意图识别,别让它散在路由里。
finished短路忘了放路由最前面:面试收尾后候选人又发了句"谢谢",面试官回了句"你好,请先做个自我介绍"。路由函数的判断顺序就是优先级,"已收尾"必须排在所有环节判断之前------状态机的每一个分支都要问一句"我在什么前提下才轮得到执行"。- 失败时也推进了环节 :最初把
stage更新写在 try 外面,模型一超时,环节照样往前跳,候选人莫名其妙从技术题跳到了场景题。修正成失败分支只返回trace和errors------不写推进字段,下次 invoke 条件边自然把候选人送回原环节。 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 检索,把"问什么"从模型的想象力变成可配置、可去重的题库。
最后一句:状态机不负责把面试聊得多好,它只负责一件事------让"下一步问什么"从此有据可查,谁做的决定,翻一行日志就能对质。