test(stress): 补更新前后全面对比脚本(cmp.py),修 blast 的计数错误

## cmp.py:更新前后对比的四个维度

★ 每轮都记录**处理数**(响应里有多少个工具结果标记)与**是否出现 error 帧**,
任一不符即记失败。理由:工具调用这一路的失败模式几乎都是**静默**的 ——
工具没跑、参数混拼、只处理了第一个 tool_call,都不报错只是结果不对。
"跑完没崩"完全不能说明它 work。

1. **批内工具调用**:!slowbatchN 在两版上各跑几轮,比中位耗时与处理数
2. **并发 vs 强制串行**(新版内部对照):!slowbatchN vs !serialbatchN
3. **调度器并发轰炸**:多连接并发排队,算通过率
4. **连续稳定性**:20 轮无错误率

## 修掉 mock 的 !serialbatch 缺失

之前只在 /tmp 的临时副本里加过,没进仓库,导致 cmp.py 测「强制串行」时
那个 marker 根本不存在 —— 测出来的"串行"其实是并发,**加速比是假的**
(0.34x / 0.31x,看起来并发比串行慢)。已加回并说明它的用途:跨版本做不了
并发/串行对照(旧版适配器缺 stream_index,工具一个都没真跑),只能在
同一套内核上做。

## 修掉 blast 的计数错误

第一版按 `conns * inputs` 起线程、每个线程又跑 `inputs` 轮 ⇒ 总输入数是
conns×inputs²,分子分母量纲不一致,算出过 **"128/32 = 400%"** 这种荒谬数字。

现在:恰好 conns 个 worker、每个跑 inputs 轮;且分母用**实际发出的**输入数
(含连接失败的),否则连接失败时通过率会虚高。

## 踩过的两个坑(都写进注释)

- 内核 `task.go:353` 有输入去重(`isDuplicateInput`,为 webui 断线重连重放
  而设),相同文本被丢弃并回空响应 ⇒ 每轮输入必须带唯一后缀
- cli 的 auth 帧本身就是 `{"type":"response"}` ⇒ 必须先吃掉它再开始收集,
  否则第一轮的"终止帧"是 auth,测出来耗时恒为 0
This commit is contained in:
JianFeeeee
2026-09-27 18:56:25 +08:00
parent d3eaff46f1
commit 190e46908d
2 changed files with 286 additions and 0 deletions

View File

@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""更新前后的**全面**性能对比与通过率。
## 为什么必须有「通过率」而不只是耗时
工具调用这一路的失败模式几乎都是**静默**的:工具没跑、参数混拼、只处理了
第一个 tool_call —— 都不报错,只是结果不对。所以"跑完没崩"完全不能说明它
work。本次每次测量都同时记录**处理数**(响应里有多少个工具结果标记)与
**是否出现错误帧**,任一不符即记为失败。
## 三块覆盖
1. **调度器**:多连接并发排队 + L4 中断(沿用 stress.py 的形态)
2. **批内工具调用**:!batchN / !slowbatchN / !serialbatchN
—— 并发批 vs 强制串行批 vs 单工具基线
3. **稳定性**:连续多轮的通过率 + 队列/背压计数
## 每轮两个必查项(任一不过即判失败)
- **工具真跑了**:响应里能看到 `done-N` 标记(cmd_run 的 echo 输出),
工具没跑就不可能有
- **无 error 帧**:`{"type":"error"}` 一律算失败
## 用法
python3 cmp.py <旧sock> <旧key> <新sock> <新key> [规模]
"""
import contextlib
import json
import re
import socket
import statistics
import sys
import threading
import time
# ---------------------------------------------------------------- 基础连接
class Conn:
"""一条 cli 连接。
★ auth 帧本身就是 {"type":"response"},必须先吃掉它再开始收集 ——
否则第一轮的"终止帧"就是 auth,测出来的耗时是 0。
"""
def __init__(self, sock, key, timeout=120):
self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.s.settimeout(timeout)
self.s.connect(sock)
self.s.sendall(("/auth %s\n" % key).encode())
self.f = self.s.makefile("rb")
auth = self.f.readline()
if b"authenticated" not in auth:
raise RuntimeError("auth 失败: %s" % auth[:80])
def ask(self, text, timeout=120):
"""发一条输入,等终止帧。返回 (耗时, 帧列表)。"""
t0 = time.time()
self.s.sendall((text + "\n").encode())
frames = []
while time.time() - t0 < timeout:
line = self.f.readline()
if not line:
break
t = line.decode("utf-8", "replace").strip()
frames.append(t)
if t.startswith("{"):
try:
if json.loads(t).get("type") in ("response", "error"):
break
except ValueError:
pass
return time.time() - t0, frames
def close(self):
with contextlib.suppress(OSError):
self.s.close()
def has_error(frames):
for f in frames:
if f.startswith("{"):
try:
if json.loads(f).get("type") == "error":
return True
except ValueError:
pass
return False
def tools_done(frames):
"""数出工具真正执行的个数(按 done-N 标记去重)。"""
blob = " ".join(frames)
return len({int(m.group(1)) for m in re.finditer(r"done-(\d+)", blob)})
# ---------------------------------------------------------------- 单轮测量
def measure(sock, key, marker, rounds):
"""连一次、跑 rounds 轮(每轮唯一输入)。
★ 每轮输入必须唯一:内核 task.go:353 有输入去重
(isDuplicateInput,为 webui 断线重连重放而设),相同文本会被丢弃
并回空响应。第一版每轮同一个 marker,只有第 1 轮有效。
"""
dts, oks = [], 0
try:
c = Conn(sock, key)
except RuntimeError as e:
print(" 连接失败: %s" % e)
return None
try:
for _ in range(rounds):
dt, frames = c.ask("%s-%d" % (marker, time.time_ns()))
dts.append(dt)
# 无 error 帧即算通过(工具数由调用方按 marker 形态另行核对)
if not has_error(frames):
oks += 1
if has_error(frames):
break
finally:
c.close()
return {"rounds": rounds, "ok": oks, "median": statistics.median(dts),
"mean": statistics.mean(dts), "min": min(dts), "max": max(dts),
"all": dts}
# ---------------------------------------------------------------- 并发轰炸
def blast(sock, key, conns, inputs, tag):
"""conns 条连接并发,每条连接连发 inputs 条输入。返回通过率。
★ 线程数与计数:第一版按 `conns * inputs` 起线程、每个线程又跑
`inputs` 轮,于是总输入数是 conns×inputs²,分子分母量纲不一致,
算出过 "128/32 = 400%" 这种荒谬数字。
现在:**恰好 conns 个 worker,每个跑 inputs 轮** ⇒ 总输入 conns×inputs。
"""
results = [None] * conns
def worker(idx):
try:
c = Conn(sock, key)
except RuntimeError:
results[idx] = {"ok": 0, "sent": 0, "err": "connect"}
return
good = 0
try:
for j in range(inputs):
_, frames = c.ask(f"{tag}-{idx}-{j}-{time.time_ns()}")
if not has_error(frames):
good += 1
results[idx] = {"ok": good, "sent": inputs, "err": ""}
except Exception as e: # noqa: BLE001
results[idx] = {"ok": good, "sent": inputs, "err": repr(e)[:60]}
finally:
c.close()
t0 = time.time()
ths = [threading.Thread(target=worker, args=(i,), daemon=True)
for i in range(conns)]
for t in ths:
t.start()
for t in ths:
t.join()
wall = time.time() - t0
# 分母用"实际发出的输入数"(含连接失败的那些),而不是标称的 conns×inputs
# —— 连接失败时分子分母必须同步缩放,否则通过率会虚高。
total = sum(r["sent"] for r in results if r)
good = sum(r["ok"] for r in results if r)
return {"total": total, "good": good, "wall": wall,
"rate": good / total if total else 0}
# ---------------------------------------------------------------- 主流程
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 tools_for(sock, key, n):
"""单跑一轮,数出真正执行的工具个数(连不上返回 0)。"""
try:
c = Conn(sock, key)
except RuntimeError:
return 0
try:
_, frames = c.ask("!slowbatch%d-%d" % (n, time.time_ns()))
return tools_done(frames)
except Exception: # noqa: BLE001
return 0
finally:
c.close()
def main():
if len(sys.argv) < 5:
print(__doc__)
sys.exit(2)
old_sock, old_key, new_sock, new_key = sys.argv[1:5]
scale = arg_int(5, 1, "scale")
print("=" * 72)
print("更新前后全面对比(旧: 85e3d66 / 新: d3eaff4),scale=%d" % scale)
print("=" * 72)
# ---- ① 批内工具调用 ----
print("\n【① 批内工具调用】")
print("%-26s %-16s %-16s %s" % ("场景", "旧 中位/工具", "新 中位/工具", "变化"))
for n in (2, 4, 8):
row = {}
for tag, sock, key in (("old", old_sock, old_key), ("new", new_sock, new_key)):
m = measure(sock, key, "!slowbatch%d" % n, 3)
if m is None:
row[tag] = (0.0, 0)
else:
row[tag] = (m["median"], tools_for(sock, key, n))
o, w = row["old"], row["new"]
chg = "—" if o[0] == 0 else "%+.1f%%" % ((w[0] - o[0]) / o[0] * 100)
print("%-26s %-16s %-16s %s"
% ("!slowbatch%d 并发批" % n,
"%.3fs / %d 个" % o, "%.3fs / %d 个" % w, chg))
# ---- ② 并发 vs 强制串行(新版内部对照)----
print("\n【② 并发 vs 强制串行(新版内部对照)】")
print("%-6s %-14s %-14s %-10s %s" % ("N", "并发", "强制串行", "加速", "工具数"))
for n in (2, 4, 8):
cp = measure(new_sock, new_key, "!slowbatch%d" % n, 3)
cs = measure(new_sock, new_key, "!serialbatch%d" % n, 3)
if cp is None or cs is None:
print("%-6d 测量失败(连接不上)" % n)
continue
k = tools_for(new_sock, new_key, n)
sp = cs["median"] / cp["median"] if cp["median"] else 0
print("%-6d %-14s %-14s %-10.2f %d"
% (n, "%.3fs" % cp["median"], "%.3fs" % cs["median"], sp, k))
# ---- ③ 调度器并发轰炸 + 通过率 ----
print("\n【③ 调度器并发轰炸】")
conns, inputs = 8 * scale, 4 * scale
for tag, sock, key in (("旧", old_sock, old_key), ("新", new_sock, new_key)):
r = blast(sock, key, conns, inputs, "!batch2")
print(" %s版: %d/%d 通过(%.1f%%),墙钟 %.1fs"
% (tag, r["good"], r["total"], r["rate"] * 100, r["wall"]))
# ---- ④ 连续稳定性 ----
print("\n【④ 连续稳定性(20 轮)】")
for tag, sock, key in (("旧", old_sock, old_key), ("新", new_sock, new_key)):
m = measure(sock, key, "!batch2", 20)
if m is None:
print(" %s版: 连接失败" % tag)
continue
print(" %s版: %d/%d 无错误,中位 %.3fs,min %.3f / max %.3f"
% (tag, m["ok"], m["rounds"], m["median"], m["min"], m["max"]))
print()
if __name__ == "__main__":
main()

View File

@ -53,6 +53,12 @@ _RE_MIXED = re.compile(r"!mixed(\d+)")
# cmd_run 已声明 ParallelSafe(执行体只依赖入参,共享的 p.history 由
# recordCmd 加锁保护),且 sleep 是纯计算、不碰磁盘 —— 延迟可精确预期。
_RE_SLOW = re.compile(r"!slowbatch(\d+)")
# ★ 强制串行对照:混入一个未声明 ParallelSafe 的工具(knowledge_create)
# ⇒ 内核 batchRunnable 必须整批退回串行("一个不安全就整批降级")。
# 有了它才能在**同一套内核**上量出并发 vs 串行的差异 —— 跨版本做不到,
# 因为旧版的适配器缺 stream_index,多个分片并到槽 0、参数混拼,
# 工具一个都没真跑(耗时更短但没干活)。
_RE_SERIALBATCH = re.compile(r"!serialbatch(\d+)")
# 批内并发的工具集。全部是**只读且已核实无共享写**的内置工具,
# 且都不触发 ONNX(TF-IDF 关键词路)。
@ -182,6 +188,16 @@ class H(BaseHTTPRequestHandler):
# ---- 批内并发:!batchN / !mixedN ----
if not has_tool_msg:
msb = _RE_SERIALBATCH.search(text)
if msb:
cnt = max(2, min(int(msb.group(1)), 32))
sm = int(os.environ.get("MOCK_TOOL_SLEEP_MS", "200"))
tcs = slow_tool_calls(cnt, sm)
# 插在中间:确保降级判据不能靠"最后一个工具"侥幸通过
tcs.insert(cnt // 2, mk_tool_call(900, SERIAL_TOOL,
{"name": "对照/强制串行", "content": "x"}))
return self._respond(body, content=None, tool_calls=tcs)
ms = _RE_SLOW.search(text)
if ms:
cnt = max(2, min(int(ms.group(1)), 32))