本篇文章讓 Microsoft Foundry 掌管大局。Azure 是主要雲端:
它託管主代理程式、擁有匯率工具,並執行所有算術運算。Google Cloud 擔任次要角色——Cloud Run 上的 ADK 客戶端,其唯一工作就是透過 A2A v1.0 進行驗證、探索與委派。

這是一個真正的架構,而非示範級的實驗。如果貴組織的治理、模型和資料已經存放在 Azure,您並不希望 Google 端擁有協調權限;您希望它成為一個可存取的客戶端,能指向不受其控制的主程式。本系列上一篇已設定相反的角色配置並測量了延遲。本篇探討當 Foundry 成為被呼叫方時的變化——幾乎所有變動都在驗證與探索層面,而非協定本身。

部署與量測均已完成:位於 us-central1 的 Cloud Run 呼叫位於 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 工具會自動顯示為技能——手動撰寫卡片感覺像是倒退。這同時也是朝向誠實的一步:卡片是一份合約,而在這一端,您必須明確陳述。

其次,此 PATCH 會同時傳送 兩個 協定,而非僅傳送 a2a。這點很重要,我沒有假設,而是對已部署的代理程式進行了探測——僅以 a2a 進行 PATCH,讀取狀態後再還原:

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。我一開始查錯欄位,短暫誤以為 PATCH 沒有作用。

第三部分:代理程式卡片也需要權杖

在 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.pyentra_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(大多數 A2A 範例仍顯示的 v0.3 拼寫)的手寫 JSON-RPC 呼叫會回傳:

{"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.

量測項目 數值
已驗證的代理程式卡片 GET(暖機中位數,6 次執行) 0.35 s
同上,第一次擷取 0.51 s
A2A SendMessage,工作站 → 主程式(暖機中位數,6 次執行) 21.2 s
透過 Cloud Run ADK 客戶端的 A2A 來回行程(中位數,5 次執行) 23.4 s
同上,範圍 18.9 s – 29.5 s
對全新 Cloud Run 修訂版的第一個請求 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 會取代,而非合併。 已驗證而非假設:僅 PATCH 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——特別是如果您的數字不同,或發現本文遺漏的預覽 quirks——我很樂意交流心得。