最近一直在折腾 Agent 方向的落地项目,手头正在做的是一个叫《码上面试》的模拟面试 Agent。这个项目说来也简单,就是让大模型扮演面试官,陪程序员做技术面试练习,但它不是那种"你问一句它答一句"的聊天机器人,而是要像一个真实面试官那样:开场、提问、追问、打断、收尾,最后还给出一份评估报告。这一篇先把整体设计和第一版实现记录下来,包括为什么选这个场景、架构怎么定、状态机怎么写、出题模块怎么控制质量,以及最关键的——实际跑起来之后踩到的几个坑。
我本身是大模型应用开发工程师,平时主要做 Agent 框架、Prompt 工程和模型调用优化这一类工作。做这个项目的初衷很简单:市面上刷题工具很多,但真正模拟"被面试"的产品少。刷题是单向的,背八股是记忆性的,而技术面试本质上是对话博弈——面试官会追问、会反问、会打断,你需要的是那种随时可能被问住的压力感。我想用 Agent 把这个体验做出来。
1. 为什么要做"码上面试":技术面试的真实缺口在哪里
1.1 刷题和真实面试之间缺了一个"追问"
先聊场景。国内程序员准备面试,常见的路径是:刷 LeetCode、看面经、背八股文。这三个动作其实都指向一个共同的问题——缺少对话式的对抗反馈。
刷题练的是解题能力,但面试时很多题目是口述的,你要边想边说,面试官还会打断你,问你"这个方案有没有考虑并发问题""你说用缓存,那缓存穿透怎么办"。八股文背得再熟,被换一个角度追问就露馅。面经更不用说了,那是别人的面试记录,你只是看,不是你在被问。
所以我的判断是:Agent 最适合干的不是"出题",而是"陪练"。它要能模拟面试官的语气、追问逻辑和打断时机,实时对候选人的回答做出反应。这是普通的题库App和ChatBot做不到的。
1.2 这个Agent的核心人群与服务边界
《码上面试》主要面向三类人:准备跳槽的中高级开发、准备校招的应届生、以及想评估团队候选人水平的面试官本人(用来自测题目质量)。
服务边界我一开始就划清楚了:不追求大而全,先做好算法面试 + 技术深挖面试两个场景。算法面试就是 LeetCode 风格的题目,面试官 Agent 引导候选人讲思路、写代码、分析复杂度;技术深挖面试覆盖 Java、Go、前端、数据库、Redis 这些八股高频方向。项目名"码上面试"也是个双关——既是"码上/马上"开始面试,也是"写代码(码)上面的面试"。
这个定位决定了后面的技术路线:对话要可控、节奏要像人、评分要有依据。这三个要求直接影响了 Agent 的架构选型,而不是像很多人想的那样,接一个大模型API就完事了。
2. 第一版架构:单Agent状态机是我觉得最稳妥的起点
2.1 为什么没有一上来就做多Agent协作
现在圈子里一聊 Agent 就是多智能体、编排、协作,好像不上多 Agent 就显得不专业。但实际做落地项目,我的经验是:如果是第一次做,单 Agent 先跑通闭环,比一上来就拆五个 Agent 靠谱得多。
多 Agent 协作的调试成本非常高。A 给你的结论要传给 B,B 的理解如果有偏差,最后 C 给出的结果就完全失控,而且你很难定位是哪个环节出了问题。面试场景尤其如此——面试官这一个角色里包含出题、提问、追问、评分等多种能力,如果把这些强行拆成多个 Agent,反而会把"同一场对话的一致性"搞坏。
所以第一版架构我定了一个原则:先让一个 Agent 具备所有能力,通过状态机和工具调用来控制行为边界。等流程稳定了,再把评分这类独立环节拆成子 Agent。这个思路其实和写代码一样,先单体应用,再微服务拆分,不要反过来。
2.2 技术选型与模块划分
整体的技术栈不算复杂,我列了个表:
| 模块 | 选型 | 说明 |
|---|---|---|
| 本体框架 | 自研状态机 + 工具调用 | 不依赖重量级 Agent 框架,降低抽象成本 |
| 后端服务 | FastAPI | 提供 WebSocket 接口,承载对话流 |
| 前端 | React + Vite | 简单聊天界面,后续可升级语音 |
| 模型层 | 国产大模型 API(OpenAI 兼容格式) | 便于替换和对比模型效果 |
| 会话记忆 | Redis 短期窗口 + 向量库长期记忆 | 短期存上下文,长期存用户能力画像 |
| 向量库 | Chroma | 轻量,本地开发够用 |
模块划分上,我做了四个核心组件:
- 状态机控制器:管理面试流程,防止 Agent 乱跳话题。
- 出题引擎:根据用户的技术栈和期望职级生成候选题目。
- 面试官对话模块:负责生成面试官话语,包括提问、追问、反馈。
- 评估模块:面试结束后基于完整对话生成评估报告。
这四个模块挂在同一个 Agent 入口下面,Agent 的每次响应都会先查询当前处于哪个状态,再决定调用哪个模块的能力。
2.3 数据流和记忆设计
对话数据流是这样的:用户消息进入后端,后端先把消息写入短期记忆窗口,然后请求状态机控制器判断当前状态,状态机返回下一步动作(提问、追问、收尾),再调用对话模块生成话术,最后把话术返回前端。
记忆设计上,短期记忆就是一个滑动窗口,最近 6 轮对话全部保留,保证上下文连贯。长期记忆比较关键,我在面试结束后会把用户的整体表现向量化存入 Chroma,下次用户再来时,Agent 能调出"这个人上次面过,薄弱点是系统设计",直接作为开场追问的素材。
这里插一句,很多 Agent 项目忽略了记忆分层,全部塞进上下文窗口,结果就是 token 爆炸和关键信息稀释。面试场景尤其需要"面试官真的记得你上轮说过什么",这个能力靠滑动窗口肯定不够,必须单独做记忆存储。
3. 面试官状态机的核心实现:追问、倾听、评分都要受控
3.1 面试流程的状态定义与迁移规则
面试官不像闲聊机器人,它必须有一个清晰的流程。我把面试拆成了六个状态:
| 状态 | 含义 | 进入条件 | 行为 |
|---|---|---|---|
| IDLE | 空闲 | 会话建立 | 输出欢迎语,询问目标岗位 |
| INTRO | 自我介绍 | 用户说明目标 | 引导用户做自我介绍 |
| QUESTION | 出题提问 | 自我介绍完成 | 从题目池选题并提问 |
| LISTENING | 倾听回答 | 问题已提问 | 记录用户回答,不打断 |
| FOLLOW_UP | 追问 | 用户回答不完整/有漏洞 | 基于当前回答继续深挖 |
| EVALUATION | 评估收尾 | 达到题目上限或用户放弃 | 生成阶段性反馈并结束 |
迁移规则是硬性的,不允许跳级和回退。比如 QUESTION 之后只能到 LISTENING,LISTENING 之后只能到 FOLLOW_UP 或下一个 QUESTION,绝对不允许从 FOLLOW_UP 直接跳回 QUESTION。这个约束很重要,因为大模型如果完全自由发挥,很容易出现"上一个话题还没聊完,突然换了个新题目"的怪现象。
3.2 状态机控制代码的结构
我用 Python 写了一个非常轻量的状态机,核心就一个字典加一个迁移函数:
class InterviewStateMachine: def __init__(self): self.state = "IDLE" self.transitions = { "IDLE": ["INTRO"], "INTRO": ["QUESTION"], "QUESTION": ["LISTENING"], "LISTENING": ["FOLLOW_UP", "QUESTION"], "FOLLOW_UP": ["FOLLOW_UP", "QUESTION", "EVALUATION"], "EVALUATION": ["END"], } def can_transit(self, next_state): return next_state in self.transitions.get(self.state, []) def transit(self, next_state): if not self.can_transit(next_state): return False self.state = next_state return True每次 Agent 要输出内容前,会先调用can_transit做校验。如果模型在 Prompt 里生成了一个非法转移动作,状态机会直接拒绝,并强制让模型重新生成。这个设计相当于给模型加了一条"物理规则",比单纯在 Prompt 里写"请遵守面试流程"可靠得多。
在实际使用中,我还加了一个状态锁存:当用户在规定时间内重复发送消息时,只有处于 LISTENING 状态才允许追加内容,其他状态一律提示"请等待面试官提问"。这样能避免用户在界面上疯狂打字导致对话节奏被打乱。
3.3 关键Prompt设计与上下文管理
状态机管流程,Prompt 管语气和质量。面试官 Agent 的 System Prompt 我改了很多版,最终核心人设是这样一段话:
你是一名资深的技术面试官,风格专业但不冷漠。你不是聊天机器人,不要复述用户的话。你有三个任务:理解候选人的回答、确认候选人的知识边界、通过追问验证候选人的真实水平。禁止在面试结束前透露标准答案。面试结束时,你会收到评估指令。
这里有两个关键点:一是"不要复述用户的话",因为通用 ChatBot 有个习惯,会先说"你刚才提到xxx,很好",真人面试官基本不会这么说话;二是"禁止在面试结束前透露标准答案",这是防止大模型一听到"我不会"就直接把答案讲出来,那样面试就变成教学了。
上下文管理上,每次调用模型时,我会在消息列表里注入一段当前状态标记:
当前状态:FOLLOW_UP 最近一轮用户回答:……(摘要) 你已经追问过的问题列表:1.…… 2.…… 3.…… 请基于以上信息生成下一句追问或判断是否结束追问。这一段保证模型不会重复问已经问过的问题,同时让它有依据决定是否结束追问。这个"已问问题列表"不是存在系统 Prompt 里写死的,而是从记忆模块动态取出,每轮更新。
4. 出题模块的难点:模型出题容易"假大空"怎么办
4.1 让模型在"限定范围"内出题
出题是面试 Agent 的第一道关口,也是最容易被低估的模块。直接让模型"出几个 Java 面试题",它大概率会给你一堆"什么是面向对象""什么是 JDK"这种既陈旧又空泛的题目。所以我做了一个结构化出题模板,把出题范围锁死在技能点、难度、题型三个维度里。
Prompt 模板大概是这个思路:
技术栈:Java 候选职级:P6(高级开发) 技能点:并发编程、JVM内存模型、MySQL索引 题目类型:场景题 2 道,概念辨析题 1 道 难度要求: - 场景题必须给出一个具体业务场景,不要问"如何优化" - 概念题必须包含两个相似概念的对比,不要问"什么是xxx" - 所有题目必须适合口述回答,不需要写代码这样约束之后,模型给出的题目就从"什么是 JVM"升级成了类似"订单量突然上涨,数据库查询变慢,你会从哪些层面排查?请说明你的排查顺序和理由"这种能真正展开追问的题目。出题的本质是给 Agent 一个追问的锚点,锚点越具体,追问越自然。
4.2 题目自检与难度校准
我还在出题环节加了一道自检:题目生成后,再用一次模型调用做质量校验,校验维度包括:是否有标准答案锚点、是否存在歧义、是否适合口述、难度是否匹配目标职级。
这道自检会过滤掉大约 15% 的生成题目,剩下的才能进入题目池。其实这也是一个省 token 的优化思路——宁可多花一次模型调用做质检,也不要让面试官 Agent 拿着一道烂题在那里硬撑十分钟。
难度校准方面,我引入了简单的规则纠正:用户回答里如果频繁出现"不知道""没做过",下一题难度自动降一级;如果用户对追问也能给出完整方案,下一题难度升一级。这个规则放在状态机迁移逻辑里,不依赖模型判断。
5. 实战踩坑记录:失控追问、评分幻觉、隐私边界
5.1 坑一:追问成了一团乱麻,靠"状态锁存"救场
第一个版本跑通后发现一个很严重的问题:追问阶段 Agent 的提问会变得很散。它可能上一句还在问"这个方案的并发瓶颈是什么",下一句突然跳到"你再说说 Redis 的数据结构"。这个问题的根因不在模型能力,而在上下文结构——追问阶段我最初没有把"当前正在讨论的题目"单独拎出来,所有历史对话都平铺在上下文里,模型很难聚焦。
解决办法就是在记忆模块里加了一个"当前焦点"字段。每道题开始的时候,把题目和标准答案锚点单独存一份,追问阶段的 Prompt 会优先注入这份焦点信息,而不是从完整对话历史里找线索。相当于给模型画了一条线:你正在追问的题目是 A,其他历史内容只能作为背景,不能跳转话题。
5.2 坑二:小模型只会说"正确的废话"
我前期为了省成本试了一轮中小尺寸开源模型,发现在中文面试场景下效果很拉胯。它倒是不会乱答,但极其喜欢说正确的废话,比如:
- "这个问题可以从多个方面来考虑"
- "首先我们需要明确一下概念"
- "我觉得你答得不错,但还有一些细节需要注意"
这些话放在面试官场景里就是灾难。真人面试官不会说"我觉得你答得不错",他会直接追问"你说的缓存穿透,那你觉得怎么避免缓存雪崩"。所以我后来做了一个混合调用策略:普通对话状态下,用小模型做意图识别和状态分类,只有追问话术生成和评分环节才调用能力更强的商用大模型。成本和数据质量之间取了个平衡。
如果后续要完全本地化部署,我会考虑用 70B 以上的大模型或者做专门的 prompt distillation,但这都是后话。
5.3 坑三:评分虚高,先给理由再给分数
评估模块最初的设计是让模型直接输出一个总分,比如 85 分。结果发现它严重虚高,动不动就给 90 分以上。原因也不难理解——大模型受到对话中用户"努力回答问题"的姿态影响,倾向于给出正面评价。后来我改了评分逻辑,核心思路是要求模型先给出证据,再给分数:
评估要求: 1. 先列出用户在对话中实际说出的技术要点,逐条对照标准答案: - 用户说到的要点 - 用户遗漏的要点 - 用户的错误表述 2. 然后按照技术准确性(40%)、沟通表达(30%)、知识深度(30%)三个维度打分 3. 最后生成总分和一句总结评语这样改了之后,评分明显可信多了。因为模型必须先"被迫"逐条对照,再汇总分数,而不是凭感觉给个数字。评估报告的本质是证据链,不是分数本身。
5.4 坑四:隐私边界一定要在系统层面堵住
做面试场景最容易忽视的是隐私问题。用户在练习中可能会说出自己的真实姓名、手机号、所在公司甚至项目中的保密信息。如果这些数据被存进向量库,后续再被 Agent 调出来,就是个安全隐患。
我在系统层面做了三道处理:第一,面试开始时明确提示用户"请使用匿名身份,不要透露真实姓名、手机号、公司名称";第二,后端在写入记忆之前对常见 PII 字段做正则脱敏,手机号、邮箱、身份证号直接打码;第三,向量库中的用户画像不存原始对话,只存"技能评估结果",需要追溯原始上下文时再关联到短期对话记录,而不是直接以向量形式长期保留敏感信息。
6. 下一步规划:多Agent拆分与MCP扩展方向
6.1 面试官与评估者拆分的动机
第一版跑通之后,我明显感觉到一个问题:面试官 Agent 同时负责对话和评估,会产生角色冲突。对话阶段它要表现得像个人,要共情、要引导;评估阶段它又要冷酷地挑毛病。这两个要求在同一个模型上下文里互相干扰,导致对话阶段偶尔会过早"剧透"评价,评估阶段又会带上对话时的关系滤镜。
所以下一步我会把评估模块独立成一个 Evaluation Agent。面试结束后,面试官 Agent 把完整的结构化面试记录(问题和回答、追问链、各题耗时)传给评估 Agent,由评估 Agent 单独生成报告。面试官 Agent 在整个面试过程中完全接触不到评分逻辑,这样能减少"因为聊得融洽所以给高分"的偏差。
6.2 接入MCP工具与记忆增强
另外一个方向是 MCP。现在的教训其实挺常见的,比如面算法题时正好考察一些基础能力,做个简单编译验证就能快速判断答案对不对,或者挂一道在线编程题让候选人现场写代码。这些都是未来可以扩展的点,等初版框架稳定后再逐个接入也不迟。
关于记忆增强,我的想法是给长期记忆增加一个"能力雷达图"结构,每次面试结束后更新。用户在平台上多面几次之后,Agent 能在开场阶段直接说:"上次我们面了 MySQL 索引优化,这次我们先聊聊缓存吧。"这种连续性会让整个模拟面试的体验往上走一个台阶。
这个《码上面试》项目还在持续迭代中,第一篇文章先记录到这里。整体做下来的体会是:Agent 项目能不能落地,很多时候不取决于模型有多强,而取决于你给模型画了多清晰的边界。状态机是边界,Prompt 是边界,记忆设计也是边界。把这些边界管住了,大模型才能真正扮演好"面试官"这个角色,而不是变成一个什么都能聊但什么都聊不透的聊天机器人。