GEOZ

ADK 上云后自家客户端反而连不上?跨云 A2A 互操作实测

2026/8/25
ADK 上云后自家客户端反而连不上?跨云 A2A 互操作实测

AIAI Summary (BLUF)

本文手把手演示了如何用一行代码将Google ADK智能体转换为A2A服务器并部署到Cloud Run,但部署后必须核验Agent Card,否则ADK自己的客户端将无法访问。同时,跨框架互操作时会遇到回复重复等问题,需要调整解析逻辑。

核心洞察

这篇文章最有意思的点是,Google 自家的 ADK agent 部署到 Cloud Run 之后,ADK 自己的客户端反而连不上它。拿另外两家云的客户端才把这个 bug 挖出来。跨云调用这事儿,有时候不光是功能需求,还是调试手段。

这篇文章一步步讲怎么把 Google ADK 的 agent 跑在 Cloud Run 上,通过 A2A 协议对外提供服务,让非 ADK 的调用方也能访问。

代码在这里:

github.com/xbill9/multicloud-a2a-subagent

核心结论

  1. Cloud Run 上部署 ADK 的 to_a2a() 服务后,agent card 公布的是 0.0.0.0:8080 这个不可路由地址;google-adkRemoteA2aAgent 按卡片路由因此连接失败,而 a2a-sdkagent-framework 客户端因重写或不按卡片路由而正常。

  2. ADK 会把同一份回复同时放进 task artifacts 和 task history;Microsoft Agent Framework 的 A2AExecutor 只写 history,因此只读 artifacts 的客户端会拿到空字符串。实际负载从汇率改成文字后,GCP 返回 202 个词,另外两云各 98 个词,长度翻倍。

  3. to_a2a() 序列化 agent 事件流时,会把模型围绕工具调用的叙述文本拼进输出;修复是只保留 is_final_response() 事件,否则下游打分器会把“让我查一下”等内容当正文评分。

  4. 3×3 互操作矩阵中,针对 ADK 服务器,google-adk 客户端单次 loopback 约 920ms,a2a-sdk 约 134ms,agent-framework 约 129ms;ADK 客户端贵约 7 倍。冷启动时 agent card 获取耗时 5849ms,实际 agent 调用仅 55ms。

  5. 24 份技术简报评测中,可用率 azure/gpt-5-mini 96%、gcp/gemini-2.5-flash 58%、aws/nova-micro 100%;规则→裁判胜率分别是 43%→87%、43%→43%、33%→0%。Gemini 是在唯一不离开自家云的最可靠路径上垫底。

这个项目要干什么

一份研究简报,三个云上的三个 agent,一个协议,彼此之间不存任何凭据。

Google 这边是 ADK agent 跑在 Cloud Run 上。AWS 那边是 Strands agent 跑在 Bedrock AgentCore 上。Azure 那边是 Agent Framework agent 跑在 Container Apps 上。协调者向三家问同一个问题,给返回的内容打分。

下面全是 Google 这一侧的事,没有一件能在笔记本上复现。

客户端是不是放错云了?

混着来,要的就是这个。

一个只回答 ADK 客户端问题的 ADK agent,谈不上跟谁互操作。下面这些发现,每一条都需要两样东西同时在场:一个部署好的服务,和一个不是 Google 的调用方。

Google ADK

Agent Development Kit 是 Google 的开源 agent 构建和部署框架。不绑定模型,也不绑定部署方式,Gemini 可以走 Vertex AI,也可以走 API key。to_a2a() 一行代码就能把 agent 变成 A2A 服务器。

更多信息:

Agent Development Kit

什么是 A2A

A2A,Agent2Agent,是一个开放协议,让不同团队、不同框架构建的 agent 能互相调用。每个 agent 在 /.well-known/agent-card.json 发布一张卡片,描述自己能干什么、怎么访问,然后通过 HTTP 走 JSON-RPC 通信。这个项目用的是 A2A v1.0。

详细文档:

Agent2Agent (A2A) Protocol

用 A2A 对外提供 ADK 服务

整个服务器就这些代码:

from google.adk.agents import LlmAgent
from google.adk.a2a.utils.agent_to_a2a import to_a2a

agent = LlmAgent(
    model="gemini-2.5-flash",
    name=..., description=...,
    instruction=INSTRUCTION,
    tools=[web_search],
)

app = to_a2a(agent, host=HOST, port=PORT)

这就是从 LlmAgent 到别的厂商的 agent 能调用,最短的一条路。也是我从这里入手的原因。

然后从源码部署到 Cloud Run,不需要 Dockerfile:

PUBLIC=1 MODEL_MODE=llm ./infra/deploy_gcp.sh deploy

验证 Agent Card

部署后的第一步,拉一下自己的卡片:

$ curl -s https://<the-adk-agent>.run.app/.well-known/agent-card.json

{"url": null,
 "additionalInterfaces": [
   {"url": "http://0.0.0.0:8080", "protocolBinding": "JSONRPC"}
 ]}

一个公开的 HTTPS 端点,卡片里写的却是不可路由的明文地址。

to_a2a(agent, host, port)host:port 原样写进卡片的 interface URL。Cloud Run 上进程绑定 0.0.0.0:8080,所以卡片就是这个。

注意,这个问题在本地复现不了。笔记本上绑定地址和拨号地址是同一个字符串,所以它能一路活到生产环境。另外两个 agent 接收一个 PUBLIC_URL 参数,对外公布的是它。ADK 缺的就是这个行为,不是什么高明的设计。

哪些客户端能活下来,才是值得知道的:

客户端 面对部署后的 ADK 服务器 原因
a2a-sdk 正常 解析卡片后重写 interfaces
agent-framework A2AAgent 正常 不按卡片路由,卡片错了也无所谓
google-adk RemoteA2aAgent 失败 按卡片路由,连 0.0.0.0:8080

一旦上线,ADK 自家的客户端连不上 ADK 自家的服务器。两半各自都能通过 Google 自己的测试,因为本地两个地址一模一样。唯一一个全部由第一方代码组成的配对,偏偏是跳不过去的那个。

而且报错报在了错误的层。连 0.0.0.0:8080 失败之后,RemoteA2aAgent 抛的是:

AttributeError: 'A2AClientError' object has no attribute 'status_code'

错误处理代码假设任何 A2AClientError 都带 status code,传输层失败没有。真正的原因,All connection attempts failed,躺在另一条日志里。两个缺陷叠在一起:第一个把客户端指向不可路由的地址,第二个把客户端去了哪里的证据删了。

所以如果你现在用 to_a2a() 提供服务:部署完拉一下自己的卡片。改不了卡片的话,确保调用方解析之后重写 interface URL,别直接按卡片路由。

同一份回复,发送了两次

ADK 的 executor 把回复作为 task artifact 附加,task history 里也留了一份。

只有跟别人对话时这才会暴露。Microsoft 的 A2AExecutor 驱动完整的 task 生命周期,回复只留在 history,artifacts 是空的。一个只读 artifacts 的客户端,这是最直观的实现,跟 ADK 也配合得很好,碰到 Agent Framework 就拿回一个空字符串。不是报错,不是超时,一次成功调用,内容是空的。

解决办法是 spec 允许的载体都读一遍,然后 ADK 的回复就到了两次。

这个项目早期版本里,agent 返回的是汇率,重复根本看不见:解析器按目标货币索引报价,第二份把第一份覆盖掉,答案仍然正确。把负载改成文字草稿,正文翻倍,字数翻倍,下游的打分器把一份合规的草稿判成长度超限 100%。

我读到输出才发现,一个云返回了 202 个词的文本,另外两个只有 98 个。

没有测试抓到它。测试套件从头到尾都是绿的。现在有一个线上测试,断言三个服务栈返回同一段固定文本、同样的长度。这是我知道的针对这一类问题最便宜的检测器。

没有函数缝

Strands 给你一个 async (prompt) -> reply,从外面包一层就行。

to_a2a() 接收一个 agent,序列化它的事件流。任何想在模型和网络之间做的事,都得在 ADK 的对象模型内部完成。我这边是一个 BaseAgent,消费 inner.run_async(ctx),最后 yield 一个最终事件。

为什么要折腾:这个系统里每份草稿都带一行,服务器写的,模型永远不会写。

<!-- a2a-research agent=gcp model=gemini-2.5-flash brain=llm -->

这一行带两样东西,协调者从协议消息里无法重建:实际是哪个模型回答的,以及到底有没有模型回答。让 Gemini 自己输出元数据,模型一旦搞错,审计里就会把草稿归错人。这是审计从内部无法发现的错误。

挂上工具那一刻,这个包装就成了承重墙。第一个版本把事件流里每个事件的文本拼起来,流里只有一个事件的时候,这样做是对的。绑上 web_search 工具之后,流里还带着模型围绕每次工具调用的叙述,比如"让我查一下"、它找到了什么。把这些拼进去,草稿开头就是 Gemini 在叙述自己的研究过程。打分器就给这段叙述打分。

async for event in inner.run_async(ctx):
    if not event.is_final_response():
        continue          # <- the whole fix
    ...

函数调用和它们的结果没有文本,本来就被 part.text 过滤器跳过了。需要显式排除的,是伴随它们的模型文本。

工具故意不用 google_search

ADK 自带 google_search,托管的原生 grounding,我没用。

另外两家云对不上。Microsoft 的 Agent Framework 导出 SupportsWebSearchTool,这是聊天客户端可以声明的一种协议,不是能交给 agent 的工具。Foundry 自己的 grounding 要先在带外建一个 Bing 资源连接。Strands 干脆没捆绑任何搜索。

如果各自用原生方案,Gemini 拿 Google 索引做 grounding,Bedrock 那边没有 grounding。三种检索产品,最后比较结果把产品差异记成了模型差异。

所以三家用的是同一个普通函数,打同一个后端。保持原生的部分才值得测量:ADK 自己包装这个普通 callable,跑自己的工具调用循环,和 Strands 的 @tool 装饰器、Agent Framework 的函数机制是完全不同的实现。

从程序调用 ADK

作为客户端,ADK 是三个栈里最重的,重很多。RemoteA2aAgent 是一个 BaseAgent,设计用来放在 agent 树里。当普通客户端用,意味着每次请求都要搭一个 Runner、一个 InMemorySessionService 和一个 session。

本地、直连、没有模型参与,每个客户端打每个服务器:

$ python3 -m matrix.runner

client \ server  gcp          aws          azure
-------------------------------------------------
a2a-sdk          ok 134ms     ok 8ms       ok 8ms
agent-framework  ok 129ms     ok 7ms       ok 8ms
google-adk       ok 920ms     ok 9ms       ok 10ms

9/9 attempted cells succeeded

单次 loopback 运行,只能当排序看。但形状是稳的:打 ADK 服务器时,ADK 客户端比其他两个贵大约七倍,而且每次调用都带着 [EXPERIMENTAL] 警告。

如果你写的是一个调用 agent 的程序,不是调用 agent 的 agent,参考实现 a2a-sdk 客户端更轻。

Cloud Run 带来了什么

Cloud Run 的两个属性塑造了整个项目。

它会为指定的 audience 铸造 workload OIDC token。这是整个 mesh 无密钥的原因:协调者拿 Google 铸造的 token 去 AWS STS 做 AssumeRoleWithWebIdentity,去 Entra 做 client assertion,任何一段都没有存密钥。

这也是协调者放在 Google 而不是别处的原因。另外两个运行时的铸造能力没被验证过,放在那边就意味着要存凭据。协调者跑在哪,决定你的系统有多少个密钥。

以及它让你付出的一个代价

冷启动出现在错误的位置。缩容到零的服务上,调用方第一个碰到的是 agent card,不是 agent。

+792ms  gcp  D research-gcp-...run.app/.well-known/  200   5849ms
+6643ms  gcp  I research-gcp-...run.app/              200     55ms

发现阶段 5849ms,真正调用只有 55ms。如果只记单段耗时,这次运行会被记成"Gemini 很慢"。

Buildpack 启动命令的坑

这个坑花了我一次部署周期。GCP 侧从源码构建一次,部署好几次,每次覆盖启动命令,从同一个镜像跑不同的进程。文档里写的覆盖方式会失败:

--command python --args="-m,agents.gcp.server"

failed to resolve binary path: error finding executable "python" in PATH

buildpack 镜像的解释器放在 CNB layers 里,/cnb/lifecycle/launcher 负责在 exec 你的进程之前把它们加进 PATH。覆盖启动命令等于替换了 launcher,覆盖后的命令跑在一个没有 Python 的地方。

改成通过 launcher 跑:

--command /cnb/lifecycle/launcher --args="python,-m,agents.gcp.server"

Cloud Run 把这个失败报成"用户提供的容器未能启动并监听 PORT=8080 定义的端口",这是任何启动崩溃都会有的症状,点名的恰恰是完全没问题的那个子系统。

Gemini 表现如何

24 份简报,三家云各答一遍,打两次分。一次用确定性规则,一次用模型裁判对同一批草稿重新排序:

云 / 模型 可用率 胜率 规则 → 裁判 遗憾值 规则 → 裁判
azure / gpt-5-mini 96% 43% → 87% 0.97 → 0.52
gcp / gemini-2.5-flash 58% 43% → 43% 1.54 → 2.21
aws / nova-micro 100% 33% → 0% 1.32 → 9.38

Gemini 那一行有三件事。

它只回答了受邀简报的 58%,三家最低,而且是在唯一一条不离开自家云、整个 mesh 里最可靠的路径上。记录到的失败是 Vertex 429。配额是文档里写的原因,不是被证实的原因。那十份缺失的草稿我没有逐一归因。不管怎样,这三个模型在这个语料上差异最大的地方不在文笔,在于有没有返回草稿。

它是唯一一个每次运行都把搜索预算花完的。三个 agent 都有六次搜索。当前指令下 Gemini 每次都用完六次,Bedrock 那个模型每七次运行里还有一次完全不搜索。这是真实的行为差异,也意味着这个上限正在塑造我拿来比较的草稿。一个每次都把最后一次搜索花掉的模型,预算更多的话也会花掉。

中间那列就有点尴尬了。模型裁判打分,Gemini 赢 43%,gpt-5-mini 拿 87%。裁判本身是 Gemini 2.5 Pro。

我的裁判和其中一个参赛者同一个厂商,这是真实的偏见风险,每条裁决上都标注了,我也不打算把它辩解掉。不过有个细节:Gemini 裁判给 Gemini 参赛者的排名低于 Azure,不是高于 Azure,跟你会预期的失效模式正好相反。

24 份简报,够算出一个比率,不足以相信一个比率。我的简报全是技术调研,指令在过程中还改过两次。别把这些当模型对比引用。

那为什么要把 ADK 服务给另外两家云?

因为上面每一条发现,都需要一个不是 Google 的调用方。

卡片缺陷需要一个按卡片路由的客户端。回复翻倍需要一个读所有载体的客户端,是别的厂商的服务器让它养成了这个习惯。事件流叙述需要一个框架之外的打分器来看文本。冷启动出现在 discovery 阶段这个发现,需要一个把卡片获取和调用分开记录的时间线。

在笔记本上拿 ADK 打 ADK,上面每一条都是绿的。

Google 侧速查

部署后的卡片公布的是 0.0.0.0:8080 to_a2a() 写的是绑定地址。客户端在解析之后重写 interface,或者提供一个你自己控制的卡片。

AttributeError: 'A2AClientError' object has no attribute 'status_code' RemoteA2aAgent 去连卡片上的地址,连不上。真正的原因在另一条日志里。

远程客户端收到你的回复两次。 ADK 把它同时放进 artifact 和 history。客户端应该去重,一个回复放在两个信封里,还是一个回复。

草稿开头是模型在叙述自己的研究过程。 事件流里有围绕每次工具调用的叙述文本。只保留 is_final_response()

buildpack 镜像报 error finding executable "python" in PATH 启动命令覆盖把 /cnb/lifecycle/launcher 换掉了。让命令通过 launcher 跑。

某条链路看起来慢,但模型没问题。 看看时间是不是花在 agent-card 获取上。缩容到零的情况下,冷启动就在那里。

任务体里报 No API key was provided,HTTP 还是 200。 ADK 默认不用 application-default credentials 访问 Gemini。设置 GOOGLE_GENAI_USE_VERTEXAI=true,配上 project 和 location,或者给 API key。

总结

Cloud Run 加 to_a2a(),是 LlmAgent 到"另一个厂商的 agent 能调用"之间最短的路。每次部署后拉自己的卡片,假设读你回复的不是 ADK,挂工具之前想清楚模型和网络之间你要站在哪。

A2A 兑现了承诺。上面这些全在协议外面,每一条都需要一个部署和一个非 Google 的调用方才会现身。

仓库里有三个 agent、共享指令和工具、协调者和裁判、3×3 互操作矩阵和部署脚本。docs/INTEROP.md 里每条发现都带测量日期:

github.com/xbill9/multicloud-a2a-subagent

常见问题(FAQ)

部署到Cloud Run后ADK客户端连不上怎么办?

部署后必须核验Agent Card。to_a2a()会把host:port原样写入卡片,Cloud Run上进程绑定0.0.0.0:8080,导致卡片URL不可路由。需确保调用方解析后重写interface URL,或修改卡片地址。

为什么ADK A2A服务器返回重复内容?

ADK的executor将回复同时附加为task artifact和task history。跨框架调用时,如Agent Framework只读artifacts,就会得到空字符串;需读取所有允许的载体,否则ADK回复会出现两次。

如何验证ADK A2A服务的Agent Card?

部署后运行curl -s https://你的服务/.well-known/agent-card.json,检查interfaces URL是否为公网HTTPS地址。若显示0.0.0.0:8080,则说明卡片错误,ADK客户端会连接失败。

阿凯广州
本文由 阿凯 审核,最后更新于 2026年8月25日
联系编辑 →
← 返回文章列表
分享到:微博

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

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

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