XBSTACK XBSTACK
小白 / Xiaobai

小白 / Xiaobai

开发者 · 产品构建者

持续构建 AI 工程系统、开发者工具与长期数字资产。

关于作者与 XBSTACK →
Google ADK 2.6.2 state_delta 在 Runner.run_async 无 new_message 恢复时未写入 session.state 的实测封面

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 消息转换回归。

发布 · 2026-08-0911 分钟阅读XBSTACK 原创
#Google ADK#Runner.run_async#state_delta#invocation_id#SessionService#Resumability#A2A#Human-in-the-loop

如果你在 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,这个校验尤其重要。

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 的 Partfunction response 语义是否保留
google-adk==2.6.1DataPartmetadata.adk_type=function_response
google-adk==2.7.0TextPart,内容变成 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.jsonlogs/2.7.0.jsonlogs/verification.json 可直接复核版本差异。

这是不是所有 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 专题统一组织架构、记忆、工具调用、评测、安全、部署和多智能体协作,让每篇文章都回到明确的主题主页面。

继续阅读

返回专题 →
AI Agent 数据分析实战教程:构建自动化金融研报与决策系统AI Agent 数据分析实战教程:详细讲解 AI 智能体在数据分析中的工程应用,包括自动分析流程、工具调用、安全沙箱和实际案例,揭示如何利用智能体实现可审计的数据分析闭环。Semantic Kernel 实战:Plugins、Function Calling 与 Agent 编排怎么做Semantic Kernel 当前怎么用?本文按 Kernel、Plugins、KernelFunction、automatic function calling、依赖注入、权限与 Agent Framework 迁移边界重写,明确 Stepwise/Handlebars 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 区别、权限隔离、记忆更新、遗忘机制、审计日志与评估指标,帮助开发者构建可控的智能体记忆系统。

AI 工程周报

只发真正改变工程判断的变化、故障、实验和新资产。

评论与补充证据

参与讨论

问题、验证与勘误

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

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