Files
HomeAgent/scripts/kernel-stress
JianFeeeee 8fafd5eafe test(stress): 补批内并发压测与 A/B 对比脚本,并给 mock 加多 tool_call 能力
## 缺口

现有 kernel-stress 只压**调度器**(多连接排队 + L4 中断 + 驻留子),mock 每轮
只发**一个** tool_call。而内核的并发判据是:

    if f == nil || len(f.PendingTools) <= 1 { return false }

⇒ **一个 tool_call 永远不并发**。所以现有压测压的全是串行路径,批内并发
一条都没走过。

## mockllm.py:让 mock 能发多个 tool_call

- `!batchN`   N 个全部 ParallelSafe 的只读工具 ⇒ 强制走 stepToolBatch
- `!mixedN`   夹一个 knowledge_create(未声明并发安全)⇒ 验证整批降级
- `!slowbatchN` N 个 sleep 型 cmd_run(单工具约 200ms,时长递增)
  ⇒ 工具慢才能让并发的收益从噪声里显出来;时长递增使**完成序与声明序相反**,
  可据此判定"是否按声明序落消息"
- `!serialbatchN` 在 !slowbatch 基础上插入一个非并发安全工具 ⇒ 强制整批串行,
  作为并发对照的另一半
- `!img` / `!ocr` 触发多模态工具路径(不加载模型,只验内核 IPC 接线与错误处理)
- `!err` / `!hang` 故障注入

### 三个协议细节(踩过才知道,都写在注释里)

1. **必须带 `index`** —— 内核按 `StreamIndex`(上游 JSON 的 index)分槽累积
   arguments。缺 index 时所有分片落到槽 0,几个 tool_call 的参数被**混拼**,
   症状是每个工具都报"参数不是合法 JSON"而工具一次没真跑过。
   单 tool_call 时不设 index 也正常,所以老 mock 一直没暴露。
2. **必须按协议分片** —— 一个 chunk 一个 tool_call,各自带 index;
   后续 chunk 只续 arguments。整数组塞进一个 chunk 会被内核按"续传"语义累积。
3. **每个工具都要发"带 name 的首片"** —— 我第一版只给第 0 个发首片、其余直接
   发续传片,看起来省事,但内核 flush 时按"无 name 即丢弃"处理,
   于是 idx=1/2/3 全被丢,只跑 1 个工具。

## batchstress.py:批内并发压测(带校验)

只看峰值是不够的 —— 校验:工具是否真跑(响应里应有结果标记)、
消息顺序是否稳定(并发执行但按索引落消息)、mixed 批是否整批降级。

## abtest.py:串行 vs 并发的定量对比

同一套内核上用 !slowbatch / !serialbatch 两组对照,交替执行抵消机器负载漂移,
取中位数(长尾会污染均值)。

★ 判据里加了"工具是否真执行"这一项:**只看耗时是不够的** —— 出现过
"内核 274ms 就回复、工具一个没跑"的情况,那种情况下并发与串行都是 0.2s,
加速比毫无意义。

## 实测(隔离 netns 实例,mock + 内核同网段,5 轮中位)

    N    并发        串行         加速     上限
    2    0.481s    0.685s      1.42x    2
    4    0.491s    1.114s      2.27x    4
    8    0.513s    2.037s      3.97x    8
    12   0.531s    3.039s      5.72x    12

并发批耗时几乎不随 N 增长,串行批严格线性。

★ 另一个踩过的坑(abtest 脚本自己的):内核有输入去重
(task.go:353 `isDuplicateInput`,为 webui 断线重连重放而设),相同文本会被
丢弃并回空响应。第一版每轮发同一个 marker ⇒ 只有第 1 轮有效,后面全是
0 秒 0 工具。修法是每轮加 `time.time_ns()` 唯一后缀。
2026-09-27 18:29:39 +08:00
..

内核二进制压力测试(kernel-stress)

对本仓库编译出来的真实内核做压力测试 —— 与 go test 的区别是:它跑真二进制、 真插件加载、真 unix socket 协议,因此能抓到只在集成面上出现的问题 (已有战绩:根 agent DataDir 漏接线、插件通道没登记为 inputch、create 后子不开工)。

为什么必须放在私有 netns 里

生产实例占着 *:8080 / *:9890 / *:9876,而插件的监听都设了 SO_REUSEADDR: 同机再起一个实例会在 127.0.0.1 上与之并存绑定(实测抢到过 127.0.0.1:9890 约 1 分钟)。 unshare -n 后实例只有 lo,结构上不可能碰到生产端口。 unix socket 是文件系统对象,跨 netns 仍可驱动,所以驱动脚本在 netns 外也能用。

前置

go build -o /tmp/homed-stress ./cmd/homed      # 被压的内核
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp

用法

# 1) 准备数据目录 + 把 LLM 指向本地 mock(无外网也能跑,且快、可控)
DATA=/var/tmp/kstress
mkdir -p $DATA
#  先跑一次实例建出 config.db,再写入下面这些键(也可直接复用现成目录):
#    core.llm.provider=mock
#    core.llm.sources.mock.base_url=http://127.0.0.1:9099/v1
#    core.llm.sources.mock.model=mock   api_key=mock   adapter=openai
#    core.llm.sources.mock.adapter_path=adapters/openai.lua   priority=100
#    core.defaults.llm_endpoints=http://127.0.0.1:9099/v1/models   # 探活端点(探活用 HEAD!)
#    core.defaults.rollback.max_retries=100000   auto_rollback=false
#  并把 core.llm.sources.deepseek* 删掉(netns 里它不可达,会让 agent 被判 degraded → rollback 循环)

# 2) 起 mock LLM + 内核(都在同一个私有 netns 里)
MOCK_DELAY_MS=300 MOCK_CHUNKS=8 ./launch.sh /tmp/homed-stress

# 3) 取认证密钥(cli 插件回落到 webui.api_key)
export KCLI_KEY=$(sqlite3 $DATA/config.db "select value from config_webui where key='api_key';")

# 4) 压
./kcli.py $DATA/cli.sock stats full            # 看内核状态(含 scheduler 计数)
./stress.py $DATA/cli.sock "$KCLI_KEY" 16 12 4 20 dense 0.05   # 16 连接×12 输入 + 4 线程×20 中断
./stress.py $DATA/cli.sock "$KCLI_KEY" 12 12 1 25 0.0 3.0      # 稀疏中断 ⇒ 压抢占/挂起/恢复
./stress.py $DATA/cli.sock "$KCLI_KEY" 1 1 0 0 resident 0.0    # 驻留子全链路(mock 见 !resident 标记)

远程设备(agent ↔ 设备)端到端

设备网关在实例的 netns 内监听 127.0.0.1:9890,所以设备客户端要进同一个 netns 跑:

TOKEN=$(sqlite3 $DATA/config.db "select value from config_remotedevice where key='ws_token';")
nsenter -t $(cat $DATA/pid) -n python3 ./devclient.py --port 9890 --token "$TOKEN" \
    --id pydev-1 --caps cmd --seconds 30 --out /var/tmp/push.txt

然后让 agent 主动发一条(mock 里 !push 会回一个 output_send__device/pydev-1 的工具调用):

# 经 CLI socket 发 "!push",设备侧应收到 {"op":"push", ...}

设备上线/下线会在内核里登记/注销输出通道 device/<id>,可用 /kernel 的 channels 观察 (在线时出现、掉线后消失)。

设计口径:agent → 设备必须是 agent 的主动调用(output_send__device/<id>); 设备的上报(op=event)虽然会被注入成输入,但 agent 的回复不会被插件自动转回设备 —— 全仓只有 webui 与 cli 两个交互界面"主动转发"(把最终回复渲染成气泡/终端输出), 其它通道(qq、设备等)一律要求显式 output_send__<通道>。

读结果

指标 含义
executed / rejected 任务执行数 / 被拒数(压力下应为 0)
峰值 峰值_队列 / 峰值_待处理中断 采样到的最大排队深度 / 待处理中断数
suspended / resumed / preempted 抢占三件套。中断要放稀才会打在高优先级任务上:密集中断会互相同级(L4 vs L4)不抢占,数值会很低
max_suspend_depth 中断栈结构上界(= 中断级数 4)

已知坑

  • 探活用 HEAD(internal/network/monitor.go):mock 必须实现 do_HEAD,否则 501 → 判不可达 → agent degraded → rollback 循环(会 reset 合并目录)。
  • CLI 协议每条连接串行处理(handleChat 阻塞到本次响应产出):单连接狂发只会排成一条线,造不出队列压力 —— 必须多连接。
  • 认证:连接后先发 /auth <key>,否则一切命令返回 unauthorized。
  • mockllm.py 的 !resident / !notify 标记用来让 mock 回工具调用,从而在真内核里驱动 resident_agents / notify_parent。