1. 从标题拆解这个 Agent 项目到底在做什么
1.1 为什么“码上面试”这个选题值得认真做一遍
看到“码上面试”这四个字,我第一反应是:这大概率是一个把面试题库、AI 对话、代码评测揉在一起的 Agent 项目。再结合热搜词里的 Agent、LLM、Redis、面试八股文、Java 面试大全这些关键词,基本可以判断,这个项目的核心目标不是做一个简单的聊天机器人,而是想做一个能“陪练面试”的智能体——你给它一道题,它能追问、能评分、能给出改进建议,甚至能模拟真实面试官那种压迫感。
我之所以对这个方向感兴趣,是因为市面上大多数“AI 面试”产品都停留在“你问我答”的层面,缺少状态管理和多轮记忆。而一个真正有用的面试 Agent,必须记住你上一轮答了什么、哪里卡壳了、哪些知识点反复出错。这就天然需要 Redis 这类缓存/状态存储,也需要 LLM 来做语义理解和评分。换句话说,这个项目标题背后其实藏着三个技术栈的交汇点:Agent 编排 + LLM 推理 + Redis 状态管理。
适合谁来参考这篇记录?如果你是刚接触 Agent 开发的后端工程师,或者正在准备 Java/算法/TypeScript 面试、想自己动手做一个练手项目的同学,再或者你已经会用 LLM API 但不知道怎么把“多轮对话状态”落地到工程里,那这篇内容会对你有直接帮助。我会尽量把每一步的“为什么这么做”讲清楚,而不是只丢一堆代码。
1.2 项目整体架构的初步设想
在没看到完整代码之前,我先根据标题和热词做一轮合理推演。一个“码上面试”Agent 的最小可行架构,通常包含这几层:
- 接入层:负责接收用户输入(题目、答案、追问),可以是 HTTP 接口,也可以是 WebSocket 长连接。
- Agent 编排层:决定当前该做什么——是出题、追问、评分,还是切换话题。这一层是 Agent 的“大脑”。
- LLM 推理层:调用大模型完成语义理解、答案评分、追问生成。
- 状态存储层:用 Redis 保存会话上下文、题目进度、历史得分。
- 题库/知识层:存放面试题、参考答案、评分标准。
这个分层不是拍脑袋定的。我试过把状态直接塞进 LLM 的 prompt 里,短对话还行,一旦超过十几轮,token 消耗爆炸,而且模型容易“忘记”早期内容。后来改成 Redis 存结构化状态、prompt 里只放摘要,效果稳很多。所以这个项目如果认真做,Redis 几乎是绕不开的。
提示:Agent 项目和普通 CRUD 项目最大的区别在于“不确定性”。LLM 的输出不是固定的,所以状态管理和异常兜底比业务逻辑本身更重要。
2. 核心细节解析:Agent、LLM、Redis 各自扮演什么角色
2.1 Agent 编排层:决定“下一步做什么”的关键
很多人一上来就写 prompt,结果发现模型一会儿出题一会儿评分,逻辑乱套。问题出在缺少编排。Agent 的本质是一个状态机:当前处于“出题中”“等待回答”“评分中”“追问中”哪个状态,决定了下一步调用 LLM 时该给什么指令。
我在实际项目里常用的做法是定义一个枚举状态:
class InterviewState: ASKING = "asking" # 正在出题 WAITING = "waiting" # 等待用户回答 SCORING = "scoring" # 正在评分 FOLLOW_UP = "follow_up" # 正在追问 FINISHED = "finished" # 面试结束每次用户输入进来,先读 Redis 里的当前状态,再决定走哪条分支。这样做的好处是:LLM 只负责它擅长的事(理解和生成),状态流转由代码控制。我踩过的坑是早期让 LLM 自己判断“现在该干嘛”,结果它经常在评分阶段又跑回去出题,用户体验很差。
Agent 编排还有一个重点是工具调用(Tool Calling)。比如评分时可能需要查题库里的标准答案,追问时可能需要查历史错题。这些都可以封装成工具函数,让 LLM 决定要不要调用。但要注意,工具调用的结果也要写回 Redis,否则下一轮又丢了。
2.2 LLM 推理层:prompt 设计与评分策略
LLM 在这个项目里承担三件事:理解用户答案、生成追问、给出评分和建议。这三件事对 prompt 的要求完全不同。
评分场景我建议用结构化输出,比如要求模型返回 JSON:
{ "score": 7, "dimension": { "correctness": 8, "depth": 6, "expression": 7 }, "comment": "对 Redis 分布式锁的理解基本正确,但没提到锁续期问题", "follow_up": "如果持有锁的线程崩溃了,锁怎么释放?" }为什么要结构化?因为后续代码要拿 score 做统计、拿 follow_up 做下一轮追问。如果让模型自由发挥,解析起来非常痛苦。我试过用正则去抠分数,十次里有三次抠错,后来强制 JSON 输出才稳定。
追问场景则相反,需要模型“像人”。prompt 里要给它设定角色,比如“你是一位有十年经验的 Java 面试官,语气平和但会追问细节”。实测下来,角色设定越具体,追问质量越高。但要注意别让模型太“戏精”,否则它会开始编造不存在的技术细节。
2.3 Redis 状态管理:会话上下文怎么存才合理
Redis 在这个项目里不是“可选项”,而是“必需品”。原因很简单:LLM 是无状态的,每次调用都要把上下文重新喂进去。如果上下文存在数据库里,读写延迟高;存在内存里,服务重启就丢。Redis 刚好卡在中间——快,且可持久化。
我一般会设计两类 key:
| Key 模式 | 类型 | 用途 | 过期时间 |
|---|---|---|---|
interview:session:{id} | Hash | 存当前状态、题目 ID、轮次 | 2 小时 |
interview:history:{id} | List | 存历史问答对 | 2 小时 |
interview:score:{id} | Hash | 存各维度累计得分 | 2 小时 |
用 Hash 存状态是因为字段可以单独更新,不用整体覆盖。用 List 存历史是因为要按顺序取最近 N 轮。过期时间设 2 小时,是因为一场模拟面试通常不会超过这个时长,过期自动清理能省内存。
注意:不要把完整的历史对话直接塞进 prompt。我的做法是只取最近 3 轮原文,更早的用 LLM 生成一段摘要存起来。这样 token 消耗能降一半以上。
Redis 的安装和主从配置这里不展开,网上资料很多。但我要提醒一点:如果你只是本地练手,单机 Redis 足够了;如果要模拟多用户并发,记得把maxmemory-policy设成allkeys-lru,避免内存打满。
3. 实操过程:从零搭一个最小可用的面试 Agent
3.1 环境准备与依赖安装
先把基础环境搭起来。我习惯用 Docker 跑 Redis,省得污染本机环境:
docker run -d --name interview-redis -p 6379:6379 redis:7-alpinePython 侧需要这几个库:
pip install redis openai fastapi uvicorn pydantic这里解释一下选型逻辑。用 FastAPI 是因为它自带异步支持,调 LLM 接口时不会阻塞;用 Pydantic 是为了强制校验 LLM 返回的 JSON 结构,字段缺失直接报错,比手动判断靠谱。Redis 客户端用官方redis-py,连接池记得配一下:
import redis pool = redis.ConnectionPool(host='localhost', port=6379, max_connections=20) r = redis.Redis(connection_pool=pool)max_connections设 20 是经验值。太小了并发上来会排队,太大了浪费连接。本地练手 20 足够,生产环境按 QPS 估算。
3.2 会话初始化与状态写入
用户第一次进来时,先创建会话:
import uuid, json def create_session(user_id: str, topic: str): session_id = str(uuid.uuid4()) r.hset(f"interview:session:{session_id}", mapping={ "user_id": user_id, "topic": topic, "state": "asking", "round": 0, "current_question": "" }) r.expire(f"interview:session:{session_id}", 7200) return session_id这里用hset一次性写多个字段,比多次hset快。expire设 7200 秒,和前面说的 2 小时对应。注意round初始为 0,每问一轮加 1,用来控制面试长度——比如设 10 轮就结束。
3.3 出题与追问的完整链路
出题时,先从题库随机取一道,写入 Redis,然后调 LLM 做“口语化改写”。为什么要改写?因为题库里的题目往往很书面,直接念出来像考试。让 LLM 改写成面试官口吻,体验会好很多。
def ask_question(session_id: str): session = r.hgetall(f"interview:session:{session_id}") question = pick_random_question(session["topic"]) prompt = f"把下面这道题改写成面试官口语提问,保持技术点不变:{question}" spoken = call_llm(prompt) r.hset(f"interview:session:{session_id}", mapping={ "current_question": question, "state": "waiting" }) return spoken用户回答后,进入评分环节。评分 prompt 里要带上题目、参考答案、用户答案三样东西:
def score_answer(session_id: str, user_answer: str): session = r.hgetall(f"interview:session:{session_id}") question = session["current_question"] reference = get_reference_answer(question) prompt = f""" 你是资深面试官。题目:{question} 参考答案:{reference} 候选人回答:{user_answer} 请按 JSON 返回评分,字段:score(0-10), comment, follow_up。 """ result = call_llm(prompt, json_mode=True) r.rpush(f"interview:history:{session_id}", json.dumps({ "q": question, "a": user_answer, "score": result["score"] })) r.hincrby(f"interview:score:{session_id}", "total", result["score"]) r.hset(f"interview:session:{session_id}", "state", "follow_up") return result这段代码里有几个细节值得说。rpush把问答对追加到 List 尾部,取的时候用lrange拿最近几条。hincrby直接对总分做原子自增,避免读改写竞态。状态改成follow_up后,下一轮就走追问分支。
3.4 追问与结束判断
追问不是无限问下去。我的策略是:如果评分低于 6 分,追问一次;高于 6 分,直接进入下一题。追问次数也存 Redis,防止死循环。
def handle_follow_up(session_id: str, user_answer: str): follow_count = int(r.hget(f"interview:session:{session_id}", "follow_count") or 0) if follow_count >= 1: r.hset(f"interview:session:{session_id}", "state", "asking") return ask_question(session_id) r.hincrby(f"interview:session:{session_id}", "follow_count", 1) return generate_follow_up(session_id, user_answer)轮次控制同理,round到 10 就把状态设成finished,然后生成一份总结报告。总结报告可以让 LLM 根据历史得分和评语生成,也可以自己拼装。我倾向自己拼装,因为 LLM 总结容易说套话,不如直接列数据。
4. 常见问题与排查技巧实录
4.1 LLM 返回格式不稳定怎么办
这是最高频的问题。明明 prompt 里写了“返回 JSON”,模型还是给你加一段“好的,以下是评分结果:”。解决办法有三个层次:
第一,用 API 的response_format参数强制 JSON 模式(如果模型支持)。第二,在 prompt 里给一个示例输出,模型模仿能力很强。第三,代码里做兜底解析,用正则先抠出{...}再json.loads,失败就重试一次。
我实测下来,三层叠加基本能解决 95% 的格式问题。剩下 5% 是模型抽风,重试即可。
4.2 Redis 连接超时与数据丢失
本地开发时经常遇到ConnectionError。排查顺序是:先docker ps看容器在不在,再redis-cli ping看能不能通,最后检查代码里的 host 是不是写成了localhost而容器里应该用服务名。
数据丢失多半是没设过期时间或者设太短。我建议所有会话相关的 key 都统一设 2 小时,并且在每次读写时都刷新一次过期时间(expire),避免用户思考太久导致会话中途失效。
4.3 多轮对话中模型“忘记”上下文
这个问题的根源是 prompt 里没带历史。我的做法是每次调 LLM 前,从 Redis 取最近 3 轮问答拼进 prompt。更早的用摘要代替。摘要本身也存 Redis,key 是interview:summary:{id},每 5 轮更新一次。
| 问题现象 | 可能原因 | 排查方法 | 解决手段 |
|---|---|---|---|
| 模型重复问同一题 | 状态没更新 | 查 Redis 里 state 字段 | 评分后强制改 state |
| 评分忽高忽低 | prompt 缺参考答案 | 打印完整 prompt | 补上 reference |
| 追问跑题 | 角色设定太泛 | 检查 system prompt | 加具体角色和约束 |
| 会话突然失效 | 过期时间太短 | ttl命令查看 | 每次读写刷新 expire |
4.4 面试题难度控制的小技巧
题库如果只有固定难度,用户体验会很平。我的做法是给每道题打一个难度标签(easy/medium/hard),然后根据用户当前累计得分动态调整。得分高就出难题,得分低就出简单题。这个逻辑放在 Agent 编排层,不交给 LLM 判断,因为 LLM 对“难度”的感知不稳定。
具体实现就是维护一个分数区间映射:
def pick_difficulty(total_score, round_num): avg = total_score / max(round_num, 1) if avg >= 8: return "hard" if avg >= 5: return "medium" return "easy"这样面试节奏会更自然,不会一上来就把人问懵。
5. 这个项目后续还能怎么扩展
5.1 接入语音与实时反馈
现在都是文字交互,但真实面试是说话的。后续可以接语音识别,把用户口述转成文字再走评分链路。更进一步,可以分析语速、停顿,给出“表达流畅度”维度的评分。这块技术栈会复杂一些,但价值很高。
5.2 多 Agent 协作:出题官、评分官、复盘官
单个 Agent 什么都干,prompt 会越来越臃肿。更好的做法是拆成多个专职 Agent:出题官只管出题,评分官只管评分,复盘官负责最后总结。它们之间通过 Redis 传递状态。这样每个 Agent 的 prompt 都能保持精简,输出质量更稳定。
5.3 题库与知识库的持续沉淀
面试题不是一成不变的。可以做一个后台,把用户高频答错的题目标记出来,人工补充参考答案,再回流到题库。时间久了,这个题库本身就是资产。如果结合 RAG(检索增强生成),还能让 Agent 在评分时引用更准确的资料,减少胡编。
我在实际搭这类项目时的体会是:Agent 的难点从来不是调通 LLM,而是把状态、异常、体验这三件事处理好。LLM 只是其中一个零件,Redis 和编排逻辑才是骨架。把骨架搭稳了,换哪个模型都能跑;骨架不稳,模型再强也白搭。