Files
iomgaa c0d9d7b66e docs: 回写 CHANGELOG、GovDoc 迁移,以及压测那条过期注释
CHANGELOG 落在「未发布」段,按那一段自己的规矩不提前写版本号。写清了四条会抛 ValueError 的
情形与迁移写法,因为这是一次破坏性变更。

migrations/govdoc-saas.md 加一节讲租户命名空间怎么传,含迁完算不算数的四条判据,最终判据是
「同一段文本由两个租户各提交一次,各自拿到自己的那份输出」。参数一律指向 0017 不复述。这份
文档原来说 GovDoc 侧「还没有可迁移的东西」,现在有了第一条能逐条验的接入动作,文件头那句
状态说明跟着补了一句例外。

tools/soak/run_soak.py 那份空绑定上方的注释在复述旧转发规则,顺手改对。它给的理由本来就不准
——让绑定留空的真正原因是绑定的全部键值都进参数快照,每批都不同的值会让故障注入那一步续跑时
报一次假的参数漂移,和转发哪些键无关。三份压测绑定常量里都没有裸键,所以这次变更打不到压测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:20 -04:00

1021 lines
40 KiB
Python
Raw Permalink 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.
"""压测入口:把 AppWorld 与 GovDoc 两个场景跑成一批真实负载,并留下记分板要读的四个文件。
跑法(在仓库根目录)::
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \\
python -m tools.soak.run_soak --scenario both --budget-calls 400 --concurrency 4 \\
--runs-dir soak-out/runs --report soak-out/soak.md
用 `-m` 而不是直接给文件路径:直接跑文件时 `tools` 不在 `sys.path` 上,
`from tools.soak.appworld import ...` 会 ImportError。
每次运行落四个文件,文件名与字段由 `tools/soak/scoreboard.py` 的模块 docstring 定义,这里
只负责写全。三个后缀常量直接从记分板 import,不在这边写第二份。
**预算与并发上限都没有默认值。** 一个能跑飞的压测入口迟早会跑飞:模型调用要花真钱,容器要
占别人也在用的机器,而这两个数字是唯一能拦住它的东西。缺了直接拒跑,不猜。
**`--dry-run` 是全量之前的必经一步。** 它不打模型,但把每个任务的 `RunRequest` 真的装配
一遍——容器起不起得来、语料读不读得进、脱敏闸过不过得了、提示词模板渲不渲染得出来,这几件
事全都在装配这一步暴露,而在全量里暴露的代价是已经花掉的那部分调用费。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
import time
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from polyloop.serialization import encode
from polyloop.session import AgentDefinition
from polyloop.session import run as session_run
from polyloop.stores import JsonlRunStore
from tools.soak.appworld import AppWorldPool
from tools.soak.scenarios import appworld as appworld_scenario
from tools.soak.scenarios import govdoc as govdoc_scenario
from tools.soak.scoreboard import EVENTS_SUFFIX, META_SUFFIX, RESULT_SUFFIX
if TYPE_CHECKING:
from polyloop.ports import Event, ModelCall
from polyloop.session import RunRequest
from polyloop.types import ModelReply, RunResult
#: 场景名。它原样进 `.meta.json` 的 `scenario`,记分板按它分组统计。
APPWORLD = "appworld"
GOVDOC = "govdoc"
#: 传给每次模型调用的项目侧标识。**空的**:绑定的全部键值都进参数快照,而每批都不同的值会让
#: 故障注入那一步续跑时的逐字段比对报一次假的参数漂移。这里跟转发无关——网关适配器只转发带
#: `gateway.` 前缀的键,而这份一个都没有。
MODEL_BINDING: Mapping[str, str] = {}
#: GovDoc 的工作区落在 runs 目录下的这个子目录里。记分板枚举 run 用的是
#: `runs_dir.glob("*.jsonl")`,那不是递归的,所以子目录里的东西它看不见。
WORKSPACES_DIRNAME = "workspaces"
_LOG = logging.getLogger("polyloop.soak")
def _say(message: str) -> None:
"""往 stdout 打一行。压测是长跑,缓冲住的输出等于没有输出。"""
print(message, flush=True)
# ---------------------------------------------------------------------------
# 一、预算护栏
# ---------------------------------------------------------------------------
class BudgetGuard:
"""包住一个 `ModelClient`,数每次调用;达到上限之后不再派发新任务。
**计数在真正发起调用之前加。** 数的是「尝试」而不是「成功」:一次失败的调用同样占了时间、
可能也已经在网关那边计了费,按成功数会让上限形同虚设。
**达到上限不砍断在跑的运行。** 半路砍断会制造一批没有结束记录的日志,而那和进程崩溃留下
的日志长得一模一样——故障注入那一步正是靠「有没有结束记录」判定的,混进来的话两边分不开。
`parameters()` 原样转发内层客户端的返回。它进参数快照,包一层不该改变快照的内容,否则
续跑时逐字段比对会报一次假漂移。
"""
def __init__(self, *, inner: object, limit: int) -> None:
"""Args:
inner: 真正打模型的客户端,满足 `polyloop.ports.ModelClient`。
limit: 整批允许的模型调用次数上限,必须 ≥ 1。
"""
if limit < 1:
raise ValueError(f"模型调用预算必须 ≥ 1,收到 {limit}")
self._inner = inner
self._limit = limit
#: 并发安全靠这把锁。asyncio 单线程下 `+= 1` 本身不会被打断,但「读计数、判上限、
#: 写计数」这三步之间有 await 点时就会,所以计数的更新整体放进锁里。
self._lock = asyncio.Lock()
self._total = 0
self._per_run: dict[str, int] = {}
@property
def limit(self) -> int:
return self._limit
@property
def total(self) -> int:
"""整批已经发起的模型调用次数。"""
return self._total
@property
def exhausted(self) -> bool:
return self._total >= self._limit
def calls_for(self, run_id: str) -> int:
"""某一次运行发起了几次模型调用。它进 `.meta.json` 的 `model_calls`。"""
return self._per_run.get(run_id, 0)
def parameters(self) -> Mapping[str, str]:
return self._inner.parameters() # type: ignore[attr-defined]
async def call(self, call: ModelCall) -> ModelReply:
async with self._lock:
self._total += 1
self._per_run[call.run_id] = self._per_run.get(call.run_id, 0) + 1
return await self._inner.call(call) # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# 二、事件出口
# ---------------------------------------------------------------------------
class JsonlEventSink:
"""把事件逐行写进 `<run_id>.events.jsonl`,并自己数投递失败次数。
**自己数是有意义的**:库也数一份(`RunResult.event_delivery_failures`),记分板比的就是
这两份对不对得上。两份都由库来数的话,这条不变量验的是库和它自己一致。
一次运行一个实例、一个文件,所以不需要跨运行的锁。
"""
__slots__ = ("_failures", "_path")
def __init__(self, *, path: Path) -> None:
self._path = Path(path)
self._failures = 0
@property
def failures(self) -> int:
return self._failures
def parameters(self) -> Mapping[str, str]:
return {"kind": "jsonl_events"}
async def emit(self, event: Event) -> None:
"""写一行。失败先记账再原样抛出——库会接住它并计数,两边的数才对得上。
`CancelledError` 继承 `BaseException`,下面那个 `except` 接不到它(CLAUDE.md §1.6)。
"""
line = json.dumps(
{
"kind": event.kind.value,
"run_id": event.run_id,
"step_idx": event.step.step_idx,
},
ensure_ascii=False,
)
try:
with self._path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
except Exception:
self._failures += 1
raise
# ---------------------------------------------------------------------------
# 三、一次运行:跑完并落四个文件
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class RunOutcome:
"""一次运行在报告里的那一行。产物的权威是磁盘上那四个文件,这个只进报告。"""
run_id: str
scenario: str
task_id: str
phase: str | None
stop_reason: str | None
steps: int
model_calls: int
wall_ms: int
error: str | None = None
#: 收尾取证:返回 `(env_executions, success)`。两样都由环境侧给,取不到就是 `None`。
Collector = Callable[[], Awaitable[tuple[int | None, bool | None]]]
async def execute_run(
*,
runs_dir: Path,
request: RunRequest,
model_client: object,
decision_parser: object,
synthetic_observations: object,
scenario: str,
task_id: str,
phase: str | None,
count_model_calls: Callable[[], int],
collect: Collector | None = None,
fault: str | None = None,
resumed_from_step: int = 0,
) -> RunOutcome:
"""跑一次运行,并把记分板要的四个文件写全。
库自己写 `<run_id>.jsonl`,另外三个由这里写。**`.meta.json` 无论跑成什么样都会写**:
运行抛异常时它记下的是「这次跑到哪儿、花了多少次调用」,而缺文件在记分板那边只会变成
一条「无法判定」,等于什么都没记下来。
Args:
runs_dir: 四个文件落在哪儿。
request: 已经装配好的运行请求,`run_id` 从它取。
model_client: 满足 `polyloop.ports.ModelClient` 的客户端,通常是 `BudgetGuard`。
decision_parser: 场景自己的决策解释器。
synthetic_observations: 场景自己的三段合成观察。
scenario: 场景名,原样进 `.meta.json`。
task_id: 任务标识,原样进 `.meta.json`。
phase: 阶段名,没有阶段的场景填 `None`。
count_model_calls: 跑完之后问一次「这次运行发起了几次模型调用」。
collect: 跑完之后的收尾取证。AppWorld 那一路必须在会话上下文退出之前调用,所以它是
个回调而不是返回值——调用时机由这里定,取什么由场景定。
fault: 注入了哪个故障。正常负载填 `None`。
resumed_from_step: 从第几步续跑。正常负载填 0。
"""
runs_dir = Path(runs_dir)
runs_dir.mkdir(parents=True, exist_ok=True)
run_id = request.run_id
sink = JsonlEventSink(path=runs_dir / f"{run_id}{EVENTS_SUFFIX}")
definition = AgentDefinition(
model_client=model_client, # type: ignore[arg-type]
decision_parser=decision_parser, # type: ignore[arg-type]
store=JsonlRunStore(directory=runs_dir),
event_sink=sink,
synthetic_observations=synthetic_observations, # type: ignore[arg-type]
)
started = time.monotonic()
result: RunResult | None = None
error: str | None = None
try:
result = await session_run(definition, request)
except Exception as exc:
# 一次运行炸掉不该带走整批。它进报告、进 `.meta.json`,然后接着跑下一个任务。
# `CancelledError` 不在这里(它是 BaseException),Ctrl-C 照样穿得过去。
error = f"{type(exc).__name__}: {exc}"
wall_ms = int((time.monotonic() - started) * 1000)
env_executions: int | None = None
success: bool | None = None
if collect is not None:
try:
env_executions, success = await collect()
except Exception as exc:
note = f"收尾取证失败 {type(exc).__name__}: {exc}"
error = note if error is None else f"{error}{note}"
if result is not None:
(runs_dir / f"{run_id}{RESULT_SUFFIX}").write_text(
json.dumps(encode(result), ensure_ascii=False),
encoding="utf-8",
)
meta = {
"scenario": scenario,
"task_id": task_id,
"phase": phase,
"wall_ms": wall_ms,
"model_calls": count_model_calls(),
"sink_failures": sink.failures,
"env_executions": env_executions,
"fault": fault,
"success": success,
"resumed_from_step": resumed_from_step,
}
(runs_dir / f"{run_id}{META_SUFFIX}").write_text(
json.dumps(meta, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return RunOutcome(
run_id=run_id,
scenario=scenario,
task_id=task_id,
phase=phase,
stop_reason=result.stop_reason.value if result is not None else None,
steps=len(result.steps) if result is not None else 0,
model_calls=meta["model_calls"], # type: ignore[arg-type]
wall_ms=wall_ms,
error=error,
)
# ---------------------------------------------------------------------------
# 四、两个场景各自的任务运行器
# ---------------------------------------------------------------------------
def appworld_run_id(task_id: str) -> str:
"""`appworld-<题目 ID>`。必须匹配 `[A-Za-z0-9._-]+``JsonlRunStore` 的约束)。"""
return f"appworld-{task_id}"
async def run_appworld_task(
*,
pool: AppWorldPool,
task_id: str,
app_descriptions: str,
runs_dir: Path,
guard: BudgetGuard,
) -> list[RunOutcome]:
"""跑一道 AppWorld 题:租一个容器、装配、跑完、评分。
**`evaluate()` 在会话上下文退出之前调**:退出之后环境状态就销毁了,那时再问分数只会拿到
一个异常。所以它被放进 `collect` 回调里,由 `execute_run` 在 `run` 返回之后立刻调用,而
整段都还在 `async with pool.session(...)` 里面。
"""
run_id = appworld_run_id(task_id)
async with pool.session(task_id) as session:
request = appworld_scenario.build_run_request(
run_id=run_id,
session=session,
app_descriptions=app_descriptions,
model_binding=MODEL_BINDING,
)
executor = request.action_executor
async def collect() -> tuple[int | None, bool | None]:
# 执行次数取自环境自己的计数器,不是本地计数器——见 `AppWorldExecutor` 那条注释。
executions = (
executor.env_executions
if isinstance(executor, appworld_scenario.AppWorldExecutor)
else None
)
score = await session.evaluate()
return executions, score.success
outcome = await execute_run(
runs_dir=runs_dir,
request=request,
model_client=guard,
decision_parser=appworld_scenario.AppWorldParser(),
synthetic_observations=appworld_scenario.build_synthetic_observations(),
scenario=APPWORLD,
task_id=task_id,
phase=None,
count_model_calls=lambda: guard.calls_for(run_id),
collect=collect,
)
return [outcome]
async def run_govdoc_task(
*,
task: govdoc_scenario.AuditTask,
runs_dir: Path,
workspace_root: Path,
guard: BudgetGuard,
phases: Sequence[str] = govdoc_scenario.PHASES,
) -> list[RunOutcome]:
"""跑一个 GovDoc 任务:同一条审核点上依次跑三个阶段。
**三个阶段必须串行。** execute 要读 plan 写下的 `plan.md`summarize 要读 execute 写下的
`evidence.md`;并发跑的话后一阶段读到的是一个还不存在的文件,而表现是模型「找不到计划」
然后自己瞎编一个——那不是压测想看的形态。任务之间才是并发的,由派发器管。
"""
workspace = Path(workspace_root) / f"govdoc-{task.index}"
workspace.mkdir(parents=True, exist_ok=True)
outcomes: list[RunOutcome] = []
for phase in phases:
run_id = govdoc_scenario.make_run_id(task_index=task.index, phase=phase)
# 审计账是三个阶段共用的,所以这一次运行的执行次数是它的增量,不是总行数。
before = len(govdoc_scenario.read_audit_lines(workspace))
request = govdoc_scenario.build_run_request(
task=task,
phase=phase,
run_id=run_id,
workspace=workspace,
model_binding=MODEL_BINDING,
)
async def collect(baseline: int = before) -> tuple[int | None, bool | None]:
# GovDoc 没有程序化判分,`success` 恒为 None——填一个我们自己算的分数,会让记分板
# 的成功率变成「我们和我们自己一致」。
return len(govdoc_scenario.read_audit_lines(workspace)) - baseline, None
outcomes.append(
await execute_run(
runs_dir=runs_dir,
request=request,
model_client=guard,
decision_parser=govdoc_scenario.GovDocParser(),
synthetic_observations=govdoc_scenario.SYNTHETIC_OBSERVATIONS,
scenario=GOVDOC,
task_id=task.checkpoint.checkpoint_id,
phase=phase,
count_model_calls=lambda run_id=run_id: guard.calls_for(run_id),
collect=collect,
)
)
return outcomes
# ---------------------------------------------------------------------------
# 五、派发:并发上限与预算护栏
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class Unit:
"""一个派发单位。AppWorld 一道题是一个单位(一次运行),GovDoc 一个任务是一个单位
(三次运行)。并发上限数的是单位,预算护栏也按单位为粒度决定派不派。"""
label: str
scenario: str
run: Callable[[], Awaitable[Sequence[RunOutcome]]]
@dataclass(frozen=True, slots=True, kw_only=True)
class BatchReport:
"""一批跑完之后的全部事实。"""
outcomes: tuple[RunOutcome, ...] = ()
#: 单位级的失败:`(单位名, 错误)`。运行级的失败记在 `RunOutcome.error` 上。
failures: tuple[tuple[str, str], ...] = ()
total_units: int = 0
dispatched: int = 0
#: 因为预算耗尽而没有派出去的第一个单位的序号(从 1 数)。没停就是 `None`。
stopped_at: int | None = None
async def dispatch(
units: Sequence[Unit],
*,
concurrency: int,
guard: BudgetGuard | None = None,
) -> BatchReport:
"""按并发上限派发,按预算护栏决定还派不派。
**信号量在派发之前拿。** 拿到槽位之后才判预算,判的是「此刻」的调用数——先建好全部任务
再让它们自己抢槽位的话,预算判定会在一瞬间全部通过,护栏等于不存在。
**单个单位抛异常不打断整批**:记进 `failures`,接着派下一个。`CancelledError` 是
`BaseException`,下面的 `except Exception` 接不到它;外层那个 `except BaseException` 只做
一件事——把还在跑的任务收干净,然后原样抛出去,Ctrl-C 因此穿得过整个编排(CLAUDE.md §1.6)。
"""
if concurrency < 1:
raise ValueError(f"并发上限必须 ≥ 1,收到 {concurrency}")
semaphore = asyncio.Semaphore(concurrency)
outcomes: list[RunOutcome] = []
failures: list[tuple[str, str]] = []
tasks: list[asyncio.Task[None]] = []
stopped_at: int | None = None
async def worker(unit: Unit) -> None:
try:
produced = await unit.run()
outcomes.extend(produced)
for item in produced:
mark = "失败" if item.error else item.stop_reason or "无结果"
_say(f" ✓ {item.run_id} {mark} {item.steps}{item.wall_ms} ms")
except Exception as exc:
failures.append((unit.label, f"{type(exc).__name__}: {exc}"))
_say(f" ✗ {unit.label} {type(exc).__name__}: {exc}")
finally:
semaphore.release()
try:
for index, unit in enumerate(units, start=1):
await semaphore.acquire()
if guard is not None and guard.exhausted:
semaphore.release()
stopped_at = index
_say(
f"预算用完(已发起 {guard.total} 次模型调用,上限 {guard.limit}),"
f"停在第 {index} 个任务;已经在跑的会跑完"
)
break
_say(f"派发 {index}/{len(units)}{unit.label}")
tasks.append(asyncio.create_task(worker(unit), name=f"soak-{unit.label}"))
if tasks:
await asyncio.gather(*tasks)
except BaseException:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
return BatchReport(
outcomes=tuple(sorted(outcomes, key=lambda item: item.run_id)),
failures=tuple(failures),
total_units=len(units),
dispatched=len(tasks),
stopped_at=stopped_at,
)
# ---------------------------------------------------------------------------
# 六、任务集
# ---------------------------------------------------------------------------
def select_appworld_tasks(
pool: AppWorldPool, *, splits: Sequence[str], limit: int | None
) -> list[str]:
"""按给定顺序依次取每个划分的题目,跨划分去重,取到 `limit` 个为止。
去重是必要的:`train` 与 `dev` 各自的题目 ID 不重叠,但划分列表是人给的,同一个划分给
两次的话没有去重就会把同一道题跑两遍——而两次的 `run_id` 一样,第二次会撞上「这个运行
标识已经有日志了」直接失败。
"""
ordered: list[str] = []
seen: set[str] = set()
for split in splits:
for task_id in pool.list_task_ids(split):
if task_id in seen:
continue
seen.add(task_id)
ordered.append(task_id)
if limit is not None and len(ordered) >= limit:
return ordered
return ordered
def load_govdoc_tasks(
*, db_path: Path, corpus_dir: Path, count: int
) -> tuple[tuple[govdoc_scenario.AuditTask, ...], tuple[govdoc_scenario.RedactionResult, ...]]:
"""读前 `count` 条审核点、读语料、脱敏、过闸,装成任务列表。
**不走 `govdoc.build_audit_tasks`**:那个函数只收一个 `data_root`,从它推出库文件与语料
目录两个位置,而这个入口的 `--govdoc-db` 与 `--govdoc-corpus` 是两条独立的路径,表达不了。
做的事情逐字相同,脱敏与过闸都由这里调的那两个函数完成。
"""
redactor = govdoc_scenario.Redactor()
checkpoints = govdoc_scenario.load_checkpoints(db_path=Path(db_path), limit=count)
loaded = govdoc_scenario.load_documents(prepared_dir=Path(corpus_dir), redactor=redactor)
documents = tuple(document for document, _ in loaded)
reports = tuple(report for _, report in loaded)
tasks = tuple(
govdoc_scenario.AuditTask(index=index, checkpoint=checkpoint, documents=documents)
for index, checkpoint in enumerate(checkpoints)
)
return tasks, reports
def interleave(groups: Sequence[Sequence[Unit]]) -> list[Unit]:
"""把几个场景的单位轮流排开。
**不是简单拼接。** 拼接的话整批共用的模型调用预算会被排在前面的场景吃光,排在后面的那个
一个任务都跑不到,而报告看起来只是「因预算停在第 N 个任务」——一次只压了一半的跑,长得
像一次正常的跑。
"""
merged: list[Unit] = []
for row in range(max((len(group) for group in groups), default=0)):
for group in groups:
if row < len(group):
merged.append(group[row])
return merged
# ---------------------------------------------------------------------------
# 七、干跑
# ---------------------------------------------------------------------------
async def dry_run_appworld(args: argparse.Namespace) -> list[str]:
"""起容器、取 app 清单、逐题装配一次 `RunRequest`。装配失败原样抛出去。"""
notes: list[str] = []
pool = AppWorldPool(
data_root=args.appworld_data_root,
size=args.containers,
port_base=args.port_base,
)
task_ids = select_appworld_tasks(pool, splits=args.split, limit=args.limit)
_say(f"AppWorld:划分 {list(args.split)} 去重后取 {len(task_ids)} 道题")
if not task_ids:
raise SystemExit("AppWorld 的题目集是空的,检查 --split 与 --limit")
notes.append(f"AppWorld 任务 {len(task_ids)} 道,容器 {args.containers} 个")
async with pool:
descriptions = await appworld_scenario.load_app_descriptions(pool, task_id=task_ids[0])
notes.append(f"app 清单 {len(descriptions)} 字符")
_say(f"AppWorldapp 清单 {len(descriptions)} 字符")
semaphore = asyncio.Semaphore(args.concurrency)
async def assemble(task_id: str) -> None:
async with semaphore, pool.session(task_id) as session:
request = appworld_scenario.build_run_request(
run_id=appworld_run_id(task_id),
session=session,
app_descriptions=descriptions,
model_binding=MODEL_BINDING,
)
chars = sum(
len(block.text)
for message in (*request.context.run_level, *request.context.goal_level)
for block in message.content
)
_say(f" ✓ {request.run_id} 上下文 {chars} 字符")
await asyncio.gather(*(assemble(task_id) for task_id in task_ids))
return notes
def dry_run_govdoc(args: argparse.Namespace) -> list[str]:
"""读语料、脱敏、过闸,逐任务逐阶段装配一次 `RunRequest`。"""
if args.limit is None:
raise SystemExit("GovDoc 场景必须给 --limit:审核点库有多少条不该由压测入口替人决定")
tasks, reports = load_govdoc_tasks(
db_path=args.govdoc_db, corpus_dir=args.govdoc_corpus, count=args.limit
)
notes = [f"GovDoc 任务 {len(tasks)}× {len(govdoc_scenario.PHASES)} 阶段"]
for report in reports:
summary = "、".join(f"{name} {count}" for name, count in sorted(report.counts.items()))
notes.append(f"脱敏替换:{summary or '无'}")
_say(f"GovDoc:脱敏替换 {summary or '无'}")
workspace_root = Path(args.runs_dir) / WORKSPACES_DIRNAME
for task in tasks:
for phase in govdoc_scenario.PHASES:
request = govdoc_scenario.build_run_request(
task=task,
phase=phase,
run_id=govdoc_scenario.make_run_id(task_index=task.index, phase=phase),
workspace=workspace_root / f"govdoc-{task.index}",
model_binding=MODEL_BINDING,
)
_say(f" ✓ {request.run_id} 工具 {list(request.tools.names())}")
return notes
async def dry_run(args: argparse.Namespace) -> int:
"""干跑:列任务集、装配全部 `RunRequest`,一次模型都不打。
**不建网关客户端。** 干跑要能在一台没配 `.env` 的机器上跑起来——它验的是容器、语料、
脱敏闸与提示词装配,网关凭据不在这几件事里。凭据的问题会在全量的第一次调用上暴露。
"""
_say("=== 干跑:不打模型 ===")
notes: list[str] = []
if args.scenario in (APPWORLD, "both"):
notes.extend(await dry_run_appworld(args))
if args.scenario in (GOVDOC, "both"):
notes.extend(dry_run_govdoc(args))
_say("\n=== 干跑通过 ===")
for note in notes:
_say(f" {note}")
_say("网关客户端没有装配(干跑不打模型),凭据要到全量的第一次调用才会被验证。")
if args.report is not None:
body = "# 压测干跑\n\n" + "\n".join(f"- {note}" for note in notes) + "\n"
_write_report(Path(args.report), body)
return 0
# ---------------------------------------------------------------------------
# 八、全量
# ---------------------------------------------------------------------------
def build_model_client() -> tuple[object, Callable[[], Awaitable[None]]]:
"""装配一个连着真实网关的模型客户端,并交回关它的办法。
**在函数里 import 网关**`polyloop.adapters` 要 `polyloop[gateway]`,而干跑与这个模块的
测试都用不着它。放在模块顶层的话,没装网关的机器上连 `--help` 都跑不起来。
"""
from polygateway import GatewayClient, GatewaySettings
from polyloop.adapters import GatewayModelClient
client = GatewayClient.from_env()
async def close() -> None:
await client.aclose()
return GatewayModelClient(client=client, settings=GatewaySettings.from_env()), close
async def full_run(args: argparse.Namespace) -> int:
"""跑正常负载。"""
runs_dir = Path(args.runs_dir)
runs_dir.mkdir(parents=True, exist_ok=True)
workspace_root = runs_dir / WORKSPACES_DIRNAME
model_client, close_client = build_model_client()
guard = BudgetGuard(inner=model_client, limit=args.budget_calls)
started = time.monotonic()
pool: AppWorldPool | None = None
groups: list[list[Unit]] = []
notes: list[str] = []
try:
if args.scenario in (APPWORLD, "both"):
candidate = AppWorldPool(
data_root=args.appworld_data_root,
size=args.containers,
port_base=args.port_base,
)
task_ids = select_appworld_tasks(candidate, splits=args.split, limit=args.limit)
if not task_ids:
raise SystemExit("AppWorld 的题目集是空的,检查 --split 与 --limit")
# `start()` 自己保证「要么全起要么全拆」,所以只有起成功之后才把池交给 finally
# 去停——没起过的池不需要停,而对它调 stop() 会打出一串看起来像故障的清理日志。
await candidate.start()
pool = candidate
descriptions = await appworld_scenario.load_app_descriptions(pool, task_id=task_ids[0])
notes.append(f"AppWorld{len(task_ids)} 道题,容器 {args.containers} 个")
groups.append(
[
Unit(
label=f"appworld/{task_id}",
scenario=APPWORLD,
run=_appworld_unit(
pool=pool,
task_id=task_id,
app_descriptions=descriptions,
runs_dir=runs_dir,
guard=guard,
),
)
for task_id in task_ids
]
)
if args.scenario in (GOVDOC, "both"):
if args.limit is None:
raise SystemExit("GovDoc 场景必须给 --limit:一个任务是三次运行,预算要按它算")
tasks, reports = load_govdoc_tasks(
db_path=args.govdoc_db, corpus_dir=args.govdoc_corpus, count=args.limit
)
for report in reports:
summary = "、".join(
f"{name} {count}" for name, count in sorted(report.counts.items())
)
notes.append(f"GovDoc 脱敏替换:{summary or '无'}")
notes.append(
f"GovDoc{len(tasks)} 个任务 × {len(govdoc_scenario.PHASES)} 阶段 = "
f"{len(tasks) * len(govdoc_scenario.PHASES)} 次运行"
)
groups.append(
[
Unit(
label=f"govdoc/{task.index}",
scenario=GOVDOC,
run=_govdoc_unit(
task=task,
runs_dir=runs_dir,
workspace_root=workspace_root,
guard=guard,
),
)
for task in tasks
]
)
units = interleave(groups)
_say(f"=== 正常负载:{len(units)} 个任务,并发 {args.concurrency},预算 {guard.limit} ===")
report = await dispatch(units, concurrency=args.concurrency, guard=guard)
finally:
if pool is not None:
await pool.stop()
await close_client()
body = render_report(
report,
guard=guard,
notes=notes,
runs_dir=runs_dir,
wall_ms=int((time.monotonic() - started) * 1000),
)
_say("\n" + body)
if args.report is not None:
_write_report(Path(args.report), body)
# **退出码只看任务级失败,不看运行级失败。** 一次以 `llm_error` 或 `step_budget` 结束的
# 运行是压测的正常产出,判它是好是坏是记分板的事;任务级失败则是异常逃出了整次运行
# (容器租不到、请求装配不出来),那说明这套 harness 本身有问题,值得让 soak.sh 停下来。
return 1 if report.failures else 0
def _appworld_unit(
*,
pool: AppWorldPool,
task_id: str,
app_descriptions: str,
runs_dir: Path,
guard: BudgetGuard,
) -> Callable[[], Awaitable[Sequence[RunOutcome]]]:
async def start() -> Sequence[RunOutcome]:
return await run_appworld_task(
pool=pool,
task_id=task_id,
app_descriptions=app_descriptions,
runs_dir=runs_dir,
guard=guard,
)
return start
def _govdoc_unit(
*,
task: govdoc_scenario.AuditTask,
runs_dir: Path,
workspace_root: Path,
guard: BudgetGuard,
) -> Callable[[], Awaitable[Sequence[RunOutcome]]]:
async def start() -> Sequence[RunOutcome]:
return await run_govdoc_task(
task=task,
runs_dir=runs_dir,
workspace_root=workspace_root,
guard=guard,
)
return start
# ---------------------------------------------------------------------------
# 九、报告
# ---------------------------------------------------------------------------
def _cell(text: str) -> str:
"""markdown 表格单元格:竖线要转义,换行要压掉,否则整张表塌了。异常消息里两样都有。"""
return text.replace("|", "\\|").replace("\n", " ")
def _write_report(path: Path, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
_say(f"报告写到了 {path}")
def render_report(
report: BatchReport,
*,
guard: BudgetGuard,
notes: Iterable[str],
runs_dir: Path,
wall_ms: int,
) -> str:
"""渲染一份 markdown 报告。判定不在这里——那是记分板的事,这里只报事实。"""
lines = ["# 压测正常负载", ""]
lines.append(f"产物目录:`{runs_dir}`")
lines.append("")
lines.append(f"- 任务:派发 {report.dispatched} / 共 {report.total_units}")
lines.append(f"- 运行:{len(report.outcomes)} 次")
lines.append(f"- 模型调用:{guard.total} / 预算 {guard.limit}")
lines.append(f"- 墙钟:{wall_ms} ms")
for note in notes:
lines.append(f"- {note}")
if report.stopped_at is not None:
lines.append(
f"- **因预算停在第 {report.stopped_at} 个任务**"
f"它和它后面的 {report.total_units - report.stopped_at + 1} 个任务没有派发,"
"已经在跑的都跑完了"
)
else:
lines.append("- 预算没有用完,全部任务都派发了")
lines.extend(
[
"",
"## 逐次运行",
"",
"| run_id | 场景 | 阶段 | 停止原因 | 步数 | 调用 | 墙钟 ms | 错误 |",
]
)
lines.append("|---|---|---|---|---:|---:|---:|---|")
for item in report.outcomes:
lines.append(
f"| `{item.run_id}` | {item.scenario} | {item.phase or '—'} | "
f"{item.stop_reason or '—'} | {item.steps} | {item.model_calls} | "
f"{item.wall_ms} | {_cell(item.error) if item.error else '—'} |"
)
lines.extend(["", "## 任务级失败", ""])
if report.failures:
for label, error in report.failures:
lines.append(f"- `{label}`{error}")
else:
lines.append("没有。")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# 十、命令行
# ---------------------------------------------------------------------------
def _positive(text: str) -> int:
value = int(text)
if value < 1:
raise argparse.ArgumentTypeError(f"必须 ≥ 1,收到 {value}")
return value
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="run_soak",
description="跑一批正常负载,并留下记分板要读的四个文件。",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"GovDoc 的 --limit 数的是**任务**,不是运行:一个任务 = 一条审核点 + 一份文书,"
"跑 plan / execute / summarize 三个阶段,也就是三次运行。--limit 20 是 60 次运行,"
"预算要按 60 次运行去估,不是 20 次。\n\n"
"AppWorld 的 --limit 数的是题目,一道题就是一次运行。\n\n"
"全量的形状:--split train --split dev --limit 100"
),
)
parser.add_argument(
"--scenario",
required=True,
choices=[APPWORLD, GOVDOC, "both"],
help="压哪个场景。both 时两个场景的任务轮流排开,共用同一份模型调用预算",
)
parser.add_argument(
"--budget-calls",
required=True,
type=_positive,
help=(
"整批允许的模型调用次数上限。**没有默认值**:一个能跑飞的压测入口迟早会跑飞。"
"达到上限就不再派发新任务,已经在跑的会跑完,所以实际调用数会略微超出"
),
)
parser.add_argument(
"--concurrency",
required=True,
type=_positive,
help="同时在跑的任务数上限。**没有默认值**,缺了拒跑",
)
parser.add_argument(
"--containers", type=_positive, default=4, help="AppWorld 的容器数(默认 4"
)
parser.add_argument(
"--split",
action="append",
default=None,
metavar="NAME",
help="AppWorld 的数据划分,可给多次;按给的顺序依次取,跨划分去重(默认 train)",
)
parser.add_argument(
"--limit",
type=_positive,
default=None,
help="每个场景最多跑几个任务。AppWorld 不给就是整个划分;GovDoc 必须给",
)
parser.add_argument("--runs-dir", required=True, type=Path, help="四个产物文件落在哪儿")
parser.add_argument("--report", type=Path, default=None, help="报告写到哪儿,不给就只打屏")
parser.add_argument(
"--dry-run",
action="store_true",
help=(
"不打模型:列出任务集、把每个任务的 RunRequest 真的装配一遍,"
"确认容器起得来、语料读得进、脱敏闸过得了,然后退出。全量之前的必经一步"
),
)
parser.add_argument(
"--appworld-data-root",
type=Path,
default=None,
help="AppWorld 数据根目录,下面应有 data/datasets 与 data/tasks(跑 AppWorld 时必填)",
)
parser.add_argument(
"--port-base",
type=int,
default=8200,
help="AppWorld 容器池的起始宿主端口(默认 8200,避开 dissect 的 8100",
)
parser.add_argument(
"--govdoc-db",
type=Path,
default=govdoc_scenario.DEFAULT_DATA_ROOT / "app.sqlite",
help="GovDoc 的审核点 sqlite(只读打开)",
)
parser.add_argument(
"--govdoc-corpus",
type=Path,
default=govdoc_scenario.DEFAULT_DATA_ROOT / "storage" / "prepared",
help="GovDoc 的语料目录",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.split is None:
args.split = ["train"]
if args.scenario in (APPWORLD, "both") and args.appworld_data_root is None:
parser.error("--scenario 含 appworld 时必须给 --appworld-data-root")
# 容器池的告警走 logging,默认级别是 WARNING 且没有 handler,会被静默丢掉——而
# 「清理容器失败」正是那条路上唯一的线索。
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
_LOG.debug("参数:%s", vars(args))
if args.dry_run:
return asyncio.run(dry_run(args))
return asyncio.run(full_run(args))
if __name__ == "__main__":
sys.exit(main())