mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
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:
@ -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`。
|
||||
|
||||
@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user