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 034945890c
commit bf2d6867e7
2 changed files with 181 additions and 93 deletions

View File

@ -491,74 +491,68 @@ bind 结果无人处理)正是 106 此前长期无 `online` 日志的成因,
--- ---
## 7. webui WebAPI 压测 ## 7. WebAPI 压测(webui + kbtree + pluginmgr + remotedevice)
脚本:`scripts/kernel-stress/webui-bench.py`,打的是**生产实例** 脚本:`scripts/kernel-stress/webui-bench.py`,打的是**生产实例**。
`127.0.0.1:8080`。
```bash ```bash
K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';") K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';")
python3 scripts/kernel-stress/webui-bench.py --key "$K" --probe # 先探测 python3 scripts/kernel-stress/webui-bench.py --key "$K" --probe # 先探测
python3 scripts/kernel-stress/webui-bench.py --key "$K" --scale 3 --json /tmp/w.json python3 scripts/kernel-stress/webui-bench.py --key "$K" --scale 3 --json /tmp/w.json
``` ```
### ★ 必须先 `--probe` ### ★ 8080 上有 4 个插件注册路由,但监听端口不止 8080
webui 无认证时返回 **200 + 登录页 HTML**(含 `THEME_PLACEHOLDER`)。 第一版我只压了 webui,**漏了 pluginmgr 与 kbtree 的根路由**。
**状态码是 200**,只看 `http_code` 会把登录页当成健康响应 ⇒ `ss -ltnp` 实测:
「全部 200」是假的。脚本因此额外校验响应体(`looks_like_login_page()`)。
### ★ 只压只读端点 | 端口 | 插件 | 路由 | 认证 |
| --- | --- | --- | --- |
| `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` `/search`(**无**前缀) | `config_kbtree.token` |
| `127.0.0.1:9890` | remotedevice | 设备 WS 网关(非 REST) | 不压 |
压测绝不能改状态。18 个端点**逐个探测确认**是 GET + 只读: ⇒ 「打 `/api/v1/*`」这个假设只对 8080 上的 webui 成立。
`:8080/plugins` 实测 **404**。
| 规模 | 端点 | 备注 | ### ★ 三条判据
| --- | --- | --- |
| 小 | `/status` `/network` `/terminals` `/tracker` `/adapters` `/agents` `/config` `/persona` `/proxy/services` | 104B–1.8KB |
| 中 | `/plugins` `/runtime` `/memory/context` `/memory/text` `/memory` `/knowledge` | 4.4KB–53KB |
| 大 | `/kernel` `/memory/graph` `/knowledge/tree` | 157KB / 338KB / **425KB** |
**排除**:`/chat`(真调 LLM)、`/chat/interrupt`(中断在跑的任务)、 1. **必须先 `--probe`**:webui 无认证时返回 **200 + 登录页 HTML**
`/settings/*` `/plugins/*`(改配置)、`/login` `/logout`(改会话)、 (含 `THEME_PLACEHOLDER`)。**状态码是 200** ⇒ 只看 `http_code`
`/knowledge/*` `/memory/*` 写接口、`/device/*`(控制真实设备)。 会把登录页当健康响应。
2. **只压只读端点**:`/chat`(真调 LLM)、`/chat/interrupt`(中断在跑
的任务)、`/settings/*` `/plugins/*`(改配置)、`/login` `/logout`、
`/device/push`(向真实设备下发)、`/device/ws`(长连接)—— 全部排除。
`/api/v1/device/online` 收进来之前逐行读过实现:仅 `GET` +
`registry.OnlineList()`,纯读。
3. **配置库只读打开**(`mode=ro`)—— 压测脚本绝不能碰生产库写路径。
### 实测(2026-09-28) ### 实测(2026-09-28,24 端点 × 3 档)
| scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 | | scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 360 | 779 req/s | 6.7ms | 26.8ms | 45.5ms | 51.6ms | **360/360** | | 1 | 480 | 953 req/s | 4.0ms | 23.0ms | 34.7ms | 40.6ms | **480/480** |
| 2 | 720 | 788 req/s | 12.2ms | 52.6ms | 163.0ms | 213.5ms | **720/720** | | 2 | 960 | 1310 req/s | 7.9ms | 32.4ms | 48.4ms | 72.3ms | **960/960** |
| 3 | 1080 | 1038 req/s | 16.4ms | 60.4ms | 79.7ms | 123.0ms | **1080/1080** | | 3 | 1440 | 1422 req/s | 12.0ms | 42.7ms | 60.5ms | 88.7ms | **1440/1440** |
**2160 请求零失败**,压测期间服务端 `active`、0 个 5xx、0 个 webui 错误, (24 端点 × 20/40/60 轮 = 480/960/1440)
QQ/agent 链路未受影响。
#### 重响应不是瓶颈(并发 1 → 16) **2880 请求零失败**;压测期间服务端 `active`、0 个 5xx。
| 端点 | 大小 | 并发 1 | 并发 16 | 吞吐 | **p99 随并发单调上升**(34.7 → 48.4 → 60.5ms),符合排队预期。
| --- | --- | --- | --- | --- | 上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的
| `/status` | 0.2KB | 104 req/s | **1781 req/s** | 320KB/s | chromium 占 766% CPU ⇒ **环境噪声**,已明确不作为性能特征。
| `/kernel` | 153KB | 126 req/s | 375 req/s | 19 → **57 MB/s** |
| `/memory/graph` | 330KB | 70 req/s | 410 req/s | 23 → **135 MB/s** |
| `/knowledge/tree` | 415KB | 79 req/s | 717 req/s | 33 → **297 MB/s** |
⇒ 大 JSON 端点的 p50 **不随并发上升**(`/knowledge/tree` 12.3ms → 8.9ms, ### 踩过的三个坑(都在脚本注释里)
即排队更充分、效率更高),瓶颈不在 JSON 序列化。
### ⚠ 一次未下结论的观察 1. **端点表漏 `/api/v1` 前缀** ⇒ 18 个端点全 404,而同一时刻 curl 是 200。
不 probe 直接压 ⇒ 结论会是「webui 全挂」。
scale=2 的 p99(163ms)反而**高于** scale=3(79.7ms)。看着像反常,但当时 2. **前缀补了两次** ⇒ `remotedevice` 拼成 `/api/v1/api/v1/device/online`
机器上另一个 agent 的 chromium 占 **766% CPU**(另有 cjpm/cjc 在编译) ⇒ 落到 webui 兜底路由、返回 **200 + 登录页**(probe 的
⇒ 是**环境噪声**,不是 webui 特性。 `looks_like_login_page()` 抓到的)。反向的「表里含前缀 + 代码仍补」
又让 webui 组全 404 ⇒ 最终统一成**表里写完整路径、代码不补**。
**未在可比条件下重测就不下结论** —— 别拿这一组数据当性能特征。 3. **`contextlib.suppress(sqlite3.connect)`** —— `connect` 是函数不是
要判定需先固定负载条件(停掉占 CPU 的进程)再跑。 异常类,`suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`。
### 踩过的坑
脚本第一版把端点表存成路径后半段(`"/status"`)而漏了 `/api/v1` 前缀 ⇒
拼出 `http://127.0.0.1:8080/status` ⇒ **18 个端点全 404**,而同一时刻
`curl /api/v1/status` 是 200。
⇒ **压测脚本必须先 probe 再压。** 若不 probe,那一跑会得出
「webui 全挂」的错误结论。

View File

@ -35,13 +35,36 @@ import argparse
import concurrent.futures as cf import concurrent.futures as cf
import contextlib import contextlib
import json import json
import os
import statistics import statistics
import sys import sys
import time import time
import urllib.error import urllib.error
import urllib.request 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 前缀。端点表里存的是**路径后半段** # ★ 必须带 /api/v1 前缀。端点表里存的是**路径后半段**
# ("/status"),拼 BASE + "/status" 会得到 "/status" ⇒ 全部 404。 # ("/status"),拼 BASE + "/status" 会得到 "/status" ⇒ 全部 404。
# 首次跑 --probe 时正是这样:18 个端点全 404,而同一时刻 curl # 首次跑 --probe 时正是这样:18 个端点全 404,而同一时刻 curl
@ -50,27 +73,65 @@ API_PREFIX = "/api/v1"
# 实测确认的只读端点(GET,200,不改状态) # 实测确认的只读端点(GET,200,不改状态)
READONLY = [ READONLY = [
("/status", "小", "首页 KPI 之一"), # ── webui(:8080,api_key)────────────────────────────────────
("/network", "小", "网络信息"), ("webui", "/api/v1/status", "小", "首页 KPI 之一"),
("/terminals", "小", "终端列表"), ("webui", "/api/v1/network", "小", "网络信息"),
("/tracker", "小", "跟踪器"), ("webui", "/api/v1/terminals", "小", "终端列表"),
("/adapters", "小", "LLM 适配器"), ("webui", "/api/v1/tracker", "小", "跟踪器"),
("/agents", "小", "agent 列表"), ("webui", "/api/v1/adapters", "小", "LLM 适配器"),
("/config", "小", "配置"), ("webui", "/api/v1/agents", "小", "agent 列表"),
("/persona", "小", "人格"), ("webui", "/api/v1/config", "小", "配置"),
("/proxy/services", "小", "反代服务"), ("webui", "/api/v1/persona", "小", "人格"),
("/plugins", "中", "插件清单"), ("webui", "/api/v1/proxy/services", "小", "反代服务"),
("/runtime", "中", "运行时"), ("webui", "/api/v1/plugins", "中", "插件清单"),
("/memory/context", "中", "记忆上下文"), ("webui", "/api/v1/runtime", "中", "运行时"),
("/memory/text", "中", "文本记忆"), ("webui", "/api/v1/memory/context", "中", "记忆上下文"),
("/memory", "中", "记忆"), ("webui", "/api/v1/memory/text", "中", "文本记忆"),
("/knowledge", "中", "知识库"), ("webui", "/api/v1/memory", "中", "记忆"),
("/kernel", "大", "★ 总览 8 个 KPI 的数据源"), ("webui", "/api/v1/knowledge", "中", "知识库"),
("/memory/graph", "大", "记忆图谱"), ("webui", "/api/v1/kernel", "大", "★ 总览 8 个 KPI 的数据源"),
("/knowledge/tree", "大", "★ 知识树(实测最大 425KB)"), ("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: def looks_like_login_page(body: bytes) -> bool:
"""webui 无认证时返回 200 + 登录页 HTML。 """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: def one_request(group: str, path: str, keys: dict, timeout: float) -> dict:
url = BASE + API_PREFIX + path """打一次请求。group 决定用哪个端口与哪把 key。
req = urllib.request.Request(url, headers={"X-API-Key": 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() t0 = time.perf_counter()
try: try:
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
@ -128,12 +205,17 @@ def pct(vals, p):
return s[k] return s[k]
def probe(key: str) -> int: def probe(keys: dict) -> int:
"""逐个确认端点真的可 GET、返回 JSON 而非登录页。""" """逐个确认端点真的可 GET、返回 JSON 而非登录页。
★ 必须先 probe:脚本第一版漏了 /api/v1 前缀,18 个端点全 404,
而同一时刻 curl /api/v1/status 是 200。不 probe 就直接压,
得出的结论会是「webui 全挂」。
"""
print("端点探测(GET / 只读)") print("端点探测(GET / 只读)")
bad = 0 bad = 0
for path, size, note in READONLY: for group, path, size, note in READONLY:
r = one_request(path, key, 15) r = one_request(group, path, keys, 15)
flag = "✓" if r["ok"] else "✗" flag = "✓" if r["ok"] else "✗"
extra = "" extra = ""
if r.get("login_page"): if r.get("login_page"):
@ -142,15 +224,15 @@ def probe(key: str) -> int:
elif r["status"] != 200: elif r["status"] != 200:
extra = f" ← {r.get('err', '')[:80]}" extra = f" ← {r.get('err', '')[:80]}"
bad += 1 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 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 = [] jobs = []
for _ in range(rounds): for _ in range(rounds):
for path, _, _ in READONLY: for group, path, _, _ in READONLY:
jobs.append(path) jobs.append((group, path))
print(f"\n压测:{len(READONLY)} 端点 × {rounds} 轮 = {len(jobs)} 请求," print(f"\n压测:{len(READONLY)} 端点 × {rounds} 轮 = {len(jobs)} 请求,"
f"并发 {conc}(scale={scale})") f"并发 {conc}(scale={scale})")
print(" 只读端点,不触碰 /chat、/settings、/plugins 写接口、/device\n") 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() t0 = time.perf_counter()
results = [] results = []
with cf.ThreadPoolExecutor(max_workers=conc) as ex: 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): for f in cf.as_completed(futs):
results.append((futs[f], f.result())) results.append((futs[f], f.result()))
wall = time.perf_counter() - t0 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: for path, r in results:
by_path.setdefault(path, []).append(r) 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) print("-" * 78)
all_ms = [] all_ms = []
total_ok = total = 0 total_ok = total = 0
failures = [] failures = []
for path, _, _ in READONLY: for group, path, _, _ in READONLY:
rs = by_path.get(path, []) rs = by_path.get((group, path), [])
if not rs: if not rs:
continue continue
oks = [r for r in rs if r["ok"]] 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) total += len(rs)
for r in rs: for r in rs:
if not r["ok"]: 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 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}") 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) print("-" * 78)
@ -202,12 +284,12 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
if failures: if failures:
print(f"\n★ 失败 {len(failures)} 次(带样本,便于定位):") print(f"\n★ 失败 {len(failures)} 次(带样本,便于定位):")
seen = set() seen = set()
for path, r in failures[:10]: for group, path, r in failures[:10]:
key2 = (path, r["status"]) key2 = (group, path, r["status"])
if key2 in seen: if key2 in seen:
continue continue
seen.add(key2) 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]}") f"{r.get('err', 'login_page' if r.get('login_page') else '')[:90]}")
if out: 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), "p95": pct([r["ms"] for r in v], 95),
"max": max((r["ms"] for r in v), default=None), "max": max((r["ms"] for r in v), default=None),
"avg_bytes": sum(r["bytes"] for r in v) / len(v)} "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) }, f, ensure_ascii=False, indent=2)
except OSError as e: except OSError as e:
# ★ 写不出去就**说清楚**,不能默默丢掉整份报告。 # ★ 写不出去就**说清楚**,不能默默丢掉整份报告。
@ -236,7 +318,9 @@ def bench(key: str, scale: int, rounds: int, conc: int, timeout: float, out: str
def main(): def main():
ap = argparse.ArgumentParser() 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("--scale", type=int, default=1)
ap.add_argument("--conc", type=int, default=0, help="0=按 scale 自动") ap.add_argument("--conc", type=int, default=0, help="0=按 scale 自动")
ap.add_argument("--timeout", type=float, default=30.0) ap.add_argument("--timeout", type=float, default=30.0)
@ -244,12 +328,22 @@ def main():
ap.add_argument("--probe", action="store_true") ap.add_argument("--probe", action="store_true")
a = ap.parse_args() 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: if a.probe:
sys.exit(probe(a.key)) sys.exit(probe(keys))
rounds = 20 * a.scale rounds = 20 * a.scale
conc = a.conc or min(64, 8 * 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__": if __name__ == "__main__":