本篇以 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 例评估矩阵尚未针对此架构重新运行。

经验教训

  1. 将 Foundry 设为主控是一个身份验证问题,而非协议问题。 无论哪个云负责,JSON-RPC 部分都是容易的部分。请为身份
    分配时间——以及 RBAC 传播,本次耗时 105 秒,而分配
    已在列表中显示。
  2. 发现并非总是免费的——但成本低廉。 已认证的代理
    卡使每个“未找到卡片”的情况在被证明是其他问题之前,都是角色分配问题。
    暖值下耗时 0.35 秒,这不是需要优化的地方。
  3. protocol_configuration 是替换而非合并。 已验证,非假设:
    仅修补 a2a 会丢弃 Responses,并导致 A2A 卡片随之失效(400),
    而执行此操作的请求返回 200。请发送您希望保留的每个协议,
    并用测试固定它。
  4. 托管运行时会扁平化您的仓库布局。 MCP stdio 工具会生成一个真正的
    子进程和真正的导入路径——如果您的部署包是
    子目录,则导入必须位于其中。
  5. 跨云凭证存在于调用方。 Google 调用 Azure
    意味着 Google Secret Manager 中存在 Azure 客户端密钥。没有办法绕过
    密钥;但有办法避免将其放入源代码树中。
  6. 在调试其他任何事情之前,请先获取正确的令牌受众。 为错误作用域生成的有效
    令牌会以看起来像权限错误的方式失败。
  7. 可注入的 HTTP 客户端是使跨厂商代理成本低廉的关键。 ADK 的
    RemoteA2aAgent 接受 httpx_client 是适配器与 fork 之间的区别。
  8. 您的测试导入源代码树;您的用户运行镜像。 缺少的
    COPY 行、仅在包外工作的扁平导入,以及您的工作站恰好拥有的
    传递性依赖(aiohttp)对绿色测试套件都是不可见的。加载容器的真实布局,
    或部署并调用它——这是发现此类错误的唯一两种方法。
  9. 在检查权限之前,请先检查方法名称。 message/send 是 v0.3;SendMessage 是 v1.x。失败是端点上的 METHOD_NOT_FOUND
    而您对此端点拥有完全的授权。
  10. 协议从来不是慢的部分。 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——特别是如果您的数据
不同,或者您发现了本文遗漏的预览期怪异行为——我希望能
与您交流。