GEOZ

从187行补丁到2分钟限制:ADK 2.x实时会话迁移踩坑记

2026/8/14
从187行补丁到2分钟限制:ADK 2.x实时会话迁移踩坑记

AIAI Summary (BLUF)

从 ADK 1.x 升级到 2.6.3 后,成功删除了长达 187 行的 monkey patch,但迁移过程并非一帆风顺:keepalive 调用因类型错误导致十秒后崩溃,视频帧率超过 API 限制,音视频会话仅有两分钟上限且未处理中断。本文详细记录了这些陷阱及解决方案,并提供了关键代码示例。

核心洞察

删掉 187 行猴子补丁只是开头。后面才是花时间的活:把文档和源码摊开逐行对照,找出一堆从第一版就存在、测试和 lint 全都没拦住的错。你要迁 Live agent 的话,后半段的检查清单比迁移本身值钱。

没有。删掉这 187 行补丁,是这次升级 ADK 2.x 最大的收获。但不是唯一的收获,也不是最后一件要修的事。

核心结论

  1. 升级到 google-adk 2.6.3 后,187 行的 patch_adk.py 可以直接删除;ADK 原生按 mime type 将 Blob 路由到 audio=video=text=。但 send_realtime() 现在只接受 types.Blob,keepalive 的文本提示必须改走 send_content();否则不会在启动时报错,而是约 10 秒后第一次触发 keepalive 时才失败。

  2. 视频帧率此前以 2 FPS 运行,是 Gemini Live API 文档上限(最大每秒 1 帧)的两倍。现在默认改为 1.0 FPS,并在代码中写死上限;即使设置 VIDEO_FPS=3,实际仍按 1.0 运行。

  3. 音视频会话的硬性上限是 2 分钟。通过 RunConfig.context_window_compression 开启滑动窗口可以解除该限制;该项目现已显式配置 ContextWindowCompressionConfig(sliding_window=SlidingWindow())

  4. 从第一版起,音频转写日志就从未输出过一行:代码把 RunConfig 上的 input_audio_transcription 当成事件字段读取,并访问了不存在的 final_transcript。正确字段是事件上的 input_transcription,用 finished 做门槛、取 text;修复后连接时输出 GEMINI TRANSCRIPT: Scanner Online.

  5. 后台标签页中 requestAnimationFrame 被限流到零,而音频 AudioWorklet 继续运行,导致视频静默停止、WebSocket 仍计费。改为自调度 setTimeout 后,后台标签页仍能以约 1 FPS 继续采集,避免完全中断。

这个 Agent 是干什么的

这个项目是一个生物识别安全扫描器,专门用来练 Gemini Live API 里那些文本聊天机器人碰不到的功能。浏览器采集摄像头和麦克风,通过一条 WebSocket 把两路数据流到 FastAPI 后端,后端再用 Agent Development Kit 转发给 Gemini 3.1 Flash Live。模型看视频流,数你举了几根手指,然后调工具。

注册了三个工具:

  • report_digit(count):识别到的手指数量,驱动 UI
  • trigger_system_error():检测到不雅手势时触发,直接结束会话
  • trigger_heavy_metal_mode():检测到“恶魔角”手势时触发,一个秘密开关

传输层刻意做得简单。二进制 WebSocket 帧带 1 字节类型前缀,1 是音频,2 是 JPEG。上行 16 kHz PCM,下行 24 kHz PCM,用 AudioWorklet 播放,主线程保持空闲。本地直接 make run,或者 make deploy 部署到 Cloud Run。

git clone https://github.com/xbill9/way-back-home
cd way-back-home/level_3_new

仓库里两个版本并存:level_3 是原始设计,level_3_new 是现在的版本。这篇文章大部分内容就是这两个版本的 diff。

Level 3 原本需要什么才能跑

最初的构建跑在 google-adk 1.27.2 上,能跑,但全靠旁边躺着一个 187 行的 patch_adk.py,在 import 时抢先应用。

它解决的问题是真实的。Gemini 3.1 废弃了 media_chunks,也就是 1.x ADK 发送实时媒体用的字段。1.x ADK 跟 3.1 Live 模型对话,发出去的还是废弃格式,什么有用的都收不回来。这个补丁给三个调用点打了猴子补丁来做转换:

# level_3_gemini/backend/app/patch_adk.py
if hasattr(rt_input, "media_chunks") and rt_input.media_chunks:
    logger.info("[PATCH] Unrolling 'media_chunks' from realtime_input.")
    for chunk in rt_input.media_chunks:
        ...
        await self.send_realtime_input(audio=chunk)
        ...
        await self.send_realtime_input(video=chunk)

三个目标分别是 live.AsyncSession.send_realtime_input,把 media_chunks 展开成新的类型化关键字;GeminiLlmConnection.send_realtime,按 mime type 把每个 blob 路由到 audio=video=text=;还有 AudioCacheManager.cache_audio,它原本需要防一手 NoneType blob,否则会直接抛异常。

补丁能用,但也是笔负债。给框架打猴子补丁,每次升级都是在赌:补丁可能变得多余,可能变得错误,也可能因为被包裹的方法改名而悄悄失效。之前那篇记录的结尾建议是:ADK 原生支持这个模型的那一刻,立刻删掉它。

ADK 2.x 原生支持了什么

这个时刻在 google-adk 2.6.3 到了。patch_adk.py 直接删除。框架自己会做路由,检测模型代次然后分发:

# google/adk/models/gemini_llm_connection.py, send_realtime()
if isinstance(input, types.Blob):
  if self._is_gemini_3_x_live or self._is_gemini_3_5_live_translate:
    if input.mime_type and input.mime_type.startswith('audio/'):
      await self._gemini_session.send_realtime_input(audio=input)
    elif input.mime_type and input.mime_type.startswith('image/'):
      await self._gemini_session.send_realtime_input(video=input)
    else:
      logger.warning(
          'Blob not sent. Unknown or empty mime type for'
          ' send_realtime_input: %s',
          input.mime_type,
      )
  else:
    await self._gemini_session.send_realtime_input(media=input)

注意第三个分支。音频和图片的 mime type 被明确分发,其他的一律丢弃加警告,不猜。mime type 缺失或者不对的 blob 哪也去不了,唯一的痕迹是一条日志。

文本也走同样的路。单段文本 Content 在 3.x 模型上会路由到 send_realtime_input(text=...),不再作为 client content 发出去。这跟 Live API 自己的指引一致:send_client_content 只用于填充历史记录。

第一个和第二个补丁目标,到这里就全被上游接手了:有人维护,有人测试。第三个,cache_audio 上的 NoneType 防护,没有带过来。上游仍然在无保护地调用 len(audio_blob.data)。这个应用的代码路径里不会产生 data=None 的 blob,所以它保持删除状态,没必要为了预防加回去。

唯一翻车的地方

补丁一直盖着调用代码里的一个 bug。删掉它,bug 露出来了。这个 bug 原本就在,只是补丁让它没见过光。

LiveRequestQueue.send_realtime() 只收 types.Blob。旧补丁内部用了 model_construct,跳过了 Pydantic 校验,所以传一个裸字符串进去碰巧能跑。没有补丁,直接 ValidationError

出问题的调用点是 keepalive。这个项目在客户端静默超过十秒时会发一条文本提示,1.x 时代这个提示就是一个字符串,直接扔给 send_realtime()。文本现在必须走 send_content()

def send_text_stimulus(live_request_queue: LiveRequestQueue, text: str) -> None:
    live_request_queue.send_content(
        types.Content(role="user", parts=[types.Part(text=text)])
    )

这个删除最可能让 agent 悄悄掉线。启动时一切正常。keepalive 第一次触发才失败,会话已经看起来健康地跑了十秒。任何要把 Live agent 从 1.x 迁出来的人,先查这个调用点,再去碰别的。

还改了什么

迁移是重头戏,但不是全部。把 Live API 文档摊开,旁边放着源码,对着读,翻出来好几处从一开始就错的东西。构建、测试、lint 从来没对它们说过不。

视频跑在文档上限的两倍。 能力指南写得清楚:

视频帧以单张图片(如 JPEG 或 PNG)按特定帧率发送(最大每秒 1 帧)。

项目跑在 2 FPS,还允许通过环境变量调到 5。多余的帧没人会拒绝,所以一直没人发现。但这些帧计费,会话预算烧得快一倍。VIDEO_FPS 现在默认 1.0,并在代码里写死上限,VIDEO_FPS=3 只会得到 1.0,不会真的按 3 跑。文档写了的限制,代码不强制,2 FPS 就是这么混进来的。

音频加视频的会话上限两分钟。 session 管理指南说:

纯音频会话限制 15 分钟,音视频会话限制 2 分钟。

上下文压缩能彻底解除这个上限。RunConfig.context_window_compression 默认是 None,得主动要:

context_window_compression=types.ContextWindowCompressionConfig(
    sliding_window=types.SlidingWindow(),
),

这个应用两路流都在持续推,所以从第一版起就被两分钟时钟管着。短测试够不到这个上限。演示时做完五个手势就到了。

中断在文档里有说明,代码里没处理。 用户打断模型时,模型会停止生成,但已经发出去的音频还躺在客户端的环形缓冲区里继续播。Live API 的指引是:收到中断,停止播放,清空队列。ADK 在事件上暴露了 interrupted 标记。后端把整个事件以 JSON 转发,所以这个标记早就到了浏览器,只是没人读。清空机制也早就存在。三行代码把它们接上了。

那条从来没执行过的日志

输入和输出音频转写,从项目第一版起就在 RunConfig 里开着。两者从来没产生过一行输出。

input_transcription = getattr(event, "input_audio_transcription", None)
if input_transcription and input_transcription.final_transcript:
    logger.info(f"USER TRANSCRIPT: {input_transcription.final_transcript}")

这里叠了两个错。input_audio_transcriptionRunConfig 上用来开启转写的字段,事件上装转写结果的是另一个字段。final_transcript 根本不是 types.Transcription 的成员,那上面是 textfinishedlanguage_codespeaker_labelwords

这两个错单独拎出来任何一个,都会抛 AttributeError,几分钟就修了。叠在一起,再躲在 getattr 默认值后面,就成了无声失败。条件永远是 None and ...,怎么都是假。

正确的字段名:

input_transcription = getattr(event, "input_transcription", None)
if input_transcription and input_transcription.finished:
    logger.info(f"USER TRANSCRIPT: {input_transcription.text}")

finished 做门槛是有意的。ADK 会先发 finished=False 的局部转写事件,最后发一条 finished=True 的累积事件。这样每个 turn 只出一条干净日志,不会按片段刷屏。依据是 run_live() 的 docstring:局部和非局部事件都会 yield 给调用方,但只有非局部事件会存进 session。

修好之后,连接时输出这一行,正好是 agent 指令里规定的开场白:

INFO - GEMINI TRANSCRIPT: Scanner Online.

切到后台就停的视频

原始设计用定时器抓帧:

intervalRef.current = setInterval(() => { /* capture, send */ }, 500);

重写之后换成了 requestAnimationFrame 加手动耗时检查。纸面上这是更好的原语。跟帧对齐,合成器没事做的时候自己歇着,配上 toBlob,不用 toDataURL,JPEG 编码不占主线程。

后台标签页里,requestAnimationFrame 被限流到零。

麦克风不会。它跑在音频线程的 AudioWorklet 里,浏览器会保活,保证切标签页时采集和播放不中断。结果是个不对称的静默故障:切走标签页,视频完全停,音频照常流。WebSocket 还开着,会话继续计费,手指检测,也就是这个应用存在的全部意义,停了。两边都没有任何报错。有一段 65 秒的会话,记了 8050 个音频包,零个视频帧。

定时器在后台标签页也会被限流,但大约一秒一次,不会到零。所以修法是自调度的 setTimeout

const captureFrame = () => {
    if (ws.current?.readyState === WebSocket.OPEN) { /* capture, send */ }
    if (intervalRef.current !== null) {
        intervalRef.current = setTimeout(captureFrame, frameIntervalRef.current);
    }
};

降到 1 FPS 左右,比完全停了强。每次 tick 重新读 interval,还保证了服务端 config 帧的权威性。rAF 版本做得到,普通 setInterval 做不到。

没有调用方的代码

四样东西直接删掉,统计下来删了九十行,加了一行。

base64 JSON 媒体通道。 它解码 JSON 里 type 为 audioimage 的负载。它原本就是原始设计的完整线上协议,媒体改成带类型前缀的二进制帧之后被遗弃了。两个测试一起删掉,因为测试锁的是实现细节,不是任何人依赖的契约。

proactivity 和 affective_dialog 两个查询参数。 声明在 WebSocket 端点上,写在 docstring 里,没有任何代码读它们。原始设计里它们是有用的,会喂给一个条件 RunConfig。Gemini 3.1 Flash Live 上线时两个特性都没带,config 删了,参数却活了下来。Live API 参考文档在限制列表里列了这两项,后面都跟着同一句指示:移除该功能的任何配置。

function-response 扫描。 它遍历 server_content.model_turn.partsfunction_responsemodel_turn 装的是模型输出,functionResponse 是客户端才发的东西。这个列表结构上永远是空的。

第二个通知通道,lastMessage。 每次匹配、系统错误、重金属触发都会更新它,从前端 hook 里导出,没有任何组件读。UI 靠回调驱动。

留在 ADK 2.x 的几条规则

ADK 2.0 把 agent 搬到了图引擎上,新的限制会静默失败,没有声响。动手写之前,有四条值得先知道:

  • 用普通的 Agent。 自定义 BaseNode 子类、重写 _run_async_impl()generate_content(),会被静默绕过。没有报错,没有警告,没有任何效果。
  • 不要手动往 session 追加事件。 这会绕过图引擎,破坏确定性。session 归 run_live() 管。
  • 小心大范围 except。 框架自己会捕获异常,用于重试和人工介入暂停。节点里一个宽泛的 except 会把框架的捕获盖掉。捕获 BaseException 直接让暂停失效。
  • run_live(session=...) 已弃用。user_idsession_id

这个项目四条都满足,所以升级风平浪静。普通的 AgentInMemorySessionService,没有手搓的事件,宽泛的异常处理都在传输层,不在图里。

2.x 有个新东西值得用。Runner 加了 auto_create_sessionrun_live() 内部本来就会调 get-or-create 辅助函数。没有这个 flag,session 不存在就直接 ValueError,所以之前的代码都是手写先查再建:

runner = Runner(
    app_name=APP_NAME,
    agent=root_agent,
    session_service=session_service,
    auto_create_session=True,
)

有一个建议可以不跟。Runner(app=App(...)) 现在被标成推荐写法,但 Runner(agent=..., app_name=...) 仍然支持,内部会包成 App,没有弃用警告。推荐和必需是两码事。

下次 ADK 发版之后,这些东西还成不成立,可以直接查,不用翻 changelog:

python -W error::DeprecationWarning -m pytest -q

现在跑是零 ADK 警告。以后每次升版本,就用这条命令验。

两个值得知道的坑

ADK 的 docstring 里写了一个不存在的字段。 run_live() 提到 RunConfig.save_live_model_audio_to_session。2.6.3 里真实的字段是 save_live_blobsave_live_audio。文档和装好的源码打架时,以源码为准,跑的是它。

测试套件全绿,能证明的没看上去那么多。 套件把 run_live() stub 掉了,这是它们能做到隔离的原因:不需要 API key,不碰网络,不花钱。代价是 session 创建、恢复、销毁从来没被跑过,套件照样绿,不管这些功能有没有坏。最可能搞挂一切 session 的两个改动,auto_create_sessioncontext_window_compression,是拿一次性 WebSocket 客户端连真实 API 验证的。

没变的部分

线上协议、agent 指令、工具定义、部署路径,原封不动地过了这次迁移。带 1 字节前缀的二进制帧,16 kHz 进 24 kHz 出,三个服务端工具,Secret Manager 管 API key。adk run 底下模型 ID 回退到 gemini-2.5-flash,因为 Live preview 模型调 generateContent 仍然 404。

大版本框架升级只碰了兼容层,这是最理想的结局。也再次说明,把 shim 隔离在单个文件里、文件名起得直白点,值得。

到底改了什么

  • patch_adk.py 删了。187 行,三个猴子补丁调用点,换成 ADK 2.6.3 原生路由。
  • 文本走 send_content()send_realtime() 只收 types.Blob。keepalive 是个容易坑人的调用点。
  • 视频降到 1 FPS,带硬顶。对照文档上限,不再翻倍。
  • 上下文压缩开了,音视频会话的两分钟上限解除。
  • 转写日志能用了。从第一版起,代码就在把 RunConfig 的字段名当成事件字段读。
  • 抓帧循环改回定时器。requestAnimationFrame 在后台标签页直接停摆,麦克风不停。
  • Barge-in 会清空播放队列。一个早就到达的标记,接上了早就存在的机制。
  • 没有调用方的九十行代码删了,覆盖它们的两个测试也删了。

总结

Agent Development Kit 2.x 让那个从最初构建就背着的兼容补丁变得没必要了。删掉它几乎全是减法,这是最舒服的升级方式。唯一咬人的是 send_realtime() 拒绝任何非 types.Blob 的输入。它不在启动时崩溃。它在会话跑了十秒、看似一切正常的时候,让 keepalive 挂掉。

更大的教训在迁移之后。框架升级告诉你什么编译不过了。什么还在编译但已经错了,它一个字不提。死分支能编译。空操作的日志行照样跑。被限流的回调干干净净地返回。文档没写的帧率,服务端照收。这些东西全都活过了迁移、测试套件、lint 配置,还有一场看起来完全正常的演示。每一处都是把文档和代码并排读出来的,不是跑出来的。

如果你也在跑自己的 Live agent,有三个检查加起来大约一小时:确认转写 handler 真的出过输出,确认切到后台标签页视频还在流,然后对着 session-limit 页面读你的 RunConfig

常见问题(FAQ)

升级到ADK 2.6.3后,keepalive调用为何崩溃?如何修复?

因为send_realtime()只接受types.Blob,旧补丁用model_construct绕过了校验。ADK 2.x中文本需改用send_content()发送Content对象。修复keepalive调用即可避免类型错误崩溃。

视频帧率超过API限制会有什么影响?如何正确配置?

Gemini Live API限制最大1 FPS,项目原本跑2 FPS,导致会话预算消耗加倍。应将VIDEO_FPS默认设为1.0并写死上限,即使设置更高也只按1 FPS运行。

音视频会话有两分钟上限,如何突破?

根据session管理指南,音视频会话限制2分钟,但通过RunConfig.context_window_compression可解除该上限。启用上下文压缩即可支持更长会话。

Roger深圳
本文由 Roger 审核,最后更新于 2026年8月14日
联系编辑 →
← 返回文章列表
分享到:微博

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。