用 AIMock 讓 AI 代理測試變得決定性 — 在單一連接埠模擬 LLM、向量資料庫、MCP 與 A2A

AIMock 在單一連接埠上模擬整個 AI 技術堆疊(LLM、向量資料庫、MCP、A2A),讓 AI 代理測試套件在三秒內跑完,而且每次輸出都相同。

更新

黑色卡片上的白色 CopilotKit 字樣,右側是藍紫色風箏與藍色波浪尾巴
本頁目錄

引言

測試建立在 LLM 之上的應用程式有個問題:同一個提示送兩次,回來的內容會稍有不同。週一通過的測試,週二就失敗了,程式碼沒改,問題在模型本身的非決定性。

2026 年的 AI 代理應用讓這件事更嚴重。單一請求會碰到六、七個服務:除了 LLM,還有做檢索增強生成(RAG)的向量資料庫、Model Context Protocol(MCP)工具,以及走 Agent2Agent(A2A)協定的 AI 代理之間的流量。要怎麼模擬其中每一個,才是真正的問題。

AIMock 是 CopilotKit 在 2026 年 4 月釋出的開源模擬伺服器,只用單一連接埠就能把這些服務全部模擬起來。本文建立的測試套件,對著模擬的 LLM、向量資料庫與 A2A 服務跑 18 個案例只花 2.35 秒,每次的輸出都完全相同。

本文會先建立這套測試套件,再把 Google codelab 的 A2A AI 代理升級到 v1.0、部署到 Cloud Run,並讓同一個 TypeScript 用戶端指向它。

AIMock 是什麼

AIMock 是一個 npm 套件,用來模擬 AI 應用程式會對話的每一個服務。

  • 11 種以上的 LLM 供應商(OpenAI、Claude、Gemini、Bedrock、Azure、Vertex AI、Ollama、Cohere 等)
  • 完整支援 MCP JSON-RPC 2.0
  • A2A 的 AI 代理卡片探索與 server-sent events(SSE)串流
  • 向量資料庫模擬(Pinecone、Qdrant、ChromaDB)
  • 混沌測試:注入 500、格式錯誤的 JSON、串流中途斷線,既可以用自行架設的伺服器,也可以透過 setChaos() API
  • 零相依套件,只用 Node.js 內建模組

核心概念就是:一個設定檔、一個連接埠,涵蓋所有 AI 技術堆疊。

它在同類工具中的位置

我去找了競品,發現這個利基幾乎沒有直接對手。

工具定位與 AIMock 的重疊
Keploy錄製真實流量並產生測試,通用 API不支援 AI 專屬協定(MCP、A2A、SSE 串流)
VCR.py / Polly.js傳統的 HTTP 錄製與重播可以用來模擬 LLM,但沒有工具呼叫這類 AI 專屬功能
Portkey / Helicone具備快取與重播的 LLM 閘道面向正式環境,沒有故障注入,也不模擬 MCP

如果要測試包含 MCP 與 A2A 的 AI 代理應用程式,AIMock 目前是唯一選項。如果只需要模擬 OpenAI API,VCR 式的 HTTP 重播就夠了。

錄製與重播測試固件,以及它為什麼重要

錄製與重播是 AIMock 的核心功能之一:先擷取一次真實的 API 回應,之後在測試中原封不動地回放。

錄製與重播解決的是這個:

# 週一:測試通過
assert response.includes("SQL injection risk") ✅
# 週二:同樣的程式碼、同樣的提示,OpenAI 換了說法
assert response.includes("SQL injection risk") ❌
# 回傳的是 "potential SQL vulnerability"

有了測試固件,一次 LLM 呼叫的行為就像純函式。相同輸入,每次都是相同輸出。

面向沒有測試固件有測試固件
成本每跑一次測試就打一次真實 API零
速度每次 LLM 呼叫 2–30 秒1 毫秒以內
決定性每次輸出都不同位元組層級完全相同
CI 穩定度會因網路、速率限制、API 變更而不穩穩定
邊界情況無法重現永久保留下來

在 CI 用模擬是不是在逃避?

用模擬跑的 CI 測的是程式邏輯。提示的品質還是需要真實 API:

你要測的是什麼需要真實 API 嗎
JSON 解析器處理得了這種回應結構嗎不用,測試固件就夠
遇到 500 時重試邏輯有效嗎不用,混沌注入更合適
這個提示能不能產出好的輸出需要,對著模擬做不到
UI 有正確算繪出回應嗎不用,測試固件就夠

實務上最好的模式是兩層 CI 流水線:

  • 每個 PR:跑得快的模擬測試,驗證程式邏輯
  • 每晚或每週:對真實 API 跑整合測試,做偏移偵測與提示驗證

AIMock 的偏移偵測正好補上這一塊。每天在 CI 跑一次,它會三方比對 SDK 的型別定義、真實 API 的回應,以及 AIMock 自己的輸出,在使用者發現之前就先抓到供應商改了 API。

打造 demo:智慧採購助理

為了把主要功能都用過一輪,我為一個採購 AI 代理寫了測試。

架構

一個 AI 代理、四次呼叫、三個模擬服務中央的買家 AI 代理方框列出四個編號步驟:檢索、規劃、詢問賣家 AI 代理、評估。步驟 1 呼叫左側的 VectorMock,步驟 3 呼叫左側自行實作的賣家 AI 代理模擬;步驟 2 與步驟 4 都呼叫右側的 LLMock。VectorMock 與 LLMock 來自 AIMock,賣家 AI 代理模擬則為自行實作。最後 AI 代理將推薦結果回覆給使用者。使用者:「旅行用的輕量筆電,預算 1,200 美元」買家 AI 代理(TypeScript)1檢索取得過往評論與商品知識2規劃LLM 擬定搜尋策略3詢問賣家 AI 代理以 A2A 詢問符合條件的商品4評估LLM 比較回傳的報價VectorMockAIMock・相容 Pinecone賣家 AI 代理模擬自行實作・A2A v1.0LLMockAIMock・OpenAI /v1將推薦結果回覆使用者

一次請求會讓 AI 代理向外呼叫四次,而每一次都落在 localhost 上的模擬服務。

圖中的每一次呼叫,都會落在下列其中一個模擬上:

  • LLMock:模擬 OpenAI API(@copilotkit/aimock 的 LLMock 類別)
  • VectorMock:相容 Pinecone 的向量資料庫模擬(VectorMock 類別)
  • A2A 模擬:我用 http.createServer 自行實作的賣家 AI 代理

專案結構

aimock-a2a-demo/
├── src/
│ ├── buyer-agent/
│ │ ├── index.ts # BuyerAgent 主協調器
│ │ ├── a2a-client.ts # A2A 協定用戶端
│ │ ├── knowledge-base.ts # 向量 DB / RAG 層
│ │ └── reasoner.ts # LLM 推理
│ └── types/
│ ├── a2a.ts # A2A 協定型別
│ └── product.ts # 商品型別
├── tests/
│ ├── setup.ts # AIMock 設定
│ ├── buyer-agent.test.ts # 主要測試套件
│ └── chaos.test.ts # 混沌測試
├── fixtures/llm/ # 錄製的 LLM 回應
├── aimock.json # AIMock 設定檔
└── package.json

Step 1:設定 LLMock

LLMock 以 systemMessage 或 userMessage 的模式比對來註冊測試固件。

tests/setup.ts
import { LLMock, VectorMock } from "@copilotkit/aimock";
const llmock = new LLMock({ port: 0 }); // 隨機連接埠
// 依系統提示的內容分派測試固件
llmock.on({ systemMessage: "evaluating product offers" }, {
content: JSON.stringify({
topPickIndex: 0,
reasoning: "The MacBook Air M4 offers the best combination...",
confidence: 0.92,
}),
});
llmock.on({ systemMessage: "purchasing concierge" }, {
content: JSON.stringify({
refinedQuery: "ultrabook laptop under 1.3kg...",
priorities: ["weight under 1.3kg", "battery life 15+ hours"],
priceRange: { min: 900, max: 1300 },
}),
});
await llmock.start();
process.env.OPENAI_BASE_URL = `${llmock.url}/v1`;
process.env.OPENAI_API_KEY = "mock-key";

onMessage() 只比對使用者訊息的文字。當應用程式呼叫 LLM 超過一次時(這裡是兩次,一次規劃搜尋策略、一次評估報價),相似的使用者訊息可能會比對到非預期的測試固件。用 on({ systemMessage: "..." }) 依系統提示的內容來分流才可靠。

LLMock 會回傳第一個比對成功的測試固件,所以要先註冊比較明確的模式,把萬用的那個(onMessage(/.*/))放最後。

Step 2:設定 VectorMock

VectorMock 提供相容 Pinecone 的 API。

tests/setup.ts
const vectorMock = new VectorMock({ port: 0 });
vectorMock.addCollection("product-knowledge", { dimension: 1536 });
// 註冊靜態結果:每次都回傳同樣的結果
vectorMock.onQuery("product-knowledge", [
{
id: "review-macbook-air",
score: 0.94,
metadata: {
brand: "Apple",
model: "MacBook Air M4",
text: "MacBook Air M4: 1.24kg, 18hr battery life...",
},
},
// ... 其他項目
]);
await vectorMock.start();

QueryResult 型別沒有 text 欄位。文字資料要放在 metadata.text,應用程式再以 m.metadata?.text 讀回來。

VectorMock 理論上可以透過 mount() 掛在跟 LLMock 同一個連接埠上,但路徑前綴不會被去掉,所以實際可行的做法是讓它跑在自己的連接埠上。

Step 3:模擬 A2A 協定

A2A 是 Google 與 Linux Foundation 推出的開放 AI 代理間協定(v1.2,150 個以上的參與組織)。

A2A 是為跨組織的 AI 代理通訊設計的:讓你的 AI 代理去跟另一家公司的 AI 代理對話,例如金流服務或物流 AI 代理。在同一個應用程式內部的子 AI 代理之間使用 A2A 是殺雞用牛刀,一次函式呼叫就夠了。

在這個 demo 裡,A2A 位於買家 AI 代理與賣家 AI 代理之間;兩者設定上分屬不同組織,所以這裡用 A2A 是合理的。

我用 http.createServer 自行實作了 A2A 模擬,遵循 A2A v1.0:

tests/setup.ts
const server = createServer((req, res) => {
// AI 代理卡片探索:GET /.well-known/agent-card.json (v1.0)
if (req.url === "/.well-known/agent-card.json" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
name: "Mock Electronics Seller",
skills: [{ id: "product-search", name: "Product Search", ... }],
...
}));
return;
}
// JSON-RPC:POST /,SendMessage (v1.0)
if (req.method === "POST") {
const rpc = JSON.parse(body);
if (rpc.method === "SendMessage") {
res.end(JSON.stringify({
jsonrpc: "2.0", id: rpc.id,
result: {
message: {
role: "ROLE_AGENT",
parts: [{ data: { offers: mockOffers } }],
messageId: `reply-${rpc.id}`,
contextId: rpc.params?.message?.contextId,
},
},
}));
}
}
});

Step 4:執行測試

18 個測試案例,全部在三秒內跑完。

$ pnpm test
✓ tests/buyer-agent.test.ts (8 tests) 46ms
✓ A2A: Seller Agent Discovery > discovers seller agent card
✓ A2A: Seller Agent Discovery > checks skill availability
✓ A2A: Seller Agent Discovery > sends message and receives offers
✓ Vector DB: Knowledge Base > returns deterministic results
✓ Vector DB: Knowledge Base > same query twice returns identical results
✓ Vector DB: Knowledge Base > queryWithFallback returns empty on unreachable server
✓ Integration: Full Purchasing Flow > end-to-end: query → RAG → LLM → A2A → LLM
✓ Integration: Full Purchasing Flow > works without RAG knowledge
✓ tests/chaos.test.ts (10 tests) 2026ms
✓ Chaos: Vector DB failures > survives connection refused
✓ Chaos: Vector DB failures > respects timeout
✓ Chaos: Vector DB failures > throws on server error
✓ Chaos: LLM failures > malformed JSON
✓ Chaos: LLM failures > empty response
✓ Chaos: LLMock setChaos() > malformedRate — every response is garbled
✓ Chaos: LLMock setChaos() > dropRate — every request gets 500
✓ Chaos: LLMock setChaos() > clearChaos() restores normal operation
✓ Chaos: LLMock nextRequestError() > single request fails, next succeeds
✓ Resilience > agent degrades gracefully
Test Files 2 passed (2)
Tests 18 passed (18)
Duration 2.35s

決定性:

tests/buyer-agent.test.ts
test("same query twice returns identical results (determinism proof)", async () => {
const results1 = await kb.query("laptop");
const results2 = await kb.query("laptop");
// 真實的 Pinecone 沒有這種保證
expect(results1).toEqual(results2);
});

優雅降級:

tests/buyer-agent.test.ts
test("works without RAG knowledge (graceful degradation)", async () => {
const agent = new BuyerAgent({
sellerAgentUrl: sellerUrl,
knowledgeBase: {
apiKey: "mock-key",
baseUrl: "http://localhost:1", // 連不到 → 回退
indexName: "product-knowledge",
},
ragTimeoutMs: 500,
});
const result = await agent.findProduct({
query: "I need a lightweight laptop for travel",
});
// 即使 RAG 停擺,AI 代理仍能靠 LLM + A2A 運作
expect(result.topPick).toBeDefined();
expect(result.knowledgeUsed).toHaveLength(0);
});

把 baseUrl 指向一個連不到的連接埠,就能隨時讓 RAG 停擺;託管的 Pinecone 索引做不到這一點。

A2A 生態系的現狀

研究這個協定時,我去找了可以自由互動的公開 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 並讓它們走 A2A 對話的實作教學。在免費額度內就能跑完

demo 級的 AI 代理有了,正式環境的端點還沒有

已經存在的還不存在的
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 的測試用的是以 http.createServer 寫成的 A2A 賣家 AI 代理模擬。下一步是在本機跑一個真實的 A2A AI 代理:Google Purchasing Concierge codelab 裡的那一個。

複製 repo 並調整

終端機視窗
git clone https://github.com/alphinside/purchasing-concierge-intro-a2a-codelab-starter.git
cd purchasing-concierge-intro-a2a-codelab-starter/remote_seller_agents/burger_agent

這個漢堡 AI 代理是建立在 CrewAI、LiteLLM 與 Vertex AI Gemini 之上的 A2A 賣家 AI 代理。它接收使用者的漢堡訂單並處理。

這個 starter repo 要先做以下調整才能跑起來:

1. 改用 Python 3.13

pyproject.toml
requires-python = ">=3.13"
Dockerfile
FROM python:3.13-slim

2. 重新產生 uv.lock

uv.lock 會把每個套件的完整下載 URL 寫死,所以產生它的那台機器當時指向哪個索引,鎖定檔就記下哪個。我那份是在安全代理鏡像後面產生的,裡面每一個 URL 都要經過那個鏡像,換到連不到它的環境,下載就會逾時。

終端機視窗
rm uv.lock
UV_DEFAULT_INDEX=https://pypi.org/simple/ uv lock --python 3.13

這是因為 uv sync --frozen 會直接沿用鎖定檔裡寫死的 URL。改索引設定並不會改掉鎖定檔裡既有的 URL,只能重新產生。

另外也在 pyproject.toml 裡用一個標記 default = true 的 [[tool.uv.index]] 項目把索引固定下來:

pyproject.toml
[[tool.uv.index]]
url = "https://pypi.org/simple/"
default = true

3. 把 litellm 加成明確的相依套件

agent.py 裡有 import litellm,但 litellm 並不在 pyproject.toml 的相依清單中。它以前是靠 CrewAI 的遞移相依帶進來的,新版本已經拿掉了。

終端機視窗
UV_DEFAULT_INDEX=https://pypi.org/simple/ uv add litellm

把 a2a-sdk 從 v0.2 遷移到 v1.0

原始程式碼寫的是 a2a-sdk v0.2,但 >=0.2.16 這個限制會安裝到 v1.1.2,它的破壞性變更會產生 import 錯誤。我把程式碼更新到 v1.0。

主要變更:

項目v0.2v1.0
伺服器建構A2AStarletteApplication 類別create_jsonrpc_routes() / create_agent_card_routes() 路由工廠
AgentCardurl 欄位supported_interfaces 清單(AgentInterface)
RequestHandler沒有 agent_cardagent_card 為必填
Part 建構Part(root=TextPart(text="..."))Part(text="...")(以 Protobuf 為基礎)
JSON-RPC 方法tasks/sendSendMessage
AI 代理卡片路徑/.well-known/agent.json/.well-known/agent-card.json
協定版本無(隱含 0.3)必須帶 A2A-Version: 1.0 標頭
錯誤型別ServerError直接使用 UnsupportedOperationError

__main__.py,伺服器建構:

__main__.py
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.types import AgentCard, AgentInterface, AgentCapabilities, AgentSkill
from 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 為基礎的型別:

agent_executor.py
from a2a.types import Message, Part, Role, Task
from 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。

agent.py
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 API
gcloud services enable aiplatform.googleapis.com
# 在本機啟動
uv run . --host 127.0.0.1 --port 8080

確認 AI 代理卡片:

終端機視窗
$ 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。Cloud Functions 每個請求呼叫一個 Python 函式;A2A AI 代理則是一個常駐的 HTTP 伺服器,帶著數個 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,讓 AI 代理卡片的 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 用戶端的變更

src/buyer-agent/a2a-client.ts
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 標頭
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.3 到 v1.0 的變更整理

項目v0.3v1.0
AI 代理卡片路徑/.well-known/agent.json/.well-known/agent-card.json
JSON-RPC 方法tasks/sendSendMessage
role 的值"user" / "agent""ROLE_USER" / "ROLE_AGENT"
Part 結構{ type: "text", text: "..." }{ text: "..." }
標頭無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 的模擬伺服器,正式環境指向 Cloud Run 上的真實 AI 代理,兩者之間只差一個 URL。

用 llmock CLI 錄製測試固件

抽象地談完錄製與重播之後,我實際跑了一次。

啟動 llmock

@copilotkit/aimock 套件附帶一個名為 llmock 的 CLI 執行檔。--record flag 會讓它以錄製代理伺服器的形式啟動。

終端機視窗
# 以錄製模式啟動 llmock,監聽 4010 連接埠
pnpm mock:record
# → llmock --record --provider-openai https://api.openai.com -f ./fixtures/llm

讓應用程式經由 llmock 執行

把 OPENAI_BASE_URL 指向 llmock,應用程式中每一次 OpenAI SDK 呼叫就都會經過它。應用程式從 .env 讀取 API 金鑰,llmock 則透明地轉發給 OpenAI。

終端機視窗
OPENAI_BASE_URL=http://localhost:4010/v1 \
SELLER_URL=https://burger-agent-XXXXXXXXXX.us-central1.run.app \
pnpm dev

錄下來的測試固件

像這樣的 JSON 會出現在 fixtures/llm/recorded/:

{
"fixtures": [{
"match": {
"userMessage": "User request: \"I need a lightweight laptop...\"",
"model": "gpt-4o-mini",
"turnIndex": 0,
"hasToolResult": false
},
"response": {
"content": "{\"refinedQuery\": \"lightweight travel laptop under $1200\", ...}"
},
"metadata": {
"systemHash": "a11524e7"
}
}]
}

match 底下的欄位(userMessage、model、turnIndex)就是比對條件。從此之後,只要請求符合同樣的條件,就會拿到這份回應,不會再打到 OpenAI。

實際用起來的感覺

  1. pnpm mock:record 啟動代理伺服器
  2. OPENAI_BASE_URL=http://localhost:4010/v1 pnpm dev 照常執行應用程式
  3. 測試固件自己存進 fixtures/llm/recorded/
  4. 之後 pnpm mock:start 就會重播它們

完全不用改程式碼。 改的只有 OPENAI_BASE_URL 指向哪裡。

LLMock 的混沌注入

前面的測試結果裡,setChaos() 與 nextRequestError() 這幾個測試到目前為止還沒解釋。它們用的是 LLMock 的程式化混沌注入 API,可以重現 LLM 的失敗情境,不必像 Chaos: Vector DB failures 與 Chaos: LLM failures 那幾個測試一樣,自己架一台 HTTP 伺服器來偽造 500 或逾時。

setChaos():機率性的故障注入

setChaos() 設定的是套用到每個請求的失敗機率。

tests/chaos.test.ts
test("setChaos({ malformedRate: 1 }) — every response is garbled", async () => {
const mock = new LLMock({ port: 0 });
// 註冊有效的測試固件(/.*/ 會比對所有訊息)
mock.onMessage(/.*/, {
content: '{"refinedQuery": "test", "priorities": [], "priceRange": {"min": 0, "max": 100}}',
});
// 混沌:100% 破壞回應
mock.setChaos({ malformedRate: 1.0 });
await mock.start();
process.env.OPENAI_BASE_URL = `http://localhost:${mock.port}/v1`;
const reasoner = new Reasoner("gpt-4o-mini");
// 測試固件有比對到,但混沌把回應弄壞了
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
await mock.stop();
});

ChaosConfig 針對每一種失敗模式各設一個機率:

參數效果沒有測試固件也能用
dropRate回傳 500可以
malformedRate回傳 {malformed json: <<<chaos>>>}不行,需要比對成功
disconnectRate在串流中途切斷 TCP 連線不行,需要比對成功

有個陷阱:onMessage("*") 比對的是字面上的字串 *,不是萬用字元。要比對所有訊息得用正規表示式 /.*/。

clearChaos():把它關掉

clearChaos() 會移除混沌設定,恢復正常的測試固件回應。適合用來測試從失敗中恢復。

tests/chaos.test.ts
test("clearChaos() restores normal operation", async () => {
const mock = new LLMock({ port: 0 });
mock.onMessage(/.*/, { content: validJson });
mock.setChaos({ malformedRate: 1.0 });
await mock.start();
// ... 環境設定 ...
const reasoner = new Reasoner("gpt-4o-mini");
// 開啟混沌:失敗
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
// 關閉混沌:正常運作
mock.clearChaos();
const strategy = await reasoner.buildSearchStrategy("laptop", []);
expect(strategy.refinedQuery).toBe("laptop"); // ✓ 有效的 JSON
});

nextRequestError():一次性的錯誤注入

nextRequestError() 只會讓下一個請求回傳錯誤,用過一次就失效,很適合「失敗一次、重試就成功」的情境。

tests/chaos.test.ts
test("nextRequestError(400) — single request fails, next succeeds", async () => {
const mock = new LLMock({ port: 0 });
mock.onMessage(/.*/, { content: validJson });
// 一次性:只有下一個請求回傳 400
mock.nextRequestError(400, { message: "Bad Request" });
await mock.start();
// ... 環境設定 ...
const reasoner = new Reasoner("gpt-4o-mini");
// 第一次呼叫:400
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
// 第二次呼叫:一次性錯誤已用掉 → 正常回應
const strategy = await reasoner.buildSearchStrategy("laptop", []);
expect(strategy.refinedQuery).toBe("laptop"); // ✓
});

OpenAI SDK 會自動重試 5xx 錯誤(500、503 等),所以一次性的錯誤測試要用 SDK 不會重試的 4xx。

測試執行結果

$ pnpm test
✓ tests/buyer-agent.test.ts (8 tests) 42ms
✓ tests/chaos.test.ts (10 tests) 1918ms
✓ Chaos: Vector DB failures > survives connection refused
✓ Chaos: Vector DB failures > respects timeout
✓ Chaos: Vector DB failures > throws on server error
✓ Chaos: LLM failures > malformed JSON
✓ Chaos: LLM failures > empty response
✓ Chaos: LLMock setChaos() > malformedRate — every response is garbled
✓ Chaos: LLMock setChaos() > dropRate — every request gets 500
✓ Chaos: LLMock setChaos() > clearChaos() restores normal operation
✓ Chaos: LLMock nextRequestError() > single request fails, next succeeds
✓ Resilience > agent degrades gracefully
Test Files 2 passed (2)
Tests 18 passed (18)
Duration 2.2s

兩種做法都可行。Chaos: Vector DB failures 與 Chaos: LLM failures 用的是自行架設的 HTTP 伺服器,setChaos() 與 nextRequestError() 那幾個測試用的是 LLMock 內建的混沌 API,而 agent degrades gracefully 兩邊都不屬於。API 版本的程式碼比較少,意圖也更清楚。

總結

我用 AIMock 建立了一套 AI 代理測試,接著把 Google codelab 的真實 A2A AI 代理遷移到 a2a-sdk v1.0、在本機跑起來、部署到 Cloud Run,再從 TypeScript 買家 AI 代理連上它。

AIMock 值得用的場合:

  • 測試同時結合 LLM、向量資料庫與其他數個服務的 AI 代理應用程式
  • 在 CI/CD 中快速、便宜、穩定地跑測試
  • 透過故障注入驗證容錯能力

用 AIMock 就太殺雞用牛刀的場合:

  • 只要模擬 OpenAI API 的情況,VCR 式的 HTTP 重播就夠了
  • 評估提示的品質,這件事對著模擬做不到

AIMock 的實作筆記:

  • onMessage() 只比對使用者訊息。on({ systemMessage }) 才能依系統提示分流
  • onMessage("*") 比對的是字面上的 *,不是萬用字元。要比對全部請用 onMessage(/.*/)
  • 測試固件的註冊順序有影響:先比對到的先勝出
  • VectorMock 的 QueryResult 沒有 text 欄位,文字要放進 metadata
  • VectorMock 要跑在跟 LLMock 分開的自有連接埠上
  • setChaos() 的 malformedRate 與 disconnectRate 需要測試固件比對成功,只有 dropRate 不需要
  • OpenAI SDK 會自動重試 5xx。用 nextRequestError() 測試錯誤時要用 4xx

a2a-sdk v1.0 遷移與部署的心得:

  • A2AStarletteApplication 已經消失。改用路由工廠(create_jsonrpc_routes / create_agent_card_routes)搭配標準的 Starlette 應用
  • 型別系統從 Pydantic 換成 Protobuf。直接建構即可,例如 Part(text="...")
  • AI 代理卡片路徑從 agent.json 改成 agent-card.json
  • JSON-RPC 方法從 tasks/send 改成 SendMessage,而且必須帶 A2A-Version: 1.0 標頭
  • uv.lock 寫死 PyPI 鏡像 URL 的問題,用 UV_DEFAULT_INDEX 重新產生鎖定檔就能解決
  • A2A AI 代理是 Starlette ASGI 伺服器,所以要部署到 Cloud Run(Docker 容器),而不是 Cloud Functions(單一函式處理模型)
  • 在 Cloud Run 上,HOST_OVERRIDE 環境變數決定 AI 代理卡片對外的 URL
  • TypeScript 的 A2A 用戶端需要同樣的 v1.0 調整:端點路徑、方法名稱、role 的值、標頭
  • 因為模擬與真實 AI 代理共用同一份用戶端程式碼與協定,在測試與正式環境之間切換只是改一個 URL

參考連結

分享這篇文章