feat(soak): 压测的环境层——AppWorld 容器池与薄 HTTP 客户端

⑥ 的验收要自己造负载压,环境用 AppWorld:733 个任务与 197M 数据都在本机、docker 镜像已
拉好、而且它自带评测端点做程序化判分——换成让另一个模型判对错,等于往验收里再塞一个非
确定源,验收本身就不可复现了。

**不 import dissect**(§1.2 禁止反向 import),照它那两个文件当协议文档自己写了一份。
副作用是这反而更强:证明一个不认识 dissect 的第三方只用公共 API 就能驱动真实环境。

复刻了 dissect 记下的几个坑:httpx 必须 trust_env=False,否则本机代理会把 127.0.0.1 的
请求也劫走、表现成 502;健康检查失败先抓容器日志再删;关闭失败按端口分开计数,用全局
计数器的话坏容器的失败会被别的容器的成功清零。

放在仓库根的 tools/ 下而不是 tests/ 下:打包只收 src/,所以它不进 wheel 也不进 sdist
(实测两个产物里 soak 命中数都是 0);而 §1.9 的四层是按「依赖什么」分的,压测不属于其中
任何一层,塞进 tests/ 要么破坏分层要么和 e2e 共用同一道花钱的闸。Makefile 的检查目标
跟着加上 tools/。

冒烟真跑通:起容器、初始化真题、跨调用保持变量、错误代码返回 traceback 而不抛异常、
评测、无容器残留;另验 2 容器并发的租借与归还。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 07:38:08 -04:00
parent 71e7762814
commit 4cbdb056b6
4 changed files with 980 additions and 4 deletions
+134
View File
@@ -0,0 +1,134 @@
"""AppWorld 环境层的离线冒烟:起 1 个容器,跑通一整条会话链路,**一次模型调用都不打**。
跑法(在仓库根目录)::
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \\
python -m tools.soak.check_appworld --data-root /path/to/appworld
用 `-m` 而不是直接给文件路径:直接跑文件时 `tools` 不在 `sys.path` 上,
`from tools.soak.appworld import ...` 会 ImportError。
它验的是环境层自己:容器起得来、题实例化得了、代码执行得了、评测调得通、容器删得干净。
执行的三段代码是写死的,其中一段故意写错——那一段验的是「代码报错不抛异常,错误文本原样
回来」这条行为,因为它是上层循环最依赖的一条,而正常路径验不出来。
退出码 0 表示全绿。任何一步失败都以非零退出并把原始报错打出来,**不做任何降级**。
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
from tools.soak.appworld import AppWorldPool
#: 冒烟用的题目。取 train 划分的第一条而不是写死一个 ID:题集换过一次(发布的 train 是
#: 90 题,原论文写的是 105 题),写死的 ID 有一天会变成一句「找不到这道题」,而那时看起来
#: 像是环境坏了。
_SPLIT = "train"
#: 三段写死的代码。顺序有意义:先确认 API 文档读得到(环境的数据挂载没问题),再确认
#: 有状态执行器跨步保留变量(这是 AppWorld 与 docker-exec 型环境的关键区别),最后确认
#: 报错不会被压成异常。
_PROBES: tuple[tuple[str, str], ...] = (
("列出可用的 app", "print(apis.api_docs.show_app_descriptions())"),
("定义一个变量", "soak_marker = 6 * 7"),
("读回上一步的变量(验有状态执行器)", "print(soak_marker)"),
("故意写错(验错误不抛异常)", "print(this_name_does_not_exist)"),
)
def _line(title: str) -> None:
print(f"\n=== {title} ===", flush=True)
async def _smoke(data_root: Path, port_base: int) -> int:
pool = AppWorldPool(data_root=data_root, size=1, port_base=port_base)
_line("池配置")
print(f"数据根目录:{data_root}(只读挂载)")
print(f"输出目录: {pool.outputs_dir}")
print(f"宿主端口: {pool.ports}")
async with pool:
task_ids = pool.list_task_ids(_SPLIT)
task_id = task_ids[0]
_line(f"划分 {_SPLIT}")
print(f"{len(task_ids)} 道题,取第一道:{task_id}")
async with pool.session(task_id) as session:
_line("初始化")
print(f"容器地址:{session.base_url}")
print(f"虚拟时间:{session.datetime}")
print(f"主管: {session.supervisor}")
print(f"题面: {session.instruction.strip()[:400]}")
for title, code in _PROBES:
_line(f"执行:{title}")
print(f"$ {code}")
output = await session.execute(code)
print(output.strip()[:1200] or "<无输出>")
_line("问一次完成没有")
print(f"is_done() = {await session.is_done()}(没做题,应为 False")
_line("评测")
score = await session.evaluate()
print(f"success = {score.success}(没做题,应为 False")
print(f"执行次数 = {score.n_executions}")
print(f"detail 的键 = {sorted(score.detail)}")
_line("容器残留检查")
return await _report_leftovers()
async def _report_leftovers() -> int:
"""确认压测的容器一个都没剩下。剩了就非零退出——它会占着端口让下一次跑直接失败。"""
process = await asyncio.create_subprocess_exec(
"docker",
"ps",
"-a",
"--filter",
"name=polyloop-soak-appworld",
"--format",
"{{.Names}} {{.Status}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
print(f"docker ps 失败(退出码 {process.returncode}):{stderr.decode(errors='replace')}")
return 1
leftovers = stdout.decode(errors="replace").strip()
if leftovers:
print(f"仍有残留容器:\n{leftovers}")
return 1
print("docker ps -a 里没有 polyloop-soak-appworld 开头的容器,干净。")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--data-root",
type=Path,
required=True,
help="AppWorld 数据根目录,下面应有 data/datasets 与 data/tasks",
)
parser.add_argument(
"--port-base",
type=int,
default=8200,
help="容器池起始宿主端口(默认 8200,避开 dissect 的 8100",
)
args = parser.parse_args()
# 容器池的告警走 logging,默认级别是 WARNING 且没有 handler,会被静默丢掉——而
# 「清理容器失败」正是这条路上唯一的线索。
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
return asyncio.run(_smoke(args.data_root.resolve(), args.port_base))
if __name__ == "__main__":
sys.exit(main())