我把 Google A2A codelab 的漢堡 AI 代理遷移到 a2a-sdk v1.0,並部署到 Cloud Run 上
Google A2A codelab 裡的漢堡 AI 代理是以 CrewAI 建構的。我把這個 AI 代理遷移到 a2a-sdk v1.0 並部署到 Cloud Run 上,再讓 TypeScript 用戶端實際連上這個 AI 代理。

本頁目錄
引言
在我之前寫的 AIMock 文章裡,買家 AI 代理的測試是以模擬的 A2A 賣家 AI 代理為對象執行的。這篇文章則讓同一個 TypeScript 用戶端連上真實的 AI 代理,也就是 Google A2A codelab 裡的漢堡 AI 代理。我先把這個 AI 代理從 a2a-sdk v0.2 遷移到 v1.0,再部署到 Cloud Run 上。
A2A 生態系的現狀
研究 A2A 協定時,我找了一下有沒有可以自由互動的公開 AI 代理。
目前找得到的公開 AI 代理
- Hello World Agent:
https://hello-world-gxfr.onrender.com/.well-known/agent.json。這是第一個公開的 A2A AI 代理,只會把收到的訊息原樣傳回,但很適合用來確認用戶端能否正常運作 - A2A Registry Playground:a2a-registry.org/playground,在這個網頁上可以直接跟已登錄的 AI 代理對話
- Google codelab:Purchasing Concierge,這份實作教學會把兩個 AI 代理部署到 Cloud Run,並讓這兩個 AI 代理透過 A2A 對話。整個教學在免費額度內就能完成
已經有 demo 級的 AI 代理,但還沒有 production 環境的端點
| 已經有的 | 還沒有的 |
|---|---|
| Hello world,或是原樣傳回訊息的 AI 代理 | 真實企業公開的正式 A2A 端點 |
| Google codelab 的示範 | Stripe、Twilio 這類廠商的 A2A API |
| 社群做的練習用 AI 代理 | 具備真正實用能力的 AI 代理市集 |
A2A 有超過 150 個組織參與,GitHub 星數也超過 22,000,但幾乎沒有可以自由互動、又真正有用的公開 AI 代理。企業導入的 A2A(例如 Azure AI Foundry、Bedrock AgentCore)幾乎都必須通過認證才能使用。
在本機執行 Google codelab 的真實 A2A AI 代理
demo 的測試裡,賣家 AI 代理是我用 http.createServer 寫成的模擬。下一步,我要在本機執行一個真實的 A2A AI 代理,也就是 Google Purchasing Concierge codelab 裡的賣家 AI 代理。
複製 repo 並調整
git clone https://github.com/alphinside/purchasing-concierge-intro-a2a-codelab-starter.gitcd purchasing-concierge-intro-a2a-codelab-starter/remote_seller_agents/burger_agent這個漢堡 AI 代理是一個 A2A 賣家 AI 代理,以 CrewAI、LiteLLM 與 Vertex AI Gemini 建構而成,負責接收並處理使用者的漢堡訂單。
這個 starter repo 需要先做以下調整,才能正常執行:
1. 改用 Python 3.13
requires-python = ">=3.13"FROM python:3.13-slim2. 明確加入 litellm 相依套件
agent.py 裡有 import litellm,但 litellm 並不在 pyproject.toml 的相依清單中。以前 litellm 是以 CrewAI 的間接相依被安裝進來的,但新版的 CrewAI 已經不再相依 litellm 了。
uv add litellm把 a2a-sdk 從 v0.2 遷移到 v1.0
原始程式碼是針對 a2a-sdk v0.2 寫的,但 >=0.2.16 這個版本限制實際上會安裝 v1.1.2。v1.1.2 含有破壞性變更,因此執行時出現了 import 錯誤。於是我把程式碼更新成 v1.0 的寫法。
為什麼 v1.0 改了這麼多?
Protobuf(Protocol Buffers)是 Google 開發的格式,用來定義訊息的型別:先在 .proto schema 裡描述一次,再由產生器替各種語言產生對應的類別。同一則訊息既可以透過 gRPC 以二進位格式傳送,也可以改用 JSON 格式傳送。
在 v1.0 中,A2A 把 a2a.proto 從 gRPC 專用的檔案,提升為 JSON-RPC、REST、gRPC 三種綁定共用的規格來源。a2a-sdk v1.0 也跟著把 Pydantic 模型換成由 a2a.proto 產生的型別。v1.0 大部分的破壞性變更,都來自這一個決定。
所有使用端都源自同一份 proto,所以同樣的名稱會同時出現在 Python SDK 裡和實際傳送的資料中。
| 變更 | 來源 |
|---|---|
Part(root=TextPart(text="...")) → Part(text="...") | Part 是一個 proto 訊息,內容以 oneof 定義,所以直接設定欄位即可 |
"user" → "ROLE_USER" | 為了符合 ProtoJSON,列舉值改成了 SCREAMING_SNAKE_CASE |
message/send → SendMessage | 方法名稱沿用 proto 裡的 rpc SendMessage |
url → supported_interfaces | 每一種綁定都各自宣告成一個 AgentInterface |
把程式碼改成 v1.0 的寫法
這個 codelab 指定的是 a2a-sdk>=0.2.16,也就是 A2A v0.2 的實作,這一版規格的傳送方法是 message/send。主要變更:
| 項目 | v0.2 | v1.0 |
|---|---|---|
| 伺服器建構 | A2AStarletteApplication 類別 | create_jsonrpc_routes() / create_agent_card_routes() 路由工廠 |
| AgentCard | url 欄位 | supported_interfaces 清單(AgentInterface) |
| RequestHandler | 沒有 agent_card | agent_card 為必填 |
| Part 建構 | Part(root=TextPart(text="...")) | Part(text="...")(以 Protobuf 為基礎) |
| JSON-RPC 方法 | message/send | SendMessage |
| Agent Card 路徑 | /.well-known/agent.json | /.well-known/agent-card.json |
| 協定版本 | 無(隱含 0.3) | 必須帶 A2A-Version: 1.0 header |
| 錯誤型別 | ServerError | 直接使用 UnsupportedOperationError |
__main__.py:建構伺服器
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routesfrom a2a.types import AgentCard, AgentInterface, AgentCapabilities, AgentSkillfrom starlette.applications import Starlette
agent_card = AgentCard( name="burger_seller_agent", description="Helps with creating burger orders", version="1.0.0", capabilities=AgentCapabilities(streaming=True), supported_interfaces=[ AgentInterface(protocol_binding="JSONRPC", url=agent_host_url) ], skills=[skill],)
request_handler = DefaultRequestHandler( agent_executor=BurgerSellerAgentExecutor(), task_store=InMemoryTaskStore(), agent_card=agent_card, # v1.0 起為必填)
routes = []routes.extend(create_agent_card_routes(agent_card))routes.extend(create_jsonrpc_routes(request_handler, "/"))
app = Starlette(routes=routes)uvicorn.run(app, host=host, port=port)agent_executor.py:改用以 Protobuf 為基礎的型別
from a2a.types import Message, Part, Role, Taskfrom a2a.utils.errors import UnsupportedOperationError
class BurgerSellerAgentExecutor(AgentExecutor): async def execute(self, context, event_queue): query = context.message.parts[0].text if context.message else "" result = await self.agent.invoke(query, context.context_id)
# v1.0:直接用 Part(text="...") 建構,不需要 TextPart 包裝 await event_queue.enqueue_event( Message( role=Role.ROLE_AGENT, parts=[Part(text=str(result))], message_id=context.task_id or "msg", context_id=context.context_id, ) )agent.py:改成非同步
CrewAI 的 kickoff() 是同步方法,但在 a2a-sdk v1.0 裡,請求處理器是在非同步環境中被呼叫的。在非同步環境裡呼叫同步方法,就會拋出 Agent execution was invoked synchronously from within a running event loop 錯誤。
def invoke(self, query, sessionId) -> str:async def invoke(self, query, sessionId) -> str: # ... response = crew.kickoff(inputs) response = await crew.kickoff_async(inputs) return response確認能正常運作
# 需要先啟用 Vertex AI APIgcloud services enable aiplatform.googleapis.com
# 在本機啟動uv run . --host 127.0.0.1 --port 8080確認 Agent Card 的內容:
$ curl http://127.0.0.1:8080/.well-known/agent-card.json | jq .name"burger_seller_agent"用 A2A v1.0 送一則訊息:
$ curl -X POST http://127.0.0.1:8080/ \ -H "Content-Type: application/json" \ -H "A2A-Version: 1.0" \ -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage", "params":{"message":{"role":"ROLE_USER", "parts":[{"text":"What burgers do you have?"}], "messageId":"m1","contextId":"ctx1"}}}'回應:
{ "result": { "message": { "role": "ROLE_AGENT", "parts": [{ "text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. Do any of these sound good?" }] } }, "id": "1", "jsonrpc": "2.0"}整條路徑都能正常運作了:Vertex AI Gemini → CrewAI → A2A v1.0。
部署到 Cloud Run
在本機能夠正常運作之後,下一步是部署到 Cloud Run。這個漢堡 AI 代理是由 uvicorn 啟動的 Starlette ASGI 伺服器,所以適合部署到 Cloud Run,而不是 Cloud Functions。這個 AI 代理提供好幾個 Starlette 路由(/.well-known/agent-card.json,以及位於 / 的 JSON-RPC 端點)。
gcloud run deploy burger-agent \ --source . \ --port 8080 \ --allow-unauthenticated \ --region us-central1 \ --min-instances 0 \ --max-instances 1 \ --memory 1Gi--source . 會在 Cloud Build 上執行 Dockerfile 建置,把映像檔存到 Artifact Registry,再部署到 Cloud Run。
部署之後,我把 HOST_OVERRIDE 設成服務的 URL,讓 Agent Card 的 url 欄位指向 Cloud Run:
gcloud run services update burger-agent \ --region us-central1 \ --set-env-vars HOST_OVERRIDE=https://burger-agent-XXXXXXXXXX.us-central1.run.app確認結果:
$ curl https://burger-agent-XXXXXXXXXX.us-central1.run.app/.well-known/agent-card.json | jq .name"burger_seller_agent"
$ curl -X POST https://burger-agent-XXXXXXXXXX.us-central1.run.app/ \ -H "Content-Type: application/json" \ -H "A2A-Version: 1.0" \ -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage", "params":{"message":{"role":"ROLE_USER", "parts":[{"text":"What burgers do you have?"}], "messageId":"m1","contextId":"ctx1"}}}'{ "result": { "message": { "role": "ROLE_AGENT", "parts": [{ "text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. What would you like to order?" }] } }}關於成本:設定 --min-instances 0 之後,服務閒置時執行個體的數量會降到零,所以閒置中的 demo 不會產生任何費用。代價是 10–15 秒的冷啟動,不過對 demo 或測試來說沒有問題。
讓 TypeScript 買家 AI 代理支援 A2A v1.0
要連上 Cloud Run 上的真實賣家 AI 代理,TypeScript 的 A2A 用戶端也必須改成符合 v1.0 的寫法。
A2A 用戶端的變更
export class A2AClient { // v1.0:agent-card.json(原為 agent.json) async discover(): Promise<AgentCard> { const url = `${this.agentUrl}/.well-known/agent-card.json`; const res = await fetch(url); this.card = await res.json(); return this.card; }
// v1.0:SendMessage 加上 A2A-Version header async sendMessage(text: string, contextId?: string): Promise<A2AMessage> { const rpcRequest = { jsonrpc: "2.0", id: messageId, method: "SendMessage", // 原為 "tasks/send" params: { message: { role: "ROLE_USER", // 原為 "user" parts: [{ text }], // 原為 { type: "text", text } messageId, contextId, }, }, };
const res = await fetch(this.agentUrl, { method: "POST", headers: { "Content-Type": "application/json", "A2A-Version": "1.0", // v1.0 起為必填 }, body: JSON.stringify(rpcRequest), });
const rpcResponse = await res.json(); return rpcResponse.result.message; // 原為 result(任務物件) }}v0.1 到 v1.0 的變更整理
我的 TypeScript 用戶端用的是比 SDK 更舊的版本:tasks/send、{ type: "text" } 與 agent.json 都是 A2A v0.1 的寫法。
| 項目 | v0.1 | v1.0 |
|---|---|---|
| Agent Card 路徑 | /.well-known/agent.json | /.well-known/agent-card.json |
| JSON-RPC 方法 | tasks/send | SendMessage |
| role 的值 | "user" / "agent" | "ROLE_USER" / "ROLE_AGENT" |
| Part 結構 | { type: "text", text: "..." } | { text: "..." } |
| header | 無 | A2A-Version: 1.0 |
| 回應 | result 是一個 Task(訊息陣列) | result.message 是單一 Message |
連上 Cloud Run 上的真實 AI 代理
我確認了 TypeScript 的 A2A 用戶端可以跟 Cloud Run 上的真實漢堡 AI 代理對話:
const client = new A2AClient("https://burger-agent-XXXXXXXXXX.us-central1.run.app");
const card = await client.discover();// → Name: burger_seller_agent, Skills: ["create_burger_order"]
const reply = await client.sendMessage("What burgers do you have?");// → role: ROLE_AGENT// → "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, ..."模擬的測試與真實的 AI 代理使用的是同一份 A2A 用戶端程式碼,也是同一個 v1.0 協定。測試時指向 AIMock 的模擬伺服器,在 production 環境則指向 Cloud Run 上的真實 AI 代理,兩者之間只差在指向的 URL 不同。
總結
A2AStarletteApplication已經移除。改用路由工廠(create_jsonrpc_routes/create_agent_card_routes)搭配標準的 Starlette 應用- 型別不再是 Pydantic 模型,而是改成由
a2a.proto產生。直接建構即可,例如Part(text="...") - Agent Card 路徑從
agent.json改成agent-card.json - JSON-RPC 方法從
message/send改成SendMessage,而且必須帶A2A-Version: 1.0header - A2A AI 代理是 Starlette ASGI 伺服器,所以要部署到 Cloud Run(Docker 容器),而不是 Cloud Functions(單一函式處理模型)
- 在 Cloud Run 上,
HOST_OVERRIDE環境變數決定 Agent Card 對外的 URL - TypeScript 的 A2A 用戶端也必須改成 v1.0 的寫法:端點路徑、方法名稱、role 的值、header
- 因為模擬與真實 AI 代理共用同一份用戶端程式碼與協定,在測試與 production 環境之間切換只需要改指向的 URL 就好




