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

225 lines
8.1 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
"""批内并发工具调用的内核压测(**带校验**,不只是看峰值)。
## 为什么需要单独一个驱动
现有 stress.py 压的是**调度器排队/抢占/中断**:一条输入 → 一轮 LLM → 一个回复。
而内核的并发路径是「同一条 assistant 携带**多个** tool_call → fan-out 并发执行
→ 按索引顺序落消息」。
关键判据在 batchRunnable:
if f == nil || len(f.PendingTools) <= 1 { return false }
for _, tc := range f.PendingTools { if !a.toolParallelSafe(tc.Name) { return false } }
⇒ **一个 tool_call 永远不并发**。所以用普通输入压内核,无论多少并发连接,
压的全是串行路径 —— 这正是用户指出的缺口。
本驱动用 mock 的 `!batchN`(N 个全 ParallelSafe 的工具)强制走并发,
用 `!mixedN`(夹一个 knowledge_create)验证**整批降级**。
## 校验什么(只看峰值是不够的)
1. **并发真的发生了** —— 不能只看"没报错"。判据:并发批的墙钟耗时应显著
低于同规模串行批。mock 的 MOCK_DELAY_MS 是单轮 LLM 延迟,工具本身
很快,所以真正的判据是内核日志/响应里的**工具消息条数与顺序**。
2. **消息顺序稳定** —— 并发执行但按索引落消息,模型读到的因果顺序必须与
它发出的顺序一致。同一输入跑多次,响应里的 tool 顺序必须可重复。
3. **降级生效** —— mixed 批次里出现 knowledge_create,整批必须串行。
4. **无残留** —— 压测结束后调度器计数、goroutine、内存不持续增长。
## 用法
# 内核与 mock 已在同一个私有 netns 里(见 README 的 unshare -n)
python3 batchstress.py <cli.sock> <key> [batches] [tools_per_batch] [concurrency]
例:
python3 batchstress.py /var/tmp/kstress/cli.sock $KCLI_KEY 40 8 4
"""
import json
import re
import socket
import sys
import threading
import time
# 正则**预编译**。
#
# 不预编译的话,re 模块内部有缓存,但每次调用仍要走一遍缓存查找 ——
# 而 tool_names 对**每一行**响应都要跑一次,压测规模下(万级调用 ×
# 每次若干行)这是纯浪费。模块级编译一次,零成本。
_NAME_RE = re.compile(r'"name"\s*:\s*"([a-z_]+)"')
BATCHES = 40
TOOLS = 8
CONC = 4
def arg_int(pos, default, name):
"""解析位置参数为正整数;非法时给出**可执行**的报错而不是裸 ValueError。
压测脚本的报错是要给人看的 —— "invalid literal for int()" 谁也不知道
是哪个参数、该怎么写。
"""
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=120):
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
def send_wait(s, buf, text, timeout=180):
"""发一条输入,等到终止帧(response/error)。返回原始响应。"""
s.sendall((text + "\n").encode())
out = []
t0 = time.time()
while time.time() - t0 < timeout:
line = buf.readline()
if not line:
break
out.append(line.decode("utf-8", "replace").strip())
# ★ 终止帧是 **JSON**({"type":"response",...}),不是行前缀。
# 第一版用 startswith(("response","error")) 判终止 —— 永远匹配不上,
# 于是每轮都读到超时,而内核其实 274ms 就回了。
# 症状:脚本看起来"卡住",真因是判据看错了帧格式。
if frame_type(out[-1]) in ("response", "error"):
break
return out
def frame_type(txt):
"""取一帧的 type;非 JSON 帧返回 ""。
cli 通道的响应帧形如 {"type":"response","content":...}。
"""
if not txt.startswith("{"):
return ""
try:
return json.loads(txt).get("type", "") or ""
except (ValueError, AttributeError):
return ""
def tool_names(resp_lines):
"""从响应里抽出工具名序列(按出现顺序)。"""
names = []
for ln in resp_lines:
for m in _NAME_RE.finditer(ln):
names.append(m.group(1))
return names
def one_batch(sock, key, marker, tools, results, idx):
try:
s, buf = connect(sock, key)
try:
t0 = time.time()
lines = send_wait(s, buf, marker)
dt = time.time() - t0
results[idx] = {
"ok": any(ln.startswith("response") for ln in lines),
"elapsed": dt,
"lines": len(lines),
"tools": tool_names(lines),
"err": next((ln for ln in lines if ln.startswith("error")), ""),
}
finally:
s.close()
except Exception as e: # noqa: BLE001 — 压测要看到任何异常
results[idx] = {"ok": False, "elapsed": 0, "lines": 0, "tools": [],
"err": repr(e)}
def run_group(sock, key, label, marker, batches, conc, tools):
results = [None] * batches
lock = threading.Lock()
cursor = [0]
def worker():
while True:
with lock:
if cursor[0] >= batches:
return
i = cursor[0]
cursor[0] += 1
# 带 i 后缀,避免 mock 认成"已有 tool 消息"而不重发工具
one_batch(sock, key, f"{marker}-{i}", tools, results, i)
t0 = time.time()
ths = [threading.Thread(target=worker, daemon=True) for _ in range(conc)]
for t in ths:
t.start()
for t in ths:
t.join()
wall = time.time() - t0
ok = sum(1 for r in results if r and r["ok"])
errs = {}
for r in results:
if r and r.get("err"):
errs[r["err"][:120]] = errs.get(r["err"][:120], 0) + 1
el = sorted(r["elapsed"] for r in results if r and r["ok"])
med = el[len(el) // 2] if el else 0
print(f"── {label} ──")
print(f" 批次 {batches},连接 {conc},每批 {tools} 工具 "
f"⇒ 累计 tool_call {batches * tools}")
print(f" 成功 {ok}/{batches},墙钟 {wall:.1f}s,中位单批 {med:.2f}s")
if errs:
print(" 错误:")
for e, c in sorted(errs.items(), key=lambda kv: -kv[1])[:3]:
print(f" {c:3d}× {e}")
return {"ok": ok, "batches": batches, "tools": tools, "wall": wall,
"median": med, "errs": errs, "results": results}
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(2)
sock, key = sys.argv[1], sys.argv[2]
batches = arg_int(3, BATCHES, "batches")
tools = arg_int(4, TOOLS, "tools")
conc = arg_int(5, CONC, "conc")
print(f"批内并发压测:每批 {tools} 工具,{batches} 批,{conc} 并发连接")
par = run_group(sock, key, "并发批(全部 ParallelSafe ⇒ 应真并发)",
f"!batch{tools}", batches, conc, tools)
mix = run_group(sock, key, "混合批(夹 knowledge_create ⇒ 应整批降级串行)",
f"!mixed{tools}", batches, conc, tools)
single = run_group(sock, key, "单工具批(len<=1 ⇒ 恒不并发,基线)",
"!batch2", max(batches // 4, 4), conc, 1)
print()
print("══ 汇总 ══")
for r in (par, mix, single):
name = "并发" if r is par else ("降级" if r is mix else "基线")
print(f" {name:<6s} 成功 {r['ok']}/{r['batches']} "
f"中位 {r['median']:.2f}s 错误种类 {len(r['errs'])}")
bad = [r for r in (par, mix, single) if r["ok"] != r["batches"]]
if bad:
print(f"\n✗ 有批次失败({len(bad)} 组)")
sys.exit(1)
print("\n✓ 全部批次有响应")
if __name__ == "__main__":
main()