本篇以 Microsoft Foundry 为主导。Azure 是主要云:
它托管主代理,拥有汇率工具,并完成所有
算术运算。Google Cloud 担任次要角色——Cloud Run 上的 ADK 客户端,其
全部工作是通过 A2A v1.0 进行身份验证、发现和委托。
这是一种真正的架构,而非演示性质的奇思。如果贵组织的
治理、模型和数据已存在于 Azure 中,您并不希望 Google
一方拥有编排权;您希望它成为一个可达的客户端,能指向
一个它无法控制的主控。前一篇文章将角色反转并测量了跃点。本文讨论
当 Foundry 被调用时发生了什么变化——几乎所有变化都
集中在身份验证和发现上,而非协议。
已部署并测量:Cloud Run 位于 us-central1,调用位于
eastus2 的 Foundry 主控,实时汇率,文末附有数据。四件事在过程中出现问题,全部
对绿色测试套件不可见,而这些才是真正有趣的部分。
工作负载位置
Google Cloud Run
Google ADK client (RemoteA2aAgent)
|
+-- A2A v1.0 / JSON-RPC + Microsoft Entra
|
v
Microsoft Foundry hosted master (Microsoft Agent Framework)
|
+-- MCP stdio --> Frankfurter exchange rates
Enter fullscreen mode Exit fullscreen mode
重要的设计决策:工具随主控一起移动。 Foundry
拥有 MCP 汇率工具和所有 Decimal 算术;ADK 端只是一个轻量级的
已认证代理,仅拥有客户端会话而无其他功能。这使得 Azure 成为真正意义上的主云,
而非装饰性的存在——能力存在于此处,Google 端即使想单独回答货币问题也无法做到。
这也让实验保持诚实。如果将 Foundry 设为主控,但将
工具留在 ADK 端,您根本没有测试跨云主控——您
只是用额外的延迟测试了自己的 MCP 服务器两次。两个
角色分配存在于独立的目录中(foundry_master/ 和
google_adk_client/,与之前的 coordinator/ 和 adk_agent/ 相对),因此
任何一方都不会悄然成为另一方的依赖,也没有任何递归调用。
第一部分:能接听电话的 Foundry 代理
主控是一个普通的 Agent Framework 代理。唯一标识它为
托管代理的标记是 ResponsesHostServer:
from agent_framework import Agent, MCPStdioTool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
def build_agent() -> Agent:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=DefaultAzureCredential(),
)
return Agent(
client=client,
name="currency-master-agent",
description="Master currency agent called remotely by Google ADK.",
instructions=INSTRUCTIONS,
tools=[build_rate_tool()],
default_options={"store": False},
)
ResponsesHostServer(build_agent()).run()
Enter fullscreen mode Exit fullscreen mode
指令刻意平淡,因为主控方负责
算术运算,我不希望语言模型介入其中:
对于每次转换,针对每个请求的目标调用一次
convert_currency。
精确复制工具返回的十进制字符串,切勿自行执行或验证算术运算。
每个目标仅回复一个 JSON 对象,且无其他文本。
汇率工具是一个 MCP stdio 子进程,在托管
容器内启动:
MCPStdioTool(
name="currency_rates",
command=sys.executable,
args=["-m", "mcp_server.server"],
env={"PYTHONPATH": os.getenv("PYTHONPATH", os.getcwd()), ...},
load_prompts=False,
)
Enter fullscreen mode Exit fullscreen mode
那个 -m mcp_server.server 是第一个非显而易见的问题所在。托管部署包是
foundry_master/ 目录,而非仓库根目录,因此 MCP 子进程无法导入 mcp_server/ 和 coordinator/——
代码根本不在镜像中。部署助手会在将目录交给 azd 之前同步这些目录:
rsync -a --delete "$REPO_ROOT/coordinator/" "$MASTER_DIR/coordinator/"
rsync -a --delete "$REPO_ROOT/mcp_server/" "$MASTER_DIR/mcp_server/"
cd "$MASTER_DIR" && azd provision && azd deploy
Enter fullscreen mode Exit fullscreen mode
不够优雅。但 MCP stdio 工具意味着一个真正的子进程和真正的导入
路径,而托管运行时并不关心您的单体仓库布局。
azure.yaml 将模型部署和托管协议
一起声明,这是 Foundry azd 提供程序的一个良好特性——一个文件
即可配置 gpt-5-mini 以及使用它的代理:
services:
currency-master-agent:
host: azure.ai.agent
codeConfiguration:
dependencyResolution: remote_build
entryPoint: main.py
runtime: python_3_13
protocols:
- protocol: responses
version: 2.0.0
Enter fullscreen mode Exit fullscreen mode
请注意 protocols 列表的内容:responses。不是 A2A。
第二部分:入站 A2A 是可选的,且处于预览阶段
托管的 Foundry 代理使用 Responses 协议。在您明确启用之前,它
不会对入站调用者使用 A2A,且在撰写本文时
这是一项预览功能,没有 azd 界面。它是一个对代理的 PATCH 请求,必须正确设置的两件事是:
{
"agent_card": {
"description": "Master currency agent using live MCP rates and Decimal arithmetic.",
"version": "1.0",
"skills": [{"id": "currency-conversion", "name": "Currency conversion", ...}],
},
"agent_endpoint": {"protocol_configuration": {"responses": {}, "a2a": {}}},
}
Enter fullscreen mode Exit fullscreen mode
首先,您需要自行提供代理卡。来自 ADK——其中 to_a2a()
从代理派生卡片,MCP 工具自动显示为技能——
手动编写卡片感觉像是倒退。但这也是迈向
诚实的一步:卡片是一份契约,在这一侧您必须声明它。
其次,补丁同时发送两种协议,而非仅发送 a2a。这很重要,
我没有假设,而是针对已部署的代理进行了探测——仅使用 a2a 进行补丁,读回状态,然后恢复:
before: protocols ['a2a', 'responses'] card 200
patch a2a-only -> 200
after a2a-only: protocols ['a2a'] card 400
patch both -> 200
after restore: protocols ['a2a', 'responses'] card 200
Enter fullscreen mode Exit fullscreen mode
因此 protocol_configuration 替换而非合并。 仅发送 {"a2a": {}}
会导致 Responses 从正在运行的代理中消失。
第二列是我未预料到的部分:Responses 被移除后,
A2A 代理卡本身开始返回 400。A2A 表面并不
独立于 Responses 协议——移除 Responses 会导致 A2A 随之失效。因此,
“仅启用我想要的协议”的失败模式并非
“Responses 损坏”,而是“一切都损坏”,且执行此操作的请求
返回一个愉快的 200。
单元测试固定了这种形状,以防止日后被修剪:
def test_patch_retains_responses_when_enabling_a2a():
protocols = patch_body()["agent_endpoint"]["protocol_configuration"]
assert set(protocols) == {"responses", "a2a"}
Enter fullscreen mode Exit fullscreen mode
读回状态时的另一个陷阱:definition.protocol_versions 在正常工作的 A2A 代理上
仍仅报告 responses。该字段描述的是容器所使用的协议。
启用状态存在于 agent_endpoint.protocol_configuration 中。
我最初检查了错误的字段,短暂地认为补丁未起作用。
第三部分:即使是代理卡也需要令牌
在 Azure 调用 Google 的方向上,发现是匿名的。您 GET 一个 URL,
获取 JSON,读取技能。卡片是公共元数据。
调用 Foundry 时,不存在匿名的 /.well-known/agent-card.json。卡片
位于代理协议端点的版本化路径下:
{project_endpoint}/agents/currency-master-agent/endpoint/protocols/a2a/agentCard/v1.0
Enter fullscreen mode Exit fullscreen mode
且对它的每个请求——包括卡片获取——都携带 Entra 持有者
令牌。调用者的身份需要在 Foundry 项目 上拥有 Foundry Agent Consumer 角色。
这是一个数据平面角色分配,与同一主体可能已拥有的任何
控制平面权限分离。
实际后果:卡片上的 404 或 401 同样可能是
缺少角色分配而非错误的 URL,因此在开始计算路径段之前,请检查访问控制。
发现需要认证也改变了信任故事,值得
直白地陈述。当 ADK 被调用时,任何人都可以读取该
代理声称能做什么。而以 Foundry 为主控时,能力发现本身就是
一项特权操作——如果没有被允许调用,您就无法枚举主控的技能。对于拥有工具的主云而言,
这可以说是正确的默认设置。
第四部分:Google 端需要非 Google 的凭证
这是 Google 托管主控时没有对应的部分。Cloud Run
容器要向 Azure 进行身份验证,需要 Entra 服务主体,这意味着
客户端密钥必须存在于 Google 端某个位置。它被放入 Secret
Manager 并在部署时注入——绝不放在源代码中,绝不放在清单中:
gcloud run deploy "$SERVICE" \
--source "$REPO_ROOT/google_adk_client" \
--no-allow-unauthenticated \
--set-secrets "AZURE_CLIENT_SECRET=${AZURE_SECRET}:latest" \
--set-env-vars "FOUNDRY_MASTER_A2A_ENDPOINT=${FOUNDRY_MASTER_A2A_ENDPOINT},AZURE_TENANT_ID=...,AZURE_CLIENT_ID=..."
Enter fullscreen mode Exit fullscreen mode
部署助手完全拒绝接受原始密钥值——它断言
密钥已存在(gcloud secrets describe),如果不存在则失败。
接受密钥值的便利标志是密钥最终进入 shell
历史记录的原因。
随后 DefaultAzureCredential 从
环境变量中获取租户、客户端 ID 和密钥。将令牌附加到出站 A2A 流量是一个小的
httpx.Auth 适配器——已缓存,在过期前五分钟刷新,并在每个请求上
标记协议版本:
class EntraAuth(httpx.Auth):
requires_request_body = True
async def async_auth_flow(self, request):
if self._token is None or self._token.expires_on - 300 <= time.time():
self._token = await self._credential.get_token("https://ai.azure.com/.default")
request.headers["Authorization"] = f"Bearer {self._token.token}"
request.headers["A2A-Version"] = "1.0"
yield request
Enter fullscreen mode Exit fullscreen mode
作用域是 https://ai.azure.com/.default——AI 服务受众,而非
ARM 受众。为错误受众生成的令牌是完全有效的令牌,但会被拒绝,这会产生一个 401 错误,看起来完全不像作用域问题。
这就是集成的整个 Google 端:
def build_root_agent() -> RemoteA2aAgent:
client = httpx.AsyncClient(auth=EntraAuth(), timeout=httpx.Timeout(120))
return RemoteA2aAgent(
name="foundry_currency_master",
description="Authenticated ADK proxy to the Azure Foundry currency master.",
agent_card=agent_card_url(os.environ["FOUNDRY_MASTER_A2A_ENDPOINT"]),
httpx_client=client,
timeout=120,
full_history_when_stateless=False,
)
Enter fullscreen mode Exit fullscreen mode
RemoteA2aAgent 接受注入的 httpx_client,这使得在不修改 ADK 的情况下实现此功能成为可能。
本次集成中所有不寻常的部分——
Entra 令牌、非标准的卡片路径、120 秒的跨云超时——都
包含在您提供给它的客户端中。这是一个真正优秀的扩展点,也是“ADK 调用 Azure”只需三十行代码而非一个 fork 的原因。
值得注意的两个较小选择:full_history_when_stateless=False 可防止
代理在每次跃点时重新发送累积的对话,120 秒的超时是前一篇文章的教训再次得到印证——
针对单云优化的默认值不适用于双云场景。冷启动加上模型生成
加上一次实时汇率调用,无需尝试即可超过十秒。
单元测试无法发现的打包错误
客户端镜像中的两个错误,都是通过在本地重建容器的
目录布局并针对其运行 ADK 的 AgentLoader 发现的——
而不是通过在 Cloud Run 中观察其失败。
镜像运行 adk api_server /app,/app 包含 agent.py 及其旁边的
entra_auth.py。ADK 2.5.0 将其检测为单代理模式:它将
目录的父目录作为代理目录,并将您的文件作为 app.agent 导入。
因此,您的代理模块是包的子模块,而非顶级
脚本。
这意味着 agent.py 中的以下代码无法工作:
from entra_auth import EntraAuth # ModuleNotFoundError: No module named 'entra_auth'
Enter fullscreen mode Exit fullscreen mode
而这个可以:
from .entra_auth import EntraAuth
Enter fullscreen mode Exit fullscreen mode
加载器将 / 放入 sys.path,而非 /app,因此扁平导入无处
解析。相对导入在两种上下文中都有效——在容器内
以及当仓库的单元测试将同一模块作为
google_adk_client.entra_auth 导入时。
同样错误的另一半:COPY pyproject.toml uv.lock agent.py ./
这一行从未提及 entra_auth.py,因此该模块根本不在镜像中。
构建时不会失败——失败的是导入。
两者对绿色测试套件都不可见,因为测试从源代码树导入
google_adk_client.entra_auth,而源代码树并非
实际交付的内容。加载重建后的 /app 布局是一个两行检查,
可以捕获两者:
from google.adk.cli.utils.agent_loader import AgentLoader
AgentLoader("/app").load_agent("app") # ModuleNotFoundError before the fix
Enter fullscreen mode Exit fullscreen mode
部署
顺序很重要,因为 ADK 客户端需要的端点在
主控启动并在其上启用 A2A 之前不存在:
./infra/deploy_foundry_master.sh # provision, deploy, then PATCH incoming A2A on
export FOUNDRY_MASTER_A2A_ENDPOINT="https://.../endpoint/protocols/a2a"
export GCP_PROJECT="your-project" AZURE_TENANT_ID="..." AZURE_CLIENT_ID="..."
./infra/deploy_google_adk_client.sh # ADK proxy on Cloud Run, secret from Secret Manager
Enter fullscreen mode Exit fullscreen mode
启用脚本会打印已解析的卡片和要导出的端点,因此
第二步是复制粘贴,而非手动组装的 URL。
仅在运行时才出现问题的两件事
单元测试自始至终都是绿色的。这两者对它们都不可见。
v1.0 方法名称与 v0.3 方法名称不同。 使用 message/send 进行手写的 JSON-RPC
调用——大多数 A2A 示例仍显示的 v0.3 拼写——返回如下结果:
{"error": {"code": -32601, "message": "Method not found",
"data": [{"reason": "METHOD_NOT_FOUND",
"metadata": {"detail": "'method' field is not a valid A2A method."}}]}}
Enter fullscreen mode Exit fullscreen mode
在 a2a-sdk 1.x 中,JSON-RPC 方法是 SendMessage,带有 proto-JSON 参数。
值得注意的是,Foundry 卡片在同一
URL 上公布了三个接口——JSONRPC 1.0、JSONRPC 0.3 和 HTTP+JSON 0.3——因此服务器并非
限制因素;是您客户端的拼写方式。
容器需要一个未声明的依赖。 azure.identity.aio
使用 aiohttp 构建其异步传输,而这不是 azure-identity 的硬依赖。
它存在于普通工作站上,因此本地运行通过。在
uv sync --frozen 容器中它不存在,构建成功,但第一次
令牌请求失败:
File "azure/core/pipeline/transport/__init__.py", line 94, in __getattr__
raise ImportError("aiohttp package is not installed")
Enter fullscreen mode Exit fullscreen mode
这两个错误具有相同的形状:锁定文件和测试运行所依据的源代码树
并非实际交付的产物。
结果
以下所有数据均来自实际部署——us-central1 的 Cloud Run
调用 eastus2 的 Foundry 主控,gpt-5-mini,实时 Frankfurter 汇率,提示词为
Convert 250 GBP to USD and JPY.
| Measurement | Value |
|---|---|
| Authenticated agent-card GET (warm median, 6 runs) | 0.35 s |
| Same, first fetch | 0.51 s |
A2A SendMessage, workstation → master (warm median, 6 runs) |
21.2 s |
| A2A round trip via the Cloud Run ADK client (median, 5 runs) | 23.4 s |
| Same, range | 18.9 s – 29.5 s |
| First request against a fresh Cloud Run revision | 20.8 s |
每次运行都返回相同的数据,且数据是正确的:
{"source_currency":"GBP","target_currency":"USD","rate":"1.3389",
"converted_amount":"334.7250","source":"frankfurter-live"}
{"source_currency":"GBP","target_currency":"JPY","rate":"218.15",
"converted_amount":"54537.50","source":"frankfurter-live"}
Enter fullscreen mode Exit fullscreen mode
有三点值得注意。
已认证发现成本低廉。 最初的担忧是,需要令牌和角色检查的卡片获取
是否会比匿名 GET 明显更昂贵。在 0.35 秒的暖值下,它与之前的调用相比只是噪音,
且令牌在调用间会被缓存。
ADK 代理跃点大约耗时 2 秒——直接 21.2 秒,而通过 Cloud Run 为 23.4 秒,
范围为 18.9–29.5 秒。简而言之,主控的运行间
方差大于在路径中增加第二个云的全部成本。
主控主导一切。 23 秒往返时间中大约 20 秒是 gpt-5-mini 决定调用 convert_currency 两次并等待
Frankfurter 每次响应的时间。如果您希望此架构更快,协议和
代理并不是耗时所在。请注意与前一篇文章中 Azure 编排 Google 的数据(a2a_only 中位数 1.69 秒)的对比:
相同的协议,相同的云,相差一个数量级——因为在那里
远程代理回答一个问题,而在这里主控运行一个多工具
推理循环。无论哪种情况,您测量的都不是协议开销。
基础设施计时,供首次部署预算参考:azd provision
64 秒,azd deploy 116 秒,以及——会让您困惑的——数据平面 RBAC 生效前 105 秒。
角色分配已在 az role assignment list 中可见,但代理写入仍持续返回 403。
这是传播问题,而非配置错误。请耐心等待,然后再开始重新分配角色。
原始结果位于 evaluations/results/foundry-master-live-2026-07-31.json,
evaluations/measure_foundry_master.py 可重新生成这些结果。
范围提示:这些是单提示样本,而非分布。前一篇文章中的
38 例评估矩阵尚未针对此架构重新运行。
经验教训
-
将 Foundry 设为主控是一个身份验证问题,而非协议问题。
无论哪个云负责,JSON-RPC 部分都是容易的部分。请为身份
分配时间——以及 RBAC 传播,本次耗时 105 秒,而分配
已在列表中显示。 -
发现并非总是免费的——但成本低廉。 已认证的代理
卡使每个“未找到卡片”的情况在被证明是其他问题之前,都是角色分配问题。
暖值下耗时 0.35 秒,这不是需要优化的地方。 -
protocol_configuration是替换而非合并。 已验证,非假设:
仅修补a2a会丢弃 Responses,并导致 A2A 卡片随之失效(400),
而执行此操作的请求返回 200。请发送您希望保留的每个协议,
并用测试固定它。 -
托管运行时会扁平化您的仓库布局。 MCP stdio 工具会生成一个真正的
子进程和真正的导入路径——如果您的部署包是
子目录,则导入必须位于其中。 -
跨云凭证存在于调用方。 Google 调用 Azure
意味着 Google Secret Manager 中存在 Azure 客户端密钥。没有办法绕过
密钥;但有办法避免将其放入源代码树中。 -
在调试其他任何事情之前,请先获取正确的令牌受众。 为错误作用域生成的有效
令牌会以看起来像权限错误的方式失败。 -
可注入的 HTTP 客户端是使跨厂商代理成本低廉的关键。 ADK 的
RemoteA2aAgent接受httpx_client是适配器与 fork 之间的区别。 -
您的测试导入源代码树;您的用户运行镜像。 缺少的
COPY行、仅在包外工作的扁平导入,以及您的工作站恰好拥有的
传递性依赖(aiohttp)对绿色测试套件都是不可见的。加载容器的真实布局,
或部署并调用它——这是发现此类错误的唯一两种方法。 -
在检查权限之前,请先检查方法名称。
message/send是 v0.3;SendMessage是 v1.x。失败是端点上的METHOD_NOT_FOUND,
而您对此端点拥有完全的授权。 -
协议从来不是慢的部分。 23 秒往返时间中约 20 秒是
主控自身的推理和工具调用。第二个云大约耗时 2 秒——
小于主控的运行间方差。
源代码
两个角色分配、部署脚本和测试:
GitHub - xbill9/foundry-adk-a2a-currency
Foundry 作为主控的组件是 foundry_master/、google_adk_client/,
以及 infra/enable_foundry_master_a2a.py。测量数据来自
evaluations/measure_foundry_master.py,原始输出位于
evaluations/results/foundry-master-live-2026-07-31.json。Google 托管远程代理的设置及其基准结果,
位于本系列的前一篇文章中。
如果您在托管的 Foundry 代理上运行过入站 A2A——特别是如果您的数据
不同,或者您发现了本文遗漏的预览期怪异行为——我希望能
与您交流。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.