4cbdb056b6
⑥ 的验收要自己造负载压,环境用 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>
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""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())
|