Files
PolyGateway/tools/soak/scoreboard.py
T
iomgaa 56defc88fb fix: align soak scoreboard metrics with limiter semantics
RPM invariant now buckets by admit time on the Redis server clock,
exempts +/-2s boundary jitter, and excludes cache-hit rows that never
consumed a limiter slot. RSS sampling reports current ps RSS instead of
the monotonic ru_maxrss peak. Dispatch is paced by the concurrency
semaphore so --max-hours stays live and memory stays bounded. Add
--rescore RUN_ID to re-judge a finished run offline.
2026-07-21 07:22:34 -04:00

226 lines
9.2 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.
"""压测记分板: 六条硬不变量断言 + 报告产出(findings §4;吃自己遥测的狗粮)。
输入口径三类(M2 计划 T11): 不变量 2/3/5/6 = 纯函数(遥测行迭代器);
不变量 1(记账归零)= 活 Redis/后端检查(async);不变量 4(RSS)= 纯函数
(run_soak 周期采样 json)。断言函数抛 AssertionError 即不变量被击穿。
"""
from __future__ import annotations
import sqlite3
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
Row = dict[str, Any]
def load_rows(*db_paths: Path | str) -> list[Row]:
"""合并全部 worker 遥测库的 llm_calls 行(dict 形态)。"""
rows: list[Row] = []
for path in db_paths:
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
try:
rows.extend(dict(r) for r in conn.execute("SELECT * FROM llm_calls"))
finally:
conn.close()
return rows
# —— 硬不变量(纯函数) ——
def inv_rows_match_calls(rows: list[Row], *, expected_calls: int, tolerance: int = 0) -> None:
"""不变量 2a: 遥测行数 == 发出的请求数(±取消双记的已知语义容差)。"""
assert abs(len(rows) - expected_calls) <= tolerance, (
f"遥测行数 {len(rows)} ≠ 请求数 {expected_calls}(容差 {tolerance})"
)
def inv_call_ids_unique(rows: list[Row]) -> None:
"""不变量 2b: call_id 无重复。"""
dupes = [cid for cid, n in Counter(r["call_id"] for r in rows).items() if n > 1]
assert not dupes, f"call_id 重复: {dupes[:5]}"
def _admit_second(row: Row, clock_offset_s: float) -> float:
"""还原准入时刻(服务器钟): created_at 是完成落库时刻,减去调用延迟。"""
done = datetime.fromisoformat(str(row["created_at"])).timestamp()
latency_ms = row.get("latency_ms") or 0
return done - float(latency_ms) / 1000.0 + clock_offset_s
def _movable_edge_rows(
admits: list[float], buckets: Counter[int], minute: int, limit: int, slack_s: float
) -> int:
"""超限窗口内可归邻窗的贴边行数(受邻窗余量约束)。"""
near_edge = sum(
1
for a in admits
if int(a // 60) == minute and min(a - minute * 60, (minute + 1) * 60 - a) <= slack_s
)
room = max(0, limit - buckets.get(minute - 1, 0)) + max(0, limit - buckets.get(minute + 1, 0))
return min(near_edge, room)
def inv_rpm_never_exceeded(
rows: list[Row],
per_source_rpm: dict[str, int],
*,
clock_offset_s: float = 0.0,
boundary_slack_s: float = 2.0,
) -> None:
"""不变量 3: 任一限流器分钟窗口内单源准入数 ≤ RPM 配置。
口径与限流器同源(`backends/redis/limiter.py` `_window_id`): 窗口 =
**Redis 服务器钟**的固定分钟;准入时刻 = created_at(完成落库) latency。
滑动 60s 窗会对"窗尾+窗头"的合法背靠背流量误报,不采用(findings §4 条 3)。
created_at 秒级截断给准入时刻 ±秒级噪声,距窗口边界 ≤ boundary_slack_s 的
行允许归入有余量的邻窗。缓存命中行不计: 缓存在限流闸之前返回,未耗名额
也未打网关(遥测必录使其带原源名落库)。2026-07-21 P6 教训: 本机钟聚桶 +
完成时刻口径 + 计入缓存行,三重口径偏差曾把合规流量误判为击穿。
"""
per_source: dict[str, list[float]] = {}
for r in rows:
if per_source_rpm.get(r["source_name"], 0) > 0 and not r.get("cache_hit"):
per_source.setdefault(r["source_name"], []).append(_admit_second(r, clock_offset_s))
breaches: dict[tuple[str, int], int] = {}
for source, admits in per_source.items():
limit = per_source_rpm[source]
buckets = Counter(int(a // 60) for a in admits)
for minute, n in buckets.items():
if n <= limit:
continue
movable = _movable_edge_rows(admits, buckets, minute, limit, boundary_slack_s)
if n - movable > limit:
breaches[(source, minute)] = n
assert not breaches, f"RPM 击穿(准入时刻+服务器钟口径): {dict(list(breaches.items())[:5])}"
def inv_rss_stable(samples_mb: list[float], *, max_growth_mb: float) -> None:
"""不变量 4: 进程 RSS 首末差 < 阈值(窗口计数器/租约表无无界增长)。"""
assert len(samples_mb) >= 2, "RSS 采样不足"
growth = samples_mb[-1] - samples_mb[0]
assert growth < max_growth_mb, f"RSS 增长 {growth:.1f}MB ≥ 阈值 {max_growth_mb}MB"
def structured_success_rate(rows: list[Row], *, session_suffix: str) -> float:
"""不变量 5 的量: 指定场景行的最终成功率(error 为空即成功)。"""
scoped = [r for r in rows if str(r.get("session_id") or "").endswith(session_suffix)]
if not scoped:
return 0.0
ok = sum(1 for r in scoped if not r.get("error"))
return ok / len(scoped)
async def inv_accounting_zeroed(limiter, sources: list[str]) -> None:
"""不变量 1: 结束后全部源 inflight == 0(无泄漏租约)。活后端检查。"""
for name in sources:
stats = await limiter.source_stats(name)
assert stats.inflight == 0, f"源 {name} 结束后 inflight={stats.inflight}(租约泄漏)"
# —— 报告 ——
def _percentile(values: list[float], q: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
return ordered[min(int(len(ordered) * q), len(ordered) - 1)]
def render_report(run_id: str, rows: list[Row], verdicts: list[tuple[str, str]]) -> str:
"""结构化 Markdown 报告(不变量 6: 成本/延迟基线产出)。"""
errors = Counter(str(r["error"]).split(":")[0] for r in rows if r.get("error"))
latencies = [float(r["latency_ms"]) for r in rows if not r.get("error")]
ttfts = [float(r["ttft_ms"]) for r in rows if r.get("ttft_ms") is not None]
costs = [float(r["cost"]) for r in rows if r.get("cost") is not None]
tokens = sum(int(r["prompt_tokens"]) + int(r["completion_tokens"]) for r in rows)
cache_hits = sum(1 for r in rows if r.get("cache_hit"))
lines = [
f"# Soak 报告: {run_id}",
"",
"## 不变量裁决",
*(f"- {name}: {verdict}" for name, verdict in verdicts),
"",
"## 规模",
f"- 遥测行数: {len(rows)};缓存命中: {cache_hits};总 token: {tokens}",
f"- 错误分布: {dict(errors) or '无'}",
"",
"## 延迟基线(成功行)",
f"- 总时长 p50/p95: {_percentile(latencies, 0.5):.0f} / {_percentile(latencies, 0.95):.0f} ms",
f"- TTFT p50/p95: {_percentile(ttfts, 0.5):.0f} / {_percentile(ttfts, 0.95):.0f} ms",
"",
"## 成本",
f"- 累计 cost: {sum(costs):.4f}(有单价行 {len(costs)}/{len(rows)})",
]
return "\n".join(lines) + "\n"
def write_report(run_id: str, content: str, out_dir: Path | str = "tests/outputs/soak") -> Path:
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
path = out / f"{run_id}.md"
path.write_text(content, encoding="utf-8")
return path
# —— 活后端检查与预算帽(2026-07-21 verifier I1/M6/M7 补齐)——
SCENARIO_CALL_CAPS = { # 设计 §8.1 签字的单场景请求数上限
"P1": 500,
"P2": 450,
"P3": 2500,
"P4": 2500,
"P5": 1000,
"P6": 8000,
}
def capped_budget(scenario: str, requested_calls: int) -> int:
"""预算帽: 请求数不越过签字上限(超出取上限并由调用方打印告知)。"""
cap = SCENARIO_CALL_CAPS[scenario]
return min(requested_calls, cap)
async def inv_gate_reenterable(gate, sources: list[str]) -> None:
"""不变量 1b: 跑后熔断门可再准入,探针不悬挂。
run 结束后已无 in-flight 调用,若某源仍处 HALF_OPEN 拒入 = 死探针
悬挂(只能等 TTL);OPEN 冷却中属故障源的合法状态,不算击穿。
拿到的探针当场归还(release_probe),不留新悬挂。
"""
for name in sources:
decision = await gate.try_enter(name, "scoreboard-probe")
if decision.allowed:
if decision.is_probe:
await gate.release_probe(decision)
continue
assert str(decision.state) != "half_open", (
f"源 {name} 跑后仍 HALF_OPEN 拒入(探针悬挂,retry_after={decision.retry_after_s:.1f}s)"
)
def inv_fault_errors_present(rows: list[Row], *, fault_source_names: list[str]) -> None:
"""不变量 2c(P5/P6): 配置了故障源则错误必然出现且落在故障源上。
注入"比例"的精确吻合依赖具体混编配置,自动断言留待 P5 实跑校准
(findings §4 条 2);此处先钉存在性: 故障源零错误 = 故障根本没被打到。
"""
if not fault_source_names:
return
fault_errors = [r for r in rows if r.get("error") and r["source_name"] in fault_source_names]
assert fault_errors, f"故障源 {fault_source_names} 零错误行——故障混编未生效"
def inv_any_errors(rows: list[Row]) -> None:
"""不变量 2c 的兜底形态(P5/P6): 故障源混编池下错误行必然存在。
紧闸源被限流闸跳过不产错误行,故障名单无法从配置泛化推断——按源归因
交报告"错误分布"人工核对;此处只钉全局存在性。
"""
assert any(r.get("error") for r in rows), "P5/P6 故障混编池零错误行——故障未生效"