1. 為什麼 ChatGPT App 需要負載測試?
在傳統 Web 世界,負載測試常被聯想到「上百萬 RPS、巨型叢集、SRE 吃披薩」的圖景。對 ChatGPT App 與 MCP 伺服器而言,現實更簡單、也更省錢。你基本已熟悉 SLO,我們來看看 SLO/可觀測性與 feed 品質在負載下如何互動。
關鍵特性:ChatGPT 會等待 tool call 完成後才繼續產生回覆。使用者會看到漂亮的 token 串流,但一旦模型決定呼叫工具,串流的魔法就結束了——直到後端回應。如果你的 MCP 或 ACP 伺服器有時回應 8–10 秒,而目標是 2–4 秒,UX 會從「神奇助理」變成「又一個慢網站」。
另外還有硬性的逾時預算: 對工具呼叫,OpenAI 設有大約數十秒的上限(確切數字依模式而異,但應該以 30–60 秒思考;就 UX 而言甚至希望在 5–10 秒內)。如果在尖峰負載時你的 tool calls 突然落在 25–30 秒,你雖然形式上仍在上限內,但對使用者而言已經「壞掉」。
第二點:我們在乎的不是抽象的 RPS,而是併發度。對來自 Store 的 App,50–100 位同時活躍使用者相當現實;這正是你要驗證的,而不是「能否撐住 50k RPS 的合成 GET /health」。
最後,ChatGPT App 是一個堆疊(stack):
flowchart LR User --> ChatGPT ChatGPT -->|tools/call| MCP["GiftGenius 的 MCP 伺服器"] MCP --> DB["禮品 feed 資料庫"] MCP --> ACP["Checkout / ACP 後端"] ACP --> PSP["金流 / Stripe"]
如果我們不檢查這個堆疊在小而真實的負載下如何運作,任何一次行銷推播或被收錄進 Store 精選,都可能把它快速變成「LLM 產品反面教材」的投影片。
在本講次裡,「輕量負載測試」指的是短時程(通常 1–10 分鐘)的壓測,用來檢查:
- 系統是否承受預期的尖峰同時在線;
- p95/p99 延遲是否飆破 SLO;
- 是否出現大量錯誤、逾時,以及外部 API 的 rate limit 限制。
同時也看看品質的另一面——產品 feed(以下簡稱「feed」)的資料;沒有它,再好的 GiftGenius 也不會既「Gift」又「Genius」。
本講次前半先處理 MCP/ACP 的輕量負載測試(該壓哪裡、怎麼壓、看哪些指標),再把它落到可觀測性(延遲、錯誤、資源、webhook 與日誌);後半則談 feed 的品質,以及它如何在負載下意外出槌。
2. 該壓什麼:不是 ChatGPT,而是你自己的 API
先釘牢一件事,避免之後混淆:負載測試要直接針對我們的後端——MCP 伺服器、ACP endpoint、webhook——而不是透過 ChatGPT 的 UI。
原因有幾個。
- 其一,省錢。如果透過 ChatGPT 發送真實的 tool calls,你會為 token 付費,還同時撞上 ChatGPT 的限制,但你其實在測你自己的程式碼。
- 其二,可預測。直接呼叫 /mcp 或 /api/checkout,你能完全控制腳本情境,而不依賴模型此刻會不會呼叫該工具。
- 其三,透明。在負載下你想清楚看見:5 分鐘內打了 2000 個 MCP 請求、延遲分佈、CPU 圖表。如果把負載繞經 ChatGPT,額外的雜訊與限制只會讓畫面更複雜。
GiftGenius 負載測試的典型 endpoint 清單:
- MCP 伺服器中實作 JSON‑RPC tools 的 endpoint(/mcp 或同類);
- 一到兩個 ACP endpoint,用於建立與完成 checkout(在金流的 sandbox 模式中);
- 或許再加上處理金流 webhook 的 endpoint,看看在事件尖峰時的行為。
假設我們有一個 Next.js 16 後端,裡面跑著 MCP 伺服器,對外提供 /api/mcp,以及一個 ACP 伺服器,提供 /api/checkout/create。
3. GiftGenius 的 smoke‑load 迷你情境
假設產品經理相當樂觀,說:「合理的尖峰是 50 位同時使用者,每個人會進來、選個禮物,偶爾走到付款。」
對輕量負載測試而言,模擬 30–50 位「虛擬使用者」(VU)就足夠;每位依序執行:
- 呼叫工具 giftgenius.search_gifts(依個人設定與預算搜尋禮物)。
- 對結果中的兩個商品呼叫 giftgenius.get_gift_details。
- (偶爾)針對其中一個商品呼叫 ACP endpoint create_checkout_session。
以上全部透過 HTTP 直接打我們的 MCP/ACP,不經 ChatGPT。
對 MCP 的 JSON‑RPC 呼叫
對 MCP 的請求體(簡化版)範例:
const body = {
jsonrpc: "2.0",
id: "test-" + Math.random(),
method: "tools/call",
params: {
toolName: "giftgenius.search_gifts",
arguments: {
occasion: "birthday",
budget: 50,
interests: ["sport", "books"],
},
},
};
在真實專案中結構可能略有差異,但原則相同:一個 JSON‑RPC 方法,裡面是 tool 與參數。
4. 用 TypeScript 寫一個簡單的負載腳本
第一步實作最簡單的情境——對 MCP 呼叫 giftgenius.search_gifts。先寫一個最小的 Node.js TypeScript 腳本,發送請求到 /api/mcp 並量測延遲,之後再加上 checkout 與更複雜的路徑。
基礎 HTTP 客戶端
假設我們有一個 .env,其中 MCP_URL=http://localhost:3000/api/mcp。
// scripts/loadTest.ts
import "dotenv/config";
const MCP_URL = process.env.MCP_URL!;
async function callSearchGifts() {
const body = {
jsonrpc: "2.0",
id: `search-${Date.now()}-${Math.random()}`,
method: "tools/call",
params: {
toolName: "giftgenius.search_gifts",
arguments: { occasion: "birthday", budget: 50 },
},
};
const started = Date.now();
const res = await fetch(MCP_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const latencyMs = Date.now() - started;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return latencyMs;
}
也可以順手加上對 JSON 回應的解析,但就量測延遲與錯誤率而言,這樣已足夠。
並行啟動多個請求
我們需要控制同時請求的數量。為簡單起見,固定一個「虛擬使用者」數量,讓每個人連續做 N 次請求。
async function runVirtualUser(iterations: number) {
const latencies: number[] = [];
for (let i = 0; i < iterations; i++) {
try {
const ms = await callSearchGifts();
latencies.push(ms);
} catch (e) {
console.error("Error in VU:", e);
latencies.push(-1); // 標記為錯誤
}
}
return latencies;
}
現在可以啟動例如 20 位虛擬使用者:
async function main() {
const users = 20;
const iterations = 10;
const tasks = Array.from({ length: users }, () =>
runVirtualUser(iterations),
);
const results = await Promise.all(tasks);
const all = results.flat();
// ...統計指標
}
main().catch((e) => console.error(e));
這已能產生約 200 次 MCP 呼叫,部分會並行執行,帶來相當不錯的併發度。
計算 p95 與錯誤率
加上一個小工具來計算分位數與錯誤。提醒:p95 是 95% 請求落在其下方的值。
function percentile(values: number[], p: number) {
const sorted = values.filter(v => v >= 0).sort((a, b) => a - b);
if (!sorted.length) return 0;
const idx = Math.floor((p / 100) * (sorted.length - 1));
return sorted[idx];
}
function errorRate(values: number[]) {
const total = values.length;
const errors = values.filter(v => v < 0).length;
return (errors / total) * 100;
}
然後在 main 中輸出:
const p95 = percentile(all, 95);
const p99 = percentile(all, 99);
const errRate = errorRate(all);
console.log(`Total: ${all.length}`);
console.log(`p95: ${p95} ms, p99: ${p99} ms`);
console.log(`Error rate: ${errRate.toFixed(2)}%`);
現在你就有了最小的 smoke‑load 腳本,可在本機或 staging 上於發佈前執行。不需要碰 ChatGPT、不會燒 token,注意力全部放在你的 MCP。
如何處理 ACP 與 checkout
同樣可以加上一個 helper callCreateCheckoutSession,打 ACP endpoint。務必使用金流的測試/沙箱模式,避免灌入真單。典型呼叫是帶 JSON 的一般 POST:
async function callCreateCheckoutSession(productId: string) {
const started = Date.now();
const res = await fetch("http://localhost:3000/api/checkout/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId, test: true }),
});
const latencyMs = Date.now() - started;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return latencyMs;
}
接著你可以在 runVirtualUser 中做一個節奏:搜尋 3 次 → checkout 1 次,去模擬「搜尋多於購買」的漏斗。
5. 更正式的工具:k6(但用法保持簡單)
Node 腳本很適合作為「最小入口」,但有時使用專門工具更方便,例如 k6:腳本寫 JavaScript、執行時是 Go(很快)。
一個小型的 MCP k6 腳本:
// loadtest-mcp.js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "30s", target: 30 },
{ duration: "2m", target: 30 },
],
};
export default function () {
const payload = JSON.stringify({
jsonrpc: "2.0",
id: `search-${Math.random()}`,
method: "tools/call",
params: {
toolName: "giftgenius.search_gifts",
arguments: { occasion: "birthday", budget: 50 },
},
});
const res = http.post(__ENV.MCP_URL, payload, {
headers: { "Content-Type": "application/json" },
});
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1);
}
執行指令:
MCP_URL=http://localhost:3000/api/mcp k6 run loadtest-mcp.js
k6 會自動計算 p95/p99 與錯誤率,並產生漂亮的報告——之後你可以匯出到 Grafana 等系統。
重點是,即便使用這些工具,我們的目標仍一樣:不是撐住百萬 RPS,而是確保在預期尖峰的 5–10 倍下系統也不會崩壞,且 p95 仍在 SLO 範圍內。
6. 在負載前後應該關注什麼
我們已談過指標與 SLO,現在把它們「落地」到負載情境。
第一,延遲(latency)。 對像是 search_gifts 的 MCP 工具,你先前應設定過目標,例如「p95 < 2–3 秒」。在 smoke‑load 時要看:p95/p99 是否上飄到原本的 2–3 倍。同時要與 baseline 比較:若改動前 p95 是 400 ms,改動後變 1500 ms,即使形式上仍在 SLO,也該警覺。
第二,錯誤率(error rate)。 在負載下常會冒出意外:資料庫連線池用罄、外部 API 回 429、打金流時逾時。正常負載下錯誤率應趨近於 0;在 smoke‑load 可以接受零星失敗,但絕不是 5–10%。
第三,資源指標: CPU、記憶體,有時還有開啟的檔案描述元與連線數。這些取決於你的基礎設施,但核心想法很簡單:你不希望在 30 VU 時 CPU 100% 且 GC 吃掉一半時間。
第四,webhook。 如果是 commerce 情境,訂單的終點常仰賴金流系統的 webhook 成功處理。要看的不僅是 ACP 請求速度,還有「webhook 抵達 → 我們成功處理」的延遲。
最後,日誌。 帶有 trace_id/checkout_session_id 的結構化日誌,能讓你在負載測試後抓兩三個最慢或失敗的請求,沿著鏈路追下去:MCP → 外部 API → ACP → webhook。當你在負載下看到奇怪的 p99 尾巴時,這尤其有用。
7. feed 資料品質:從結構到語義
我們已看過延遲、錯誤、資源在負載下的表現。但即便上述 SLO 全都達標,使用者體驗仍可能因資料品質不佳而「崩壞」。
進入第二個大主題:資料。對 GiftGenius 這類 commerce App 而言,product feed(商品 feed)不是「放在硬碟上的東西」,而是 LLM 與代理的燃料。若 feed 充滿雜訊,模型不會幫你「捏造」正確價格與庫存。
將 feed 品質分成三層來思考很實用。
結構層
這是資料的基本有效性:
- JSON 能正確解析。
- 所有必填欄位齊全:id、name、price、currency、imageUrl、availability 等。
- 值的型別符合預期:價格是數字、availability 是 enum、categories 是字串陣列。
- 沒有重複的 id。
這部分你可能已用契約式測試覆蓋過,透過 JSON Schema/Zod 描述 feed 結構。現在要把這些 schema 套在真實的資料量上。
GiftGenius 單一 feed 項目的簡單 Zod schema 範例:
import { z } from "zod";
export const giftItemSchema = z.object({
id: z.string().min(1),
name: z.string().min(3),
description: z.string().optional(),
price: z.number().positive(),
currency: z.enum(["USD", "EUR", "GBP"]),
imageUrl: z.string().url(),
inStock: z.boolean(),
tags: z.array(z.string()).default([]),
});
整個 feed 的 schema 就是 z.array(giftItemSchema)。
商業層(語義)
結構上商品可能「合法」,但在商業邏輯上卻很荒謬:
- 昂貴商品的價格是 0 或 0.01。
- 幣別不符市場(只在 EUR 銷售的商品卻標 USD)。
- inStock = true,但最後更新日期是半年前。
- 類別多達上千種但未統一。
在這一層,加上一些額外檢查與「常識規則」很有用。例如:
const businessRules = (item: GiftItem) => {
const problems: string[] = [];
if (item.price > 10000) {
problems.push("可疑的高價");
}
if (!item.inStock && item.tags.includes("bestseller")) {
problems.push("標為 bestseller,卻無庫存");
}
return problems;
};
這些檢查可以當作 nightly job 的一部分,或在生成新 feed 時執行。
LLM 層
模型很強,但也有它的「小毛病」:
- 描述塞滿 HTML、多餘標籤與技術性文字。
- 語言混雜(半個 feed 是俄文、半個是英文)卻未標示 locale。
- 過長的「SEO 名稱」,像「快來買最棒超級無敵禮物便宜又划算」。
這一層要把資料整理成對模型友善的格式:
- 移除 HTML 標籤或轉為純文字。
- 正規化描述的語言(至少明確標註 locale)。
- 截斷過長的名稱與重複資訊。
部分可以自動化(例如透過前處理腳本),另一些則需要與負責填充 feed 的團隊達成約定。
8. 實作:GiftGenius 的 feed 驗證器
為專案加入一個簡單腳本 validateFeed.ts,讀取 feed 的 JSON、用 Zod 驗證,並計算基本品質指標。
// scripts/validateFeed.ts
import { readFile } from "fs/promises";
import { giftItemSchema } from "../src/schema/giftItem";
async function main() {
const raw = await readFile("data/gift-feed.json", "utf-8");
const data = JSON.parse(raw);
const items = giftItemSchema.array().parse(data);
console.log(`商品總數:${items.length}`);
const missingImages = items.filter(i => !i.imageUrl).length;
console.log(`缺少圖片:${missingImages}`);
}
main().catch((e) => {
console.error("Feed validation failed:", e);
process.exit(1);
});
我們在此使用與 MCP 伺服器相同的契約,也就是說,契約式測試與 feed 檢查共用同一份 schema——這能大幅降低不一致的機率。
之後可以加上商業規則檢查與指標,例如:
- 無描述的商品占比;
- 價格異常偏低/偏高的占比;
- 重複的 id 或重複的 name + price 數量。
這些數字可以上報到指標系統(如 Prometheus、Datadog 等),並針對資料品質訂定獨立的 SLO——就像你為程式碼訂定 SLO 一樣。
9. 負載與 feed 之間如何互相影響
有時看似「效能」與「資料品質」是兩個不太相關的主題。實際上,它們緊密交織。
一些關聯例子:
- 在負載下,部分請求會走到以往少見的邏輯分支。例如有特殊折扣類型或非標準 shipping 的商品。如果這些位置的 feed 很髒,你可能同時遇到錯誤與嚴重效能劣化(大量驗證、例外、fallback 邏輯)。
- 若 feed 噪音很大(超長描述含 HTML、無意義標籤),MCP 伺服器必須拉與序列化更多資料,直接拉長 tool-call 的處理時間與回應大小。
- 在 commerce 流程中,糟糕的 feed 會導致許多「空的」checkout 嘗試——使用者選到突然缺貨的商品。這同時傷害 UX 與 ACP 指標(未成功 intent 增加)。
把它看成一張矩陣會很實用:
| Feed 問題 | 在負載下的症狀 | 觀察位置 |
|---|---|---|
| 價格/幣別不一致 | ACP 出錯、付款被拒 | ACP 日誌 + checkout SLO |
| 商品重複 | 推薦結果怪異、冗餘呼叫 | MCP 日誌、UX 指標 |
| 缺少圖片/描述 | 模型給出「扁平」的推薦 | App 日誌 + UX 回饋 |
| 描述含 HTML/雜訊 | 序列化變慢、payload 變大 | MCP 延遲 |
負載測試在此就像手電筒:它能照亮那些平時少被觸及、但在活躍流量下會出槍的 feed 區段。
10. 把這些嵌入 GiftGenius 的 release 流程
就流程而言,以上不該只是「上線前的一次性」。在模組 16(「Production、網路與擴充」)與 17(「可觀測性與品質」)的課綱裡,這套做法就是例行 release‑checklist 的一部分:上線前不只跑 unit/contract/E2E,還要短時的 smoke‑load 加上 feed 檢查。
上線前的一個合理最小 pipeline:
- Unit + contract + 整合測試皆為綠燈。
- 在 staging 對 MCP/ACP 跑一次短的 smoke‑load(若關鍵碼有變:搜尋邏輯、資料庫處理、checkout)。
- feed 驗證器無錯誤,feed 的基礎品質指標(壞資料筆數、缺圖占比等)在可接受範圍內。
- 儀表板與警報已更新,涵蓋新 endpoint 與 SLO。
- 若發生失敗有回滾方案:功能旗標關閉或回退建置版本。
如此一來,你的 GiftGenius 就不再是「DevDay 的 demo」,而是準備好進 Store、能承受流量波峰的服務。
11. 負載測試與 feed 檢查的常見錯誤
錯誤 №1:對 ChatGPT 做負載,而不是你的後端。
有人想「像真實情境那樣」測試,於是寫腳本從 ChatGPT UI 走。結果撞上 OpenAI 的限制、燒 token、得到極度雜訊的結果。同時,MCP/ACP 的問題本來可以便宜 100 倍就抓到,直接打 /mcp 與 /api/checkout 就好。
錯誤 №2:只關注平均回應時間。
「我們平均延遲 500 ms,一切良好」——但 p95 是 5 秒這件事卻被忽略。我們在 SLO 主題已談過,真正決定 UX 的是分佈尾端(p95/p99)。在負載下,平均值常維持體面,但尾巴會長到兩三倍。
錯誤 №3:搞「企業級壓測」而非務實的 smoke‑load。
為 ChatGPT App(如 GiftGenius)花數月打造模擬數萬使用者的複雜場景,幾乎都是多餘的。更有價值的是一個簡單、但能定期執行的 smoke‑load:50–100 個 VU,指標清楚。
錯誤 №4:不真實的負載情境。
腳本一直送同一個請求,沒有使用者、語言、商品類型的變化,也不碰 ACP 與 webhook。結果你測的是單一熱門 happy‑path,系統真正的「角落」仍在陰影裡。至少要模擬一個簡化但可信的流程:不同預算、不同興趣,有些使用者會走到 checkout,有些不會。
錯誤 №5:feed 只「用眼看」或直接在 prod 檢查。
feed 生好了、丟進 prod,看到模型給出奇怪的推薦才摸頭。如果有個簡單的 Zod/JSON Schema 腳本,早在 1 分鐘內就能指出:10% 商品沒圖片、5% 價格是 0、3% 幣別是 XXX。缺少自動化的 feed 驗證,是 commerce 應用中最常見的「糗點」之一。
錯誤 №6:在 feed 很差時指望 LLM「自己會懂」。
模型很厲害,但不會替你編出正確價格或庫存。同一商品若在 feed 中同時出現不同價格,或「有庫存」/「無庫存」並存,代理可能給出幻覺與不一致的體驗。資料乾淨的責任在你,不在模型。
錯誤 №7:feed 指標與整體 SLO 脫節。
MCP 與 ACP 再快也沒用,如果 30% 的商品在 feed 裡是「壞的」,使用者體驗仍然糟。團隊常只追蹤技術 SLO(延遲、錯誤率),卻忽略資料品質的 SLO(有效 SKU 的最低比例、重複上限等)。於是「數字上都很好」,體感卻不好。
錯誤 №8:未準備就直接在生產環境跑負載測試。
有時有人在星期五晚上決定「快跑一個 k6 打生產 MCP」,也沒通知任何人。最好情況下你會干擾真實指標、讓 on‑call 工程師被流量尖峰嚇到;最糟是踩到外部 API 或金流系統的 rate limit。先在 staging 跑首批情境;若需要 prod 測試——要有計畫、有時段、有通知。
GO TO FULL VERSION