Files
iomgaa 4cbdb056b6 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>
2026-08-11 07:38:08 -04:00

135 lines
5.3 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 环境层的离线冒烟:起 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())