结果:已合并 (2026-10-27 18:04, google-gemini/cookbook#1433). 实际上,这些内容取自 GitHub 的 PR、审阅、行评论、评论、时间线、检查记录,以及 Cookbook 源码(PR 的基础提交
d9df60de)、Live API 参考文档、贡献账本的初稿和回复初稿,以及 TT Lab 仓库的语音中继代码。时间均为韩国时间。
目录
一句话总结
| PR | google-gemini/cookbook#1433 websockets Live API starter: use realtime_input.audio/video and flush playback only on interrupted |
| 修改内容 | quickstarts/websockets/Get_started_LiveAPI.py 一个文件。输入字段、声音 MIME 格式、接收端循环、解码。两个提交,增加 29 行,删除 34 行 |
| 反应 | 审阅机器人的一个建议,审阅者的“变更请求”(附带亲自复现和额外的一行),另一位审阅者的离线检查,最后在实际语音对话测试后批准 |
| 结果 | PR 公开 10-17 17:22 → 变更请求 10-19 19:57 → 反映 22:03 → 批准 10-22 19:19 → 合并 10-27 18:04,约 10 天 41 分钟 |
1. 问题所在:示例与参考文档分道扬镳的地方
TT Lab 的语音对话功能是一个中继,服务器在浏览器和 Gemini Live API 之间通过 WebSocket 进行连接(TT Lab 仓库 backend/app/lang_call.py)。不使用 SDK,
而是直接处理 WebSocket 协议,麦克风声音发送到 realtimeInput.audio 的 audio/pcm;rate=16000 中,如果服务器发送了 serverContent.interrupted ,则会清空浏览器中
累积的播放内容,而在 turnComplete 中则不清空。该代码的注释中记录了在 2026-10-09 连续发送了四个问题,分别用四种语言正确回答,并且说话结束后第一段声音
为 0.88–1.48 秒。同时附带说明,由于是单次测量,速度比较尚未实际测量的提示。
区分我们测量过的内容和未测量过的内容。根据上述记录,我们的中继能够使用当前字段正常运行。然而,如果按原样运行此示例, media_chunks 是否真的被忽略了并没有进行测量(未实际测量)。因为需要麦克风、摄像头和 API 密钥,所以在 PR 正文中写道:“仅用 Python compile() 确认了语法,没能运行到最后。”因此,最初的 PR 的分量大约是“整理废弃字段,替换为当前字段”的程度。这个问题有多严重,审查者在第 4 节中告诉了我们。哪里出现了偏差,在第 2 节中逐行记录。
2. 原因:示例中的哪一行
Gemini Cookbook 中有两套做同样事情的示例。使用 SDK 的 quickstarts/Get_started_LiveAPI.py 和直接使用 WebSocket 的
quickstarts/websockets/Get_started_LiveAPI.py 。在阅读与我们的中继方式相同的第二个示例时,我发现有四个地方与参考文档不同。行号基于 PR 的基础提交
d9df60de 。
| 行 | 示例所做的事情 | 标准 |
|---|---|---|
| 151 | 将摄像头画面作为 {"media_chunks": [frame]} 传输 |
Live API 参考文档将 mediaChunks 标记为已废弃,并要求使用 audio·video·text 中的一个 |
| 181 | 将屏幕共享作为 {"media_chunks": frame} 传输(单个对象而非列表) |
也与上面的第 151 行不同 |
| 204–210 | 将麦克风声音发送到 media_chunks ,MIME 为 "audio/pcm" |
其他 Live 示例只播放 audio/pcm;rate=16000 |
| 214–242 | parts[0] ,并在每个 turnComplete 清空播放队列 |
打断信号为 interrupted. SDK 版示例已经修改为“仅在interrupted 时清空并播放所有片段” |
接收方循环的注释中写着:“如果打断模型说话,则发送回合结束(end of turn),因此必须清空队列才能实现打断。”然而,即使不打断,回合结束也会在每次回答时到来。这就相当于每次回答结束时都会丢弃尚未播放的声音。SDK 版示例的
第 187–199 行
已经修改为了 interrupted 。
综上所述,WebSocket 版示例在协议更改时没有随之更新,只有同一仓库中的 SDK 版示例进行了更新。参考文档 mediaChunks 虽然已被标记为弃用,但WebSocket版本仍发送到该字段,并且中断处理在SDK版本更改后仍然停留在旧的方式(turnComplete 中清空)。两套示例做同一事情时只修改其中一侧的情况似乎很常见。贡献日志的草稿备忘录中写道,WebSocket版本的最后一次实际变更是 2025-11,此后仅修改了模型名称。
3. 修改了什么:两个提交
第一个提交(10-17)修复了第2节中的四项内容(+24/−30)。
- msg = {"realtime_input": {"media_chunks": [frame]}}
+ msg = {"realtime_input": {"video": frame}}
...
- "media_chunks": [ { "data": ..., "mime_type": "audio/pcm" } ]
+ "audio": {
+ "data": base64.b64encode(data).decode(),
+ "mime_type": f"audio/pcm;rate={SEND_SAMPLE_RATE}",
+ }
...
+ # If you interrupt the model, the server sets `interrupted`.
+ if server_content.get("interrupted"):
+ while not self.audio_in_queue.empty():
+ self.audio_in_queue.get_nowait()
+
+ # A single message can carry several parts, so play all of them.
+ for part in server_content.get("modelTurn", {}).get("parts", []):
+ if b64data := part.get("inlineData", {}).get("data"):
+ self.audio_in_queue.put_nowait(base64.b64decode(b64data))
+
+ if server_content.get("turnComplete"):
+ print("\nEnd of turn")
由于文件使用的是 CRLF 行尾,补丁也保留了 CRLF。其中有一个没有故意加入。这个示例将 API 密钥作为 WebSocket 地址的 ?key= 发送,而 SDK 在同一个
WebSocket 中将密钥作为 x-goog-api-key 头发送。地址很容易留在日志和异常消息中。不过,由于我们没有重新验证直接使用 WebSocket 的一端是否支持头部认证,且参考文档中也没有记录,因此我们没有将其放入 PR 中,仅在正文末尾作为“另行讨论的话题”记录。日志中没有以此建议发起过 Issue 的记录。
第二个提交(10-19)反映了评审意见(第4节)。将两处使用 raw_response.decode("ascii") 解析 WebSocket 帧的地方(配置响应和接收循环)改为了 json.loads(raw_response) 。 json.loads 接收字节并将其解析为 UTF-8。按照机器人的建议,将 .get("modelTurn", {}) 改为了 (server_content.get("modelTurn") or {}) ,使得当值显式为 null 时也作为空字典处理(inlineData 也是如此)。为了防止麦克风流未打开时在 close() 处崩溃, if self.audio_stream: 也增加了。最终 diff 为新增 29 行,删除 34 行。
使用 AI 的情况在 PR 正文末尾用一句话说明:“在 AI 助手 (Claude) 的帮助下准备,并经我审查和确认。”贡献日志的草稿备忘录中写道,此仓库的贡献指南中
没有 AI 政策措辞。第一个提交中没有 AI 脚注,只有 DCO 签名和 SSH 签名,第二个提交中带有 Assisted-by: Claude 和 DCO 签名,但没有 SSH
签名。该仓库要求谷歌 CLA,且通过了相关检查。
4. 反应:“比看上去更大的修改”
| 时间 | 日 |
|---|---|
| 10-17 17:22 | 公开 PR。标签 status:awaiting review·component:quickstarts |
| 10-17 17:22–17:24 | 审查机器人 (Gemini Code Assist) 给出了总结和一个行建议: modelTurn·inlineData 为显式 null 时, .get(…, {}) 将返回 None ,因此 (… or {}) 来使用 |
| 10-19 19:57 | 审阅者“更改请求”。要求附带直接复现结果再修改一行 |
| 10-19 22:03 | 第二个提交,22:13 回复 |
| 10-20 01:01 | 补充了使用假 WebSocket 运行结果的回复 |
| 10-20 06:01 | 另一位评审员给出了“无阻碍问题”的评审,进行了八项离线检查 |
| 10-22 16:51 | 第一位评审员通过实际语音对话测试到最后,并给出了“LGTM” |
| 10-22 16:57 | 请求将“变更请求”状态更改 |
| 10-22 18:29, 19:19 | 评审员向维护者请求评审,并将其更改为批准 |
| 10-27 18:04 | 该维护者进行了 squash 合并 |
这个变更请求是一个令人感激的变更请求。评审员表示这是一个“比看上去更大的修改”,并 gemini-3.8-live 表示曾通过 WebSocket 直接向模型发送了相同的语音片段。
media_chunks 使用 (rate= 无论有无) 都没有任何反应,而使用 realtime_input.audio 则得到了正确的回答。也就是说,在 main 的示例中,麦克风实际上已经
失效了。他还表示确认了 realtime_input.video 和 interrupted 的处理。并且他还请求了另一行代码。服务器默认会发送回答的字符(outputTranscription),
如果将帧解包为 ASCII,在非英文的回答中就会崩溃。他提到在 main 中输入 “Say hello in Japanese.” 即可重现。
他在同一天的第二个提交中进行了修复,并回复称配置响应中也有相同的 decode("ascii") ,因此一起进行了修复。此时他写道,仅在本地确认了包含日语字符的一个帧现在能够正常解包,并未在实际服务器上重新运行。几小时后,他又补充了将接收循环本身连接到虚拟 WebSocket(无网络、无音频,Python 3.12)运行的结果。当放入日语字符帧、 modelTurn: null 帧、 inlineData: null 片段、 turnComplete 这四个内容时,修复前会在第一个帧处
被
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe3 阻断,修复后这四个都能正常处理,音频片段到达了播放队列。这里他再次写道“未在实际服务器上运行”。这两条回复作为草稿留在了原始记录中。
另一位评审员执行了八项无需实际设备即可进行的检查,称“没有阻碍性问题”。他将示例的类 ast 提取出来,连接了虚拟相机、屏幕和麦克风,
并确认了多个片段、二进制与文本帧、 turnComplete 之后的剩余播放、 interrupted 清空的播放以及 Unicode。他还表示如果在 main 上运行相同的检查,
旧问题会再次出现。并且附带了说明,表示没有使用实际的麦克风、扬声器和服务器进行运行。
最后的确认由第一位评审员完成。他用第二个提交测试到了最后,确认可以通过语音进行长达 5 分钟的对话(在 main 中模型完全听不到麦克风),发送屏幕画面能够被识别,即使以第一条消息让其用日语打招呼,也能完成播放和“End of turn”的输出。他还指出了一同找到的配置响应解码。由于该评审员的评审状态仍为“变更请求”从而导致合并被阻止,因此在致谢的同时请求更改了一次状态,2小时22分钟后状态变更为批准。合并在五天后由维护者完成。合并提交 db6fb41f 是将两个提交合并的 squash,
作者是我们的账号。
5. 心得体会:开源贡献要领
- 「两套示例互为基准。”等同一个仓库的 SDK 版示例已经做了正确的处理,因此“符合参考”比起“与旁边的示例保持一致”有着更短的依据。
- 「没测过的就写没测过,评审员会帮着测。”我们没能把这个示例运行到最后,并如实记录了这一点。评审员亲自测试后,发现了“main 中的麦克风已经失效”这一我们并未主张的更重大事实。相反,如果夸大说“运行过了”,这个对话大概就不会存在了。
- 「机器人提出的微小建议直接采纳。」
(… or {})虽然看似微不足道,但评审员也指出了相同的内容,并表示“虽然是可选的,但这也是机器人的指出”。正确的指出不加争论地采纳,可以缩短评审流程。 - 「“变更请求”状态不会自动解除。即使评审员在评论中说了 LGTM,如果评审状态保持不变,合并仍会被阻止。接下来的做法是向对方表示感谢,并礼貌地请他们更改状态。
- 「同时检查每个提交的声明和签名。第一个提交缺少 AI 尾注,第二个提交缺少 SSH 签名。由于正文中作了说明,所以这不是个问题,但这与原则不符。
Titi 的一言。 对讲机使用手册上写着“请用旧按钮说话”。但那个按钮现在已经没有连接到任何东西了。此外,每当对方说完话,它就会删掉还没听过的话。我递过去一张说明换成新按钮的字条后,老师亲自拿着对讲机试了一下,说:“这比想象的要严重啊,用旧按钮根本听不见!”而且他还发现用日语回答会让对讲机卡住。连那个也修复后,我们聊了 5 分钟,他还给了我一个“赞”。
链接
所有资料均于 2026-10-29 查阅。
- PR: google-gemini/cookbook#1433 (合并提交
db6fb41f) - 修复后的示例(基础提交):
quickstarts/websockets/Get_started_LiveAPI.py - SDK 版示例(基础提交):
quickstarts/Get_started_LiveAPI.py第 187–199 行 - Live API 参考: https://ai.google.dev/api/live#BidiGenerateContentRealtimeInput