AG Day 41 效能總檢與壓測
執行需求:CPU 可跑。前幾篇把研究助理從 CLI 工具推進到 HTTP 服務、容器化部署、Streamlit 前端,整個系統看起來已經完整。但任何 production 服務在沒做過壓力測試之前,都只能說「能跑」,不能說「能扛」。今天的任務是用 AG Day 35 已經接好的 Langfuse trace 與 AG Day 39 已經跑起來的 docker compose 環境,設計一份壓測腳本(k6 或 locust 都可),把「十個並行使用者同時送出研究問題」、「LLM API 慢回應」、「SQLite 寫入瓶頸」這三個情境在可控條件下重現出來,並從 trace 中找出瓶頸節點。我們會用 Python 的 asyncio 與 httpx 寫一份輕量壓測器,搭配 matplotlib 把結果繪成圖表,這樣 AG Day 42 文件化時可以一起附上。
引言
壓力測試的目的不是「證明系統很快」,而是「找出系統在壓力下會先從哪裡崩潰」。對 research-agent 來說,瓶頸點可能散落在三個地方:FastAPI 的同步/非同步橋接(AG Day 38 已經用 run_in_executor 處理)、SQLite 寫入的鎖定(AG Day 31 留下來的問題)、以及最慢的元件——LLM API 本身的延遲。前兩個是我們自己程式碼的問題,可以靠最佳化與架構調整解決;第三個是外部依賴的問題,我們只能透過快取(AG Day 36)、模型分級、與併行處理去緩解。今天我們要在不修改 production 程式的情況下,從外部觀察這三個瓶頸的嚴重程度。
壓測工具的選擇很多:k6 適合純 HTTP 層的吞吐測試、locust 適合模擬真實使用者行為、ApacheBench 適合最簡單的單端點基準測試。我們今天刻意選用 Python 的 asyncio + httpx 自製壓測器,理由有三:第一是設定最簡單、不需安裝額外工具;第二是與既有 tests/ 結構一致、容易在 CI 跑;第三是我們可以同時打 FastAPI 與讀 Langfuse trace,方便做端對端分析。對單機 CPU 環境,這個自製壓測器每秒發 50–100 個請求已經足夠;真的要測更高負載,未來可以改用 k6。
今天的具體目標有三個。第一是「基準測試」:在沒有壓力的情況下,POST /runs、GET /runs/{run_id}、POST /runs/{run_id}/resume 三個端點的平均延遲、P95 延遲、與吞吐量。第二是「並行測試」:模擬 5 個、10 個、20 個並行使用者同時送出研究問題,觀察系統的吞吐量與錯誤率曲線。第三是「故障注入」:刻意把 LLM API 換成慢回應版本(例如每個呼叫加 2 秒延遲),觀察系統在依賴變慢時的退化模式,並找出 AG Day 38 留下的 run_in_executor 在慢回應下會不會阻塞整個 event loop。所有測試結果會被寫進 reports/perf-2026-08-24.json,方便 AG Day 42 的文件化章節引用。
原理/觀念
壓測的三個層次
壓力測試通常分三個層次。第一層是「基準測試(baseline)」:在沒有其他負載的情況下量測單一請求的延遲分布,這是後續比較的基準。第二層是「負載測試(load test)」:用預期的 production 流量(通常是基準測試的 5-10 倍)持續打一段時間,觀察系統是否能穩定維持目標吞吐量與延遲。第三層是「壓力測試(stress test)」:把流量推到超過預期峰值(10-20 倍),觀察系統從哪裡開始失敗、失敗模式是 graceful degradation 還是 catastrophic failure。我們今天會做第一層與第二層,第三層留給 production 環境的維運團隊。
延遲的百分位數比平均值有意義
壓測結果最常見的誤用是「平均延遲」。平均值會被幾個極端的長尾值拉高或拉低,無法反映真實使用者體驗。實務上我們看的是 P50(中位數)、P95(95% 的請求都比這快)、P99(99% 的請求都比這快)。對研究助理來說,「平均 3 秒完成」沒有意義,因為有 5% 的請求可能卡了 15 秒,使用者會以為系統壞了。AG Day 35 的 Langfuse 已經會自動計算每個 span 的 P50/P95/P99,今天的壓測腳本也會把這層資訊印出來,與 Langfuse 的數字交叉比對。
Little's Law 與系統容量估算
Little's Law 是一個簡單但實用的公式:系統中的平均請求數 = 吞吐量 × 平均延遲。對我們的系統來說,如果平均延遲是 5 秒、吞吐量目標是每秒 2 個請求,那系統中「同時在跑的請求數」大約是 10 個。這告訴我們 FastAPI 的 run_in_executor thread pool 至少要 10 個 worker 才能撐住目標吞吐量;AG Day 38 我們用預設的 None(表示用 asyncio 預設的 thread pool,5 * CPU 數量),對 8 核機器是 40 個 worker,足夠應付今天的壓測。AG Day 44 維運手冊會把這個估算寫成正式公式。
故障注入是測試彈性設計的捷徑
今天的故障注入不會真的去把 LLM API 弄壞(那需要 mock server),而是在壓測腳本裡把 RESEARCH_AGENT_DRY_RUN 環境變數打開、並把 LangGraph 圖換成一個「每次 invoke 都 sleep 2 秒」的假實作。這樣我們可以在不需要 API 金鑰的情況下,模擬 LLM API 慢回應的場景,觀察系統的退化模式。故障注入的價值在於:當外部依賴出問題時,你的系統是「慢慢變慢」還是「整個停擺」,這兩種故障模式對使用者體驗與維運成本完全不同。
完整實作
今天的程式集中在兩個檔案:scripts/loadtest.py(壓測器本體)與 scripts/summarize_perf.py(壓測結果彙整)。我們先建立檔案:
mkdir -p research-agent/scripts
touch research-agent/scripts/loadtest.py
touch research-agent/scripts/summarize_perf.py
第一步:寫壓測器本體。我們用 asyncio.Semaphore 控制並行數、用 httpx.AsyncClient 送請求、用簡單的 list 收集每次請求的延遲:
# research-agent/scripts/loadtest.py
from __future__ import annotations
import argparse
import asyncio
import json
import statistics
import time
from pathlib import Path
import httpx
async def _one_request(client: httpx.AsyncClient, base: str, question: str) -> dict:
"""送出一次研究問題,並量測端對端延遲。"""
started = time.perf_counter()
try:
resp = await client.post(f"{base}/runs", json={"question": question}, timeout=30)
elapsed = (time.perf_counter() - started) * 1000
return {
"status": resp.status_code,
"elapsed_ms": elapsed,
"run_id": resp.json().get("run_id") if resp.status_code == 201 else None,
"ok": resp.status_code == 201,
}
except httpx.HTTPError as exc:
return {"status": "error", "elapsed_ms": -1, "ok": False, "error": str(exc)}
async def _poll_until_done(client: httpx.AsyncClient, base: str, run_id: str, max_wait: float = 30.0) -> dict:
"""輪詢直到 run 完成或逾時,回傳最終狀態與總耗時。"""
started = time.perf_counter()
while (time.perf_counter() - started) < max_wait:
resp = await client.get(f"{base}/runs/{run_id}", timeout=10)
if resp.status_code != 200:
return {"status": "error", "elapsed_ms": -1}
data = resp.json()
if data["status"] in {"completed", "failed", "interrupted"}:
elapsed = (time.perf_counter() - started) * 1000
return {"status": data["status"], "elapsed_ms": elapsed, "ok": data["status"] == "completed"}
await asyncio.sleep(0.5)
return {"status": "timeout", "elapsed_ms": max_wait * 1000, "ok": False}
async def run_scenario(base: str, *, total: int, concurrency: int, scenario: str) -> dict:
"""執行一個壓測情境:發出 total 個請求,concurrency 同時進行。"""
semaphore = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
async def worker(idx: int) -> dict:
async with semaphore:
question = f"[{scenario}] 壓測問題 {idx}:請研究某個示範主題。"
create = await _one_request(client, base, question)
if create["ok"] and create["run_id"]:
polled = await _poll_until_done(client, base, create["run_id"])
return {**create, "final": polled}
return create
tasks = [asyncio.create_task(worker(i)) for i in range(total)]
results = await asyncio.gather(*tasks)
ok_count = sum(1 for r in results if r.get("ok"))
elapsed = [r["elapsed_ms"] for r in results if r["elapsed_ms"] > 0]
p50 = statistics.median(elapsed) if elapsed else 0
p95 = sorted(elapsed)[int(len(elapsed) * 0.95)] if len(elapsed) >= 20 else 0
return {
"scenario": scenario,
"total": total,
"concurrency": concurrency,
"ok": ok_count,
"fail": total - ok_count,
"p50_ms": round(p50, 1),
"p95_ms": round(p95, 1),
"max_ms": round(max(elapsed) if elapsed else 0, 1),
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="http://127.0.0.1:8000")
parser.add_argument("--total", type=int, default=20)
parser.add_argument("--concurrency", type=int, default=5)
parser.add_argument("--scenario", default="baseline")
parser.add_argument("--out", default="reports/perf-latest.json")
args = parser.parse_args()
summary = asyncio.run(run_scenario(
args.base,
total=args.total,
concurrency=args.concurrency,
scenario=args.scenario,
))
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
第二步:寫故障注入版的圖實作。我們新增 src/research_agent/fake_graph.py,提供可控制延遲的假 LangGraph 圖,專門給壓測與故障演練使用:
# research-agent/src/research_agent/fake_graph.py
"""壓測用的假 LangGraph 圖:用環境變數控制延遲,模擬 LLM API 慢回應。"""
from __future__ import annotations
import asyncio
import os
from langchain_core.messages import AIMessage
from research_agent.security import redact
LATENCY_MS = int(os.environ.get("RESEARCH_AGENT_FAKE_LATENCY_MS", "0"))
FAIL_RATE = float(os.environ.get("RESEARCH_AGENT_FAKE_FAIL_RATE", "0.0"))
class FakeGraph:
"""延遲可控、隨機失敗的假圖;呼叫介面與 LangGraph 編譯後的圖一致。"""
def __init__(self) -> None:
self.invocations: list[str] = []
def invoke(self, payload, config):
import random
self.invocations.append(str(config))
if FAIL_RATE > 0 and random.random() < FAIL_RATE:
raise RuntimeError("fake graph 隨機失敗(測試用)")
asyncio.get_event_loop().run_until_complete(asyncio.sleep(LATENCY_MS / 1000))
question = payload["messages"][0].content
return {
"messages": payload["messages"] + [
AIMessage(content=redact(f"(fake)對「{question}」的示範回應。")),
]
}
def get_state(self, config):
class Snap:
values = {"messages": []}
next = ()
return Snap()
第三步:在 server.py 加一個環境變數 hook,讓 RESEARCH_AGENT_FAKE_GRAPH=true 時 build_graph() 自動換成假實作:
# research-agent/src/research_agent/server.py(節錄)
def _build():
if os.environ.get("RESEARCH_AGENT_FAKE_GRAPH") == "true":
from research_agent.fake_graph import FakeGraph
return FakeGraph()
return build_graph_from_core()
# 把原本所有 _invoke_graph 內的 build_graph(checkpointer=...) 換成 _build()
第四步:寫結果彙整腳本,把多個壓測情境的結果彙整成一張圖表。我們用 matplotlib 畫 P50/P95 延遲對比、用文字模式列出錯誤率:
# research-agent/scripts/summarize_perf.py
"""把多個壓測情境的 JSON 結果彙整成一張延遲對比圖。"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def load_summaries(path: Path) -> list[dict]:
out = []
for fp in sorted(path.glob("perf-*.json")):
out.append({"file": fp.name, **json.loads(fp.read_text(encoding="utf-8"))})
return out
def print_table(rows: list[dict]) -> None:
print(f"{'scenario':18s} {'total':>6s} {'conc':>5s} {'ok':>5s} {'p50(ms)':>10s} {'p95(ms)':>10s}")
for r in rows:
print(f"{r['scenario']:18s} {r['total']:>6d} {r['concurrency']:>5d} {r['ok']:>5d} {r['p50_ms']:>10.1f} {r['p95_ms']:>10.1f}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--dir", default="reports", help="壓測 JSON 結果目錄")
args = parser.parse_args()
rows = load_summaries(Path(args.dir))
if not rows:
print("找不到 perf-*.json;請先跑 loadtest.py 產生結果")
return
print_table(rows)
if __name__ == "__main__":
main()
第五步:用一段 orchestrator 腳本,依序跑「baseline」「concurrency 5」「concurrency 10」「slow backend」四個情境,把結果都寫進 reports/perf-*.json:
# research-agent/scripts/run_all_scenarios.sh
#!/usr/bin/env bash
set -euo pipefail
BASE="http://127.0.0.1:8000"
OUT_DIR="reports"
mkdir -p "$OUT_DIR"
echo "[1/4] baseline (1 user, 10 reqs)..."
uv run python scripts/loadtest.py --base "$BASE" --total 10 --concurrency 1 \
--scenario baseline --out "$OUT_DIR/perf-baseline.json"
echo "[2/4] concurrency 5 (5 users, 20 reqs)..."
uv run python scripts/loadtest.py --base "$BASE" --total 20 --concurrency 5 \
--scenario conc-5 --out "$OUT_DIR/perf-conc-5.json"
echo "[3/4] concurrency 10 (10 users, 20 reqs)..."
uv run python scripts/loadtest.py --base "$BASE" --total 20 --concurrency 10 \
--scenario conc-10 --out "$OUT_DIR/perf-conc-10.json"
echo "[4/4] slow backend (RESEARCH_AGENT_FAKE_LATENCY_MS=2000)..."
RESEARCH_AGENT_FAKE_GRAPH=true \
RESEARCH_AGENT_FAKE_LATENCY_MS=2000 \
uv run python scripts/loadtest.py --base "$BASE" --total 10 --concurrency 5 \
--scenario slow-backend --out "$OUT_DIR/perf-slow.json"
echo "全部完成;執行 summarize_perf.py:"
uv run python scripts/summarize_perf.py --dir "$OUT_DIR"
離線示範輸出(用 fake graph 模擬,所有延遲都是 fake 的,僅示範格式):
$ bash scripts/run_all_scenarios.sh
[1/4] baseline (1 user, 10 reqs)...
{
"scenario": "baseline",
"total": 10,
"concurrency": 1,
"ok": 10,
"fail": 0,
"p50_ms": 142.7,
"p95_ms": 198.4,
"max_ms": 215.0
}
[2/4] concurrency 5 ...
[3/4] concurrency 10 ...
[4/4] slow backend ...
scenario total conc ok p50(ms) p95(ms)
baseline 10 1 10 142.7 198.4
conc-5 20 5 20 158.2 220.5
conc-10 20 10 20 162.4 235.1
slow-backend 10 5 10 2032.4 2085.0
第六步:用 Langfuse 的查詢 API 讀出壓測期間的 trace,計算每個 span 的 P50/P95 延遲。我們把這個分析也寫成一支小腳本,把 Langfuse 數字與壓測結果交叉比對:
# research-agent/scripts/langfuse_perf.py
"""從 Langfuse 讀出壓測期間的 trace,計算每個 span 的 P50/P95。"""
from __future__ import annotations
import os
import statistics
from typing import Iterable
from langfuse import Langfuse
LANGFUSE = Langfuse(
public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
secret_key=os.environ["LANGFUSE_SECRET_KEY"],
)
def percentiles(values: Iterable[float]) -> dict:
vs = sorted(values)
if not vs:
return {"p50": 0, "p95": 0}
p50 = vs[len(vs) // 2]
p95 = vs[int(len(vs) * 0.95)] if len(vs) >= 20 else vs[-1]
return {"p50": round(p50, 1), "p95": round(p95, 1)}
def main() -> None:
spans = LANGFUSE.fetch_spans(tags=["loadtest"])
by_name: dict[str, list[float]] = {}
for span in spans:
duration_ms = (span.end_time - span.start_time).total_seconds() * 1000
by_name.setdefault(span.name, []).append(duration_ms)
print(f"{'span':24s} {'count':>6s} {'p50(ms)':>10s} {'p95(ms)':>10s}")
for name, durations in sorted(by_name.items()):
stats = percentiles(durations)
print(f"{name:24s} {len(durations):>6d} {stats['p50']:>10.1f} {stats['p95']:>10.1f}")
if __name__ == "__main__":
main()
這支腳本會在壓測結束後跑一次,把 Langfuse 觀察到的 span 等級延遲印出來。我們可以從中看出:到底是 FastAPI 的入口慢、還是 LangGraph 的節點慢、還是模型呼叫本身慢,進而決定要最佳化哪一塊。
常見錯誤與踩雷
第一個雷是「壓測環境與 production 環境不一致」。我們今天刻意用 docker compose 跑 production 版的服務(AG Day 39 的 image),確保壓測結果能反映真實情況;但很多團隊壓測時跑的是開發版(例如 uvicorn --reload 模式),這個模式多了檔案監聽、多了 debug middleware,量到的數字會比 production 好很多。建議 CI 上的壓測永遠跑 production image,這樣數字才有意義。如果你的 production 用 Kubernetes,也可以用 kind 或 minikube 在 CI 上跑一份輕量的壓測環境;不必追求 100% 等價,只要確保網路拓樸與資源配置接近即可。
第二個雷是「忘記 warmup」。冷啟動的服務第一次呼叫通常比較慢(imports、連線池初始化),這個 cold start 不該算在平均值裡。我們今天的 loadtest.py 預設就把所有請求算進去;如果你想要更精準,可以先發 5 個 warmup 請求再開始計時。
第三個雷是「壓測結果寫進了 production 資料庫」。我們的壓測腳本送出的問題會真的進入 LangGraph 圖、被真的檢索、被真的寫進 SQLite。對 demo 沒問題,但對 production 會污染真實資料。建議在壓測時用獨立的 RESEARCH_AGENT_DRY_RUN=true 環境變數、或獨立的 named volume mount,讓壓測資料與 production 資料完全隔離。如果你的 production 還在用同一個 Langfuse 專案接收 trace,也要記得用 tag 把壓測 trace 過濾掉,否則一段時間後就會被假資料淹沒。
第四個雷是「壓測本身造成 LLM API 帳單飆升」。如果 production 環境真的在接 OpenAI API,每次壓測都會消耗 token。我們今天刻意用 fake graph 繞過 LLM API,這也是為什麼 fake graph 是必要的;真實 production 壓測前務必確認 RESEARCH_AGENT_FAKE_GRAPH=true 已經設好。
第五個雷是「只看吞吐量、不看資源使用」。今天我們只量了延遲與錯誤率,但 CPU、記憶體、網路 I/O 的數字也很重要。建議同時跑 docker stats,把容器資源使用率也記錄下來,這樣 AG Day 44 維運手冊裡的容量規劃才有依據。
效能與實務提醒
實務上最容易踩到的效能問題是「asyncio 預設 thread pool 不夠大」。AG Day 38 我們用 loop.run_in_executor(None, ...) 跑同步的 LangGraph 圖,None 表示用 asyncio 預設的 executor。這個預設 executor 大小是 min(32, CPU + 4),對 8 核機器是 12。如果壓測發現 FastAPI 端開始拒絕服務、但 LangGraph 還在跑,第一個要查的就是 thread pool 是否被打滿。可以透過 loop.set_default_executor(ThreadPoolExecutor(max_workers=64)) 調大。
第二個提醒是「SQLite 在高並行下的鎖定」。AG Day 31 的 SQLite checkpointer 在單一寫入下沒問題,但 10 個並行使用者同時寫入時,會頻繁出現 database is locked 錯誤。壓測時如果發現這個錯誤率隨並行數線性上升,就該考慮換成 Postgres 或加連線池。AG Day 39 的 docker compose 已經預留 Postgres 設定,必要時只要把 RESEARCH_AGENT_CHECKPOINTER_URL 換成 postgresql://... 就能切換。
第三個提醒是「壓測結果要搭配 Langfuse trace 看」。單純的 HTTP 延遲數字看不出瓶頸在 FastAPI、LangGraph 還是 LLM API。今天我們用 Langfuse 的 span 等級資料交叉比對,這對調校最有幫助。實務上我們會在 Langfuse 上設一個「慢於 5 秒的 trace 自動告警」規則,這樣壓測期間如果某個 span 突然變慢,會立刻收到通知。如果發現「每個 span 都很快、但總延遲很長」,那瓶頸通常出在「模型間的等待」或「外部 API 的限流」,這時候 Langfuse 的 timeline 視圖會比單純的 span 等級數據更有用。
第四個提醒是「壓測腳本本身可能會被當成 production 流量」。如果你的 Langfuse 設定是「所有 trace 都寫入」,壓測的 trace 也會被寫進去,造成 Langfuse 介面被假資料淹沒。建議在 Langfuse SDK 加上 tags=["loadtest"] 標記,並在 Langfuse 介面上用 tag filter 把壓測 trace 過濾掉。今天的範例已經加上 tag,記得在 production 環境也沿用這個習慣。
第五個提醒是「定期重跑壓測」。效能會隨著依賴版本、模型版本、流量模式而變化,建議每個月或每次重大改版後跑一次今天寫的壓測腳本。AG Day 44 維運手冊會把這個排程寫進 CI 流程(每月第一個週一凌晨自動跑),壓測結果自動發到維運頻道。我們今天刻意把 perf_regression_check.py 寫成可單獨執行的 CLI,這樣日後接到 CI runner 不需要改任何程式碼,直接加一個 cron job 就能用。
小結
今天我們用 Python 的 asyncio 與 httpx 自製了一份壓測器,搭配 FakeGraph 做故障注入,並把壓測期間的 trace 與 Langfuse 交叉比對找出瓶頸節點。整個流程包含「基準測試 → 並行測試 → 故障注入」三個層次,結果會自動寫進 reports/perf-*.json 並用 summarize_perf.py 彙整成表格。這份壓測腳本是 AG Day 44 維運手冊裡「容量規劃」一節的核心輸入。
今天新增的關鍵詞:基準測試(baseline)——沒有其他負載下的單一請求延遲;負載測試(load test)——用預期峰值的流量持續打系統;百分位數(P50/P95/P99)——比平均值更能反映真實使用者體驗的延遲指標;Little's Law——吞吐量、平均延遲、同時在跑的請求數三者的關係公式;故障注入(fault injection)——刻意模擬依賴失敗來測試系統彈性的方法。
第七步:寫一個簡單的 pytest,把今天的壓測器本體做最小化測試。我們確保 _one_request() 與 _poll_until_done() 兩個核心函式在錯誤情境下也能穩定回傳結構化資料:
# research-agent/tests/test_loadtest.py
import pytest
from research_agent.scripts.loadtest import _poll_until_done # type: ignore # path-based import
class FakeClient:
"""模擬 httpx.AsyncClient 的極簡版。"""
def __init__(self, responses):
self.responses = responses
self.calls = 0
async def get(self, url, timeout):
self.calls += 1
if self.calls > len(self.responses):
raise RuntimeError("no more responses")
return self.responses[self.calls - 1]
class FakeResp:
def __init__(self, payload, status=200):
self._payload = payload
self.status_code = status
def json(self):
return self._payload
@pytest.mark.asyncio
async def test_poll_returns_when_completed():
client = FakeClient([FakeResp({"status": "completed"}), FakeResp({"status": "completed"})])
out = await _poll_until_done(client, "http://x", "run-1", max_wait=2.0)
assert out["status"] == "completed"
assert out["ok"] is True
@pytest.mark.asyncio
async def test_poll_times_out(monkeypatch):
import asyncio
async def fake_sleep(_):
return None
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
client = FakeClient([FakeResp({"status": "running"})] * 5)
out = await _poll_until_done(client, "http://x", "run-1", max_wait=0.1)
assert out["status"] == "timeout"
assert out["ok"] is False
第八步:把 reports/perf-*.json 接到 GitHub Actions(或其他 CI),做成自動化的「效能回歸測試」。我們比對新一次的 P95 與基準的差距,超過 20% 就讓 CI 紅燈:
# research-agent/scripts/perf_regression_check.py
"""比對最新壓測結果與基準,超過門檻則視為效能回歸。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
BASELINE = Path("reports/perf-baseline.json")
LATEST = Path("reports/perf-conc-10.json")
THRESHOLD_PCT = 20.0
def main() -> int:
if not BASELINE.exists() or not LATEST.exists():
print("找不到基準或最新結果,跳過回歸檢查")
return 0
base = json.loads(BASELINE.read_text(encoding="utf-8"))
latest = json.loads(LATEST.read_text(encoding="utf-8"))
delta = (latest["p95_ms"] - base["p95_ms"]) / base["p95_ms"] * 100
if delta > THRESHOLD_PCT:
print(f"[FAIL] P95 延遲比基準慢 {delta:.1f}%,超過 {THRESHOLD_PCT}% 門檻")
return 1
print(f"[OK] P95 延遲差距 {delta:.1f}%,未超過門檻")
return 0
if __name__ == "__main__":
sys.exit(main())
結語
今天的壓測把研究助理的能見度從「能跑」推進到「能扛」。但任何一個 production 系統都必須有人能接手維運。接下來 AG Day 42 會進入「文件與交接:README 與架構圖」:把所有今天的設定、壓測結果、部署指令彙整成一份新工程師能在半天內接手的 README,並畫一張 ASCII 架構圖把整個系統的元件關係視覺化。今天 reports/perf-*.json 的內容會直接被 README 的「容量規劃」段落引用,這是文件化章節的第一個具體產出。AG Day 42 結束時,你手上應該會有一份能直接交給同事或未來自己的自己完整文件。順帶把今天動過的檔案做個總結:scripts/loadtest.py(新增,壓測器本體)、scripts/summarize_perf.py(新增,結果彙整)、scripts/run_all_scenarios.sh(新增,批次跑四個情境)、scripts/langfuse_perf.py(新增,Langfuse 跨對)、scripts/perf_regression_check.py(新增,CI 門檻)、src/research_agent/fake_graph.py(新增,故障注入假圖)、tests/test_loadtest.py(新增,壓測器單元測試)、src/research_agent/server.py(小幅 hook 環境變數)。
明天,我們會進入「AG Day 42 文件與交接:README 與架構圖」,把今天產出的壓測數據、AG Day 39 的 docker compose 設定、AG Day 40 的前端指令,全部彙整進一份新工程師能在半天內接手的 README,並畫一張 Mermaid 架構圖把整個系統的元件關係視覺化。
延伸資源
- Python asyncio 官方文件:
https://docs.python.org/3/library/asyncio.html。Semaphore、gather、run_in_executor的完整介面。 - httpx 官方文件:
https://www.python-httpx.org/。AsyncClient與 timeout 設定。 - k6 官方文件:
https://k6.io/docs/。若未來需要更高負載的壓測,可改用 k6。 - Langfuse 官方文件:
https://langfuse.com/docs。fetch_spans與 tag 過濾的查詢介面。
留言
張貼留言