按 tier2 图说目标做缺口分析(8族逐元素比对),补齐 0号 spike 为过门收窄掉、 但图说明确要求的「核心引擎待补」项。全部加性/observe-only:金标冒烟仍 ACCEPT (九门9/9+富游戏三门3/3,门一道没放松),真依赖下全链 import+自测+一款真 M3 跑验证通过。 G族(n≥30 runbook 执行基建): - worker/config.py: build_model_openai 便宜档 client(deepseek 经 new-api OpenAI 兼容路,与 M3 Anthropic 路并存) - worker/run_record.py: G4 采集字段表 → 可序列化 RunRecord(含退路树分流键 fail_system) - worker/fallback_tree.py: 退路树五出口判定器(Q1–Q4 数字触发线,★阈值常量区待校准) - batch_run.py / aggregate.py: model×variant×n 批跑(断点续跑/失败隔离)+ 矩阵聚合三图喂判定器 H族(观测/成本接线,把孤儿件缝进 run 主链): - observability/newapi_pricing.py: 活读 new-api /api/pricing 倍率(取不到回落显式参数+告警) - middleware.py: Tier2TraceMiddleware 挂 writer agent 最外层洋葱,ReAct 全事件旁路 ingest - agent_loop/studio.py 收口: records→cost_for_run 折¥;真跑实测 cost_rmb=1.29(newapi-live)、trace 647事件 dropped=0 - contracts/trace/: additive trace 事件契约位(忠实 trace.py 落 sink 形状) D族(L3 视觉软检接线,observe-only): - agent_loop/studio.py: 收口调一次 M3 多模态(真截图+真玩取证→fun映射0-100),只写 verdict.L3,绝不参与 decision - 真跑实测 L3 score=25 准确指出空心表现层;decision=fix 仍由 L1硬门/熔断裁、与 L3 无关(防 Goodhart 成立) 留后(不投机抢建):工作室 Agent Team/第二装载落库/控制面/Agent Service 等按 plan 决策②⑤ gate 到 B门后; n≥30 等统计相是「跑」非「写」(批跑底座已就位);A-model 4插件复用待合并对账;4处图说 spec-drift 待 doc 线回写。 详见 tier2/HANDOFF.md「图说对账补全」节。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
516 lines
32 KiB
Python
516 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""batch_run.py —— tier2 0号 spike · 批跑编排(model × brief_variant 矩阵 → JSONL run-records)。
|
||
|
||
【这份在 spike runbook 里的位置(权威 = G 族图说)】
|
||
docs/architecture/架构/生成引擎/tier2细节图说-G-spike-runbook.md:
|
||
- 图 G3「模型矩阵 + 跑序」:0号 spike 不是笼统拿「便宜模型」跑,而是跑一张点名到具体模型的矩阵——
|
||
主力便宜档 deepseek-v4-flash / 强便宜档 deepseek-v4-pro / 中等 agentic 档 MiniMax-M3 / 强基线 Opus·Fable;
|
||
题面用 5 个一句话变体(美食/水果/咖啡/面包/糖水店),每变体每档跑 6 次,5×6 凑够 n≥30;
|
||
**跑序 = 先 M3 证路、再便宜档比成本**(2026-06-21 创始人定:别一上来把整张矩阵 × n≥30 全铺开,
|
||
先用 M3 全流程自治产一款、创始人亲玩判肥鹅味,路走通了再用便宜档各跑 n≥30 比成本)。
|
||
- 图 G4「过门阈值 + 采集字段」:每款一行的 run 级采集字段(RunRecord,见 worker/run_record.py),
|
||
汇成矩阵级三张图(过门率 / ¥/成功款 / 收敛中位数 + fail_system 分布)当 go/no-go 直接输入。
|
||
|
||
【本模块只做编排,不 re-implement 生成逻辑】
|
||
- 单格生成 = 直接调 run_engine._run(...)(那是把 6 模块接成一条线的总入口的异步函数),
|
||
本模块绝不复制那条生成链;它只负责「矩阵展开 + 跑序 + 落 RunRecord + 断点续跑 + 失败隔离」。
|
||
- 真发 LLM / 真跑 chrome 的部分只在 mini-desktop 跑(6c6g 禁 chrome、无 esbuild、无 agentscope wheel)。
|
||
本机(6c6g)用 --dry-run 走桩 run_one,只校验编排骨架(矩阵展开 / 跑序 / 续跑 / 隔离 / 落盘),不碰网络。
|
||
|
||
【result → RunRecord 接线(observe-only,不改 run 主链裁决)】
|
||
task A3 的 run_record.py 注释里说「接线交 Phase2 B1/B2」——本模块就是那个接线方:
|
||
从 run_studio 返回的 result(见 agent_loop/studio.py 末尾的 result dict)抽字段,
|
||
按 G4 字段表填一条 RunRecord。其中 fail_stage / fail_system 由 _classify_failure() 从 verdict 纯读派生
|
||
(observe-only:只写进 RunRecord 供退路树分流,绝不回头改 verdict 的 accept/reject 裁决——
|
||
judge 纯代码判的门结果是唯一裁决源,本模块只观测、不裁判,防 Goodhart)。
|
||
|
||
CLI 用法:
|
||
# mini-desktop(真跑;先 M3 证路一格):
|
||
python batch_run.py --out results/spike-runs.jsonl --models MiniMax-M3 --variants 面包店 --n 1
|
||
# mini-desktop(便宜档比成本,5 变体 × 6 次 = n30):
|
||
python batch_run.py --out results/spike-runs.jsonl \
|
||
--models deepseek-v4-flash deepseek-v4-pro --n 6
|
||
# 6c6g(只验编排骨架,走桩、不碰网络):
|
||
python batch_run.py --out /tmp/dry.jsonl --models MiniMax-M3 deepseek-v4-flash --n 2 --dry-run
|
||
|
||
可 import 用法:
|
||
from batch_run import run_matrix, BatchConfig
|
||
records = await run_matrix(BatchConfig(out_path="...", models=[...], variants=[...], n=6))
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import sys
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Awaitable, Callable, Optional
|
||
|
||
# 包内/直跑兼容:本文件在 tier2/gen-worker/batch_run.py;把 gen-worker/ 加进 sys.path 使顶层包 `worker` 可解析。
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
|
||
from worker import run_record # noqa: E402 —— A3 采集记录(纯数据,无 agentscope 依赖,6c6g 可 import)
|
||
from worker.run_record import RunRecord, append_record # noqa: E402
|
||
|
||
# 结果落点(承袭 run_engine.py 的 RESULTS_DIR 惯例)。
|
||
RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||
# 批跑台账默认文件名(矩阵级底料;analyze 侧逐行读)。
|
||
DEFAULT_JSONL = RESULTS_DIR / "spike-runs.jsonl"
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# 题面变体(G3 图说钉死的 5 个一句话变体;键 = 变体名,值 = 喂 run 的一句话 brief)
|
||
# 每个 brief 都落在 business-sim(经营/合成富游戏 = mini-肥鹅)品类,只换主题皮肤;
|
||
# 主题只影响 item id 的 slug 命名与表现层风格,不改三系统耦合结构(那是考题本身,见 G2)。
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
BRIEF_VARIANTS: dict[str, str] = {
|
||
"美食": "做一个美食大排档经营游戏,合成食材做成招牌菜交给食客订单赚金币",
|
||
"水果": "做一个水果铺经营合成游戏,合成水果拼成果盘满足顾客订单赚金币",
|
||
"咖啡": "做一个咖啡馆经营游戏,合成咖啡豆与配料调出饮品交订单赚金币",
|
||
"面包": "做一个面包店经营合成游戏,合成原料做成成品交订单赚金币",
|
||
"糖水店": "做一个糖水店经营合成游戏,合成食材熬成糖水甜品交订单赚金币",
|
||
}
|
||
|
||
# 默认题面变体跑序(G3 的 5 变体;CLI --variants 可覆盖)。
|
||
DEFAULT_VARIANTS: list[str] = list(BRIEF_VARIANTS.keys())
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# 跑序权威(G3 图说 / 2026-06-21 创始人定):先 M3 证路、再便宜档比成本、强基线只验上限。
|
||
# 本模块据此给传入的 models[] 排序,使「先证路」的档先跑——这样路若不通,在更便宜的档上空耗前就能止损。
|
||
# 档名对齐 fallback_tree.py 的角色档名常量(同一套档名,真跑改档名两处一起改)。
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
_RUN_ORDER_RANK: dict[str, int] = {
|
||
# 0 = 最先(中等 agentic 档,先证路)
|
||
"MiniMax-M3": 0, "M3": 0,
|
||
# 1 = 便宜档比成本(强便宜 → 主力便宜)
|
||
"deepseek-v4-pro": 1, "v4-pro": 1,
|
||
"deepseek-v4-flash": 2, "v4-flash": 2,
|
||
# 3 = 强基线只验上限(最后跑,不进成本评估)
|
||
"Opus": 3, "Fable": 3, "opus": 3, "fable": 3,
|
||
}
|
||
|
||
|
||
def _run_order_key(model: str) -> int:
|
||
"""按 G3 跑序给模型档定序号;未登记的档排在便宜档与强基线之间(rank=2.5 → 用 25)。"""
|
||
return _RUN_ORDER_RANK.get(model, 25)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# 失败分类(observe-only):从 verdict 纯读派生 fail_stage / fail_system,喂退路树分流。
|
||
# 只读 verdict、不改它;decision=accept(过门)时两者都 None。
|
||
# 词表严格对齐 worker/run_record.py 的 FailStage / FailSystem Literal(集成接缝:产单条 → 聚合 → 喂判定器)。
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
|
||
# 致命门名 → fail_stage 的映射(九门名按 D 族三层校验的段序归类)。
|
||
# 九门里:结构/构建段(structure_*)→ build;可运行段(A_boot…I_control)→ seven_gate。
|
||
_GATE_TO_STAGE: dict[str, str] = {
|
||
# 结构/构建相关门(bundle 没打出来 / 结构非法)→ build 段
|
||
"structure": "build", "structureOk": "build", "build": "build",
|
||
# 其余九门(boot/render/live/progress/control/wiring…)= 可运行层,统归 seven_gate
|
||
}
|
||
|
||
|
||
def _classify_failure(verdict: dict | None,
|
||
circuit_break: dict | None) -> tuple[Optional[str], Optional[str]]:
|
||
"""从 verdict(+ 熔断)纯读派生 (fail_stage, fail_system),observe-only。
|
||
|
||
判定顺序(从早段到晚段,取最早命中的失败段——失败定位要落在「最先崩的那道关」):
|
||
1) verdict 为 None / 熔断:抽取或装载即崩、或熔断停 → fail_stage='extract'(产出取不出可用结构);
|
||
2) decision == 'accept' 且 L1.passed → 过门,(None, None);
|
||
3) 九门致命门未过:据门名归 build(结构/构建)或 seven_gate(可运行层);
|
||
4) 富游戏门 tripleLink 未过 → triple_link 段;其子检查 requiresReachable/dagAcyclic 失败
|
||
多由订单或合成数据表错引起 → fail_system 落 order(requiresReachable)/ merge(dagAcyclic);
|
||
5) 富游戏门 economy 未过 → economy 段,fail_system 落 resource(经济枢纽);
|
||
6) 否则按 verdict.reasons / findings 兜底归类,取不到则 (None, None)。
|
||
|
||
fail_system 经营品类特有(退路树分流关键依据,G4/G5):
|
||
- presentation(表现层 56%):F 门 wiring calls=0、E_live 没渲染等多在表现层没接输入/没画;
|
||
- merge(合成):dagAcyclic 失败(合成链有环);
|
||
- order(订单):requiresReachable 失败(订单要的物品合不出来);
|
||
- resource(资源/经济枢纽):economy 门失败(金币不动 / 经济不闭环)。
|
||
|
||
注:这是 best-effort 启发式归类,不是精确根因——它给退路树一个可分流的方向键,
|
||
真根因仍以 verdict.reasons / harness log 为准。归类绝不影响门裁决(observe-only)。
|
||
"""
|
||
# 1) 无 verdict(抽取/装载即崩)或熔断 → extract 段。
|
||
if not verdict:
|
||
# 熔断 stuck(卡死)多发生在表现层没接输入导致门一直不绿 → presentation;其余熔断系统不明。
|
||
sys_hint = "presentation" if (circuit_break or {}).get("kind") == "stuck" else None
|
||
return ("extract", sys_hint)
|
||
|
||
l1 = ((verdict.get("layerResults") or {}).get("L1") or {})
|
||
decision = verdict.get("decision")
|
||
# 2) 过门:accept + L1 全绿 → 不失败。
|
||
if decision == "accept" and l1.get("passed"):
|
||
return (None, None)
|
||
|
||
# 3) 九门致命门未过:取第一道未过的致命门,据门名归 build / seven_gate。
|
||
for g in (l1.get("gateResults") or []):
|
||
if g.get("passed") is False and g.get("fatal"):
|
||
gate_name = (g.get("gate") or "")
|
||
# 门名可能带前缀(如 A_boot / F_wiring);取下划线前的字母段或整名查映射。
|
||
stage = _GATE_TO_STAGE.get(gate_name)
|
||
if stage is None:
|
||
# 默认归可运行层 seven_gate;F_wiring(接线)失败多在表现层没接输入 → presentation。
|
||
stage = "seven_gate"
|
||
sys_hint = None
|
||
low = gate_name.lower()
|
||
if "wiring" in low or "control" in low or "render" in low or "live" in low:
|
||
sys_hint = "presentation" # 接线/渲染/活动门多挂在表现层 56%
|
||
return (stage, sys_hint)
|
||
|
||
# 4)~5) 富游戏三门(tripleLink / economy / latch)未过:据子检查名定位系统。
|
||
rich = l1.get("richGameGates") or {}
|
||
tl = rich.get("tripleLink")
|
||
if isinstance(tl, dict) and tl.get("passed") is False:
|
||
bad = {c.get("name") for c in (tl.get("checks") or []) if c.get("ok") is False}
|
||
if "requiresReachable" in bad:
|
||
return ("triple_link", "order") # 订单要的物品合不出来 = 订单/可达性问题
|
||
if "dagAcyclic" in bad:
|
||
return ("triple_link", "merge") # 合成链有环 = 合成系统问题
|
||
return ("triple_link", "presentation") # 其余三联动子检查多在表现层没把系统接起来
|
||
econ = rich.get("economy")
|
||
if isinstance(econ, dict) and econ.get("passed") is False:
|
||
return ("economy", "resource") # 经济门挂 = 资源/经济枢纽不闭环
|
||
latch = rich.get("latch")
|
||
if isinstance(latch, dict) and latch.get("passed") is False:
|
||
# latch 终态没 latch 多因 tick 没接(表现层删了 tick)→ presentation。
|
||
return ("seven_gate", "presentation")
|
||
|
||
# 6) 兜底:有 findings 但未落到上面任何门 → 段/系统不明,只标段为 None(让退路树按整体分布兜底)。
|
||
return (None, None)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# result → RunRecord(G4 字段表落地;observe-only 接线)
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
def result_to_record(result: dict, *, run_id: str, brief_variant: str,
|
||
timestamp: float) -> RunRecord:
|
||
"""把 run_studio 返回的 result(见 agent_loop/studio.py 末尾)抽成一条 RunRecord。
|
||
|
||
字段抽取口径严格对齐 worker/run_record.py 各字段 docstring 标注的【来源】:
|
||
- model ← result['model']
|
||
- pass_gate ← verdict.decision=='accept' 且 L1.passed(judge 纯代码判,零自评)
|
||
- repairs ← breaker_counters.model_calls 兜底为 0(自治循环代价;真值由 studio attempt 计数,
|
||
本期 result 未单列 attempt,用 model_calls 作收敛代价的近似观测,注明)
|
||
- fail_stage/system← _classify_failure(verdict, circuit_break)(observe-only,见上)
|
||
- cost_yuan ← cost.cost_rmb;tokens_* ← result['tokens'];tokens_by_model ← cost.tokens_by_model
|
||
- wall_seconds ← result['wall_s']
|
||
- file_count ← len(result['file_tree']);total_loc ← 对 source_project 各文件 content 计行
|
||
- decision/finished← verdict.decision / result['finished']
|
||
|
||
stage 字段:本批跑每格都贯穿设计→build→play 三段,成功落 play;
|
||
未真 finish 的失败款,stage 仍标其最终落点(用 fail_stage 区分中途终止的关序)。
|
||
"""
|
||
verdict = result.get("last_verdict") or {}
|
||
l1 = ((verdict.get("layerResults") or {}).get("L1") or {})
|
||
cb = result.get("circuit_break")
|
||
cost = result.get("cost") or {}
|
||
tokens = result.get("tokens") or {}
|
||
|
||
decision = verdict.get("decision")
|
||
pass_gate = bool(decision == "accept" and l1.get("passed"))
|
||
fail_stage, fail_system = _classify_failure(verdict if verdict else None, cb)
|
||
|
||
# stage:成功款落 play;失败款若有 fail_stage 落在 build 段则标 build,否则也算走到 play(真玩段)。
|
||
stage: str = "play"
|
||
if not pass_gate and fail_stage == "build":
|
||
stage = "build"
|
||
elif not pass_gate and fail_stage == "extract":
|
||
stage = "build" # 抽取/装载即崩=没走到真玩,落 build 段(extract 在 fail_stage 里另记)
|
||
|
||
# total_loc:对 finish 交付的源工程各文件 content 计行(未 finish 则 0)。
|
||
total_loc = 0
|
||
sp = result.get("source_project") or {}
|
||
for f in (sp.get("files") or sp.get("fileTree") or []):
|
||
content = f.get("content") if isinstance(f, dict) else None
|
||
if isinstance(content, str):
|
||
total_loc += content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
||
|
||
return RunRecord(
|
||
run_id=run_id,
|
||
model=result.get("model") or "unknown",
|
||
stage=stage, # type: ignore[arg-type] —— Literal,运行期不强校验
|
||
brief_variant=brief_variant,
|
||
pass_gate=pass_gate,
|
||
# repairs:近似取模型调用数作收敛代价观测(真值=studio attempt;result 未单列,注明近似)。
|
||
repairs=int((result.get("breaker_counters") or {}).get("model_calls") or 0),
|
||
fail_stage=fail_stage, # type: ignore[arg-type]
|
||
fail_system=fail_system, # type: ignore[arg-type]
|
||
cost_yuan=float(cost.get("cost_rmb") or 0.0),
|
||
tokens_in=int(tokens.get("prompt") or 0),
|
||
tokens_out=int(tokens.get("completion") or 0),
|
||
tokens_cached=int(tokens.get("cached") or 0),
|
||
tokens_by_model=cost.get("tokens_by_model") or {},
|
||
wall_seconds=float(result.get("wall_s") or 0.0),
|
||
file_count=len(result.get("file_tree") or []),
|
||
total_loc=total_loc,
|
||
# 长程一致性三字段:本期 result 未单列(ctx_compressed/checkpoint_recovered 待控制面 trace 落库后接);
|
||
# 先取保守默认(无压缩 / 未恢复 / 幂等无脏),真值由后续观测面补。
|
||
ctx_compressed=False,
|
||
checkpoint_recovered=False,
|
||
idempotency_clean=True,
|
||
decision=decision, # type: ignore[arg-type]
|
||
finished=bool(result.get("finished")),
|
||
timestamp=timestamp,
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# 批跑配置
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
@dataclass
|
||
class BatchConfig:
|
||
"""一次矩阵批跑的配置(model × brief_variant × n)。"""
|
||
|
||
out_path: str # JSONL 台账落点(断点续跑也读它判已跑)
|
||
models: list[str] # 模型档列表(会按 G3 跑序重排:先 M3 后便宜档)
|
||
variants: list[str] = field(default_factory=lambda: list(DEFAULT_VARIANTS)) # 题面变体(默认 5 个)
|
||
n: int = 6 # 每 (model, variant) 格的重复跑次数(G3:5 变体 ×6 = n30)
|
||
game_id_prefix: str = "feie" # 工程标识前缀(每格 game_id = <prefix>-<model>-<variant>-<i>)
|
||
# 单次 run 的模型参数(透传给 run_engine._run;默认对齐 run_engine.py 的 CLI 默认)。
|
||
max_tokens: int = 16000
|
||
thinking_budget: int = 8000
|
||
max_iters: int = 40
|
||
do_design: bool = True
|
||
template: str = "business-sim"
|
||
dry_run: bool = False # True=走桩 run_one(不碰网络/chrome,本机校验编排骨架)
|
||
|
||
|
||
def _slug(s: str) -> str:
|
||
"""把模型名/变体名压成 game_id 安全的 slug(去掉路径不安全字符,中文保留为占位 hash)。"""
|
||
safe = "".join(ch if (ch.isalnum() or ch in "-_") else "" for ch in s)
|
||
if safe:
|
||
return safe
|
||
# 全是中文/特殊字符(如变体名「面包」)→ 用短 hash 占位,保证 game_id 唯一且文件系统安全。
|
||
import hashlib
|
||
return "v" + hashlib.md5(s.encode("utf-8")).hexdigest()[:6]
|
||
|
||
|
||
def _cell_run_id(prefix: str, model: str, variant: str, i: int) -> str:
|
||
"""一格内第 i 次跑的 run_id(行级主键 + game_id 复用同一串;断点续跑据它判已跑)。"""
|
||
return f"{prefix}-{_slug(model)}-{_slug(variant)}-{i}"
|
||
|
||
|
||
def _load_done_run_ids(out_path: str) -> set[str]:
|
||
"""读已有 JSONL 台账,取已完成的 run_id 集合(断点续跑:已有 run_id 跳过)。
|
||
|
||
台账不存在 → 空集(首次跑)。坏行(JSON 解析失败)跳过不计、不中断(best-effort 续跑)。
|
||
"""
|
||
done: set[str] = set()
|
||
p = Path(out_path)
|
||
if not p.exists():
|
||
return done
|
||
for line in p.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
rec = RunRecord.from_jsonl_line(line)
|
||
done.add(rec.run_id)
|
||
except Exception: # noqa: BLE001 —— 坏行不阻断续跑;但要让人看见(打印告警)
|
||
print(f"[batch_run] ⚠ 台账坏行已跳过(续跑不计):{line[:80]}", file=sys.stderr)
|
||
return done
|
||
|
||
|
||
# ── 默认单格生成函数:复用 run_engine._run(不 re-implement 生成逻辑)──
|
||
async def _default_run_one(*, game_id: str, brief: str, model: str,
|
||
cfg: BatchConfig) -> dict:
|
||
"""调 run_engine._run 真跑一格(真发 LLM + 真跑 chrome,只在 mini-desktop)。
|
||
|
||
这里【绝不复制生成链】,只把矩阵格的参数翻译成 run_engine._run 的入参。
|
||
缺 NEWAPI key / 无 chrome 时,run_engine._run 内部会诚实失败(返回结构化失败 result),
|
||
本批跑据 result 落一条 RunRecord(失败也是数据,计入分母),不静默吞、不拖垮全批。
|
||
"""
|
||
# 延迟 import:run_engine 顶层会 import agentscope(经 client 装代理旁路),
|
||
# 6c6g 无 agentscope wheel → import 即失败。故只在真跑路径(非 dry-run)才 import,
|
||
# 保证本机 --dry-run 校验骨架时不触发 agentscope import。
|
||
import run_engine # noqa: E402 —— 仅真跑路径加载
|
||
return await run_engine._run(
|
||
game_id, brief,
|
||
model_name=model, max_tokens=cfg.max_tokens, thinking_budget=cfg.thinking_budget,
|
||
max_iters=cfg.max_iters, do_design=cfg.do_design, template=cfg.template,
|
||
)
|
||
|
||
|
||
# ── 桩单格生成函数(--dry-run 用:不碰网络/chrome,产一个形状合法的假 result)──
|
||
async def _stub_run_one(*, game_id: str, brief: str, model: str,
|
||
cfg: BatchConfig) -> dict:
|
||
"""dry-run 桩:产一个形状对齐 run_studio result 的假结果,供本机校验编排骨架(矩阵/跑序/续跑/隔离/落盘)。
|
||
|
||
桩按 game_id 末位数字制造可预测的多样性:偶数=过门(accept),奇数=失败(轮流挂不同门),
|
||
使聚合与退路树在 dry-run 下也能跑出非平凡的分组结果(便于人核对编排正确性)。
|
||
绝不发任何网络/子进程——纯内存造数。
|
||
"""
|
||
# 用 game_id 末位与 model 名制造确定性的桩结果(可复现、可核对)。
|
||
last = game_id[-1]
|
||
seed = (sum(ord(c) for c in game_id)) % 4
|
||
await asyncio.sleep(0) # 让出事件循环(模拟 await 点,使并发语义与真跑一致)
|
||
|
||
if seed == 0:
|
||
# 过门款(accept + L1 全绿)。
|
||
verdict = {"decision": "accept",
|
||
"layerResults": {"L1": {"passed": True, "gateResults": [], "richGameGates": {}}}}
|
||
finished, src = True, {"files": [{"path": "src/main.js", "content": "x\n" * 40}]}
|
||
elif seed == 1:
|
||
# 失败款:三联动门 requiresReachable 挂 → order 系统。
|
||
verdict = {"decision": "fix",
|
||
"layerResults": {"L1": {"passed": False, "gateResults": [],
|
||
"richGameGates": {"tripleLink": {"passed": False,
|
||
"checks": [{"name": "requiresReachable", "ok": False}]}}}}}
|
||
finished, src = False, {}
|
||
elif seed == 2:
|
||
# 失败款:F_wiring 致命门挂 → 表现层 presentation。
|
||
verdict = {"decision": "fix",
|
||
"layerResults": {"L1": {"passed": False,
|
||
"gateResults": [{"gate": "F_wiring", "passed": False, "fatal": True,
|
||
"detail": "calls=0 输入没接进 core"}],
|
||
"richGameGates": {}}}}
|
||
finished, src = False, {}
|
||
else:
|
||
# 失败款:经济门挂 → resource 枢纽。
|
||
verdict = {"decision": "fix",
|
||
"layerResults": {"L1": {"passed": False, "gateResults": [],
|
||
"richGameGates": {"economy": {"passed": False,
|
||
"checks": [{"name": "bankruptPathLose", "ok": False}]}}}}}
|
||
finished, src = False, {}
|
||
|
||
return {
|
||
"game_id": game_id, "model": model,
|
||
"design_text": "[dry-run 桩]", "writer_final_text": "[dry-run 桩]",
|
||
"source_project": src, "finished": finished,
|
||
"last_verdict": verdict, "circuit_break": None,
|
||
"breaker_counters": {"tool_calls": 3 + seed, "model_calls": 4 + seed},
|
||
"tokens": {"prompt": 100000 + seed * 1000, "completion": 30000, "cached": 5000},
|
||
"cost": {"cost_rmb": round(1.0 + seed * 0.7, 5),
|
||
"tokens_by_model": {model: {"in": 100000, "out": 30000, "cached": 5000}},
|
||
"pricingSource": "dry-run-stub"},
|
||
"trace": {"traceId": game_id, "steps": 0, "dropped": 0},
|
||
"wall_s": round(60.0 + seed * 15, 1),
|
||
"file_tree": [f["path"] for f in (src.get("files") or [])],
|
||
}
|
||
|
||
|
||
# 单格生成函数的类型(可注入:真跑=_default_run_one,dry-run=_stub_run_one,测试可传自定义桩)。
|
||
RunOneFn = Callable[..., Awaitable[dict]]
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# 主编排:run_matrix —— 展开矩阵、按跑序串跑、落 RunRecord、断点续跑、失败隔离
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
async def run_matrix(cfg: BatchConfig, *, run_one: Optional[RunOneFn] = None) -> list[RunRecord]:
|
||
"""跑一张 model × brief_variant × n 矩阵,每格落一条 RunRecord 进 JSONL,返回本次新落的记录列表。
|
||
|
||
编排语义(对齐 G3 图说 + task 要求):
|
||
- 跑序:models[] 先按 G3 跑序重排(先 M3 证路、再便宜档、强基线最后),外层 model、中层 variant、内层 i。
|
||
串行跑(不并发):真跑要烧 token + 占 chrome/端口,且「先证路再比成本」要求 M3 结果先出来人看;
|
||
失败隔离也更清晰(一格异常只 except 这一格)。
|
||
- 断点续跑:已落台账的 run_id 直接跳过(读 out_path 的已有记录;首次跑=空集)。
|
||
- 失败隔离:单格 run_one 抛异常 → except 住、落一条「异常款」RunRecord(fail_stage=extract)、继续下一格,
|
||
绝不让一格挂拖垮整批(spike 一跑几十格,中途一格崩不能白跑前面的)。
|
||
- 落盘:每格跑完立即 append_record 落盘(append-only),即使中途整批被 kill,已跑的也不丢、可续。
|
||
|
||
:param run_one: 单格生成函数(默认按 cfg.dry_run 选 _default_run_one / _stub_run_one;测试可注入桩)。
|
||
:return: 本次实际跑(非跳过)的 RunRecord 列表。
|
||
"""
|
||
if run_one is None:
|
||
run_one = _stub_run_one if cfg.dry_run else _default_run_one
|
||
|
||
# 校验变体名都在 BRIEF_VARIANTS 里(防把不存在的变体名传进来导致 brief 缺失)。
|
||
unknown = [v for v in cfg.variants if v not in BRIEF_VARIANTS]
|
||
if unknown:
|
||
raise ValueError(
|
||
f"未知题面变体 {unknown};合法变体 = {list(BRIEF_VARIANTS.keys())}(见 G3 图说 5 变体)。")
|
||
|
||
# 跑序:模型档按 G3 跑序排(先证路后比成本);变体与 i 保持给定顺序。
|
||
models_ordered = sorted(cfg.models, key=_run_order_key)
|
||
done = _load_done_run_ids(cfg.out_path)
|
||
print(f"[batch_run] 矩阵 = {len(models_ordered)} 档 × {len(cfg.variants)} 变体 × n{cfg.n} "
|
||
f"= {len(models_ordered) * len(cfg.variants) * cfg.n} 格;"
|
||
f"已跑 {len(done)} 格(断点续跑跳过);dry_run={cfg.dry_run};跑序={models_ordered}",
|
||
file=sys.stderr)
|
||
|
||
new_records: list[RunRecord] = []
|
||
cell_idx = 0
|
||
total_cells = len(models_ordered) * len(cfg.variants) * cfg.n
|
||
for model in models_ordered: # 外层:模型档(先 M3 证路)
|
||
for variant in cfg.variants: # 中层:题面变体
|
||
brief = BRIEF_VARIANTS[variant]
|
||
for i in range(cfg.n): # 内层:重复 n 次
|
||
cell_idx += 1
|
||
run_id = _cell_run_id(cfg.game_id_prefix, model, variant, i)
|
||
if run_id in done:
|
||
print(f"[batch_run] ({cell_idx}/{total_cells}) 跳过已跑 {run_id}", file=sys.stderr)
|
||
continue
|
||
ts = time.time()
|
||
print(f"[batch_run] ({cell_idx}/{total_cells}) 跑 {run_id} "
|
||
f"model={model} variant={variant} …", file=sys.stderr)
|
||
try:
|
||
result = await run_one(game_id=run_id, brief=brief, model=model, cfg=cfg)
|
||
rec = result_to_record(result, run_id=run_id, brief_variant=variant, timestamp=ts)
|
||
except Exception as e: # noqa: BLE001 —— 失败隔离:一格挂不拖垮全批,落异常款继续
|
||
# 异常款:落一条标记 extract 段失败的 RunRecord(不静默吞;异常也是 spike 数据)。
|
||
print(f"[batch_run] ✗ {run_id} 异常已隔离(落异常款,继续):"
|
||
f"{type(e).__name__}: {e}", file=sys.stderr)
|
||
rec = RunRecord(
|
||
run_id=run_id, model=model, stage="build", brief_variant=variant,
|
||
pass_gate=False, repairs=0,
|
||
fail_stage="extract", fail_system=None,
|
||
decision=None, finished=False, timestamp=ts,
|
||
)
|
||
# 每格跑完立即落盘(append-only;中途被 kill 也不丢已跑的)。
|
||
append_record(cfg.out_path, rec)
|
||
new_records.append(rec)
|
||
done.add(run_id)
|
||
_kind = "PASS" if rec.pass_gate else f"FAIL({rec.fail_stage}/{rec.fail_system})"
|
||
print(f"[batch_run] ({cell_idx}/{total_cells}) ✓ {run_id} → {_kind} "
|
||
f"cost=¥{rec.cost_yuan} wall={rec.wall_seconds}s", file=sys.stderr)
|
||
|
||
print(f"[batch_run] 本次新跑 {len(new_records)} 格,落 → {cfg.out_path}", file=sys.stderr)
|
||
return new_records
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# CLI
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(
|
||
description="tier2 0号 spike 批跑编排(model × brief_variant 矩阵 → JSONL run-records)")
|
||
ap.add_argument("--out", default=str(DEFAULT_JSONL),
|
||
help=f"JSONL 台账落点(默认 {DEFAULT_JSONL};断点续跑也读它判已跑)")
|
||
ap.add_argument("--models", nargs="+", required=True,
|
||
help="模型档列表(会按 G3 跑序重排:先 M3 后便宜档);如 MiniMax-M3 deepseek-v4-flash")
|
||
ap.add_argument("--variants", nargs="+", default=DEFAULT_VARIANTS,
|
||
help=f"题面变体(默认 5 个:{DEFAULT_VARIANTS});合法值见 G3 图说")
|
||
ap.add_argument("--n", type=int, default=6, help="每 (model,variant) 格的重复跑次数(G3:5×6=n30)")
|
||
ap.add_argument("--prefix", default="feie", help="game_id 前缀(默认 feie)")
|
||
ap.add_argument("--max-tokens", type=int, default=16000)
|
||
ap.add_argument("--thinking-budget", type=int, default=8000)
|
||
ap.add_argument("--max-iters", type=int, default=40, help="单写 ReAct 放开的最大轮数(C3≤40)")
|
||
ap.add_argument("--no-design", action="store_true", help="跳过阶段 1 设计")
|
||
ap.add_argument("--template", default="business-sim", help="品类模板(默认 business-sim=mini-肥鹅)")
|
||
ap.add_argument("--dry-run", action="store_true",
|
||
help="走桩(不碰网络/chrome/agentscope,本机校验编排骨架);6c6g 用它")
|
||
args = ap.parse_args()
|
||
|
||
cfg = BatchConfig(
|
||
out_path=args.out, models=args.models, variants=args.variants, n=args.n,
|
||
game_id_prefix=args.prefix, max_tokens=args.max_tokens,
|
||
thinking_budget=args.thinking_budget, max_iters=args.max_iters,
|
||
do_design=not args.no_design, template=args.template, dry_run=args.dry_run,
|
||
)
|
||
recs = asyncio.run(run_matrix(cfg))
|
||
n_pass = sum(1 for r in recs if r.pass_gate)
|
||
print(f"\n=== batch_run 完成:本次新跑 {len(recs)} 格,过门 {n_pass} 格 ===")
|
||
print(f" 台账 → {cfg.out_path}")
|
||
print(f" 下一步:python aggregate.py --in {cfg.out_path}(聚合三图 + 退路树判定)")
|
||
# 退出码:0=编排正常跑完(无论过门率高低;过门率判定交 aggregate + fallback_tree)。
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|