先把对话状态存哪儿想清楚——GPT-Live 语音应用的分层设计

文章导读
GPT-Live 这类语音对话应用出现上下文丢失、插话后答非所问,通常不是模型侧的问题,而是状态散落在客户端内存、WebSocket 连接对象、前端组件和服务端会话表里,谁是真源说不清。判断方向是:先列出当前实际在存的状态字段并标出唯一写入方,再按连接态、会话态、轮次态三层定生命周期,最后才写接入和重连代码。存储选型(进程内存、Redis、数据库)放在分层之后再决定,需结合部署形态和是否要多实例共
📋 目录
  1. 壹 列出目前实际在存的全部状态字段并标注写入方
  2. 贰 把状态分成连接态、会话态、轮次态三层
  3. 叁 给掉线重连写一段恢复顺序伪代码
  4. 肆 在轮次被中断时只回滚轮次态
  5. 伍 用重连和插话两个场景验证分层是否成立
A A

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[] 落盘或归档;轮次态的失效由播报结束或打断事件触发,删掉即结束,不需要归档。

先把对话状态存哪儿想清楚——GPT-Live 语音应用的分层设计

给掉线重连写一段恢复顺序伪代码

恢复顺序的原则是先轻后重、先可用后完整:连接态最新建,会话态只读加载,轮次态按锚点决定要不要续。下面这段骨架里的函数名是占位,替换成项目里实际的服务调用即可。

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)再问一个依赖上文的问题。

先把对话状态存哪儿想清楚——GPT-Live 语音应用的分层设计

期望结果:连接态是全新的 connId,会话态仍是原 sessionId,turns[] 长度与断线前一致,回答能引用断线前的内容。若结果不符,先看 loadSessionSnapshot 是否返回空,再看客户端重连时有没有生成新的 sessionId——这两个点覆盖了多数重连丢上下文的情况。

场景二:回答中插话

复现步骤:1)让模型开始一段较长回答;2)在播报中途插话打断;3)观察本轮音频是否停止、轮次态是否清空;4)再问一个与上一轮相关的问题。

期望结果:本轮 ttsBuffer 与 asrPartial 被清空,turnId 置为 interrupted,turns[] 中除本轮以外的记录不变;新一轮回答基于插话前已有的历史,而不是基于半句被打断的回答。验证方式是比对插话前后的 turnsLen 与 historyRevision。

两个场景都通过,说明分层在当前实现里站得住;若插话场景里 turns[] 仍被改动,或者重连场景里会话态换了 sessionId,通常意味着轮次态和会话态共用了同一个写入路径,需要先把写入方拆开再继续做功能。