最近在业余时间做了一个叫《码上面试》的Agent项目,简单说,它是一个面向程序员求职场景的模拟面试助手。市面上类似的面试刷题工具太多了,但大多数都是“题库+固定题解”的模式,候选人对着标准答案背,实际面试时遇到追问就露馅。我想要的不是一个题库,而是一个会主动提问、会追问、会评估的AI面试官。这就决定了它必须是一个Agent项目,而不只是套壳聊天机器人。这种需求天然适合Agent架构:它要感知候选人上一轮的回答、调用题库和知识库工具、决定追问方向,还要在对话中维护候选人画像。这篇是这个系列学习记录的第一篇,重点梳理我搭起第一个多Agent流程时踩过的坑和沉淀下来的思路,主要想聊三个问题:为什么这样设计角色、手写ReAct循环时遇到哪些边界问题、多Agent协作时怎么管好评分与记忆。如果你也在折腾agent开发、ai agent搭建,或者刚看完吴恩达那种Agent教程不知道怎么落地,这篇应该能提供一些可复用的经验。
1. 项目整体拆解:为什么先做“面试官”而不是“做题家”
1.1 面试场景的真实痛点
做这个项目之前,我访谈过几个正在跳槽的朋友,也回看了自己当年准备面试时的记录。本质痛点其实就三个:第一个是“题海战术效率太低”,市面上的题库动辄几千道,但候选人很难知道哪些题对自己的目标岗位、自己的薄弱点有针对性;第二个是“追问缺失”,背会了一道题的标准答案,面试官换个角度深挖一层,立刻露馅,因为静态题解完全不含追问逻辑;第三个是“复盘主观”,面完感觉良好,但不知道到底哪里好、哪里差,没有结构化的能力评估。
这三个痛点单靠传统的问答机器人是解不了的。传统ChatBot更像一个“答案检索器”:你问它红黑树的性质,它给你一段标准定义,然后对话就结束了。而面试场景真正需要的是一连串行为:根据简历信息判断候选人应该被问简单还是难的问题;根据上一轮回答质量动态调整追问方向;如果候选人的描述里涉及某个框架,实时查一下这个框架的最新版本特性再继续追问;最后还要对整个面试过程打分并生成复盘报告。这一串行为拆开来看,每一步都不复杂,但连起来需要系统具备“感知-决策-行动-观察”的闭环能力,这正是Agent的核心循环。所以我才决定用Agent架构来做,而不是堆一个大prompt的机器人。
我还发现一个容易被忽略的点:候选人技术能力通常分好几项,比如算法、操作系统、网络、项目深挖、系统设计。传统题库往往一次只考查一个方向,但真实面试是混着来的。Agent的价值在于它可以维护一个候选人能力画像,在回答中随时识别候选人的短板,然后在后续轮次里针对短板出题。这个能力需要跨多轮对话的状态管理,天然属于Agent擅长的领域。
1.2 从单Agent到多Agent的选型思考
一开始我确实试过单Agent方案:一个模型实例,同时扮演面试官、评分员、知识库检索员。结果很快发现不可行。原因在于面试官需要保持对话的压迫感和快节奏,评分员需要冷静梳理打分维度,知识库检索员只要忠实地查资料,这三个任务对语气、上下文窗口、输出格式的要求完全不一样。硬塞进一个对话上下文里,模型的行为会不自觉地漂移:有时候它突然开始长篇大论地背知识点,不像在面试;有时候它又忘了自己还在等候选人回答,直接替候选人把答案说了。
后来我做了个简单的实验来验证这个问题:用同一个Prompt模板分别跑单Agent和多Agent,在20轮模拟面试里统计“角色错乱次数”。单Agent在12轮之后出现明显的角色遗忘,多Agent几乎没出现过。当然这个实验不够严谨,但对我的决策足够有说服力。多Agent的本质是用工程复杂度换“上下文纯净度”:每个Agent只在自己的职责范围内思考,看到的信息被裁剪到最小必要集合,语气和输出格式各自约束,最终结果用代码逻辑串联。
不过这并不意味着我要直接从重框架起步。我选择先用一段自己手写的代码程序把这些Agent串起来,而不是立刻套LangChain、LangGraph或者某个agent框架。原因有三点:第一,我想通过手写过程理解Agent循环的底层机制,比如模型输出里的Thought/Action怎么被解析、工具结果怎么回填、上下文怎么增量维护;第二,面试场景的流程在初期变化很快,手写代码改起来更灵活,框架的抽象反而碍手碍脚;第三,调试成本。前期跑不通的问题大多数是Prompt问题、格式解析问题和状态丢失问题,手写能让我一眼定位到出错位置,用框架反而多一层黑盒。
当然我也清楚手写方式的天花板:当Agent数量超过三个,消息路由、状态持久化、并发控制都会变得繁琐。所以我在项目里留了一个替换层,后续如果协作复杂度继续上升,就迁移到LangGraph之类的编排框架。最后我的架构定成了四个角色:面试官Agent、知识库检索Agent、评估Agent、复盘汇总Agent,这四者通过一个简单的消息队列通信,项目里管它叫面试调度核心。
2. 第一个可运行的Agent流程:从提示词到ReAct循环
2.1 最小闭环:让模型先学会“按流程说话”
我最开始做的并不是华丽的功能,而是先跑通一个最小可用的ReAct循环。ReAct这个模式现在讲agent开发基本绕不开,它的核心是让模型交替输出Thought、Action和Observation,形成一个“思考-行动-观察”的闭环。用一个生活化的类比:你中午点外卖,先想“今天想吃辣的还是清淡的”,这是Thought;然后打开外卖App搜索“川菜”,这是Action;看到一堆候选店铺,这是Observation;看完评价后决定点哪家,这又触发下一个Thought。Agent做事的逻辑也一样,只不过它的“App”是一个个工具。
我在项目里的第一个最小闭环是这样的:面试官Agent收到用户说“我想面后端岗位”之后,先思考这道题应该出什么难度,然后调用一个叫做出题器的工具,工具返回几道候选题目,Agent再把这些题目整理成自然语言发出来。这个流程听起来简单,但落地时有个关键细节:模型输出的Action必须能被程序解析。我最初让模型输出自由格式的JSON,结果解析时经常翻车。后来我改成一种更稳的格式,让模型先输出一行ACTION:结尾的文本,再换行输出JSON内容,程序用正则按标记位截取。
我写这个最小循环时用的Python代码大概是这样的,给新手一个参考:
def run_agent_loop(user_input, tools, max_steps=5): messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}] step = 0 while step < max_steps: response = llm.chat(messages) action = extract_action(response) if action["type"] == "finish": return action["output"] if action["type"] == "call_tool": result = execute_tool(action["tool_name"], action["arguments"]) messages.append({"role": "assistant", "content": response}) messages.append({"role": "tool", "content": f"工具返回:{result}"}) step += 1 return "达到最大步数,自动结束"这个循环是所有Agent项目的骨架,后面做的所有复杂功能都是在往这个循环里塞更精细的Prompt和更多的工具。注意我没有直接把工具结果拼进用户消息,而是单独标记一条role: tool的消息,这点很重要。很多模型在微调时已经见过工具消息的格式,这样做能让模型更好地理解哪些内容是工具返回的,而不是把工具结果误当成用户说的话。
2.2 用代码控制推荐逻辑,而不是把一切丢给模型
跑通最小循环之后,我犯了一个很多agent初学者都会犯的错:试图让模型自己决定所有事情,包括题目难度选择。我在面试官Agent的Prompt里写了一句“请根据候选人的水平出合适的题目”,然后模型就开始自由发挥,有时候出一个中等难度题,有时候直接出hard题,完全没有标准。我意识到这个问题后,专门花了一晚上梳理“难度控制”的决策逻辑,最终结论是:难度这类可以结构化的决策,应该交给代码规则,而不是交给模型感觉。
具体我做了这么一件事:第一轮先让候选人给自己打三个分,分别是算法能力、工程能力、项目经验,每项1到5分。然后面试官Agent只是把这些分数读进上下文,真正决定第一题难度的逻辑是代码里的规则引擎:三项总分大于等于12分就给hard题,9到11分给medium题,9分以下给easy题。等候选人答完第一题,评估Agent会返回一个能力值变化量,这个变化量再回流到难度调节逻辑里。模型在中间扮演的角色并不是“决定难度”,而是“根据给定难度生成符合面试习惯的题目表述”。换句话说,模型负责的是语言生成,代码负责的是决策控制。
这个取舍我觉得是Agent项目里最关键的原则之一:能放进代码的确定性,绝不要放给模型自由发挥。模型擅长的是开放式的语言理解与生成,不擅长的是精确的条件判断和数学规则。把不带确定性的任务(比如“判断候选人评分是否达到6分”)交给模型,等于把系统的稳定性交给概率,这在实际工程里是完全不可接受的。
2.3 为什么我暂时没有上复杂框架
很多朋友看到Agent项目第一反应是去选框架,比如LangGraph、Dify、Coze或者其他agent平台。我之前也反复比较过,最后在项目第一阶段选择暂时不上框架,几千行代码跑完才发现这个决定帮我把基本功打扎实了。框架能帮我们解决编排、状态管理、工具注册这些通用问题,但前提是你得先理解这些问题为什么存在。手写阶段让我直面了三个框架抽象掉的关键点:模型输出格式的脆弱性、多轮上下文的状态维护、Agent之间的通信协议设计。
第一阶段结束后,我整理了一个权衡表格,给后面选型用:
| 维度 | 手写轻量实现 | 成熟框架(如LangGraph) |
|---|---|---|
| 学习成本 | 高,但收益直接 | 中,先会调用再理解底层 |
| 调试体验 | 非常直观,完全可控 | 偶有黑盒,需要查文档 |
| 状态管理 | 自己维护,简单但原始 | 内置持久化和检查点 |
| 多Agent编排 | 需要手写队列 | 内置节点图和条件路由 |
| 后续扩展 | 需要自己造轮子 | 生态丰富,插件齐全 |
我的结论是:前期适合手写,中期适合框架,后期可能还需要自己改框架。这篇学习记录碰巧在“手写但准备迁移”的时间点上,所以公众号里面很多细节是手写实现,但思路参考了LangGraph的状态机模型。我计划在第二阶段引入更完整的记忆系统和MCP工具协议,届时再评估是否需要迁移。
3. 多Agent协作:面试官、评估者、知识库检索者如何分工
3.1 Agent角色的划分与提示词设计
多Agent协作的第一步是分清楚角色边界。我在项目里把Agent按职责切成了四块:面试官Agent负责所有面向用户的提问语言;知识库检索Agent负责在题库和技术文档库中检索参考信息;评估Agent负责对候选人的回答做结构化评分;复盘汇总Agent负责在面试结束时把多维度的评估结果整理成一份报告。这里最核心的设计原则是“谁面向用户,谁负责表达”,知识库检索Agent哪怕查到了再好的内容,也不能直接输出给候选人,必须返回给面试官Agent,由面试官用自己的语气转述。
提示词设计方面我有一个很大的经验转变。最开始我把每个Agent都写成了“人设”,比如给面试官Agent写“你是一位经验丰富的技术面试官,对待候选人亲切友善,善于引导”,看起来没什么问题,但实际跑下来效果很虚。后来我把Prompt彻底改成“职责说明书”式的写法,重点放在三个部分:输入说明、任务边界、输出格式。给一个比较典型的Prompt示例:
你的角色是面试官Agent。 输入:候选人的简历信息、上一轮回答的评估摘要、本轮知识库检索结果。 职责:基于输入内容生成一个自然的面试追问问题。不要替候选人回答。 约束:问题长度不超过150字,每次只问一个问题,不要自带答案。 输出格式:JSON,包含字段type(固定为question)和content(问题文本)。这种Prompt风格的效果好得多,因为任务边界清晰,模型不会因为被赋予“性格”而产生幻觉。我后来还发现一个经验:在输出格式里明确“不允许出现的内容”比只规定“允许出现的内容”更有效,比如明确写“不要自带答案”能显著减少模型抢答的现象。
3.2 上下文窗口与记忆管理的折中方案
多Agent跑起来之后,内存占用问题立刻暴露了。最开始的实现很粗糙:每个Agent收到的是全量对话历史,相当于面试官、评估者、知识库检索者都在重复读同样的几十轮对话。结果有20轮面试跑到一半,上下文长度突破模型窗口限制,直接报错。我后来用了一个折中方案,核心是“最小必要上下文”原则:每个Agent只收到它完成任务所需的最少信息,其余信息一律不传。
具体实现上,我维护了一个面试状态对象,里面只包含结构化字段,比如候选人基础信息、当前题目难度、最近三轮问题的摘要、候选人对每类知识点的掌握度、上轮评分等。面试官Agent发消息时,程序把候选人的回答先转成一句由摘要模型生成的短摘要,再把这份摘要连同当前题目信息传给面试官。知识库检索Agent则只收到查询关键词,检索出结果后以工具消息的形式返回给调度核心。评估Agent收到的信息最完整,但也只是当前轮次的问答全文加历史评分表格,不会收到前几轮的完整对话。
记忆管理方面,我区分了短期记忆和工作记忆。短期记忆就是当前面试会话内的对话缓存,因为面试通常控制在40分钟以内,缓存不会爆炸。工作记忆则是跨场次持久化的,比如候选人上一次面试时在哪些知识点上表现弱,下次面试开始时可以直接拉出来用于追问。这个我用一个简单的SQLite表来存,表里记候选人ID、知识点、掌握度打分和时间戳。这样设计下来,哪怕大模型只有有限的上下文窗口,Agent也能通过“滚动摘要+结构化状态”保持对长流程的控制。
3.3 路由与状态传递:消息总线模式
多Agent之间的通信,我一开始是直接函数调用A调B、B调C,代码耦合到后来自己都看不懂。后来我改成一个轻量的事件总线模式,调度核心内部维护一个消息队列,每个Agent完成自己的任务后往队列里扔一条消息。消息类型有:面试官提问事件、候选人回答事件、检索请求事件、检索结果事件、评分完成事件。调度核心根据事件类型和当前状态决定下一步该触发哪个Agent。
这套消息总线模式的核心优势是解耦。每个Agent不需要知道其他Agent的存在,只需要知道自己收到指令后该做什么。面试官Agent不会直接调用评估Agent,它只是发出一条“候选人回答完成”的事件,调度核心再决定把这个事件转交给评估Agent。这样新增Agent时不需要改既有Agent的代码,只需要在调度核心注册新的事件类型就行。比如我后来加了“追问策略Agent”,就是通过事件路由接进来的,老Agent完全无感。对于只有三五个Agent的项目来说,这个模式比上消息中间件要轻量得多,也足够应付。
这里有一个容易踩的坑:Agent之间消息传递如果走自然语言文本,模型可能会误解消息内容。所以我规定消息体必须包含结构化字段,比如{"event_name": "candidate_answer", "candidate_id": "1001", "round": 12}。自然语言文本只作为辅助内容,不影响路由决策。路由决策完全靠代码读结构化字段来做,不依赖模型理解,这样下来稳定性高很多。
4. 实操中踩过的坑与排查记录
4.1 Agent把“搜索”和“回答”混在一起
第一次正式模拟面试时,候选人问了一道“什么是JVM内存模型”,结果面试官Agent调用了知识库检索工具,拿到资料后没有任何加工,直接把工具返回的整段文档原文给候选人读了一遍。这段回答风格又硬又长,完全没有面试官该有的语气,候选人体验非常差。我当时以为是Prompt写得不够好,加了很多描述“请用口语化的方式回答”,效果仍然不稳定。
后来我才意识到问题的本质不在语气,而在职责边界。面试官Agent不应该同时承担“内容提供者”的身份,它的职责是“基于已有内容进行表达”。所以我新增了一条处理规则:如果面试官Agent要回答某个知识性问题,它只能引用知识库检索Agent返回内容里“结论和关键词”部分,并且每条引用最多提炼成三句话。知识库检索Agent的Prompt里也加了一条约束:返回内容必须以知识点关键词+核心结论+来源参考的格式组织,不允许输出完整长文。这一改之后,回答质量明显回升。
这个坑给我的教训是:工具调用链路中,信息传递的“格式设计”比内容本身更重要。如果检索结果直接原封不动拼进模型上下文,模型很容易误以为可以直接把这段内容当成最终回答。而当你把工具结果设计成“中间结论摘要”的形态,模型就能自然地把工具当参考,而不是当答案。
4.2 模型输出JSON不稳定,怎么兜底
评估Agent需要输出结构化评分,我一开始要求模型输出严格的JSON,像{"algorithm": 7, "communication": 8}这样。实测下来,10次输出里至少有2次是坏的:要么多个逗号,要么JSON里混进中文说明,要么外层多包了一层Markdown代码块。对于一个要上线的项目,这种概率完全不可接受。
我最后做了三层兜底。第一层是让模型把JSON放在Markdown代码块里,这样程序可以先按代码块标记切出纯文本;第二层是用json.loads解析,如果失败,再尝试用正则提取所有数字字段;第三层是终极兜底——如果前两层都失败,就让评估Agent改成输出一段自然语言评分描述,然后由一个小规则引擎从描述中抽出分数关键词。实际效果是,第一层就让成功率到70%,加入正则后到95%,最后那5%虽然还偶尔出现,但已经不会让整个流程崩溃了。
给一段我自己写的解析兜底函数片段,你拿去就能用:
import json, re def robust_parse_llm_json(raw_text): # 第一层:剥离 Markdown 代码块 pattern = r"```(?:json)?\s*([\s\S]*?)\s*```" m = re.search(pattern, raw_text) candidate = m.group(1) if m else raw_text try: return json.loads(candidate) except Exception: pass # 第二层:正则提取所有数字 nums = re.findall(r"[-+]?\d+\.?\d*", candidate) return {"manual_score_hit": nums}这段代码虽然简单,但对稳定性帮助非常大。我的经验是:不要迷信大模型的输出结构化能力,模型输出本质是概率采样,任何格式都可能有偏差。真正负责任的做法是在模型外层包一层Schema校验和纠错逻辑,把模型的“无限自由”关进应用层的“有限笼子”。
4.3 长对话后上下文被污染,如何做记忆压缩
项目跑到第20轮时,出现了一个奇怪的现象:候选人前面答错的一个知识点,被后续问题覆盖之后,评估Agent居然完全忘了,评分直接给了满分。我排查后发现问题出在上下文截断:为了适配窗口限制,我把超过长度限制的历史对话粗暴丢了,模型自然就“失忆”了。这种粗暴截断的危害比想象中更大,它会静默地丢失关键信息,而你无法第一时间发现。
这个问题我改成了两套机制并行。第一套是滚动摘要机制:每5轮面试结束后,调度核心调用一次摘要Agent,把最近5轮的对话压成一段150字以内的摘要,替换掉原始对话文本。第二套是关键点缓存机制:面试每轮产生的评估结果,都会以一种结构化的形式写入全局状态,比如弱项:哈希表冲突处理,索引: 89%。这样即使对话摘要丢了一些细节,关键的结构化信息依然保留,评估Agent在给后续轮次打分时可以参照历史关键点。
两套机制并行后,整个面试流程可以稳定跑完40轮而不超过上下文窗口。压缩前后的对比我简单测过:同一场面试,原来的上下文占用约1.8万token,压缩后降到了6000 token左右,而评分的连贯性反而更好。原因很好理解:结构化关键点比冗长对话更清晰,模型注意力不会被无关细节分散。这也是Agent记忆设计的一个核心思路:记忆不是“把能存的全存下来”,而是“把该记的按结构存下来”。
5. 从这次学习记录里沉淀的三条经验
5.1 优先把流程跑通,再优化模型
第一阶段我最大的收获是确立了“先流程,后模型”的开发顺序。很多Agent项目失败不是因为模型能力不够,而是流程还没跑通就开始追求单点效果。我的做法是先把最基本的“1轮面试”完整跑通:候选人答完题,评估Agent打分,面试官Agent根据分数追问。这个最小闭环跑通之后,后面的多Agent扩展就像搭积木一样,一个一个往流程里加。如果一开始就想把所有功能一次做完,你会发现自己被无数个并行问题困住,根本不知道先修哪个。
5.2 能放进代码的确定性,绝不放给模型自由发挥
这条经验几乎贯穿了项目的所有模块。从题目难度控制、事件路由、JSON解析兜底,到记忆压缩策略,凡是能用代码明确判断的逻辑,我都没有交给模型决策。模型适合做的是开放式语言任务,比如把一道题改成符合人话的提问、把候选人的回答总结成面试反馈。把这两者分开之后,系统稳定性和开发心情都明显提升。任何一次你发现模型输出出现随机波动,都该想一想是不是把某个确定性结口漏给了模型。
5.3 下一阶段的规划:接入MCP与更完善的记忆系统
第一版跑通之后,我已经在规划第二阶段的两个方向。第一个是接入MCP协议,把题库和面经文档以标准工具协议的方式挂载进来,这样知识库检索Agent可以动态发现工具,不用每次新增数据源都改代码。第二个是更完善的长期记忆系统,我目前用的是SQLite存面试状态,后续计划引入向量数据库,对候选人的历史大面试记录做语义检索,让系统能更智能地基于历史弱项出题。这些内容我会在下一期学习记录里继续写。如果你也在做agent项目,尤其也是面试或者教育场景,欢迎带着你的问题来交流,代码和踩坑笔记我都可以整理出来一起讨论。