mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
## 缺口
现有 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()` 唯一后缀。
225 lines
8.1 KiB
Python
225 lines
8.1 KiB
Python
#!/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()
|