Google ADK state_delta 不生效怎么办?Runner.run_async 恢复时状态更新被静默忽略实测
实验使用 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_message,state_delta 就会正常进入 Session。
这不是模型问题,也不需要 Gemini/OpenAI API 才能触发。我的复现使用了一个本地 Echo stub model,没有 API Key、没有外部模型调用。
先给结论
我这次实测环境是:
- macOS 26.5.2 arm64
- Python 3.10.2
google-adk==2.6.2ResumabilityConfig(is_resumable=True)InMemorySessionService- 离线 Echo model
四组结果如下:
| Runner 路径 | resume 时有 new_message | state_delta 是否写入 | 结果 |
|---|---|---|---|
Node / LlmAgent | 否 | 否 | 复现失败路径 |
Node / LlmAgent | 是 | 是 | 正常对照 |
legacy / BaseAgent | 否 | 否 | 复现失败路径 |
legacy / BaseAgent | 是 | 是 | 正常对照 |

图 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 也没有另一条独立持久化路径接住它。

图 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 随时可能变化。
因此如果你看到这篇文章时已经升级到更高版本,先做两件事:
- 检查 #6644 是否已经关闭,以及关联 PR 是否进入你的版本;
- 直接跑四组最小对照,而不是看到版本号不同就默认“已经修了”。
这个问题非常适合做回归测试,因为它完全不需要真实模型调用。
最小排查清单
遇到 Google ADK state_delta 不生效,可以按这个顺序检查:
- 确认当前
google-adk版本; - 确认调用是否包含
invocation_id; - 确认 App 是否启用了 resumability;
- 确认 resume 时是否没有
new_message; - resume 后重新读取
session.state,不要只看run_async()是否报错; - 用“有
new_message/ 无new_message”做最小 A/B 对照; - 如果命中 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() 正常返回当作状态已经持久化的证据。
相关链接
- Google ADK Issue #6644: https://github.com/google/adk-python/issues/6644
- Google ADK repository: https://github.com/google/adk-python
- 如果你要比较不同 Agent 框架在状态、恢复与编排上的取舍,可以继续看 AI Agent 框架选型:LangChain / LangGraph、AutoGen 与 CrewAI。
- 如果你的恢复流程同时涉及人工审批与跨进程继续执行,可以参考 OpenAI Agents SDK RunState 审批与恢复实战。
- 如果 state 更新之后会触发支付、发送、删除等高风险工具,建议同时落实 AI Agent Tool Authorization Policy Gate。
- XBSTACK AI Agent 专题 · XBSTACK AI 工程总入口
实验资产
本次完整复现与 workaround 文件:
repro.py:四组失败/正常对照workaround.py:content-less state event workaroundrequirements.txt:固定google-adk==2.6.2README.md:环境、结果和证据边界
从单个 Agent 问题继续进入完整生产体系
AI Agent 专题统一组织架构、记忆、工具调用、评测、安全、部署和多智能体协作,让每篇文章都回到明确的主题主页面。
下一步阅读
返回专题入口 →
AI Agent 数据分析实战教程:构建自动化金融研报与决策系统
AI Agent 数据分析实战教程:详细讲解 AI 智能体在数据分析中的工程应用,包括自动分析流程、工具调用、安全沙箱和实际案例,揭示如何利用智能体实现可审计的数据分析闭环。
Semantic Kernel 实战:构建工业级 AI 插件系统与 Planner 调度中枢
Semantic Kernel 实战:深度拆解 AI 插件系统的工业级架构,揭秘如何通过 Planner 实现复杂任务的自动化调度与能力解耦。
OpenAI Responses API 中断流后,为什么报 No tool call found for function call output?
OpenAI Responses API 流式返回 function_call 后,如果客户端提前关闭 Stream,call_id 可能没有写入 Conversation,下一轮提交 function_call_output 就会报 400。本文结合官方 Issue 和本地状态机实验,给出判断、恢复、幂等与生产修复方案。
AI Agent Memory System 实战:记忆分层、用户隔离、遗忘机制与长期状态管理
AI Agent Memory System 实战:系统拆解 AI Agent Memory System 的生产级设计方法,覆盖短期状态、长期记忆、用户画像、业务记忆、Checkpoint、RAG 区别、权限隔离、记忆更新、遗忘机制、审计日志与评估指标,帮助开发者构建可控的智能体记忆系统。
小白
Full-Stack AI Engineer
小白,全栈 AI 工程师,持续构建生产级 Agent 系统、产品工具与独立软件资产。
了解小白与 XBSTACK →
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。