Commit Graph

2 Commits

Author SHA1 Message Date
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
aed8f1f808 test(webui): WebAPI 只读端点压测 + 实测 2160 请求零失败
打的是**生产实例** 127.0.0.1:8080。

## 为什么只压只读端点

压测绝不能改状态。18 个端点**逐个探测确认**是 GET + 只读:
小(/status…/proxy/services,104B–1.8KB)、中(/plugins…/knowledge,
4.4KB–53KB)、大(/kernel 157KB、/memory/graph 338KB、/knowledge/tree 425KB)。

**排除**:/chat(真调 LLM)、/chat/interrupt(中断在跑的任务)、
/settings/* 与 /plugins/*(改配置)、/login /logout(改会话)、
knowledge/memory 写接口、/device/*(控制真实设备)。

## ★ 两条判据(都不是"看有没有报错")

### 1. 必须先 `--probe`:webui 无认证时返回 200 + 登录页 HTML

含 `THEME_PLACEHOLDER`,**状态码是 200**。只看 `http_code` 会把登录页
当成健康响应 ⇒「全部 200」是假的。脚本因此额外校验响应体。

### 2. 端点表漏了 `/api/v1` 前缀 ⇒ 18 个端点全 404

第一版把端点存成路径后半段(`"/status"`),拼出
`http://127.0.0.1:8080/status`,而真实路径是 `/api/v1/status` ⇒
**全部 404**,同一时刻 curl `/api/v1/status` 却是 200。

⇒ **压测脚本必须先 probe 再压。** 不 probe 的话那一跑的结论会是
「webui 全挂」,完全错误。这条已写进手册。

## 实测(2026-09-28)

| 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 |

**2160 请求零失败**;压测期间服务端 `active`、0 个 5xx、0 个 webui 错误,
QQ/agent 链路未受影响,homed CPU 仅 1.2%。

### 重响应不是瓶颈(并发 1 → 16)

| 端点 | 大小 | 并发 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** |

大 JSON 端点的 p50 **不随并发上升**(/knowledge/tree 12.3ms → 8.9ms,
排队更充分、效率更高)⇒ 瓶颈不在 JSON 序列化。

## ⚠ 一处未下结论的观察

scale=2 的 p99(163ms)反而**高于** scale=3(79.7ms)。看着反常,但当时
机器上另一个 agent 的 chromium 占 **766% CPU**(另有 cjpm/cjc 在编译)
⇒ 是**环境噪声**。

**未在可比条件下重测就不下结论** —— 不拿这一组当性能特征。要判定需先
固定负载条件再跑。这条也写进手册。

## 顺带

ruff 报的三条 blocker 都修了:`pct()` 空样本不再抛错(调用方直接拿去做
f-string 格式化)、写 JSON 失败明确报错而非静默丢报告、错误体读取用
`contextlib.suppress` 免得二次异常盖掉真正的状态码。
2026-09-28 09:22:16 +08:00