AI-Meeting项目复盘
写在前面
这篇文章是我对“码上面试”项目的一次完整复盘。主要是按照初学者第一次接触复杂项目时的思考与理解的逻辑去写的,如果大家觉得有需要补充的或者哪里理解的不到位的,欢迎评论区探讨哦!
学习方法
首先我想简单的讲一下我的总结复盘方法。其实我在以前学习项目的时候也会走一些弯路,比如说一直给自己输入知识但不输出,学一天下来脑子里乱哄哄的不知道学到了什么。后来我慢慢找到了比较适合我自己的办法,就是要随时记录自己的思路,比如说我们可以开一个学习笔记的文档,里面的内容就是自己每隔一个小时记录一下这一轮的任务以及学到的东西。
对于学到的东西我们应该怎么去总结,一个比较简单的办法就是:现在学习的时候基本上都是搭配AI学习的,我可以把这一个小时学到的内容用语音转文字口述的总结一下,随后再交给AI检查是否有理解有误的地方、是否有用词不标准的地方,让AI给我加以润色按照我刚才的总结的那种逻辑顺序修改部分不合适的字眼以后给我返回过来。
这样做有两个好处,一方面是加强我们的语言表达能力,总结的时候就想象此时正在面试,也能让自己适应把你的思路讲出来这种模式。因为我经常会遇到一些人包括我自己,可能脑子里知道但一讲出来就紧张磕巴,所以这是比较适合我们这类人的一种提高方法。另一方面,我们得到的文本会更加符合我们自己的思考逻辑,所以相比于官方文档或者项目源码,更符合自己逻辑的文本随便一看就能看懂自己当时的思路是怎么样的。包括现在的文章其实也是我直接把我复盘好的学习笔记上传给AI让他帮我润色的。
切记!一定不能懒得总结,一直埋头学新的,这样只是在欺骗自己,降低自己的效率,学了一天傻乎乎的不知道自己学到了什么。其实还有退阶版:如果你觉得你的总结能力真的很弱,其实你可以先让AI给你总结,然后自己理解、补充之后再用合乎自己的逻辑讲一遍出来。后面慢慢的再脱离AI总结,培养自己整合信息并表达的能力。
项目简介
接着就是这个项目啦。这个项目表面上看是一个 AI 模拟面试系统:用户上传简历,系统抽取面试题,用户逐题作答,AI 给出评分、反馈、追问,最后生成面试结果和能力画像。但真正值得讲的地方不是通过调用大模型接口,而是围绕一次 AI 面试的完整业务闭环,做了几套工程化设计:
- 用状态机和规则链推进核心答题流程,避免 AI 面试链路乱跳、重复推进、数据不一致。
- 用 Redis + MongoDB 的热冷分层治理长对话运行态,解决 Redis 丢失后会话无法继续的问题。
- 用 WebSocket + Pipe + 增量组装器实现实时 ASR,解决实时转写里的重复文本、片段修正和最终结果收口问题。
- 用分布式 Single-flight 合并高成本 AI 调用,避免多实例部署下重复打模型。
- 用 Skill 机制沉淀项目知识,尝试从技术角度缓解文档腐化和业务知识断层。
如果只用一句话概括这个项目,我会这样说:
这是一个 AI 面试长会话系统,后端不只是把请求转发给大模型,而是围绕“出题、答题、评分、追问、状态恢复、实时语音输入、AI 调用治理、知识沉淀”搭了一整套可持续推进、可恢复、可降级、可复盘的工程链路。
一、项目整体链路
用户进入系统后,完整链路大致是这样:
创建面试会话
-> 上传简历
-> AI 抽取面试题
-> 初始化面试 flow
-> 用户回答当前题
-> 后端校验当前题上下文
-> AI 评分
-> 规则链判断是否追问
-> 生成追问或推进下一题
-> 写入分数、turn log、运行态快照
-> 最终完成面试并生成报告
旁路能力还有两条:
实时 ASR:
前端 WebSocket 上传音频
-> 后端会话级 Pipe 缓冲
-> 推送给讯飞 ASR
-> 增量结果组装
-> 结构化快照回推前端
神态分析:
前端上传图片
-> 会话级重锁
-> 文件上传
-> 分布式 Single-flight
-> AI guard
-> 神态分析 workflow
-> 解析 panicLevel / seriousnessLevel / compositeScore
整个后端的架构可以分成几层理解:
Controller 层:接收 HTTP / WebSocket 请求,只做参数和用户身份入口
Facade / 编排层:把业务入口收敛成统一的面试流程
领域服务层:出题、答题、评分、追问、神态、ASR 各自处理业务
AI 调用层:统一构造 AI 请求、Single-flight、限流、熔断、超时、重试
运行态层:Redis 保存在线热状态,MongoDB 保存可恢复检查点
知识治理层:用 Skill 维护业务知识和代码映射
几个角色:
统一 AI 调用入口:负责生成业务 key,把真实 AI 调用包装成 supplier
答题主控器:负责一轮答题的幂等、校验、评分、追问和推进
运行态恢复服务:负责 Redis 丢失后从快照和归档材料重建运行态
分布式协同服务:负责 owner/follower 选举,让同一个 AI 请求只执行一次
ASR 会话上下文:负责把前端音频输入和下游 ASR 推流解耦
Skill 知识库:负责让 Agent 写代码前知道项目业务边界
二、核心答题链路:按主流程看一轮答案怎么闭环
AI 面试答题接口从外面看很像一句话:
用户提交答案 -> 系统评分 -> 返回下一题
但项目里真正要保证的是“一轮答题闭环”。因为一次提交不只是拿到一个 AI 分数,它还会影响当前题号、flow 状态、追问次数、总分、turn log、幂等回放和运行态快照。任何一步重复执行、顺序错位、半成功半失败,都会让后面的长会话变得不可控。
所以这条链路更准确地说是:
HTTP 接收答案
-> 用户和会话门禁
-> 工作流分发到答题主控器
-> 基础参数校验
-> requestId 归一化
-> 幂等门禁
-> 加载可写热运行态和当前题上下文
-> 校验请求题号是否仍是当前题
-> 获取题级分布式锁
-> 锁后再次校验当前题
-> 进入评分阶段
-> 解析评分结果但暂不提交分数
-> LiteFlow 裁决是否追问
-> 生成追问或推进下一道主问题
-> 提交分数、写 turn log、标记幂等成功、刷新快照
下面按这个主链路顺序拆开讲。遇到专门需要展开的点,我会在对应步骤里展开。
1. Controller:接口层只负责接收参数,不承载主流程
用户点击提交答案后,请求先进入接口层。这里会拿到几类核心参数:
sessionId:当前面试会话
questionNumber:前端认为自己正在回答的题号
answerContent:用户答案
requestId:前端传来的请求幂等标识,可选
currentUser:当前登录用户
接口层的职责比较克制:接收 HTTP 请求、完成参数绑定、做最基础的空值校验,然后把请求交给下游。它不应该在这里直接调 AI,也不应该在这里推进题目。因为答题链路后面涉及幂等、锁、运行态恢复、状态机推进、评分和快照,如果都塞进接口层,后面会很难维护。
可以把这一层说成“入口适配层”:它负责把外部请求变成内部 DTO,但不负责真正的答题业务闭环。
2. Facade 门禁:先判断这个人和这场面试能不能继续答
请求进入真正的答题流程前,先要过一层用户和会话门禁。这里也不是评分,而是先回答几个基础问题:
这个用户是不是这场 session 的拥有者?
这场面试当前状态是否允许继续?
如果是第一次正式答题,要不要把会话从 READY 推到 IN_PROGRESS?
这层存在的意义是避免非法请求直接打到答题主控器。否则可能出现:
非本人 session 被提交答案
已经结束的面试继续答题
会话一直停留在 READY,没有进入 IN_PROGRESS
每个接口都重复写一遍归属校验和状态校验
所以 Facade 可以理解成“用例入口守门员”。它保证当前用户对这场会话执行“提交答案”这个动作是合法的,再把请求交给后面的工作流层。
3. 工作流分发:上层声明动作,主控器处理细节
过了门禁之后,请求不会直接散落到某个评分服务,而是先进入工作流抽象层。这个层面定义的是面试系统对外提供的能力,比如:
抽取面试题
提交答案
获取当前题
获取下一题
神态分析
在“提交答案”这个场景下,工作流层会把请求分发给答题主控器。这里的设计思想是:上层只需要知道“我要完成一次答题动作”,不需要知道里面到底怎么幂等、怎么加锁、怎么恢复 runtime、怎么调评分 Agent、怎么决定追问。
这里也可以顺手理解 Agent 热插拔的第一层原因:业务入口不应该绑定具体 Agent 或 workflow。上层关心的是“评分能力”“追问能力”“抽题能力”,而不是某个写死的 Agent 名称。
项目里更合理的表达是:
业务层声明能力
-> 解析层根据场景找到当前绑定的 Agent
-> 缓存/数据库读取 Agent 调用配置
-> 统一 AI 调用入口执行真实调用
这样后面评分 Agent、追问 Agent、抽题 Agent 要换版本,不需要改主流程代码,只需要调整配置或数据库里的绑定关系。严格一点说,它是“代码结构上支持热插拔”,具体能不能实时生效,还取决于配置刷新、缓存失效和服务部署策略。
4. 答题主控器总览:Pipeline 不是调 AI,而是组织闭环
真正复杂的逻辑从答题主控器开始。它不是简单调用评分 Agent,而是把一次提交拆成一串可控步骤:
校验请求
归一化 requestId
处理幂等
加载当前题和运行态
获取题级锁
锁后再次校验
评分
追问裁决
推进 flow
提交分数
记录 turn
刷新快照
这条 Pipeline 的设计思路是:
先保证请求可信、可追踪、可去重
再保证当前题上下文正确
再用锁解决同题并发
再调用 AI 评分
再由规则链决定追问或推进
最后统一提交分数、记录轨迹、刷新运行态
所以答题主控器的核心价值不是“调用 AI 接口”,而是把 AI 调用放进一个有边界、有顺序、有恢复能力的长会话流程里。
5. 参数校验和 requestId 归一化:先把一次提交变成可追踪操作
进入 Pipeline 后,第一步是校验基础参数:
sessionId 不能为空
请求体不能为空
questionNumber 不能为空
answerContent 不能为空
这些看起来普通,但后面的幂等 key、题级锁 key、当前题校验、turn log 都依赖这些字段。如果前面不收紧,后面就会出现“无法判断这是哪场面试、哪道题、哪次提交”的问题。
接着是归一化 requestId。如果前端传了 requestId,后端会清理后使用;如果前端没有传,系统会根据:
sessionId + questionNumber + answerContent
计算一个自动 requestId。这样做的意义是:即使前端没有生成请求标识,后端仍然能对“同一场面试、同一道题、同一份答案”的重复提交做基础幂等。
这一小步的本质不是生成一个字符串,而是把用户的一次答题提交变成一件可以追踪、可以回放、可以去重的业务操作。
6. 幂等门禁:同一份答案只能真正执行一次
这里不能把它理解成前端防抖,它是后端业务幂等。
同一个 requestId 如果重复进入系统,不能重复产生业务副作用。否则会出现:
重复调用评分 Agent
重复加分
重复推进 flow
重复写 turn log
重复刷新快照
这条链路里的幂等 key 可以理解成:
sessionId + requestId
请求进来后,系统会先判断它是已经成功处理过、正在处理中,还是第一次处理。
第一步会先查 Redis 里的成功响应记录。如果这个请求之前已经成功过,Redis 里就会保存上一次的响应结果。这个时候我们直接把结果回放给前端,不会再调用 AI,也不会重复评分、重复加分或者重复推进题目。
如果没有查到成功响应,第二步就会尝试写入一个“处理中”状态,相当于抢这次请求的处理资格。如果写入失败,说明同一个请求已经有别的线程或者请求在处理了,这时候直接返回“处理中”,让前端稍后再试。
如果写入成功,说明当前请求拿到了处理资格。但这个时候也不会马上调用 AI,而是会再去运行时快照或历史记录里查一下,看这次答案之前是不是已经成功处理过。因为 Redis 里的成功记录可能过期或者丢失,但历史快照里还可能留着结果。
如果快照里能找到历史响应,就直接回放这个结果,并且把成功响应重新补写回 Redis;如果快照里也找不到,才说明这真的是一次新提交,后面才会继续走评分 Agent、追问判断和题目推进流程。
所以这里的幂等不是简单防止用户重复点击,而是保证同一次答题提交只真正执行一次,避免重复调用 AI、重复加分和重复推进面试流程。
7. 加载当前题之前:先恢复可写 HOT_RUNTIME
幂等通过后,系统仍然不能直接评分。因为答题是一条写路径,后面要写入分数、推进 flow、缓存追问题、写 turn log、刷新快照,所以必须先保证当前 session 的运行态是可写的,而且是答题热路径需要的运行态。
这里会通过运行态恢复入口确保 runtime,大致语义是:
ensureRuntime(
sessionId,
READ_WRITE_REQUIRED,
HOT_RUNTIME
)
后面两个参数很关键。
READ_WRITE_REQUIRED 表示这次请求不是只读查看,而是要继续推进面试。如果只能恢复出只读状态,并且面试还没结束,那就不能继续答题。否则评分可能成功了,但后续无法安全写入 flow、分数和 turn。
HOT_RUNTIME 表示恢复范围聚焦在答题热路径,而不是把所有冷数据都拉一遍。它主要包括:
当前 flow
当前题缓存
追问题缓存
分数聚合
turn log 相关材料
requestId 幂等材料
近期运行态快照
如果 Redis 里的这些数据完整,就直接复用;如果不完整,就从 MongoDB 的 hot snapshot、cold snapshot、turn archive、题库等材料里恢复。恢复前还要校验 sessionId,因为没有明确 session,就不知道要重建哪场面试的运行态。
这一步可以理解成“评分前先确认现场能不能继续写”。它是后面所有流程推进的地基。
针对这里的两个参数的详细讲述,要涉及到长对话状态治理的恢复链路中的知识,如果是第一次看可能有点难以理解,建议跳转到长对话治理的恢复篇重点讲的就是这个。
8. 加载当前题上下文:确认现在到底应该答哪一道题
运行态可写后,系统开始加载当前题上下文。这个步骤的目的不是评分,而是先确认当前答题现场:
当前 flow 是否存在
当前面试是否已经完成
当前主问题下标是否越界
现在等待回答的是哪一道题
当前题目内容是什么
当前题是主问题还是追问题
已经追问了几次
最多允许追问几次
这里要把状态机规则表和 flow 分开理解。
状态机规则表更像“合法跳转表”,通常用类似 EnumMap 的结构表达:
INIT -> ASKING / COMPLETED
ASKING -> EVALUATING / COMPLETED
EVALUATING -> FOLLOW_UP / ASKING / COMPLETED
FOLLOW_UP -> EVALUATING / COMPLETED
COMPLETED -> 无后续跳转
它回答的是“能不能这么跳”。而 flow 回答的是“这场面试现在实际走到哪里”:
当前阶段
当前主问题下标
当前实际题号
总题数
是否追问
当前追问次数
最大追问次数
所以 EnumMap 不是 flow。状态机可以知道 ASKING 能跳到 EVALUATING,但它不知道当前是在答第 2 道主问题,还是第 2 道题的第 1 个追问。真正记录这些现场信息的是 flow。
如果 flow 存在,系统直接使用;如果 flow 不存在,就尝试从题库恢复或初始化。题库来自简历上传后的抽题 Agent,主问题会保存到 MongoDB,并缓存到 Redis。Redis 题库没了,就按 sessionId 从 MongoDB 回补整份主问题题库,再写回 Redis。
如果题库仍然为空,就不能初始化 flow,也不能继续评分。因为系统不知道这份答案到底属于哪道题,硬调 AI 只会制造错误数据。
如果题库存在,就可以初始化基础 flow:
当前题:第一道主问题
当前不是追问
followUpCount = 0
默认 maxFollowUp = 2
totalQuestions = 题库大小
加载 flow 后还要做完成态和边界检查。如果 flow 已经完成,就不再评分,直接返回完成状态和总分。如果当前主问题下标已经超过总题数,也要收口到完成态,不能继续找不存在的题。
9. 根据题号得到题目内容:主问题可以回补,追问更依赖运行态
当前题上下文里还有一个非常容易出错的点:currentIndex 和当前实际题号不是一回事。
currentIndex 是主问题下标,只能推出主问题题号,比如第 0 个主问题是第 1 题,第 1 个主问题是第 2 题。
但当前实际等待回答的题号可能是:
2 第二道主问题
2-F1 第二道主问题的第 1 个追问
2-F2 第二道主问题的第 2 个追问
如果当前处于追问状态,却用 currentIndex + 1 推题号,就会把追问题误当成主问题。所以系统应该优先使用 flow 里记录的当前实际题号,只有这个字段缺失时,才用主问题下标兜底推导。
题号确定之后,再按题号加载题目内容。主问题可以从 Redis 题库缓存里取;如果 Redis 没命中,就从 MongoDB 回补整份主问题题库、写回 Redis,再取一次。
追问题不同。追问题是运行过程中动态生成的,不属于最初那份主问题题库,所以不能简单靠“从 MongoDB 主问题题库回补”恢复。它更依赖运行态缓存、hot snapshot 或 turn archive 里的追问材料。
如果最后仍然找不到题目内容,就应该拒绝评分。因为没有题目上下文,AI 就不知道答案是回答哪道题的,后续分数、反馈和追问都可能错。
10. 过期题校验:防止旧页面把答案交给新 flow
当前题号和题目内容确定后,还要校验前端请求里的 questionNumber 是否等于 flow 当前等待回答的题号。
这一步解决的是 stale question,也就是过期题提交。例如:
用户页面还停在第 1 题
另一个请求已经把 flow 推进到第 2 题
旧页面或浏览器重试又提交了第 1 题答案
如果不校验,系统可能拿第 1 题答案去匹配第 2 题上下文,或者把已经推进过的 flow 再推进一次。
所以加载当前题这一步的核心可以概括为:
运行态必须可写
flow 必须存在或可恢复
当前实际题号必须准确
题目内容必须存在
请求题号必须等于 flow 当前题号
这些都通过之后,才值得进入加锁和评分。
11. 题级分布式锁:解决同一道题并发提交
当前题校验通过后,系统会按:
sessionId + currentQuestionNumber
获取题级分布式锁。
锁粒度是“某场面试里的某一道题”,不是整个用户,也不是整个会话全部操作。它主要防止同一道题并发提交,比如用户连续点两次提交,或者浏览器重试发出两个请求。
如果没有这把锁,两个请求可能同时进入评分,导致:
评分 Agent 调两次
同一道题加两次分
flow 推进两次
turn log 写两条
但只做锁前校验还不够。拿到锁之后,必须再次读取 flow 和当前题号。因为可能出现这种时序:
请求 A 读到当前题是 1
请求 B 也读到当前题是 1
请求 B 先拿到锁,完成评分,把 flow 推进到题 2
请求 A 后拿到锁
如果 A 拿到锁后不重新校验,它会继续按题 1 往下评分和推进。于是系统仍然可能重复推进。
所以这里的设计是:
锁前校验:请求进来时是不是当前题
题级加锁:同一道题串行提交
锁后校验:我拿到锁时是不是仍然当前题
只有锁后看到的当前题号仍然等于请求题号,系统才允许进入评分。否则就拒绝这次过期提交。
12. 评分阶段:上下文和并发都确认后,AI 才进场
通过锁后校验后,系统才进入评分阶段。这里会先把 flow 切到“评分中”状态,表示当前题已经从“等待回答”进入“正在评估”。
评分 Agent 的选择仍然不硬编码。答题主控器只声明“我要答案评分能力”,解析层根据业务场景找到当前绑定的评分 Agent,再读取调用配置。这个点和前面的热插拔是同一套思想:主流程稳定,具体 Agent 可替换。
评分服务会组装评分上下文,大致包括:
用户答案
当前题号
当前题目
简历上下文
面试类型或岗位信息
requestId
然后通过统一 AI 调用入口执行真实调用。统一入口内部还会包住分布式 Single-flight、限流、熔断、超时、重试等保护。评分服务不需要每次自己处理这些底层治理,只需要表达“请对这道题和这份答案评分”。
评分结果回来后,会被解析和归一化成业务字段:
score:当前回答得分
feedback:反馈
missing_points:缺失点
follow_up_needed:AI 是否建议追问
follow_up_question:AI 建议的追问问题
这里不能默认模型永远返回完美 JSON,所以解析层还要做字段兜底、默认值补齐和格式清洗。
13. 分数暂不入账:先把后续流程走通
评分 Agent 返回 score 后,系统不会立刻把分数加到总分里,而是先把分数、反馈、缺失点写入响应草稿和上下文。
原因是评分后还没有完成一轮答题闭环。后面还要:
判断是否追问
必要时生成追问题
缓存追问题
推进 flow 到追问或下一题
组装响应
写 turn log
刷新快照
如果评分一出来就立刻加分,而后面 flow 推进失败,就会出现“分数已经入账,但用户没有进入追问或下一题”的不一致。
所以这里采用的是延迟提交:先拿到评分结果,等追问/推进链路组装成功后,再提交分数。这样更符合“一轮答题成功”这个业务边界。
14. LiteFlow 裁决追问:AI 给建议,规则链做最终判断
评分结果里可能包含“是否建议追问”和“建议追问问题”,但项目并不是完全听 AI 的。
因为追问是业务流程的一部分,不能只靠模型感觉决定。系统还要考虑:
当前分数
缺失点
AI 是否建议追问
当前题是不是已经是追问题
当前追问次数
最大追问次数
面试是否已经完成
所以项目会把这些信息组装成规则上下文,交给 LiteFlow 规则链做最终裁决:
评分结果
+ 缺失点
+ AI 追问建议
+ 当前追问次数
+ 最大追问次数
+ 当前题类型
-> LiteFlow 规则链
-> 是否追问
这一步就是我理解里很重要的一句话:AI 是能力提供者,不是业务流程的唯一决策者。
AI 可以告诉系统“我觉得这里应该追问”,但最终是否追问,要由规则链结合业务约束决定。比如已经达到最大追问次数,就不能继续追;当前题本来就是追问题,也要限制递归追问;AI 没有建议追问,但缺失点明显,也可以由规则触发追问。
这也是 LiteFlow 的价值:把追问策略从答题主控器里拆出去,避免主流程堆满 if else。后续要调整追问规则时,影响面更清晰。
15. 需要追问:生成追问题并切换 flow
如果 LiteFlow 裁决需要追问,系统会调用追问 Agent。追问 Agent 会结合当前题目、用户答案、缺失点、简历上下文等信息,生成一个更贴合当前回答的追问题。
如果追问 Agent 调用失败,但评分 Agent 返回过可用的追问建议,系统可以用评分结果里的追问问题兜底。这样能避免“规则已经决定要追问,但追问生成失败导致链路直接断掉”。
追问成功后,系统会按当前主问题生成追问题号,比如:
2-F1
2-F2
然后按顺序完成几件事:
缓存追问题内容
把 flow 切到 FOLLOW_UP
提交当前回答分数
在响应里返回追问题
这里顺序也很重要。要先保证追问题已经缓存、flow 已经能指向追问题,再提交分数并返回给前端。否则前端拿到了追问题,但后端运行态里找不到这道追问题,下一轮提交就会出问题。
16. 不追问:推进下一道主问题或完成面试
如果 LiteFlow 裁决不追问,或者追问生成失败且没有合适兜底,系统就推进主问题。
推进主问题时,状态机会把当前主问题游标往后移动,然后判断是否还有下一道题:
还有下一道主问题:读取下一题内容,返回给前端
没有下一道主问题:标记面试完成,返回完成状态和总分
这里也能看出一个点:下一题推进不是单独靠“获取下一题接口”完成的,而是由“提交答案成功”触发。也就是说,答题成功本身会推进 flow,前端拿到的下一题只是这次推进后的结果。
17. 最终收口:分数、turn log、幂等成功和快照一起落下
一轮答题最终不是返回响应就结束。系统还要把这次答题轨迹沉淀下来,方便后续恢复、回放和复盘。
成功收口时,会做这些事:
提交本轮分数
写 turn log,记录题号、题目、答案、评分、反馈、是否追问、下一题等信息
标记 requestId 幂等成功,并保存可回放响应
刷新 Redis 中的 flow、分数、追问题缓存等热状态
触发 runtime checkpoint,把关键材料沉淀到 MongoDB
如果 turn log 写入失败,也不能简单当没事。因为 turn log 后面既用于复盘,也可能参与运行态恢复。所以更合理的做法是进入异步 repair 或补偿队列,保证这条答题轨迹最终能被补齐。
把这一节压缩成面试表达,可以这样讲:
用户提交答案后,请求先经过用户和会话门禁,再通过工作流层进入答题主控器。主控器先校验参数、归一化 requestId、做幂等门禁,然后恢复可写 HOT_RUNTIME,加载 flow 和当前题,校验请求题号是不是当前题。通过后按 sessionId + 当前题号加题级锁,拿到锁后再校验一次当前题,防止等待锁期间 flow 已经被推进。之后才进入评分 Agent。评分结果不会立即入账,而是先交给 LiteFlow 结合分数、缺失点、AI 建议和追问次数做最终追问裁决。最后根据裁决生成追问或推进下一题,再统一提交分数、写 turn log、标记幂等成功并刷新运行态快照。
18.答题轮次异步补偿
问题产生背景
一轮答题处理完成后,也就是用户答案已经评分完成,flow 也已经决定进入追问、下一题或者完成态之后,系统会组装一条 turn log,用来记录这一轮答题的完整轨迹。这个 turn 里会包含题号、题目内容、用户答案、评分结果、反馈、是否追问、下一题信息、requestId 等。它不一定决定当前这次请求能不能继续返回,但它对后面的运行态恢复、答题回放和报告生成都很重要。
正常情况下,这条 turn log 会写入在线运行态或者持久化轮次记录里。后面如果 Redis 里的运行态丢失,系统恢复时就可以从 snapshot、MongoDB、turn archive 这些已经落下来的数据源里恢复出答题现场。所以 turn log 不能静默丢失,否则后面恢复和回放的时候就会缺少这一轮行为轨迹。
但是 turn log 写入也可能失败。失败不一定是因为这条 turn 数据有问题,更多可能是 Redis 或 MongoDB 当时压力比较大、网络抖动、连接超时、连接池满、存储节点短暂不可用。这时候 turn 数据本身已经在主链路里组装好了,只是当时没有成功写进去。
如果因为 turn log 短暂写失败,就让整个答题主链路重新执行,其实代价很大。因为前面的评分、flow 推进、分数提交可能都已经成功了,重新执行反而容易带来重复评分、重复推进、重复加分这些问题。但如果完全不管这条 turn,它后面又会影响恢复、回放和报告。因此这里就把 turn log 写失败这件事拆出来做异步补偿。
解决办法
具体做法就是:当主链路发现 turn log 写失败时,把这条已经组装好的 turn 封装成一条补偿消息,投递到 MQ。MQ 里保存的不是新生成的信息,而是这次本来应该写入的 turn 写入任务。消费者之后从 MQ 里取出消息,再尝试把这条 turn 写入 Redis 或 MongoDB。
消费者写入时一定要做幂等。我们可以用 sessionId 加 requestId 作为一轮答题的唯一标识,写之前先判断这条 turn 是否已经存在。如果已经存在,就直接认为消费成功,避免 MQ 重复投递导致同一轮 turn 被写两次。如果不存在,才真正追加写入。
如果消费者写失败,就进行重试,比如最多重试三次。因为很多失败是短暂的,过几秒 Redis 或 MongoDB 恢复了,重试就可能成功。如果三次之后仍然失败,就说明可能不是普通抖动,而是存储异常、数据格式问题或者代码 bug,这时候就不要无限重试,而是进入死信队列或触发告警,让开发人员感知并人工处理。
和恢复机制的恢复turn的对比
这套机制的核心不是重新执行答题,也不是重新算分,而是补齐已经成功答题后的轮次日志。它和长对话恢复链路里 runtime 恢复机制是互补的:异步补偿负责在 turn 写失败时尽量把这条 turn 补写进去;runtime 恢复负责在 Redis 在线态丢失时,从已经落下来的 snapshot、MongoDB、turn archive 里恢复现场。前者解决“写失败怎么补”,后者解决“已有数据怎么恢复”。
常见的几个问题
1.如何检测消息丢失
我们如果要检查消息有没有丢失,可以利用消息队列的有序性来做验证。简单来说,就是 Producer 在发送每条消息的时候,给消息附加一个连续递增的序号;Consumer 收到消息以后,再检查这个序号是不是连续的。比如上一条消息的序号是 3,下一条正常应该是 4,如果 Consumer 收到的下一条直接变成了 5,那就说明中间的 4 可能丢了,或者至少说明这条消息链路出现了异常,需要进一步排查。
但是在分布式系统里,这个事情不能简单地理解成整个 MQ 里所有消息都是全局有序的。像 Kafka、RocketMQ 这种消息队列,一般只能保证同一个分区内部的消息是有序的,不保证整个 Topic 上所有消息严格有序。所以我们在检测消息丢失的时候,不能把整个 Topic 的消息混在一起看,而是要按照分区分别检测。也就是说,分区 0 有自己的一套序号连续性,分区 1 也有自己的一套序号连续性,每个分区都要单独判断有没有出现序号断裂。
然后如果 Producer 是多实例部署的,也需要注意。因为多个 Producer 同时发消息的时候,很难协调它们之间谁先发、谁后发,所以不能强行要求所有 Producer 发出来的消息共享一套全局递增序号。更合理的做法是:每个 Producer 实例维护自己的递增序号,并且在消息里带上 Producer 标识。Consumer 检测的时候,就按照“Producer 标识 + 分区”这个维度去判断序号是否连续。比如 Producer A 在分区 0 上发出的消息序号应该是连续的,Producer B 在分区 0 上发出的消息也应该是连续的,但是 A 和 B 之间不要求互相连续。
在 Consumer 这一侧,Consumer 实例数量最好和分区数量一致,这样可以做到一个 Consumer 固定消费一个分区。这样检测起来会比较简单,因为每个 Consumer 只需要维护自己负责的那个分区里,上一次收到的消息序号是多少。如果 Consumer 数量比分区少,也不是不能做,只是一个 Consumer 需要同时维护多个分区的序号状态,逻辑会更复杂。如果 Consumer 数量比分区多,多出来的 Consumer 可能没有分区可消费,也是一种资源浪费。所以这里说“最好一致”,主要是为了让分区内顺序检测更清楚、更好实现。
完整下来理解就是:检测消息丢失时,我们可以利用 MQ 分区内有序的特点,在 Producer 端加递增序号,在 Consumer 端按分区、按 Producer 标识检查序号是否连续。
2.如何防止消息丢失
如果我们想尽量确保消息不丢,一般会把消息从 Producer 到 Consumer 的过程拆成三个阶段来看:生产阶段、存储阶段和消费阶段。
生产阶段解决的是:Producer 发出去的消息,Broker 到底有没有收到。Producer 把消息发送给 Broker 以后,Broker 收到消息会返回一个确认响应。Producer 收到确认以后,才说明这次发送在生产阶段是成功的。如果 Producer 没有收到确认,或者发送过程中出现异常,就不能假装发送成功,而是要通过返回值、异常或者回调结果感知这次发送失败,然后做重试、记录日志,或者触发后续补偿。这里如果是同步发送,发送结果会直接体现在返回值或者异常里;如果是异步发送,就必须在回调函数里判断发送成功还是失败,不能只调用发送方法就不管了。
存储阶段解决的是:Broker 收到消息以后,会不会因为自己宕机或者故障把消息弄丢。Broker 收到消息以后,如果只是把消息放到内存里就返回成功,那么一旦 Broker 宕机,内存里的消息就可能丢失。所以可靠性要求比较高的时候,单节点 Broker 一般会配置同步刷盘,也就是消息真正写入磁盘以后,再给 Producer 返回确认。这样即使 Broker 后面宕机了,恢复以后也可以从磁盘里把消息读出来继续消费。
如果 Broker 是多节点组成的集群,那么 Producer 通常不是自己把消息发给多个 Broker,而是先把消息发给某一个主节点,比如 Broker A。Broker A 收到消息以后,会把这条消息复制给其他副本节点,比如 Broker B。只有当 Broker A 确认至少还有一个副本节点也保存成功以后,才会给 Producer 返回发送成功。这样即使 Broker A 后面宕机了,Broker B 里面还有这条消息,可以继续接管消费,避免消息因为单个节点故障而丢失。
最后是消费阶段。消费阶段解决的是:Consumer 拿到消息以后,业务有没有真正处理成功。这里不能理解成 Consumer 一收到消息就立刻告诉 Broker“我消费成功了”。更稳妥的做法是,Consumer 先执行业务逻辑,等业务真正处理成功以后,再给 Broker 返回消费成功确认,也就是 ack。比如放到我们项目里的 turn 补偿场景,Consumer 拿到 turn 补偿消息以后,要先把这条 turn log 成功写入 Redis 或 MongoDB,写入成功以后才能 ack。如果刚拿到消息就 ack,但是后面写 Redis 或 MongoDB 失败了,Broker 会以为这条消息已经处理完了,就不会再投递了。这样消息虽然没有丢在 MQ 里,但其实丢在业务处理过程中。
所以完整理解下来就是:保证消息可靠性时,我们会拆成生产、存储、消费三个阶段:生产阶段靠 Broker 确认消息已经收到;存储阶段靠同步刷盘或者多副本复制保证 Broker 宕机后消息还能恢复;消费阶段靠业务真正处理成功以后再 ack,避免消息在业务执行过程中被错误确认。这样才能比较完整地避免消息在发送、存储和消费过程中悄悄丢掉。
3.怎么处理消息堆积
然后我们来讲一下消息堆积怎么解决。首先,消息堆积产生的本质原因,就是消息进入 MQ 的速度大于 Consumer 处理消息的速度,所以队列里的消息才会越堆越多。排查的时候一般分两边看:一边看是不是生产速度突然变快了,另一边看是不是消费速度变慢了。
如果放到我们项目的 turn 补偿场景里,生产速度变快通常意味着:主链路里 turn log 写入失败的数量变多了。因为正常情况下,turn log 写成功就结束了,不需要进入补偿队列;但是如果写失败,就会投递一条补偿消息,交给后面的消费者异步重试。所以如果短时间内补偿消息变多,一般说明 Redis 或 MongoDB 这类存储系统可能出现了不可用、超时、压力过高、网络抖动等问题。这个时候不能只盯着 MQ 本身,应该优先排查底层存储是不是异常,因为 MQ 里堆的只是“写失败以后留下来的补偿任务”。
其次,如果问题出在消费太慢,那我们要做的就是提高 Consumer 的消费能力。这里主要有几个方向。
第一个方向是优化消费逻辑。比如 Consumer 拿到一条 turn 补偿消息以后,可能要做数据校验、幂等判断、写 Redis、写 MongoDB。如果这里有很多不必要的查询,或者锁等待时间太长,或者每条消息都做了很重的转换逻辑,就会导致单条消息处理时间变长,消费 QPS 下降。所以我们可以减少不必要的查询,缩短锁等待,能批量的地方批量处理,能简化的地方简化,让每条消息尽快处理完。
第二个方向是增加单机消费线程数。一个 Consumer 实例里面不一定只能一个线程处理消息,可以开多个线程并行处理。比如原来同一时间只能处理一条消息,现在开多个线程,就可以同时处理多条消息,单机消费能力会提高。但是这里要注意,多线程并行消费可能会破坏严格顺序。如果业务强依赖顺序,就不能随便加;如果像 turn 补偿这种可以用 sessionId + requestId 做幂等控制,顺序要求没有那么强,就比较适合通过增加线程来提升吞吐。
第三个方向是减少跨地域或者跨机房调用带来的网络耗时。比如 MQ 在北京,Consumer 在上海,Redis 或 MongoDB 又在另一个机房,那每次拉消息、写数据、返回确认都会经过比较长的网络链路,延迟就会变高。所以要尽量让 Producer、Consumer、MQ、Redis、MongoDB 部署在同一个地域,最好是同一个机房,减少网络耗时。
第四个方向是扩容 Consumer 机器数。也就是说,原来只有一台机器消费 turn 补偿消息,现在可以增加到多台机器一起消费,分摊压力。但是这里要注意分区数量。在同一个消费组里,一个分区同一时刻通常只能分配给一个 Consumer 实例消费,这是为了保证分区内顺序和消费进度的一致性。所以如果 Topic 只有 2 个分区,即使部署 5 个 Consumer,最多也只有 2 个 Consumer 能真正消费,剩下的 Consumer 抢不到分区,只能闲着。因此扩容机器之前要先看单机资源是不是已经到瓶颈,比如 CPU、内存、网卡是不是打满;如果 Consumer 数量已经大于分区数量,还要考虑增加分区,否则单纯加机器不会继续提升消费能力。
第五个方向是调整消费模式,比如使用 push 消费模式。这里可以先理解成:不是让 Consumer 一直自己主动去 Broker 拉消息,而是由 MQ 框架更主动地把消息投递给消费者,业务代码表现出来就是“消息来了自动触发消费方法”。这种方式可以在某些场景下降低 Consumer 数量和分区数量之间的强绑定,让更多消费机器参与处理。它比较适合两类场景:一种是消息量不一定特别大,但是每条消息处理时间很长;另一种是某个时间点突然来了一批消息,希望尽快把这批消息消费完。
所以面试里我可以这样总结:消息堆积本质上是生产速度大于消费速度。对于 turn 补偿场景,如果补偿消息突然变多,我会先排查是不是 Redis 或 MongoDB 写入异常导致补偿任务大量产生;如果是消费端处理不过来,就从消费逻辑、单机线程数、网络部署、Consumer 机器数和分区数量、消费模式这几个方向去优化。同时每条 turn 补偿消息都要用 sessionId 和 requestId 做幂等,避免重试或并发消费导致重复写。
4.消费失败怎么办
消费失败我们不会承诺一定立即写成功,因为存储层、网络、中间件都可能出问题。我们的目标是失败可感知、可重试、可追踪。消费者失败后先自动重试,比如最多三次;超过次数后进入死信队列或失败表,并触发告警。告警后不是人工逐条写 Redis,而是先修复根因,然后通过补偿脚本或后台任务批量重放失败消息。由于 turn 写入使用 sessionId + requestId 做幂等,重放也不会重复追加。这样既避免无限重试拖垮系统,也保证失败数据后续能批量修复。
我们人工介入指的是人工处理故障和触发批量重放。消费者重试超过上限后,会把消息放到死信队列或者失败表,并记录 sessionId、requestId、失败原因、重试次数和原始 turn 内容,同时触发告警。开发先排查是 Redis/MongoDB 不可用、连接池打满、数据格式异常还是代码 bug。故障恢复后,可以通过管理接口或补偿脚本批量扫描失败表/死信队列,重新投递或直接批量重写 turn log。整个过程是批量化、可追踪的 。
无限重试可能会把故障放大。如果 Redis/MongoDB 已经不可用,消费者一直重试只会造成线程占满、队列堆积和存储压力更大。所以我们设置最大重试次数,超过后进入死信或失败表,让系统降级为可观测、可处理的状态。
5.重复消费怎么办?
这里主要就是我们发消息的时候不能发重复了,那就是在发MQ的时候做一下分布式锁Redis来实现幂等性就OK了。
6.和豆包相比的优势
我会先说明:我不是说豆包这种大模型平台没有工程能力。它作为大厂的大模型平台,背后肯定有很复杂的模型服务治理,比如限流、超时、高可用、模型调度、监控这些能力。它主要解决的是模型服务层的问题,也就是我给它 prompt 或 workflow,它能相对稳定地产生模型输出。
但是我们的 AI 面试项目解决的是另一层问题:业务链路层的问题。一次面试答题不是简单地让模型回答一段文字,而是要知道当前用户答到第几题、这道题是不是追问题、这次提交是不是重复请求、评分以后是否允许追问、追问次数有没有超过上限、当前 flow 能不能从“答题中”推进到“评分中”或者“追问中”、Redis 运行态丢了以后怎么恢复、turn log 写失败以后怎么补偿。
这些事情如果只放在提示词或者模型上下文里,就会变成不稳定的“模型记忆”。模型可能会记错,也可能因为上下文变化输出不一致。但我们项目里不会让模型决定这些核心流程状态,而是把它们放在后端系统里:用缓存和数据库保存 session 状态,用状态机限制流程跳转,用幂等和题级锁处理重复提交和并发,用规则链决定是否追问,用快照和补偿机制保证异常后可以恢复。
所以这个项目的优势不是说模型能力一定比豆包强,而是我们把大模型当成一个可插拔的能力提供方,只让它负责评分、出题、生成追问这类 AI 能力;而真正的面试流程由后端系统控制。这样整个答题闭环更稳定、更可控,也更容易扩展。后面如果要换评分 Agent、换追问 Agent,或者调整追问规则,也不需要重写主流程。
你可以记成一句很稳的面试总结:
豆包解决的是通用模型调用能力,我这个项目解决的是 AI 面试业务闭环。模型负责生成内容,后端负责保证流程正确、状态可靠、请求幂等、异常可恢复、能力可扩展。我的重点不是做一个会聊天的 Agent,而是把 Agent 接进一个生产可用的后端业务系统。
三、长对话状态治理:按“更新”和“恢复”两条主线看
AI 面试不是一次请求就结束,而是一场持续推进的长会话。用户上传简历之后,系统会抽题、答题、评分、追问、继续下一题,最后生成报告。这个过程中,后端必须一直知道:
当前答到第几题
当前 flow 是什么状态
当前实际题号是主问题还是追问题
已经答过哪些 turn
当前累计分数是多少
题库、追问题、反馈和建议在哪里
如果 Redis 丢了,怎么把会话恢复回来
所以这块不能只说“我用了 Redis 存状态”。更准确的主链路是:
业务运行中
-> Redis 保存高频热状态
-> 关键业务事件发生
-> 生成运行态刷新请求
-> 按 session 聚合到 bucket
-> 普通刷新防抖,强制刷新尽快执行
-> refreshSnapshot 生成检查点
-> MongoDB 保存 hot snapshot、cold snapshot、turn archive
-> Redis 缺失或不完整时
-> ensureRuntime 按业务 scope 恢复运行态
-> 写回 Redis,继续推进面试
这套设计本质上有两条线:
更新机制:平时把以后恢复需要的材料写好
恢复机制:Redis 出问题时,把面试运行态重建回来
1. 为什么会丢状态:Redis 快,但不能当唯一真相
面试运行态放 Redis 是合理的,因为它读写快,适合保存高频变化的数据,比如:
当前 flow
当前题号
题目缓存
追问题缓存
累计分数
最近 turn
幂等回放材料
但 Redis 本质上更像在线运行态存储,不适合作为唯一真相源。它可能因为过期、淘汰、重启、部署切换、网络抖动、写入半成功等原因变得不完整。
一旦 Redis 里的 flow 或当前题缓存丢了,系统就会遇到很现实的问题:
不知道用户现在应该答哪一道题
不知道当前题是不是追问题
不知道已经追问了几次
不知道当前累计分数是多少
不知道下一步该进入评分、追问还是完成态
所以项目把 Redis 和 MongoDB 做了分层:Redis 负责“在线跑”,MongoDB 负责“能恢复”。
2. Redis 和 MongoDB 分层:热状态跑在线,检查点负责救场
这块可以按照数据温度来理解。
Redis 保存的是热运行态,也就是答题过程中频繁读写的数据:
flow
当前题和追问题缓存
分数聚合
最近 turn log
requestId 幂等结果
MongoDB 保存的是恢复材料,主要分三类:
Hot Snapshot:
热快照,保存 flow、分数、近期状态、当前题等恢复高频状态需要的材料。
Cold Snapshot:
冷快照,保存简历上下文、题库、岗位信息、建议等相对稳定的数据。
Turn Archive:
答题轮次归档,保存每一轮答题轨迹,比如题号、题目、答案、评分、反馈、追问和下一步。
这里不要把 hot snapshot 和 Redis 混为一谈。Redis 是在线运行态,hot snapshot 是 MongoDB 里的恢复检查点。平时系统用 Redis 跑得快;Redis 出问题时,再用 MongoDB 里的检查点把现场恢复回来。
3. 更新入口:业务事件先变成刷新意图
更新机制不是让每个业务服务自己随手写 MongoDB。更好的做法是:业务层只表达“发生了什么”,后面的调度器统一决定“什么时候刷、刷什么、怎么刷”。
会触发刷新意图的业务事件包括:
题目抽取完成
神态评估完成
一轮答题提交成功
会话最终完成
异常补偿后需要补刷
这些事件会被抽象成 trigger。trigger 里会带一些策略信息:
snapshotLevel:这次应该刷新到什么快照级别
forceFlush:是否需要尽快强刷
persistTurnArchive:是否需要归档本轮 turn
priority:多个刷新意图合并时谁优先
然后系统构造标准刷新请求,里面通常包括:
sessionId
trigger
snapshotLevel
requestId
committedTurn
是否强刷
是否归档 turn
触发时间
这样上游业务不会关心 MongoDB 怎么写,也不会每个地方各写一套快照逻辑。它只需要告诉运行态服务:“这场面试发生了一个关键事件,需要刷新检查点。”
4. 更新调度:bucket 合并、降噪和控频
同一场面试在短时间内可能连续发生多个状态变化。如果每个事件都立即写 MongoDB,会造成频繁写入;但如果简单覆盖,又可能丢掉关键语义,比如强刷和 turn 归档。
所以调度层按 sessionId 把刷新请求合并到一个 bucket 里。bucket 的核心不是“后来的覆盖前面的”,而是按字段语义 merge()。
时间字段会记录:
firstEventAt:这个 bucket 第一条事件时间
lastEventAt:最近一次事件时间
下一次刷新时间通常会在两个时间之间取一个更保守的点:
lastEventAt + debounceWindow
firstEventAt + maxAggregateWindow
前者用于防抖,避免事件刚来就马上刷;后者用于兜底,避免事件一直来导致永远不刷。
其他字段也不是简单覆盖:
trigger / snapshotLevel:
按优先级合并,高优先级不能被低优先级降级。
requestId / committedTurn:
取最新的非空值,因为它们代表最终要沉淀的请求上下文。
forceFlush / persistTurnArchive:
用 OR 合并。只要有一次要求强刷或归档 turn,最终就必须保留这个语义。
对于 trigger,它按优先级合并。高优先级事件覆盖低优先级事件。如果前面已经是 FINALIZED,后面来了优先级更低的 ANSWER_COMMITTED,就不会降级;如果前面是 QUESTION_READY,后面来了 ANSWER_COMMITTED,就会升级成 ANSWER_COMMITTED。
对于 requestId 和 committedTurn,如果新请求有值,就取最新的非空值。
对于布尔标记,比如:
forceFlush
persistTurnArchive
它们是 OR 合并。只要合并过的请求里有一次是 true,最后就是 true。这样可以保证强刷和归档这些关键语义不会丢。
然后会进入 startFlush。这个方法主要做三件事。
第一,防止并发 flush。如果当前 bucket 已经在 flush,就不会再取出新的 request,保证同一个 session 在同一个节点内不会同时跑多个 flush。
第二,转换格式。bucket 里保存的是聚合后的 pending 状态,而下游 snapshot service 需要的是标准的 request,所以 startFlush 会把 pending 状态打包成一个 HotRefreshRequest。
第三,清空本次已经被接管的 pending 状态。这里清空的是本轮 flush 已经取走的 pending,而不是销毁整个 bucket。所以同一时刻可能出现:
下游正在 flush 一个 request
bucket 里又 merge 进了新的 pending,等待下一轮 flush
然后看两条刷新路径。
普通刷新走 submitDeferred,适合题目抽取、神态评分这类可以稍微延迟的事件。它会先按 sessionId 找到对应 bucket,然后把 request merge 进去,再通过防抖窗口重新计算刷新时间,取消旧的定时任务,并安排新的 flushBucket。这样可以合并短时间内同一个 session 的多次刷新,减少 MongoDB 的频繁写入。
强制刷新走 submitForceFlush,主要用于答题提交成功、会话结束和失败重试这类关键事件。它也会先 merge 到 bucket,但会取消已有的延迟任务,并尽快触发 flushBucket。如果同一个 session 当前已经在 flush,它不会再并发开一个 flush,而是最多等待 forceFlushWaitMillis。这个时间控制的是当前线程最多阻塞多久,不是说超过这个时间请求就丢了。即使等待超时返回,请求也已经 merge 在 bucket 里;等当前 flush 完成后,调度器会发现 bucket 里还有 pending 状态,再继续刷新下一轮。
然后进入真正的写操作,也就是 flushBucket 的流程。
它会进入一个循环,先从 bucket 里取出一批待刷新的请求。如果没有取到 request,就说明当前没有活要干,清理空闲 bucket 后返回。
如果取到了 request,就调用:
runtimeSnapshotService.flushHotRefreshRequest(...)
正式执行刷新。这里才会进入 refreshSnapshot,真正生成并提交检查点。
执行完后,会统一收尾:标记本轮 flush 结束,并唤醒等待的强刷线程。然后判断本次刷新是否成功。如果失败,就把这次 request 重新 restore 回 bucket,并标记成强刷,保证失败请求不会丢。
接着判断 bucket 里还有没有新的 pending。如果没有,就清理并返回。如果还有 pending,就判断是否需要立刻进入下一轮。如果当前 pending 是强刷,或者刚才刷新失败,并且没有超过立即重试次数上限,就立刻继续下一轮。否则就按防抖时间或者失败重试时间,延迟重排下一次刷新。
所以调度层解决的是刷新节奏和并发协调问题,而不是最终快照内容正确性问题。它决定什么时候刷、刷哪一份 request、要不要合并、要不要强刷、失败要不要重试。
5.更新执行层
最后来到更新执行层,也就是 refreshSnapshot。
5.1.读取当前快照
这一层只会收到几个核心字段:
sessionId
snapshotLevel
requestId
committedTurn
persistTurnArchive
首先它会确认 session 是否存在。如果这个会话不存在,就没法存档。
然后它会读取旧的 hot snapshot 和 cold snapshot。为什么要读旧快照?因为这次不是盲写,而是要基于旧版本判断:
这次内容有没有变化
是不是重复请求
版本有没有被别人改过
状态有没有倒退
5.2.补全生成检查点所需要的材料
接着它会补齐恢复材料。比如题目、建议、简历上下文这些偏冷的材料。注意这些材料不一定只从数据库拿,也可能优先从 Redis 拿;Redis 没有,再从实体字段里解析。
如果这次需要归档 turn,比如一轮答题提交成功,就会先把 committedTurn 写入 Turn Archive,得到 archiveWatermark。
然后它会整理一些热运行态字段,比如:
turns
flow
scoreAggregate
recentTurns
followUpQuestions
这些都是热快照里的高频运行态。系统会根据这些材料构造一个 HotPatch。
5.3.比较hotpatch和当前的热快照
随后开始比较 HotPatch 和当前 HotSnapshot。
1.如果它们在业务字段上等价,说明这次没有带来新的热状态变化,就跳过 hot snapshot 的写入,但仍然会检查 cold snapshot 是否需要补写。
2.如果当前还没有 hot snapshot,就先 seed 一个基础热快照,建立一个可演进的版本基座,然后重新进入循环,再基于新版本重新计算和提交 patch。
3.接下来是防重复提交。这里不是乐观锁,而是幂等判断。系统会通过 requestId 生成 mutationId,再判断 hot snapshot 里的 lastMutationId 是否已经等于当前 mutationId。如果相等,说明这次业务变化之前已经成功写过了,就不需要重复提交。
4,然后是单调性校验。系统会检查:
lastTurnSeq 不能倒退
archiveWatermark 不能倒退
scoreCount 不能倒退
flow 不能倒退
也就是说,检查点可以前进,也可以等价重写,但不能回到旧状态。
5.最后才是 CAS 写 hot snapshot。CAS 本质是乐观锁的一种实现方式。系统会判断 MongoDB 里的 snapshotVersion 是否仍然等于我刚才读到的版本。只有版本没变,才允许把 HotPatch 写进去,并推进版本。如果 CAS 失败,说明中间有别的线程已经写过了,那就重新读取最新快照,再进入下一轮循环重试。
5.4.按需补写cold snapshot
当 hot snapshot 成功写入以后,再按需要写 cold snapshot。cold snapshot 里存放的是低频、稳定的恢复材料,比如:
题目
建议
简历上下文
面试方向
神态评分
5.5.总结整条更新执行链路
所以完整顺序可以理解成:
先归档关键 turn
再更新 hot snapshot
最后按需更新 cold snapshot
但要注意,只有答题提交这类事件才需要归档 turn;题目抽取、神态评估这类事件通常不会归档 turn。
整体来看,这套更新机制就是:
业务事件先被标准化成 request,
调度层用 bucket 合并、防抖、强刷和失败重试,
执行层再把当前业务状态整理成 Turn Archive、HotPatch、ColdPatch,
最后用幂等判断、单调性校验和 CAS 保证检查点不会重复、不会倒退、不会被并发写乱。
一条完整的时间线:
把整个过程压成一条时间线,大概是这样:
1. 读取 session / question / hot snapshot / cold snapshot
2. 从 cache、archive、snapshot 中解析 turns、flow、score
3. 如果需要,先归档 committedTurn
4. 组装 hotPatch
5. 如果 patch 等价,跳过 hot 写入
6. 如果没有 hot snapshot,先 seed 基座版本
7. 检查 mutation 是否已经提交过
8. 校验单调性
9. CAS 提交 hot checkpoint
10. 按需要更新 cold checkpoint
11. 成功返回 true
6.恢复链路
这里的重点是:恢复不是无脑把所有数据都恢复一遍,而是按业务场景恢复。
比如查看报告可能只需要只读结果,继续答题则必须恢复成可写热运行态。也就是前面答题链路里提到的:
READ_WRITE_REQUIRED + HOT_RUNTIME
恢复的大致流程是:
归一化 scope
校验 sessionId
检查 Redis 中 scope 需要的数据是否 ready
如果 ready,直接返回缓存可用
如果不 ready,尝试获取 session 级恢复锁
没拿到锁,等待其他请求恢复并复用结果
拿到锁后,再次检查 Redis,避免重复恢复
仍不 ready,进入 rebuildRuntime
从 hot snapshot、cold snapshot、turn archive、题库等材料恢复
把 flow、分数、题库、turn、追问等写回 Redis
返回恢复来源和可信度
它和 Single-flight 的思想有点像:同一个 session 同一时间只允许一个请求真正重建运行态,其他请求等待并复用结果。这样可以避免 Redis 一丢,多个请求同时从 MongoDB 重建,反而把状态写乱。
6.1恢复链路里三个重要的关键字
它主要有三个关键词:scope、loadMode 和 confidence。
loadMode 像是调用方的要求,表示这次请求对恢复结果的要求有多严格。它主要有两种形式:
READ_ONLY:只读场景,只要能支撑当前查询或展示即可READ_WRITE_REQUIRED:读写场景,后续还要推进流程或写回状态,所以必须恢复到可写级别
判断一个业务该用哪种 loadMode,主要看它后面会不会继续写运行态。比如推进 flow、追加 turn、累计分数、刷新快照,这些都属于写链路,就应该用 READ_WRITE_REQUIRED。如果只是当前查询展示,不会推进状态,就可以用 READ_ONLY。
confidence 表示系统这次实际恢复出来的可信程度。它有几个结果:
EXACT:精确恢复,通常来自缓存或完整快照DERIVED:可推导恢复,数据是从历史材料中推出来的,但仍然可以支撑写READ_ONLY:只能读,不能继续推进写业务TERMINAL:会话已经终态,不再需要继续写
所以真正判断能不能继续写,不是看 loadMode,而是看返回结果里的 confidence。源码里也是通过 runtimeView.canWrite() 判断的。
第三个是 scope,表示本次要检查和恢复的数据范围,主要包括:
FLOW_ONLYSCORE_ONLYPLAYBACK_ONLYMATERIAL_ONLYHOT_RUNTIMEFULL_RUNTIME
这些基本从名字能看出来含义。核心是:不同业务只恢复自己需要的部分,不是所有接口都无脑恢复完整运行态。
6.2ensureRuntime()的运行逻辑
6.2.1归一化scope
这是恢复机制主要的方法。方法一开始会传入三个参数:
sessionId
loadMode
scope
首先会对 scope 做归一化。如果调用方没传,就默认按 FULL_RUNTIME 处理。
6.2.2校验sessionId
然后判断 sessionId 是否为空。如果为空,就说明没有明确的会话可以恢复,直接返回一个 NONE + READ_ONLY 的结果,也不会重建缓存。
6.2.3 检查redis的scope是否ready
如果 sessionId 不为空,就先检查 Redis 里当前 scope 需要的数据是否已经 ready。如果 ready,就直接返回 CACHE + EXACT,表示数据来自缓存,而且不需要重建。
6.2.4 未ready,尝试获取session级锁
如果 Redis 没命中,就进入恢复逻辑。这里会先尝试拿 session 级别的恢复锁,这套逻辑和 single-flight 很像:同一个 session 同一时间只允许一个请求真正做恢复,其他请求先等待它的恢复结果。
如果当前请求没有拿到锁,就先等别人恢复。如果等到了可用结果,就直接复用。如果没等到,再尝试重新拿锁。
拿到锁以后,还会再检查一次 Redis 是否 ready。因为在等待或抢锁的过程中,可能别的线程已经恢复好了。如果这时 Redis 已经 ready,就直接返回缓存结果,避免重复恢复。
6.2.5真正的rebuildRuntime
如果还是不 ready,才真正进入 rebuildRuntime(...)。
rebuildRuntime(...) 内部又分成两层:
第一层是优先看 snapshot 能不能恢复。因为 snapshot 本来就是为恢复设计的聚合检查点,数据更集中,恢复更稳定,速度也更快。如果 snapshot 对当前 scope 足够,就直接把 snapshot 里的对应数据回写 Redis,然后返回恢复结果。
第二层:如果 snapshot 不够用,才退化到材料重建。这时会从 session、question、record、已有 snapshot 等数据源里重新拼出 RuntimeMaterial。这已经是降级路线了。
第三层:如果连 material 都拼不出来,就不要硬装成可写,直接返回 READ_ONLY,告诉上层这次不能继续推进写业务。
最后不管走哪条路径,都会统一通过 buildView(...) 返回一个 RuntimeView。这个结果里会带上:
- 本次的
loadMode - 恢复来源
restoreSource - 实际可信度
confidence - 是否发生缓存重建
cacheRebuilt - 相关 snapshot 信息
6.3实战场景
实战场景可以这样记:
| 业务场景 | 业务需求 | 是否推进写链路 | loadMode | scope | 为什么这样配 |
|---|---|---|---|---|---|
| 答题提交/推进 | 用户提交答案后,要校验当前题、AI 评分、推进 flow、累计分数、追加 turn、刷新快照 | 是 | READ_WRITE_REQUIRED | HOT_RUNTIME | 后面要继续写运行态,所以必须可写;但它主要依赖答题热路径数据,不需要报告材料全量恢复 |
| 流程编排 | 获取当前题/下一题,核心是知道当前 flow 走到哪、题库是否可用 | 偏是 | READ_WRITE_REQUIRED | FLOW_ONLY | 重点是流程能继续,不需要完整材料、分数和报告上下文;但它和流程推进相关,所以要求恢复结果足够可靠 |
| 题目/建议/简历分读取 | 给前端展示题目、建议、简历分等材料信息 | 否 | READ_ONLY | MATERIAL_ONLY | 只是展示材料,不会推进 flow、加分或写 turn,所以只读即可;恢复材料相关缓存就够了 |
| 分数读取 | 查询当前会话总分 | 否 | READ_ONLY | SCORE_ONLY | 只关心分数信号,不需要题目、材料、flow、turn 全量恢复,所以按分数范围恢复最省 |
| 报告/全量展示 | 生成或展示报告,需要材料、分数、轮次、流程等较完整上下文 | 通常否 | READ_ONLY | FULL_RUNTIME | 不继续推进答题,所以不要求可写;但报告依赖的数据面更广,所以恢复范围要完整 |
四、实时 ASR:按音频从前端到文本回推的链路看
实时 ASR 这条链路不能理解成“前端上传一段音频,后端返回一段文字”。它本质上是一条持续运行的实时通信链路:前端一边把音频分片传给后端,后端一边把音频推给讯飞 ASR,同时还要不断接收讯飞返回的实时识别结果,再把这些结果整理成前端能展示的文本快照。
这条链路可以先压缩成一句话:
前端 WebSocket 持续上传音频
-> 后端创建 TranscriptionSessionContext
-> 音频写入会话级 Pipe
-> XunfeiAudioService 从 Pipe 读取音频并推给讯飞
-> 讯飞返回实时识别 JSON
-> 后端解析成 RealtimeTranscriptionUpdate
-> AstTranscriptionAssembler 组装增量片段
-> 生成 snapshot / committedText / liveText
-> 保存 lastUpdate
-> 通过 WebSocket 推回前端
1.前端通过webSocket持续上传音频
1.1. 为什么这里用 WebSocket,而不是普通 HTTP
如果要介绍这条链路,应该先从 WebSocket 说起。
WebSocket 本质上是一种让客户端和服务端建立长连接,并且能够进行双向实时通信的协议。我们项目里的实时 ASR 场景,前端需要持续上传音频分片,后端也需要持续把实时识别结果推送给前端,这种场景天然不适合普通 HTTP。
HTTP 更像“一次请求对应一次响应”。如果每一段音频都通过 HTTP 请求发送,就会产生频繁建连、重复请求头、连接维护、响应等待等额外开销,也不利于后端主动把识别结果实时推给前端。
WebSocket 建立连接后,底层 TCP 连接会持续保持。客户端可以持续发送音频,服务端也可以主动推送识别结果,双方不需要按照 HTTP 那种一问一答的模式通信,所以更适合实时转写这种双向、高频、低延迟的业务。
1.2. WebSocket 的建立、传输和关闭
WebSocket 的实现流程可以分为三个阶段:
握手阶段
-> 数据传输阶段
-> 关闭阶段
首先是握手阶段。WebSocket 一开始不是直接建立一个全新的协议连接,而是客户端先向服务端发送一个特殊的 HTTP GET 请求。这个请求头里会带上:
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key
Upgrade: websocket 和 Connection: Upgrade 表示客户端希望把当前连接从 HTTP 协议升级成 WebSocket 协议。Sec-WebSocket-Key 用于握手校验。
如果服务端支持 WebSocket,就会返回:
101 Switching Protocols
这个状态码表示协议切换成功。服务端还会返回 Sec-WebSocket-Accept,客户端校验通过后,握手就完成了。此时底层 TCP 连接不会断开,而是从普通 HTTP 通信切换成 WebSocket 通信。
握手成功后,就进入数据传输阶段。WebSocket 基于这条 TCP 长连接进行通信,数据传输时以“帧”为单位。可以把帧理解成 WebSocket 自己定义的小数据包。
一个 WebSocket 帧里常见的字段包括:
FIN
Opcode
Payload length
Payload data
FIN 表示当前帧是不是一条消息的最后一片。如果 FIN = 0,说明后面还有数据;如果 FIN = 1,说明这条消息到这里结束。
Opcode 表示这一帧的数据类型,比如文本帧、二进制帧、关闭帧、ping 帧、pong 帧。在实时 ASR 场景里,前端上传音频时通常是二进制帧,后端返回识别结果时通常是文本帧,也就是 JSON 字符串。
Payload length 表示真正数据内容的长度,Payload data 就是真正传输的数据,比如音频字节或者识别结果 JSON。
WebSocket 还有 ping / pong 心跳机制。因为 WebSocket 是长连接,连接可能保持很久。如果中间网络断了,客户端或服务端不一定马上知道,所以可以通过 ping / pong 判断连接是否还活着。一方发送 ping,另一方返回 pong。如果长时间收不到 pong,就可以认为连接已经不可靠,然后关闭连接并释放资源。
最后是关闭阶段。WebSocket 不是随便直接断开的,它有专门的关闭帧。比如用户停止转写时,可以在业务层发送停止消息,也可以触发 WebSocket 的关闭流程。一方发送关闭帧,另一方确认后连接才正式关闭,这样可以尽量避免还有数据没发送完就被直接断开。
所以在我们的实时 ASR 链路里,WebSocket 负责解决浏览器和后端之间的实时双向通信问题:
前端通过二进制帧持续上传音频
后端通过文本帧持续返回识别结果
2. 前端音频进入后端:Handler 只接收,不直接推给讯飞
当前业务里,前端通过 WebSocket 把二进制音频分片传到后端的音频 WebSocket Handler。
Handler 收到音频分片后,不会直接把音频同步推给讯飞,而是先写入当前会话上下文里的 audioOutputStream。
这里的核心原因是:前端上传音频的速度和下游讯飞 ASR 消费音频的速度不一定一致。如果 Handler 每收到一段音频就直接同步交给讯飞处理,那么 WebSocket 接收线程就会被下游处理节奏拖住,后续音频也可能被迫等待。
所以系统在中间加了一层会话级缓冲,也就是 TranscriptionSessionContext + Pipe。
3. TranscriptionSessionContext:保存一场转写会话的运行现场
TranscriptionSessionContext 主要保存三类东西。
第一类是音频缓冲管道:
audioOutputStream
audioInputStream
第二类是会话生命周期状态:
active
stopRequested
closed
第三类是最后一次识别结果快照:
lastUpdate
这几个字段各自解决的问题不一样。
audioOutputStream 和 audioInputStream 是一对 Pipe,用来传递音频字节。
active 和 stopRequested 用来描述当前转写会话是否还在进行、用户是否已经请求停止。
lastUpdate 保存最近一次识别结果。它不是为了传音频,而是为了在最终构造 final 结果或者 stop 响应时,还能拿到最近一次实时识别结果里的上下文字段,比如:
segmentId:segmentId 可以用来定位“第几段话”。
segmentText:revision 可以表示“第几次更新”。
pgs:有apd(append)和rpl(replace),表示识别修正的两种状态,没有pgs情况会复杂一点后面细讲
rg:要替换的位置
bg:开始时间
ed:结束时间
snapshot
committedText
liveText
这样最终返回给前端的结果不只是一个纯文本,而是保留了段落编号、时间范围、修正策略等信息,方便前端统一渲染、去重、替换和定位。即使当前最小功能只需要最终文字,这些字段也为实时 ASR 场景下的扩展能力留好了空间。
可以这样总结:
Pipe 解决的是音频生产和消费的节奏解耦。
lastUpdate 解决的是实时识别结果到最终结果的上下文衔接。
4. Pipe:解耦音频生产和音频消费
Pipe 在这里的作用,其实就是做一个会话级的音频缓冲区。
前端通过 WebSocket 传音频分片时,上游只负责把音频尽快写进 audioOutputStream。下游的 XunfeiAudioService 再从 audioInputStream 按自己的节奏读取音频,并推给讯飞 ASR。
这就形成了一个生产者消费者模型:
生产者:WebSocket Handler
缓冲区:PipedOutputStream / PipedInputStream
消费者:XunfeiAudioService
这样前端音频接收和下游 ASR 推流就被解耦了。
这个设计借鉴了 NIO Buffer 的思想,也就是数据先进入一个中间缓冲区,再由消费方读取处理。但它本身不是完整 NIO,而是基于 BIO 的 Pipe 做了一个轻量化实现。至于这样做的具体原因:主要是因为NIO的Buffer管理,是一个需要消耗大量资源的行为,这会涉及到很多网络编程相关的知识,例如Spring的WebFlux框架来做后端的背压控制,整体实现复杂度稍高。
且ASR属于CPU密集型任务,如果真的要基于NIO来做ASR,最佳的工程实践是要单独起一个NIO网关,利用一些现成的NIO轮子,把ASR任务和其他业务线程隔离开的,避免ASR高压的CPU计算挤占其他业务线程的CPU,进而拖垮线上服务。
所以为了快速实现业务,这里使用了一个折中的方案,在Context内部基于BIO维护一对单向的数据流管道,来模拟NIO的Buffer,作为缓冲池,而非直接使用较重的NIO方案。
5. XunfeiAudioService:从 Pipe 读音频,再按讯飞节奏推流
下游的 XunfeiAudioService 会从 audioInputStream 读取音频字节,然后按照讯飞要求的节奏推给讯飞 ASR。
它处理的是音频推流这部分事情,比如:
从 Pipe 读取音频字节
按固定块大小切分
按供应商要求控制发送节奏
区分首帧、中间帧、尾帧
监听 stopRequested
在用户停止或音频结束时收口
所以这条链路前半段传递的是音频字节:
前端音频帧
-> WebSocket Handler
-> audioOutputStream
-> Pipe
-> audioInputStream
-> XunfeiAudioService
-> 讯飞 ASR
等讯飞 ASR 识别完成或产生实时结果后,链路就进入后半段:处理识别结果 JSON。
6. 讯飞返回 JSON:后端不能只拿文字直接拼接
讯飞 ASR 返回的不是一个永远稳定的最终文本,而是一包一包的实时识别 JSON。这些 JSON 里除了文本,还可能包含:
段落编号
时间戳
pgs
rg
bg
ed
finalized
前端表面上只想看到文字,但后端不能只拿文字直接拼接。因为实时 ASR 有几个特点:
识别引擎是边听边猜,后续可能修正前面的内容
返回的是增量结果,不一定每次都返回完整全文
回包顺序不一定完全等于文本时间顺序
分段编号也不一定连续
如果后端只是简单 append,每来一包就拼到末尾,就很容易出现重复文本。
比如:
第一包:今天
第二包:今天我们
第三包:今天我们来聊
如果直接 append,前端看到的可能是:
今天天天我们今天我们来聊
但如果每来一个新包就粗暴替换旧内容,又可能把前面已经稳定的内容删掉,造成语义丢失。
所以这里需要 AstTranscriptionAssembler。
7. AstTranscriptionAssembler:处理的不是音频,而是 ASR 增量结果
AstTranscriptionAssembler 处理的是讯飞 AST 返回的实时识别结果,而不是音频本身。
前面的 WebSocket 和 Pipe 负责把前端音频送到讯飞;讯飞识别完成后返回的一包包增量 JSON,则交给 AstTranscriptionAssembler 组装成前端能展示的文本。
它的核心使命不是“拼字符串”,而是:
如何把 ASR 返回的一堆增量、修正、可能乱序的分段事件,
维护成一份可靠的实时文本状态。
因此这里用了:
TreeMap<Integer, SegmentState>
TreeMap 的 key 是 segmentId / sn,value 是每一段的 SegmentState。
这样做有三个好处:
同一个 segmentId 可以天然覆盖更新,保证幂等
分段可以按照 key 从小到大有序遍历
最后构建完整文本时,只需要按顺序拼接所有分段
它不是按文本内容去重,而是按分段编号管理和更新状态。
8. apply 流程:先看文本,再看有没有 pgs
当 apply(...) 接收到一包 ASR 增量结果后,首先会判断文本是否为空。
如果文本为空,这包就没有可合并的正文价值,可以直接忽略。
如果文本不为空,就判断有没有 pgs。
pgs 可以理解成供应商告诉后端“这一包应该怎么合并”的指令。如果有 pgs,后端优先按照供应商指令处理。
8.1 有 pgs:按供应商明确指令处理
当 pgs = rpl 时,表示 replace,也就是替换。
这时要看 rg 是否有效。rg 是一个范围数组,表示要替换哪些 segmentId。
如果 rg 有效,就先从 TreeMap 里移除这个范围内的旧分段,再把当前 segmentId 对应的新文本 upsert 进去。
如果 rg 无效,就不执行范围删除,只 upsert 当前分段。
可以理解成:
pgs = rpl
-> rg 有效:删除 rg 范围内旧段,再写入新段
-> rg 无效:只写入当前段
当 pgs = apd 时,表示 append,也就是追加或更新当前分段。这时直接 upsert 当前 segmentId 对应的分段。
pgs = apd
-> upsert 当前 segmentId
如果 pgs 存在,但既不是 rpl 也不是 apd,说明后端无法识别这个指令,也会退化成普通 upsert。
8.2. 无 pgs:供应商没给指令,后端只能保守推断
如果没有 pgs,说明供应商没有明确告诉后端这包是追加还是替换,这时后端就不能激进处理,只能基于 bg / ed 时间范围和文本相似度做保守推断。
首先看关键信息是否完整:
bg 是否存在
ed 是否存在
文本是否存在
bg / ed 表示这段文本在音频时间轴上的开始和结束时间。
如果 bg、ed 或文本这些关键信息为空,后端就没办法做旧段演化匹配,只能走普通 upsert。
如果信息完整,就会遍历当前 TreeMap 里的已有分段。对于每一个旧分段,先看时间范围是否重叠。
如果完全不重叠,说明它们肯定不是同一段音频,就跳过当前候选,继续看下一个。
如果时间范围完全一致,那就是最高优先级的精确匹配,可以直接认为新包是在更新同一个分段,于是复用这个旧段。
如果时间范围只是部分重叠,就继续计算重叠比例。只有当重叠比例达到 60% 以上,并且文本也足够相似时,才认为它可能是同一句的演化版本,然后进入打分。
打分时,基础分是时间重叠比例。如果新文本包含旧文本,说明旧文本很可能是新文本的早期版本,就额外加分。最后选择得分最高的旧段作为可复用分段。
这段可以用流程表示:
无 pgs
-> bg / ed / 文本 不完整
-> 普通 upsert
-> 信息完整
-> 遍历 TreeMap 旧分段
-> 时间完全不重叠:跳过
-> 时间完全一致:精确匹配,直接复用
-> 时间部分重叠:
-> 计算重叠比例
-> 判断文本相似度
-> 重叠 >= 60% 且文本相似,进入候选
-> 根据重叠比例和包含关系打分
-> 选择最高分旧段复用
-> 没有可复用段:按新分段 upsert
找到可复用分段后:更新旧段,并清理兄弟旧段
如果找到了可复用分段,代码不会新建一段,而是更新旧段,让它从旧文本演化成新文本。更新完以后,还会清理那些被新范围完整覆盖、并且明显属于同一句演化的兄弟旧段,避免多个旧版本同时留在池子里导致重复。
这里的删除策略是保守的:不是只要时间范围有重叠就删除,而是要求:
旧段被新范围完整覆盖
并且文本上明显属于同一句演化
才会删除。这样可以避免误删用户真实说过的内容。
8.3 无 pgs 的小bug
这套策略仍然可能有边界问题。比如新包只覆盖了某个旧段的一部分,旧段因为没有被完整覆盖,所以不会被删除。最终拼接时,可能出现局部重复。但其实说实话这里本来就容易产生误会,因为字段的重复并不一定代表语义的重复。这也是语音转对话里比较难以解决的问题,因为不知道用户到底想表达什么,所以这块采用保守一点的办法。
例如:
王者荣耀这款这款手机游戏
理论上可以做前后缀裁剪,但这样又可能误删用户真实说出的重复词。
比如:
王者荣耀荣耀典藏皮肤
这里的“荣耀荣耀”可能不是 ASR 重复,而是用户真实说出的词。如果我们激进地根据前后缀相同就裁剪,就可能误删真实语义。
所以当前实现选择的是保守策略:
时间像、文本也像,才复用旧段
能确定被完整覆盖,才删除旧段
不确定就保留或新增
优先避免语义丢失
也就是说,无 pgs 时,它不是确定性执行供应商指令,而是在信息不完整时做保守推断。
9. 组装结果:snapshot、committedText、liveText
Assembler 最后会构造三类文本:
snapshot:当前完整文本
committedText:已经稳定的文本
liveText:还在变化的文本
比如:
snapshot = 我去年做了一个电商项目用到了 WebSocket
committedText = 我去年做了一个电商项目
liveText = 用到了 WebSocket
这样前端拿到以后,不只是拿到一整串字,而是知道:
哪些字已经稳定
哪些字还在实时滚动修正
用户看到的字幕体验就会更自然。
committedText 可以作为相对稳定的前半部分,liveText 可以作为仍然可能被 ASR 修正的尾部内容。这样前端既能实时展示,又不会让整段文本一直抖动。
10. RealtimeTranscriptionUpdate:把组装结果封装成前端可消费的数据
XunfeiAudioService 解析讯飞 JSON 后,会把文本、段落编号、时间戳、动态修正信息等字段封装成 RealtimeTranscriptionUpdate。
这个 update 里会包含 Assembler 组装后的结果和上下文字段,比如:
snapshot
committedText
liveText
segmentId
segmentText
pgs
rg
bg
ed
finalized
然后后端通过 WebSocket 把这个 update 推回前端。
这一步的意义是:前端不需要直接理解讯飞原始协议,也不需要自己处理 pgs、rg、分段覆盖、文本演化这些复杂逻辑。后端已经把供应商协议整理成了产品侧更容易消费的数据结构。
11. lastUpdate:最终结果收口时复用最近一次识别快照
最后再回到 TranscriptionSessionContext。
它里面的 lastUpdate 会保存最近一次 RealtimeTranscriptionUpdate。
lastUpdate 的作用不是传音频,而是:
保存最后一次识别结果快照
方便最终 final 包收口时复用上下文
因为最终返回给前端的结果,不应该只是一个孤零零的纯文本。它最好还能保留最近一次识别结果里的上下文字段,比如:
segmentId
segmentText
pgs
rg
bg
ed
这样前端后续如果要做统一渲染、去重、替换、定位,就有足够信息可以用。
12. stop 和 final:用户停止不等于供应商自然完成
ASR 链路里还要区分 stop 和 final。
final 表示供应商侧识别自然完成,后端拿到了最终识别结果。
stop 表示用户主动要求停止录音,不想继续等待。
如果用户点击停止转写,后端会设置 stopRequested,并关闭 Pipe 写端,让下游推流任务尽快读到结束,然后收口当前会话。
当前实现里,用户主动 stop 后通常不会强行补一个 final。因为 stop 的语义是“这次会话现在停止”,不是“继续等供应商整理最终结果”。
但为了避免前端丢内容,后端可以利用 lastUpdate 返回最近一次可用的识别快照。这样即使没有供应商自然 final,前端也能拿到当前已经识别出来的文本。
13. 整条 ASR 链路总结
这条链路最适合背的版本是:
前端 WebSocket 负责持续上传音频,
TranscriptionSessionContext + Pipe 负责把前端音频接收和下游讯飞推流解耦,
XunfeiAudioService 负责把音频推给讯飞并解析讯飞返回的实时 JSON,
AstTranscriptionAssembler 负责把这些增量、乱序、可能修正的识别片段维护成有序分段,
再构造完整文本、稳定文本和实时变化文本,
最后封装成 RealtimeTranscriptionUpdate,通过 WebSocket 推回前端。
如果面试官问这个设计的亮点,可以这样说:
Pipe 解决的是音频生产和消费的节奏解耦;
AstTranscriptionAssembler 解决的是 ASR 增量修正包不能简单 append 的问题;
lastUpdate 解决的是实时识别结果到 final / stop 收口时的上下文衔接;
WebSocket 解决的是前端和后端之间双向、高频、低延迟通信的问题。
五、分布式 Single-flight
Single-flight 这块不能一上来就讲 Redis、Lua、ownerToken。它前面其实要先理解一个最基础的问题:为什么同一个 AI 请求会被重复执行,以及单机版 Single-flight 到底解决了什么。
1. Single-flight 解决的是什么问题
Single-flight 解决的是“正在执行中的重复请求合并”,不是普通缓存。
普通缓存解决的是:
这个结果以前算过,现在能不能直接复用?
Single-flight 解决的是:
这个结果现在正在被计算,后来的同 key 请求要不要再算一遍?
比如面试评分场景里,用户连续点了两次提交,或者浏览器重试了一次。同一道题、同一份答案,本质上只需要调一次 AI。没有 Single-flight 的话,两个请求可能都会去调用模型,造成:
重复消耗 AI 成本
重复占用线程资源
重复触发超时、熔断、限流
甚至可能导致后续业务重复推进
Single-flight 的核心思想就是:
同一个 key 同一时刻只允许一个 leader 真正执行,
其他请求等待 leader 的结果。
2. 单机 Single-flight 怎么做
在单机 JVM 里,Single-flight 的实现其实不复杂。最核心的数据结构可以理解成:
Map<key, Future>
当第一个请求进来时,如果 Map 里没有这个 key,就创建一个 Future,由当前线程作为 leader 真正执行任务。
后续相同 key 的请求再进来时,发现 Map 里已经有 Future,就不再重复执行,而是等待这个 Future 完成。
流程是:
请求 A 进来
-> Map 里没有 key
-> A 创建 Future
-> A 成为 leader
-> A 真正执行 AI 调用
请求 B 进来
-> Map 里已经有同 key Future
-> B 不调 AI
-> B 等 Future 完成
A 执行完成
-> Future 写入结果
-> B 拿到同一份结果返回
可以用一句话概括:
单机 Single-flight 就是用一个 in-flight Map,把同 key 并发请求收敛成一个 leader 执行、多个 waiter 等同一个 Future。
3. 单机 Single-flight 和缓存的区别
这块很容易混。
缓存通常发生在任务完成之后:
结果已经算完
-> 放进缓存
-> 后续请求直接读缓存
Single-flight 发生在任务执行过程中:
结果还没算完
-> 已经有人在算
-> 后续请求等待这次执行结果
所以 Single-flight 不是缓存的替代品,它更像是“执行中去重”。项目里后面也有 L1 回放缓存和 Redis result 回放,但那是结果完成后的复用;Single-flight 重点解决的是执行中的重复调用。
4. 为什么单机版不够
如果项目只有一个 JVM,Map<key, Future> 基本就够用了。
但实际项目是多实例部署,请求会经过负载均衡:
请求 A -> 节点 1
请求 B -> 节点 2
请求 C -> 节点 3
这时候每个 JVM 都有自己的本地 Map。节点 1 看不到节点 2 正在执行的 Future,节点 2 也看不到节点 3 的状态。
于是同一个业务语义的 AI 请求,仍然可能在多个节点上重复执行。
所以分布式 Single-flight 要解决的是:
在多实例集群里,所有节点都能围绕同一个 requestKey 协同,
选出唯一 owner 真正调用 AI,
其他节点作为 follower 等待并复用结果。
5. 分布式 Single-flight 的整体链路
整个链路可以先压缩成:
业务请求进入统一 AI 调用入口
-> 判断 stage
-> 构造 requestKey
-> 重任务先做 session 级重锁
-> 把真实 AI 调用包装成 supplier
-> 进入分布式 Single-flight
-> 先查本地 L1 回放缓存
-> Redis Lua 根据 meta 返回 Action
-> owner 真正调 AI
-> follower 等待结果
-> owner 写 Redis result 并发布通知
-> follower 读取 result 回放
-> Redis 协调异常时降级本机 Single-flight
这里可以先记住三个角色:
owner:真正执行 AI 调用的节点
follower:不调 AI,只等待结果的节点
Redis:全局裁判,保存状态、结果和通知
5.1 Stage:先区分这次 AI 调用到底在干什么
在这个项目里,AI 调用主要分四类:
interview-evaluation:面试回答评分
interview-followup:追问问题生成
interview-extraction:简历题目抽取
interview-demeanor:神态分析
stage 是业务语义标签,不是状态机里的 PENDING / RUNNING。
它解决两个问题。
第一,隔离不同业务场景。同一个 session 下,评分和简历抽题绝对不能复用同一个结果。
interview-evaluation|sessionA|...
interview-extraction|sessionA|...
这两个 key 因为 stage 不同,所以天然属于不同请求空间。
第二,不同 stage 可以使用不同治理策略。比如评分是高频文本请求,适合开本地 L1 回放;简历抽题是长耗时任务,需要更长 TTL;神态分析是媒体状态分析,结果复用要更谨慎。
所以 stage 可以这样理解:
stage = AI 请求的业务类别标识。
它既参与 key 归一化,也决定这次请求套用哪套 Single-flight 策略。
5.2 requestKey:定义什么叫同一个请求
Single-flight 能不能正确工作,关键看 key 设计。
key 太粗,会误拦截不同请求;key 太细,又合并不了真正重复请求。
项目里 key 本质上是一份 AI 请求的业务语义指纹。
问答类 key:
stage | sessionId | questionNumberOrBusinessPart | answerHash
例如:
面试评分:
interview-evaluation|session-1001|Q1|a1b2c3d4e5f60708
追问生成:
interview-followup|session-1001|当前问题文本|a1b2c3d4e5f60708
这里的 answerHash 不是明文答案,而是对答案内容做摘要。这样既能识别同一份答案,又不会把用户原文直接塞进 key。
文件/业务对象类 key:
stage | sessionId | businessKey
例如:
简历抽题:
interview-extraction|session-1001|resumeContentHash
神态分析:
interview-demeanor|session-1001|imageUrl
这套 key 的判断逻辑是:
不同 stage:不复用
不同 session:不复用
同一题不同答案:不复用
同一会话不同文件或图片:不复用
同一业务语义的重复请求:复用
为什么要这么设计:
Key 归一化的核心是按业务语义定义“什么算同一个请求”。问答类请求对答案内容很敏感,所以 key 设计得更细;文件类重任务的核心输入是文件本身,所以用 fileUrl 或文件 hash 作为业务标识,既避免错误复用,也避免因为无关参数变化导致 single-flight 失效。
5.3 重任务 session 锁:先挡住重任务重复启动
不是所有请求都直接进入 Single-flight。简历抽题和神态分析这类重任务,会先做 session 级重锁。
锁粒度是:
stage + sessionId
锁 key 可以理解成:
interview:ai:heavy:lock:{stage}:{sessionId}
为什么要有这层锁?
因为简历抽题和神态分析不是单纯调用 AI。它们前面可能有文件上传、图片上传、URL 生成,后面还有解析、结构化、落库等操作。
如果没有 session 重锁,用户连续点两次“根据简历生成题目”,两个请求可能都会开始上传、解析、落库。即使后面的 Single-flight 最终只让一个 owner 调 AI,前置资源也已经浪费了。
为什么不让single-flight放在重任务来做这个锁?
其实是因为再单独做一个Session重锁更简单。因为如果把Single-Flight提到最外层,那Single-Flight将会多出很多冗余设计。
(1)Single-Flight 的本质不是“互斥”,而是“同语义请求去重 + 结果复用”。
Singl-Flight 默认假设后来的请求和前面的请求是同一件事,所以 follower 可以直接复用 owner 的结果。但重任务入口很多时候不是同一个语义。我们要的只是重任务不要并发执行,而不是追求结果复用
比如用户并发上传两份简历,我们要的是同一时间内只有一个请求在执行,而且也不需要有结果复用。
(2)重任务场景里,key 很难既不拦错,又不漏拦。
比如简历抽题,如果 key 设计得很精细,带上 fileUrl 或文件哈希,那么同一个 session 下两次不同简历版本并发进来,它们 key 不一样,Single-Flight 就不会拦住;但从业务上你可能仍然希望“同一会话一次只跑一个抽题流程”。
反过来,如果你把 key 粗化成 sessionId + stage,倒是能拦住并发了,但这时第二次请求很可能会错误复用第一次的结果。比如用户刚换了一版新简历,你却把旧简历抽出来的题复用了,这就错了。
所以从这两个维度来看,其实不是说Single-Flight干不了这个事,而是说让Single-Flight干太麻烦了。所以我们才重新设计了一个Session重锁放到最外层。
所以这里要区分:
session 重锁:
解决同一会话的重任务不要并发启动。
Single-flight:
解决同一语义 AI 请求不要重复执行,并让 follower 复用 owner 结果。
当前重锁更像入口闸门,拿不到锁就快速失败,提示当前任务正在处理中,而不是排队等待。
另外这里还要区分:
requestKey用的是 single-flight key,他的目的是不要复用结果,所以设计的偏细
Session 重锁用的是 lock key,他的目的是不让重任务并发,所以设计的偏粗
同一个任务在这两个阶段用的是不同的key
5.4 supplier:真实 AI 调用先被包装起来
统一 AI 调用入口不会一上来就直接调用 AI。它会先做两件事:
第一步:根据 stage、sessionId、题号、答案 hash 等信息生成 single-flight key
第二步:把真正调用 AI 的代码包装成一个 supplier
这里的 supplier 可以理解成:
一段“暂时还不执行的 AI 调用逻辑”
也就是说,它里面装着真正要做的事情,比如:
调用 AI guard
调用星辰 workflow
拿到模型返回结果
但是这段逻辑不会马上执行,而是先交给分布式 Single-flight:
DistributedSingleFlight.execute(stage, requestKey, supplier)
接下来 Single-flight 会根据这个 requestKey 去 Redis 里判断:
这个 key 现在有没有人在执行?
如果没人执行,当前节点成为 owner
如果已经有人在执行,当前节点成为 follower
如果之前已经成功过,直接回放成功结果
如果之前失败过,就按失败策略处理
只有当当前节点被判断为 owner 时,supplier 才会真正执行。
可以理解成:
先根据 single-flight key 判断是 owner 还是 follower;
如果是 owner,才真正调用 AI;
如果是 follower,就等待并复用 owner 的结果。
5.5. 本地 L1 回放缓存:先拦刚刚成功过的请求
进入 Redis 协调前,系统会先查本地 L1 回放缓存。
它解决的是短时间内同一 JVM 上重复回放的问题。比如某个 requestKey 刚刚成功过,本机还有结果,就直接返回,不需要再查 Redis。
但 L1 不是全局真相源。
L1 命中:本机快速回放
L1 未命中:进入 Redis 分布式协同
神态分析这类场景可能不开 L1,因为它更偏实时媒体状态,复用窗口需要谨慎。
5.6 L1 没命中后:进入 Redis Lua 状态机
如果本地 L1 回放缓存没有命中,系统就不能直接调 AI,而是要进入 Redis 协调层。
这一步可以理解成当前节点在问 Redis:
这个 requestKey 现在是什么情况?
我应该成为 owner 去执行?
还是成为 follower 等别人?
还是直接复用已有结果?
还是直接返回失败?
这就是 Redis Lua 要做的事情。
之所以用 Redis,是因为项目是多实例部署,请求可能被负载均衡打到不同机器。所有节点都必须围绕同一个 requestKey 看到同一份状态。
之所以用 Lua,是因为这里不是简单读一下 Redis,而是要原子完成一串判断:
meta 是否存在?
当前 status 是什么?
owner 是否还活着?
失败能不能重试?
要不要分配新的 ownerToken?
当前节点到底返回什么 Action?
这些判断必须一次性完成,不能做到一半被其他请求插队。所以 Redis Lua 在这里扮演的是一个全局裁判。
5.6.1为什么要用状态机
单机版 Single-flight 很简单。同一个 key 来了以后,当前 JVM 里保存一个 CompletableFuture,后来的线程直接等这个 future 完成就行。
但分布式场景不一样。请求可能被打到不同机器,这时系统要解决的不只是“当前线程要不要执行”,还要解决:
哪个节点抢到了真正执行权?
哪个节点只是 follower,应该等待结果?
owner 是刚创建的,还是对旧任务的接管者?
owner 现在是否还活着?
请求最后是成功、失败、取消,还是过期?
失败后能不能重试,能不能由别的节点接管?
follower 现在应该等待、复用结果,还是直接失败返回?
这些问题不是一个简单的 true / false 能表达的。所以项目把一次 AI 请求抽象成一个有生命周期的 flight。
一句话说:
分布式 Single-flight 不是“有锁 / 没锁”这么简单,
而是一个请求协调实体从创建、运行、完成到失败或失效的生命周期管理。
这套生命周期状态就保存在 Redis 的 meta 里:
ai:flight:meta:{requestKey}
你可以把 meta 理解成这次 flight 的“状态说明书”。它不是 AI 结果本身,而是告诉系统:
这次请求现在处于什么状态
当前 owner 是谁
owner 最近有没有 heartbeat
失败后能不能重试
真正结果应该去哪个 result key 里读
其中最核心的字段就是 status。
5.6.2状态机的几个状态
当前状态机里主要有六个状态:
PENDING
RUNNING
SUCCEEDED
FAILED
CANCELLED
EXPIRED
其中前四个是当前主链路真正高频使用的状态,后两个更多是预留态。
(1)PENDING:占坑成功,但还没正式开跑
PENDING 表示这个 requestKey 对应的 flight 已经被创建出来了,owner 也已经被选出来了,但真正的业务执行还没有正式开始。
它通常出现在三种情况:
第一次请求进来,Redis 里还没有 meta
之前 FAILED 且 retryable=true,新 owner 接管
之前 owner heartbeat 超时,新 owner takeover
为什么不一创建就直接 RUNNING?
因为“抢到执行权”和“真正开始执行业务”是两个动作。
抢占阶段:我先声明这个 flight 由我负责
运行阶段:我真的开始执行 supplier,并启动 heartbeat
所以 PENDING 的意义是:显式区分“占坑成功”和“正式开跑”。
(2)RUNNING:owner 正在真实执行
RUNNING 表示 owner 已经正式开始执行 supplier,flight 处于活跃运行中。
进入 RUNNING 后:
owner 会周期性 heartbeat
follower 会认为当前已有活跃执行者
其他节点不会重复调 AI
旧 owner 即使恢复,也要被 ownerToken 拦住写入
这个状态对应的是“真实模型调用正在发生”。
(3)SUCCEEDED:结果已经成功写入
SUCCEEDED 表示 owner 已经把 AI 结果写入 result 存储,并完成成功收尾。
进入 SUCCEEDED 后:
follower 不需要继续等待
后续同 key 请求可以直接 REPLAY_SUCCESS
本地 L1 回放缓存也可以被填充
所以 SUCCEEDED 是典型终态,主要服务于结果复用。
(4)FAILED:执行失败,但要区分能不能重试
FAILED 表示 owner 执行失败,并且失败结论已经写入状态机。
但 FAILED 不能只理解成“出错了”。它还要结合:
errorType
retryable
一起看。
如果是可重试失败,比如:
超时
过载
上游服务临时不可用
网络抖动
那么:
retryable = true
后续请求可以把它从 FAILED 重新推进到 PENDING,由新 owner 接管执行。
如果是不可重试失败,比如:
参数非法
文件格式不支持
明确业务校验失败
那么:
retryable = false
后续请求会直接读取失败结论,不再重复调用模型。
(5)CANCELLED:预留的主动取消态
CANCELLED 表示这个 flight 被主动取消,不应该再继续执行。
当前主链路里它更多是预留态,未来可以用于:
人工中止任务
上层流程状态机主动取消
用户离场后取消异步 flight
当前实现里 Lua 能识别它,但主链路暂时没有完整围绕它展开。
(6)EXPIRED:预留的过期态
EXPIRED 表示这个 flight 因生命周期结束而失效,不应该再被继续复用。
不过当前实际运行中,过期更多是通过 Redis TTL 删除 meta/result key 来实现的。也就是说,一个 flight 真正过期后,常见表现是:
meta 被 Redis 删除
下次再看到这个 requestKey,就像一个全新请求
所以 EXPIRED 目前也更偏预留态。
5.6.3 状态机的主路径
如果只看当前真正高频使用的链路,状态迁移其实很清楚:
PENDING -> RUNNING -> SUCCEEDED
PENDING -> RUNNING -> FAILED
FAILED(retryable=true) -> PENDING -> RUNNING -> SUCCEEDED/FAILED
也就是:
抢占成功,生成 PENDING
owner 正式开跑,进入 RUNNING
执行完成后进入 SUCCEEDED 或 FAILED
如果失败可重试,retryable表示是否是可重试失败
如果 retryable=true,后续请求可以 OWNER_TAKEOVER 接管后可重新回到pending
如果 retryable=false,后续请求直接 REPLAY_FAILURE
5.6.4 Lua 根据状态机返回 Action
有了上面的状态机以后,Redis Lua 才能判断当前节点该做什么。
Action 不是状态,而是当前节点拿到的动作结果。
常见 Action 是:
OWNER_NEW:
Redis 里没有 meta,当前节点第一次抢占成功。
OWNER_TAKEOVER:
之前失败可重试,或者旧 owner heartbeat 超时,当前节点接管。
FOLLOWER_WAIT:
当前已有 RUNNING owner,并且 heartbeat 新鲜,当前节点等待。
REPLAY_SUCCESS:
状态已经是 SUCCEEDED,当前节点直接读取 result 回放。
REPLAY_FAILURE:
之前已经 FAILED 且不可重试,当前节点直接按失败结论返回。
这里一定要区分:
FlightStatus:这条 flight 当前是什么状态
FlightAction:当前节点现在应该做什么
比如:
RUNNING 是状态
FOLLOWER_WAIT 是动作
SUCCEEDED 是状态
REPLAY_SUCCESS 是动作
所以这一段的完整逻辑是:
L1 没命中
-> 进入 Redis Lua
-> Lua 读取 meta
-> 根据 status、heartbeatAt、retryable、ownerToken 等信息判断 flight 当前生命周期
-> 返回当前节点应该执行的 Action
-> Java 根据 Action 进入 ownerExecute、followerWait、replaySuccess 或 replayFailure
5.6.5 Redis Lua 的五个典型分支
可以按这五个分支理解 Redis 裁决。
分支 A:第一次请求。
Redis 没有 meta
-> 创建 meta
-> status = PENDING
-> 分配 ownerToken
-> 返回 OWNER_NEW
意思是:这件事以前没人干过,现在你是 owner。
分支 B:已经成功过。
status = SUCCEEDED
-> 返回 REPLAY_SUCCESS
意思是:这件事已经成功做过,你直接去读结果。
分支 C:之前失败过。
status = FAILED
-> retryable = true:返回 OWNER_TAKEOVER
-> retryable = false:返回 REPLAY_FAILURE
意思是:可重试失败就让新节点接管;不可重试失败就直接复用失败结论。
分支 D:当前 owner 还活着。
status = RUNNING
heartbeatAt 还新鲜
-> 返回 FOLLOWER_WAIT
意思是:已经有人在干,你别重复调模型,等结果。
分支 E:owner 疑似失活。
status = RUNNING
heartbeatAt 太旧
-> 分配新 ownerToken
-> 返回 OWNER_TAKEOVER
意思是:旧 owner 可能挂了或卡住了,你接管。
5.7 owner 执行路径:真正调 AI,并把结果安全写入 Redis
如果当前节点拿到:
OWNER_NEW
OWNER_TAKEOVER
说明它成为了这次 requestKey 的 owner。只有 owner 才会真正执行 AI 调用。
owner 的完整流程可以理解成:
markRunning,把状态从 PENDING 改成 RUNNING
-> 启动 heartbeat,防止长耗时调用期间被误接管
-> 执行 supplier
-> 进入 AI guard
-> 调用真实 AI 平台
-> 拿到 AI 最终结果
-> 对结果做序列化、压缩、Base64 编码和 checksum 校验摘要
-> 带着 ownerToken 写入 Redis result
-> finishSuccess,把 meta 改成 SUCCEEDED
-> 通过 Redis Stream 通知 follower
-> 写入本地 L1 回放缓存
-> 停止 heartbeat
-> 返回业务层
这里有三个细节要重点理解:heartbeat、ownerToken、result 序列化。
5.7.1 heartbeat:owner 执行期间持续续租
AI 调用可能比较慢。如果 owner 已经开始调 AI,但 Redis 里的运行 TTL 先过期了,其他节点就可能误以为 owner 已经失活,然后发起接管,导致同一个 AI 请求被重复执行。
所以 owner 进入 RUNNING 后,会启动 heartbeat。
heartbeat 会定时刷新:
heartbeatAt
updatedAt
expireAt
Redis TTL
它的意思是:
我这个 owner 还在执行,不要让别人接管。
心跳任务的 key 不是单纯的 requestKey,而是:
requestKey | ownerToken
因为同一个 requestKey 可能先后有多任 owner。requestKey 表示“哪件事”,ownerToken 表示“这件事的第几任负责人”。这样旧 owner 和新 owner 的 heartbeat 生命周期就不会混在一起。
不过 heartbeat 也有一个边界:它主要证明调度线程还活着,不一定证明业务线程真的在推进。如果业务线程卡死,但 heartbeat 线程还在续租,follower 仍然会认为 owner 正常。更完善的方案是增加 lastProgressAt,让 heartbeat 不只表示“我还活着”,还表示“业务最近确实有进展”。
5.7.2 ownerToken:写结果时防止旧 owner 脏写
分布式场景里,只知道 ownerId 不够。可能出现这种情况:
节点 A 成为 owner,开始执行 AI
A 因为 GC、网络抖动或卡顿,heartbeat 超时
节点 B 接管,成为新 owner
A 后来恢复,又试图写结果
如果系统只看 ownerId,旧 owner A 可能把新 owner B 的结果覆盖掉。
所以项目设计了 ownerToken。它本质上是递增的 fencing token,也就是每一任 owner 的版本号。
新 owner 上任时,拿到更大的 ownerToken
关键写操作必须带 ownerId + ownerToken
Redis Lua 校验 token 是否仍然有效
旧 owner 即使恢复,也不能写入结果
需要校验 ownerToken 的关键动作包括:
markRunning
heartbeat
storeResult
finishSuccess
finishFailure
所以 ownerToken 不是为了记日志,而是为了拒绝旧 owner 的迟到写入。
5.7.3 result 序列化:AI 结果不是裸字符串直接塞 Redis
owner 拿到 AI 最终结果后,不会直接把字符串塞进 Redis,而是交给结果序列化组件处理。
它的目标是把 AI 返回的普通字符串,包装成一份可以跨节点存储、回放、校验的结果对象。
流程可以理解成:
AI 返回字符串
-> 兜底 null,转成安全字符串
-> 按 UTF-8 转成原始字节 rawBytes
-> 根据 stage 策略判断是否需要压缩
-> 如果达到压缩阈值,就用 GZIP 压缩
-> 得到真正要存储的 storedBytes
-> 对 storedBytes 做 Base64,变成 payload 字符串
-> 对 rawBytes 算 SHA-256,得到 checksum
-> 记录 rawSize、storedSize、codec、compressed、finishedAt、ownerToken
-> 写入 Redis result
这里几个概念要分清楚。
rawBytes 是原始内容的字节形式。比如 AI 返回的是一段中文文本,程序会先用 UTF-8 把它转成字节。后面的压缩、大小统计、checksum 都围绕这份确定的字节数据来做。
storedBytes 是真正准备存储的字节。如果这次没有压缩,storedBytes = rawBytes;如果压缩了,storedBytes = gzip(rawBytes)。
payload 是把 storedBytes 做 Base64 之后得到的字符串。Base64 不是压缩,也不是加密,它只是把二进制字节转换成 Redis、JSON、日志里更容易承载的文本格式。
checksum 是对 rawBytes 做 SHA-256 得到的摘要。SHA-256 也不是压缩,更不是加密。它在这里的作用是完整性校验:follower 读出来并解压后,再对恢复出来的 rawBytes 算一次 SHA-256,如果两次不一致,说明 Redis 里的结果可能损坏了,不能静默回放。
可以这样记:
GZIP:负责压缩,减少体积
Base64:负责把二进制变成可存储的字符串
SHA-256:负责校验恢复出来的内容有没有损坏
这三个东西不是重复设计,而是各自解决不同问题。
5.7.4 一个小科普:计算机压缩大概是怎么回事
之前一个朋友问过我“计算机是怎么把文件压缩小的”,这里顺带科普一下计算机的压缩原理。
严格说,计算机把文本、图片、文件等内容统一看成一串二进制字节也就是字符编码后的二进制数据 。压缩算法要做的事情,就是在这串字节里找规律,然后用更短的方式表示它。
最常见的思路有两类。
第一类是找重复片段。比如一段文本或 JSON 里反复出现同样的字段:
score feedback missing_points score feedback missing_points
压缩算法可以把重复片段用“前面出现过,从哪里开始,重复多长”来表示,而不是每次都完整存一遍。
第二类是让高频内容用更短编码,低频内容用更长编码。比如某些字符或片段出现很多次,就给它短编码;很少出现的内容,就给它长编码。你之前学过的哈夫曼编码就是这种思想:
出现次数多的符号 -> 编码短一点
出现次数少的符号 -> 编码长一点
这样整体平均长度就会下降。
GZIP 底层常见的是 DEFLATE 思路,可以粗略理解成:
LZ77:负责找重复片段
Huffman 编码:负责让高频内容用短编码表示
所以哈夫曼编码不是只存在于算法题里,它在真实压缩场景里就有用。只不过工程里的 GZIP 已经把这些细节封装好了,我们调用 gzip(rawBytes) 时,不需要自己手写哈夫曼树。
项目里还设置了压缩阈值,意思是:不是所有结果都压缩。因为压缩也要消耗 CPU,而且很短的文本压缩后不一定更小,甚至可能因为压缩头信息变得更大。所以代码会判断:
rawBytes.length >= compressionThresholdBytes
并且 codec 不是 none
满足条件才压缩。
5.8 follower 回放路径:读 result,解码、解压、校验,再返回
如果当前节点是 follower,它不会执行 supplier,也不会调用 AI。
它等 owner 完成后,会读取 Redis 里的 result,然后走反序列化流程:
读取 FlightStoredResult
-> 取出 payload
-> Base64 解码,得到 storedBytes
-> 如果 compressed = true,就 gunzip 解压
-> 得到 rawBytes
-> 对 rawBytes 重新计算 SHA-256
-> 和 Redis 里存的 checksum 对比
-> 校验通过后,用 UTF-8 还原成字符串
-> 写入本地 L1
-> 返回业务层
这里最关键的是 checksum 校验。
如果 checksum 对不上,说明恢复出来的内容已经不是 owner 当初写进去的那份内容了。这时候应该直接失败,而不是把一份可能损坏的 AI 结果返回给业务层。
可以这样理解:
owner 写入时:
原始内容 -> SHA-256 -> checksum
follower 读取时:
恢复内容 -> SHA-256 -> 新 checksum
两个 checksum 一样:
说明内容完整,可以回放
两个 checksum 不一样:
说明内容损坏,不能回放
所以 follower 复用的不是 Redis meta 状态,而是 owner 写入 Redis result 的最终 AI 结果。meta 只是订单状态,result 才是外卖本身。
5.9 Redis 里几类 key 的分工
大家可能注意到这里用的key有点多,刚好现在基本上也全部讲完了,我们再做一下区分。分布式 Single-flight 里主要有四类 Redis key:
flight meta
ai:flight:meta:{requestKey}
保存这次 flight 的状态信息,比如当前是 RUNNING 还是 SUCCEEDED、owner 是谁、heartbeat 是否新鲜、失败后能不能重试。它可以理解成“请求状态单”。
flight result
ai:flight:result:{requestKey}
保存真正可复用的 AI 结果,比如 payload、压缩信息、checksum、ownerToken。follower 最终复用的是这里的结果。
flight owner-seq
ai:flight:owner-seq
全局递增序列,用来生成新的 ownerToken,防止旧 owner 恢复后脏写。
flight stream
ai:flight:stream:{requestKey}
终态通知通道。owner 成功或失败后,通过 Stream 通知 follower,减少 follower 盲等。
一句话区分:
meta 管状态,result 存结果,owner-seq 发 token,stream 做通知。
其中真正决定能不能回放的是 meta + result,stream 只是通知通道,不是结果真相源。
5.10 小总结
owner执行和follower等待这一小块的总结:
owner 真正调 AI,执行期间用 heartbeat 续租,防止长耗时调用被误接管;
写结果时用 ownerToken 做 fencing token,防止旧 owner 恢复后脏写;
AI 结果写 Redis 前会先转 UTF-8 字节,再按 stage 策略决定是否 GZIP 压缩,
然后用 Base64 转成字符串 payload,并对原始字节算 SHA-256 checksum;
follower 读取结果时,先 Base64 解码,再按 compressed 标记解压,
最后重新计算 checksum 校验完整性,确认无误后才回放给业务层。
这样做有两个好处:
大结果可以压缩,减少 Redis 存储压力
follower 回放前可以校验 checksum,避免损坏结果被静默复用
也就是说,result 不是“能读到字符串就行”,还要保证它是完整可信的。
5.11 失败处理:可重试失败和不可重试失败
owner 调 AI 可能失败。失败后,不能简单抛异常就结束,因为 follower 还在等结果。
所以 owner 会把失败写回 meta:
status = FAILED
errorType = TIMEOUT / OVERLOAD / PROVIDER / VALIDATION / UNEXPECTED
errorCode = ...
retryable = true / false
后续处理分两类。
可重试失败:
超时
服务商临时不可用
网络抖动
系统过载
这类失败可能下一次就好了,所以:
retryable = true
后续请求可以拿到:
OWNER_TAKEOVER
由新 owner 接管执行。
不可重试失败:
参数非法
文件格式不支持
请求内容校验失败
明确业务异常
这类失败重复执行也没意义,所以:
retryable = false
后续请求会拿到:
REPLAY_FAILURE
直接按失败结论返回,不再重复调模型。
5.12 本地降级:Redis 协调坏了,退回单机 Single-flight
前面讲的是 AI 调用成功或失败。还有另一种情况:分布式协调链路本身坏了。
比如:
Redis 连不上
Lua 执行异常
Redis Stream 异常
返回了非法 Action
acquireResult 为空
这时不是 AI 失败,而是协调系统失败。
当前项目是 hybrid(混合) 模式,所以会回退到本机 Single-flight。
本机 Single-flight 的核心结构是:
ConcurrentHashMap<String, FlightEntry>
FlightEntry = CompletableFuture + expireAt
流程是:
同 key 第一个线程成为 leader
leader 执行 supplier
其他线程等待同一个 CompletableFuture
leader 完成后 waiter 复用结果
降级的代价是:
暂时失去跨节点全局去重
但保住了:
当前 JVM 内的请求合并
主业务链路可用
AI 能力不会因为 Redis 协调异常整体不可用
6. 单机 Single-flight 和分布式 Single-flight 对比
可以最后放一张对比表。
单机 Single-flight:
作用范围:单个 JVM
核心结构:Map<key, Future>
角色:leader / waiter
解决问题:同一实例内的并发重复请求
不足:多实例部署时,不同节点之间看不到彼此的 in-flight 请求
分布式 Single-flight:
作用范围:整个集群
核心结构:Redis meta + result + ownerToken + Stream
角色:owner / follower
解决问题:负载均衡下,多节点同 key AI 请求只执行一次
额外能力:结果回放、失败接管、heartbeat 续租、旧 owner 防脏写、本地降级
代价:实现复杂度更高,依赖 Redis 协调
一句话对比:
单机 Single-flight 是用本地 Future 合并同 JVM 内的重复请求;
分布式 Single-flight 是用 Redis 状态机在集群里选 owner,让 follower 等待并复用结果。
7.分布式系统的CAP理论
另外在这里,我想再科普一下分布式系统中的CAP理论。

在分布式系统中,一致性、可用性和分区容错性三者不能同时完全满足,系统设计必须在其中做出权衡。
C (Consistency - 一致性): 每次读取要么获得最新写入的数据,要么报错。在分布式系统中,这意味着所有节点在同一时间的数据必须完全一致,就像访问单机单台数据库一样。
A (Availability - 可用性): 每次请求都能获得一个(非错误)的响应,但不保证获取的是最新写入的数据。系统必须一直处于“可提供服务”的状态。
P (Partition Tolerance - 分区容错性): 尽管网络会丢失或延迟节点之间的任意数量的消息(即发生网络分区),系统仍能继续运行。
所以,为了实现在分布式环境下,系统能够实现高可用,基于CAP理论,我们的分布式Single-flight分布式做了如下的设计
核心设计理念与 CAP 取舍:
- 全局锁与状态机(偏向 CP): 使用 Redis Lua 脚本保证争抢执行权的原子性。全网多个实例同时发起相同请求时,系统强制保证只有一个 Leader 节点突围。此时为了防止“僵尸节点”脑裂,引入 Fencing Token(防护令牌) 机制,一旦原 Leader 假死超时,立即剥夺其回写结果的权利。保证了在并发控制上对强一致性(C)。
- 心跳续期与主动接管(保证 A): 如果 Leader 节点真宕机了,其他处于等待共享状态的 Follower 节点不能被永远阻塞。通过 Owner Heartbeat 机制,一旦 Leader 心跳丢失,Follower 迅速超时并重新发起选举接管请求。这保证了哪怕发生单点故障,整个面试链路的可用性(A)依然不受影响。
- 本地 L1 Cache 终极降级(AP 兜底): 当遭遇极端网络故障(严重的 P),Redis 集群整体不可用时,框架自动退化回本地单机 Single-flight 模式。此时系统主动放弃了全局一致的请求合并(C),但死保住了用户的核心面试流程不中断(A)。
8. 整条链路总结
我做的 Single-flight 不是简单加锁,而是围绕一次 AI 请求建立分布式协同生命周期。请求先按 stage、sessionId 和业务指纹归一化成 requestKey,重任务先做 session 级互斥,再查本地 L1 回放缓存;未命中后进入 Redis Lua 状态机,由 Lua 原子返回当前请求是 owner、follower 还是直接回放。owner 才会进入 AI guard 调模型,并通过 heartbeat 续租,最后写 result、标记终态、通知 follower;follower 不调模型,而是通过 Stream 等待终态并用低频轮询兜底。为了防止旧 owner 迟到写入,关键写操作都要校验递增的 ownerToken。如果 Redis 协同异常,就降级成本地 single-flight,牺牲跨节点去重但保住主链路可用。
亮点:
Stage 解决的是不同 AI 场景的语义隔离和策略区分。
归一化 key 解决的是跨节点识别同一业务请求。
Session 重锁解决的是重任务入口不要重复启动。
Redis Lua 状态机解决的是 owner/follower 原子裁决。
Action 解决的是当前节点下一步该干什么。
ownerToken 解决的是旧 owner 脏写。
heartbeat 解决的是长耗时 owner 被误接管。
Redis Stream + 低频轮询解决的是 follower 等待和通知兜底。
本地降级解决的是 Redis 协调异常时主链路仍然可用。
六、skill文档治理
1. Skill 产生的背景
我想总结一下 Skill 相关的知识。Skill 产生的背景,主要来自两个方面的业务痛点。
第一个痛点是,在团队使用 Agentic Coding 的过程中,业务代码里经常存在一些缺少上下文说明的历史逻辑。很多代码只有最初编写的人知道它为什么要这样写,其他人只能依赖业务文档去理解。如果当时没有把这些逻辑记录下来,后来的人就很难判断这些代码的真实意图,只能去问原作者。但如果原作者已经离职,这条线索就会中断,代码也就不敢轻易修改。长期下来,就容易形成所谓的“屎山代码”。
第二个痛点是,Agent 在进行 Coding 时,也会受到这些历史代码的影响。由于部分历史逻辑缺少清晰解释,Agent 可能会被历史噪声污染。另外,很多业务代码之间存在耦合关系。如果想修改某一块业务代码,就必须知道它和其他业务模块之间的关系,否则很可能会影响到其他功能。虽然整个项目的代码量很大,但真正高频修改的热代码往往只占一小部分。
这两个痛点的根本原因,其实都是团队的领域知识没有被系统化地记录和管理。因此,我们需要建立一套面向 Agent 的知识体系。
2. MCP 和 Skill 的区别
后来有了 MCP,可以把 MCP 理解成给 AI 接入外部工具和数据源的一条管道。通过 MCP,AI 可以访问云文档、数据库、接口、搜索工具等外部能力。
但是,仅仅有 MCP 还不够。MCP 更像是给 AI 一个工具箱,里面有锤子、螺丝刀、扳手等工具,每个工具都标明了名字和功能。但问题是,AI 不一定知道在什么场景下该用哪个工具,也不一定知道这些工具应该按照什么顺序组合使用。
所以,这里就需要 Skill。Skill 本质上可以理解成一份 AI 能读懂的“最佳实践手册”或“任务执行模板”。它不是单纯提供工具,而是告诉 AI:在某一类任务中,应该什么时候触发、应该调用哪些工具、按照什么流程执行、参数怎么传、出错时怎么处理、最后结果应该怎么输出。
MCP 解决的是“AI 能不能使用工具”的问题;
Skill 解决的是“AI 应该如何正确使用这些工具完成任务”的问题。
3. Skill 的三层结构
Skill 本身可以理解成一种结构化的领域知识载体。它有自己的三层结构:
第一层:元数据,也就是每个 Skill 的名称,以及这个 Skill 的简要描述。
第二层:SKILL.md 正文,也就是 Skill 的主文件。
第三层:引用资源,也就是更详细的说明文件,通常放在 references/ 目录下。
一个完整的 Skill 通常会包含几个核心部分:
任务描述:说明这个 Skill 能解决什么问题,适合什么业务场景。
触发条件:说明在什么情况下应该调用这个 Skill。
工具依赖:说明执行过程中可能需要用到哪些工具、脚本、接口或 MCP 能力。
执行流程:说明先做什么、再做什么、遇到不同情况如何分支处理。
参数映射:把用户自然语言需求转换成工具或代码执行需要的结构化参数。
错误处理:当工具调用失败、信息缺失、权限不足或结果异常时,Agent 应该如何处理。
结果格式化:Agent 最后应该用什么形式把结果返回给用户。
4. 渐进式披露机制
从结构上看,Skill 采用的是渐进式披露机制。它不是一次性把所有知识都塞进上下文,而是按需加载。
第一层是路由判断层。Agent 进入项目后,会先看到暴露在外的 AGENTS.md、Rules,以及所有 Skill 的 name 和 description。AGENTS.md 和 Rules 主要介绍当前项目背景、项目架构、工程操作约束和全局术语。Skill 的元数据则告诉 Agent 每个 Skill 分别适合处理什么类型的任务。Agent 会结合这些信息,判断当前业务需要使用哪个 Skill。
第二层是 Skill 主文件层。当 Agent 选中某个 Skill 后,会读取对应的 SKILL.md 正文。这个正文主要描述该 Skill 适用于什么业务场景、标准处理流程是什么、关键约束和规范是什么、输出要求是什么,以及什么时候需要继续读取第三层详细文件。
第三层是引用详情层。Agent 在执行任务时,如果发现当前问题需要更细的规则或背景知识,才会根据 SKILL.md 中的指引继续读取对应详情文件。如果当前任务不需要这些细节,就不会额外读取第三层资源。
这就是 Skill 和普通 Prompt 很大的区别:
普通 Prompt:一次性全量加载。
Skill:结构化、模块化、按需加载。
5. Skill 的演化方向
Skill 后来也可以继续演化。虽然现在的三层架构已经能够帮助 Agent 理解业务背景和流程规范,但它仍然有一个痛点:Skill 里的领域知识还不一定能和实际代码直接对应起来。
也就是说,Agent 读完 Skill 之后,仍然需要再去阅读代码,并自己判断 Skill 中描述的业务规则对应到代码里的哪些位置。
因此,下一步改进方向是让领域知识与实际代码流程产生显式映射。这个变化的意义在于:我们不只是告诉模型“有哪些知识”,而是进一步告诉模型“这些知识应该如何在代码中被使用”。这相当于从提供知识,升级为指导模型运用知识。
6. Skill 防腐机制
业务知识是不断变化的,Skill 中也可能残留旧知识。一旦这些旧知识已经过期,它们反而会比“没有知识”更危险,因为它会让 Agent 带着一种“错误的正确性”去理解和修改代码。因此,Skill 需要一套持续维护的防腐机制。
这套机制主要分为四层:
第一层:反向校验
Agent 在处理业务代码时,如果发现代码逻辑与 Skill 中描述的知识口径不一致,就生成校验报告。低风险问题可以生成修改建议,甚至在规则允许时自动更新;高风险、涉及核心业务口径的问题,需要人工确认。
第二层:沟通补充
需求开发过程中,如果 Agent 发现当前 Skill 没有覆盖某些业务背景、规则或约束,就和人工沟通确认。确认有必要后,把新增知识补充到对应 Skill 文件里。
第三层:Commit 前校验
代码提交、PR 创建或 Agent 交付时,Agent 读取本次 diff,判断变更是否影响 Skill 中记录的业务知识。如果影响,就生成更新建议,确认后写入 Skill。
第四层:基线巡检
代码合并到稳定分支或进入发布流程时,触发全量巡检。检查代码锚点、接口说明、数据库字段、Skill 覆盖度和更新时间,生成结构化健康报告,再由人工 Review。
这四层机制结合起来,目的就是让 Skill 不只是一次性写出来的知识文档,而是能够随着真实业务代码和开发过程持续更新,尽量避免知识过期、缺失和失真。
7. CI/CD 中加入 Skill 巡检
CI/CD 可以理解成代码从提交到发布之间的自动化流水线。CI 负责在代码提交或合并后自动做检查,比如安装依赖、编译、跑测试、代码扫描。CD 负责在检查通过后继续打包、部署到测试环境或线上环境。
这里提到 CI/CD 的意思是:在原本的发布流水线里,多加一个 Skill 全量巡检节点。
原来:
合并到 master
-> 跑测试
-> 构建
-> 发布
现在:
合并到 master
-> 跑测试
-> 构建
-> Skill 全量巡检
-> 生成健康报告
-> 人工 Review
-> 必要时更新 Skill
-> 发布 / 完成基线
8. 为什么不用 SDD
SDD 是 Spec-Driven Development,意思是先为某个需求写一份完整的 spec 规范文档,再让 Agent 按照规范去写代码。
但团队日常需求大多是中小型改动。如果每次都写 spec,可能会出现“规范比代码还长”的情况,维护成本很高。而且很多 spec 只服务于当前这一次需求,需求做完后复用价值不高。
相比之下,Skill 维护的是长期稳定的领域知识,比如业务规则、核心流程、接口口径和代码映射。所以,与其为每个需求维护一次性 spec,不如维护一套会持续演进、后续能反复复用的 Skill 知识体系。
9. 业务中的 Skill 怎么拆
我们不是简单按代码文件夹拆 Skill,而是按 Agent 处理需求时的认知边界来拆。
首先有一个 repo-map 作为导航型 Skill,帮助 Agent 判断当前需求应该落到哪个业务域。
然后针对核心业务域拆出:
用户认证
通用 Agent 会话
面试主链路
媒体能力
同时把这些稳定性问题单独拆成运行时 Skill:
限流
singleflight
线程池
配置
对于跨模块变更和问题排查,又补了:
change-playbook:处理影响面分析
debug-playbook:处理故障定位
这样拆的好处是:每个 Skill 边界清晰,Agent 不需要一次读完所有知识,而是先路由,再按需加载对应领域的知识。
如果全部写到一个 Skill 里,内容会非常大,Agent 每次处理小需求都会加载大量无关知识,浪费上下文,也容易被无关信息干扰。所以我们把高频业务域拆开,让 Agent 只读取当前任务相关的 Skill。
改登录逻辑:读 xunzhi-auth-user
改面试评分:读 xunzhi-interview-domain
改 singleflight:读 xunzhi-ai-runtime
Skill 也不能拆得太碎。拆太碎之后,Agent 反而需要频繁路由,不知道该读哪个,维护成本也会上升。
所以我们的原则是:
一个 Skill 对应一个相对完整的业务上下文,
而不是一个接口、一个类就拆一个 Skill。
比如面试会话、评分、追问、恢复虽然代码分散,但都属于面试主链路,所以放在 xunzhi-interview-domain 里。
10. 每个 Skill 的基本结构
每个 Skill 基本都是这个结构:
SKILL.md
references/
scripts/
agents/openai.yaml
其中:
SKILL.md:
主文件,写这个 Skill 什么时候用、先看哪些资料、关键入口在哪、哪些约束不能破坏。
references/:
详细知识,比如状态机、接口映射、对象词典、易错点、业务流程。
scripts/:
自动生成脚本,比如从代码里提取接口索引、配置索引、工作流契约。
agents/openai.yaml:
给 Agent 平台看的展示和调用配置。
更多推荐




所有评论(0)