
用 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 passesassert response.includes("SQL injection risk") ✅
# Tuesday: same code, same prompt, OpenAI rephrasedassert 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:智慧採購助理
為了把主要功能都用過一輪,我為一個採購智能體寫了測試。
架構
一次請求會讓智能體向外呼叫四次,而每一次都落在 localhost 上的模擬服務。
測試裡用到三樣東西:
- LLMock:模擬 OpenAI API(
@copilotkit/aimock的LLMock類別) - 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.jsonStep 1:設定 LLMock
LLMock 以 systemMessage 或 userMessage 的模式比對來註冊測試固件。
import { LLMock, VectorMock } from "@copilotkit/aimock";
const llmock = new LLMock({ port: 0 }); // random port
// route fixtures by the content of the system promptllmock.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。
const vectorMock = new VectorMock({ port: 0 });
vectorMock.addCollection("product-knowledge", { dimension: 1536 });
// register static results — the same results come back every timevectorMock.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:
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其中兩個值得拿出來看。
證明決定性:
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);});優雅降級:
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 Agent:
https://hello-world-gxfr.onrender.com/.well-known/agent.json。第一個公開的 A2A 智能體。只會回聲,但用來確認用戶端能不能動很有用 - A2A Registry Playground:a2a-registry.org/playground,可以跟已登錄的智能體對話的實驗場
- Google codelab:Purchasing 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.gitcd purchasing-concierge-intro-a2a-codelab-starter/remote_seller_agents/burger_agent這個漢堡智能體是建立在 CrewAI、LiteLLM 與 Vertex AI Gemini 之上的 A2A 賣家智能體。它接收使用者的漢堡訂單並處理。
相對於原始儲存庫,需要四處調整。
1. 改用 Python 3.13
requires-python = ">=3.13"FROM python:3.13-slim2. 重新產生 uv.lock
uv.lock 會把每個套件的完整下載 URL 寫死,因此它帶著產生它的那台機器所指向的索引來源。我的那份是在安全代理鏡像後面產生的,鎖定檔裡每一個 URL 都繞過那個鏡像。在連不到該鏡像的環境下,下載就會逾時。
rm uv.lockUV_DEFAULT_INDEX=https://pypi.org/simple/ uv lock --python 3.13這是因為 uv sync --frozen 會直接沿用鎖定檔裡寫死的 URL。改索引設定並不會改掉鎖定檔裡既有的 URL,只能重新產生。
在專案內也一併覆寫索引是值得的:
[[tool.uv.index]]url = "https://pypi.org/simple/"default = true3. 把 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,伺服器建構:
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, # 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 為基礎的型別:
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: 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。
# 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 enabledgcloud services enable aiplatform.googleapis.com
# start it locallyuv 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 用戶端的變更
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 4010pnpm 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。
實際用起來的感覺
流程之簡單是最令人意外的地方:
pnpm mock:record啟動代理伺服器OPENAI_BASE_URL=http://localhost:4010/v1 pnpm dev照常執行應用程式- 測試固件自己存進
fixtures/llm/recorded/ - 之後
pnpm mock:start就會重播它們
完全不用改程式碼。 改的只有 OPENAI_BASE_URL 指向哪裡。
LLMock 的混沌注入
上面那次執行裡有四個測試(setChaos() 與 nextRequestError() 那幾個)到目前為止還沒解釋。它們用的是 LLMock 的程式化混沌注入 API,可以重現 LLM 的失敗情境,不必像前五個混沌測試那樣自己架一台 HTTP 伺服器來偽造 500 或逾時。
setChaos():機率性的故障注入
setChaos() 設定的是套用到每個請求的失敗機率。
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() 會移除混沌設定,恢復正常的測試固件回應。適合用來測試從失敗中恢復。
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() 只會讓下一個請求回傳錯誤,然後就自我消耗。很適合「失敗一次、重試就成功」的情境。
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()的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="...") - 智能體卡片路徑從
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