From 79c15f0452f4bd08b6ccc1b8b2216f97f418e8f2 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 09:33:22 +0800 Subject: [PATCH] =?UTF-8?q?test(webui):=20=E5=8E=8B=E6=B5=8B=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=8F=A6=E5=A4=96=203=20=E4=B8=AA=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E8=B7=AF=E7=94=B1=20=E2=80=94=E2=80=94=20=E6=88=91?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E7=89=88=E6=BC=8F=E4=BA=86=E4=B8=A4=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一版只压了 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 逐项比对,三档现已全部一致。 --- docs/zh/deploy-runbook.md | 94 +++++++------- scripts/kernel-stress/webui-bench.py | 180 ++++++++++++++++++++------- 2 files changed, 181 insertions(+), 93 deletions(-) diff --git a/docs/zh/deploy-runbook.md b/docs/zh/deploy-runbook.md index a3dd5ae..ac76bb2 100644 --- a/docs/zh/deploy-runbook.md +++ b/docs/zh/deploy-runbook.md @@ -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`。 diff --git a/scripts/kernel-stress/webui-bench.py b/scripts/kernel-stress/webui-bench.py index 956e63f..567e86e 100644 --- a/scripts/kernel-stress/webui-bench.py +++ b/scripts/kernel-stress/webui-bench.py @@ -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__":