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()` 唯一后缀。
This commit is contained in:
JianFeeeee
2026-09-27 18:07:58 +08:00
parent 61a2d56bbb
commit 8fafd5eafe
3 changed files with 646 additions and 14 deletions

View File

@ -0,0 +1,215 @@
#!/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()