本篇文章讓 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.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(大多數 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 個案例評估矩陣尚未針對此架構重新執行。
經驗教訓
- 讓 Foundry 成為主程式是驗證問題,而非協定問題。 無論哪個雲端掌管,JSON-RPC 的一半都是容易的部分。請為身分識別預留時間——以及 RBAC 傳播,這裡花了 105 秒,而指派在當時已顯示為存在。
- 探索並非總是免費的——但成本很低。 已驗證的代理程式卡片會讓每個「找不到卡片」在證明非此原因前,都變成角色指派問題。暖機時耗時 0.35 秒,這不是需要最佳化的地方。
-
protocol_configuration會取代,而非合併。 已驗證而非假設:僅 PATCHa2a會移除 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——特別是如果您的數字不同,或發現本文遺漏的預覽 quirks——我很樂意交流心得。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.