Files
AgentLab/backend/tests/test_querycore_perf.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

589 lines
32 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.
"""K13:固定执行的测量与受控性能基线(app/ledger/querycore.py 的 measurement、engine/runner 的 usage.queue_ms)。
这份文件盯住两件事:
1. **测量本身是真的**(默认每次都跑):一次固定执行交回的 measurement 把阶段耗时(发布/定位、闭包/计划、参数、排队、
数据库、证据、总耗时)、计数(计划与**真发出去**的 SQL 条数、内部模型调用数、交回的查询条数与截断条数、问题/错误/
超时)连同 trace / run / query 身份一起记下来。各阶段加得起来;量不到的字段是 None,不填 0 冒充;SQL 条数对着真的
执行次数;LLM 恒为 0;超时回 running 且**不冒充成功**;排队(usage.queue_ms)绝不拿等人审批的 wait_ms 顶上。
2. **基线是 opt-in 的**(`AGENTLAB_QUERYCORE_PERF=1` 才跑):同一份代码、同一份数据快照、同一组参数、同一台机器、
同一份预算,冷启动(先清掉进程内缓存的那一次)与热执行分开,各档至少 100 次,原始样本 + P50/P95 + 错误率;跑并发 1
与并发 4 两档(名额上限 1 与 2,4 那一档一半在排队,用来验证 queue_ms)。**失败样本不剔除**:都留在样本里、算进错误率。
数据全是合成的(一个临时 SQLite 库、7 条合成标量指标),不连真实业务库;样本只写临时目录。
**没有生产负载,这份基线推不出生产的 P50/P95**——它只说明这套代码在这台机器、这份数据、这组参数、这份预算下的样子。
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import math
import os
import platform
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Run
from app.engine.runner import run_manager
from app.ledger import querycore
from app.main import app
from tests import ledger_samples as S
from tests.fixtures.sources import drop_source
from tests.publication_samples import make_workflow, publish
#: 口径卡的节点 id
CARD = "card"
#: 取数窗口:start(含)/ end(不含)
WINDOW = "AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'"
#: 这次的入口取值(每个样本都一样:同一组参数)
INPUTS = {"start": "2026-09-01", "end": "2026-09-08"}
#: 窗口内的期望值(合成店铺库:三单、两个大区、两家门店)
WINDOW_GMV = 170.5
WINDOW_ORDERS = 3
#: 合成库上的 7 条标量指标(落在 3 条取数 SQL 上)
METRIC_IDS = ["gmv", "orders", "peak", "buyers", "gmv_cents", "region_gmv", "store_count"]
#: 闭包要执行几条查询:三条取数各一条
QUERIES = 3
def metrics() -> list[dict[str, Any]]:
"""7 条合成标量指标:sales 那条 SQL 出 4 个(gmv_cents 与之共用),另两条取数各出 1 个。"""
return [
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "expression": "cell(nodes.sales, 0, 'gmv')"},
{"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.sales, 0, 'orders')"},
{"id": "peak", "name": "最大单笔", "unit": "元", "decimals": 2, "expression": "cell(nodes.sales, 0, 'peak')"},
{"id": "buyers", "name": "买家数", "unit": "人", "expression": "cell(nodes.sales, 0, 'buyers')"},
{"id": "gmv_cents", "name": "销售额(分)", "unit": "分", "decimals": 0,
"expression": "cell(nodes.sales, 0, 'gmv') * 100"},
{"id": "region_gmv", "name": "大区销售额", "unit": "元", "decimals": 2,
"expression": "cell(nodes.region, 0, 'region_gmv')"},
{"id": "store_count", "name": "门店数", "unit": "家", "expression": "cell(nodes.stores, 0, 'stores')"},
]
def perf_graph() -> dict[str, Any]:
"""入口 → 三条取数 → 口径卡(7 条指标)→ 出口。取数节点自己的 SQL 不点入口参数以外的任何东西。"""
return S.chain(
S.node("start", "input", fields=[{"name": "start", "type": "string", "required": True},
{"name": "end", "type": "string", "required": True}]),
S.node("sales", "tool", tool=S.tool(),
args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders, MAX(amount) AS peak, "
f"COUNT(DISTINCT phone) AS buyers FROM orders WHERE status = 'paid' {WINDOW}"}),
S.node("region", "tool", tool=S.tool(),
args={"sql": "SELECT region, SUM(amount) AS region_gmv FROM orders "
f"WHERE status = 'paid' {WINDOW} GROUP BY region ORDER BY region"}),
S.node("stores", "tool", tool=S.tool(), args={"sql": "SELECT COUNT(*) AS stores FROM stores"}),
S.node(CARD, "metrics", caliber="周销售", caliber_version="v1", metrics=metrics()),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.sales }}"}]))
def clipping_graph() -> dict[str, Any]:
"""一条取数按大区分组、只留一行:结果被截短(truncated),测量里要数得出来。"""
return S.chain(
S.node("start", "input", fields=[{"name": "start", "type": "string", "required": True},
{"name": "end", "type": "string", "required": True}]),
S.node("sales", "tool", tool=S.tool(),
args={"sql": "SELECT region, SUM(amount) AS gmv FROM orders "
f"WHERE status = 'paid' {WINDOW} GROUP BY region ORDER BY region", "limit": 1}),
S.node(CARD, "metrics", caliber="周销售", caliber_version="v1", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "expression": "cell(nodes.sales, 0, 'gmv')"}]),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.sales }}"}]))
async def make_asset(tmp_path, client, build) -> dict[str, Any]:
"""建一份已发布的合成口径:临时 SQLite 库(合成店铺库)+ 已发布版本 + 发布清单。数据只写临时目录。
build() 在数据源定下来**之后**才调用:图里的工具名带着这一次的源名。"""
path = tmp_path / f"perf_{uuid.uuid4().hex[:6]}.db"
S.build_shop(path)
S.CURRENT["source"] = f"perf_{uuid.uuid4().hex[:8]}"
source_id = await S.set_source(path)
made = await make_workflow(graph=build())
await publish(client, made)
return {**made, "source_id": source_id}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def asset(tmp_path, client):
"""这份文件大部分用例用的那份口径(7 条指标、3 条取数)。"""
made = await make_asset(tmp_path, client, perf_graph)
yield made
await drop_source(made["source_id"])
async def execute_once(asset: dict, *, trace: str | None = None, timeout_s: float | None = None,
metric_ids: list[str] | None = None) -> Any:
"""走服务入口跑一次(同一组参数、同一份数据快照),等结果。"""
async with SessionLocal() as session:
return await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=list(METRIC_IDS if metric_ids is None else metric_ids),
inputs=dict(INPUTS), trace=trace, timeout_s=timeout_s)
async def run_row(run_id: str) -> Run | None:
async with SessionLocal() as session:
return await session.get(Run, run_id)
def no_model(monkeypatch) -> None:
"""内部 LLM = 0:任何模型调用都让这次执行炸掉。"""
from app.providers import mock_model
def boom(self, messages):
raise AssertionError("QueryCore 不该调用模型")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", boom)
def count_sql_calls(monkeypatch) -> dict[str, int]:
"""独立地数这次真正发出去几条 SQL:包住调用工具节点的执行器,只数这次那个数据源工具被调了几次。"""
from app.engine import compiler
from app.engine.schema import NodeType
original = compiler.RUNNERS[NodeType.TOOL]
seen = {"calls": 0}
async def runner(state, ctx):
if str(getattr(ctx, "config", {}).get("tool") or "") == S.tool():
seen["calls"] += 1
return await original(state, ctx)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TOOL, runner)
return seen
def slow_tools(monkeypatch, seconds: float) -> None:
"""把取数拖慢:让「还在跑」那一段足够长,超时用例不靠机器快不快碰运气。"""
from app.engine import compiler
from app.engine.schema import NodeType
original = compiler.RUNNERS[NodeType.TOOL]
async def runner(state, ctx):
if str(getattr(ctx, "config", {}).get("tool") or "") == S.tool():
await asyncio.sleep(seconds)
return await original(state, ctx)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TOOL, runner)
def limit_slots(monkeypatch, limit: int) -> None:
"""把并发名额的全站上限定成 limit:名额本身走真实那一套(app/cluster/slots),只把「现在生效的设置」这一个入口
换掉。**不往库里写设置**——写下去这一条用例跑完也不恢复,后面的用例会跟着排成单名额,而父运行调用口径查询时
父子两个运行同时要名额,单名额就等成死锁。"""
from app.cluster import slots
value = slots.values({"max_total": limit,
"classes": {k: {"limit": limit, "priority": 40 - i} for i, k in enumerate(slots.CLASSES)}})
async def fixed(*, fresh: bool = False): # noqa: ARG001 - 和真 policy 同一个签名
return value
monkeypatch.setattr(slots, "policy", fixed)
# ==========================================================================
# 1、测量本身:阶段加得起来、字段不虚构、身份留得住
# ==========================================================================
def test_an_old_run_never_gets_a_queue_time_invented_for_it():
"""加 queue_ms 这一项之前跑完的运行记录里没有它:读的一方要按「没量过」处理(None),绝不拿 wait_ms 冒充排队。"""
class Old:
usage = {"duration_ms": 12, "wall_ms": 12, "active_ms": 12, "wait_ms": 900}
class Fresh:
usage = {"duration_ms": 12, "wall_ms": 12, "active_ms": 12, "wait_ms": 0, "queue_ms": 7}
class Bogus:
usage = {"queue_ms": "7"}
assert querycore._queue_of(Old()) is None, "老运行没有这一项:不许拿那 900ms 的 wait_ms 顶上"
assert querycore._queue_of(Fresh()) == 7
assert querycore._queue_of(Bogus()) is None, "存坏了的按没量到算"
assert querycore._queue_of(None) is None
async def test_the_measurement_locates_every_stage_and_adds_up(asset):
"""一次跑得成、数也对的执行:阶段加得起来,计数与身份都对得上,排队按运行记录上那一项来。"""
outcome = await execute_once(asset, trace="perf-locate")
assert outcome.ok, outcome.problems
measured = outcome.as_dict()["measurement"]
phases, counts, ids = measured["phases"], measured["counts"], measured["ids"]
assert set(phases) == set(querycore.PHASES)
for name, value in phases.items():
assert isinstance(value, int) and value >= 0, (name, value)
# 跑的还是那份合成数据、那组参数:窗口里的期望值对得上(不是「跑通了但算的是别的东西」)
values = {m["id"]: m["value"] for m in outcome.metrics}
assert values["gmv"] == WINDOW_GMV and values["orders"] == WINDOW_ORDERS
# 首尾相接的五段加起来就是总耗时,中间只差测量自身那几十微秒
disjoint = sum(phases[name] for name in ("locate_ms", "closure_ms", "params_ms", "run_ms", "evidence_ms"))
assert 0 <= phases["total_ms"] - disjoint <= 60, phases
# queue / database 是 run 里面的两段:各出自己的出处,不重复计时
assert 0 <= phases["queue_ms"] <= phases["run_ms"], phases
assert 0 <= phases["database_ms"] <= phases["run_ms"], phases
assert phases["database_ms"] > 0, "真查了三条 SQL,取数那一段不该是 0"
# 排队那一项就是运行记录上的 queue_ms(不是等人审批的 wait_ms)
row = await run_row(outcome.run_id)
assert row is not None and row.status == "succeeded"
assert phases["queue_ms"] == row.usage["queue_ms"]
assert row.usage["wait_ms"] == 0, "这次没人审批"
# 计数:计划 3 条、真发出去 3 条、模型 0 次、交回 3 条、一条都没截断、没有错也没有超时
assert counts == {"planned_queries": QUERIES, "executed_sql": QUERIES, "llm_calls": 0,
"returned_queries": QUERIES, "truncated_queries": 0,
"problems": 0, "errors": 0, "timeouts": 0}
# 身份:run / trace / query 都对得上,按其中任何一个都能找回来
assert ids["run_id"] == outcome.run_id
assert ids["trace"] == "perf-locate"
assert ids["workflow_id"] == asset["workflow_id"] and ids["card_id"] == CARD and ids["version"] == 1
assert len(ids["query_ids"]) == QUERIES and len(ids["execution_ids"]) == QUERIES
assert ids["execution_ids"] == [q["execution_id"] for q in outcome.as_dict()["queries"]]
assert set(outcome.as_dict()["data"]["sources"]) == {S.CURRENT["source"]}
async def test_the_http_entry_hands_the_measurement_out(asset, client):
"""接口这一层也交回测量:调用方拿这一份就能按 run_id / trace / query_ids 定位回去。"""
res = await client.post("/api/ledger/querycore", json={
"workflow_id": asset["workflow_id"], "card_id": CARD, "metric_ids": list(METRIC_IDS),
"inputs": dict(INPUTS), "trace": "perf-http"})
assert res.status_code == 200, res.text
body = res.json()
measured = body["measurement"]
assert measured["ids"]["run_id"] == body["run_id"] and measured["ids"]["trace"] == "perf-http"
assert measured["counts"]["executed_sql"] == QUERIES and measured["counts"]["llm_calls"] == 0
assert measured["phases"]["total_ms"] >= measured["phases"]["run_ms"]
assert len(measured["ids"]["query_ids"]) == QUERIES
async def test_the_sql_count_is_the_number_of_statements_actually_sent(asset, monkeypatch):
"""SQL 条数对着真的执行次数(独立数一遍),内部模型调用恒为 0。"""
no_model(monkeypatch)
seen = count_sql_calls(monkeypatch)
outcome = await execute_once(asset)
assert outcome.ok, outcome.problems
measured = outcome.as_dict()["measurement"]
assert seen["calls"] == QUERIES, "三次取数各发一条 SQL"
assert measured["counts"]["executed_sql"] == seen["calls"] == QUERIES
assert measured["counts"]["planned_queries"] == QUERIES
assert measured["counts"]["llm_calls"] == 0
# 取数那一段也确实量到了:三条 SQL 各自都记了执行时长
assert measured["phases"]["database_ms"] > 0
async def test_a_timeout_stays_running_and_never_fills_in_what_it_could_not_measure(asset, monkeypatch):
"""超时:状态是 running、ok=False,**没量到的字段是 None**——不拿计划条数或 0 冒充已执行。"""
slow_tools(monkeypatch, 0.4)
started = time.perf_counter()
outcome = await execute_once(asset, timeout_s=0.0)
assert outcome.status == "running" and outcome.ok is False
assert [p["code"] for p in outcome.problems] == [querycore.TIMEOUT]
assert outcome.run_id, "没收尾也要把 run_id 交回,让人能去看"
assert time.perf_counter() - started < 0.4, "到点就返回,不等它跑完"
measured = outcome.as_dict()["measurement"]
phases, counts, ids = measured["phases"], measured["counts"], measured["ids"]
# 量得到的照记(这次是刚刚才发起的),量不到的照实空着
assert isinstance(phases["total_ms"], int) and isinstance(phases["run_ms"], int)
assert phases["database_ms"] is None, "没跑完没有执行证据:数据库那一截是 None,不是 0"
assert phases["queue_ms"] is None, "还没收尾,运行记录上还没有排队这一项"
assert counts["timeouts"] == 1 and counts["errors"] == 0
assert counts["planned_queries"] == QUERIES
assert counts["executed_sql"] is None and counts["llm_calls"] is None
assert counts["returned_queries"] is None and counts["truncated_queries"] is None
assert ids["run_id"] == outcome.run_id and len(ids["query_ids"]) == QUERIES
row = await run_row(outcome.run_id)
assert row is not None and row.status == "running", "超时那一刻运行还在跑"
async def test_the_truncated_count_matches_the_queries_that_were_clipped(tmp_path, client):
"""交回几条、被截几条,数得出来:结果被截短时台账标它、测量里也数到它。"""
made = await make_asset(tmp_path, client, clipping_graph)
try:
outcome = await execute_once(made, metric_ids=["gmv"])
finally:
await drop_source(made["source_id"])
assert outcome.ok, outcome.problems
[query] = outcome.as_dict()["queries"]
assert query["truncated"] is True and query["row_count"] == 1
counts = outcome.as_dict()["measurement"]["counts"]
assert counts["planned_queries"] == counts["executed_sql"] == 1
assert counts["returned_queries"] == 1 and counts["truncated_queries"] == 1
async def test_queue_never_takes_the_place_of_waiting_on_a_person(asset, monkeypatch):
"""排队(等并发名额)与等人审批是两回事:谁也不冒充谁。
一:没人和它抢名额时,queue_ms 只有「要名额到拿到名额」那一下的开销,而 wait_ms 是等人那 0.5 秒。
二:名额确实被别人占着时,queue_ms 记的就是等名额的那一段,wait_ms 仍然是 0。
"""
# 一、等人:图停在审批上 0.5 秒
gate_graph = S.chain(
S.node("start", "input"),
S.node("gate", "human", mode="approve", title="看一眼"),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.gate.approved }}"}]),
)
run = await run_manager.start(graph=gate_graph, input_payload={})
for _ in range(200):
if (await run_row(run.id)).status == "interrupted":
break
await asyncio.sleep(0.02)
await asyncio.sleep(0.5)
await run_manager.resume(run.id, {"approved": True})
for _ in range(400):
row = await run_row(run.id)
if row.status in querycore.TERMINAL_RUN_STATUSES:
break
await asyncio.sleep(0.02)
assert row.status == "succeeded", row.error
assert row.usage["wait_ms"] >= 400, row.usage
assert row.usage["queue_ms"] < 200, "排队不能把等人那 0.5 秒算进去"
# 二、等名额:唯一的名额被别人占着,0.3 秒后才放
limit_slots(monkeypatch, 1)
from app.cluster import slots
hog = slots.hold("perf-hog", "chat")
await hog.__aenter__()
async def release() -> None:
await asyncio.sleep(0.3)
await hog.__aexit__(None, None, None)
releaser = asyncio.create_task(release())
try:
outcome = await execute_once(asset, trace="perf-queue")
finally:
# 用例中途出错也要把名额放掉:占着唯一的名额会拖住后面每一个用例
if not releaser.done():
releaser.cancel()
with contextlib.suppress(Exception):
await hog.__aexit__(None, None, None)
assert outcome.ok, outcome.problems
phases = outcome.as_dict()["measurement"]["phases"]
assert phases["queue_ms"] >= 250, phases
row = await run_row(outcome.run_id)
assert row.usage["queue_ms"] == phases["queue_ms"] and row.usage["wait_ms"] == 0
# ==========================================================================
# 2、受控基线(opt-in:AGENTLAB_QUERYCORE_PERF=1 才跑)
# ==========================================================================
#: 打开基线的环境变量:默认不跑(一次基线是几百次真跑,不该混进日常回归)
PERF_ENV = "AGENTLAB_QUERYCORE_PERF"
#: 每一档跑多少次热执行(至少 100;显式调小只用于冒烟)
PERF_N_ENV = "AGENTLAB_QUERYCORE_PERF_N"
PERF_WARM = int(os.environ.get(PERF_N_ENV, "100"))
#: 冷启动样本各跑几次
PERF_COLD = int(os.environ.get("AGENTLAB_QUERYCORE_PERF_COLD", "3"))
#: 样本写到哪儿:默认一个临时目录(也可以指定 AGENTLAB_QUERYCORE_PERF_OUT)
PERF_OUT_ENV = "AGENTLAB_QUERYCORE_PERF_OUT"
#: 两个并发档:客户端同时发几个 execute。名额上限取 max(1, L // 2):4 那一档有一半在排队,queue_ms 才有东西可验
PERF_LEVELS = (1, 4)
#: 报告里逐个看的耗时
REPORTED = ("total_ms", "run_ms", "database_ms", "queue_ms", "locate_ms", "closure_ms", "params_ms", "evidence_ms")
def percentile(values: list[int], pct: float) -> int | None:
"""最近的排位(nearest-rank):样本少也不插值编一个中间值出来。"""
if not values:
return None
ordered = sorted(values)
rank = max(0, min(len(ordered) - 1, math.ceil(pct / 100 * len(ordered)) - 1))
return ordered[rank]
async def cold_start(source_id: str) -> None:
"""按「进程刚起来、还没连过这个源」的条件跑:清掉进程内的数据源连接池与现查缓存。
真正的进程级冷启动要另起进程,这一版不做——报告里就按「清了进程内缓存」说清。"""
from app.data import coverage
from app.data.engine import engines
await engines.invalidate(source_id)
for key in [k for k in coverage._LIVE if k and k[0] == source_id]:
coverage._LIVE.pop(key, None)
def sample_of(suite: str, level: int, phase: str, index: int, limit: int, wall_ms: int, outcome: Any,
failure: str | None) -> dict[str, Any]:
"""一个原始样本:这次跑的哪一档、第几次、花了多久、交回什么、量到什么(量不到的照实 null)。"""
measured = outcome.as_dict().get("measurement") if outcome is not None else None
return {"suite": suite, "level": level, "phase": phase, "index": index, "slots_limit": limit,
"wall_ms": wall_ms, "ok": bool(outcome is not None and outcome.ok),
"status": None if outcome is None else outcome.status,
"run_id": None if outcome is None else outcome.run_id,
**({} if measured is None else {"phases": measured["phases"], "counts": measured["counts"],
"ids": measured["ids"]}),
"failure": failure}
async def run_sample(asset: dict, *, suite: str, level: int, phase: str, index: int, limit: int,
cold: bool = False) -> dict[str, Any]:
"""跑一个样本。**失败也留下**:异常记成样本的 failure,不剔除。"""
if cold:
await cold_start(asset["source_id"])
began = time.perf_counter()
outcome: Any = None
failure: str | None = None
try:
outcome = await execute_once(asset, trace=f"{suite}:{index}")
except Exception as e: # noqa: BLE001 - 失败样本也要留下来,不能悄悄丢掉
failure = f"{type(e).__name__}: {e}"
return sample_of(suite, level, phase, index, limit, int((time.perf_counter() - began) * 1000), outcome, failure)
def group_key(sample: dict[str, Any]) -> tuple[str, int, str]:
return str(sample["suite"]), int(sample["level"]), str(sample["phase"])
def summarize(samples: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按档汇总:错误率算在**全部**样本上(失败不剔除),每个耗时的分位数只用真的量到它的那些样本。"""
groups: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
for sample in samples:
groups.setdefault(group_key(sample), []).append(sample)
out: list[dict[str, Any]] = []
for (suite, level, phase), group in sorted(groups.items()):
failed = [s for s in group if not s["ok"]]
row: dict[str, Any] = {"suite": suite, "level": level, "phase": phase, "n": len(group),
"slots_limit": group[0]["slots_limit"], "failed": len(failed),
"error_rate": round(len(failed) / len(group), 4),
"failure_kinds": sorted({str(s["failure"]) for s in failed if s["failure"]})
or sorted({str(s.get("status")) for s in failed}),
"metrics": {}}
for name in REPORTED:
values = [int(s["phases"][name]) for s in group
if isinstance(s.get("phases"), dict) and isinstance(s["phases"].get(name), int)]
row["metrics"][name] = {"n": len(values), "p50": percentile(values, 50), "p95": percentile(values, 95),
"max": max(values) if values else None}
wall = [int(s["wall_ms"]) for s in group]
row["metrics"]["wall_ms"] = {"n": len(wall), "p50": percentile(wall, 50), "p95": percentile(wall, 95),
"max": max(wall)}
executed = [s["counts"]["executed_sql"] for s in group
if isinstance(s.get("counts"), dict) and isinstance(s["counts"].get("executed_sql"), int)]
row["counts"] = {"executed_sql_p50": percentile(executed, 50), "executed_sql_n": len(executed),
"llm_calls": sorted({s["counts"]["llm_calls"] for s in group
if isinstance(s.get("counts"), dict)}),
"planned_queries": sorted({s["counts"]["planned_queries"] for s in group
if isinstance(s.get("counts"), dict)})}
out.append(row)
return out
def render(summary: list[dict[str, Any]], header: str) -> str:
lines = [header, ""]
for row in summary:
lines.append(f"{row['suite']:>12} 并发{row['level']} 名额{row['slots_limit']} {row['phase']}: "
f"n={row['n']} 失败={row['failed']}({row['error_rate']:.1%})"
+ (f" 失败类型={row['failure_kinds']}" if row["failure_kinds"] else ""))
for name in REPORTED:
stat = row["metrics"][name]
lines.append(f" {name:<14} n={stat['n']:<4} P50={stat['p50']}ms P95={stat['p95']}ms max={stat['max']}ms")
counts = row["counts"]
lines.append(f" 计数 executed_sql P50={counts['executed_sql_p50']}"
f" (n={counts['executed_sql_n']}) llm_calls={counts['llm_calls']}"
f" planned_queries={counts['planned_queries']}")
return "\n".join(lines)
async def run_baseline(asset: dict, out_dir: Path, monkeypatch) -> dict[str, Any]:
"""跑完整一套:冷启动 + 并发 1 + 并发 4,原始样本落盘,返回汇总。"""
samples: list[dict[str, Any]] = []
for index in range(PERF_COLD):
limit_slots(monkeypatch, 1)
samples.append(await run_sample(asset, suite="cold", level=1, phase="cold", index=index, limit=1, cold=True))
for level in PERF_LEVELS:
limit = max(1, level // 2)
limit_slots(monkeypatch, limit)
index = 0
while index < PERF_WARM:
batch = min(level, PERF_WARM - index)
results = await asyncio.gather(*[
run_sample(asset, suite=f"concurrency-{level}", level=level, phase="warm", index=index + offset,
limit=limit)
for offset in range(batch)])
samples.extend(results)
index += batch
(out_dir / "samples.jsonl").write_text(
"\n".join(json.dumps(s, ensure_ascii=False) for s in samples) + "\n", encoding="utf-8")
summary = summarize(samples)
code = {}
for name in ("app/ledger/querycore.py", "app/engine/runner.py"):
code[name] = hashlib.sha256((Path(__file__).resolve().parents[1] / name).read_bytes()).hexdigest()[:12]
header = (f"== QueryCore K13 基线(合成数据) ==\n"
f"样本目录:{out_dir}(samples.jsonl / summary.json)\n"
f"代码快照:{code}\n"
f"机器:{platform.platform()} / python {platform.python_version()} / {os.cpu_count()} 核\n"
f"数据:{S.CURRENT['source']}(临时 SQLite,合成店铺库);参数:{INPUTS};指标:{METRIC_IDS}\n"
f"预算:timeout_s={querycore.DEFAULT_TIMEOUT_S} 秒、max_queries={querycore.DEFAULT_MAX_QUERIES}\n"
f"冷启动 = 每次先清掉进程内数据源连接池与现查缓存的那一次(不重启进程):代表「连接/现查缓存还没热」;\n"
f" 进程级的一次性开销(模块按需导入、编译)只落在整套基线的**第一次**执行上,是冷启动那一档的 max\n"
f"热执行 = 连着跑(前面那些一次性开销已经付过)\n"
f"每档热执行 {PERF_WARM} 次(要求 ≥100)、冷启动 {PERF_COLD} 次;失败样本不剔除\n"
f"**没有生产负载:这一份推不出生产的 P50/P95**")
report = render(summary, header)
(out_dir / "summary.json").write_text(json.dumps({"header": header, "code_hashes": code, "params": INPUTS,
"metric_ids": METRIC_IDS, "levels": list(PERF_LEVELS),
"warm": PERF_WARM, "cold": PERF_COLD,
"summary": summary}, ensure_ascii=False, indent=2),
encoding="utf-8")
print("\n" + report)
return {"samples": samples, "summary": summary, "report": report, "dir": str(out_dir)}
@pytest.mark.skipif(not os.environ.get(PERF_ENV), reason=f"受控基线要 {PERF_ENV}=1 才跑(默认不跑几百次真跑)")
async def test_the_controlled_baseline(asset, monkeypatch):
"""受控基线:冷启动/热执行分开、并发 1 与 4 两档、原始样本 + P50/P95 + 错误率。失败样本不剔除。"""
out_dir = Path(os.environ.get(PERF_OUT_ENV) or tempfile.mkdtemp(prefix="agentlab-querycore-perf-"))
out_dir.mkdir(parents=True, exist_ok=True)
baseline = await run_baseline(asset, out_dir, monkeypatch)
got = {(row["suite"], row["phase"]) for row in baseline["summary"]}
assert got == {("cold", "cold"), ("concurrency-1", "warm"), ("concurrency-4", "warm")}, got
hot = [row for row in baseline["summary"] if row["phase"] == "warm"]
assert hot, "至少要有热执行那一档"
for row in hot:
assert row["n"] == PERF_WARM, f"{row['suite']} 跑了 {row['n']} 次,应当跑满 {PERF_WARM} 次"
# 默认就是「至少 100 次」;显式把次数调小(冒烟)就按调小的算,报告里写着实际次数
assert row["n"] >= 100 or PERF_N_ENV in os.environ, f"基线要求每档至少 100 次,实际 {row['n']}"
assert row["metrics"]["total_ms"]["p50"] is not None
assert row["metrics"]["total_ms"]["p95"] is not None
# 每次都要把这次真的执行了几条 SQL 记下来(跑得成的那些)
assert row["counts"]["executed_sql_n"] > 0
# 并发 4 那一档要能看到排队:一半样本在等名额
queued = [s for s in baseline["samples"]
if s["suite"] == "concurrency-4" and isinstance(s.get("phases"), dict)
and isinstance(s["phases"].get("queue_ms"), int) and s["phases"]["queue_ms"] > 0]
assert queued, "并发 4(名额 2)那一档必须有样本真的排了队,queue_ms 才谈得上被验证"
assert all(s["counts"]["llm_calls"] == 0 for s in baseline["samples"]
if isinstance(s.get("counts"), dict) and s["counts"].get("llm_calls") is not None)
assert (out_dir / "samples.jsonl").exists() and (out_dir / "summary.json").exists()