標籤
黑色卡片上的白色 CopilotKit 字樣,右側是藍紫色風箏與藍色波浪尾巴

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

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

本頁目錄

引言

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

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

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

本文會走過 demo 智能體與它的測試、錄製與重播真實的測試固件、LLMock 的混沌注入 API,然後是另一半練習:把 Google codelab 的漢堡智能體從 a2a-sdk v0.2 遷移到 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 的智能體卡片探索與 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 的智能體應用程式,AIMock 目前是唯一選項。如果只需要模擬 OpenAI API,VCR 式的 HTTP 重播就夠了。

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

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

第一次看到它時,我想不通這跟 n8n 那種重跑工作流程中間步驟的做法差在哪裡。結果兩者要解的問題根本不同。

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

# Monday: test passes
assert response.includes("SQL injection risk") ✅
# Tuesday: same code, same prompt, OpenAI rephrased
assert response.includes("SQL injection risk") ❌
# it returned "potential SQL vulnerability"

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

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

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

「用模擬跑的 CI 抓得到正式環境的問題嗎」是個合理的疑問。答案是:它測的本來就是別的東西。

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

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

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

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

打造 demo:智慧採購助理

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

架構

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

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

測試裡用到三樣東西:

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

專案結構

aimock-a2a-demo/
├── src/
│ ├── buyer-agent/
│ │ ├── index.ts # the main BuyerAgent orchestrator
│ │ ├── a2a-client.ts # A2A protocol client
│ │ ├── knowledge-base.ts # vector DB / RAG layer
│ │ └── reasoner.ts # LLM reasoning
│ └── types/
│ ├── a2a.ts # A2A protocol types
│ └── product.ts # product types
├── tests/
│ ├── setup.ts # AIMock setup
│ ├── buyer-agent.test.ts # main test suite
│ └── chaos.test.ts # chaos tests
├── fixtures/llm/ # recorded LLM responses
├── aimock.json # AIMock config
└── package.json

Step 1:設定 LLMock

LLMock 以 systemMessageuserMessage 的模式比對來註冊測試固件。

tests/setup.ts
import { LLMock, VectorMock } from "@copilotkit/aimock";
const llmock = new LLMock({ port: 0 }); // random port
// route fixtures by the content of the system prompt
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 });
// register static results — the same results come back every time
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...",
},
},
// ... more entries
]);
await vectorMock.start();

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

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

Step 3:模擬 A2A 協定

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

它真正站得住腳的地方是跨組織的智能體通訊,也就是你的智能體去跟另一家公司的智能體對話,例如金流服務或物流智能體。在同一個應用程式內部的子智能體之間使用 A2A 是殺雞用牛刀,一次函式呼叫就夠了。

在這個 demo 裡,A2A 位於買家智能體與賣家智能體之間。它們設定上分屬不同組織,所以是正當的用法。

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

tests/setup.ts
const server = createServer((req, res) => {
// Agent card discovery: 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");
// real Pinecone gives no such guarantee
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", // unreachable → falls back
indexName: "product-knowledge",
},
ragTimeoutMs: 500,
});
const result = await agent.findProduct({
query: "I need a lightweight laptop for travel",
});
// even with RAG down, the agent still works via LLM + A2A
expect(result.topPick).toBeDefined();
expect(result.knowledgeUsed).toHaveLength(0);
});

這個測試沒有模擬基礎設施是很難寫的。總不能為了跑它就去把一個真實的 Pinecone 執行個體停掉。

A2A 生態系的現狀

研究這個協定時,我去找了可以自由互動的公開智能體。

找得到的東西

  • Hello World Agenthttps://hello-world-gxfr.onrender.com/.well-known/agent.json。第一個公開的 A2A 智能體。只會回聲,但用來確認用戶端能不能動很有用
  • A2A Registry Playgrounda2a-registry.org/playground,可以跟已登錄的智能體對話的實驗場
  • Google codelabPurchasing Concierge,把兩個智能體部署到 Cloud Run 並讓它們走 A2A 對話的實作教學。在免費額度內就能跑完

率直的評估

已經存在的 還不存在的
Hello world / 回聲類智能體 真實企業公開的正式 A2A 端點
Google codelab 的示範 Stripe、Twilio 這類廠商的 A2A API
社群的玩具型智能體 具備真正實用能力的智能體市集

A2A 有 150 個以上的參與組織、GitHub 星數超過 22,000,但幾乎沒有可以自由互動又真正有用的公開智能體。企業端的 A2A 採用(Azure AI Foundry、Bedrock AgentCore)幾乎全部躲在認證後面。

協定本身已經成熟(v1.2,隸屬 Linux Foundation)。智能體還沒跟上。

在本機跑起 Google codelab 的真實 A2A 智能體

前半部用的是以 http.createServer 寫成的 A2A 賣家智能體模擬。下一步是在本機跑一個真實的 A2A 智能體:Google Purchasing Concierge codelab 裡的那一個。

複製儲存庫並調整

終端機視窗
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

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

相對於原始儲存庫,需要四處調整。

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
[[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.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 方法 tasks/send SendMessage
智能體卡片路徑 /.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, # required in 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: construct directly with Part(text="..."), no TextPart wrapper
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
# Before: def invoke(self, query, sessionId) -> str:
async def invoke(self, query, sessionId) -> str:
# ...
# Before: response = crew.kickoff(inputs)
response = await crew.kickoff_async(inputs)
return response

確認可以動

終端機視窗
# the Vertex AI API has to be enabled
gcloud services enable aiplatform.googleapis.com
# start it locally
uv run . --host 127.0.0.1 --port 8080

確認智能體卡片:

終端機視窗
$ 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。這個漢堡智能體是由 uvicorn 啟動的 Starlette ASGI 伺服器,因此合適的是 Cloud Run 而不是 Cloud Functions。Cloud Functions 每個請求呼叫一個 Python 函式;A2A 智能體則是一個常駐的 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。

部署後要設一個環境變數,讓智能體卡片的 url 欄位反映 Cloud Run 的 URL:

終端機視窗
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 之後,執行個體在請求之間會縮到零,不會計費。代價是 10–15 秒的冷啟動,對 demo 或測試來說沒問題。

讓 TypeScript 買家智能體支援 A2A v1.0

要連上 Cloud Run 上的真實賣家智能體,TypeScript 的 A2A 用戶端也得做 v1.0 的調整。

A2A 用戶端的變更

src/buyer-agent/a2a-client.ts
export class A2AClient {
// v1.0: agent-card.json (was: 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 plus the A2A-Version header
async sendMessage(text: string, contextId?: string): Promise<A2AMessage> {
const rpcRequest = {
jsonrpc: "2.0",
id: messageId,
method: "SendMessage", // was: "tasks/send"
params: {
message: {
role: "ROLE_USER", // was: "user"
parts: [{ text }], // was: { type: "text", text }
messageId,
contextId,
},
},
};
const res = await fetch(this.agentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"A2A-Version": "1.0", // required in v1.0
},
body: JSON.stringify(rpcRequest),
});
const rpcResponse = await res.json();
return rpcResponse.result.message; // was: result (a task object)
}
}

v0.3 到 v1.0 的變更整理

項目 v0.3 v1.0
智能體卡片路徑 /.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: "..." }
標頭 A2A-Version: 1.0
回應 result 是一個 Task(訊息陣列) result.message 是單一 Message

連上 Cloud Run 上的真實智能體

我確認了 TypeScript 的 A2A 用戶端可以跟 Cloud Run 上的真實漢堡智能體對話:

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

模擬的測試與真實的智能體,走的是同一份 A2A 用戶端程式碼與同一個 v1.0 協定。測試指向 AIMock 的模擬伺服器,正式環境指向真實的 Cloud Run 智能體。改的只有 URL。

實際錄製測試固件

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

啟動 llmock

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

終端機視窗
# start llmock in record mode, listening on port 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 底下的欄位(userMessagemodelturnIndex)就是比對條件。從此之後,只要請求符合同樣的條件,就會拿到這份回應,不會再打到 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 的失敗情境,不必像前五個混沌測試那樣自己架一台 HTTP 伺服器來偽造 500 或逾時。

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

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

tests/chaos.test.ts
test("setChaos({ malformedRate: 1 }) — every response is garbled", async () => {
const mock = new LLMock({ port: 0 });
// register a valid fixture (/.*/ matches every message)
mock.onMessage(/.*/, {
content: '{"refinedQuery": "test", "priorities": [], "priceRange": {"min": 0, "max": 100}}',
});
// chaos: corrupt the response 100% of the time
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");
// the fixture matches, but chaos breaks the response
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();
// ... env setup ...
const reasoner = new Reasoner("gpt-4o-mini");
// chaos on — it fails
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
// chaos off — it works
mock.clearChaos();
const strategy = await reasoner.buildSearchStrategy("laptop", []);
expect(strategy.refinedQuery).toBe("laptop"); // ✓ valid 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 });
// one-shot: return 400 for the next request only
mock.nextRequestError(400, { message: "Bad Request" });
await mock.start();
// ... env setup ...
const reasoner = new Reasoner("gpt-4o-mini");
// first call: 400
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
// second call: the one-shot is spent → normal response
const strategy = await reasoner.buildSearchStrategy("laptop", []);
expect(strategy.refinedQuery).toBe("laptop"); // ✓
});

要注意 OpenAI SDK 會自動重試 5xx 錯誤(500、503 等)。用一次性注入可靠地測試錯誤時,要用不會被重試的 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

兩種做法都可行:自行架設 HTTP 伺服器的混沌測試(前五個),以及 LLMock 內建的混沌 API(接下來四個;最後一個測試 agent degrades gracefully 兩邊都不屬於)。API 版本的程式碼比較少,意圖也更清楚。

總結

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

AIMock 站得住腳的地方:

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

殺雞用牛刀的地方:

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

AIMock 的實作筆記:

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

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

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

參考連結

分享這篇文章