Files
HomeAgent/scripts/kernel-stress/abtest.py
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

216 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""串行 vs 并发的**定量**对比:多工具调用与平均轮次延迟。
## 测什么
同一批 N 个慢工具(cmd_run sleep),在两套内核上各跑一遍,比:
1. **平均轮次延迟** —— 从发输入到收到最终回复的墙钟时间。
并发的理论收益 = 单工具耗时 × (N-1):串行要 N×t,并发只要约 1×t。
2. **加速比** = 基线延迟 / 新版延迟。
3. **工具消息顺序** —— 内核声明「并发执行但按**声明序**落消息」,
而 mock 故意让完成顺序与声明序**相反**(索引越大 sleep 越短)。
所以:若响应里的工具顺序是 0,1,2,…,说明按声明序落对了;
若是完成序,就暴露了「按完成顺序合并」这个 bug。
## 为什么工具要慢
并发的收益 = 单工具耗时 × (N-1)。工具若只跑几微秒,串行与并发的差异会
被 LLM 延迟(DELAY_MS,默认 200ms)整个淹没 —— 测出来全是噪声。
所以用 `sleep` 型 cmd_run:延迟可精确预期、无外部副作用、不受机器负载影响。
## 怎么保证对比公平
- 两套内核实测用**同一个** mock、同一个数据目录模板、同一批 N;
- 交替执行(A/B/A/B)而不是先跑完 A 再跑 B —— 抵消机器负载漂移;
- 每组取中位数,不用平均值(长尾会污染均值)。
## 用法
python3 abtest.py <sockA> <keyA> <labelA> <sockB> <keyB> <labelB> [tools] [rounds]
"""
import json
import re
import socket
import statistics
import sys
import time
TOOLS = 8
ROUNDS = 12
SLEEP_MS = 200
def arg_int(pos, default, name):
"""解析位置参数为正整数;非法时给出可执行报错而不是裸 ValueError。"""
if pos >= len(sys.argv):
return default
raw = sys.argv[pos]
try:
v = int(raw)
except ValueError:
sys.exit(f"参数 {name} 需要一个正整数,收到 {raw!r}(用法见本文件顶部)")
if v <= 0:
sys.exit(f"参数 {name} 必须 > 0,收到 {v}")
return v
def connect(sock, key, timeout=180):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect(sock)
s.sendall(f"/auth {key}\n".encode())
buf = s.makefile("rb")
line = buf.readline()
if b"error" in line.lower():
raise RuntimeError(f"auth failed: {line}")
return s, buf
_NAME_RE = re.compile(r'"name"\s*:\s*"([a-z_]+)"')
_CMD_RE = re.compile(r"done-(\d+)")
def one_round(sock, key, marker, tools, out, idx):
"""跑一轮:发 !slowbatchN,等最终回复。返回 (延迟, 工具顺序, 错误)。"""
t0 = None
try:
s, buf = connect(sock, key)
try:
t0 = time.time()
# ★ 必须每轮唯一:内核 task.go:353 有输入去重
# (`isDuplicateInput`,为 webui 断线重连重放而设),
# 相同文本会被直接丢弃并回空响应。
# 我第一版每轮发同一个 marker ⇒ 只有第 1 轮有效,
# 后面全是 0 秒 0 工具,加速比算出来是噪声。
s.sendall(f"{marker}-{idx}-{time.time_ns()}\n".encode())
lines = []
while True:
line = buf.readline()
if not line:
break
txt = line.decode("utf-8", "replace").strip()
lines.append(txt)
if txt.startswith(("response", "error")):
break
dt = time.time() - t0
err = next((x for x in lines if x.startswith("error")), "")
# 从全部行里抽工具顺序与"工具真跑"的证据。
#
# ★ 关键:只看耗时是不够的。前面几轮出现过"内核 274ms 就回复、
# 工具一个没跑"的情况 —— 那种情况下并发与串行都是 0.2s,
# 加速比毫无意义。所以必须确认 done-N 标记真的出现在响应里:
# 那是 cmd_run 执行完 echo 的输出,工具没跑就不可能有。
names = []
for x in lines:
names.extend(_NAME_RE.findall(x))
order = [int(m.group(1)) for x in lines for m in _CMD_RE.finditer(x)]
if not order:
out[idx] = {"dt": dt, "order": [], "names": names, "ran": 0,
"err": "响应里没有 done-N 标记:工具可能没真执行",
"ok": False}
return
out[idx] = {"dt": dt, "order": order, "names": names,
"ran": len(order), "err": err, "ok": not err}
finally:
s.close()
except Exception as e: # noqa: BLE001
out[idx] = {"dt": 0.0, "order": [], "names": [], "ran": 0,
"err": repr(e), "ok": False}
def measure(sock, key, label, tools, rounds, warmup=2):
out = [None] * rounds
# 预热:首次调用会建连接、可能触发插件懒加载,不计入
for _ in range(warmup):
one_round(sock, key, "!warmup", tools, [None], 0)
for i in range(rounds):
one_round(sock, key, f"!slowbatch{tools}", out, i)
dts = [r["dt"] for r in out if r and r["ok"]]
errs = [r["err"] for r in out if r and r["err"]]
orders = [r["order"] for r in out if r and r["ok"] and r["order"]]
return {
"label": label,
"ok": len(dts),
"rounds": rounds,
"median": statistics.median(dts) if dts else 0.0,
"mean": statistics.mean(dts) if dts else 0.0,
"min": min(dts) if dts else 0.0,
"max": max(dts) if dts else 0.0,
"errs": errs[:3],
"orders": orders,
"ran": [r.get("ran", 0) for r in out if r],
"tools": tools,
}
def main():
if len(sys.argv) < 6:
print(__doc__)
sys.exit(2)
sockA, keyA, labelA, sockB, keyB, labelB = sys.argv[1:7]
tools = arg_int(7, TOOLS, "tools")
rounds = arg_int(8, ROUNDS, "rounds")
print(f"A/B 定量对比:每批 {tools} 个 sleep 工具(单工具约 {SLEEP_MS}ms),"
f"每组 {rounds} 轮")
print(f" A = {labelA}")
print(f" B = {labelB}")
print()
# 交替执行 A/B/A/B 抵消机器负载漂移
a_runs, b_runs = [], []
for _ in range(rounds):
a_runs.append(measure(sockA, keyA, labelA, tools, 1, warmup=0))
b_runs.append(measure(sockB, keyB, labelB, tools, 1, warmup=0))
A = {
"label": labelA, "tools": tools, "rounds": rounds,
"dts": [x["median"] for x in a_runs if x["median"] > 0],
"orders": [o for x in a_runs for o in x["orders"]],
"ran": [x["ran"] for x in a_runs],
"errs": [e for x in a_runs for e in x["errs"]],
}
B = {
"label": labelB, "tools": tools, "rounds": rounds,
"dts": [x["median"] for x in b_runs if x["median"] > 0],
"orders": [o for x in b_runs for o in x["orders"]],
"errs": [e for x in b_runs for e in x["errs"]],
}
def show(x):
if not x["dts"]:
print(f" {x['label']:22s} 无有效样本(错误:{x['errs'][:1]})")
return
d = sorted(x["dts"])
med = statistics.median(d)
print(f" {x['label']:22s} 中位 {med*1000:7.1f}ms "
f"min {d[0]*1000:7.1f}ms max {d[-1]*1000:7.1f}ms "
f"样本 {len(d)}/{x['rounds']}")
if x["errs"]:
print(f" {'':22s} 错误 {x['errs'][0][:90]}")
print("── 平均轮次延迟 ──")
show(A)
show(B)
if A["dts"] and B["dts"]:
ma, mb = statistics.median(A["dts"]), statistics.median(B["dts"])
print(f"\n 加速比 A/B = {ma/mb:.2f}x"
f"(理论上限 ≈{tools}x,单工具 {SLEEP_MS}ms × {tools-1})")
# 理论:串行 ≈ (t)*N,并发 ≈ t + 少量开销
t = SLEEP_MS / 1000.0
print(f" 参照:全串行 ≈{t*tools*1000:.0f}ms,完全并发 ≈{t*1000:.0f}ms")
print("\n── 工具消息顺序(mock 让完成序与声明序相反)──")
for x in (A, B):
if not x["orders"]:
print(f" {x['label']:22s} (未捕获到工具顺序)")
continue
bad = [o for o in x["orders"] if o != sorted(o)]
tag = "✗ 错乱" if bad else "✓ 声明序"
print(f" {x['label']:22s} {tag} {len(x['orders'])} 轮"
+ (f",例:{bad[0][:8]}" if bad else ""))
if __name__ == "__main__":
main()