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

@ -491,74 +491,68 @@ bind 结果无人处理)正是 106 此前长期无 `online` 日志的成因,
---
## 7. webui WebAPI 压测
## 7. WebAPI 压测(webui + kbtree + pluginmgr + remotedevice)
脚本:`scripts/kernel-stress/webui-bench.py`,打的是**生产实例**
`127.0.0.1:8080`。
脚本:`scripts/kernel-stress/webui-bench.py`,打的是**生产实例**。
```bash
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
```
### ★ 必须先 `--probe`
### ★ 8080 上有 4 个插件注册路由,但监听端口不止 8080
webui 无认证时返回 **200 + 登录页 HTML**(含 `THEME_PLACEHOLDER`)。
**状态码是 200**,只看 `http_code` 会把登录页当成健康响应 ⇒
「全部 200」是假的。脚本因此额外校验响应体(`looks_like_login_page()`)。
第一版我只压了 webui,**漏了 pluginmgr 与 kbtree 的根路由**。
`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` `/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`(中断在跑的任务)、
`/settings/*` `/plugins/*`(改配置)、`/login` `/logout`(改会话)、
`/knowledge/*` `/memory/*` 写接口、`/device/*`(控制真实设备)。
1. **必须先 `--probe`**:webui 无认证时返回 **200 + 登录页 HTML**
(含 `THEME_PLACEHOLDER`)。**状态码是 200** ⇒ 只看 `http_code`
会把登录页当健康响应。
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 | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 360 | 779 req/s | 6.7ms | 26.8ms | 45.5ms | 51.6ms | **360/360** |
| 2 | 720 | 788 req/s | 12.2ms | 52.6ms | 163.0ms | 213.5ms | **720/720** |
| 3 | 1080 | 1038 req/s | 16.4ms | 60.4ms | 79.7ms | 123.0ms | **1080/1080** |
| 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** |
**2160 请求零失败**,压测期间服务端 `active`、0 个 5xx、0 个 webui 错误,
QQ/agent 链路未受影响。
(24 端点 × 20/40/60 轮 = 480/960/1440)
#### 重响应不是瓶颈(并发 1 → 16)
**2880 请求零失败**;压测期间服务端 `active`、0 个 5xx。
| 端点 | 大小 | 并发 1 | 并发 16 | 吞吐 |
| --- | --- | --- | --- | --- |
| `/status` | 0.2KB | 104 req/s | **1781 req/s** | 320KB/s |
| `/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** |
**p99 随并发单调上升**(34.7 → 48.4 → 60.5ms),符合排队预期。
上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的
chromium 占 766% CPU ⇒ **环境噪声**,已明确不作为性能特征。
⇒ 大 JSON 端点的 p50 **不随并发上升**(`/knowledge/tree` 12.3ms → 8.9ms,
即排队更充分、效率更高),瓶颈不在 JSON 序列化。
### 踩过的三个坑(都在脚本注释里)
### ⚠ 一次未下结论的观察
scale=2 的 p99(163ms)反而**高于** scale=3(79.7ms)。看着像反常,但当时
机器上另一个 agent 的 chromium 占 **766% CPU**(另有 cjpm/cjc 在编译)
⇒ 是**环境噪声**,不是 webui 特性。
**未在可比条件下重测就不下结论** —— 别拿这一组数据当性能特征。
要判定需先固定负载条件(停掉占 CPU 的进程)再跑。
### 踩过的坑
脚本第一版把端点表存成路径后半段(`"/status"`)而漏了 `/api/v1` 前缀 ⇒
拼出 `http://127.0.0.1:8080/status` ⇒ **18 个端点全 404**,而同一时刻
`curl /api/v1/status` 是 200。
⇒ **压测脚本必须先 probe 再压。** 若不 probe,那一跑会得出
「webui 全挂」的错误结论。
1. **端点表漏 `/api/v1` 前缀** ⇒ 18 个端点全 404,而同一时刻 curl 是 200。
不 probe 直接压 ⇒ 结论会是「webui 全挂」。
2. **前缀补了两次** ⇒ `remotedevice` 拼成 `/api/v1/api/v1/device/online`
⇒ 落到 webui 兜底路由、返回 **200 + 登录页**(probe 的
`looks_like_login_page()` 抓到的)。反向的「表里含前缀 + 代码仍补」
又让 webui 组全 404 ⇒ 最终统一成**表里写完整路径、代码不补**。
3. **`contextlib.suppress(sqlite3.connect)`** —— `connect` 是函数不是
异常类,`suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`。

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__":