GPT-Live 这类语音对话应用出现上下文丢失、插话后答非所问,通常不是模型侧的问题,而是状态散落在客户端内存、WebSocket 连接对象、前端组件和服务端会话表里,谁是真源说不清。判断方向是:先列出当前实际在存的状态字段并标出唯一写入方,再按连接态、会话态、轮次态三层定生命周期,最后才写接入和重连代码。存储选型(进程内存、Redis、数据库)放在分层之后再决定,需结合部署形态和是否要多实例共享来确认。
先把状态分层再写代码:连接态随连接生灭,会话态跨连接存活,轮次态只在一次回答内有效。断线重连只恢复会话态和最后一段轮次的锚点,插话中断只回滚轮次态。适用多轮语音问答场景;验证方式是重连后上文字段是否与断线前一致、插话后历史记录是否被污染。若三层的写入方不唯一,分层基本失效,需要先拆写入路径。
列出目前实际在存的全部状态字段并标注写入方
先摸现状,不要急着选存储。打开客户端与服务端两侧的日志和断点,按“字段名 / 存放位置 / 唯一写入方 / 读频率 / 写频率 / 是否跨连接存活”六列过一遍。写入方只写一个,谁是唯一真源谁写,其他位置只读;同一个字段有两处都在写的时候,通常就是错乱的来源。写频率高的字段(音频分片偏移、ASR 中间结果)不要塞进需要持久化的会话记录里。
字段名 | 存放位置 | 唯一写入方 | 读频率 | 写频率 | 跨连接存活
connId | 服务端连接池 | gateway | 低 | 每次建连 | 否
lastPingAt | 服务端连接池 | gateway | 低 | 心跳周期 | 否
sessionId | 客户端 + 服务端 | session-service | 高 | 建会话时 | 是
turns[] | 服务端会话表 | session-service | 高 | 每轮结束 | 是
summary | 服务端会话表 | session-service | 中 | 多轮之后 | 是
asrPartial | 客户端内存 | client-asr | 高 | 每帧 | 否
ttsBuffer | 客户端音频队列 | client-tts | 高 | 每块音频 | 否
turnId | 客户端 + 服务端 | session-service | 高 | 每轮开始 | 否
清单填完后做一次筛选:“跨连接存活”为是的字段,才进会话态候选;标为“仅当前连接”的进连接态;只在一轮回答内变化的进轮次态。写频率这一列还有第二个用途:写频率高且跨连接存活为否的字段,重连时直接丢弃重建,比同步到服务端更省事,也更不容易出现两份互相覆盖的副本。
把状态分成连接态、会话态、轮次态三层
三层的差别不在存哪儿,而在生命周期由谁决定。连接态由网络事件决定,会话态由用户会话决定,轮次态由一次问答决定。下面这张表可以直接拿来对代码里现有的状态对象做归类。
| 层 | 典型字段 | 创建时机 | 过期时机 | 清理方式 |
|---|---|---|---|---|
| 连接态 | connId、lastPingAt、传输缓冲 | WebSocket 握手成功 | 连接关闭或心跳超时 | 在连接关闭回调中删除,日志只记 connId |
| 会话态 | sessionId、userId、turns[]、summary、音色偏好 | 首次建会话,或重连时按 sessionId 命中已有记录 | 用户显式结束,或超过约定的空闲阈值 | 由会话服务统一删除,删除前落一条审计日志 |
| 轮次态 | turnId、asrPartial、ttsBuffer、打断标记 | 每轮用户输入开始 | 本轮播报完成,或被插话打断 | 完成或打断时立即释放,不写回会话表 |
失效条件要写成“谁触发、删什么”,不能只写“超时清理”。连接态的失效由心跳检测触发,只删连接池里的记录;会话态的失效由用户动作或空闲阈值触发,删之前要把 turns[] 落盘或归档;轮次态的失效由播报结束或打断事件触发,删掉即结束,不需要归档。
给掉线重连写一段恢复顺序伪代码
恢复顺序的原则是先轻后重、先可用后完整:连接态最新建,会话态只读加载,轮次态按锚点决定要不要续。下面这段骨架里的函数名是占位,替换成项目里实际的服务调用即可。
async function onReconnect(socket):
conn = acceptConnection(socket) # 建全新连接态
sid = readSessionId(socket.handshake) # 客户端带上来的 sessionId
if not sid:
return degradeNewSession(conn, reason="no-session-id")
snap = await loadSessionSnapshot(sid) # 只读,不修改
if not snap:
return degradeNewSession(conn, reason="snapshot-missing")
ok = await verifySessionOwner(conn, snap) # 同一用户 / 同一设备
if not ok:
return degradeNewSession(conn, reason="owner-mismatch")
attachSession(conn, snap) # 会话态挂到新连接
anchor = snap.lastTurnAnchor # 只取最后一段轮次锚点
if anchor and anchor.status == "playing":
resumed = await resumeAudioOut(conn, anchor)
if not resumed:
discardTurnAudio(anchor) # 降级:本轮不续播
return notifyClient(conn, status="restored")
降级分支要事先定好,不要等到线上再想。snapshot-missing 或 owner-mismatch 时,新建会话并清空客户端本地历史,同时给用户一句“上一段上下文已失效”的提示,让用户自己决定要不要重说;resumeAudioOut 失败时只丢弃该轮音频,会话态不动。降级只影响被降级的那一层,不要把三层一起重置。
在轮次被中断时只回滚轮次态
插话打断的处理边界是只回滚轮次态。具体动作:丢弃本轮未播完的 ttsBuffer,清掉 asrPartial 与打断标记,把 turnId 置为 interrupted。已写入 turns[] 的历史记录保持不动,sessionId、summary、音色偏好都不重新生成。常见的错误做法是打断时重写整个会话快照,一旦写入不完整,重连后就会看到半句话的历史,或者看到两次回答叠在一起。
验证回滚是否越界,靠结构化日志而不是肉眼观察。在回滚前后各打一行,字段至少包含 sessionId、turnId、rollbackScope=turn、turnsLenBefore、turnsLenAfter、historyRevision。正常回滚时 turnsLenBefore 与 turnsLenAfter 相等,historyRevision 不变;如果 turnsLenAfter 变小或 historyRevision 变了,说明回滚动到了会话态,需要回去检查调用链里是不是复用了同一个写入函数。
用重连和插话两个场景验证分层是否成立
场景一:断线重连
复现步骤:1)开始一轮多轮问答,让 turns[] 至少有两条记录;2)记录当前 sessionId 与 turns 长度;3)在客户端杀进程或断网,等服务端心跳超时;4)重新连接并带上同一个 sessionId;5)再问一个依赖上文的问题。
期望结果:连接态是全新的 connId,会话态仍是原 sessionId,turns[] 长度与断线前一致,回答能引用断线前的内容。若结果不符,先看 loadSessionSnapshot 是否返回空,再看客户端重连时有没有生成新的 sessionId——这两个点覆盖了多数重连丢上下文的情况。
场景二:回答中插话
复现步骤:1)让模型开始一段较长回答;2)在播报中途插话打断;3)观察本轮音频是否停止、轮次态是否清空;4)再问一个与上一轮相关的问题。
期望结果:本轮 ttsBuffer 与 asrPartial 被清空,turnId 置为 interrupted,turns[] 中除本轮以外的记录不变;新一轮回答基于插话前已有的历史,而不是基于半句被打断的回答。验证方式是比对插话前后的 turnsLen 与 historyRevision。
两个场景都通过,说明分层在当前实现里站得住;若插话场景里 turns[] 仍被改动,或者重连场景里会话态换了 sessionId,通常意味着轮次态和会话态共用了同一个写入路径,需要先把写入方拆开再继续做功能。