Files
HomeAgent/docs/zh/deploy-runbook.md
JianFeeeee 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

23 KiB
Raw Blame History

HomeAgent 生产部署手册(homed / waiter)

适用范围:本机(.60)的 homeagent.service,以及两台设备桥宿主上的 waiter-remote.service。

静态站 / nginx / 证书的运维见另册 site-infra-runbook.md —— 本册不涉及。

记录日期:2026-09-27。本册所有数字均与现场核对过,不是模板值。


0. 一句话拓扑

                    ┌─────────────────── 本机 .60 ───────────────────┐
                    │ homeagent.service ← /usr/local/bin/homed      │
                    │   (必须 -tags=onnxruntime 构建)              │
  QQ 用户 ─NapCat─► │   :9890  remotedevice 网关                    │
   (在 106 上)    │   /home/newqqagent/  51G 模型资产(部署不动)   │
                    └────────┬──────────────────────┬────────────────┘
                             │ ws 设备桥             │ ws 设备桥
                 ┌───────────▼──────────┐  ┌────────▼─────────┐
                 │ 192.168.2.106 fnnas  │  │ 192.168.2.30     │
                 │ /opt/waiter/waiter   │  │  mainnas         │
                 │ waiter-remote.service│  │ /opt/waiter/...  │
                 │ + NapCat(25570)      │  │                  │
                 └──────────────────────┘  └──────────────────┘

要点:

  • waiter 与 homed 是两个独立部署单元,更新其一不影响其二。
  • GUI 不在服务端部署(见下)。
  • 设备桥授权(device_authorized)在 waiter 侧,网关地址也在 waiter 的 waiter.yaml 里。
  • NapCat(QQ 上游)在 106 上,http://192.168.2.106:25570。

1. 部署 homed(本机)

1.1 硬前置:必须 onnxruntime 构建

普通 go build 只有 ~28MB,缺 ONNX Runtime,会让依存句法分析与多模态 向量化失效。生产二进制是 -tags=onnxruntime(约 87MB)。

deploy/packaging/package-linux.sh:139 会显式拒绝非 onnxruntime 构建。

1.2 ★ 构建参数必须与线上一致

CGO_ENABLED=1 CC=cc go build -tags onnxruntime -o /tmp/homed-ort-new \
  -ldflags "-X .../internal/meta.Version=<v> -X .../internal/meta.Commit=<c>" \
  ./cmd/homed

不要顺手加 -s -w。 加了会 strip 掉符号,产物从 ~86.8MB 掉到 ~78MB —— 体积差 8.8MB 会让人误判成"构建坏了",而它只是被 strip 了。 若确实要 strip,须先确认线上也是同样参数,否则两次构建不可比。

验证:

go version -m /tmp/homed-ort-new | grep onnxruntime   # 必须有 build -tags=onnxruntime
ls -l /tmp/homed-ort-new                             # 与 /usr/local/bin/homed 同量级

1.3 部署

bash deploy-plan.sh check                             # 只读
NEW_BIN=/tmp/homed-ort-new bash deploy-plan.sh deploy # 需输入 yes
bash deploy-plan.sh rollback                          # 回滚
  • check:服务状态、onnxruntime 标签、libonnxruntime.so、模型资产、适配器清单
  • deploy:备份 → 替换二进制 → 重启 → 验证
  • 备份落在 /var/tmp/homed-backup-<时间戳>/,含 ROLLBACK.sh、旧二进制、 适配器、unit 文件
  • 默认候选 /tmp/homed-ort,可用 NEW_BIN= 覆盖

1.4 部署后必须核对

systemctl is-active homeagent.service
journalctl -u homeagent.service --since "-3 min" | grep "multimodal space active"
journalctl -u homeagent.service --since "-3 min" | grep -c "registering tool: seq_"  # 应为 7

multimodal space active: provider=chineseclip dim=512 是 ONNX 链路真的 加载起来的标志 —— 缺它说明已降级,只是没报错。

1.4b ★ 判断线上跑的是哪次构建:看 commit,别看 strings

生产二进制里嵌着 commit(internal/meta),这是唯一可靠的判据:

K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';")
curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"commit":"[^"]*"'
# ⇒ "commit":"d084137"   这才是线上真实在跑的提交

⚠ 必须带 X-API-Key:无认证时 /api/v1/status 返回登录页 HTML (200 + THEME_PLACEHOLDER),grep '"commit"' 匹配不到任何东西 —— 看起来像"命令没输出",实际是认证缺失。这与 GUI 客户端里 api() 专门检测 THEME_PLACEHOLDER 是同一件事。

为什么不能用 strings 判:webui 等插件的静态资源是 //go:embed 编译进二进制的(internal/plugins/webui/handler.go:27), 其内容取决于构建时磁盘上的文件。

⇒ 已提交过的改动,即使还没部署,线上二进制的 strings 里也可能 已经出现新代码片段。

2026-09-28 实踩:我据此差点白部署一次(线上 commit 已是 d084137, 而我以为还是旧版)。同一天还发生过一次字节数完全相同的巧合 (86811464),更掩盖了这个问题。

⇒ 部署前的判据顺序:

  1. /api/v1/status 的 commit —— 线上在跑什么
  2. git log <commit>..HEAD —— 差哪些提交
  3. 那些提交里有无运行时改动(internal/、cmd/)—— 只有它才需要部署
  4. 文档 / 判据 / 部署脚本类的提交不需要部署

1.4c 内置插件 vs 独立二进制

internal/plugins/<name>/ 是内置插件,编译进 homed。 /home/newqqagent/plugins/<name>/plugin.bin 是独立插件,要单独构建部署。

判定方法(2026-09-28 核实):

for p in webui qq cmd seq; do
  printf "%-8s " $p
  ls /home/newqqagent/plugins/$p/plugin.bin >/dev/null 2>&1 \
    && echo "独立二进制(需单独部署)" || echo "内置(随 homed 部署)"
done
# 2026-09-28 实测:webui/cmd/seq 内置,qq 独立

⚠ 目录存在不等于有独立二进制 —— plugins/webui/ 目录存在但0 个文件, 走的是内置。改内置插件只需重编 homed。

1.5 ★ 适配器升级的保护语义

/home/newqqagent/adapters/.bundled 记录上次随包带出的版本哈希:

盘上版本 判定 行为
无 .bundled(首次升级) 未知 只补缺失文件,不动已有文件
盘上 == 旧内嵌 未被改过 自动覆盖为新版本
盘上 != 旧内嵌 用户改过 保留用户版本

2026-09-27 21:50 实测:生产 openai.lua 是 8月26日手工补过 stream_index 的版本(盘上 1a649be2… ≠ 旧内嵌 6374c596…),被正确判定为用户修改并保留。

验证方式(部署前后各跑一次,应完全一致):

md5sum /home/newqqagent/adapters/*.lua | md5sum

1.6 两次部署的真实记录(2026-09-27)

19:45 首次 21:50 修复后
二进制 86,496,624 → 86,784,400 86,784,400 → 86,811,464
onnxruntime ✓ ✓
seq_* 工具 0 → 7 7
适配器 无 .bundled ⇒ 一律不动 有清单 ⇒ 保护用户修改
备份 homed-backup-20260927-194554 homed-backup-20260927-215026
has empty arguments 误报 24 次(仍在增长) 0 次

首次部署前生产二进制构建于当天 06:36,而 seq 插件引入于更晚的提交 ⇒ 旧实例的 strings /usr/local/bin/homed | grep -c internal/plugins/seq 为 0。 它在 QQ 上如实回答"没有编排工具"不是说谎,是确实没有。

这类"实例自述与代码状态不一致",先查二进制构建时间与内含符号, 别急着怀疑提示词或模型。


2. 部署 waiter(设备桥,106 / 30)

2.1 现状

两台配置相同(同一 device_gateway、同一 device_token、device_authorized: true), 差异只在登录方式:106 走 admin@ + sudo,30 走 root@。

2.2 更新

bash deploy-waiter.sh check                # 两台一起,只读
bash deploy-waiter.sh deploy 192.168.2.106 # 需输入 yes
bash deploy-waiter.sh deploy 192.168.2.30  # 上一台验证通过后再做
bash deploy-waiter.sh rollback <ip>

逐台更新,不要并行 —— 两台都连同一网关,同时重启会同时断链。

deploy 会:备份 /opt/waiter/waiter → 替换 → 重启服务 → 验证 (服务 active、进程时长、配置 md5 未变)。

2.3 部署后确认设备已注册

journalctl -u homeagent.service --since "-2 min" | grep "device waiter-"
# 期望:device waiter-fnnas online / device waiter-mainnas online

2026-09-27 顺手解决的一个悬案:106 此前一直没有 online 日志而 30 正常, 两台配置与 token 完全相同 ⇒ 差异只可能在旧 waiter 二进制。8月27日那版落在 "未 bind 时收到 ping 会关连接"的缺陷窗口里,更新后即正常。

2.4 命令白名单(device_cmd_allowlist)

waiter 的命令白名单原本是源码里硬编码的正则(18 个命令),waiter.yaml 里 没有任何键能改它 ⇒ find/grep/sed/sort/tr 这些排查问题最常用的 只读命令一律被拒,报错:

device_ctl_cmdrun  device_id:waiter-fnnas  error: command not in whitelist

现改为 waiter.yaml 可配置(2026-09-27 随 7193446 部署):

device_cmd_allowlist:
  - ls
  - find
  - grep
  - sed
  • 替换默认集而非追加 —— 避免"以为加了 find、结果还留着 python3 -c 任意执行"
  • 留空 ⇒ 用内置默认集(绝不能变成"全放行",那等于静默拆掉闸门)
  • 生效验证:重启后启动日志应打印 device cmd allowlist: 22 条(来自 waiter.yaml)

当前生产配置:22 条,只读为主(ls pwd cat du df free ps ip uname uptime date hostname find grep sed sort tr wc head tail stat file)。

已知局限:白名单只匹配命令名、不看参数 ⇒ find -delete、 find -exec rm {} ;、sed -i、sort -o 仍能放行。 这是"命令名清单",不是"只读保证"。参数级拦截是后续项。 ⇒ 文档与对话里都不要把它称作"只读白名单",那会让人以为写操作被挡住了。

追加到 waiter.yaml 的幂等做法(已用于两台):

Y=/opt/waiter/waiter.yaml
cp -a "$Y" "$Y.bak-$(date +%Y%m%d-%H%M%S)"
grep -q '^device_cmd_allowlist:' "$Y" || cat >> "$Y" <<'CFG'
device_cmd_allowlist:
  - ls
  - find
  - grep
CFG
systemctl restart waiter-remote.service

3. 故障排查

3.1 "消息发过去没反应"

按这个顺序查,别跳步:

# 1) 消息到了吗
journalctl -u homeagent.service --since "-5 min" | grep -E "interrupt from|webhook recv"
#    群聊里没 @bot 会打 not @bot —— 那不是 bug,是设计

# 2) 任务执行了吗(tools=[] 说明模型没发 tool_call)
journalctl -u homeagent.service --since "-5 min" | grep "→ response"

# 3) LLM 通吗
journalctl -u homeagent.service --since "-10 min" | grep -i unreachable

# 4) 代理层活着吗
curl -s --max-time 8 http://127.0.0.1:8081/v1/models -H "Authorization: Bearer <key>"
#    data:[] 是正常的(该网关不列模型),能返回即说明进程活着

"群聊 not @bot" 与 "私聊没回" 是两件事:前者是插件按规则丢弃, 内核压根没收到任务,自然没有"卡死"。

3.2 设备命令被拒

journalctl -u homeagent.service --since "-10 min" | grep "not in whitelist"

先确认命令第一段在不在 waiter.yaml 的 device_cmd_allowlist 里 (netstat、systemctl 不在当前的 22 条内,被拒是正确行为)。

3.3 适配器流式 tool_call 异常

journalctl -u homeagent.service --since "-10 min" | grep -E "finish_reason=length|unmarshal unified"

非法 JSON 帧 出现在启动握手期属正常(插件启动时的非 JSON 帧), 只在运行期持续出现才是问题。

3.4 有告警但不确定是不是缺陷

先看是不是内核的兜底在工作。例:重复申请 stage 锁 是 SDK 模板在每个 stage handler 入口自动加锁 + 锁不可重入所致,插件侧问题;内核的强制释放 是有意设计(避免后续插件死锁),不要当内核缺陷去修。详见 toolcall-parallel-execution-plan.md 末节。


4. 已知未修

项 性质 说明
白名单不看参数 功能限制 find -delete/sed -i 能逃,用户已知悉并选择先下发
重复申请 stage 锁 插件缺陷 需改 SDK 模板并重编 9月14日的 plugins/qq/plugin.bin
群聊必须 @ 才触发 设计 放宽会在群里引发 unwanted 触发

5. 部署站点(SDK 文档站 / introduce)

deploy-sdk-site.sh 覆盖两个静态站,与 homed / waiter 是独立部署单元。

站 本地源 线上位置 构建
SDK 文档站 third_party/homeagent-sdk/site_build/ /vol1/docker/navi-data/sites/sdk 需 tools/apidoc/build.sh(§5.3 坑①)
introduce site/(排除 README.md) /vol1/docker/navi-data/sites/introduce 零构建,源即产物

两站部署在同一台 106 的同一 sites/ 目录下。

introduce 是零构建的 —— site/ 里就是成品,脚本部署时故意不带 README.md(那是仓库说明文档,不是站点资源)。

5.1 用法

bash deploy-sdk-site.sh --check                        # 只核对差异,不动线上
bash deploy-sdk-site.sh                               # 构建 + 部署 + 验证
bash deploy-sdk-site.sh --rollback .sdk-bak-<时间戳>  # 回滚

--check 逐字节比对本地与线上,两个站都一致时可直接跳过部署。

5.2 链路(实测确认)

本机 192.168.2.60
  └─ nginx stream 按 ssl_preread SNI 把 443 透传 → 192.168.2.106:3080
       └─ navi 容器内的 nginx(**不是** portal-nginx,那个已 Exited 两周)
            └─ server_name sdk.homeagent.jianfgit.xyz → root /app/data/sites/sdk
                 └─ 宿主对应 /vol1/docker/navi-data/sites/sdk

登录 106 只能用 admin@ + sudo -n,root@ 会被拒。

5.3 ★ 两个坑

① 构建必须走 tools/apidoc/build.sh,不能裸跑 mkdocs build

裸跑会丢掉整个 api/*.md 和 llms.txt —— 而 llms.txt 正是给 agent 直读的入口。 正确产物 106 个文件,裸跑只有 78 个。

② 打包不能走设备网关

106 的 waiter 白名单(device_cmd_allowlist)现有 22 条:

ls pwd cat du df free ps ip uname uptime date hostname
find grep sed sort tr wc head tail stat file

里面没有 tar(有 sed 也不能打包)⇒ device_ctl_cmdrun 打不了包, 必须走 SSH 直连。

早先这里记的是「只放行 ls/stat/find/cat」,那是 waiter 白名单 硬编码 18 条时的状况。2026-09-27 部署 7193446 后扩到 22 条 —— 结论(打不了包)不变但理由已变,别照旧文字理解。

5.4 新增文档后要记得更新 nav

mkdocs.yml 的 nav 没登记的页面,mkdocs 会明确警告 not included in the nav configuration —— 等于文档写完了但在站点里不可达。

改完 SDK 文档的完整流程:

cd third_party/homeagent-sdk
# 1) 在 docs/guide/ 写文档
# 2) 在 mkdocs.yml 的 nav 里登记          ← 漏了这步站点里找不到
# 3) 重新生成(含 docs/api/* 与 llms.txt 同步)
tools/apidoc/build.sh
# 4) 部署并验证
bash ../../deploy-sdk-site.sh

验证线上是否真的可访问(别只看本地产物):

curl -s -o /dev/null -m 10 -w '%{http_code}\n' -k https://sdk.homeagent.jianfgit.xyz/guide/<新文档>/

5.5 回滚点

每次部署留 .sdk-bak-<时间戳>(同 sites/ 下)。--rollback 会先把当前站 cp -a 成 .sdk-failed-<时间戳> 再恢复,失败版本不丢。


6. 部署单元清单(哪些在服务端、哪些不在)

排查"改了什么没生效"时,先确认改动落在哪个单元 —— 服务端只有前三个。

单元 入口 部署位置 服务端部署
homed(内核) cmd/homed 本机 /usr/local/bin/homed ✅
waiter / waitercli cmd/waiter 106、30 的 /opt/waiter/waiter ✅
站点 site/、site_build/ 106 的 sites/ ✅
GUI cmd/gui 不在服务端 ❌

6.1 GUI 是客户端,不在服务端

cmd/gui 是 Electron 桌面应用(main.js / icon-tray.png / devicebridge_dll.js),随客户端分发,不在服务端部署: 生产既无 *.service 也无对应进程。

核查时的坑:ps -ef | grep -icE "[e]lectron|cmd/gui" 会把它自己的 grep 命令行算进去而返回非 0(本机实测返回 2,实际 0)。 确认进程是否存在要看列出的内容,别只看计数。

⇒ 不要在服务端找 GUI 的产物或配置;排查 GUI 问题时, 要看用户机器上运行的客户端,而不是 homeagent.service 的日志。

它与 waiter 的关系是两端:GUI 用 devicebridge_dll.js 走设备桥协议 连接服务端 waiter,a781f8e/ff69127 修的正是 GUI 侧 bind 结果判 ok 与登记状态暴露。

6.2 ★ 本机 /usr/local/bin/waiter 与 106/30 不同步

本机也留了一份 waiter,但不是 106/30 那条设备桥:

位置 版本 状态
106 / 30 1.4.0(55906b8) ✅ 最新
本机 /usr/local/bin/waiter v1.3.2-153-gff69127(2026-09-25 构建) ⚠️ 落后 main

本机这份早于 main 在 2026-09-26 合入的那批修复(设备反复掉线、 bind 判 ok、服务端发现自动链接),因此缺它们。

不过它无进程、无服务(只有 ~/.config/homeagent/waiter.yaml), 所以不影响生产设备桥。若要更新,用与 106/30 同样的构建:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
  -ldflags "-X .../internal/meta.Version=1.4.0 -X .../internal/meta.Commit=$(git rev-parse --short HEAD)" \
  -o /tmp/waiter-new ./cmd/waiter

6.3 一次误判的记录:别只看"不在分支上"

2026-09-27 排查时,我用 git merge-base --is-ancestor ff69127 origin/main 判定"这批提交没进 main",并据此推断"存在一条未合入、只靠 reflog 撑着的 5 提交线"。该结论是错的。

真实情况:这批改动在 2026-09-26 以新 hash 重做并进入 main:

旧(9-25 那条线) 新(main 上) 内容 diff
1acbd39 通用反向代理 1e58af7 0 行
3d30482 反代两处故障 5d278cf 0 行
078517e 设备桥自动链接 7f5bf16 0 行
aaafaac 修复设备反复掉线 94c74b2 0 行
ff69127 GUI bind 判 ok a781f8e 0 行

逐字节一致,无需合入。之所以误判,是因为只查了旧 hash 的祖先关系, 没查提交标题/内容是否已有等价副本。

⇒ 判定"某改动是否已合入"要按内容查,不能只按 hash 查。 同一改动可能被重做为新 hash;此时 merge-base 必然说不包含, 而 git merge-tree 报的 13 个"冲突"正是同源改动做两遍的必然结果。

另注:aaafaac/94c74b2(修复设备反复掉线/静默失联:ping 路径断连 + bind 结果无人处理)正是 106 此前长期无 online 日志的成因, 2026-09-26 已进 main,今天部署的 1.4.0 包含它。


7. WebAPI 压测(webui + kbtree + pluginmgr + remotedevice)

脚本:scripts/kernel-stress/webui-bench.py,打的是生产实例。

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" --scale 3 --json /tmp/w.json

★ 8080 上有 4 个插件注册路由,但监听端口不止 8080

第一版我只压了 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) 不压

⇒ 「打 /api/v1/*」这个假设只对 8080 上的 webui 成立。 :8080/plugins 实测 404。

★ 三条判据

  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,24 端点 × 3 档)

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

(24 端点 × 20/40/60 轮 = 480/960/1440)

2880 请求零失败;压测期间服务端 active、0 个 5xx。

p99 随并发单调上升(34.7 → 48.4 → 60.5ms),符合排队预期。 上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的 chromium 占 766% CPU ⇒ 环境噪声,已明确不作为性能特征。

踩过的三个坑(都在脚本注释里)

  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。