「AI 模拟面试」这两年是个热词,但大部分 demo 拆开看都是同一套:准备一个题库,模型挨个抛题,用户答完抛下一题,最后甩一段总结。这玩意儿严格说不是面试,是带语音的问卷。
真正的面试官不是这么干的。你答「我觉得应该加强监管」,他会追一句「具体怎么加强?谁来执行?」;你答得心虚开始绕,他能听出来从你绕的那个点切进去。追问,才是面试的灵魂。 而追问恰恰是工程上最难做稳的部分。
我们在做智蛙公考 AI 智能体的模拟面试时(结构化面试 + 无领导小组),把这套啃下来了。这篇讲讲多轮对话和实时追问的工程实现,纯技术,不灌鸡汤。
一、第一个坑:多轮对话开下去,模型会「失忆」
最朴素的实现,是把整场对话塞进 messages 数组一路 append 下去:
system: 你是一名公考面试考官...
assistant: 请回答第一题:谈谈你对躺平的看法
user: (考生作答 300 字)
assistant: (追问)
user: (考生作答 200 字)
...
这个做法开三五轮还行,再往下就开始出问题:
模型忘了当前是第几题,把已经问过的题又问一遍;
模型忘了岗位设定,本来面的是税务岗,问着问着开始问教育理念;
上下文越堆越长,注意力漂移,追问质量肉眼可见地下降;
token 成本随轮次线性甚至超线性上涨。
根因:对话流程的「状态」,不该靠模型在长 history 里自己记,而应该外置成结构化数据,由代码维护。 这是整篇文章的核心思路。
二、面试状态机:把一场面试结构化
我们把一场面试建模成一个状态机,流程由代码驱动,模型只负责「在当前状态下生成自然语言」。
from dataclasses import dataclass, field
from enum import Enum
class Phase(Enum):
OPENING = "opening" # 开场/暖场
MAIN_Q = "main_question" # 主问题作答
FOLLOW_UP = "follow_up" # 追问
TRANSITION = "transition" # 过题
CLOSING = "closing" # 结束语
SCORING = "scoring" # 评分(离线)
@dataclass
class InterviewState:
position: str # 岗位设定,全程不变
questions: list # 预设主问题列表
cur_q_index: int = 0 # 当前第几题
phase: Phase = Phase.OPENING
follow_up_count: int = 0 # 当前题已追问几次
max_follow_up: int = 2 # 每题最多追问
history_digest: list = field(default_factory=list) # 压缩后的历史
answers: list = field(default_factory=list) # 结构化留存每题作答
每一轮交互,代码先看 state 决定该干嘛,再把「当前状态 + 必要上下文」组装成 prompt 喂给模型。模型不需要从一长串 history 里推断「我现在该问啥」------流程控制权在代码手里,模型只管把当前这一步说好。
状态流转大致是:
OPENING → MAIN_Q → (要不要追问?)
├─ 是 → FOLLOW_UP → (还追吗? 看 follow_up_count)
└─ 否 → TRANSITION → 下一题 MAIN_Q → ... → CLOSING → SCORING
好处很直接:永远不会跑飞、不会漏题、不会重复问、岗位设定全程钉死。模型生成内容里就算偶尔飘,状态机也会把它拽回正轨。
三、追问决策:别让模型「一把梭」
最容易出问题的环节是追问。常见错误做法是:把用户回答丢给模型,直接说「请追问」。结果就是模型为了追问而追问,经常追一些无关痛痒甚至answer里根本没提的东西,很假。
我们把追问拆成两步:先决策,再生成。
第一步:判断「该不该追、从哪追」
这一步本质是个分析任务,单独一次调用,temperature=0,输出结构化结果:
FOLLOW_UP_DECISION_PROMPT = """
你是资深公考面试考官。考生对题目「{question}」的作答如下:
---
{answer}
---
请判断是否需要追问,并以 JSON 输出:
{{
"need_follow_up": true/false,
"reason": "为什么追问/不追问",
"weak_points": ["作答中空泛、回避、自相矛盾或值得深挖的点,最多2个"],
"suggested_angle": "若追问,从哪个角度切入最有价值"
}}
判断标准:
- 作答空泛、停留在口号(如'要加强监管'但没说怎么加强)→ 追"具体怎么做"
- 作答回避了题目某个关键面 → 追被回避的点
- 作答出现自相矛盾 → 追矛盾处
- 作答已经充分、具体、闭环 → 不追问,need_follow_up=false
"""
注意最后一条:作答得好就不追问。 真考官不会对一个答得滴水不漏的人硬挑刺。让模型有「不追问」这个合法选项,整个交互的真实感会上一个台阶。
第二步:基于决策结果生成追问
只有 need_follow_up=true 时才进第二次调用,把 suggested_angle 和 weak_points 作为约束生成自然语言追问。这次可以给点温度(temp≈0.6)让措辞自然。
def decide_and_generate_follow_up(state, answer):
decision = llm_json(FOLLOW_UP_DECISION_PROMPT.format(
question=state.questions[state.cur_q_index], answer=answer))
if not decision["need_follow_up"] or state.follow_up_count >= state.max_follow_up:
state.phase = Phase.TRANSITION
return None
state.follow_up_count += 1
state.phase = Phase.FOLLOW_UP
return gen_follow_up_question(state, decision) # temp≈0.6
决策与生成分离,和我们做申论批改时「评分与点评分离」是同一个思想:把需要稳定判断的部分摁到 temp=0、结构化输出;把需要自然表达的部分单独放开。两个目标解耦,互不拖累。
四、长对话的上下文压缩
状态机解决了「流程不跑飞」,但模型生成每一步时仍需要一些历史上下文(比如追问时要看到用户原答案)。如果把全量 history 一直带着,长面试照样上下文膨胀。
做法:外置一份 history_digest,每过完一题就把那一题压缩成一条摘要存进去,原始长文本不再进 prompt。
def digest_one_question(state, question, answer, follow_ups):
summary = llm(f"""把以下一题面试问答压缩成不超过80字的客观摘要,
保留:考生核心观点、明显短板、追问及其回应。不要评价。
题目:{question}
作答:{answer}
追问记录:{follow_ups}""")
state.history_digest.append({
"q_index": state.cur_q_index,
"summary": summary
})
state.answers.append({ # 原文另存,供离线评分用,不进对话 prompt
"question": question, "answer": answer, "follow_ups": follow_ups
})
后续轮次的 prompt 里,只带 history_digest(几条 80 字摘要)而不是全部原文。效果:
|----------------------|------------------|---------------|
| 方案 | 第8轮 prompt token | 追问相关性(人工评1-5) |
| 全量 history 直接 append | ~4200 | 3.2 |
| 状态机 + digest 压缩 | ~900 | 4.4 |
token 砍到约 1/4,追问质量反而升了------因为模型注意力不再被海量原文稀释。原始作答全文我们另存一份(answers),只在离线评分时才完整调出来用,不参与实时对话。
五、评分与对话解耦
最后一步:打分。我们不在对话过程里实时打分,而是面试全部结束后,拿离线存下的 answers(每题原文 + 追问回应)单独走一遍评分 pipeline。
原因有二:
实时打分会干扰对话节奏,且边聊边打的分前后标准不一致;
评分需要看完整作答原文 + temp=0 + rubric 档位表(这套和我们申论批改一模一样,不展开,可参见上一篇《用大模型做主观题批改怎么保证评分一致性》)。
对话归对话,评分归评分。两条 pipeline 各自优化,互不污染。
六、踩过的坑
坑1:追问跑偏,问到用户没说的内容。 早期没有「决策」这步,直接让模型追问,它经常脑补------用户压根没提风险管控,它追「你刚才说的风险怎么控制」。解法就是上面的两步法,先抽 weak_points 锚定在原答案里,追问必须挂靠到考生真实说过的点。
坑2:上下文爆炸 + 注意力漂移。 全量 history append 到后面,模型开始重复问、忘岗位。解法是状态机 + digest 压缩,把「记流程」这件事从模型手里收回到代码里。
坑3:打断/追问时机靠模型自己判断不靠谱。 什么时候该过题、每题追几次,这种节奏控制别交给模型即兴发挥。用 max_follow_up、follow_up_count 这种硬计数器在代码里卡死,模型只决定「这一次追不追」,不决定「整体节奏」。
坑4:评分维度和对话过程耦合,分会飘。 一旦让模型边对话边给印象分,最终分受对话顺序影响很大。务必把评分拆成独立的离线环节,用固定 rubric 一次性评,别让对话过程污染评分。
七、小结
做一个「不像问卷」的 AI 模拟面试,关键不在模型多强,在于把控制逻辑从模型手里拿回来:
状态机控流程:第几题、追几次、什么岗,全由代码维护,模型只生成当前步;
追问两步法:先判断该不该追、从哪追(temp=0 结构化),再生成追问(放开措辞);
外置 digest 压上下文:每题压成短摘要,原文另存供离线评分,长对话不膨胀;
评分与对话解耦:面试结束后离线统一打分,rubric 固定,不被对话过程带偏。
这套思路适用于一切「多轮、有追问、要评估」的对话式 AI------面试、AI 问诊、口语陪练、销售陪练,底层都是同一个问题:怎么让一个无状态的模型,跑出一场有状态、有节奏、有判断的对话。 答案是别指望模型自己撑住,用工程把状态机、决策点、上下文、评分一个个框出来。
我们在公考赛道做 AI 模拟面试和 AI 批改(智蛙面试 / 智蛙公考),上面这些都是真刀真枪踩出来的。做对话式 AI 评估场景的同行,评论区交流。