把业务状态塞进语音会话里,最先出问题的往往不是模型质量,而是重连和阻塞:连接一断,新会话里没有工单号、没有当前流程节点,模型只能重新问一遍用户;工具调用如果在音频回调线程里同步等结果,上行采集和下行播放都会被卡住。可行的划分是让 GPT-Realtime-2.1 只负责“这一轮语音回合”,业务状态和工具结果留在自己的服务里,两层之间用显式字段对接。
适用场景:用实时语音模型做外呼、坐席辅助或语音助手,且一轮对话里需要查订单、写工单、调外部接口。处理方向:语音侧只保留会话与回合状态,业务状态以 session_id / turn_id 为纽带放在业务库;工具调用走异步,先回执再回填结果。验证方式:在日志里按 source 字段区分事件来源,用一轮带工具调用的对话观察音频分片是否连续。风险边界:异步回填会改变多轮顺序,需要业务侧自己保证幂等与顺序。
画出一次语音回合的生命周期,标出哪些事件由语音侧产生
一次语音回合通常经历这几个阶段:会话建立、上行音频开始、语音活动检测(说话开始 / 说话结束)、转写文本生成、模型生成回复、下行音频分片播放、回合结束。其中前六个阶段的事件基本都由语音侧产生,只有“回合结束”之后是否还要继续动作,需要业务侧确认。
属于会话的状态包括:连接标识、音频流是否在推、当前是否处于说话中、未播完的音频缓冲、上下文窗口。属于业务的状态包括:用户身份、订单号或工单号、流程当前节点、已经执行过哪些工具、工具返回了什么。把这两类状态混在一张表里,重连时就必然要重建一次业务数据。
事件来源标注方法很朴素:接入层写日志时给每条事件加一个 source 字段,voice 表示语音侧回调,biz 表示业务服务自己产生;两类事件都带同一个 turn_id,排查时按 turn_id 排序就能还原一次回合的完整顺序。下面这张草图用于和团队对齐边界,不是接口定义。
语音侧(会话 / 回合) 业务侧(状态 / 工具)
--------------------------------- ---------------------------
session.open
audio.in
vad.start voice 事件
vad.stop ─────────────────► 只记录,不写业务状态
transcript.delta
response.audio.delta
turn.end ─────────────────► 业务侧决定是否开新一轮
biz: 订单 / 工单 / 节点
tool.call ──────► 入队
◄──── tool.result(异步回填,带 reply_to)把业务状态放到语音会话之外,约定两层之间的消息字段
重连丢状态的根因是业务数据存在了会话上下文里。把业务状态挪到自己的存储后,语音会话重建时只要把业务键重新带进去就能恢复;业务服务重启同理,只要业务库还在,语音侧不需要知道这些细节。
- session_id:语音会话标识,两边必须一致;若由语音侧生成,业务侧要建一张映射表
- turn_id:单次语音回合标识,用于把一次回合内的所有事件串起来
- biz_key:业务主键,用工单号或订单号这类业务唯一键,不要拿随机 UUID 当业务主键
- event_type:如 transcript.delta、tool.call、tool.result
- reply_to:回填时指向原始 tool_call 的 id
- deadline_ms:该工具调用允许等待的毫秒数,超时由业务侧负责回填
- idempotency_key:幂等键,重连重放时避免同一工具被执行两次
会话标识的传递方式:建立会话时通过请求 metadata 或首帧 hello 消息把 session_id 和 biz_key 传进去,语音侧每个回调原样回带。不要在每一个音频帧里重复携带业务对象,音频链路不该承担这个职责。
{
"session_id": "<语音会话标识>",
"turn_id": "<本回合标识>",
"biz_key": "<工单号或订单号>",
"event_type": "tool.result",
"reply_to": "<tool_call_id>",
"status": "ok | timeout | error",
"result": { "fields": "按各工具自己约定" },
"idempotency_key": "<tool_call_id>"
}替换项:session_id 若由业务侧指定,需要在建立会话的请求里显式声明;biz_key 的取值要和业务库主键一致,避免多一层翻译。
让工具调用的结果异步回到语音回合
时序上分四步:模型输出 tool.call,语音侧把事件抛给业务侧;业务侧立即返回 accepted 并带上 tool_call_id,不返回结果;语音侧不等待,继续收发音频;业务侧处理完成后调用语音侧的“注入事件”入口,带上 reply_to 把结果回填,语音侧据此生成后续回复。
- tool.call 到达业务侧,写入任务队列并立刻回执
- 音频链路不受影响,用户可以继续说话或听到停顿提示
- 工具执行完成,业务侧发起一次注入事件调用
- 语音侧消费注入事件,产生新的语音回合
超时处理写法:业务侧为每个任务设一个 deadline,超时也要回填一条 tool.result,status 填 timeout,宁可让模型说“正在处理”,也不要让语音侧无限等待。语音侧自身再设一个最大等待时长,超过就先播一句占位话术,避免静音。
def on_tool_call(call):
# call: {tool_call_id, session_id, turn_id, name, args, deadline_ms}
biz_key = store.lookup(call.session_id) # 业务侧维护的映射
enqueue(worker, {**call, "biz_key": biz_key})
return {"status": "accepted", "tool_call_id": call.tool_call_id} # 立即返回
def worker(job):
try:
result, status = run_tool(job.name, job.args, timeout=job.deadline_ms / 1000), "ok"
except TimeoutError:
result, status = None, "timeout"
except Exception as exc:
result, status = str(exc), "error"
voice.inject_event({
"session_id": job.session_id,
"turn_id": job.turn_id,
"reply_to": job.tool_call_id,
"event_type": "tool.result",
"status": status,
"result": result,
"idempotency_key": job.tool_call_id,
})需要留意的边界:异步回填意味着结果到达顺序不一定等于调用顺序,业务侧要么给工具调用编号,要么在回填前做一次顺序校验。
用一轮带工具调用的对话验证音频不等待业务
验证场景:准备一轮会触发工具调用的对话,业务侧的执行函数里人为加一段延迟(例如固定等待若干秒),其余逻辑保持不变。这样做的目的是把“业务慢”放大到能被日志观察到,而不是为了测性能。
观察指标主要有五个:上行音频分片的发送间隔是否稳定;下行音频分片的播放间隔是否稳定;tool.call 到业务侧回执之间的耗时;业务侧回填结果到语音侧开始生成的间隔;日志中 voice 与 biz 两类事件是否按 turn_id 正常交错,而不是出现一长段 voice 事件空白。
| 记录点 | 方向 | source | event_type | turn_id | 备注 |
|---|---|---|---|---|---|
| T0 | — | voice | session.open | — | 记录 session_id 与 biz_key |
| T1 | 上行 | voice | vad.start / vad.stop | turn-1 | 确认音频在推 |
| T2 | 出 | voice | tool.call | turn-1 | 记录 tool_call_id 与 deadline_ms |
| T3 | 回 | biz | tool.accepted | turn-1 | 回执时间,此时音频不应中断 |
| T4 | 入 | biz | tool.result | turn-1 | 带 reply_to,注明 status |
| T5 | 下行 | voice | response.audio.delta | turn-1 | 确认回填后才有新回复 |
填写方式:每条记录只填实际观察到的时间点或相对顺序,不补齐推测值;如果某个 event_type 在日志里没出现,就在备注里写“未观察到”,而不是留空。判定标准也很直接——T2 到 T3 之间以及业务执行期间,上行和下行音频的分片记录应当是连续的;若这段时间里 voice 事件出现整段空白,说明工具调用仍然卡在音频线程里,需要回到第二节的字段约定和第三节的异步回填重新对齐。