《码上面试》Agent 项目学习记录(一)

《码上面试》Agent 项目学习记录(一)

该项目作者为程序员牛肉,感兴趣的同学可以去抖音搜索程序员牛肉

《码上面试》 是一个 AI 模拟面试与智能技术评测平台。候选人上传 PDF 简历后,系统全自动唤醒专精 Agent 解析技能图谱并智能出题,在随后的多轮实时语音交互中,AI 面试官会结合候选人回答进行拟人化打分、深度连环技术追问,最终生成包含多维雷达图、知识盲区诊断与改进建议的深度技术报告。

在《码上面试》平台中,分工明确地部署了 5 个专精 Agent:

  1. 简历出题官 Agent:负责解析候选人 PDF 简历,做技能画像提炼与结构化考点抽取;
  2. 面试提问官 Agent:根据当前考点,拟人化发起面试开场与技术提问;
  3. 答案评分官 Agent:比对回答要点,输出结构化打分与知识点漏洞;
  4. 追问决策官 Agent:结合遗漏要点,生成深度连环技术追问;
  5. 神态分析官 Agent:抓拍摄像头画面,评估候选人专注度与仪态。

该项目通过 5 大技术阶段 ,将这 5 个 Agent 紧密包裹在工业级高可用、高并发、低成本与长会话治理架构中。本文重点解析第一阶段:简历解析与智能出题。


第一阶段:简历解析与智能出题实现流程

1. 阶段目标与业务流转

当候选人上传一份 PDF 技术简历后,系统需要以高可靠、防并发、低成本的方式调度"简历出题官 Agent",由大模型完成:

  • 识别候选人技术方向(Java 后端、前端、算法、全栈等);
  • 提炼 3~5 道覆盖基础、架构、实战的结构化面试题目;
  • 给出针对该简历的改进建议(suggestions)与简历综合评分(resumeScore);
  • 初始化 Redis 答题流状态,推动会话进入就绪态(READY)。

2. 架构交互时序图(Mermaid)

sequenceDiagram autonumber participant Client as 候选人前端 participant Service as InterviewQuestionExtractionService participant Resolver as BusinessAgentResolver participant Lock as SessionLockService participant AI as 出题官 Agent (星辰/LLM) participant DB as MySQL (存底容灾) participant Redis as Redis (流转缓存) Client->>Service: 上传 PDF 简历 (sessionId, resumePdf) Service->>Resolver: 1. 动态场景解析 (获取出题官 Agent 配置) Service->>Service: 2. 本地计算文件哈希 (resumeContentHash) Service->>Lock: 3. 获取会话级排他锁 (防止重复连击) Service->>AI: 4. 带 Single-Flight Key 调用出题官 Agent AI-->>Service: 返回大模型原始 JSON/文本 (fullContent) Service->>DB: 5. 关键容灾:先持久化原始文本 (防止解析异常数据丢失) Service->>Service: 6. 结构化防御提取 (提取 questions, 拦截 smallTalk) Service->>Redis: 7. 初始化答题流状态 (cacheQuestions & initInterviewFlow) Service-->>Client: 返回题目列表与评估结果

3. 核心业务代码主链路剖析

在 InterviewQuestionExtractionService.java 中,核心业务代码主链路如下:

java 复制代码
public InterviewQuestionRespDTO extractInterviewQuestions(InterviewQuestionReqDTO reqDTO) {
    InterviewQuestionRespDTO response = new InterviewQuestionRespDTO();
    response.setSessionId(reqDTO.getSessionId());
    response.setUserName(reqDTO.getUserName());

    // 1. 动态场景解析:获取出题官 Agent 配置
    AgentPropertiesDO agentProperties = businessAgentResolver.resolveRequired(
            BusinessAgentScene.INTERVIEW_QUESTION_EXTRACTION);
    reqDTO.setAgentId(agentProperties.getId());
    response.setIsSuccess(0);

    // 2. 锁外预计算:纯本地 CPU/内存计算文件哈希,减少锁占用时长
    String resumeContentHash = computeResumeHash(reqDTO.getResumePdf(), reqDTO.getSessionId());

    RLock heavyLock = null;
    long startTime = System.currentTimeMillis();
    try {
        // 3. 防线二:获取会话级分布式排他锁(Redisson Fail-Fast)
        heavyLock = interviewAiSessionLockService.acquire(reqDTO.getSessionId(), InterviewAiGuardStage.INTERVIEW_EXTRACTION);
        if (heavyLock == null) {
            response.setErrorMessage("AI_OVERLOADED: extraction is processing, please retry");
            return response;
        }

        // 4. 上传简历文件至大模型平台
        String fileUrl = uploadResumeIfPresent(reqDTO, agentProperties, response);
        if (fileUrl == null) {
            return response;
        }

        // 5. 防线三:带 Single-Flight 指纹调度出题官 Agent
        String fullContent = interviewAiInvoker.callAiSyncWithFile(
                EXTRACTION_PROMPT,
                reqDTO.getSessionId(),
                agentProperties,
                fileUrl,
                InterviewAiGuardStage.INTERVIEW_EXTRACTION,
                interviewAiInvoker.buildSingleFlightKey(InterviewAiGuardStage.INTERVIEW_EXTRACTION, reqDTO.getSessionId(), resumeContentHash)
        );

        long responseTime = System.currentTimeMillis() - startTime;
        reqDTO.setResumeFileUrl(fileUrl);

        // 6. 防线四A:先持久化原始文本,再做反序列化(关键容灾)
        persistRawResponse(reqDTO, fullContent, responseTime);

        // 7. 防线四B:结构化解析、兼容性处理与 smallTalk 语义拦截
        if (!populateStructuredResponse(reqDTO, response, fullContent)) {
            return response;
        }

        response.setIsSuccess(1);
        log.info("Interview question extraction completed, sessionId={}", reqDTO.getSessionId());
        return response;
    } catch (Exception e) {
        // 异常记录存底与错误返回...
    } finally {
        // 8. 必须在 finally 中安全释放分布式锁
        interviewAiSessionLockService.release(heavyLock);
    }
}

二、 环环相扣的"四道防线"设计

很多初学者容易产生一个误解:"简历上传出题,不就是在 Controller 或 Service 上加一个分布式锁防重复提交吗?"

在面对大模型动辄 10~20 秒的高耗时推理与不确定性输出时,单一的分布式锁根本兜不住复杂的线上故障。系统构建了 "环环相扣的四道防线":

防线序号 防线名称 技术载体 核心防护目标
第一道防线 业务状态机门禁 markResumeUploading + requireOwnedSession 从业务层锁死生命周期,非草稿/初始态直接拦截,防止跨阶段非法调用与状态污染
第二道防线 会话级分布式排他锁 Redisson tryLock(0, 45, TimeUnit.SECONDS) 拦截毫秒级用户手抖连击,未获锁者快速失败(Fail-Fast),保护服务器工作线程池
第三道防线 基于简历指纹的 Single-Flight 文件 SHA-256 + Redis Lua 状态机 + Fencing Token 应对用户刷新重试、网关超时重试与锁超时并发,全集群原地搭便车与秒级回放,杜绝重复调用大模型与重复扣费
第四道防线 原始报文落库容灾与语义防御门禁 persistRawResponse + smallTalk Guard 先落 raw text 原始盘再解析,避免格式损坏丢数据;拦截非简历或模型闲聊,杜绝空题库流入答题流水线

1. 第一道防线:业务状态机门禁(markResumeUploading)

  • 作用机制 :只有处于 DRAFT(草稿态)的会话才允许触发简历上传。进入提取前,先把会话标记为 RESUME_UPLOADING。一旦处于此状态,任何并发进入的上传请求或提前进入的答题请求都会在业务校验层被直接拦截,避免状态机发生逆向漂移。

2. 第二道防线:会话级分布式排他锁(Redisson Fail-Fast)

  • 作用机制 :锁粒度为 interview:ai:heavy:lock:{stage}:{sessionId}。锁等待时间 waitTime = 0,持锁超时 leaseTime = 45s。当面对瞬间连击时,只有 1 个线程获锁,其余并发线程立即抛出异常并快速失败,坚决不在内存中排队阻塞,保护应用线程池不被耗尽。

3. 第三道防线:基于简历指纹的 Single-Flight

  • 作用机制:普通分布式锁只能防"同一会话的瞬时并发",但如果用户在 15 秒后以为卡住了刷新页面重试,或者网关触发了超时重试,此时分布式锁可能已经释放。通过计算简历文件的二进制 SHA-256 哈希,系统只要识别到内容指纹一致,后到的请求直接挂起"搭便车"或秒级回放前次结果,实现 0 毫秒响应与 0 重复扣费。

4. 第四道防线:原始报文落库容灾与语义防御门禁

  • 作用机制 :
    1. 存底容灾:拿到响应第一时间先将大模型原始报文(raw text)存入 MySQL。即便后续 JSON 反序列化崩溃,原始凭据依然完好,支持事后补偿;
    2. 语义拦截 :若候选人上传空白文件或风景图片,大模型可能会客套闲聊并把回复放入 smallTalk。代码硬性校验题目数量,一旦题目为空或触发 smallTalk,直接报错阻断,绝不允许空题目数据污染答题流程。

四、 核心疑难问题深度剖析


Q1:Single-Flight 是怎么实现的?

1. 架构与运行机制

Single-Flight(单飞机制)的核心思想是:"全集群针对同一请求,永远只有 1 个节点去真正调用大模型,其余所有相同请求原地'搭便车',共享这一份计算结果。"

在本项目中,自研了基于 Redis Lua 原子状态机 + 递增 Fencing Token + 观察者通知 的分布式 Single-Flight 方案:

sequenceDiagram autonumber participant NodeA as 节点 A (Owner) participant NodeB as 节点 B (Follower) participant Redis as Redis (Lua 协调中心) participant LLM as 出题官 Agent (LLM) Note over NodeA,NodeB: 并发到达相同指纹的请求 NodeA->>Redis: 1. 执行 acquireOrJoin Lua 脚本 NodeB->>Redis: 2. 执行 acquireOrJoin Lua 脚本 Redis-->>NodeA: 返回 OWNER_NEW (分配 ownerToken = 101) Redis-->>NodeB: 返回 FOLLOWER_WAIT (识别为跟随者) NodeA->>LLM: 3. 真正发起模型调用 (耗时 12s) loop 后台守护线程心跳保活 NodeA->>Redis: 4. 定期发送 Heartbeat 续期 PEXPIRE end NodeB->>NodeB: 5. followerWait: 挂起监听 Redis 通知或本地短轮询 LLM-->>NodeA: 6. 返回出题内容 NodeA->>Redis: 7. 执行 Lua 存入压缩结果, 状态置为 SUCCEEDED NodeA-->>NodeB: 8. 发布 Redis Pub/Sub 广播唤醒 NodeB->>Redis: 9. 校验 checksum 并反序列化, 极速回放 (Replay) Note over NodeA,NodeB: 两个节点同时向客户端返回成功出题结果,大模型只被调用 1 次!
2. 核心技术设计要点
  1. Lua 脚本原子状态裁决(acquireOrJoin) : 在代码中,单脚本内裁决状态:
    • 若 Key 不存在:通过 INCR ai:flight:owner-seq 生成单调递增的 ownerToken,状态置为 PENDING,返回 OWNER_NEW;
    • 若状态为 SUCCEEDED:直接返回 REPLAY_SUCCESS;
    • 若仍在运行且心跳健康:增加 followerCount,返回 FOLLOWER_WAIT;
    • 若原 Owner 心跳超时:当前请求原子夺权,返回 OWNER_TAKEOVER。
  2. Fencing Token 防脑裂 : 每次换届或抢占都会递增 ownerToken。老 Owner 如果因为网络闪断或 Full GC 假死恢复,尝试写回旧结果时,Lua 脚本比对 ownerToken 发现失效,会直接拒绝写入,杜绝脑裂覆盖。
  3. 后台心跳保活与超时接管 : Owner 启动后台守护定时任务(FlightHeartbeatManager),以固定频率刷新 Redis 中元数据的 heartbeatAt。若 Owner 宕机,Follower 在等待超时间隙检测到心跳过期,会自动发起接管,避免请求无限死等。
  4. 两级缓存极速回放 : 配备本地 L1 缓存(FlightReplayLocalCache)与 L2 Redis。一旦成功,本地与远程同时缓存压缩结果,后续请求回放延迟低于 1ms。

Q2:这里重复点击会不会用到 Single-Flight?

结论

一定会用到!分布式锁与 Single-Flight 在这里是"时空互补"的防御关系,解决的是完全不同时间维度的并发痛点。

深度对比剖析
tex 复制代码
+-----------------------------------------------------------------------------------+
|  【维度一:毫秒级用户手抖连击】                                                   |
|  Request 1 (0ms)   ---> 抢占分布式锁成功 ---> 正在调用大模型 (预计耗时 15s)       |
|  Request 2 (50ms)  ---> 尝试获取分布式锁 (waitTime=0) ---> 💥 瞬间 Fail-Fast 报错 |
|  此时由【第二道防线:分布式排他锁】直接阻断,保护线程池,不需要打到 Single-Flight。|
+-----------------------------------------------------------------------------------+

+-----------------------------------------------------------------------------------+
|  【维度二:用户焦虑刷新 / 网关超时重试 / 跨会话相同简历】                         |
|  Request 1 (0s)    ---> 拿到锁调用大模型,耗时较久...                             |
|  Request 2 (16s)   ---> 此时 Request 1 结束或锁超时释放;用户刷新页面重新点击上传  |
|                         Request 2 成功获取分布式锁!                              |
|                         进入调用链路时,识别出【简历 SHA-256 内容哈希一致】       |
|                         ---> 🎯 命中 Single-Flight!0ms 极速回放已出题目!         |
+-----------------------------------------------------------------------------------+
  1. 分布式锁负责防御"瞬时高并发冲垮应用线程池" : 用户 1 秒内连续狂点 5 次,第 1 个请求获锁,第 2~5 个请求在 tryLock(0) 时快速失败,避免 5 个长耗时线程把 Tomcat 工作线程全部卡死。
  2. Single-Flight 负责防御"业务重试导致的大模型算力重复扣费" :
    • 客户端刷新重试 :大模型解析长简历耗时往往在 10~20 秒。用户见页面长时间 loading,误以为卡死而按 F5 刷新重新上传。此时分布式锁可能已自然释放,新请求将获锁进入。如果没有 Single-Flight,大模型又会被调用一次并扣费;而在 Single-Flight 保护下,新请求通过文件哈希指纹直接秒级回放结果;
    • 网关层自动重试(Nginx / Envoy / Axios):微服务网关默认常配置 5 秒超时重试。旧请求还在模型端执行,网关在第 5 秒发起重试。Single-Flight 让重试请求作为 Follower 挂起,并在第 8 秒直接拿走第一个请求算好的结果,挽救了网关 504 报错;
    • 用户体验差异("报错弹窗" vs "静默搭车"):分布式锁给用户的是冷冰冰的"请勿重复提交"报错红字;而 Single-Flight 能让并发与重试请求静默复用计算结果,候选人体验极度丝滑。

Q3:业务状态机门禁是怎么实现的?

1. 状态生命周期枚举
java 复制代码
public enum InterviewSessionStatus {
    DRAFT,            // 草稿初始态
    RESUME_UPLOADING, // 简历上传解析中
    READY,            // 题库与画像就绪
    IN_PROGRESS,      // 正在作答面试
    FINISHED,         // 面试完成
    ABANDONED         // 已废弃
}
2. 状态机门禁与流转闭环
java 复制代码
public InterviewQuestionRespDTO extractInterviewQuestions(...) {
    // 1. 门禁检查:验证用户所有权,并打上"RESUME_UPLOADING"状态
    interviewSessionService.markResumeUploading(sessionId, userId);

    // 2. 调度底层提取
    InterviewQuestionRespDTO response = interviewWorkflowService.extractInterviewQuestions(reqDTO);

    // 3. 状态原子推进
    if (response != null && Integer.valueOf(1).equals(response.getIsSuccess())) {
        // 成功:推进到 READY 态,并持久化简历 URL 与方向
        interviewSessionService.markReady(sessionId, userId, response.getResumeFileUrl(), response.getInterviewType());
        // 触发长会话运行时快照同步
        runtimeSnapshotService.refreshAfterQuestionExtraction(sessionId);
        return response;
    }

    // 4. 异常回滚补偿:解析失败回退回 DRAFT,允许用户修正重试
    interviewSessionService.markDraft(sessionId, userId);
    return response;
}
3. 业务价值
  • 防止逆向穿越 :如果当前会话已经在作答第 3 题(IN_PROGRESS)或已结束(FINISHED),业务层 requireOwnedSession 校验会直接抛出非法状态异常,坚决杜绝在面试中途被重新上传简历覆盖题库的灾难性 Bug;
  • 可恢复性设计 :若大模型调用遭遇偶发网络超时,状态机在 catch 块中优雅地原子回退到 DRAFT,候选人刷新后即可再次尝试,绝不会卡死在中间态。

Q4:怎么保证 Agent 面试官解析简历和提出的问题不出问题?

大模型的本质是概率预测,具有天然的不确定性、格式幻觉与发散性。系统通过 "强提示词工程 + 结构化防御提取 + SmallTalk 拦截 + 原始报文存底" 构建了稳健的工程护栏。

1. 强提示词约束(Strict System Prompt)
text 复制代码
Extract technical interview questions from the uploaded resume. 
Return JSON only with keys questions, sugest, type, and resumeScore. 
Do not output smallTalk, greetings, or fallback chat content.

从提示词层面直接封杀"好的,已收到您的简历"等寒暄前缀,强约束只返回标准 JSON。

2. 自适应容错解析器(InterviewResponseParser)

即使规定了 JSON,不同模型版本依然可能返回包含 Markdown 代码块(如 json ... )或在外层包装了 choices[0].message.content。

所以在代码中实现了自适应解包:

  • 递归剥离大模型的 Envelope 包装层;
  • 支持字段历史别名模糊兼容:例如方向字段,优先取 type,若不存在则依次探测 interviewType、direction、interviewDirection,具备强大的模型迁移容错能力。
3. 闲聊拦截门禁(SmallTalk Guard)

如果候选人恶意或误上传了一张风景图、表情包或空白文档,大模型往往会礼貌性回复:"您上传的似乎不是一份简历,我无法出题哦"。模型甚至会把这句话塞进 smallTalk 字段,而把 questions 置空。

代码中构建了硬核拦截:

java 复制代码
List<String> questions = normalizeStringList(responseMap.get("questions"));
if (questions.isEmpty()) {
    String smallTalk = interviewResponseParser.asString(responseMap.get("smallTalk"));
    response.setErrorMessage(StrUtil.isNotBlank(smallTalk)
            ? "workflow fell back to smallTalk instead of interview questions"
            : "workflow returned empty interview questions");
    log.warn("Interview question extraction returned no questions, sessionId={}, smallTalk={}",
            reqDTO.getSessionId(), smallTalk);
    return false; // 坚决阻断流程推进!
}

绝对不允许题目数量为 0 的脏数据推进到 READY 状态,并在前端清晰告知用户重新上传有效简历。

4. 容灾思维:"先落原始盘,再做反序列化"

在拿到大模型响应后:

java 复制代码
// 先持久化原始响应到 MySQL 存底
persistRawResponse(reqDTO, fullContent, responseTime);

// 随后才进行反序列化
if (!populateStructuredResponse(reqDTO, response, fullContent)) {
    return response;
}

"先落盘再解析"确保即便模型吐出了畸变报文导致 Fastjson2 反序列化崩溃,数据库里依然存有完整的原始调用凭证,便于运维定位、日志回溯与离线补回。


Q5:分布式锁的设计是怎么设计的?

分布式锁看似简单,但在长达 10~20 秒的大模型调用场景下,细节稍有不慎就会引发死锁、误删锁或工作线程池打满。

1. 锁维度(Key 粒度设计)
java 复制代码
String lockKey = "interview:ai:heavy:lock:" + stage + ":" + sessionId;
  • 精准锁会话,不锁全局:会话与会话之间完全并发,隔离互不影响;
  • 引入 stage 命名空间 :将出题阶段(INTERVIEW_EXTRACTION)与后续答题阶段区分开,防止不同阶段的锁发生命名冲突。
2. 锁外预计算(Lock-Free Pre-computation)
java 复制代码
// 纯本地 CPU / 内存操作,严禁进入锁临界区
String resumeContentHash = computeResumeHash(reqDTO.getResumePdf(), reqDTO.getSessionId());

// 预计算完成后,再获取分布式锁
heavyLock = interviewAiSessionLockService.acquire(sessionId, stage);

在加锁前先把 PDF 读入内存完成 SHA-256 计算。永远不要把与共享状态无关的耗时本地计算写在锁内部,极致压缩持锁时间。

3. 快速失败策略(Fail-Fast)
java 复制代码
public RLock acquire(String sessionId, String stage) throws InterruptedException {
    RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + stage + ":" + sessionId);
    boolean acquired = lock.tryLock(
            resolveWaitMillis(),      // waitTime 默认配置为 0L (不等待)
            resolveExpireSeconds(),   // leaseTime 默认配置为 45L 秒 (防死锁兜底)
            TimeUnit.SECONDS
    );
    return acquired ? lock : null;
}
  • 为什么 waitTime 设为 0?
    大模型解析一份简历需要 15 秒,如果有 10 个并发重复请求,如果设置排队等待,10 个线程会被全部挂起在内存中 10~15 秒,极易打满应用工作线程池。设为 0 可以实现毫秒级快速失败,立刻将压力从服务器卸掉;
  • 为什么 leaseTime 设为 45 秒?
    防止业务节点在调用大模型期间发生物理机宕机、OOM 崩溃或网络断开导致锁永久无法释放(死锁)。45 秒足够覆盖一次模型超时的极限时间。
4. 原子安全释放与防误删
java 复制代码
public void release(RLock lock) {
    if (lock != null && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

在 finally 块中释放锁时,必须调用 lock.isHeldByCurrentThread()。Redisson 底层通过 Lua 脚本比对客户端 UUID + 线程 ID:

  • 只有当前线程持有的锁才允许执行 unlock;
  • 彻底杜绝因业务超时锁已自动过期、当前线程在 finally 中误删别的线程刚申请的锁的经典并发 Bug。

Q6:为什么不用看门狗?

  1. 防止假死导致无限死锁:大模型调用属于外部网络 I/O,一旦外部服务僵死或连接半开,只要本地 JVM 没挂,看门狗就会在后台永远续期,导致该 Session 的分布式锁永久卡死,候选人永远无法重试;
  2. 契合网络超时 SLA:我们在调用大模型的 HTTP 客户端配置了 30s 读超时,分布式锁设置 45s 已经足够覆盖'模型超时+本地容灾落盘'的安全上界,超过 45s 说明链路彻底异常,不应继续持有;
  3. 架构职责解耦:Redisson 锁在我们系统里只负责'入口级防手抖连击';真正针对模型长耗时的保活与容灾,我们交给了底层的分布式 Single-Flight 机制(带心跳管理与 Fencing Token 超时接管)。如果最外层用了看门狗无限锁死,反而会废掉底层的故障自愈能力。"
相关推荐
货拉拉技术2 小时前
货拉拉 DataAgent 实践:策略复盘的智能化探索
llm·agent
review445432 小时前
AI 协作项目里,如何引入"测试先行"
agent
hpoenixf2 小时前
别再把 Agent 失败都算给模型:一次从粗错误码到失败链的排查
agent
hpoenixf2 小时前
大模型有返回等于 Agent 成功运行吗?
agent
码哥字节2 小时前
Claude Code 把自己改成了任务调度器,这次设计比功能更值得看
agent·claude
杨杨杨大侠2 小时前
一句“修个 Bug”,AI 编程工具到底怎么扣额度?
人工智能·agent·ai编程
杨杨杨大侠2 小时前
MCP 到底接在了哪一层?从“Agent 调工具”说起
agent·ai编程·mcp
李溪白2 小时前
篇七:部署 —— 从本地脚本到 API 服务,再到生产环境架构选型
agent
阿里云大数据AI技术2 小时前
云栖2026 | 阿里云 OpenLake 迈向 Agentic Lake,一份全模态数据驱动智能体就绪
大数据·人工智能·agent