Google ADK 2.6.2 state_delta 在 Runner.run_async 无 new_message 恢复时未写入 session.state 的实测封面 - XBSTACK

Google ADK state_delta 不生效怎么办?Runner.run_async 恢复时状态更新被静默忽略实测

Release Date
2026-08-09
Reading Time
9分钟
Content Size
5,085 chars
Google ADK
Runner.run_async
state_delta
invocation_id
SessionService
Resumability
Python
AI Agent
Xiaobai's Note / 实验室笔记

实验使用 macOS 26.5.2 arm64、Python 3.10.2、google-adk 2.6.2、ResumabilityConfig(is_resumable=True) 与 InMemorySessionService。无 API Key、无真实模型请求。workaround 尚未在数据库或托管 SessionService 上验证。

适合谁读

  • 使用 Google ADK 构建可恢复 Agent、审批流和长任务工作流的 Python 开发者。
  • 需要从外部 UI、Webhook 或后台任务恢复 invocation 并只更新 state 的平台团队。
  • 正在排查 session.state 未按预期更新但 Runner 又没有抛异常的工程团队。

如果你在 Google ADK 里做可恢复 Agent,有一个问题比直接报错更难排查:Runner.run_async() 没有抛异常,invocation_id 也能正常恢复,但你同时传进去的 state_delta 最后没有进入 session.state

我在本地用 google-adk==2.6.2 做了四组离线对照。结果非常稳定:只要是“通过 invocation_id 恢复 + 传 state_delta + 不传 new_message”,无论走 LlmAgent 的 Node 路径还是普通 BaseAgent 的 legacy 路径,状态都没有写进去;而相同代码只要同时带上 new_messagestate_delta 就会正常进入 Session。

这不是模型问题,也不需要 Gemini/OpenAI API 才能触发。我的复现使用了一个本地 Echo stub model,没有 API Key、没有外部模型调用。

先给结论

我这次实测环境是:

  • macOS 26.5.2 arm64
  • Python 3.10.2
  • google-adk==2.6.2
  • ResumabilityConfig(is_resumable=True)
  • InMemorySessionService
  • 离线 Echo model

四组结果如下:

Runner 路径resume 时有 new_messagestate_delta 是否写入结果
Node / LlmAgent复现失败路径
Node / LlmAgent正常对照
legacy / BaseAgent复现失败路径
legacy / BaseAgent正常对照

Google ADK state_delta 四组实测结果:Node 与 legacy 路径在有无 new_message 时的状态写入对比

图 2:四组本地对照。无 new_message 时 Node 与 legacy 两条路径都没有应用 state_delta;加入 new_message 后,相同 delta 都能进入 session.state

实际输出:

node-no-message        new_message=False applied=False state={}
node-with-message      new_message=True  applied=True  state={'resumed_key': 'resumed_value'}
legacy-no-message      new_message=False applied=False state={}
legacy-with-message    new_message=True  applied=True  state={'resumed_key': 'resumed_value'}

所以如果你的代码类似下面这样:

async for _ in runner.run_async(
    user_id=user_id,
    session_id=session_id,
    invocation_id=invocation_id,
    state_delta={"approved": True},
):
    pass

在我测试的 ADK 2.6.2 中,调用本身可以继续执行,但 approved=True 不一定会被写进 Session。最危险的地方就在这里:它不是一个显式异常,而是状态更新被静默忽略。

为什么这个问题容易被误判

第一次看到这种现象时,很容易怀疑三个地方:是不是 InMemorySessionService 没有持久化,是不是恢复时拿错了 invocation_id,或者是不是 Agent 的 callback 又把状态覆盖了。

但四组对照把范围缩得很小。

同样的 SessionService、同样的 state_delta、同样的 Agent,只改变一个变量——resume 时有没有 new_message——结果就从 state={} 变成了:

{"resumed_key": "resumed_value"}

这说明问题不是“ADK 完全不能在 resume 时更新 state”,而是 state_delta 当前依附在某条特定的事件写入路径上。

我在 2.6.2 源码里看到的关键路径

我直接检查了本地安装的 google-adk==2.6.2

在 Runner 的 Node 执行路径里,用户事件只有在 new_message 存在时才会追加:

if new_message:
    user_event = await self._append_user_event(
        ic, new_message, state_delta=state_delta
    )

_append_user_event() 才会把 state_delta 包装进 EventActions

Event(
    invocation_id=ic.invocation_id,
    author="user",
    actions=EventActions(state_delta=state_delta),
    content=content,
)

随后再通过:

self.session_service.append_event(...)

把 Event 写进 Session。

这就解释了为什么“有 new_message”的对照组能更新状态:state_delta 跟着 user event 一起进入了 SessionService。而在 resume 时没有 new_message,这条 append-user-event 路径不会执行,传入的 delta 也没有另一条独立持久化路径接住它。

Google ADK Runner.run_async 无 new_message 时 state_delta 被忽略,以及显式 append_event 临时方案的流程对比

图 3:失败路径与临时 workaround。左侧是 Runner.run_async(invocation_id + state_delta) 在无 new_message 时跳过 user event;右侧是先显式追加携带 EventActions(state_delta=...) 的无 content 事件,再恢复 invocation。

Google ADK 官方 Issue #6644 对这个边界的描述与本地结果一致:Runner.run_async() 接受 state_delta 参数,但在 resumable invocation 通过 invocation_id 恢复且没有 new_message 时,delta 会被接受然后静默丢弃。Issue 于 2026 年 8 月 8 日提交;截至 2026 年 8 月 9 日仍为 Open,页面没有关联 branch 或 pull request。也就是说,下面的 workaround 只能作为临时处理,不能写成“官方已经修复”。

不建议用“伪造一个 new_message”当默认修复

看到上面的对照结果以后,一个很自然的想法是:既然有 new_message 就正常,那每次 resume 都塞一条空消息不就好了?

我不建议把这个作为生产默认方案。

new_message 不只是一个触发 state_delta 的开关,它本身属于会话事件和 Agent 输入语义的一部分。为了让 state 写进去而制造一条没有业务意义的用户消息,会改变 history、callback、审计以及后续模型上下文。在更复杂的 Human-in-the-loop 或 long-running workflow 中,这类“修 Bug 用假消息”的做法很容易在后面制造第二个状态问题。

更稳妥的临时处理,是把“更新 Session state”和“恢复 invocation”拆成两个明确动作。

我验证通过的临时 workaround

ADK 的 Session 状态本身就是通过 Event 上的 EventActions(state_delta=...) 更新的。因此我增加了另一组实验:在 resume 之前,先显式向当前 SessionService 追加一个不带 content 的 state event,再执行没有 new_message 的 resume。

核心代码:

from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions

session = await session_service.get_session(
    app_name=app_name,
    user_id=user_id,
    session_id=session_id,
)

await session_service.append_event(
    session=session,
    event=Event(
        invocation_id=invocation_id,
        author="user",
        actions=EventActions(
            state_delta={"resumed_key": "resumed_value"}
        ),
    ),
)

async for _ in runner.run_async(
    user_id=user_id,
    session_id=session_id,
    invocation_id=invocation_id,
):
    pass

我分别在 Node 和 legacy 两条路径运行,结果都是:

node       applied=True state={'resumed_key': 'resumed_value'}
legacy     applied=True state={'resumed_key': 'resumed_value'}

也就是说,在这次 2.6.2 实验中,显式通过当前 SessionService 写入携带 state_delta 的 content-less Event,可以绕过 Runner.run_async(state_delta=...) 在无 new_message resume 场景中的丢失问题。

这仍然只是临时 workaround,不是上游修复。特别是如果你使用的不是 InMemorySessionService,而是数据库或平台托管的持久化 SessionService,应该重新验证事件持久化、幂等、审计和并发语义,不能直接把我的本地结果等同于所有后端。

哪些场景最需要检查这个 Bug

如果你只是普通聊天,每轮都有新的用户消息,这个问题可能长期不会出现。真正容易踩坑的是可恢复执行:审批结果从外部系统回来、后台任务完成后恢复、人工操作只改变 state 而没有新的自然语言输入,或者你把 invocation_id 当成长任务恢复句柄使用。

例如:

state_delta={
    "approval_status": "approved",
    "reviewer": "human-42",
}

如果这次恢复没有 new_message,业务代码看到 run_async() 没报错,很可能继续往后执行;但下一节点读取 session.state["approval_status"] 时仍然拿不到更新。这类错误比立即抛 ValueError 更难发现,因为日志上看起来“恢复成功了”。

生产环境至少应该给 resume state 增加一次写后校验:

session = await session_service.get_session(...)
assert session.state.get("approval_status") == "approved"

如果状态决定后续是否执行支付、发送、删除、审批等高风险 Tool,这个校验尤其重要。

这是不是所有 Google ADK 版本都有?

不能这么下结论。

本文自己的实验边界是 google-adk==2.6.2。官方 #6644 的复现同样基于 2.6.2 对应代码,并指出这个无 new_message 的路径尚未应用 delta;但 Issue 状态和后续 release 随时可能变化。

因此如果你看到这篇文章时已经升级到更高版本,先做两件事:

  1. 检查 #6644 是否已经关闭,以及关联 PR 是否进入你的版本;
  2. 直接跑四组最小对照,而不是看到版本号不同就默认“已经修了”。

这个问题非常适合做回归测试,因为它完全不需要真实模型调用。

最小排查清单

遇到 Google ADK state_delta 不生效,可以按这个顺序检查:

  1. 确认当前 google-adk 版本;
  2. 确认调用是否包含 invocation_id
  3. 确认 App 是否启用了 resumability;
  4. 确认 resume 时是否没有 new_message
  5. resume 后重新读取 session.state,不要只看 run_async() 是否报错;
  6. 用“有 new_message / 无 new_message”做最小 A/B 对照;
  7. 如果命中 2.6.2 这条失败路径,优先升级到已经确认修复的版本;尚无可用修复版本时,再评估显式 state event 的临时 workaround。

FAQ

为什么 run_async() 没报错,但 state 还是旧的?

因为这次问题发生在状态持久化路径,而不是参数校验路径。invocation_id 可以让 resumable invocation 合法继续,但在 2.6.2 的这条无 new_message 路径里,state_delta 没有进入负责更新 Session 的 user event。

加一个空 new_message 可以吗?

从我的对照实验看,“存在 new_message”确实会让 delta 被写入,但不建议为了触发状态更新伪造用户消息。它会改变会话事件和上下文语义。

显式 SessionService.append_event() 是官方修复吗?

不是。本文只验证它在本地 2.6.2、InMemorySessionService、Node/legacy 两条实验路径中有效。它更适合作为理解底层状态机制和临时绕过的参考。

这个 Bug 与模型有关吗?

我的复现不使用外部模型 API,Node 路径使用离线 Echo stub,legacy 路径甚至不依赖 LLM,因此这次失败边界不需要通过 Gemini、OpenAI 或 LiteLLM 才能触发。

最后

Google ADK 这类可恢复执行框架里,最值得测试的往往不是“Agent 能不能回答”,而是暂停、恢复、状态更新、幂等这些没有漂亮 Demo 的路径。

这次问题就是一个典型例子:调用不报错、invocation 能继续,但决定后续业务行为的 state 没有真正写进去。

如果你的 Agent 有审批、长任务或后台恢复逻辑,建议把“resume 后重新读取并断言关键 state”直接做成回归测试,而不是把 run_async() 正常返回当作状态已经持久化的证据。


相关链接

实验资产

本次完整复现与 workaround 文件:

  • repro.py:四组失败/正常对照
  • workaround.py:content-less state event workaround
  • requirements.txt:固定 google-adk==2.6.2
  • README.md:环境、结果和证据边界
专题入口 / AI Agent Hub

从单个 Agent 问题继续进入完整生产体系

AI Agent 专题统一组织架构、记忆、工具调用、评测、安全、部署和多智能体协作,让每篇文章都回到明确的主题主页面。

下一步阅读

返回专题入口 →
小白

小白

Full-Stack AI Engineer

小白,全栈 AI 工程师,持续构建生产级 Agent 系统、产品工具与独立软件资产。

了解小白与 XBSTACK →

喜欢这篇文章?
加入小白实验室的周刊

每期只整理 AI 工程变化、真实故障、可复现实验、值得尝试的工具和 XBSTACK 新资产,不做泛新闻汇总,也不为周更凑数。

Comments

参与讨论

问题、验证与勘误

登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。

登录评论 审核后公开
正在加载评论区…