小白 / Xiaobai
开发者 · 产品构建者
持续构建 AI 工程系统、开发者工具与长期数字资产。
关于作者与 XBSTACK →
Google ADK 恢复问题实测:state_delta 丢失与 2.7.0 A2A HITL 回归
Google ADK state_delta 不生效怎么办?实测 2.6.2 的 state-only resume 状态丢失,并对比 2.6.1 与 2.7.0 的 A2A HITL 消息转换回归。
如果你在 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,这个校验尤其重要。
2026-08-14 补充:ADK 2.7.0 的 A2A 审批恢复还有另一条独立回归
这次更新我没有为新 Issue 再拆一个页面,而是继续放在现有的 Google ADK 恢复问题页里。原因很简单:它仍然属于“暂停以后如何正确恢复”的生产边界,只是故障点从 Runner.run_async(state_delta=...) 换成了 RemoteA2aAgent 对 relayed human-in-the-loop 响应的序列化。
我按上游 Issue #6721 的最小输入重新做了一组完全离线的版本对照,没有调用远端 Agent,也没有模型 API。固定 a2a-sdk==0.3.26,给 RemoteA2aAgent._create_a2a_request_for_user_function_response() 相同的两条事件:第一条是远端 Agent relay 回来的 adk_request_confirmation function call,第二条是用户针对同一个 call id 返回的 FunctionResponse。
结果如下:
| 版本 | 发给远端 Agent 的 Part | function response 语义是否保留 |
|---|---|---|
google-adk==2.6.1 | DataPart,metadata.adk_type=function_response | 是 |
google-adk==2.7.0 | TextPart,内容变成 JSON 文本 | 否 |
2.6.1 的实际输出仍然携带 call id、adk_request_confirmation 名称和结构化 response;2.7.0 对完全相同的输入则只留下了一段文本 JSON。也就是说,至少在这条消息构造路径上,2.7.0 已经不再把“对远端暂停调用的回答”作为 function response 往回传,而是把它转换成了普通文本。
这个结论和本文前面的 state_delta Bug 不能混为一谈:前者是 2.6.2 Runner.run_async 的 state-only resume 持久化问题;这里验证的是 2.7.0 RemoteA2aAgent 的 A2A relayed approval 消息转换。两者共享“恢复”这个生产场景,但根因、API 和受影响版本不同。
我也需要把证据边界写清楚:本地实验验证的是消息转换层,没有启动三个真实 A2A Agent,也没有验证最终业务 Tool 是否执行。因此这里不会把“2.7.0 一定导致审批工具跳过”写成本地实测结论。真正能确定的是:2.6.1 的结构化 FunctionResponse 在 2.7.0 中变成了 TextPart,这已经足够作为升级门禁。
如果你的生产系统依赖 A2A + human approval,升级到 2.7.0 前至少增加一条回归断言:用户批准后,发回远端 peer 的 part 必须仍然是可关联原 call id 的 function response,而不能只是普通文本。没有通过这条检查之前,不要仅凭“上层 workflow 继续运行”就判定审批恢复成功。
本次对照脚本和结果保存在 experiments/google-adk-a2a-relayed-hitl-resume-repro/,其中 logs/2.6.1.json、logs/2.7.0.json 和 logs/verification.json 可直接复核版本差异。
这是不是所有 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 工程周报
只发真正改变工程判断的变化、故障、实验和新资产。
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。