feat(benchmark): 阶段2 数据采集 — 真实 run 终态自动采集 SwarmMetrics 并落库(+可选导出)

把采集器接进真实运行,让数据自己累积,为后续【经验标定】O/系数/S_gain 攒真实
用户数据(这些量只能由真实运行事后标定,不能先验写死)。本阶段不编任何公式。

- orchestrator/main.py:run 终态(completed/failed)在终态事件后调 capture_run_metrics。
  只读 run 状态、try/except 包裹绝不失败 run;默认开,BENCHMARK_CAPTURE=0 可关。
- orchestrator/swarm_runtime.py:record_benchmark_metrics → 落 run.metadata['benchmark']。
- benchmark/collectors/capture.py:collect(run_collector) → 持久化 → 可选导出(to_thread)。
- benchmark/export/:MetricsExporter;默认 NoopExporter(无依赖/无凭据);
  BENCHMARK_EXPORT_TARGET=blob 启用 Azure Blob(连接串或 Workload/Managed Identity,
  凭据经环境注入,绝不写进代码),归档 <scenario>/<swarm_id>.json。

诚实:缺项指标 NaN→null,不伪造(规则#9)。

验证:新增 test-benchmark-capture.py + 现有 collector/selfcert + 契约冒烟(runtime/
merge/freeze)全 PASS。

影响范围:agent_swarm。运行时新增**只读**终态钩子(不改派发/执行/契约/计费/审计字段);
导出默认关,无密钥落地。Client/Manager/Agnet/CodeGW/发布链路不涉及。
依赖:benchmark/ 需在镜像内(quality.py 早已 import benchmark;由 #44 Dockerfile 修复覆盖)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fasthei
2026-06-12 17:57:59 +08:00
co-authored by Claude Opus 4.8
parent b5cc68c977
commit 94ef894ace
5 changed files with 251 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
"""阶段2:真实 run 终态 → 自动采集 SwarmMetrics 并落库(+ 可选导出存储账户)。
把「采集器」变成「会自己攒数据的管道」:每次真实蜂群 run 跑到终态时,用
`SwarmRunMetricsCollector` 算出该 run 的真实指标(缺项 NaN,**不编公式、不伪造**),
持久化到 `run.metadata['benchmark']`(Redis),并按 `BENCHMARK_EXPORT_TARGET` 可选导出。
目的:为后续【经验标定】O / BASE_COEFFICIENTS / S_gain 累积**真实用户**数据——这些量
只能由真实运行数据事后标定,无法先验写死(见 docs/benchmark/emergence-evaluation.md §6/§7)。
只读 run 状态,绝不抛进 run 主路径(调用方包 try/except)。
"""
from __future__ import annotations
import asyncio
import logging
import math
from .run_collector import SwarmRunMetricsCollector
from ..export import get_exporter
logger = logging.getLogger(__name__)
def _num(x):
return None if (isinstance(x, float) and math.isnan(x)) else x
def _metrics_dict(metrics) -> dict:
return {k: _num(getattr(metrics, k)) for k in metrics.__dataclass_fields__}
async def capture_run_metrics(run) -> dict:
"""Collect + persist (+ optionally export) one terminal run's SwarmMetrics. Returns the payload."""
from orchestrator.swarm_runtime import swarm_runtime # lazy: avoid import cycle
collector = SwarmRunMetricsCollector(run.swarm_id)
metrics = await collector.collect()
payload = {
"swarm_id": run.swarm_id,
"scenario": (run.metadata or {}).get("scenario"),
"status": run.status,
"metrics": _metrics_dict(metrics),
"coverage": collector.coverage,
}
await swarm_runtime.record_benchmark_metrics(run, payload)
# Export is best-effort and OFF by default (NoopExporter). Run in a thread so a blocking
# blob upload never stalls the event loop; never fails the run.
try:
await asyncio.to_thread(get_exporter().export, run.swarm_id, payload)
except Exception as exc: # pragma: no cover - export is opt-in
logger.warning("benchmark export failed for run %s: %s", run.swarm_id, exc)
return payload
+79
View File
@@ -0,0 +1,79 @@
"""Benchmark metrics 导出器(阶段2 数据采集)。
把每次真实 run 终态采集到的 `SwarmMetrics` 快照导出到外部留存,供后续【经验标定】
(O / BASE_COEFFICIENTS / S_gain)累积数据。**默认 NoopExporter**:不导出、无外部依赖、
不需任何凭据——指标仍持久化在 `run.metadata['benchmark']`(Redis)。
启用 Azure Blob 导出(凭据经环境/连接串注入,**绝不写进代码或日志**):
BENCHMARK_EXPORT_TARGET=blob
BENCHMARK_BLOB_CONTAINER=benchmark-selfcert # 默认 benchmark-selfcert
AZURE_STORAGE_CONNECTION_STRING=... # 方式一:连接串
# 或方式二(无密钥,推荐):账户 URL + Workload Identity / Managed Identity
BENCHMARK_BLOB_ACCOUNT_URL=https://heicode.blob.core.windows.net
归档路径:`<scenario>/<swarm_id>.json`。
"""
from __future__ import annotations
import json
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
class MetricsExporter:
"""导出一次 run 的 benchmark 指标快照。子类实现 export()。"""
def export(self, swarm_id: str, payload: dict) -> None:
raise NotImplementedError
class NoopExporter(MetricsExporter):
"""默认:不导出(指标已落 run.metadata)。无外部依赖、无凭据。"""
def export(self, swarm_id: str, payload: dict) -> None:
logger.debug("benchmark export disabled (BENCHMARK_EXPORT_TARGET=none); "
"run %s metrics persisted to run.metadata only", swarm_id)
class BlobExporter(MetricsExporter):
"""Azure Blob 导出(懒加载 SDK;凭据来自连接串或 Managed/Workload Identity)。"""
def __init__(self, container: str, *, connection_string: Optional[str] = None,
account_url: Optional[str] = None):
if not (connection_string or account_url):
raise RuntimeError("BlobExporter 需 AZURE_STORAGE_CONNECTION_STRING 或 BENCHMARK_BLOB_ACCOUNT_URL")
self.container = container
self.connection_string = connection_string
self.account_url = account_url
def _client(self):
from azure.storage.blob import BlobServiceClient # lazy: keep core import-light & key-free
if self.connection_string:
return BlobServiceClient.from_connection_string(self.connection_string)
from azure.identity import DefaultAzureCredential # Workload/Managed Identity, no secret
return BlobServiceClient(account_url=self.account_url, credential=DefaultAzureCredential())
def export(self, swarm_id: str, payload: dict) -> None:
scenario = (payload.get("scenario") or "unknown")
blob_name = f"{scenario}/{swarm_id}.json"
client = self._client().get_blob_client(container=self.container, blob=blob_name)
client.upload_blob(json.dumps(payload, ensure_ascii=False), overwrite=True)
logger.info("benchmark metrics exported → %s/%s", self.container, blob_name)
def get_exporter() -> MetricsExporter:
"""按 BENCHMARK_EXPORT_TARGET 选择导出器(默认 none → NoopExporter)。"""
target = (os.getenv("BENCHMARK_EXPORT_TARGET") or "none").strip().lower()
if target in ("none", "off", ""):
return NoopExporter()
if target == "blob":
return BlobExporter(
container=os.getenv("BENCHMARK_BLOB_CONTAINER", "benchmark-selfcert"),
connection_string=os.getenv("AZURE_STORAGE_CONNECTION_STRING"),
account_url=os.getenv("BENCHMARK_BLOB_ACCOUNT_URL"),
)
logger.warning("unknown BENCHMARK_EXPORT_TARGET=%s; falling back to noop", target)
return NoopExporter()
+12
View File
@@ -669,6 +669,18 @@ async def refresh_swarm_run_status(run):
await swarm_runtime.emit_event(run, "timeline.updated", payload=timeline_payload)
await maybe_emit_budget_alert(run)
# Benchmark data capture (阶段2): on a terminal run, compute + persist the run's SwarmMetrics
# so real-user data accrues for later EMPIRICAL calibration (O / coefficients / S_gain — these
# cannot be defined a priori, only fitted from real runs). Read-only over run state; never
# fails the run. Default-on; set BENCHMARK_CAPTURE=0 to disable. Export is separately gated
# (BENCHMARK_EXPORT_TARGET, off by default) — see benchmark/export.
if (os.getenv("BENCHMARK_CAPTURE", "on").strip().lower() not in ("0", "off", "false", "no")):
try:
from benchmark.collectors.capture import capture_run_metrics
await capture_run_metrics(run)
except Exception as exc:
logger.warning("benchmark capture failed for run %s: %s", run.swarm_id, exc)
# Lifespan context manager
@asynccontextmanager
+9
View File
@@ -420,6 +420,15 @@ class SwarmRuntime:
run.quality = quality or {}
await self.save_run(run)
async def record_benchmark_metrics(self, run: SwarmRun, payload: Dict[str, Any]) -> None:
"""Persist a terminal run's benchmark SwarmMetrics snapshot (阶段2 数据采集).
Stored on run.metadata['benchmark'] so real-user runs accrue data for later empirical
calibration (O / coefficients). Read-only over run state; never fabricates (NaN → null).
"""
run.metadata["benchmark"] = payload or {}
await self.save_run(run)
async def record_decision(self, run: SwarmRun, decision: Dict[str, Any]) -> None:
"""Append one ACO assignment decision (Group A telemetry). Bounded list."""
run.decisions = (run.decisions or [])[-999:] + [decision]
+98
View File
@@ -0,0 +1,98 @@
"""Test 阶段2 benchmark data capture: terminal run → SwarmMetrics persisted to run.metadata.
Hermetic (REDIS_FAKE), default NoopExporter (no storage dependency). Verifies:
- capture_run_metrics persists run.metadata['benchmark'] with metrics + coverage.
- real metrics are numbers; uncollected ones are null (NaN→null, never fabricated).
- default export is a no-op and does not fail the capture.
Run: python scripts/test-benchmark-capture.py
"""
import asyncio
import json
import os
import sys
from pathlib import Path
os.environ["REDIS_FAKE"] = "1"
os.environ.pop("BENCHMARK_EXPORT_TARGET", None) # ensure default Noop
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from orchestrator.redis_client import redis_client
from orchestrator import swarm_runtime as sr_mod
from orchestrator.swarm_runtime import swarm_runtime
from orchestrator.task_queue import task_queue, TaskStatus
from benchmark.collectors.capture import capture_run_metrics
failures = []
def check(name, cond):
print(("PASS" if cond else "FAIL"), "-", name)
if not cond:
failures.append(name)
async def _noop(self, *a, **k):
return None
async def add_task(run, task_id, *, status, agent, cost=0.0, retry=0, depends_on=None):
t = await task_queue.create_task(task_id=task_id, description=task_id,
agent_role=task_id.split("-")[-1], depends_on=depends_on or [],
enqueue=False)
t.status = status
t.assigned_agent_id = agent
t.retry_count = retry
t.result = json.dumps({"usage": {"model_cost_usd": cost}})
await task_queue._save_task(t)
await swarm_runtime.attach_task(run, t.task_id)
return t
async def main():
await redis_client.connect()
sr_mod.SwarmRuntime._post_callback = _noop
body = {
"mode": "swarm",
"requirement": {"objective": "capture test"},
"orchestration_plan": {"budget": {"max_cost_usd": 10}},
"callback": {"url": "http://127.0.0.1:9/cb", "subscribed_events": []},
"metadata": {"manager_deployment_id": "m-cap", "scenario": "coding"},
}
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="c")
await add_task(run, "t-implementation", status=TaskStatus.COMPLETED, agent="A", cost=2.0)
await add_task(run, "t-testing", status=TaskStatus.COMPLETED, agent="B", cost=3.0)
payload = await capture_run_metrics(run)
# persisted onto the run
reloaded = await swarm_runtime.get_run(run.swarm_id)
bench = (reloaded.metadata or {}).get("benchmark")
check("run.metadata['benchmark'] persisted", isinstance(bench, dict))
check("payload scenario carried", bench.get("scenario") == "coding")
check("metrics dict present", isinstance(bench.get("metrics"), dict))
check("coverage dict present", isinstance(bench.get("coverage"), dict))
# s_completion real (2/2*100 = 100), serialized as a number
check("s_completion real (100)", bench["metrics"].get("s_completion") == 100.0
and bench["coverage"].get("s_completion") is True)
# uncollected metric (gain) → null, coverage False (no fabrication)
check("s_gain null + coverage False", bench["metrics"].get("s_gain") is None
and bench["coverage"].get("s_gain") is False)
# JSON-serializable (would raise on NaN-as-float being non-serializable only with allow_nan=False;
# we converted NaN→None, so strict dumps must succeed)
try:
json.dumps(payload, allow_nan=False)
check("payload strictly JSON-serializable (no NaN)", True)
except ValueError:
check("payload strictly JSON-serializable (no NaN)", False)
print()
if failures:
print(f"{len(failures)} capture check(s) FAILED: {failures}")
sys.exit(1)
print("all benchmark capture checks passed")
if __name__ == "__main__":
asyncio.run(main())