老客户端要改:转写从 done 迁到 delta
speech-to-speech 的 Realtime Engine 文档里,转写事件的兼容性变更是单开一节写的。文档专门为某件事开一节,通常意味着它咬过人。这一节的核心是一句迁移指引:以前消费每个块级 done 事件的客户端,必须把实时渲染改到 delta 上,把 done 只当作定稿。
这篇按排查的路子走一遍:现象长什么样、怎么确认就是它、文档语义给出的处置是什么、改完怎么验、以及什么情况说明你遇到的根本不是这个问题。
一、现象:字幕整轮憋到最后才出现
典型表征是这样的:服务端跑起来,音频那一路是通的,助手在说话;但界面上的助手文字迟迟不出现,一直等到这一轮快结束时才「啪」地整段刷出来。看上去像是转写慢了半拍,实际上是客户端挑错了渲染锚点。
按仓库内 Realtime Engine 架构文档的说法,助手转写块以 response.output_audio_transcript.delta 发出,每一个 delta 是当前音频输出项的助手转写增量后缀;而 response.output_audio_transcript.done 是完整助手转写,输出项关闭时只发一次,位置在 response.output_audio.done 之后、response.done 之前。
也就是说,如果你的客户端只在 done 上渲染,那它拿到第一块可显示文本的时间点,天然就在这一轮音频播完之后。字幕不是慢,是被你排到了队尾。
还有一种变体:文字确实出来了,但被打断之后界面上残留的内容跟实际听到的对不上,或者干脆被清空。这一种同样和事件选择有关,后面第三节会讲。
二、怎么确认是这个问题
三个动作,从便宜的做起。
动作一:把这一轮的事件类型原样打出来。 文档给了一段 WebSocket 客户端示例,可以直接拿来当探针用:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8765/v1",
websocket_base_url="ws://localhost:8765/v1",
api_key="not-needed",
)
with client.realtime.connect(model="local") as conn:
conn.send(
{
"type": "session.update",
"session": {
"type": "realtime",
"instructions": "You are a helpful assistant.",
"audio": {
"input": {
"turn_detection": {
"type": "server_vad",
"interrupt_response": True,
}
}
},
},
}
)
for event in conn:
print(event.type)
这段的最后三行就是判据:数一轮对话里 response.output_audio_transcript.delta 出现了几次、response.output_audio_transcript.done 出现了几次。如果 delta 是多次、done 恰好一次,那服务端的行为完全符合文档描述,问题在客户端这一侧。端口用 8765,那是 Realtime 服务的端口;docker compose 里另一个 8080 是跑 Gemma 4 的 llama.cpp 服务,两个别写反。
顺带说一句,示例里的 api_key="not-needed" 不是笔误。文档在讲 LLM 代理时把话说得很直白:这个服务端自己不做任何认证,也不做任何限流,只应在受信网络上启用,或者部署在一个由你自己掌管访问控制的网关后面。调试时把它留在本机就好,别顺手挂到公网上去。
动作二:翻客户端代码里的事件分支。 在你的前端或客户端代码里搜 output_audio_transcript。如果只搜到 .done 的处理分支、没有 .delta 的分支,那基本可以结案了。
动作三:核对 done 出现的位置。 在打印出来的事件序列里找那唯一一次 response.output_audio_transcript.done,确认它夹在 response.output_audio.done 和 response.done 之间。位置对得上,说明服务端按文档在走,不用再往服务端查。
三、文档语义给出的处置
第一步,把实时渲染搬到 delta 上。 每收到一个 response.output_audio_transcript.delta,就把它的内容追加到当前输出项的显示缓冲区尾部——它本来就是增量后缀,追加即可。
第二步,把 done 降级成定稿。 文档明确说明:把这些 delta 值拼接起来,能复现终态的 response.output_audio_transcript.done.transcript。所以到达 done 时,你既可以什么都不做(内容已经一致),也可以用它做一次整体覆盖以求稳妥。值得留意的是,仓库自带的音频客户端也接受遗留的 done-only 流,但它不会在显示完 delta 之后再重新渲染完整终态转写——这说明「不覆盖」是官方客户端选择的策略,不是遗漏。
第三步,把取消路径单独处理。 文档写得很清楚:那次 done 恰好发一次,包括取消关闭了未完成助手项的情况;取消时它包含已累积的部分转写。这意味着被打断的那一轮,你在界面上应当保留 done 给出的部分转写,而不是清空重来。
这里有个容易踩空的时序,来自打断处理那一节的原文顺序:VAD 检测到语音后,如果当时有活跃响应,服务端先发 response.output_audio.done,再在产生过转写文本时发 response.output_audio_transcript.done,最后发 response.done,带 status="cancelled"、reason="turn_detected";而 input_audio_buffer.speech_started 是跟在这些终态事件之后的。如果你的客户端习惯在收到 speech_started 时清空助手区域,那它清掉的正是刚刚定稿的那段部分转写。要么改成不清,要么把清空动作绑到新一轮的开始上。
第四步,别把转写 done 当轮次终点。 文档保证的是「一个产生了转写文本的响应恰好发一次转写 done」,它没有对不产生转写文本的响应做出同样的承诺。轮次的终点事件是 response.done,把状态机挂在它上面才稳。
顺带一提,response.created 也不是在请求发起时发的,而是在第一个出站音频块出来时才发。拿它当「开始渲染」的锚点同样会偏晚,界面上的「正在思考」状态怎么写要考虑这个时机差。
四、改完怎么验证
第一,重跑第二节那段事件打印,确认 delta 计数大于零,并且客户端的显示缓冲区是随 delta 一次次增长的,而不是一次到位。
第二,做一次字符串相等断言:客户端自己把这一轮收到的所有 response.output_audio_transcript.delta 按到达顺序拼接,与最后那个 done 里的 transcript 比对。文档说这两者能对上,对不上就说明你的拼接逻辑(比如跨输出项串了、或者做了 trim)有问题。
第三,验取消路径。客户端主动发 response.cancel,应当看到 response.done 带 status="cancelled"、reason="client_cancelled";用说话打断则是 reason="turn_detected"。两种情况下界面上都应保留那段部分转写。
第四,如果你用的是 WebRTC 传输(需要装 webrtc extra),所有 JSON 事件走 oai-events 数据通道,协议与 WebSocket 模式相同,上面这套断言可以照搬。差异要记住三处:input_audio_buffer.append 会被拒绝并返回 invalid_event_for_transport,output_audio_buffer.clear 是 WebRTC 独有的,session.created 在数据通道打开时发送而不是连接时。
五、什么情况说明不是这个原因
这一节比前面几节更值钱,因为它能省下你半天时间。
事件流里既没有 transcript 的 delta 也没有 done。 那就不是渲染锚点的问题,而是这一轮压根没产生助手转写文本,或者会话没走到生成那一步。往前看有没有 response.created、有没有 response.output_audio.delta。
看到 error 事件。 已知错误码包括 session_limit_reached、unknown_or_invalid_event、invalid_session_type、conversation_already_has_active_response。这些是协议用法问题,跟 delta/done 迁移没有关系,照错误码本身去查。
WebRTC 下发 input_audio_buffer.append 收到 invalid_event_for_transport。 说明音频根本没进流水线——这条传输下音频走媒体轨。没有输入自然没有转写,先把音频链路接通再说。
文字正常、音频不正常。 这两条是分开的流。文档里 LMOutputProcessor 把 LLM 输出劈成两路:干净文本给 TTS,assistant_text 加工具调用字典进 text_output_queue;出站音频以 response.output_audio.delta 走,助手转写以 response.output_audio_transcript.delta 走。一路好一路坏,说明问题在被劈开之后的某一支上,别混着查。
文字被整段吞掉,且总是发生在打断之后。 这更像服务端的世代管理在起作用,而不是客户端选错了事件。服务端用一个共享的 CancelScope 协调 VAD、_send_loop 与 LLM/TTS handler:调 cancel() 时 cancel_scope.generation 递增,更早的世代经 is_stale 判定立刻过期;discarding 标志置位期间,cancel_generation 不属于当前世代的音频块与助手文本会被丢掉,守卫在世代匹配的 __RESPONSE_DONE__ 到达时清除。文档还专门说明了一条兜底:来自当前世代的输出永远放行,避免新响应被残留的丢弃窗口吞掉。判定方法很简单——看被吞的那一轮前面有没有 speech_started 和 status="cancelled",有就往这个方向查,没有就回到客户端。
丢的是用户侧转写而不是助手转写。 用户这一轮的转写走的是 conversation.item.input_audio_transcription.delta 和 conversation.item.input_audio_transcription.completed,是另一组事件,不在这次迁移的范围里。顺带记一条:打断时冲队列是有保留清单的,speech_stopped、部分/完整转写、token 用量这些用户侧事件不会跟着助手输出一起被冲掉——所以用户侧转写丢失,几乎可以确定不是打断导致的。
最后说个态度问题。这类破坏性变更之所以难查,是因为它不报错:服务端按新语义老老实实地发事件,客户端按旧语义老老实实地渲染,两边都「正常工作」,只有用户看着一片空白的字幕在等。遇到这种「没报错但就是不对」的情况,先把事件类型原样打出来数一遍,比读十遍自己的代码有用。
延伸阅读
本文依据 speech-to-speech 官方仓库(github.com/huggingface/speech-to-speech)的 README、
src/speech_to_speech/arguments_classes/ 下的参数定义与 Realtime Engine 架构文档整理,核对日 2026-08-09。
本文内容为仓库源码与文档口径,我们没有安装、部署或调用过该服务,文中毫秒值均为参数默认值而非实测延迟。
参数与默认值随版本变动,请以 speech-to-speech serve -h 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。