test(webui): 压测补齐另外 3 个插件的路由 —— 我第一版漏了两个

第一版只压了 webui 的 `/api/v1/*`。核实后发现 **8080 上注册 HTTP 路由的
插件有 4 个,监听端口还不止 8080**(`ss -ltnp` 实测):

| 端口 | 插件 | 路由 | 认证 |
| --- | --- | --- | --- |
| 127.0.0.1:8080 | webui | /api/v1/* | config_webui.api_key |
| | remotedevice | /api/v1/device/* | config_remotedevice.ws_token |
| | kbtree 子路径 | /api/v1/knowledge/tree/{categories,counts} | config_webui.api_key |
| 127.0.0.1:9876 | **pluginmgr** | /plugins(**无** /api/v1) | **无需认证** |
| 127.0.0.1:9892 | **kbtree** | /categories /counts(**无**前缀) | config_kbtree.token |
| 127.0.0.1:9890 | remotedevice | 设备 WS 网关(非 REST) | 不压 |

⇒ 「打 /api/v1/*」这个假设只对 8080 上的 webui 成立:`:8080/plugins`
实测 **404**。三套认证各不相同,脚本现在按分组取对应 token。

## 端点 18 → 24

新增:`/api/v1/device/online`(remotedevice)、`/plugins`(pluginmgr)、
`/categories` `/counts`(kbtree 根路由)、以及 webui 前缀内的
`/api/v1/knowledge/tree/{categories,counts}`。

收 `/api/v1/device/online` 之前逐行读过实现:仅 `GET` +
`registry.OnlineList()`,纯读。**不收** `/device/push`(向真实设备下发)、
`/device/ws`(长连接)、`/device/{id}`(语义未核实)。

## 实测(24 端点 × 3 档,2880 请求零失败)

| scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 480 | 953 req/s | 4.0ms | 23.0ms | 34.7ms | 40.6ms | 480/480 |
| 2 | 960 | 1310 req/s | 7.9ms | 32.4ms | 48.4ms | 72.3ms | 960/960 |
| 3 | 1440 | 1422 req/s | 12.0ms | 42.7ms | 60.5ms | 88.7ms | 1440/1440 |

压测期间服务端 `active`、0 个 5xx。

**p99 随并发单调上升**(34.7 → 48.4 → 60.5ms),符合排队预期。
上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的
chromium 占 766% CPU ⇒ 环境噪声,已明确不作为性能特征。

## 又踩了一次前缀的坑(两种方向都踩了)

1. 「表里存路径后半段 + 代码按 group 补前缀」⇒ remotedevice 拼成
   `/api/v1/api/v1/device/online` ⇒ 落到 webui 兜底路由、
   返回 **200 + 登录页 HTML**(靠 `looks_like_login_page()` 抓到)。
2. 反过来「表里已含前缀 + 代码仍补」⇒ webui 组全 404。

⇒ 最终统一成**表里写完整路径、代码不补**。两次都是靠 `--probe` 抓到的,
这正是它存在的理由。

## 顺带修掉我写错的一处

`contextlib.suppress(sqlite3.connect)` —— `connect` 是函数不是异常类,
`suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`,
并把 `config.db` 的打开方式保持 `mode=ro`(压测不碰生产库写路径)。

## 文档里我自己写错又改正的一处

§7 表格里 scale=1 一行先写成 1680/2013/5.4ms,核对 JSON 后实为
**480/953/4.0ms**(24 端点 × 20 轮)。已订正,并加了提交前的
JSON 逐项比对,三档现已全部一致。
This commit is contained in:
JianFeeeee
2026-09-28 09:33:22 +08:00
parent aed8f1f808
commit 79c15f0452
2 changed files with 181 additions and 93 deletions

View File

@ -35,13 +35,36 @@ import argparse
import concurrent.futures as cf
import contextlib
import json
import os
import statistics
import sys
import time
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8080"
# ★ 8080 上注册 HTTP 路由的插件有 4 个,但**监听端口不止 8080**。
# 实测(ss -ltnp):
# 127.0.0.1:8080 webui(/api/v1/*) + remotedevice(/api/v1/device/*)
# + kbtree 的 /api/v1/knowledge/tree/* 子路径
# 127.0.0.1:9876 pluginmgr 全部路由(/plugins)—— 无 /api/v1 前缀
# 127.0.0.1:9892 kbtree 根路由(/categories /counts /search)—— 无前缀
# 127.0.0.1:9890 remotedevice 的设备 WS 网关(不是 REST,不压)
#
# ⇒ 我第一版只压 8080 上的 webui,**漏了 pluginmgr 与 kbtree 根路由**。
# 它们不在 /api/v1 下(实测 :8080/plugins = 404)。
#
# 三套认证各不相同:
# webui / kbtree-subpath → config_webui 的 api_key
# remotedevice → config_remotedevice 的 ws_token
# kbtree 根路由 → config_kbtree 的 token
# pluginmgr → 无需认证(实测 200)
PORTS = {
"webui": "http://127.0.0.1:8080",
"pluginmgr": "http://127.0.0.1:9876",
"kbtree": "http://127.0.0.1:9892",
"remotedevice": "http://127.0.0.1:8080",
}
BASE = PORTS["webui"]
# ★ 必须带 /api/v1 前缀。端点表里存的是**路径后半段**
# ("/status"),拼 BASE + "/status" 会得到 "/status" ⇒ 全部 404。
# 首次跑 --probe 时正是这样:18 个端点全 404,而同一时刻 curl
@ -50,27 +73,65 @@ API_PREFIX = "/api/v1"
# 实测确认的只读端点(GET,200,不改状态)
READONLY = [
("/status", "小", "首页 KPI 之一"),
("/network", "小", "网络信息"),
("/terminals", "小", "终端列表"),
("/tracker", "小", "跟踪器"),
("/adapters", "小", "LLM 适配器"),
("/agents", "小", "agent 列表"),
("/config", "小", "配置"),
("/persona", "小", "人格"),
("/proxy/services", "小", "反代服务"),
("/plugins", "中", "插件清单"),
("/runtime", "中", "运行时"),
("/memory/context", "中", "记忆上下文"),
("/memory/text", "中", "文本记忆"),
("/memory", "中", "记忆"),
("/knowledge", "中", "知识库"),
("/kernel", "大", "★ 总览 8 个 KPI 的数据源"),
("/memory/graph", "大", "记忆图谱"),
("/knowledge/tree", "大", "★ 知识树(实测最大 425KB)"),
# ── webui(:8080,api_key)────────────────────────────────────
("webui", "/api/v1/status", "小", "首页 KPI 之一"),
("webui", "/api/v1/network", "小", "网络信息"),
("webui", "/api/v1/terminals", "小", "终端列表"),
("webui", "/api/v1/tracker", "小", "跟踪器"),
("webui", "/api/v1/adapters", "小", "LLM 适配器"),
("webui", "/api/v1/agents", "小", "agent 列表"),
("webui", "/api/v1/config", "小", "配置"),
("webui", "/api/v1/persona", "小", "人格"),
("webui", "/api/v1/proxy/services", "小", "反代服务"),
("webui", "/api/v1/plugins", "中", "插件清单"),
("webui", "/api/v1/runtime", "中", "运行时"),
("webui", "/api/v1/memory/context", "中", "记忆上下文"),
("webui", "/api/v1/memory/text", "中", "文本记忆"),
("webui", "/api/v1/memory", "中", "记忆"),
("webui", "/api/v1/knowledge", "中", "知识库"),
("webui", "/api/v1/kernel", "大", "★ 总览 8 个 KPI 的数据源"),
("webui", "/api/v1/memory/graph", "大", "记忆图谱"),
("webui", "/api/v1/knowledge/tree", "大", "★ 知识树(实测最大 425KB)"),
# ── kbtree 子路径(:8080 前缀内,api_key)────────────────────
("webui", "/api/v1/knowledge/tree/categories", "小", "kbtree:分类列表"),
("webui", "/api/v1/knowledge/tree/counts", "小", "kbtree:各分类计数"),
# ── remotedevice(:8080,ws_token)──────────────────────────
# 仅 /online:实测仅 GET + registry.OnlineList(),纯读。
# ★ 不收 /device/push(会向真实设备下发)、/device/ws(长连接)、
# /device/{id}(逐设备查询,语义未核实)。
("remotedevice", "/api/v1/device/online", "小", "在线设备列表(只读)"),
# ── pluginmgr(:9876,无认证,无 /api/v1 前缀)──────────────
("pluginmgr", "/plugins", "中", "插件清单(7.4KB,独立端口)"),
# ── kbtree 根路由(:9892,kbtree 自己的 token)─────────────
("kbtree", "/categories", "小", "分类列表(独立端口)"),
("kbtree", "/counts", "小", "各分类计数(独立端口)"),
]
def _cfg(table: str, key: str) -> str:
"""从生产 config.db 取插件自己的 token(不打印内容)。
只读打开(mode=ro)—— 压测脚本绝不能碰生产库的写路径。
"""
import sqlite3
db = "/home/newqqagent/config.db"
if not os.path.exists(db):
return ""
con = None
try:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
row = con.execute(f"select value from {table} where key=?", (key,)).fetchone()
return row[0] if row else ""
except sqlite3.Error:
# 库锁、schema 变了、表不存在……一律当作「没取到」,
# 由调用方报「缺 token」而不是抛栈。
return ""
finally:
if con is not None:
con.close()
def looks_like_login_page(body: bytes) -> bool:
"""webui 无认证时返回 200 + 登录页 HTML。
@ -84,9 +145,25 @@ def looks_like_login_page(body: bytes) -> bool:
)
def one_request(path: str, key: str, timeout: float) -> dict:
url = BASE + API_PREFIX + path
req = urllib.request.Request(url, headers={"X-API-Key": key})
def one_request(group: str, path: str, keys: dict, timeout: float) -> dict:
"""打一次请求。group 决定用哪个端口与哪把 key。
三套认证实测各不相同(见 PORTS 处的说明):
webui / remotedevice 在 :8080,但 key 不同;
pluginmgr / kbtree 在各自独立端口,前缀也不同。
"""
base = PORTS[group]
# ★ 约定:端点表里存**完整路径**(含 /api/v1 前缀,若该组需要)。
# 这里直接拼,不再按 group 补前缀 ——
# 早先「表里存后半段 + 按 group 补前缀」导致 remotedevice 那条
# 拼成 /api/v1/api/v1/device/online ⇒ 落到 webui 兜底路由、
# 返回 200 + 登录页 HTML。
# 反过来「表里含前缀 + 仍补前缀」又让 webui 组全 404。
# 两种都踩过 ⇒ 统一成一种:表里写全,代码不补。
url = base + path
key = keys.get(group, "")
headers = {"X-API-Key": key} if key else {}
req = urllib.request.Request(url, headers=headers)
t0 = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
@ -128,12 +205,17 @@ def pct(vals, p):
return s[k]
def probe(key: str) -> int:
"""逐个确认端点真的可 GET、返回 JSON 而非登录页。"""
def probe(keys: dict) -> int:
"""逐个确认端点真的可 GET、返回 JSON 而非登录页。
★ 必须先 probe:脚本第一版漏了 /api/v1 前缀,18 个端点全 404,
而同一时刻 curl /api/v1/status 是 200。不 probe 就直接压,
得出的结论会是「webui 全挂」。
"""
print("端点探测(GET / 只读)")
bad = 0
for path, size, note in READONLY:
r = one_request(path, key, 15)
for group, path, size, note in READONLY:
r = one_request(group, path, keys, 15)
flag = "✓" if r["ok"] else "✗"
extra = ""
if r.get("login_page"):
@ -142,15 +224,15 @@ def probe(key: str) -> int:
elif r["status"] != 200:
extra = f" ← {r.get('err', '')[:80]}"
bad += 1
print(f" {flag} {path:24} {r['status']:>3} {r['bytes']:>7}B {r['ms']:>7.1f}ms [{size}] {note}{extra}")
print(f" {flag} {group:12} {path:24} {r['status']:>3} {r['bytes']:>7}B {r['ms']:>7.1f}ms [{size}] {note}{extra}")
return 1 if bad else 0
def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str | None):
def bench(keys: dict, scale: int, rounds: int, conc: int, timeout: float, out: str | None):
jobs = []
for _ in range(rounds):
for path, _, _ in READONLY:
jobs.append(path)
for group, path, _, _ in READONLY:
jobs.append((group, path))
print(f"\n压测:{len(READONLY)} 端点 × {rounds} 轮 = {len(jobs)} 请求,"
f"并发 {conc}(scale={scale})")
print(" 只读端点,不触碰 /chat、/settings、/plugins 写接口、/device\n")
@ -158,7 +240,7 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
t0 = time.perf_counter()
results = []
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
futs = {ex.submit(one_request, p, key, timeout): p for p in jobs}
futs = {ex.submit(one_request, g, p, keys, timeout): (g, p) for g, p in jobs}
for f in cf.as_completed(futs):
results.append((futs[f], f.result()))
wall = time.perf_counter() - t0
@ -167,13 +249,13 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
for path, r in results:
by_path.setdefault(path, []).append(r)
print(f"{'端点':24} {'成功':>10} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8} {'平均KB':>8}")
print(f"{'组':12} {'端点':24} {'成功':>10} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8} {'平均KB':>8}")
print("-" * 78)
all_ms = []
total_ok = total = 0
failures = []
for path, _, _ in READONLY:
rs = by_path.get(path, [])
for group, path, _, _ in READONLY:
rs = by_path.get((group, path), [])
if not rs:
continue
oks = [r for r in rs if r["ok"]]
@ -183,9 +265,9 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
total += len(rs)
for r in rs:
if not r["ok"]:
failures.append((path, r))
failures.append((group, path, r))
avg_kb = sum(r["bytes"] for r in rs) / len(rs) / 1024
print(f"{path:24} {len(oks):>4}/{len(rs):<5} "
print(f"{group:12} {path:24} {len(oks):>4}/{len(rs):<5} "
f"{pct(ms,50):>8.1f} {pct(ms,95):>8.1f} {pct(ms,99):>8.1f} {max(ms):>8.1f} {avg_kb:>8.1f}")
print("-" * 78)
@ -202,12 +284,12 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
if failures:
print(f"\n★ 失败 {len(failures)} 次(带样本,便于定位):")
seen = set()
for path, r in failures[:10]:
key2 = (path, r["status"])
for group, path, r in failures[:10]:
key2 = (group, path, r["status"])
if key2 in seen:
continue
seen.add(key2)
print(f" {path:24} status={r['status']:>3} "
print(f" {group:12} {path:24} status={r['status']:>3} "
f"{r.get('err', 'login_page' if r.get('login_page') else '')[:90]}")
if out:
@ -223,7 +305,7 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
"p95": pct([r["ms"] for r in v], 95),
"max": max((r["ms"] for r in v), default=None),
"avg_bytes": sum(r["bytes"] for r in v) / len(v)}
for p, v in by_path.items()},
for (g, p), v in by_path.items()},
}, f, ensure_ascii=False, indent=2)
except OSError as e:
# ★ 写不出去就**说清楚**,不能默默丢掉整份报告。
@ -236,7 +318,9 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--key", required=True, help="webui API key")
ap.add_argument("--key", required=True, help="webui api_key(config_webui)")
ap.add_argument("--device-token", default="", help="remotedevice ws_token(默认从 config.db 读)")
ap.add_argument("--kbtree-token", default="", help="kbtree token(默认从 config.db 读)")
ap.add_argument("--scale", type=int, default=1)
ap.add_argument("--conc", type=int, default=0, help="0=按 scale 自动")
ap.add_argument("--timeout", type=float, default=30.0)
@ -244,12 +328,22 @@ def main():
ap.add_argument("--probe", action="store_true")
a = ap.parse_args()
keys = {
"webui": a.key,
"remotedevice": a.device_token or _cfg("config_remotedevice", "ws_token"),
"kbtree": a.kbtree_token or _cfg("config_kbtree", "token"),
"pluginmgr": "", # 实测无需认证
}
for g in ("webui", "remotedevice", "kbtree"):
if not keys[g]:
print(f"★ 缺 {g} 的 token,该组端点会 401。先跑 --probe 确认。", file=sys.stderr)
if a.probe:
sys.exit(probe(a.key))
sys.exit(probe(keys))
rounds = 20 * a.scale
conc = a.conc or min(64, 8 * a.scale)
sys.exit(bench(a.key, a.scale, rounds, conc, a.timeout, a.json))
sys.exit(bench(keys, a.scale, rounds, conc, a.timeout, a.json))
if __name__ == "__main__":