mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
## 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
271 lines
9.6 KiB
Python
271 lines
9.6 KiB
Python
#!/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()
|