Files
HomeAgent/scripts/kernel-stress/webui-bench.py
JianFeeeee 79c15f0452 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 逐项比对,三档现已全部一致。
2026-09-28 09:33:22 +08:00

351 lines
15 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.

#!/usr/bin/env python3
"""webui WebAPI 只读端点压测。
## 为什么只压只读端点
这是**生产实例**(192.168.2.60:8080)。压测绝不能改状态:
`/chat` 会真调 LLM、`/chat/interrupt` 会中断正在跑的任务、
`/settings/*` 与 `/plugins/*` 会改配置、`/device/*` 会控制真实设备。
⇒ 端点表**按实测逐个确认过**是 GET + 只读,不是照路由表抄的。
探测方式见下方 `--probe`。
## 判据(与 kernel-stress 那套一致)
不能只看"没报错"或墙钟时间。每一项都记:
- 成功率(HTTP 200 占比)
- 延迟分布(p50 / p95 / p99 / max)
- 失败状态码与响应体样本
**失败必须带样本**,否则「200 但返回空 JSON」这类退化看不见。
## 认证
webui 认两种:`X-API-Key` 头,或 `homeagent_session` cookie。
无认证时返回 **200 + 登录页 HTML**(含 `THEME_PLACEHOLDER`)——
**状态码是 200 但内容是登录页**,只统计状态码会得出「全部健康」的错误结论。
本脚本因此额外校验响应体,见 `looks_like_login_page()`。
用法:
python3 webui-bench.py --key <API_KEY> [--scale 1|2|3] [--json out.json]
python3 webui-bench.py --key <API_KEY> --probe # 只探测端点可用性
"""
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
# ★ 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
# /api/v1/status 是 200。⇒ 压测脚本**必须先 probe** 再压。
API_PREFIX = "/api/v1"
# 实测确认的只读端点(GET,200,不改状态)
READONLY = [
# ── 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。
状态码骗人:只看 http_code 会把「登录页」当成健康响应。
"""
head = body[:4096]
return (
b"THEME_PLACEHOLDER" in head
or "统一门户登录".encode() in head
or (b"<title>" in head and b"login" in head)
)
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:
body = r.read()
dt = (time.perf_counter() - t0) * 1000
return {
"ok": r.status == 200 and not looks_like_login_page(body),
"status": r.status,
"bytes": len(body),
"ms": dt,
"login_page": looks_like_login_page(body),
}
except urllib.error.HTTPError as e:
dt = (time.perf_counter() - t0) * 1000
# ★ 读取错误体可能二次抛错(连接已断、body 为空),不能让它
# 盖掉真正的状态码 —— 用 suppress 只吞这一处的异常。
sample = ""
with contextlib.suppress(Exception):
sample = e.read()[:200].decode("utf-8", "replace")
return {"ok": False, "status": e.code, "bytes": 0, "ms": dt, "err": sample}
except Exception as e:
dt = (time.perf_counter() - t0) * 1000
return {"ok": False, "status": 0, "bytes": 0, "ms": dt, "err": repr(e)[:200]}
def pct(vals, p):
"""百分位。空列表 / 非法入参都返回 None 而不是抛错。
调用方会把它直接拿去做 f-string 格式化(可能收到 None),
所以**这里不能抛** —— 一个端点的空样本不该让整份报告崩掉。
"""
if not vals:
return None
try:
s = sorted(float(v) for v in vals)
k = min(len(s) - 1, max(0, int(round((float(p) / 100) * (len(s) - 1)))))
except (TypeError, ValueError):
return None
return s[k]
def probe(keys: dict) -> int:
"""逐个确认端点真的可 GET、返回 JSON 而非登录页。
★ 必须先 probe:脚本第一版漏了 /api/v1 前缀,18 个端点全 404,
而同一时刻 curl /api/v1/status 是 200。不 probe 就直接压,
得出的结论会是「webui 全挂」。
"""
print("端点探测(GET / 只读)")
bad = 0
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"):
extra = " ← 返回登录页(认证有问题)"
bad += 1
elif r["status"] != 200:
extra = f" ← {r.get('err', '')[:80]}"
bad += 1
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(keys: dict, scale: int, rounds: int, conc: int, timeout: float, out: str | None):
jobs = []
for _ in range(rounds):
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")
t0 = time.perf_counter()
results = []
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
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
by_path = {}
for path, r in results:
by_path.setdefault(path, []).append(r)
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 group, path, _, _ in READONLY:
rs = by_path.get((group, path), [])
if not rs:
continue
oks = [r for r in rs if r["ok"]]
ms = [r["ms"] for r in oks] or [r["ms"] for r in rs]
all_ms += ms
total_ok += len(oks)
total += len(rs)
for r in rs:
if not r["ok"]:
failures.append((group, path, r))
avg_kb = sum(r["bytes"] for r in rs) / len(rs) / 1024
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)
rps = total / wall if wall else 0
print(f"总计 {total} 请求 / {wall:.2f}s ⇒ {rps:.0f} req/s "
f"成功 {total_ok}/{total} = {total_ok/total*100:.2f}%")
print(f"全局 p50={pct(all_ms,50):.1f}ms p95={pct(all_ms,95):.1f}ms "
f"p99={pct(all_ms,99):.1f}ms max={max(all_ms):.1f}ms")
if all_ms:
print(f"平均 {statistics.mean(all_ms):.1f}ms "
f"stdev {statistics.pstdev(all_ms):.1f}ms "
f"min {min(all_ms):.1f}ms")
if failures:
print(f"\n★ 失败 {len(failures)} 次(带样本,便于定位):")
seen = set()
for group, path, r in failures[:10]:
key2 = (group, path, r["status"])
if key2 in seen:
continue
seen.add(key2)
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:
try:
with open(out, "w", encoding="utf-8") as f:
json.dump({
"wall_s": wall, "rps": rps, "total": total, "ok": total_ok,
"p50": pct(all_ms, 50), "p95": pct(all_ms, 95), "p99": pct(all_ms, 99),
"max": max(all_ms) if all_ms else None,
"per_path": {p: {"n": len(v),
"ok": sum(1 for r in v if r["ok"]),
"p50": pct([r["ms"] for r in v], 50),
"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 (g, p), v in by_path.items()},
}, f, ensure_ascii=False, indent=2)
except OSError as e:
# ★ 写不出去就**说清楚**,不能默默丢掉整份报告。
print(f"\n★ 写入 {out} 失败: {e}", file=sys.stderr)
return 1
print(f"\n已写 {out}")
return 0 if total_ok == total else 1
def main():
ap = argparse.ArgumentParser()
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)
ap.add_argument("--json", default=None)
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(keys))
rounds = 20 * a.scale
conc = a.conc or min(64, 8 * a.scale)
sys.exit(bench(keys, a.scale, rounds, conc, a.timeout, a.json))
if __name__ == "__main__":
main()