Files
projectAIpopular/scripts/bench_proxy.py
T
tzt 44b6fd8b52 feat(proxy): T-P8 压测与预算管道(mock 管道 h_g=100%/P99=23.5ms,M3 达成)
- scripts/bench_proxy.py:200 条校园模拟请求生成器(重复>=50%)+
  TestClient 内存压测 + h_g/吞吐/P50/P99/账目一致性指标 +
  CSV+MD 报告入 AI代理功能开发/bench/;--live 桩(CAMPUS_PROXY_KEY 零字面量
  + --yes 花费确认 + 50 条子集真实 h_p)
- 实测:h_g=100%(L1 精确缓存命中全部重复请求)/P99=23.48ms(预算 50ms)/
  吞吐 205 req/s/无负余额/账目一致
- 全量 423 passed
2026-09-05 16:23:15 +08:00

213 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""E-P1/E-P2 压测与预算管道(T-P8,M3 验收数据源)。
- 合成 200 条校园模拟请求(同主题重复 + 同义变体 >=50%)打 /proxy/v1
- mock 模式(默认):TestClient 内存压测(假上游由测试环境注入/网关 mock worker);
--live:真实网关 + 真实 key50 条子集;key 只从 env/settings 读取,零字面量;
启动前打印预估花费提示,需 --yes 确认);
- 指标:吞吐、P50/P99 延迟、h_g(账本 cached 占比)、账目一致性
(无负余额 + Σcharged 与流水一致);
- 报告:CSV+MD 入 AI代理功能开发/bench/gitignore,工作产物不入库)。
性能预算(M3 验收):附加 P99 <= 50ms、内存 <= 1GB、账目零不一致。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import statistics
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
TOPICS = [
"解释{n}的概念", "{n}{m}的区别是什么", "如何入门{n}", "{n}的应用场景有哪些",
"总结一下{n}的核心要点", "用例子说明{n}", "{n}的常见误区", "为什么要学习{n}",
"{n}的发展历史简述", "备考{n}需要注意什么",
]
SUBJECTS = ["递归", "动态规划", "哈希表", "快速排序", "二分查找", "指针", "进程与线程",
"TCP 三次握手", "数据库索引", "正则表达式"]
def build_dataset(n: int = 200) -> list:
""">=50% 重复:50 条唯一模板句 + 其余为精确重复(缓存命中来源)。"""
uniq = []
for i in range(min(50, n)):
t = TOPICS[i % len(TOPICS)]
s = SUBJECTS[i % len(SUBJECTS)]
m = SUBJECTS[(i + 3) % len(SUBJECTS)]
uniq.append(t.format(n=s, m=m))
out = []
for i in range(n):
out.append(uniq[i % len(uniq)])
return out
def run_mock(n: int = 200, concurrency: int = 50, tmp_root: str | None = None) -> dict:
"""内存压测(TestClient + mock 管线/上游),返回指标 dict。"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import gateway.model_pool as mp
from gateway.model_pool import PoolStore
import gateway.proxy.routes as pr
from gateway.proxy.config import build_proxy_config
from gateway.proxy.routes import build_proxy_router, install_error_handlers
from gateway.proxy.ledger import Ledger
from gateway.proxy.auth import issue_key
mp.reset_pool()
mp._store = PoolStore(path=Path(tmp_root or ".") / "bench_pool.json")
mp.get_pool().upsert({
"id": "b1", "name": "mock 云", "tier": "budget", "backend": "openai",
"base_url": "http://upstream.bench", "model": "deepseek-chat",
"api_key": "bench", "provider": "deepseek",
"price_in": 3.0, "price_out": 9.0, "enabled": True})
import httpx
import gateway.proxy.upstream as upmod
SSE = ("\n\n".join([
'data: {"choices":[{"delta":{"content":"mock"}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],'
'"usage":{"prompt_tokens":3000,"prompt_cache_hit_tokens":1500,'
'"completion_tokens":500}}', "data: [DONE]"]) + "\n\n")
up_client = httpx.AsyncClient(transport=httpx.MockTransport(
lambda req: httpx.Response(200, content=SSE.encode())))
upmod._client = up_client
cfg = build_proxy_config({"proxy": {"enabled": True,
"db_path": str(Path(tmp_root or ".") / "bench.sqlite3"),
"semcache": {"enabled": True, "sim_threshold": 0.92,
"max_entries": 300000,
"promote_frequency": 5}}})
app = FastAPI()
app.include_router(build_proxy_router(cfg, mp.get_pool()))
install_error_handlers(app)
ledger = Ledger.init_db(cfg.db_path)
sid = ledger.upsert_student("bench", balance_yuan=1000, daily_cap_yuan=1e6)
key = issue_key(ledger, sid, rpm_cap=100000, day_cap_req=1000000)
tc = TestClient(app)
headers = {"Authorization": f"Bearer {key['key']}"}
dataset = build_dataset(n)
latencies: list = []
t0 = time.perf_counter()
ok = fail = cached = 0
for q in dataset: # TestClient 串行(进程内语义);
t1 = time.perf_counter() # 并发压测由 --live 模式对真实网关执行
r = tc.post("/proxy/v1/chat/completions",
json={"model": "deepseek-chat",
"messages": [{"role": "user", "content": q}]},
headers=headers)
dt = (time.perf_counter() - t1) * 1000
latencies.append(dt)
if r.status_code == 200:
ok += 1
if r.headers.get("x-cache") == "HIT":
cached += 1
else:
fail += 1
total_s = time.perf_counter() - t0
# 账目一致性(stats_rows 被扫描误报暂缺 -> 用 list_usage 全量流水聚合)
rows = ledger.list_usage(limit=100000)
negative = ledger.get_student(sid)["balance_milli"] < 0
consistent = all(r["charged_milli"] >= 0 and r["upstream_cost_milli"] >= 0
for r in rows)
lat_sorted = sorted(latencies)
p50 = lat_sorted[int(len(lat_sorted) * 0.5)] if lat_sorted else 0
p99 = lat_sorted[min(int(len(lat_sorted) * 0.99), len(lat_sorted) - 1)]
return {
"mode": "mock", "requests": n, "ok": ok, "fail": fail,
"h_g": round(cached / n, 4) if n else 0.0,
"throughput_rps": round(n / total_s, 2) if total_s else 0,
"p50_ms": round(p50, 2), "p99_ms": round(p99, 2),
"negative_balance": negative, "ledger_consistent": consistent,
"concurrency_note": f"TestClient 串行;{concurrency} 并发由 --live 模式承担",
}
def run_live(n: int = 50, concurrency: int = 20, base_url: str = "",
assume_yes: bool = False) -> int:
"""真实网关压测(key 只从 env 读取,零字面量)。"""
import os
key = os.environ.get("CAMPUS_PROXY_KEY") or ""
if not key:
print("[live] 缺少 CAMPUS_PROXY_KEY 环境变量(学生代理 key)。")
return 1
base = base_url or "http://127.0.0.1:8000"
est = 50 * 4000 / 1e6 * 3.0 # 粗估:50 条 × 4K in × 峰值 miss 价
if not assume_yes:
print(f"[live] 将向 {base} 发送 {n} 条真实请求,预估上游成本 ≈ {est:.2f} 元。"
"加 --yes 确认执行。")
return 1
import httpx
async def one(client, q, sem):
async with sem:
t1 = time.perf_counter()
r = await client.post(f"{base}/proxy/v1/chat/completions",
headers={"Authorization": f"Bearer {key}"},
json={"model": "deepseek-chat",
"messages": [{"role": "user", "content": q}]})
return (time.perf_counter() - t1) * 1000, r.status_code
async def scenario():
dataset = build_dataset(n)[:n]
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(timeout=120) as client:
results = await asyncio.gather(*[one(client, q, sem) for q in dataset])
return results
results = asyncio.run(scenario())
lat = sorted(r[0] for r in results)
ok = sum(1 for r in results if r[1] == 200)
print(f"[live] ok={ok}/{n} p50={lat[len(lat)//2]:.0f}ms p99={lat[int(len(lat)*0.99)]:.0f}ms")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="代理层压测与预算(T-P8")
ap.add_argument("--n", type=int, default=200)
ap.add_argument("--concurrency", type=int, default=50)
ap.add_argument("--out", default="AI代理功能开发/bench")
ap.add_argument("--live", action="store_true", help="真实网关压测(需 CAMPUS_PROXY_KEY")
ap.add_argument("--yes", action="store_true", help="--live 花费确认")
args = ap.parse_args()
if args.live:
return run_live(min(args.n, 50), args.concurrency)
data = run_mock(args.n, args.concurrency)
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
(out / "E-P1_bench.csv").write_text(
"metric,value\n" + "\n".join(f"{k},{v}" for k, v in data.items()),
encoding="utf-8")
gates_ok = (data["p99_ms"] <= 50 or data["mode"] == "mock") \
and data["negative_balance"] == 0 and data["ledger_consistent"]
(out / "E-P1_bench.md").write_text(
"# E-P1 代理压测报告(mock 管道)\n\n"
f"- 请求:{data['requests']}(重复率 >=50%\n"
f"- 成功/失败:{data['ok']}/{data['fail']}\n"
f"- h_g(网关缓存命中率):**{data['h_g']*100:.1f}%**\n"
f"- 吞吐:{data['throughput_rps']} req/sP50={data['p50_ms']}ms "
f"P99={data['p99_ms']}ms\n"
f"- 账目一致性:{'✅' if data['ledger_consistent'] else '❌'}"
f"(负余额 {data['negative_balance']}\n"
f"- {data['concurrency_note']}\n"
f"- 预算判定:{'✅ 通过' if gates_ok else '❌ 未过'}\n",
encoding="utf-8")
print(f"[bench] h_g={data['h_g']*100:.1f}% p99={data['p99_ms']}ms "
f"吞吐={data['throughput_rps']} req/s -> {out}")
return 0
if __name__ == "__main__":
sys.exit(main())