feat(tier2): 图说对账补全核心引擎待补——n≥30 runbook基建+观测成本接线+L3软检(全加性/observe-only)
按 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>
This commit is contained in:
parent
c543f7c921
commit
d6e977a5ac
29
contracts/trace/README.md
Normal file
29
contracts/trace/README.md
Normal file
@ -0,0 +1,29 @@
|
||||
# contracts/trace/ —— tier2 统一 trace 事件契约(additive 立位)
|
||||
|
||||
这一目录登记 tier2 富游戏自治生成线的统一 trace 事件契约。它是契约组的新一类 additive 立位:只新增、不改动也不破坏任何现有契约(events / game-package / tier2-verdict 等),随 0号 spike 或控制面 phase-1 才真接代码。当前状态是「建」——设计已出、schema 已立,tier2 这条线本身整体待 spike 验证。
|
||||
|
||||
## 这是什么
|
||||
|
||||
`tier2-trace-event.schema.json` 定义一条 trace 记录的形状。一条记录 = 一次生成轨迹里的一步,由公共核心五字段(`traceId` / `step` / `cost` / `verdict` / `timestamp`)加一个 tier2 私有的 `ext` 扩展段构成。核心五字段是两条生成线(tier2 ReAct 多轮、SAA 16 节点廉价线)都必须老老实实填上的对称子集;`ext` 是 tier2 这一轨各写各的不对称段,塞它独有的 ReAct 三段——推理、动作、观察。
|
||||
|
||||
这份契约约束的是数据口径,不是采集机制。两条线各按各的框架采集轨迹,但写进同一张表时字段同名同义。要让两条异构线共用一张表,靠的不是强求字段对齐,而是「接口对称、内容不对称」:核心子集对称,扩展段各写各的、没有的字段绝不编造。这才是消灭 split-brain 的真口径。
|
||||
|
||||
## 谁产
|
||||
|
||||
产出方是 `tier2/gen-worker/observability/trace.py` 里的 `TraceAdapter`。它订阅 AgentScope 2.0.2 官方的 typed Event System(ReAct agent 经 `reply_stream()` 吐的 `AgentEvent` 流),把每个事件经纯函数 `to_trace_step` 映射成这份契约规定的形状。tier2 这条线只「订阅 + 映射」,不「埋点 + 采集」——事件流是框架现成件,源码已逐条核过,不必自造。schema 里每个字段的口径都忠实映射 adapter 已实现的产出:adapter 没产的字段一概不写进契约;代码注释里标为可选、当前还没实填的字段(如 `cost.tokens.cached`、`cost.cost_rmb`),在 schema 的 description 里注明了它们各自的填充时机。
|
||||
|
||||
SAA 那一轨的扩展段由 Java 线另一个 adapter 产,字段与本契约的 `ext` 不同名也不要求对齐,各写各的——本契约只管 tier2 这一轨。
|
||||
|
||||
## 谁消费(后续)
|
||||
|
||||
消费链路按 H 族图说 H2 是「Event System → TracingMiddleware → OpenTelemetry span → AgentScope Studio」。trace 记录最终落进已部署的 MySQL 加对象存储,不上重型可观测中间件;观测要早建,是为了 spike 调试和成本对账当下就用得上,而不是要先铺一套独立基建。`traceId` 贯穿整条生成任务链路,既是反查键,也是成本关联键——它对接 `tier2-verdict.schema.json` 的 `evidence.traceId`,让一次验收终判能反查到它对应的全过程轨迹。成本侧由 `cost.py` 读 new-api 的 `logs.quota` 权威口径,按 `traceId` 关联后把人民币金额 best-effort 回填进 `cost.cost_rmb`(trace 阶段只记 token,不携金额估算)。
|
||||
|
||||
## 与 H 族图说的对应
|
||||
|
||||
设计权威是 `docs/architecture/架构/生成引擎/tier2细节图说-H-观测与成本.md`。
|
||||
|
||||
- 公共核心五字段 = 图 H1 的对称子集。
|
||||
- `ext` 的三段(`reasoning` / `action` / `observation`)= 图 H1/H2 讲的 tier2 ReAct 三段扩展段,逐段对到图 H2 摊开的七类强类型事件:`TextBlock*` 与 `ThinkingBlock*` 映射进 `reasoning`,`ToolCall*` 映射进 `action`,`ToolResult*` 映射进 `observation`。
|
||||
- 图 H2 的另外两类事件——`ModelCallStart` / `ModelCallEnd` 与 `ReplyStart` / `ReplyEnd`——在代码里不单独成段:`ModelCallEnd` 带的 token 用量落进 `cost.tokens`(供 H3 成本台账抓取),其余关键字段(模型名、回复边界、reply_id、agent 名)落进 `ext.raw`。这一点是代码实现与图说「七类事件」措辞的精确对应,schema 的顶层 description 与各 `$defs` 段里都注明了。
|
||||
|
||||
写失败时的策略遵循 H1/H2 的 best-effort 铁律:轨迹写失败默认不阻塞主生成流程,但落一条告警——不让一次落库抖动把整局已经跑出来的生成废掉,也不让它无声丢失。这条策略在 adapter 代码里实现(`ingest` / `_emit_to_sink` 全包 try、只告警不抛),不在 schema 的约束范围内,但读契约时该一并知道。
|
||||
143
contracts/trace/tier2-trace-event.schema.json
Normal file
143
contracts/trace/tier2-trace-event.schema.json
Normal file
@ -0,0 +1,143 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://wanxiang.ai/contracts/trace/tier2-trace-event.schema.json",
|
||||
"title": "Tier2TraceEvent",
|
||||
"description": "tier2 富游戏自治生成线的统一 trace 事件契约(H 族 · A2)。【这是什么】一条 trace 记录 = 一次生成轨迹里的一步,由 tier2/gen-worker/observability/trace.py 的 TraceAdapter 订阅 AgentScope 2.0.2 官方 typed Event System(reply_stream() 吐的 AgentEvent 流)映射而成。tier2 这条线只「订阅 + 映射」,不「埋点 + 采集」——事件流是框架现成件(源码 agentscope/src/agentscope/event/_event.py 已逐条核过)。【为什么独立成约】统一 trace 要让两条异构生成线(tier2 ReAct 多轮 / SAA 16 节点)写进同一张表,靠的是「接口对称、内容不对称」:一个对称的公共核心五字段子集 + 各轨一个不对称的 ext 扩展段——消灭 split-brain 是『每条派发路诚实镜像它真有的字段、没有的绝不编造』,不是强求字段对齐(H1)。本 schema 定义的是 tier2 这一轨写进统一表的记录形状;SAA 那一轨的扩展段由 Java 线另一个 adapter 产、字段各写各的。【字段口径来源】所有字段忠实映射 trace.py 已实现的 TraceStep / to_trace_step,凡代码未产的字段一律不造;代码注释提及为可选、当前未实填的字段(如 cost.tokens.cached、cost.cost_rmb)在各自 description 标注其填充时机。【与图说的对应】公共核心五字段 = H1 对称子集;ext 三段(reasoning/action/observation)= H1/H2 tier2 ReAct 三段扩展段,逐段对到 H2「七类强类型事件」(TextBlock*/ThinkingBlock* → reasoning、ToolCall* → action、ToolResult* → observation;ModelCall* 不单独成段而落 cost.tokens + ext.raw,ReplyStart/End 落 ext.raw 作一步回复边界)。【additive 立位】contracts/trace/ 是契约组新一类 additive 立位,本约不改、不破坏任何现有契约(events / game-package / tier2-verdict 等);随 0号 spike 或控制面 phase-1 才真接代码。状态=建(tier2 待 spike)。详见 docs/architecture/架构/生成引擎/tier2细节图说-H-观测与成本.md 图 H1/H2,以及 tier2/gen-worker/observability/trace.py。",
|
||||
"type": "object",
|
||||
"required": ["traceId", "step", "timestamp", "ext"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"traceId": {
|
||||
"description": "公共核心子集字段(两条生成线同名同义必填)。一次生成的轨迹主键,贯穿整条生成任务链路,是对账 / 成本关联键,对接 tier2-verdict.schema.json 的 evidence.traceId。由 TraceAdapter 在构造时钉定,本次轨迹所有 step 共用同一个 traceId(trace.py TraceAdapter.trace_id)。",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"step": {
|
||||
"description": "公共核心子集字段。第几步(tier2 = TraceAdapter 每 ingest 一个 AgentEvent 递增一次的步序;在 SAA 那一轨语义为第几节点)。从 0 起,由 adapter 内部计数器维护(trace.py TraceAdapter._step)。",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"cost": {
|
||||
"description": "公共核心子集字段。这一步折成人民币的成本载体;仅在 ModelCallEndEvent 那一步非空(End 事件带 token 用量),其余事件步为 null。本 adapter 只记 token 量,折人民币(cost_rmb)由 cost.py 按 new-api quota 权威口径事后 best-effort 回填(成本权威源是 new-api 计费行,trace 不携金额估算)。",
|
||||
"type": ["object", "null"],
|
||||
"$ref": "#/$defs/cost"
|
||||
},
|
||||
"verdict": {
|
||||
"description": "公共核心子集字段。这一步 / 这道门的裁决(可空)。tier2 这一轨当前仅在 ToolResultEndEvent 处由工具执行最终状态(ToolResultState)填入(成功 / 失败 / 拒绝 / 中断 / 运行中),其余事件步为 null;门级裁决(九门 / 富游戏三门)由裁决引擎 judge 另行回填,不在 adapter 映射阶段产生(trace.py to_trace_step:verdict = ToolResultEnd 的 state)。",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"timestamp": {
|
||||
"description": "公共核心子集字段。本步对应事件的发生时刻,取 AgentEvent.created_at(ISO8601 字符串,源码 EventBase.created_at = datetime.now().isoformat())。事件缺 created_at 时为兜底空串。",
|
||||
"type": "string"
|
||||
},
|
||||
"ext": {
|
||||
"description": "tier2 扩展段(不对称段,各轨各写各的,SAA 轨的扩展段字段与此不同名也不要求对齐)。一个 JSON 对象,必含 raw(原始事件类型 + 关键字段,反查用),按当前事件类型可含 reasoning / action / observation 三段之一(ReAct 三段:想一步 reason → 做一个动作 act → 看一次结果 observe)。一个事件步至多落三段中的一段;ModelCall* 与 ReplyStart/End 不落三段、只把关键字段写进 raw(trace.py to_trace_step:ext = {raw, [reasoning|action|observation]?})。",
|
||||
"$ref": "#/$defs/ext"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"cost": {
|
||||
"type": ["object", "null"],
|
||||
"description": "成本载体(ModelCallEndEvent 步)。tokens 由 adapter 实填(从 End 事件抓 input_tokens / output_tokens);cost_rmb 与 tokens.cached 是代码注释明确为可选、当前未由 adapter 实填的字段,标注其归属:cost_rmb 由 cost.py 折算回填,cached 预留缓存命中 token(代码 TraceStep 注释 {tokens?:{in,out,cached}, cost_rmb?})。",
|
||||
"additionalProperties": false,
|
||||
"required": ["tokens"],
|
||||
"properties": {
|
||||
"tokens": {
|
||||
"description": "本次模型调用的 token 用量。in / out 由 adapter 从 ModelCallEndEvent.input_tokens / output_tokens 抓取并实填;cached 预留(缓存命中 token,当前 adapter 未填,见 trace.py 注释)。",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["in", "out"],
|
||||
"properties": {
|
||||
"in": { "description": "输入 token 数(ModelCallEndEvent.input_tokens)。", "type": "integer", "minimum": 0 },
|
||||
"out": { "description": "输出 token 数(ModelCallEndEvent.output_tokens)。", "type": "integer", "minimum": 0 },
|
||||
"cached": { "description": "缓存命中 token 数(可选,当前 adapter 未实填,预留;代码注释 {in,out,cached})。", "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"cost_rmb": {
|
||||
"description": "本步折算人民币(可选)。adapter 不填,由 cost.py 读 new-api logs.quota 按 traceId 关联后 best-effort 回填(权威口径;trace 阶段不带金额估算)。",
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ext": {
|
||||
"type": "object",
|
||||
"description": "tier2 扩展段对象。必含 raw;reasoning / action / observation 三段按事件类型至多落一段(纯函数 to_trace_step 对单事件的映射结果)。additionalProperties:false 锁死段名,防止 adapter 之外注入未约定段。",
|
||||
"additionalProperties": false,
|
||||
"required": ["raw"],
|
||||
"properties": {
|
||||
"raw": { "$ref": "#/$defs/raw" },
|
||||
"reasoning": { "$ref": "#/$defs/reasoningSeg" },
|
||||
"action": { "$ref": "#/$defs/actionSeg" },
|
||||
"observation": { "$ref": "#/$defs/observationSeg" }
|
||||
}
|
||||
},
|
||||
"raw": {
|
||||
"type": "object",
|
||||
"description": "原始事件类型 + 关键字段(调试 / 反查用,非判定来源)。所有事件步必填 event(原始 AgentEvent 类型名);其余键按事件类型有条件出现:ModelCallStart 带 model_name、ModelCallEnd 带 model_call_end=true(token 已落 cost),ReplyStart/End 带 reply_boundary 与 reply_id(ReplyStart 另带 agent_name)。映射源:to_trace_step 对 ModelCall* / ReplyStart-End / 其余事件(熔断 / HITL / Custom 等)只落 raw 不落三段。",
|
||||
"additionalProperties": true,
|
||||
"required": ["event"],
|
||||
"properties": {
|
||||
"event": { "description": "原始 AgentEvent 类型名(如 ReplyStartEvent / ModelCallEndEvent / ToolCallStartEvent;映射逻辑取 type(event).__name__,序列化 dict 形态取 type 字段)。", "type": "string", "minLength": 1 },
|
||||
"model_name": { "description": "模型名(仅 ModelCallStartEvent 步;ModelCallStartEvent.model_name)。", "type": ["string", "null"] },
|
||||
"model_call_end": { "description": "标记本步是 ModelCallEndEvent(token 用量已落 cost.tokens),恒为 true。", "type": "boolean" },
|
||||
"reply_boundary": { "description": "一轮回复边界事件类型名(ReplyStartEvent / ReplyEndEvent;框住一步完整 reason→act→observe)。", "type": "string" },
|
||||
"reply_id": { "description": "本轮回复 id(ReplyStart/End 步;AgentEvent.reply_id)。", "type": ["string", "null"] },
|
||||
"agent_name": { "description": "agent 名(仅 ReplyStartEvent 步;ReplyStartEvent.name)。", "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"reasoningSeg": {
|
||||
"type": "object",
|
||||
"description": "reason 段(想一步)。映射七类事件中的思考块与文本块:ThinkingBlockStart/Delta/End、TextBlockStart/Delta/End(源码事件类 ThinkingBlock* / TextBlock*)。phase 恒为 reasoning;kind 记具体原始事件类名;delta 仅在 *DeltaEvent 带增量文本 / 思考片段(落 sink 时可截断,此处保真)。",
|
||||
"additionalProperties": false,
|
||||
"required": ["phase", "kind"],
|
||||
"properties": {
|
||||
"phase": { "description": "段标记,恒为 reasoning。", "type": "string", "const": "reasoning" },
|
||||
"kind": {
|
||||
"description": "具体原始事件类名(thinking / text 块的六类之一)。",
|
||||
"type": "string",
|
||||
"enum": ["ThinkingBlockStartEvent", "ThinkingBlockDeltaEvent", "ThinkingBlockEndEvent", "TextBlockStartEvent", "TextBlockDeltaEvent", "TextBlockEndEvent"]
|
||||
},
|
||||
"delta": { "description": "增量文本 / 思考片段(仅 Delta 事件带;ThinkingBlockDeltaEvent.delta / TextBlockDeltaEvent.delta)。", "type": "string" }
|
||||
}
|
||||
},
|
||||
"actionSeg": {
|
||||
"type": "object",
|
||||
"description": "act 段(做一个动作)。映射工具调用三类事件:ToolCallStart/Delta/End(源码 ToolCall* 事件类)。phase 恒为 action;kind 记具体原始事件类名;tool / toolCallId 在事件带对应字段时填(ToolCallStart 带工具名 tool_call_name 与 tool_call_id);argsDelta 仅 ToolCallDeltaEvent 带工具参数 JSON 增量片段。",
|
||||
"additionalProperties": false,
|
||||
"required": ["phase", "kind"],
|
||||
"properties": {
|
||||
"phase": { "description": "段标记,恒为 action。", "type": "string", "const": "action" },
|
||||
"kind": {
|
||||
"description": "具体原始事件类名(工具调用三类之一)。",
|
||||
"type": "string",
|
||||
"enum": ["ToolCallStartEvent", "ToolCallDeltaEvent", "ToolCallEndEvent"]
|
||||
},
|
||||
"tool": { "description": "工具名(事件带 tool_call_name 时填;ToolCallStartEvent.tool_call_name)。", "type": "string" },
|
||||
"toolCallId": { "description": "工具调用 id(事件带 tool_call_id 时填;贯穿同一次调用的 Call 与 Result)。", "type": "string" },
|
||||
"argsDelta": { "description": "工具参数 JSON 增量片段(仅 ToolCallDeltaEvent 带;ToolCallDeltaEvent.delta)。", "type": "string" }
|
||||
}
|
||||
},
|
||||
"observationSeg": {
|
||||
"type": "object",
|
||||
"description": "observe 段(看一次结果)。映射工具结果四类事件:ToolResultStart / ToolResultTextDelta / ToolResultDataDelta / ToolResultEnd(源码 ToolResult* 事件类)。phase 恒为 observation;kind 记具体原始事件类名;toolCallId / tool 在事件带对应字段时填;state 仅 ToolResultEndEvent 带(工具执行最终状态 ToolResultState,use_enum_values 故为小写字符串值),同时该 state 被填进顶层 verdict。",
|
||||
"additionalProperties": false,
|
||||
"required": ["phase", "kind"],
|
||||
"properties": {
|
||||
"phase": { "description": "段标记,恒为 observation。", "type": "string", "const": "observation" },
|
||||
"kind": {
|
||||
"description": "具体原始事件类名(工具结果四类之一)。",
|
||||
"type": "string",
|
||||
"enum": ["ToolResultStartEvent", "ToolResultTextDeltaEvent", "ToolResultDataDeltaEvent", "ToolResultEndEvent"]
|
||||
},
|
||||
"toolCallId": { "description": "对应工具调用 id(事件带 tool_call_id 时填)。", "type": "string" },
|
||||
"tool": { "description": "工具名(事件带 tool_call_name 时填;ToolResultStartEvent.tool_call_name)。", "type": "string" },
|
||||
"state": {
|
||||
"description": "工具执行最终状态(仅 ToolResultEndEvent 带;源码 ToolResultState 枚举,use_enum_values 落为小写字符串值)。该值同时被填进顶层 verdict 作这一步裁决。",
|
||||
"type": "string",
|
||||
"enum": ["success", "error", "interrupted", "denied", "running"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -70,6 +70,22 @@ M3(MiniMax-M3,中等模型)自治写出的面包店经营合成富游戏
|
||||
检查(play-scene 工厂结构)考虑做成更稳的结构化校验,或进一步收窄 agent 可写面。相关 commit 在
|
||||
`feat/tier2-phaser-engine`(b43d59c/f8ec30c/0fd0d4f/f64dcc6/8f2bbce 五个)。
|
||||
|
||||
## 图说对账补全(2026-06-24)
|
||||
|
||||
把 0 号 spike 为过门收窄掉、但 tier2 图说明确要求的「核心引擎待补」项补齐。改动全部加性 / observe-only:金标 fixture 冒烟仍 ACCEPT(九门 9/9 + 富游戏三门 3/3,门一道没放松),真依赖下全链 import + 自测 + 一款真 M3 跑端到端验证通过。
|
||||
|
||||
**n≥30 runbook 执行基建(G 族)。** 第一段只跑了 M3、n=1、driver 冻结;要测真实成功率与成本分布,得先有批跑底座。新增便宜档模型 client(`worker/config.py` 的 `build_model_openai`,deepseek-v4-flash/pro 经 new-api OpenAI 兼容路,与 M3 的 Anthropic 原生路并存,base 口径相反、各自注释写清);结构化采集字段(`worker/run_record.py`,把 G4 采集字段表编译成可序列化 RunRecord,含退路树分流键 fail_system);退路树五出口判定器(`worker/fallback_tree.py`,Q1–Q4 数字触发线 → go/R1/R2/R3/KEEP,★阈值集中常量区待实测校准);批跑编排与矩阵聚合(`batch_run.py` 跑 model×variant×n 矩阵、断点续跑、失败隔离;`aggregate.py` 出三图并喂判定器)。
|
||||
|
||||
**观测/成本接线(H 族)。** 此前 `observability/{cost,trace}.py` 件已造齐却是孤儿、从未接进 run 主链。现在 `run_studio` 收口把 RecordingChatModel.records 折成 ¥(新增 `observability/newapi_pricing.py` 活读 new-api /api/pricing 倍率,取不到回落显式参数并告警,绝不中断主链);新增 `Tier2TraceMiddleware` 挂在 writer agent 最外层洋葱,把 ReAct 全事件旁路 ingest 进 TraceAdapter。真跑实测:cost_rmb=1.29(pricingSource=newapi-live)、trace 647 事件 dropped=0。`contracts/trace/` 立了 additive trace 事件契约位(忠实于 trace.py 真实落 sink 形状)。
|
||||
|
||||
**L3 视觉软检接线(D 族 · observe-only)。** player_system 提示词此前从不被调、verdict.L3.score 恒 null。现在 `run_studio` 收口后调一次 M3 多模态(真截图 after-play.png + 真玩取证 → fun 1–5 映射 0–100 分),只写 verdict.L3.{scoreOnly,score,notes},绝不参与 decision。真跑实测:L3 score=25 准确指出「`[object Object]` 满屏 + 订单零交付却判胜」,而 decision=fix 仍由 L1 硬门 / 熔断裁、与 L3 无关——observe-only 铁律落实、防 Goodhart 成立(坏游戏正确被拒,金标仍过)。
|
||||
|
||||
**没补的,按既定决策留后,不投机抢建:**
|
||||
- *设计判后续(plan 决策⑤ gate 到 B 门后才投)*:阶段 1 工作室 Agent Team 多 agent 设计、第二装载分支 + 源项目落库取回(交后端)、完整配置外置、控制面 / 管理面(B 族;D12 与 GenerationDispatcher 属 SAA Java 后端、不在本分支)、ReMe、Agent Service / MessageBus / Workspace 双轴 / checkpoint(决策②明写「本地 runner·不上 Agent Service」)。
|
||||
- *spike 统计相(是「跑」不是「写」)*:n≥30、5×6 题面变体、模型矩阵跑序、过门率 / 成本统计、★阈值校准、创始人亲玩软门——批跑底座现已就位,可直接执行。
|
||||
- *跨分支(待 A-model 合并对账)*:4 个引擎无关插件复用、advisory 分级(本引擎分支 play-phaser 已字面落 driven 两态,合并时对账即可)。
|
||||
- *图说待回写(交生成引擎子树 doc 线 / 在飞 session;本会话不跨分支跨 session 改 canonical)*:A4/C3 把「官方 ReplyBudgetControlMiddleware 软刹」画成现成,但 2.0.2 源码核验该类不存在(tier2 已用自建 on_system_prompt 替代);C3 预算闸实际是硬熔断、比图说当前口径强;C 族 frontmatter「尚未落代码」已过时(0 号 spike PASSED);D 族 advisory 标「待 A-model 合并」,本引擎分支已字面落。
|
||||
|
||||
## 协调
|
||||
|
||||
按 git 中介异步:本分支 `docs/tier2-plan` 已推送。你在该分支实现、提交;6c6g 侧(我)审 commit、按需更新计划/设计。完成判据是 git push,不是读屏。
|
||||
|
||||
333
tier2/gen-worker/aggregate.py
Normal file
333
tier2/gen-worker/aggregate.py
Normal file
@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""aggregate.py —— tier2 0号 spike · JSONL run-records → 矩阵级三图聚合 + 退路树判定。
|
||||
|
||||
【这份在 spike runbook 里的位置(权威 = G 族图说)】
|
||||
docs/architecture/架构/生成引擎/tier2细节图说-G-spike-runbook.md 图 G4 底部那条汇总带:
|
||||
run 级采集字段(每款一行,batch_run.py 落的 JSONL)卷成【矩阵级三张图】——
|
||||
① 过门率 = pass 款数 / n;
|
||||
② ¥/成功款 = Σcost / pass 款数;
|
||||
③ 收敛中位数 = median(repairs);
|
||||
外加 fail_system 分布(经营品类特有,退路树 Q2/Q3 分流的关键依据)。
|
||||
这三图按 model × brief_variant 分组(G3:过门率/成本/墙钟各有归属对象,「60% 是哪个模型的 60%」要答得上),
|
||||
并卷成 fallback_tree.decide 吃的矩阵级 by_model 聚合,直接喂退路树判 go/no-go(图 G5)。
|
||||
|
||||
【职责边界:纯读 + 纯算 + 输出报告,零生成副作用】
|
||||
- 只读 JSONL 台账(batch_run 落的 RunRecord 行)、纯算聚合、调 fallback_tree.decide(纯函数判定器)。
|
||||
- 不发网络、不跑 chrome、不改任何 run 记录;输出 = 一个聚合 JSON + 一段人读文本摘要(写文件 + 打印)。
|
||||
- 不依赖 agentscope(只 import worker.run_record + worker.fallback_tree,二者皆纯数据/纯函数),6c6g 可跑。
|
||||
|
||||
【observe-only / 防 Goodhart】
|
||||
本模块只把 run 记录里 judge 纯代码判出的 pass/fail 聚合呈现,绝不改裁决;退路树出口也只是「据数字裁一步」的
|
||||
建议,人锚软门(创始人试玩判肥鹅味)不可被本聚合替代——GO 出口的文本会显式提示「人锚仍须过」。
|
||||
|
||||
CLI 用法:
|
||||
python aggregate.py --in results/spike-runs.jsonl
|
||||
python aggregate.py --in results/spike-runs.jsonl --out-json results/spike-agg.json --out-txt results/spike-agg.txt
|
||||
|
||||
可 import 用法:
|
||||
from aggregate import load_records, aggregate, build_decide_stats
|
||||
recs = load_records("results/spike-runs.jsonl")
|
||||
agg = aggregate(recs) # 矩阵级三图 + fail_system 分布
|
||||
stats = build_decide_stats(agg) # 卷成 fallback_tree.decide 的 by_model schema
|
||||
verdict = fallback_tree.decide(stats) # 五出口判定
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 包内/直跑兼容:把 gen-worker/ 加进 sys.path 使顶层包 `worker` 可解析。
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from worker import fallback_tree # noqa: E402 —— A4 退路树判定器(纯函数)
|
||||
from worker.run_record import RunRecord # noqa: E402 —— A3 采集记录(纯数据)
|
||||
|
||||
# fail_system 桶名(对齐 run_record.FailSystem Literal + fallback_tree 的桶名常量,集成接缝唯一口径)。
|
||||
_FAIL_SYSTEM_BUCKETS = ("resource", "merge", "order", "presentation")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 1) 读 JSONL 台账 → RunRecord 列表
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def load_records(path: str) -> list[RunRecord]:
|
||||
"""逐行读 JSONL 台账,反序列化成 RunRecord 列表。
|
||||
|
||||
坏行(JSON 解析失败 / 字段缺失到无法构造)跳过不计、不中断(best-effort:一行坏不该让整份聚合崩),
|
||||
但打印告警让人看见(采集数据有坏行是要查的)。空文件 / 文件不存在 → 空列表。
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
print(f"[aggregate] ⚠ 台账不存在:{path}(无数据可聚合)", file=sys.stderr)
|
||||
return []
|
||||
out: list[RunRecord] = []
|
||||
for ln, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
out.append(RunRecord.from_jsonl_line(line))
|
||||
except Exception as e: # noqa: BLE001 —— 坏行跳过、告警、不中断
|
||||
print(f"[aggregate] ⚠ 第 {ln} 行解析失败已跳过:{type(e).__name__}: {e}", file=sys.stderr)
|
||||
return out
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 2) 分组聚合工具(纯函数:一组 RunRecord → 三图指标 + fail_system 分布)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def _agg_cell(recs: list[RunRecord]) -> dict[str, Any]:
|
||||
"""把一组 RunRecord(同一分组,如某 model×variant 格)聚成三图指标 + fail_system 分布。
|
||||
|
||||
产出字段(对齐 G4 矩阵级三图 + fallback_tree.decide 的 by_model schema 字段名):
|
||||
- n = 这组的总跑次数(分母);
|
||||
- pass_count = 过门款数(pass_gate=True);
|
||||
- pass_rate = pass_count / n(图① 过门率;n=0 → 0.0);
|
||||
- cost_rmb_sum = Σcost_yuan(全部款,含失败款——失败也烧了钱);
|
||||
- cost_rmb_per_pass = Σcost_yuan / pass_count(图② ¥/成功款;无过门款 → None,避免除零误导);
|
||||
- wall_s_median = median(wall_seconds)(墙钟图;空 → 0.0);
|
||||
- repairs_median = median(repairs)(图③ 收敛中位数;空 → 0);
|
||||
- fail_system = {桶: 失败款数}(仅 fail_gate 款计入;退路树 Q2/Q3 读它);
|
||||
- fail_stage = {段: 失败款数}(失败定位的段分布,人读摘要用)。
|
||||
"""
|
||||
n = len(recs)
|
||||
passes = [r for r in recs if r.pass_gate]
|
||||
pass_count = len(passes)
|
||||
cost_sum = round(sum(r.cost_yuan for r in recs), 5)
|
||||
fail_system: dict[str, int] = {}
|
||||
fail_stage: dict[str, int] = {}
|
||||
for r in recs:
|
||||
if r.pass_gate:
|
||||
continue
|
||||
if r.fail_system:
|
||||
fail_system[r.fail_system] = fail_system.get(r.fail_system, 0) + 1
|
||||
if r.fail_stage:
|
||||
fail_stage[r.fail_stage] = fail_stage.get(r.fail_stage, 0) + 1
|
||||
return {
|
||||
"n": n,
|
||||
"pass_count": pass_count,
|
||||
"pass_rate": round(pass_count / n, 4) if n else 0.0,
|
||||
"cost_rmb_sum": cost_sum,
|
||||
# ¥/成功款:无过门款时 None(不写 0,0 会被误读成「免费过门」;退路树读 by_model 时也不用它判 GO)。
|
||||
"cost_rmb_per_pass": round(cost_sum / pass_count, 5) if pass_count else None,
|
||||
"wall_s_median": round(statistics.median([r.wall_seconds for r in recs]), 2) if recs else 0.0,
|
||||
"repairs_median": int(statistics.median([r.repairs for r in recs])) if recs else 0,
|
||||
"fail_system": fail_system,
|
||||
"fail_stage": fail_stage,
|
||||
}
|
||||
|
||||
|
||||
def _group_by(recs: list[RunRecord], key) -> dict[str, list[RunRecord]]:
|
||||
"""按 key(rec → 分组键)把记录分桶,保持首次出现顺序。"""
|
||||
out: dict[str, list[RunRecord]] = {}
|
||||
for r in recs:
|
||||
out.setdefault(key(r), []).append(r)
|
||||
return out
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 3) 主聚合:aggregate(recs) → 矩阵级三图(by_model / by_variant / by_model_variant + overall)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def aggregate(recs: list[RunRecord]) -> dict[str, Any]:
|
||||
"""把 run-records 卷成矩阵级聚合(三图按 model / variant / model×variant 三种分组 + 全局)。
|
||||
|
||||
Returns(聚合报告主体):
|
||||
{
|
||||
"total_runs": int, # 总跑次数
|
||||
"overall": {三图指标 + fail_system 分布}, # 不分组的全局聚合
|
||||
"by_model": {model: {三图...}}, # 按模型档分组(G3:过门率/成本归属到档)
|
||||
"by_variant":{variant: {三图...}}, # 按题面变体分组(变体维度方差)
|
||||
"by_model_variant": {"model | variant": {三图...}}, # 5×6 矩阵每格(最细粒度)
|
||||
"fail_system_overall": {桶: 失败款数}, # 跨全部便宜档/全档的 fail_system 分布(退路树兜底用)
|
||||
}
|
||||
"""
|
||||
by_model = {m: _agg_cell(g) for m, g in _group_by(recs, lambda r: r.model).items()}
|
||||
by_variant = {v: _agg_cell(g) for v, g in _group_by(recs, lambda r: r.brief_variant).items()}
|
||||
by_mv = {f"{r_m} | {r_v}": _agg_cell(g)
|
||||
for (r_m, r_v), g in _group_by(recs, lambda r: (r.model, r.brief_variant)).items()}
|
||||
|
||||
# 全局 fail_system 分布(退路树未给 fail_system_overall 时的兜底来源;这里直接全档合)。
|
||||
fail_overall: dict[str, int] = {}
|
||||
for r in recs:
|
||||
if not r.pass_gate and r.fail_system:
|
||||
fail_overall[r.fail_system] = fail_overall.get(r.fail_system, 0) + 1
|
||||
|
||||
return {
|
||||
"total_runs": len(recs),
|
||||
"overall": _agg_cell(recs),
|
||||
"by_model": by_model,
|
||||
"by_variant": by_variant,
|
||||
"by_model_variant": by_mv,
|
||||
"fail_system_overall": fail_overall,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 4) 卷成 fallback_tree.decide 吃的 by_model schema(集成接缝:聚合产物 → 判定器)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def build_decide_stats(agg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""把 aggregate() 的 by_model 转成 fallback_tree.decide 的 stats schema。
|
||||
|
||||
fallback_tree.decide 读的字段(见其 docstring 的 stats schema):
|
||||
by_model[档] = {pass_rate, n, pass_count, fail_system, cost_rmb_per_pass, repairs_median}
|
||||
+ 顶层 fail_system_overall(Q2 判失败是否集中表现层)。
|
||||
aggregate 的 by_model 每格已含这些字段(字段名刻意对齐),这里只做「挑字段 + 透传」,不重算。
|
||||
"""
|
||||
by_model_in = agg.get("by_model") or {}
|
||||
by_model_out: dict[str, Any] = {}
|
||||
for model, cell in by_model_in.items():
|
||||
by_model_out[model] = {
|
||||
"pass_rate": cell.get("pass_rate", 0.0),
|
||||
"n": cell.get("n", 0),
|
||||
"pass_count": cell.get("pass_count", 0),
|
||||
"fail_system": cell.get("fail_system", {}),
|
||||
"cost_rmb_per_pass": cell.get("cost_rmb_per_pass"),
|
||||
"repairs_median": cell.get("repairs_median", 0),
|
||||
}
|
||||
return {
|
||||
"by_model": by_model_out,
|
||||
# 顶层跨档 fail_system 分布(退路树 Q2/Q3 优先读它;缺则它内部由各便宜档现合)。
|
||||
"fail_system_overall": agg.get("fail_system_overall") or {},
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 5) 人读文本摘要(资深工程师看的散文式报告,不堆电报体)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def _fmt_pct(x: float | None) -> str:
|
||||
return f"{x:.0%}" if isinstance(x, (int, float)) else "—"
|
||||
|
||||
|
||||
def _fmt_yuan(x: float | None) -> str:
|
||||
return f"¥{x:.3f}" if isinstance(x, (int, float)) else "—(无过门款)"
|
||||
|
||||
|
||||
def render_text_summary(agg: dict[str, Any], decide_out: dict[str, Any]) -> str:
|
||||
"""把聚合 + 退路树判定渲染成一段人读文本摘要(供终端打印 + 落 .txt)。
|
||||
|
||||
结构:总览 → 按模型档三图(过门率/¥每成功款/墙钟中位/收敛中位/失败分布)→ 按变体 → 退路树裁决。
|
||||
阈值口径不在这里硬编码,引用 fallback_tree 的常量(单一事实源),避免摘要与判定器漂移。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
ov = agg.get("overall") or {}
|
||||
lines.append("=" * 72)
|
||||
lines.append("tier2 0号 spike · 矩阵级聚合报告(过门率 / 成本 / 墙钟 by model×variant + 退路树)")
|
||||
lines.append("=" * 72)
|
||||
lines.append(f"总跑 {agg.get('total_runs', 0)} 款;"
|
||||
f"全局过门率 {_fmt_pct(ov.get('pass_rate'))}"
|
||||
f"({ov.get('pass_count', 0)}/{ov.get('n', 0)});"
|
||||
f"全局 ¥/成功款 {_fmt_yuan(ov.get('cost_rmb_per_pass'))};"
|
||||
f"总成本 ¥{ov.get('cost_rmb_sum', 0)}。")
|
||||
|
||||
# ── 按模型档(G3:三图归属到档,这是 go/no-go 的主分组)──
|
||||
lines.append("")
|
||||
lines.append("【按模型档】(过门率 / ¥每成功款 / 墙钟中位 / 收敛中位 / 失败系统分布)")
|
||||
by_model = agg.get("by_model") or {}
|
||||
if not by_model:
|
||||
lines.append(" (无数据)")
|
||||
for model, c in by_model.items():
|
||||
fs = c.get("fail_system") or {}
|
||||
fs_desc = ", ".join(f"{k}:{v}" for k, v in fs.items()) or "无失败或系统不明"
|
||||
lines.append(
|
||||
f" {model:<20} 过门率 {_fmt_pct(c.get('pass_rate')):>5}"
|
||||
f"({c.get('pass_count', 0)}/{c.get('n', 0)}) "
|
||||
f"¥/成功款 {_fmt_yuan(c.get('cost_rmb_per_pass'))} "
|
||||
f"墙钟中位 {c.get('wall_s_median', 0)}s "
|
||||
f"收敛中位 {c.get('repairs_median', 0)} 轮 "
|
||||
f"失败系统[{fs_desc}]")
|
||||
|
||||
# ── 按题面变体(变体维度方差;G3 要 5 变体各跑,看变体间过门率差异)──
|
||||
lines.append("")
|
||||
lines.append("【按题面变体】(过门率 / ¥每成功款 / 墙钟中位)")
|
||||
by_variant = agg.get("by_variant") or {}
|
||||
if not by_variant:
|
||||
lines.append(" (无数据)")
|
||||
for v, c in by_variant.items():
|
||||
lines.append(
|
||||
f" {v:<8} 过门率 {_fmt_pct(c.get('pass_rate')):>5}"
|
||||
f"({c.get('pass_count', 0)}/{c.get('n', 0)}) "
|
||||
f"¥/成功款 {_fmt_yuan(c.get('cost_rmb_per_pass'))} "
|
||||
f"墙钟中位 {c.get('wall_s_median', 0)}s")
|
||||
|
||||
# ── 全局 fail_system 分布(退路树分流的关键依据)──
|
||||
lines.append("")
|
||||
fso = agg.get("fail_system_overall") or {}
|
||||
fso_desc = ", ".join(f"{k}:{v}" for k, v in fso.items()) or "无失败或系统不明"
|
||||
lines.append(f"【全局 fail_system 分布】{fso_desc}")
|
||||
|
||||
# ── 退路树裁决(fallback_tree.decide 的五出口 + 逐条触发线 reasons)──
|
||||
lines.append("")
|
||||
lines.append("【退路树裁决(图 G5 五出口)】")
|
||||
lines.append(f" 出口 = {decide_out.get('exit')}")
|
||||
for r in (decide_out.get("reasons") or []):
|
||||
lines.append(f" · {r}")
|
||||
lines.append("")
|
||||
lines.append("注:退路树只裁数字面;人锚软门(创始人试玩判肥鹅味)不可被替代,GO 仍须另行过人锚。")
|
||||
lines.append(" ★ 阈值(过门率门 / ¥3每款 / 集中比例)= directional v1,需创始人和实测校准"
|
||||
"(集中在 worker/fallback_tree.py 常量区)。")
|
||||
lines.append("=" * 72)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 6) 顶层:从 JSONL 直接产完整报告(聚合 + decide stats + 退路树 + 文本摘要)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def report_from_jsonl(path: str) -> dict[str, Any]:
|
||||
"""读 JSONL → 聚合 → 卷 decide stats → 退路树判定 → 组装完整报告 dict(含人读文本)。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"aggregate": {...}, # aggregate() 的矩阵级三图
|
||||
"decide_stats": {...}, # 喂 fallback_tree 的 by_model schema
|
||||
"fallback_decision": {exit, reasons}, # 退路树五出口判定
|
||||
"text_summary": "...", # 人读文本摘要
|
||||
}
|
||||
"""
|
||||
recs = load_records(path)
|
||||
agg = aggregate(recs)
|
||||
stats = build_decide_stats(agg)
|
||||
decision = fallback_tree.decide(stats)
|
||||
text = render_text_summary(agg, decision)
|
||||
return {
|
||||
"aggregate": agg,
|
||||
"decide_stats": stats,
|
||||
"fallback_decision": decision,
|
||||
"text_summary": text,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# CLI
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="tier2 0号 spike 聚合(JSONL run-records → 矩阵级三图 + 退路树判定)")
|
||||
ap.add_argument("--in", dest="in_path", required=True, help="输入 JSONL 台账(batch_run 落的)")
|
||||
ap.add_argument("--out-json", default=None,
|
||||
help="聚合报告 JSON 落点(默认 = 输入同目录 <stem>-agg.json)")
|
||||
ap.add_argument("--out-txt", default=None,
|
||||
help="人读文本摘要落点(默认 = 输入同目录 <stem>-agg.txt)")
|
||||
args = ap.parse_args()
|
||||
|
||||
rep = report_from_jsonl(args.in_path)
|
||||
|
||||
in_p = Path(args.in_path)
|
||||
out_json = Path(args.out_json) if args.out_json else in_p.with_name(in_p.stem + "-agg.json")
|
||||
out_txt = Path(args.out_txt) if args.out_txt else in_p.with_name(in_p.stem + "-agg.txt")
|
||||
|
||||
# 落 JSON(完整聚合 + decide stats + 判定;text_summary 也一并落,便于单文件回看)。
|
||||
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_json.write_text(json.dumps(rep, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
out_txt.write_text(rep["text_summary"], encoding="utf-8")
|
||||
|
||||
# 终端打印人读摘要(创始人要紧凑;细节落文件)。
|
||||
print(rep["text_summary"])
|
||||
print(f"\n[aggregate] 报告 → JSON {out_json} / TXT {out_txt}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
515
tier2/gen-worker/batch_run.py
Normal file
515
tier2/gen-worker/batch_run.py
Normal file
@ -0,0 +1,515 @@
|
||||
#!/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()
|
||||
@ -6,9 +6,11 @@
|
||||
- H1/H2 统一 trace:订阅 AgentScope 官方 Event System(typed 事件流),映射成统一 trace 形状
|
||||
(公共核心子集 traceId/step/cost/verdict/timestamp + tier2 扩展段),best-effort 不阻塞主生成流程。
|
||||
|
||||
两个子模块:
|
||||
- cost —— RecordingChatModel(Anthropic 路录制变体) + new-api quota 折¥(纯函数,显式传参)。
|
||||
- trace —— Event System → 统一 trace 形状的 adapter(best-effort)。
|
||||
三个子模块:
|
||||
- cost —— RecordingChatModel(Anthropic 路录制变体) + new-api quota 折¥(纯函数,显式传参)。
|
||||
- newapi_pricing —— new-api /api/pricing + /api/status 计费参数活读取(喂 cost_for_run 的 pricing/qpu/usd_rate;
|
||||
best-effort 取不到回落显式参数,httpx 惰性 import,6c6g 仍可 import 本模块)。
|
||||
- trace —— Event System → 统一 trace 形状的 adapter(best-effort)。
|
||||
|
||||
tier2 .agent 红线:复用只走显式传参,endpoint/key/model/pricing/quota 全显式传入,禁读 wg1 模块级全局。
|
||||
"""
|
||||
@ -21,6 +23,13 @@ from .cost import (
|
||||
cost_for_run,
|
||||
GroupedRecorder,
|
||||
)
|
||||
from .newapi_pricing import (
|
||||
fetch_pricing,
|
||||
fetch_status,
|
||||
fetch_pricing_params,
|
||||
DEFAULT_QUOTA_PER_UNIT,
|
||||
DEFAULT_USD_RATE,
|
||||
)
|
||||
from .trace import (
|
||||
TraceAdapter,
|
||||
TraceStep,
|
||||
@ -35,6 +44,12 @@ __all__ = [
|
||||
"tokens_by_model_from_records",
|
||||
"cost_for_run",
|
||||
"GroupedRecorder",
|
||||
# ── new-api 计费参数活读取(H3 接线)──
|
||||
"fetch_pricing",
|
||||
"fetch_status",
|
||||
"fetch_pricing_params",
|
||||
"DEFAULT_QUOTA_PER_UNIT",
|
||||
"DEFAULT_USD_RATE",
|
||||
# ── trace(H1/H2)──
|
||||
"TraceAdapter",
|
||||
"TraceStep",
|
||||
|
||||
159
tier2/gen-worker/observability/newapi_pricing.py
Normal file
159
tier2/gen-worker/observability/newapi_pricing.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""observability/newapi_pricing.py —— tier2 富游戏自治线 · new-api 计费参数活读取(H3 成本台账接线)。
|
||||
|
||||
职责(对 docs/architecture/架构/生成引擎/tier2细节图说-H-观测与成本.md 图 H3「new-api quota 折¥」一步):
|
||||
从 new-api 网关活读取折算所需的三件计费参数,喂给 observability.cost.cost_for_run 的
|
||||
pricing / qpu / usd_rate 形参 —— 把「token → ¥」那一步从「显式硬传参」升级成「网关权威活值」。
|
||||
权威成本源 = new-api(H3 钉死:成本不是估的,是从 new-api quota 口径折出来的),本模块就是接到这个口径的薄片。
|
||||
|
||||
① fetch_pricing() —— GET {base}/api/pricing → {model_name: {model_ratio, completion_ratio, cache_ratio}}。
|
||||
这套倍率就是 new-api 计费引擎结算时用的同一套(与 wg1 cost.py / orchestrator newapi_cost.py 同口径,
|
||||
已被 newapi-billing-plane-integration 验证);据它 + token 自算的 quota 与网关结算同公式(含缓存折扣)。
|
||||
② fetch_status() —— GET {base}/api/status → (quota_per_unit, usd_exchange_rate);缺则取编译默认 500000 / 7.3。
|
||||
③ fetch_pricing_params() —— 一步取齐三件,打成 cost_for_run 直接可用的 dict;**任何失败一律 best-effort 返回 None**
|
||||
(不抛),让调用方回落到显式 pricing 参数并 log 警告——取价失败绝不中断生成主链(H1/H3 best-effort 铁律)。
|
||||
|
||||
为什么不直读 PG logs.quota(H3 图说提到的另一条路):
|
||||
直读 new-api PostgreSQL 取每次调用的权威 quota(orchestrator newapi_cost.py 那条)要 mini-infra ssh + PG 口令 +
|
||||
按时间窗关联,是「跑批事后对账」的重口径;tier2 这条线在 run 收口处要的是「当次 run 即时折一笔成本进 RunRecord」,
|
||||
用 /api/pricing 倍率 + 当次 token 自算(与网关结算同公式)是最小且同口径的接法——pricing 倍率本身就是 quota 的来源。
|
||||
跑批级的 PG 权威对账留给编排/分析侧(复用 orchestrator newapi_cost.py),本模块不重复造那条重链路。
|
||||
|
||||
tier2 .agent 红线(复用只走显式传参):
|
||||
- base_url / api_key 经 worker.client(显式解析)传入,本模块不写死 base、不硬编码 key
|
||||
(key 权威来源 = docs/内网凭据与端点.md NEWAPI_KEY;经 client.get_api_key 从 env/.env 读)。
|
||||
- httpx 在函数内**惰性 import**:6c6g 静态校验环境无 httpx wheel,本模块仍能被 import(降级为「取价不可用、回落显式参数」),
|
||||
真读只在装了依赖的 mini-desktop / Mac 上发生。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# client 提供 get_api_key()(读 NEWAPI_KEY)与 resolve_base_url()(host 根 + 装代理旁路)。
|
||||
# 包内/直跑兼容导入(直跑 observability 下脚本时 worker 包仍可解析)。
|
||||
try:
|
||||
from worker import client # type: ignore
|
||||
except Exception: # pragma: no cover —— 直跑兜底:把 gen-worker/ 加进 sys.path 再取 worker.client
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from worker import client # type: ignore
|
||||
|
||||
|
||||
# new-api /api/status 缺字段时的编译默认(与 wg1 cost.py / orchestrator newapi_cost.py 同源:
|
||||
# 500000 quota = 1 USD;USD→¥ 汇率为标注假设、非 new-api 配置项,成本台账折算用)。
|
||||
DEFAULT_QUOTA_PER_UNIT = 500000.0
|
||||
DEFAULT_USD_RATE = 7.3
|
||||
|
||||
|
||||
def fetch_pricing(base_url: str, api_key: str, *, timeout_s: float = 20.0) -> dict:
|
||||
"""GET {base_url}/api/pricing → {model_name: {model_ratio, completion_ratio, cache_ratio}}。
|
||||
|
||||
这套倍率即 new-api 计费引擎结算用的同一套(cost.compute 据它 + token 按网关同公式自算 quota)。
|
||||
|
||||
:param base_url: new-api 网关 host 根(不带 /v1;凭据见 docs/内网凭据与端点.md NEWAPI_BASE_URL)。
|
||||
:param api_key: NEWAPI_KEY(sk- token);经 Authorization: Bearer 头传。
|
||||
:param timeout_s: 单次 HTTP 超时(秒)。
|
||||
:return: {model_name: pricing_dict};网关无数据时返回 {}。
|
||||
:raises: httpx / JSON 解析异常由调用方(fetch_pricing_params)best-effort 兜住,本函数不吞(便于上层记真因)。
|
||||
"""
|
||||
# 惰性 import:6c6g 无 httpx 时本模块仍可被 import(只是真读会在此抛 ImportError,由上层 best-effort 兜)。
|
||||
import httpx # noqa: PLC0415 —— 故意函数内 import,避免模块级硬依赖 httpx
|
||||
|
||||
# base 归一去尾斜杠(防 'host:3000/' 拼成 'host:3000//api/pricing')。
|
||||
base = (base_url or "").rstrip("/")
|
||||
resp = httpx.get(
|
||||
base + "/api/pricing",
|
||||
headers={"Authorization": "Bearer " + api_key},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data") or []
|
||||
# new-api /api/pricing 返回 data 为模型数组,每项含 model_name + 各倍率;按 model_name 索引成 dict。
|
||||
return {m["model_name"]: m for m in data if isinstance(m, dict) and m.get("model_name")}
|
||||
|
||||
|
||||
def fetch_status(base_url: str, *, timeout_s: float = 20.0) -> tuple[float, float]:
|
||||
"""GET {base_url}/api/status → (quota_per_unit, usd_exchange_rate);缺字段取编译默认。
|
||||
|
||||
/api/status 是公开端点(无需鉴权;wg1 cost.fetch_status 同口径);缺字段回落 500000 / 7.3。
|
||||
|
||||
:return: (quota_per_unit, usd_exchange_rate)。
|
||||
:raises: httpx / JSON 异常由上层 best-effort 兜住,本函数不吞。
|
||||
"""
|
||||
import httpx # noqa: PLC0415 —— 函数内惰性 import(同 fetch_pricing 理由)
|
||||
|
||||
base = (base_url or "").rstrip("/")
|
||||
resp = httpx.get(base + "/api/status", timeout=timeout_s)
|
||||
resp.raise_for_status()
|
||||
d = resp.json().get("data") or {}
|
||||
qpu = d.get("quota_per_unit", DEFAULT_QUOTA_PER_UNIT)
|
||||
usd_rate = d.get("usd_exchange_rate", DEFAULT_USD_RATE)
|
||||
# 防脏值:非正数 qpu 会让 cost.compute 的 usd=quota/qpu 出问题,回落默认。
|
||||
try:
|
||||
qpu = float(qpu)
|
||||
if qpu <= 0:
|
||||
qpu = DEFAULT_QUOTA_PER_UNIT
|
||||
except (TypeError, ValueError):
|
||||
qpu = DEFAULT_QUOTA_PER_UNIT
|
||||
try:
|
||||
usd_rate = float(usd_rate)
|
||||
except (TypeError, ValueError):
|
||||
usd_rate = DEFAULT_USD_RATE
|
||||
return qpu, usd_rate
|
||||
|
||||
|
||||
def fetch_pricing_params(
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout_s: float = 20.0,
|
||||
) -> Optional[dict]:
|
||||
"""一步取齐折算三件套(pricing / qpu / usd_rate),打成 cost_for_run 直接可用的 dict。
|
||||
|
||||
**best-effort 铁律**:取价是成本台账的旁路,绝不能因网关抖动 / 6c6g 无 httpx / key 缺失而中断生成主链。
|
||||
故任何异常一律捕获 → 返回 None + 落一条可追溯告警,让调用方回落到显式 pricing 参数(见 cost.cost_for_run)。
|
||||
|
||||
:param base_url: new-api host 根;None → client.resolve_base_url()(env NEWAPI_BASE_URL 或默认端点,已装代理旁路)。
|
||||
:param api_key: NEWAPI_KEY;None → client.get_api_key()(从 env/.env 读;缺则抛,被本函数 best-effort 兜成 None)。
|
||||
:param timeout_s: 单次 HTTP 超时。
|
||||
:return: {"pricing": {...}, "qpu": float, "usd_rate": float} 或 None(取价失败,调用方回落显式参数)。
|
||||
"""
|
||||
try:
|
||||
# base / key 解析:显式入参优先,否则经 client(红线:不在本模块写死 base、不硬编码 key)。
|
||||
resolved_base = base_url or client.resolve_base_url()
|
||||
resolved_key = api_key or client.get_api_key()
|
||||
pricing = fetch_pricing(resolved_base, resolved_key, timeout_s=timeout_s)
|
||||
qpu, usd_rate = fetch_status(resolved_base, timeout_s=timeout_s)
|
||||
if not pricing:
|
||||
# 取到空 pricing(网关无数据 / 模型表为空)也按取价失败处理,回落显式参数更安全。
|
||||
print(
|
||||
"[tier2-cost] new-api /api/pricing 返回空 pricing,"
|
||||
"回落到调用方显式 pricing 参数。",
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
return {"pricing": pricing, "qpu": qpu, "usd_rate": usd_rate}
|
||||
except Exception as exc: # noqa: BLE001 —— best-effort:取价任何失败都不中断主链,只告警 + 回落
|
||||
# 可追溯日志(错误路径铁律):记下取价失败真因,但不抛 —— 折算会回落到显式 pricing 参数。
|
||||
print(
|
||||
f"[tier2-cost] new-api 计费参数活读取失败(best-effort,回落显式 pricing 参数):"
|
||||
f"{type(exc).__name__}: {exc}",
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover —— 本地自检:真连 new-api 取一次计费参数(需 httpx + 网关可达)
|
||||
import json
|
||||
|
||||
params = fetch_pricing_params()
|
||||
if params is None:
|
||||
print("[newapi_pricing] 取价失败(见上方告警);真跑请在 mini-desktop / Mac 上装 httpx + 网关可达。")
|
||||
else:
|
||||
print(f"[newapi_pricing] qpu={params['qpu']} usd_rate={params['usd_rate']} "
|
||||
f"models={len(params['pricing'])}")
|
||||
# 抽样打印前 3 个模型的倍率(便于核对 MiniMax-M3 / deepseek 在不在表里)。
|
||||
sample = dict(list(params["pricing"].items())[:3])
|
||||
print(json.dumps(sample, ensure_ascii=False, indent=2))
|
||||
@ -21,6 +21,8 @@
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@ -29,18 +31,34 @@ from pathlib import Path
|
||||
try:
|
||||
from .. import config, roles, run
|
||||
from ..toolkit import Tier2Session, build_toolkit
|
||||
from ..middleware import CircuitBreakerMiddleware, Tier2CircuitBreak
|
||||
from ..middleware import CircuitBreakerMiddleware, Tier2CircuitBreak, Tier2TraceMiddleware
|
||||
except ImportError: # pragma: no cover —— 直接 python studio.py 兜底
|
||||
# 本文件在 tier2/gen-worker/worker/agent_loop/studio.py;把 gen-worker/ 加进 sys.path,
|
||||
# 使顶层包 `worker` 可解析(包名 worker 合法;目录名 gen-worker 含连字符不可直接 import)。
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from worker import config, roles, run # type: ignore
|
||||
from worker.toolkit import Tier2Session, build_toolkit # type: ignore
|
||||
from worker.middleware import CircuitBreakerMiddleware, Tier2CircuitBreak # type: ignore
|
||||
from worker.middleware import ( # type: ignore
|
||||
CircuitBreakerMiddleware, Tier2CircuitBreak, Tier2TraceMiddleware)
|
||||
|
||||
# 成本接线(H3):RecordingChatModel.records → cost_for_run 折¥;new-api 计费参数活读取(取不到回落显式参数)。
|
||||
# observability 是 gen-worker 顶层包(非 worker 子包);直跑兜底里已把 gen-worker/ 加进 sys.path。
|
||||
try:
|
||||
from observability.cost import tokens_by_model_from_records, cost_for_run
|
||||
from observability.newapi_pricing import fetch_pricing_params, DEFAULT_QUOTA_PER_UNIT, DEFAULT_USD_RATE
|
||||
except Exception: # pragma: no cover —— 直跑/路径未就位兜底
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from observability.cost import tokens_by_model_from_records, cost_for_run # type: ignore
|
||||
from observability.newapi_pricing import ( # type: ignore
|
||||
fetch_pricing_params, DEFAULT_QUOTA_PER_UNIT, DEFAULT_USD_RATE)
|
||||
|
||||
# 框架接缝(2.0.2):Agent + ReActConfig + UserMsg + AgentState + BYPASS 权限。
|
||||
from agentscope.agent import Agent, ReActConfig
|
||||
from agentscope.message import UserMsg
|
||||
# L3 视觉软检多模态消息块(2.0.2 源码核验:UserMsg.content 允许 text/data 两类块;
|
||||
# 图走 DataBlock(source=Base64Source(media_type='image/png')),AnthropicChatFormatter 会转成
|
||||
# Anthropic image base64 入参,见 formatter/_anthropic_formatter.py:227 _format_data_block)。
|
||||
from agentscope.message import TextBlock, DataBlock, Base64Source
|
||||
from agentscope.state import AgentState
|
||||
from agentscope.permission import PermissionContext, PermissionMode
|
||||
|
||||
@ -86,6 +104,174 @@ def _bypass_state() -> AgentState:
|
||||
return AgentState(permission_context=PermissionContext(mode=PermissionMode.BYPASS))
|
||||
|
||||
|
||||
# ── L3 视觉软检(observe-only · 绝不当门 · 防 Goodhart)──────────────────────────
|
||||
# 设计依据:tier2细节图说-D-三层校验与九门.md 图 D1 三层校验全景。
|
||||
# L3 = 效果层:特效/美观/好不好玩,处置「只评分、绝不解决、绝不阻塞拒发」。工具 = M3 多模态视觉软检
|
||||
# (看截图打分),产出只进质量趋势/告警/给人工终审减负。**永不参与 decision**——decision 只由 L1 硬门裁。
|
||||
# 为何死活不当门:扩确定性门判好玩→可被刷的代理指标;纯 LLM 当裁判→Goodhart 红线(优化代理指标偏离真目标)。
|
||||
# verdict schema 把 L3.scoreOnly 焊成 const true,本实现只写 score/notes,绝不回写 decision/L1。
|
||||
|
||||
# L3 玩家人格(单一视觉软检位;wg1 player panel 同款「急性子休闲玩家」口径,凭第一眼观感+反馈下判断)。
|
||||
L3_PERSONA = "急性子休闲玩家,凭第一眼观感和系统联动反馈下判断,只信看到的证据、不脑补"
|
||||
|
||||
|
||||
def _b64_data_block(png_path: Path) -> DataBlock | None:
|
||||
"""把一张本地 PNG 读成 L3 多模态消息块(DataBlock + Base64Source);读不到/空文件返 None。
|
||||
|
||||
截图只取证、绝不进硬门(防 Goodhart);本函数只为 L3 软检喂图。任何 IO 异常都吞成 None
|
||||
(L3 是 observe-only,缺图绝不能中断主链),由调用方据 None 走「无图」兜底。
|
||||
"""
|
||||
try:
|
||||
if not png_path.exists() or png_path.stat().st_size == 0:
|
||||
return None
|
||||
data = base64.b64encode(png_path.read_bytes()).decode("ascii")
|
||||
return DataBlock(source=Base64Source(media_type="image/png", data=data))
|
||||
except Exception as e: # noqa: BLE001 —— 读图失败绝不抛(L3 observe-only),记日志后走无图兜底
|
||||
print(f"[tier2-studio][L3] 读截图失败(走无图兜底):{png_path} {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _l3_play_summary(verdict: dict | None) -> str:
|
||||
"""把 verdict 的真玩取证(playReport / 富游戏门)摘成给 L3 玩家 agent 的运行数据文字。
|
||||
|
||||
只喂确定性 harness 的客观观测(是否真渲染/真玩到终态/终局语义 state),让玩家 agent 据证据判,
|
||||
而非脑补。注意:L3 看的是「真玩取证 + 截图」,judge 的硬门结论(pass/fail)不喂给它当答案
|
||||
(出题的和被考的分离)。
|
||||
"""
|
||||
if not verdict:
|
||||
return "(无 verdict:疑似装载即崩,无真玩取证)"
|
||||
pr = verdict.get("playReport") or {}
|
||||
rich = (((verdict.get("layerResults") or {}).get("L1") or {}).get("richGameGates")) or {}
|
||||
snap = pr.get("stateSnapshot") or {}
|
||||
lines = [
|
||||
f"- 是否捕获 boot 就绪(loaded):{pr.get('loaded')}",
|
||||
f"- harness 真输入次数(drivenInputs):{pr.get('drivenInputs')}",
|
||||
f"- 是否驱动到终态(reachedTerminal):{pr.get('reachedTerminal')} 终态 phase={pr.get('terminalPhase')}",
|
||||
f"- 终局语义 state 快照:coins={snap.get('coins')} ingredients={snap.get('ingredients')} "
|
||||
f"orders={snap.get('orders')} phase={snap.get('phase')}",
|
||||
]
|
||||
# 富游戏三门的子检查名(只给「测了哪些联动」的客观项名,不给过/不过当答案)。
|
||||
for name in ("tripleLink", "economy", "latch"):
|
||||
g = rich.get(name)
|
||||
if isinstance(g, dict):
|
||||
sub = [c.get("name") for c in (g.get("checks") or [])]
|
||||
lines.append(f"- 富游戏门 {name} 观测子项:{sub}")
|
||||
return "确定性 harness 真玩取证:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_l3_judge(raw: str) -> dict:
|
||||
"""容错解析 L3 玩家 agent 的 JSON 评判({completeness,fun,problems,verdict,note})。
|
||||
|
||||
便宜模型/M3 偶尔吐带前后缀的脏 JSON,故先抠出第一个 {...} 再 json.loads;全失败 → 给一个
|
||||
score=null 的诚实空判(observe-only,绝不因解析失败中断或翻 GREEN)。
|
||||
"""
|
||||
obj = None
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
# 抠出首个花括号块再试(去掉模型可能加的 ```json 包裹 / 解说前后缀)。
|
||||
try:
|
||||
s = raw.find("{")
|
||||
e = raw.rfind("}")
|
||||
if s >= 0 and e > s:
|
||||
obj = json.loads(raw[s:e + 1])
|
||||
except Exception:
|
||||
obj = None
|
||||
if not isinstance(obj, dict):
|
||||
return {"completeness": None, "fun": None, "problems": [], "verdict": "?",
|
||||
"note": (raw or "")[:160]}
|
||||
probs = obj.get("problems")
|
||||
if not isinstance(probs, list):
|
||||
obj["problems"] = [str(probs)] if probs else []
|
||||
return obj
|
||||
|
||||
|
||||
def _fun_to_score(fun) -> float | None:
|
||||
"""把玩家 agent 的 fun(1-5 锚)线性映射到 verdict.L3.score(0-100;schema 允许 number|null)。
|
||||
|
||||
映射:fun=1→0,fun=5→100(score=(fun-1)/4*100)。非法/缺失 → None(诚实留空,不编分)。
|
||||
"""
|
||||
try:
|
||||
f = float(fun)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f < 1 or f > 5:
|
||||
return None
|
||||
return round((f - 1) / 4 * 100, 1)
|
||||
|
||||
|
||||
async def _l3_player_panel(game_id: str, brief: str, verdict: dict | None, model) -> dict:
|
||||
"""L3 视觉软检:player_system 提示词 + 真截图 + 真玩取证,调一次 M3 produce score/note。
|
||||
|
||||
**observe-only 铁律**:本函数只产 {score, notes, raw}(写进 verdict.L3),绝不回写 decision/L1/accept;
|
||||
任何失败(截不到图/模型超时/解析失败)→ score=None + notes 记错,绝不中断、绝不翻 GREEN。
|
||||
|
||||
Args:
|
||||
game_id: 工程标识(定位 evidence 目录里的真截图)。
|
||||
brief: 题面(给玩家 agent 上下文)。
|
||||
verdict: 最近一次 run_gates 的 verdict(摘真玩取证;无则只凭截图判)。
|
||||
model: 复用单写/设计同档 M3 模型客户端(AnthropicChatModel;含 thinking,多模态由 formatter 适配)。
|
||||
|
||||
Returns:
|
||||
{score: float|None, notes: [str], raw: {...}}。score 即 verdict.L3.score 的取值。
|
||||
"""
|
||||
notes: list[str] = []
|
||||
# ① 取真截图:优先玩后帧(after-play.png,更能看出系统联动/进展),回落首帧(first-paint.png)。
|
||||
# 真图由 CDP harness 在 run_gates 真玩时落盘(mini-desktop);6c6g 无 chrome → 无图,走无图软检。
|
||||
ev_dir = run._workdir(game_id) / "evidence"
|
||||
img_block = None
|
||||
used_shot = None
|
||||
for fn in ("after-play.png", "first-paint.png"):
|
||||
blk = _b64_data_block(ev_dir / fn)
|
||||
if blk is not None:
|
||||
img_block, used_shot = blk, fn
|
||||
break
|
||||
if used_shot:
|
||||
notes.append(f"L3 看图:{used_shot}")
|
||||
else:
|
||||
notes.append("L3 无截图(6c6g 无 chrome 或 harness 未产图),仅凭真玩取证软评")
|
||||
|
||||
# ② 组装多模态 UserMsg:文本(题面 + 真玩取证)+ 截图块(若有)。
|
||||
summary = _l3_play_summary(verdict)
|
||||
user_text = (f"游戏题面:\n{(brief or '').strip()}\n\n{summary}\n\n"
|
||||
"请基于以上确定性真玩取证(及截图,若附)给出你的玩家评判,严格只输出约定 JSON。")
|
||||
content: list = [TextBlock(text=user_text)]
|
||||
if img_block is not None:
|
||||
content.append(TextBlock(text="【玩后截图 after-play.png(如缺则为首帧)】"))
|
||||
content.append(img_block)
|
||||
user = UserMsg(name="user", content=content)
|
||||
|
||||
# ③ 调一次 M3 玩家 agent(单轮、无工具;system = player_system 提示词)。任何异常吞成 score=None。
|
||||
try:
|
||||
agent = Agent(
|
||||
name="l3-player",
|
||||
system_prompt=roles.player_system(L3_PERSONA),
|
||||
model=model,
|
||||
react_config=ReActConfig(max_iters=1), # 软检只发散一轮、不调工具(它也没 toolkit)
|
||||
)
|
||||
resp = await agent.reply(user)
|
||||
raw = text_of(resp)
|
||||
except Exception as e: # noqa: BLE001 —— 模型超时/网关 502 等绝不中断主链(L3 observe-only)
|
||||
print(f"[tier2-studio][L3] 玩家 agent 调用失败(score=null 兜底):{type(e).__name__}: {e}", flush=True)
|
||||
notes.append(f"L3 模型调用失败:{type(e).__name__}: {e}")
|
||||
return {"score": None, "notes": notes, "raw": None}
|
||||
|
||||
# ④ 解析评判 → fun 映射 score;problems/note 进 notes(给人工终审减负,非判定)。
|
||||
judge = _parse_l3_judge(raw)
|
||||
score = _fun_to_score(judge.get("fun"))
|
||||
if score is None:
|
||||
notes.append(f"L3 fun 缺失/非法(fun={judge.get('fun')}),score 留 null")
|
||||
if judge.get("note"):
|
||||
notes.append(f"L3 总评:{str(judge.get('note'))[:160]}")
|
||||
for p in (judge.get("problems") or [])[:5]:
|
||||
notes.append(f"L3 劣化信号:{str(p)[:120]}")
|
||||
print(f"[tier2-studio][L3] game={game_id} fun={judge.get('fun')} completeness={judge.get('completeness')} "
|
||||
f"→ score={score}(observe-only,不参与 decision)", flush=True)
|
||||
return {"score": score, "notes": notes,
|
||||
"raw": {"completeness": judge.get("completeness"), "fun": judge.get("fun"),
|
||||
"verdict": judge.get("verdict")}}
|
||||
|
||||
|
||||
async def run_studio(
|
||||
game_id: str,
|
||||
brief: str,
|
||||
@ -120,10 +306,14 @@ async def run_studio(
|
||||
mname, max_tokens=max_tokens, thinking_budget=thinking_budget)
|
||||
design_text = await _design_stage(brief, design_model)
|
||||
|
||||
# ── 阶段 2:单写 ReAct(放开 max_iters + 九工具 + 四熔断 + BYPASS 权限)──
|
||||
# ── 阶段 2:单写 ReAct(放开 max_iters + 九工具 + 四熔断 + trace 接线 + BYPASS 权限)──
|
||||
session = Tier2Session(game_id, play_spec=play_spec)
|
||||
toolkit = build_toolkit(session)
|
||||
breaker = CircuitBreakerMiddleware() # 四道熔断 + 软刹(on_reply / on_system_prompt)
|
||||
# trace 接线(H1/H2):traceId 用 game_id(贯穿本次生成,对接 verdict.evidence.traceId / 成本关联键)。
|
||||
# sink=None → 只留内存 steps(spike 期收口由编排器读 adapter.summary;真落库 sink 随控制面 phase-1 接)。
|
||||
# 单实例对应单 agent;它与 breaker 都挂 on_reply,由框架按 middlewares 列表序串成洋葱链(trace 在外、纯旁路)。
|
||||
tracer = Tier2TraceMiddleware(trace_id=game_id)
|
||||
writer_model = config.build_model(
|
||||
mname, max_tokens=max_tokens, thinking_budget=thinking_budget)
|
||||
|
||||
@ -132,7 +322,9 @@ async def run_studio(
|
||||
system_prompt=roles.writer_system(brief, design_text, fixture_hint=fixture_hint),
|
||||
model=writer_model,
|
||||
toolkit=toolkit,
|
||||
middlewares=[breaker],
|
||||
# trace middleware 列在 breaker 前 → 它是更外层洋葱:先 ingest 事件(纯旁路),再进熔断巡检。
|
||||
# 熔断抛 Tier2CircuitBreak 时,已 ingest 的轨迹仍在 adapter 内(可收口反查),不丢。
|
||||
middlewares=[tracer, breaker],
|
||||
state=_bypass_state(),
|
||||
react_config=ReActConfig(max_iters=writer_max_iters),
|
||||
)
|
||||
@ -188,13 +380,74 @@ async def run_studio(
|
||||
breaker_tripped = {"kind": None, "reason": f"未捕获异常:{type(e).__name__}: {e}"}
|
||||
print(f"[tier2-studio] game={game_id} 异常:{type(e).__name__}: {e}", flush=True)
|
||||
|
||||
# ── L3 视觉软检(observe-only · 收口后调一次 · 绝不参与 accept/reject)───────────────
|
||||
# 时机:过完 L1 九门/富游戏门、ReAct 收敛后(无论 finish/kill/熔断)调一次。
|
||||
# 它用 player_system 提示词 + 真截图 + 真玩取证产 score/简评,只写进 verdict.L3.score/notes。
|
||||
# **不改 decision、不改 L1/runnableOk/accept**(裁决仍只看 L1 硬门)——保金标 GREEN 不被 L3 翻动(防 Goodhart)。
|
||||
# 失败兜底:截不到图/模型超时/解析失败 → score=None + log,绝不中断、绝不翻 GREEN。
|
||||
# 复用 writer_model(同档 M3 客户端);其 token 计入本 run 成本(L3 也是这次生成的真实开销)。
|
||||
l3_score = None
|
||||
l3_notes: list[str] = []
|
||||
try:
|
||||
l3 = await _l3_player_panel(game_id, brief, session.last_verdict, writer_model)
|
||||
l3_score = l3.get("score")
|
||||
l3_notes = l3.get("notes") or []
|
||||
# observe-only:把 L3 结果写回最近 verdict 的 layerResults.L3(scoreOnly 恒 true,只填 score/notes)。
|
||||
# 只在已有 verdict(真跑过门)时回写;verdict 缺失则 L3 结果只留在 result.l3(下方),不伪造 verdict。
|
||||
v = session.last_verdict
|
||||
if isinstance(v, dict):
|
||||
lr = v.setdefault("layerResults", {})
|
||||
l3seg = lr.setdefault("L3", {})
|
||||
l3seg["scoreOnly"] = True # schema const true 不变量(防 L3 退化成阻塞门)
|
||||
l3seg["score"] = l3_score # number|null;observe-only,不影响 decision
|
||||
# notes 累加(保留 harness 已写的 l3Notes,再并入软检备注)。
|
||||
prev = l3seg.get("notes") or []
|
||||
l3seg["notes"] = list(prev) + [n for n in l3_notes if n not in prev]
|
||||
except Exception as e: # noqa: BLE001 —— L3 整体兜底:任何异常都不中断主链、不翻 GREEN
|
||||
print(f"[tier2-studio][L3] 软检整体异常(score=null 兜底,不影响 decision):"
|
||||
f"{type(e).__name__}: {e}", flush=True)
|
||||
l3_notes = [f"L3 软检整体异常:{type(e).__name__}: {e}"]
|
||||
|
||||
# ── 汇总结果 ──
|
||||
wall_s = round(time.perf_counter() - t0, 1)
|
||||
# 成本取证:RecordingChatModel.records → (in, out, cached) 汇总(M3 路;cost.compute ¥ 折算事后做)。
|
||||
# 成本取证:RecordingChatModel.records → (in, out, cached) 汇总(M3 路 + 设计阶段;按模型分列供折¥)。
|
||||
# 一次 run 可能跨多个模型客户端(设计 model + 单写 model;本期同档 mname,但各自一份 records),
|
||||
# 按模型名聚合成 records_by_model 再汇总,与 cost.tokens_by_model_from_records 的入参形状对齐。
|
||||
tin = tout = tcached = 0
|
||||
records_by_model: dict[str, list] = {}
|
||||
for m in (locals().get("design_model"), writer_model):
|
||||
for r in getattr(m, "records", []) or []:
|
||||
recs = getattr(m, "records", None) or []
|
||||
for r in recs:
|
||||
tin += r[0]; tout += r[1]; tcached += (r[2] if len(r) >= 3 else 0)
|
||||
if recs:
|
||||
# 同档多客户端(设计 + 单写)的 records 合并到同一模型名下(本期 mname 单档)。
|
||||
records_by_model.setdefault(mname, []).extend(recs)
|
||||
|
||||
# ── 成本接线(H3):tokens_by_model → cost_for_run 折¥,写进 result['cost'] ──
|
||||
# 计费参数活读取:优先 new-api /api/pricing + /api/status 权威活值;取不到则回落显式默认参数 + 告警
|
||||
# (best-effort 铁律:取价失败绝不中断主链;回落口径见下)。折算本身是纯函数,绝不抛(cost.compute 内部容错)。
|
||||
tokens_by_model = tokens_by_model_from_records(records_by_model)
|
||||
params = fetch_pricing_params() # None = 活读取失败(已在内部 best-effort 告警)
|
||||
if params is not None:
|
||||
# 活读取成功:用 new-api 权威倍率折¥。
|
||||
pricing, qpu, usd_rate = params["pricing"], params["qpu"], params["usd_rate"]
|
||||
pricing_source = "newapi-live"
|
||||
else:
|
||||
# 回落:无活倍率则 pricing 空表(cost.compute 对缺 model 取保守默认 model_ratio=0 → ¥=0),
|
||||
# qpu/usd_rate 用 new-api 编译默认(500000 / 7.3)。¥ 折不出真值但不中断、不抛;
|
||||
# 接线方(RunRecord B2)据 pricing_source=fallback 知道这笔 ¥ 不可信(待补真倍率重算)。
|
||||
pricing, qpu, usd_rate = {}, DEFAULT_QUOTA_PER_UNIT, DEFAULT_USD_RATE
|
||||
pricing_source = "fallback"
|
||||
print(f"[tier2-studio] game={game_id} 成本折算回落显式默认参数(pricing 空表,¥ 不可信);"
|
||||
"真值待编排器据 new-api pricing 重算。", flush=True)
|
||||
cost = cost_for_run(tokens_by_model, pricing, qpu, usd_rate)
|
||||
# cost = {cost_rmb, tokens_by_model, by_model};附带本次折算用的计费口径(审计追溯 + 标注 ¥ 是否可信)。
|
||||
cost["pricingSource"] = pricing_source
|
||||
cost["qpu"] = qpu
|
||||
cost["usdRate"] = usd_rate
|
||||
|
||||
# ── trace 收口(H1/H2):读 tracer 摘要(traceId / 步数 / 丢弃数),供编排器对账反查 ──
|
||||
trace_summary = tracer.summary()
|
||||
|
||||
result = {
|
||||
"game_id": game_id,
|
||||
@ -204,13 +457,23 @@ async def run_studio(
|
||||
# 单写 agent 交付的源工程(finish 工具组装的 A3 形状;未 finish 则 None)。
|
||||
"source_project": session.finished,
|
||||
"finished": session.finished is not None,
|
||||
# 最近一次 run_gates 的 verdict(tier2-verdict 形状;judge 纯代码产出,零自评)。
|
||||
# 最近一次 run_gates 的 verdict(tier2-verdict 形状;judge 纯代码产出,零自评;若真跑过门,
|
||||
# 其 layerResults.L3.score/notes 已由上面 L3 软检 observe-only 回填)。
|
||||
"last_verdict": session.last_verdict,
|
||||
# L3 视觉软检结果(observe-only,绝不参与 decision;verdict 缺失时仍在此可见,不伪造 verdict)。
|
||||
"l3": {"score": l3_score, "notes": l3_notes},
|
||||
# 四熔断触发记录(对接 verdict.breakerKind:step_cap/budget/stuck/timeout)。
|
||||
"circuit_break": breaker_tripped,
|
||||
"breaker_counters": {"tool_calls": breaker.tool_calls, "model_calls": breaker.model_calls},
|
||||
# 成本取证(M3 路 token;¥ 折算复用 wg1 cost.compute 纯函数,事后按 new-api quota 口径算)。
|
||||
# 成本取证(M3 路 token;扁平汇总,向后兼容既有消费方)。
|
||||
"tokens": {"prompt": tin, "completion": tout, "cached": tcached},
|
||||
# 成本接线(H3,新增):run 级折¥ + per-model token 明细 + 计费口径
|
||||
# (cost_rmb=run 级总¥;by_model=逐档折算明细;pricingSource=newapi-live/fallback)。
|
||||
# RunRecord 接线(B2)从此抽 cost_yuan / tokens_by_model 填 G4 采集字段。
|
||||
"cost": cost,
|
||||
# trace 收口摘要(H1/H2,新增):{traceId, steps, dropped, middlewareDropped}。
|
||||
# spike 期 trace step 留在 tracer.adapter.steps 内存(sink=None);真落库 sink 随控制面 phase-1 接。
|
||||
"trace": trace_summary,
|
||||
"wall_s": wall_s,
|
||||
"file_tree": session.file_tree(),
|
||||
}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
"""config.py —— tier2 富游戏自治线 · M3 模型客户端构建(AgentScope AnthropicChatModel)。
|
||||
"""config.py —— tier2 富游戏自治线 · 模型客户端构建(AgentScope v2.0.2)。
|
||||
|
||||
两条并存的接法(G 族图 G3 模型矩阵):
|
||||
- **M3 中等 agentic 档**走 Anthropic 原生 /v1/messages(thinking 分离):build_model + RecordingChatModel;
|
||||
- **便宜档 deepseek-v4-flash / deepseek-v4-pro**(现行 Tier0/1 在产模型)走 new-api OpenAI 兼容路:
|
||||
build_model_openai + RecordingOpenAIChatModel(本单元 A1 新增,加性、不动 Anthropic 路)。
|
||||
两条路共用 client.py 的凭据解析与代理旁路;OpenAI 兼容路 base 带 /v1,Anthropic 路 base 是 host 根(见各自函数注释)。
|
||||
|
||||
M3 接法(创始人 06-19「用对它」+ 凭据档 + AgentScope v2.0.2 源码核验):
|
||||
- MiniMax-M3 是 agentic SOTA,走 **Anthropic 原生 /v1/messages**(thinking 分离),不是 OpenAI 兼容路;
|
||||
@ -24,12 +30,21 @@ from . import client # 包内导入
|
||||
|
||||
_DEFAULT_BASE_URL = client.resolve_base_url()
|
||||
|
||||
# ② NO_PROXY 已设妥,此后再 import agentscope(其内部用 anthropic / httpx)才直连网关。
|
||||
# ② NO_PROXY 已设妥,此后再 import agentscope(其内部用 anthropic / openai / httpx)才直连网关。
|
||||
from agentscope.credential import AnthropicCredential # noqa: E402
|
||||
from agentscope.credential import OpenAICredential # noqa: E402 —— 便宜档走 OpenAI 兼容路
|
||||
from agentscope.model import AnthropicChatModel # noqa: E402
|
||||
from agentscope.model import OpenAIChatModel # noqa: E402 —— deepseek 两档经 new-api OpenAI 兼容路
|
||||
from agentscope.model import ChatResponse # noqa: E402
|
||||
|
||||
|
||||
# ── 便宜档默认模型名(G 族图 G3 模型矩阵:deepseek 两档为现行 Tier0/1 在产模型)──
|
||||
# 只是默认常量、不硬编死:build_model_openai 的 model_name 形参显式传入优先,
|
||||
# model_name_from_env 亦可经 env 覆盖(下文)。spike 跑矩阵时由调用方点名到具体档。
|
||||
DEEPSEEK_FLASH = "deepseek-v4-flash" # 主力便宜档(spike 主问「便宜到什么程度还守得住」)
|
||||
DEEPSEEK_PRO = "deepseek-v4-pro" # 强便宜档(救场档;退路树第一条触发线 v4-pro<40% 读它)
|
||||
|
||||
|
||||
class RecordingChatModel(AnthropicChatModel):
|
||||
"""记录每次模型调用 usage 的 AnthropicChatModel 子类(per-call token 取证,供成本台账)。
|
||||
|
||||
@ -91,6 +106,74 @@ class RecordingChatModel(AnthropicChatModel):
|
||||
return (sum(r[0] for r in self.records), sum(r[1] for r in self.records))
|
||||
|
||||
|
||||
class RecordingOpenAIChatModel(OpenAIChatModel):
|
||||
"""记录每次模型调用 usage 的 OpenAIChatModel 子类(便宜档 deepseek 经 new-api OpenAI 兼容路的 token 取证)。
|
||||
|
||||
与 RecordingChatModel(Anthropic 路)同口径、同 records 三元组结构 (input, output, cached):
|
||||
- 命中缓存的 prompt token(cached):AgentScope 的 OpenAIChatModel 解析器把
|
||||
`usage.prompt_tokens_details.cached_tokens` 归一进 ChatUsage.cache_input_tokens
|
||||
(源码 _openai_chat/_model.py:346/585),DeepSeek 原生的顶层 `prompt_cache_hit_tokens`
|
||||
在 OpenAI 兼容解析路上不被读取(那个字段只有专门的 _deepseek 模型类才读)。
|
||||
故这里以 cache_input_tokens 为权威字段;同时**防御性**再试 OpenAI/DeepSeek 两套原始字段名
|
||||
(cached_tokens / prompt_cache_hit_tokens),取三者 max —— 万一 new-api 把原始字段透传进
|
||||
ChatUsage.metadata 或未来 ChatUsage 扩展出这些字段,也能抓到,口径与 wg1 cost.cache_hit_tokens 对齐。
|
||||
- 逐字段 _safe_int(脏值/None/负数 → 0),绝不抛 —— usage 解析不得中断生成主链(P0 纪律)。
|
||||
|
||||
⚠️ ChatUsage 是 DictMixin(dict)子类,`__getattr__ = dict.__getitem__`:对缺失字段 getattr
|
||||
会抛 KeyError(getattr 的默认值兜不住 dict.__getitem__ 抛的 KeyError),故每个字段都显式 try
|
||||
(与 Anthropic 变体同款防御;memory 记的「ChatUsage KeyError」头号坑)。
|
||||
|
||||
向后兼容:消费方只读 r[0]/r[1] 时 3-tuple 不破。
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
# [(input_tokens, output_tokens, cached_tokens)];每次 __call__ 追加一条。
|
||||
self.records: list[tuple[int, int, int]] = []
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(v) -> int:
|
||||
"""脏值/None/负数 → 0;合法数 → int(绝不抛,usage 解析不得中断生成主链)。"""
|
||||
try:
|
||||
n = int(v)
|
||||
return n if n >= 0 else 0
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
async def __call__(self, messages, **kwargs):
|
||||
"""透传父类调用,顺手把 usage 记进 records(命中 token 兼容三套字段取 max)。"""
|
||||
resp = await super().__call__(messages, **kwargs)
|
||||
# ChatResponse.usage 是 ChatUsage(可能为 None);流式时父类已聚合到末块。
|
||||
u = resp.get("usage") if hasattr(resp, "get") else getattr(resp, "usage", None)
|
||||
if u is not None:
|
||||
# 安全取 usage 字段:ChatUsage(DictMixin)缺字段会抛 KeyError,getattr 默认值兜不住 → 显式 try。
|
||||
def _ug(key):
|
||||
try:
|
||||
return getattr(u, key, None)
|
||||
except (KeyError, AttributeError, TypeError):
|
||||
return None
|
||||
# 命中字段三套取 max:① AgentScope OpenAI 解析归一后的 cache_input_tokens(权威);
|
||||
# ② OpenAI 原始 cached_tokens;③ DeepSeek 原始 prompt_cache_hit_tokens(透传兜底)。
|
||||
cands = []
|
||||
for k in ("cache_input_tokens", "cached_tokens", "prompt_cache_hit_tokens"):
|
||||
v = _ug(k)
|
||||
if v is not None:
|
||||
cands.append(self._safe_int(v))
|
||||
cached = max(cands) if cands else 0
|
||||
self.records.append(
|
||||
(
|
||||
self._safe_int(_ug("input_tokens")),
|
||||
self._safe_int(_ug("output_tokens")),
|
||||
cached,
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
def usage_sum(self) -> tuple[int, int]:
|
||||
"""汇总 (in, out);命中 token 由调用方按 records[2] 单独汇总(向后兼容)。"""
|
||||
return (sum(r[0] for r in self.records), sum(r[1] for r in self.records))
|
||||
|
||||
|
||||
def build_model(
|
||||
model_name: str,
|
||||
*,
|
||||
@ -146,6 +229,61 @@ def build_model(
|
||||
)
|
||||
|
||||
|
||||
def build_model_openai(
|
||||
model_name: str,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
max_tokens: int = 16000,
|
||||
stream: bool = False,
|
||||
max_retries: int = 2,
|
||||
record: bool = True,
|
||||
) -> OpenAIChatModel:
|
||||
"""构建便宜档(deepseek 两档)的 AgentScope OpenAIChatModel 客户端,走 new-api OpenAI 兼容路。
|
||||
|
||||
与 build_model(Anthropic 路)并存、互不影响:G 族图 G3 模型矩阵要求 spike 能跑便宜档
|
||||
deepseek-v4-flash / deepseek-v4-pro,这两档是现行 Tier0/1 在产模型,经 new-api 的 OpenAI
|
||||
兼容端点访问(thinking 分离的 Anthropic 原生路只 M3 用)。
|
||||
|
||||
Args:
|
||||
model_name: 模型名(如 'deepseek-v4-flash' / 'deepseek-v4-pro';默认常量见 DEEPSEEK_FLASH/PRO)。
|
||||
base_url: new-api 网关 base;None → 用本进程默认(client.resolve_base_url,host 根)。
|
||||
**注意 /v1 口径**:OpenAI 兼容路要带 /v1。client 默认的 base 是 host 根(不带 /v1,
|
||||
为 Anthropic SDK 自拼 /v1/messages 准备);AgentScope 的 OpenAIChatModel 把 base_url 原样
|
||||
透传给 openai.AsyncClient(源码 _openai_chat/_model.py:215),openai SDK **不自动补 /v1**、
|
||||
只在其后追加 /chat/completions。故这里若 base 末尾没有 /v1 就补上 —— 这正是 SAA skill
|
||||
「剥 /v1」口径的反向:SAA 那条是别人多带了 /v1 要剥;OpenAI 兼容路反过来是必须带 /v1。
|
||||
api_key: new-api token;None → 从 env/.env 读(client.get_api_key)。
|
||||
max_tokens: 输出上限(便宜档无 thinking 预算约束,直接作 OpenAI Parameters.max_tokens)。
|
||||
stream: 是否流式(默认 False,便于一次性取完整 response + usage)。
|
||||
max_retries: 网关偶发 502 突发兜底(wg1 spike 实证有效)。
|
||||
record: True → 用 RecordingOpenAIChatModel 记 usage 供成本取证;False → 裸 OpenAIChatModel。
|
||||
"""
|
||||
resolved_base = base_url or _DEFAULT_BASE_URL
|
||||
# base_url 若是调用方临时换的端点,也补一次代理旁路(幂等,已在 NO_PROXY 里则不动)。
|
||||
client.install_proxy_bypass(resolved_base)
|
||||
# OpenAI 兼容路必须带 /v1:openai SDK 不自动补,只在 base 之后接 /chat/completions。
|
||||
# 归一去掉末尾斜杠后判断,避免 'host:3000/' 与 'host:3000/v1/' 之类的尾斜杠误判。
|
||||
normalized = resolved_base.rstrip("/")
|
||||
if not normalized.endswith("/v1"):
|
||||
normalized = normalized + "/v1"
|
||||
resolved_base_v1 = normalized
|
||||
resolved_key = api_key or client.get_api_key()
|
||||
|
||||
cls = RecordingOpenAIChatModel if record else OpenAIChatModel
|
||||
return cls(
|
||||
# OpenAICredential.api_key 是 SecretStr(pydantic 自动从 str 包装);base_url 带 /v1。
|
||||
credential=OpenAICredential(api_key=resolved_key, base_url=resolved_base_v1),
|
||||
model=model_name,
|
||||
# 不传 formatter → AgentScope 自动配 OpenAIChatFormatter(_openai_chat/_model.py:166)。
|
||||
parameters=OpenAIChatModel.Parameters(
|
||||
max_tokens=max_tokens,
|
||||
),
|
||||
stream=stream,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
|
||||
def model_name_from_env(default: str = "MiniMax-M3") -> str:
|
||||
"""单写 agent 的模型名:env TIER2_MODEL 覆盖,默认 MiniMax-M3(M3 路)。"""
|
||||
return os.environ.get("TIER2_MODEL", default)
|
||||
|
||||
402
tier2/gen-worker/worker/fallback_tree.py
Normal file
402
tier2/gen-worker/worker/fallback_tree.py
Normal file
@ -0,0 +1,402 @@
|
||||
"""fallback_tree.py —— tier2 0号 spike 退路树五出口判定器(纯函数,零副作用)。
|
||||
|
||||
职责(对 docs/architecture/架构/生成引擎/tier2细节图说-G-spike-runbook.md 图 G5「退路树」
|
||||
与 tier2实现详设.md §退路树):
|
||||
spike 在 mini-desktop 跑完一整张模型矩阵后,把 run 级采集字段汇成「矩阵级聚合」(过门率 /
|
||||
¥/成功款 / 收敛中位数 / fail_system 分布);本模块吃这份聚合,按 Q1–Q4 三条数字触发线把它
|
||||
纵向分流成五个确定出口,让 go/no-go 不靠人工拍脑袋,也让 no-go 之后有确定去处、绝不滑成无限调参。
|
||||
|
||||
设计依据(权威 = 图说,本模块 deepen 不偏离):
|
||||
- 退路树拓扑(START→Q1→{GO | Q2→{R1 | Q3→{R2 | Q4→{R3 | KEEP}}}})照搬详设 §退路树的 mermaid,
|
||||
每条触发线在 decide() 里写明对应图说哪一条 Q + 阈值常量。
|
||||
- 五出口语义(GO / R1 退模板化 / R2 补骨架 / R3 天花板 / KEEP 留观)见下文每个出口的 docstring。
|
||||
- 阈值标 ★ 的是 directional v1 建议值,**需创始人和实测校准**(详设 §过门判据 / §退路树都标了 ★);
|
||||
全部集中在下方常量区、注明来源,真跑校准时只改这一处。
|
||||
|
||||
输入接口(stats)说明:
|
||||
本判定器吃的是「矩阵级聚合」,不是单条 run-record。A3 的 run_record.py(单条 run 形状)在
|
||||
Phase1 并行下可能还没落地;故这里按 G 族图说图 G4「汇成矩阵级三张图」+ 详设 §采集指标字段表 的
|
||||
字段名先行定义 stats 的形状(见 decide() docstring 的 stats schema)。主会话集成 A3 时,
|
||||
以「单条 run-record 列表 → 矩阵级聚合」这一步对齐字段名:聚合产物喂本判定器即可。
|
||||
|
||||
为什么是纯函数:退路树判定是 go/no-go 的可复核裁决,必须可单测、可对拍、零副作用——
|
||||
不读文件、不发网络、不打印副作用日志(reasons 全部以返回值携带,供调用方落档/展示)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 阈值常量区(全部集中可调;★ = directional v1,需创始人和实测校准)
|
||||
# 来源:tier2细节图说-G-spike-runbook.md 图 G5 / tier2实现详设.md §退路树 + §过门判据
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ★ Q1 触发线:最强便宜档(v4-pro)过门率「达阈值」的下限。
|
||||
# 图说图 G5 / 详设 §退路树第一句:Q1「最强便宜档 v4-pro 过门率是否仍 < 40%?」
|
||||
# 否(≥ 40%、达阈值)→ GO 转第三步铺引擎;是(< 40%)→ 继续往下分流。
|
||||
# 注:这是退路树自己的「达阈值」线(40%),与 §过门判据里 spike 整体过门率
|
||||
# ★≥50% 起评 / ≥70% 强信号 是两层口径——退路树用 40% 作 GO/不GO 的分界,
|
||||
# ≥50%/≥70% 是过门率本身的强弱评级。两者都标 ★、都待校准。
|
||||
QPASS_GO_MIN = 0.40 # ★ v4-pro 过门率 ≥ 此值 → 达阈值,Q1 走 GO 分支
|
||||
|
||||
# ★ Q4 触发线:便宜档「全线崩」的过门率上限。
|
||||
# 图说图 G5 / 详设 §退路树第三句:Q4「是否便宜档全线 < 20%、而强模型基线 Opus/Fable 能过?」
|
||||
# 是 → R3 判便宜模型天花板;否 → KEEP 留观。
|
||||
QPASS_FLOOR_MAX = 0.20 # ★ 所有便宜档过门率 < 此值 → 视为「便宜档全线崩」
|
||||
|
||||
# 强基线(Opus/Fable)「能过」的过门率下限。
|
||||
# 图说:强基线只作「路通不通」的上限基线,n=2~3、不进成本评估。Q4 的「强基线能过」
|
||||
# = 强基线至少能跑出一款过门的(>0)。不设 ★:这是「上限基线能不能跑通」的存在性判断,
|
||||
# 不是要给强基线定过门率门(强基线本就不进成本/过门率门评估)。
|
||||
QPASS_STRONG_BASELINE_MIN = 0.0 # 强基线过门率 > 此值(即 >0,至少 1 款过)→ 视为「强基线能过」
|
||||
|
||||
# fail_system 分布里「表现层」桶名 + 「集中」判定阈值。
|
||||
# 图说图 G2 / 详设 §采集字段:fail_system 区分 表现层 与 资源/合成/订单 三系统(经营品类特有)。
|
||||
# 桶键名以 A3 的数据结构属主为准——run_record.py 的 `FailSystem` Literal 定义为
|
||||
# {"resource", "merge", "order", "presentation"}(表现层 = presentation,英文)。本判定器是
|
||||
# run-record 的消费方,故桶名对齐 A3 的英文字面值,不用图说散文里的中文「表现层」,否则集成时
|
||||
# Q2 永远命中不到表现层桶(集成接缝:A3 产单条 run-record → 分析侧聚合成矩阵级 fail_system 分布 → 喂本判定器)。
|
||||
# Q2「失败是否集中在表现层(render / 事件接线)?」、Q3「是否失败集中在某个系统装不出、其余能过?」
|
||||
# 都读这份分布。「集中」= 该桶占失败总数的比例 ≥ 下面这条线。
|
||||
PRESENTATION_BUCKET = "presentation" # 表现层桶键名(对齐 A3 run_record.FailSystem 的 Literal 值)
|
||||
SYSTEM_BUCKETS = ("resource", "merge", "order") # 三系统桶名(资源/合成/订单,同 A3 FailSystem)
|
||||
|
||||
# ★ 「集中」阈值:某一类失败占失败总数 ≥ 此比例,视为「集中在该处」。
|
||||
# 图说没钉死这个比例数(只定性说「集中在表现层」/「集中在某个系统」),这是把定性判据
|
||||
# 落成可计算门时引入的实现阈值,标 ★ 待校准:太低会把零散失败误判成「集中」,太高会
|
||||
# 放过真正的集中失败。directional v1 取 0.5(过半失败落在同一处即算集中)。
|
||||
CONCENTRATION_RATIO = 0.50 # ★ 某桶失败占比 ≥ 此值 → 判「集中」
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 五出口码(返回值 exit 字段的取值;与图说图 G5 的五个出口框一一对应)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# go:最强便宜档过门率达阈值 → 转第三步以 Phaser 铺引擎。
|
||||
# 注:人锚软门(创始人试玩判「有没有肥鹅味」)不可被本判定器替代——四维数字门给 GO,
|
||||
# 人锚仍须另行过(详设 §过门判据「人锚软门 / 数字硬门双重结构」)。本判定器只裁数字面,
|
||||
# 故 GO 的 reasons 里会显式提示「人锚仍须过」。
|
||||
EXIT_GO = "go"
|
||||
# R1 退向更模板化:判 56% 自治承重太重,改成更多预制表现模板、少 LLM 写。
|
||||
EXIT_R1_TEMPLATE = "R1_退模板化"
|
||||
# R2 补骨架再试:判某系统的骨架/driver 缺口,不是范式问题,补该系统骨架后再试。
|
||||
EXIT_R2_SCAFFOLD = "R2_补骨架"
|
||||
# R3 便宜模型天花板:换更贵模型重估经济性(撞 ¥3/款 上限就缓行),或整轨缓行。
|
||||
EXIT_R3_CEILING = "R3_天花板"
|
||||
# KEEP 留观:既非全线崩、也非天花板 → 据具体数微调前置物再跑(只许微调一轮,不滑成无限调参)。
|
||||
EXIT_KEEP = "KEEP_留观"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 内部小工具(纯函数;只做读取/计算,不抛出业务异常——脏输入按保守缺省处理)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def _pass_rate(model_stat: dict[str, Any] | None) -> float | None:
|
||||
"""从单个模型档的聚合里取过门率(pass_rate)。
|
||||
|
||||
取值优先级:① 直接给的 pass_rate(0~1);② 由 pass_count / n 现算。
|
||||
缺失/不可计算 → None(让上层显式区分「没这档数据」与「这档过门率为 0」)。
|
||||
"""
|
||||
if not isinstance(model_stat, dict):
|
||||
return None
|
||||
pr = model_stat.get("pass_rate")
|
||||
if isinstance(pr, (int, float)):
|
||||
return float(pr)
|
||||
# 退一步:由 pass_count / n 现算(n>0 时)。
|
||||
n = model_stat.get("n")
|
||||
pc = model_stat.get("pass_count")
|
||||
if isinstance(n, (int, float)) and n and isinstance(pc, (int, float)):
|
||||
return float(pc) / float(n)
|
||||
return None
|
||||
|
||||
|
||||
def _bucket_share(fail_dist: dict[str, Any] | None, bucket: str) -> float:
|
||||
"""某个 fail_system 桶的失败占比 = 该桶失败数 / 失败总数;失败总数为 0 → 0.0。"""
|
||||
if not isinstance(fail_dist, dict) or not fail_dist:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for v in fail_dist.values():
|
||||
if isinstance(v, (int, float)) and v > 0:
|
||||
total += float(v)
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
hit = fail_dist.get(bucket)
|
||||
hit = float(hit) if isinstance(hit, (int, float)) and hit > 0 else 0.0
|
||||
return hit / total
|
||||
|
||||
|
||||
def _system_concentrated(fail_dist: dict[str, Any] | None) -> str | None:
|
||||
"""是否「失败集中在某个系统装不出」(Q3)。是 → 返回该系统桶名;否 → None。
|
||||
|
||||
判据:三系统桶(resource/merge/order)里有任意一个的失败占比 ≥ CONCENTRATION_RATIO。
|
||||
取占比最高且达阈值的那个系统(便于 reasons 指明补哪个系统的骨架)。
|
||||
"""
|
||||
best_bucket: str | None = None
|
||||
best_share = 0.0
|
||||
for b in SYSTEM_BUCKETS:
|
||||
s = _bucket_share(fail_dist, b)
|
||||
if s >= CONCENTRATION_RATIO and s > best_share:
|
||||
best_bucket, best_share = b, s
|
||||
return best_bucket
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 主判定器:decide(stats) -> {exit, reasons[]}
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def decide(stats: dict[str, Any]) -> dict[str, Any]:
|
||||
"""据矩阵级聚合 stats,按退路树 Q1–Q4 三条数字触发线分流到五个出口。
|
||||
|
||||
stats schema(矩阵级聚合;字段名对齐详设 §采集指标字段表汇成的矩阵级三张图):
|
||||
{
|
||||
# 各模型档的聚合,键 = 模型名(对齐 G3 模型矩阵的角色档名)。
|
||||
"by_model": {
|
||||
"deepseek-v4-pro": {"pass_rate": 0.45, "n": 30, "pass_count": 14,
|
||||
# fail_system 分布:{resource/merge/order/presentation: 失败款数}
|
||||
"fail_system": {"presentation": 3, "merge": 1},
|
||||
"cost_rmb_per_pass": 2.1, "repairs_median": 12},
|
||||
"deepseek-v4-flash": {"pass_rate": 0.30, ...},
|
||||
"MiniMax-M3": {"pass_rate": 0.55, ...},
|
||||
"Opus": {"pass_rate": 0.50, ...}, # 强基线,n 小、不进成本
|
||||
"Fable": {"pass_rate": 0.50, ...},
|
||||
},
|
||||
# 可选:跨「便宜档」聚合的 fail_system 分布(Q2 用,判失败是否集中表现层)。
|
||||
# 缺则由 by_model 里各便宜档的 fail_system 现合(见下)。
|
||||
"fail_system_overall": {"presentation": 8, "merge": 2, "order": 1},
|
||||
}
|
||||
|
||||
角色档名约定(对齐 G3 图 / 详设 §模型矩阵):
|
||||
- 最强便宜档(Q1 读它)= STRONG_CHEAP_KEYS 里第一个命中的;默认 'deepseek-v4-pro'。
|
||||
- 便宜档集合(Q4「便宜档全线 <20%」读它)= CHEAP_KEYS 命中的全部档。
|
||||
- 强模型基线(Q4「强基线能过」读它)= STRONG_BASELINE_KEYS 命中的任意一个。
|
||||
|
||||
Returns:
|
||||
{"exit": <五出口码之一>, "reasons": [<每步触发线的可读裁决依据>, ...]}
|
||||
reasons 逐条记录走过的 Q 与读到的数字,供调用方落档/展示(纯函数不打印)。
|
||||
|
||||
脏/缺输入处理(绝不抛业务异常,保守裁决):
|
||||
- by_model 缺失或非 dict → 直接 KEEP 留观,reasons 说明「数据不足无法裁,先补齐再跑」。
|
||||
- 最强便宜档过门率取不到 → 同样 KEEP(数据缺口不当作 GO,也不当作天花板)。
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
|
||||
by_model = stats.get("by_model") if isinstance(stats, dict) else None
|
||||
if not isinstance(by_model, dict) or not by_model:
|
||||
# 数据不足:不能裁 GO(过乐观)也不能裁 no-go(过悲观)→ 留观,让调用方先补齐数据。
|
||||
reasons.append("stats.by_model 缺失或为空:矩阵级聚合数据不足,无法据数字裁定,先补齐采集再跑。")
|
||||
return {"exit": EXIT_KEEP, "reasons": reasons}
|
||||
|
||||
# ── 解析三类档名(按约定优先级命中;命中即取,便于真跑时改档名映射只动这几处常量)──
|
||||
strong_cheap = _first_present(by_model, STRONG_CHEAP_KEYS) # 最强便宜档(Q1)
|
||||
cheap_models = _present_subset(by_model, CHEAP_KEYS) # 便宜档集合(Q4)
|
||||
strong_baseline = _first_present(by_model, STRONG_BASELINE_KEYS) # 强基线(Q4)
|
||||
|
||||
# ── Q1:最强便宜档 v4-pro 过门率是否仍 < QPASS_GO_MIN(★40%)?──
|
||||
# 图说图 G5 / 详设 §退路树:否(≥40%、达阈值)→ GO;是(<40%)→ 进 Q2。
|
||||
pr_strong_cheap = _pass_rate(strong_cheap[1]) if strong_cheap else None
|
||||
if pr_strong_cheap is None:
|
||||
# 最强便宜档过门率取不到:这是退路树的入口判据,缺则无法分流 → 留观补数据。
|
||||
reasons.append(
|
||||
f"Q1 无法判定:最强便宜档({STRONG_CHEAP_KEYS[0]} 等)过门率缺失,"
|
||||
"无 by_model 该档的 pass_rate/pass_count——先补齐该档采集再跑。")
|
||||
return {"exit": EXIT_KEEP, "reasons": reasons}
|
||||
|
||||
reasons.append(
|
||||
f"Q1 读最强便宜档 {strong_cheap[0]} 过门率={pr_strong_cheap:.0%}"
|
||||
f"(GO 阈值 ★≥{QPASS_GO_MIN:.0%},来源图 G5/详设 §退路树第一条触发线)。")
|
||||
if pr_strong_cheap >= QPASS_GO_MIN:
|
||||
# GO:达阈值 → 转第三步铺引擎;但人锚软门仍须另行过(本判定器只裁数字面)。
|
||||
reasons.append(
|
||||
f"→ GO:最强便宜档过门率 {pr_strong_cheap:.0%} ≥ ★{QPASS_GO_MIN:.0%},达阈值,"
|
||||
"转第三步以 Phaser 铺引擎。注意:人锚软门(创始人试玩判肥鹅味)不可被数字门替代,仍须过。")
|
||||
return {"exit": EXIT_GO, "reasons": reasons}
|
||||
|
||||
# ── Q2:失败是否集中在表现层(render / 事件接线)?──
|
||||
# 图说图 G5 / 详设 §退路树第一句:是(集中表现层)→ R1 退模板化(判 56% 自治承重太重)。
|
||||
fail_overall = stats.get("fail_system_overall")
|
||||
if not isinstance(fail_overall, dict) or not fail_overall:
|
||||
# 没给跨便宜档的整体分布 → 由各便宜档的 fail_system 现合(退而求其次)。
|
||||
fail_overall = _merge_fail_systems(cheap_models)
|
||||
pres_share = _bucket_share(fail_overall, PRESENTATION_BUCKET)
|
||||
reasons.append(
|
||||
f"Q2 读便宜档 fail_system 分布,表现层占比={pres_share:.0%}"
|
||||
f"(集中阈值 ★≥{CONCENTRATION_RATIO:.0%})。")
|
||||
if pres_share >= CONCENTRATION_RATIO:
|
||||
reasons.append(
|
||||
f"→ R1 退模板化:失败集中在表现层(占比 {pres_share:.0%} ≥ ★{CONCENTRATION_RATIO:.0%}),"
|
||||
"判 56% 自治承重太重 → 改用更多预制表现模板、少 LLM 写。")
|
||||
return {"exit": EXIT_R1_TEMPLATE, "reasons": reasons}
|
||||
|
||||
# ── Q3:是否失败集中在某个系统装不出、其余系统能过?──
|
||||
# 图说图 G5 / 详设 §退路树第二句:是 → R2 补骨架再试(判骨架/driver 缺口,不是范式问题)。
|
||||
sys_bucket = _system_concentrated(fail_overall)
|
||||
if sys_bucket is not None:
|
||||
share = _bucket_share(fail_overall, sys_bucket)
|
||||
reasons.append(
|
||||
f"Q3 命中:失败集中在系统 {sys_bucket}(占比 {share:.0%} ≥ ★{CONCENTRATION_RATIO:.0%}),"
|
||||
"其余系统能过。")
|
||||
reasons.append(
|
||||
f"→ R2 补骨架:判 {sys_bucket} 系统的骨架/driver 缺口(非范式问题),"
|
||||
"补该系统骨架后再试。")
|
||||
return {"exit": EXIT_R2_SCAFFOLD, "reasons": reasons}
|
||||
reasons.append(
|
||||
f"Q3 不命中:失败未集中在单一系统(三系统桶 {SYSTEM_BUCKETS} 占比均 < ★{CONCENTRATION_RATIO:.0%})。")
|
||||
|
||||
# ── Q4:是否便宜档全线 < QPASS_FLOOR_MAX(★20%)、而强模型基线 Opus/Fable 能过?──
|
||||
# 图说图 G5 / 详设 §退路树第三句:是 → R3 便宜模型天花板;否 → KEEP 留观。
|
||||
cheap_rates = [(name, _pass_rate(s)) for name, s in cheap_models]
|
||||
cheap_rates_known = [(n, r) for n, r in cheap_rates if r is not None]
|
||||
all_cheap_floor = bool(cheap_rates_known) and all(
|
||||
r < QPASS_FLOOR_MAX for _, r in cheap_rates_known)
|
||||
pr_baseline = _pass_rate(strong_baseline[1]) if strong_baseline else None
|
||||
baseline_passes = pr_baseline is not None and pr_baseline > QPASS_STRONG_BASELINE_MIN
|
||||
|
||||
# 拼 Q4 的可读裁决依据:便宜档各自过门率 + 强基线过门率(缺则显式标「缺」)。
|
||||
cheap_desc = ", ".join(f"{n}={r:.0%}" for n, r in cheap_rates_known) or "(便宜档过门率缺失)"
|
||||
baseline_name = strong_baseline[0] if strong_baseline else "(缺)"
|
||||
baseline_desc = (
|
||||
f"{pr_baseline:.0%}(能过 = >{QPASS_STRONG_BASELINE_MIN:.0%})"
|
||||
if pr_baseline is not None else "缺失"
|
||||
)
|
||||
reasons.append(
|
||||
f"Q4 读便宜档全线={cheap_desc}(全线崩阈值 ★<{QPASS_FLOOR_MAX:.0%});"
|
||||
f"强基线 {baseline_name} 过门率={baseline_desc}。")
|
||||
if all_cheap_floor and baseline_passes:
|
||||
reasons.append(
|
||||
f"→ R3 天花板:便宜档全线 < ★{QPASS_FLOOR_MAX:.0%} 而强基线能过 → 判便宜模型天花板,"
|
||||
"换更贵模型重估经济性(撞 ¥3/款 上限就缓行),或整轨缓行。")
|
||||
return {"exit": EXIT_R3_CEILING, "reasons": reasons}
|
||||
|
||||
# ── KEEP:既非全线崩、也非天花板 → 据具体数微调前置物再跑(只许微调一轮)──
|
||||
reasons.append(
|
||||
"→ KEEP 留观:既非全线崩(便宜档非全线 < ★20% 或强基线也没过)、也非单系统/表现层集中失败,"
|
||||
"据具体数微调前置物后再跑一轮(铁律:只许微调一轮,绝不滑成无限调参)。")
|
||||
return {"exit": EXIT_KEEP, "reasons": reasons}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 角色档名映射常量(对齐 G3 模型矩阵的四角色档;真跑改档名只动这里)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 最强便宜档(Q1 读它):图 G3「强便宜档 deepseek-v4-pro」。按序命中第一个存在的档。
|
||||
STRONG_CHEAP_KEYS = ("deepseek-v4-pro", "v4-pro")
|
||||
# 便宜档集合(Q4「便宜档全线」读它):强便宜档 + 主力便宜档(图 G3 v4-pro / v4-flash)。
|
||||
CHEAP_KEYS = ("deepseek-v4-pro", "v4-pro", "deepseek-v4-flash", "v4-flash")
|
||||
# 强模型基线(Q4「强基线能过」读它):图 G3「上限基线 Opus/Fable」(仅验上限,不进成本)。
|
||||
STRONG_BASELINE_KEYS = ("Opus", "Fable", "opus", "fable")
|
||||
|
||||
|
||||
def _first_present(by_model: dict[str, Any], keys: tuple[str, ...]) -> tuple[str, dict] | None:
|
||||
"""按 keys 顺序在 by_model 里找第一个存在的档,返回 (命中键名, 该档聚合 dict);全缺 → None。"""
|
||||
for k in keys:
|
||||
v = by_model.get(k)
|
||||
if isinstance(v, dict):
|
||||
return (k, v)
|
||||
return None
|
||||
|
||||
|
||||
def _present_subset(by_model: dict[str, Any], keys: tuple[str, ...]) -> list[tuple[str, dict]]:
|
||||
"""取 by_model 里命中 keys 的所有档(去重,保持首次出现顺序),返回 [(键名, 聚合 dict), ...]。"""
|
||||
out: list[tuple[str, dict]] = []
|
||||
seen: set[str] = set()
|
||||
for k in keys:
|
||||
if k in seen:
|
||||
continue
|
||||
v = by_model.get(k)
|
||||
if isinstance(v, dict):
|
||||
out.append((k, v))
|
||||
seen.add(k)
|
||||
return out
|
||||
|
||||
|
||||
def _merge_fail_systems(models: list[tuple[str, dict]]) -> dict[str, float]:
|
||||
"""把若干便宜档各自的 fail_system 分布按桶相加,合成一份跨便宜档的整体分布。
|
||||
|
||||
仅当 stats 未给 fail_system_overall 时退而用此现合。桶名沿用采集字段表
|
||||
(resource/merge/order/presentation),其余未知桶原样累加(不丢弃,便于 reasons 追溯)。
|
||||
"""
|
||||
merged: dict[str, float] = {}
|
||||
for _, s in models:
|
||||
dist = s.get("fail_system") if isinstance(s, dict) else None
|
||||
if not isinstance(dist, dict):
|
||||
continue
|
||||
for bucket, cnt in dist.items():
|
||||
if isinstance(cnt, (int, float)) and cnt > 0:
|
||||
merged[bucket] = merged.get(bucket, 0.0) + float(cnt)
|
||||
return merged
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# __main__ 自测块:构造五组 stats 各触发一个出口,内联断言覆盖五出口(无网络/无 agentscope)
|
||||
# 校验:python3 -c 'from worker import fallback_tree'(import 干净)+ python3 fallback_tree.py(自测)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__": # pragma: no cover —— 本地自测,五出口各一次
|
||||
def _check(label: str, stats: dict, expect: str) -> None:
|
||||
"""跑一次 decide 并断言出口符合预期;打印走过的 reasons 便于人工核对触发线。"""
|
||||
out = decide(stats)
|
||||
got = out["exit"]
|
||||
ok = "OK" if got == expect else "FAIL"
|
||||
print(f"[{ok}] {label}: exit={got}(期望 {expect})")
|
||||
for r in out["reasons"]:
|
||||
print(f" · {r}")
|
||||
assert got == expect, f"{label}: 期望 {expect},实得 {got}"
|
||||
|
||||
# ① GO:最强便宜档 v4-pro 过门率 45% ≥ ★40% → go(达阈值,人锚另判)。
|
||||
_check(
|
||||
"GO · v4-pro 45% 达阈值",
|
||||
{"by_model": {
|
||||
"deepseek-v4-pro": {"pass_rate": 0.45, "n": 30, "pass_count": 14},
|
||||
"deepseek-v4-flash": {"pass_rate": 0.33, "n": 30},
|
||||
"MiniMax-M3": {"pass_rate": 0.55, "n": 30},
|
||||
}},
|
||||
EXIT_GO,
|
||||
)
|
||||
|
||||
# ② R1 退模板化:v4-pro 30% < 40%,失败 8/(8+2+1) ≈ 73% 集中 presentation(表现层)→ R1。
|
||||
_check(
|
||||
"R1 · v4-pro 30% 且失败集中表现层",
|
||||
{"by_model": {
|
||||
"deepseek-v4-pro": {"pass_rate": 0.30, "n": 30},
|
||||
"deepseek-v4-flash": {"pass_rate": 0.25, "n": 30},
|
||||
},
|
||||
"fail_system_overall": {"presentation": 8, "merge": 2, "order": 1}},
|
||||
EXIT_R1_TEMPLATE,
|
||||
)
|
||||
|
||||
# ③ R2 补骨架:v4-pro 30% < 40%,表现层不集中,失败 7/(7+1+1) ≈ 78% 集中 merge 系统 → R2。
|
||||
_check(
|
||||
"R2 · 失败集中在 merge 系统",
|
||||
{"by_model": {
|
||||
"deepseek-v4-pro": {"pass_rate": 0.30, "n": 30},
|
||||
"deepseek-v4-flash": {"pass_rate": 0.28, "n": 30},
|
||||
},
|
||||
"fail_system_overall": {"merge": 7, "presentation": 1, "order": 1}},
|
||||
EXIT_R2_SCAFFOLD,
|
||||
)
|
||||
|
||||
# ④ R3 天花板:便宜档全线 < ★20%(15%/10%),且强基线 Opus 能过(50%>0),失败分散不集中 → R3。
|
||||
_check(
|
||||
"R3 · 便宜档全线 <20% 且强基线能过",
|
||||
{"by_model": {
|
||||
"deepseek-v4-pro": {"pass_rate": 0.15, "n": 30},
|
||||
"deepseek-v4-flash": {"pass_rate": 0.10, "n": 30},
|
||||
"Opus": {"pass_rate": 0.50, "n": 3},
|
||||
},
|
||||
# 失败分散到四桶,均不过半,不触发 Q2/Q3。
|
||||
"fail_system_overall": {"presentation": 6, "resource": 6, "merge": 6, "order": 6}},
|
||||
EXIT_R3_CEILING,
|
||||
)
|
||||
|
||||
# ⑤ KEEP 留观:v4-pro 30% < 40%,失败分散(无集中),便宜档非全线 <20%(flash 30%)→ KEEP。
|
||||
_check(
|
||||
"KEEP · 既非集中也非全线崩",
|
||||
{"by_model": {
|
||||
"deepseek-v4-pro": {"pass_rate": 0.30, "n": 30},
|
||||
"deepseek-v4-flash": {"pass_rate": 0.30, "n": 30},
|
||||
"Opus": {"pass_rate": 0.50, "n": 3},
|
||||
},
|
||||
"fail_system_overall": {"presentation": 5, "resource": 5, "merge": 5, "order": 5}},
|
||||
EXIT_KEEP,
|
||||
)
|
||||
|
||||
# ⑥ 兜底:数据不足 → KEEP(不当 GO、不当 no-go)。
|
||||
_check("KEEP · 数据不足兜底", {"by_model": {}}, EXIT_KEEP)
|
||||
|
||||
print("\n[fallback_tree] 五出口 + 兜底自测全部通过。")
|
||||
@ -1,4 +1,4 @@
|
||||
"""middleware.py —— tier2 单写 ReAct agent 的四道熔断(C3 图说)+ 软刹。
|
||||
"""middleware.py —— tier2 单写 ReAct agent 的四道熔断(C3 图说)+ 软刹 + trace 接线(H1/H2)。
|
||||
|
||||
为什么要熔断:放开 max_iters 的自治 ReAct 循环不能无限烧 —— 模型可能卡在「改→构建失败→再改」的
|
||||
死圈、或 thinking 跑飞、或单步外部调用挂死。四道熔断是循环的硬地板,任一触发即停,落 verdict.breakerKind
|
||||
@ -19,12 +19,34 @@
|
||||
(middleware/__init__.py 仅导出 TracingMiddleware/TTSMiddleware)。故软刹也在本模块自实现:
|
||||
软刹 = 临近硬顶时(达 soft_ratio×硬顶)往 agent 注入一条 system-reminder,提醒它「快收敛、尽早 finish」,
|
||||
给模型一个体面收尾的机会,而非到硬顶才粗暴 kill。软刹经 on_system_prompt 钩子(transformer 管线)实现。
|
||||
|
||||
trace 接线(H1/H2,Tier2TraceMiddleware,本模块第二个 middleware):
|
||||
H 族图说要求 tier2 这条线「订阅 + 映射」官方 typed Event System 产统一 trace 形状。AgentScope v2.0.2 的
|
||||
`on_reply` 钩子是**最外层洋葱**(_agent.py:505/532),它看得见一次 reply 流经的**全部** AgentEvent ——
|
||||
ReplyStart/End、ModelCallStart/End(End 带 token)、ThinkingBlock*/TextBlock*(reason)、ToolCall*(act)、
|
||||
ToolResult*(observe)——正是 trace.py to_trace_step 映射的那七类(类名经 event/_event.py 逐条核过)。
|
||||
故 trace 接线 = 一个独立的 on_reply middleware,把每个流经事件 ingest 进 observability.trace.TraceAdapter,
|
||||
再原样 yield 下去(零修改事件流、不影响熔断与生成)。**不并进 CircuitBreakerMiddleware** 是为了单一职责:
|
||||
熔断与 trace 各自一个 middleware,互不耦合;两者都挂 on_reply 时由框架按洋葱顺序串成链(_agent.py:511 execute_chain)。
|
||||
best-effort 铁律(H1/H2 黄带):trace ingest 全包 try(TraceAdapter.ingest 内部已 best-effort),
|
||||
本 middleware 再兜一层——trace 失败绝不中断生成,只告警。
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from agentscope.middleware import MiddlewareBase
|
||||
|
||||
# trace adapter 在 observability 模块(H1/H2 产出件);包内/直跑兼容导入。
|
||||
try:
|
||||
from observability.trace import TraceAdapter # type: ignore
|
||||
except Exception: # pragma: no cover —— 直跑/路径未就位兜底:把 gen-worker/ 加进 sys.path 再取
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from observability.trace import TraceAdapter # type: ignore
|
||||
|
||||
|
||||
class Tier2CircuitBreak(Exception):
|
||||
"""熔断触发异常:编排器 catch 它,据 kind 落 verdict.breakerKind。
|
||||
@ -167,3 +189,62 @@ class CircuitBreakerMiddleware(MiddlewareBase):
|
||||
# 可追溯日志(创始人铁律:错误路径要有可追溯日志)。
|
||||
print(f"[tier2-circuit] 熔断触发 kind={kind} reason={reason}", flush=True)
|
||||
raise Tier2CircuitBreak(kind, reason)
|
||||
|
||||
|
||||
class Tier2TraceMiddleware(MiddlewareBase):
|
||||
"""trace 接线(H1/H2):把官方 typed Event System 经 on_reply 洋葱钩子 ingest 进 TraceAdapter。
|
||||
|
||||
职责(对 tier2细节图说-H §H1/H2「订阅 + 映射」,非「埋点 + 采集」):
|
||||
on_reply 是最外层洋葱(_agent.py:505/532),看得见一次 reply 流经的全部 AgentEvent。本 middleware
|
||||
逐事件喂给 TraceAdapter.ingest(adapter 内部经纯函数 to_trace_step 映射成统一 trace 形状并落 sink),
|
||||
再原样 yield 下去——零修改事件流、不影响熔断/生成,纯旁路观测。
|
||||
|
||||
与 CircuitBreakerMiddleware 的关系:两者都挂 on_reply,各自一个 middleware(单一职责)。框架把多个
|
||||
on_reply middleware 串成洋葱链(_agent.py:511 execute_chain 递归);本 middleware 与熔断的相对顺序
|
||||
由 Agent(middlewares=[...]) 列表序决定,二者互不依赖(trace 只读事件、不拦截、不抛)。
|
||||
|
||||
best-effort 铁律(H1/H2 黄带):trace ingest 失败绝不中断生成。TraceAdapter.ingest 内部已 best-effort
|
||||
(映射/落 sink 异常只告警不抛),本 middleware 再兜一层 try——哪怕 adapter 本身抛了,也只告警、照样
|
||||
把事件 yield 下去,保证生成主链不被一次轨迹抖动废掉。
|
||||
|
||||
单实例对应单 agent 单次 reply;trace_id 在构造时钉定,本次 reply 所有 step 共用(对接 verdict.evidence.traceId)。
|
||||
"""
|
||||
|
||||
def __init__(self, trace_id: str, *, adapter: Optional[TraceAdapter] = None,
|
||||
sink=None, keep_in_memory: bool = True) -> None:
|
||||
"""
|
||||
Args:
|
||||
trace_id: 本次生成的 traceId(贯穿;对接 tier2-verdict evidence.traceId / 成本关联键)。
|
||||
adapter: 复用外部已建的 TraceAdapter(便于编排器收口读 steps/summary);None → 本类新建一个。
|
||||
sink: 落库回调 sink(trace_step_dict);None → adapter 只留内存 steps(收口由编排器读)。
|
||||
keep_in_memory: 是否在 adapter.steps 留内存副本(长 run 可置 False 只走 sink 省内存)。
|
||||
"""
|
||||
self.trace_id = trace_id
|
||||
# 复用传入 adapter 或新建;adapter 是 trace 的产出件(H1/H2),本 middleware 只负责把事件喂进去。
|
||||
self.adapter = adapter or TraceAdapter(
|
||||
trace_id=trace_id, sink=sink, keep_in_memory=keep_in_memory)
|
||||
# 兜底层告警计数(adapter 自身抛异常的极端情况;正常应为 0,因 adapter 内部已 best-effort)。
|
||||
self._middleware_dropped = 0
|
||||
|
||||
async def on_reply(self, agent, input_kwargs, next_handler):
|
||||
"""最外层洋葱:逐事件 ingest 进 TraceAdapter 后原样 yield(旁路观测,best-effort 不中断)。"""
|
||||
async for evt in next_handler(**input_kwargs):
|
||||
# trace ingest 全包 try:adapter.ingest 内部已 best-effort,这里再兜一层防御
|
||||
# (哪怕 adapter 本身抛了也不连累生成流;trace 失败只告警,事件照常向下游转发)。
|
||||
try:
|
||||
self.adapter.ingest(evt)
|
||||
except Exception as exc: # noqa: BLE001 —— best-effort:trace 任何异常都不中断生成主链
|
||||
self._middleware_dropped += 1
|
||||
print(
|
||||
f"[tier2-trace] middleware ingest 兜底失败(best-effort,traceId={self.trace_id}):"
|
||||
f"{type(exc).__name__}: {exc}",
|
||||
flush=True,
|
||||
)
|
||||
# 原样向下游转发事件(不修改、不拦截 —— trace 是纯旁路)。
|
||||
yield evt
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""收口摘要:本次 trace 的 traceId、步数、丢弃数(透传 adapter.summary,另带 middleware 兜底丢弃数)。"""
|
||||
s = self.adapter.summary()
|
||||
s["middlewareDropped"] = self._middleware_dropped
|
||||
return s
|
||||
|
||||
289
tier2/gen-worker/worker/run_record.py
Normal file
289
tier2/gen-worker/worker/run_record.py
Normal file
@ -0,0 +1,289 @@
|
||||
"""worker/run_record.py —— tier2 富游戏自治线 · run 级结构化采集记录(G 族 G4 采集字段表落地)。
|
||||
|
||||
【为什么有这个模块】
|
||||
G 族图说(tier2细节图说-G-spike-runbook.md 图 G4「过门阈值 + 采集字段」)钉死:0号 spike 的
|
||||
go/no-go 不能靠拍脑袋,必须有 run 级、每款一行的可对比底料。没有字段定义,跑完拿不到可对比的数,
|
||||
退路树(图 G5)的三条数字触发线也跟着悬空。当前 run 收口(worker/agent_loop/studio.py 的 run_studio)
|
||||
只攒到 cost / tokens / wall 子集,缺 G4 列出的 run_id / model / stage / brief_variant / fail_stage /
|
||||
fail_system / repairs 与长程一致性三字段——其中 **fail_system 是退路树分流的关键依据**(它能区分
|
||||
「整轨范式不行」和「某个系统的骨架缺口」,这两者的退路 R1/R2 完全不同)。本模块把 G4 的字段表
|
||||
编译成一个可序列化的数据类,补齐这个采集面。
|
||||
|
||||
【职责边界:纯数据 + 序列化,零副作用】
|
||||
- 本模块只定义 RunRecord(数据形状)与序列化函数(to/from jsonl line),零运行期依赖
|
||||
(不 import agentscope / wg1 / worker.run),故 6c6g 上也能直接 py_compile / import 自检。
|
||||
- 唯一的显式副作用是 append_record(path, rec):把一条记录追加进 JSONL 台账文件——这是采集的落盘出口,
|
||||
与「纯数据」分离,调用方显式调用才会写盘。除它之外,本模块构造/序列化记录绝不碰文件系统。
|
||||
- 接线(从 studio result / verdict / cost.cost_for_run 抽字段填一条 RunRecord)交 Phase2 B1/B2,
|
||||
不在本模块、也不改 run 主链。本模块只提供「形状 + 序列化 + 落盘」三件,供接线方调用。
|
||||
|
||||
【时间戳显式传入,不硬取】
|
||||
timestamp 由调用方传入(留参数),不在记录构造时 time.time() / datetime.now() 硬取——
|
||||
这样测试可复现(传固定时间往返一致),也便于接线方统一用 run 开始/结束的同一口径时钟。
|
||||
|
||||
【字段命名权威 = G4 图说】
|
||||
采集字段名严格对齐 tier2细节图说-G §图 G4 与 tier2实现详设「采集指标字段表」:
|
||||
长程一致性三字段取图说原名 ctx_compressed / checkpoint_recovered / idempotency_clean
|
||||
(任务描述里给的 cross_table_reachability/economy_closure/latch_consistency 只是「或图说原名」的
|
||||
备选提示,图说原名是前者,故采前者,避免与 D 族富游戏三门(三联动/经济/latch)的门名混淆)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 受控词表(与 G4 采集字段表 / tier2-verdict 契约对齐;用 Literal 标注语义,不强制运行期校验)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# stage:这条 run 记的是哪一阶段(G4 标识组)。design=阶段1设计稿;build=写源+构建;play=真玩跑门。
|
||||
# spike 主链一次 run 通常贯穿三段、最终落 play;但失败可能终止在更早的段(见 fail_stage)。
|
||||
Stage = Literal["design", "build", "play"]
|
||||
|
||||
# fail_stage:失败挂在哪一道关(G4 失败定位组)。对齐 D 族三层校验的段序与 verdict.reasons 词表:
|
||||
# extract —— 抽取/解析失败(模型产出取不出可用结构);
|
||||
# validate —— 数据表预检失败(validate_datatable:schema/DAG/可达性,见 toolkit);
|
||||
# build —— esbuild 多文件构建不过(structureOk=false / structure_invalid);
|
||||
# seven_gate —— L1 九门(A_boot…I_control)某致命门未过(runnable_fail / gate_<门名>);
|
||||
# triple_link —— 富游戏三联动门未过(gate_tripleLink);
|
||||
# economy —— 富游戏经济门未过(gate_economy);
|
||||
# "" / None —— 未失败(decision=accept)或失败段不明。
|
||||
FailStage = Literal[
|
||||
"extract", "validate", "build", "seven_gate", "triple_link", "economy"
|
||||
]
|
||||
|
||||
# fail_system:失败挂在哪个游戏系统(G4 经营品类特有列,**退路树分流关键依据**)。
|
||||
# 对齐 mini-肥鹅 三系统 + 表现层:resource(资源/经济枢纽)/ merge(合成)/ order(订单)/ presentation(表现层 56%)。
|
||||
# 它区分「整轨范式不行(便宜模型写不出表现层)」与「某系统骨架缺口」——前者退 R1 模板化,后者退 R2 补骨架。
|
||||
# "" / None —— 未失败或系统不明。
|
||||
FailSystem = Literal["resource", "merge", "order", "presentation"]
|
||||
|
||||
# decision:终判三值,取自 verdict.decision(tier2-verdict 契约)。accept/fix/kill。
|
||||
Decision = Literal["accept", "fix", "kill"]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# RunRecord —— G4 采集字段表的数据类(每款 run 一条,落 JSONL 台账)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
@dataclass
|
||||
class RunRecord:
|
||||
"""一次 tier2 富游戏自治 run 的结构化采集记录(对到 G4「run 级采集字段,每款一行」)。
|
||||
|
||||
字段按 G4 的分组排列:标识 / 过门核心 / 失败定位 / 成本与墙钟 / 长程一致性。
|
||||
每个字段注释它的【来源】(从哪个对象抽)与【用途】(汇到哪张矩阵级图 / 喂哪条退路触发线)。
|
||||
|
||||
汇总口径(矩阵级,本记录是其行级底料,聚合在编排/分析侧做,本类不做):
|
||||
- 过门率 = pass 款数 / n —— 据 decision/pass 统计;
|
||||
- ¥/成功款 = Σcost_yuan / pass 款数 —— 据 cost_yuan 与 pass 统计;
|
||||
- 收敛中位数 = median(repairs) —— 据 repairs;
|
||||
- fail_system 分布 —— 据 fail_system,直接喂退路树 Q2/Q3 分流。
|
||||
"""
|
||||
|
||||
# ── 标识组(G4:run_id / model / stage / brief_variant)──
|
||||
run_id: str
|
||||
"""run 唯一标识。来源:调用方为每次 run 生成(可用 game_id + 序号 / uuid);用途:行级主键,
|
||||
关联 verdict.evidence.traceId 与 cost 账,跨表对账。"""
|
||||
|
||||
model: str
|
||||
"""本次 run 使用的模型档名(如 MiniMax-M3 / deepseek-v4-flash / deepseek-v4-pro)。
|
||||
来源:studio result['model'];用途:G3 矩阵把过门率/成本/收敛按模型档归属——「60% 是哪个模型的 60%」靠它答。"""
|
||||
|
||||
stage: Stage
|
||||
"""这条记录落在哪一阶段(design/build/play)。来源:接线方按 run 走到哪段判定(成功款=play);
|
||||
用途:标识本行的口径,区分中途终止与完整跑完。"""
|
||||
|
||||
brief_variant: str
|
||||
"""题面变体名(G3 的 5 个一句话变体之一:美食/水果/咖啡/面包/糖水店,或其 slug)。
|
||||
来源:调用方按当前 brief 归类;用途:n≥30 的 5×6 抽样里标这一行属哪个变体,做变体维度的方差分析。"""
|
||||
|
||||
# ── 过门核心组(G4:pass / repairs)──
|
||||
pass_gate: bool
|
||||
"""是否过门(L1 九门 + 富游戏三门全绿 + latch)。来源:verdict.layerResults.L1.passed
|
||||
且 verdict.decision == 'accept'(judge 纯代码判,零自评);用途:矩阵级过门率 = pass 款数 / n 的分子。
|
||||
字段名用 pass_gate(pass 是 Python 关键字不能直接做字段名),序列化时映射回 G4 的 'pass'。"""
|
||||
|
||||
repairs: int
|
||||
"""自纠轮数。来源:studio 的 attempt 计数(外层有界 resume 的次数 / ReAct 自治循环轮数,
|
||||
见 studio.run_studio 的 max_resumes 循环;也可取 verdict.round);用途:收敛步数判据③,
|
||||
超 ★ 上限(单系统 8 轮 / 整局 40 轮)即判不收敛;矩阵级取中位数看模型档的收敛代价。"""
|
||||
|
||||
# ── 失败定位组(G4:fail_stage / fail_system)──
|
||||
fail_stage: Optional[FailStage] = None
|
||||
"""失败挂在哪道门(见上 FailStage 词表)。来源:据 verdict.reasons / 未过门名映射;
|
||||
用途:定位失败发生的关序,与 fail_system 一起喂退路树;decision=accept 时为 None。"""
|
||||
|
||||
fail_system: Optional[FailSystem] = None
|
||||
"""失败挂在哪个游戏系统(resource/merge/order/presentation)。**退路树分流关键依据**。
|
||||
来源:据 verdict 富游戏门 checks / playReport 语义 state 推断(如三联动门 requiresReachable 挂→order
|
||||
或 merge;F门 calls=0 多在 presentation 没接输入);用途:退路树 Q2(失败集中表现层?→R1)/
|
||||
Q3(某系统装不出其余能过?→R2)的分流键;decision=accept 时为 None。"""
|
||||
|
||||
# ── 成本与墙钟组(G4:cost_rmb / tokens_by_model / wall_s;此处字段名按任务要求落地)──
|
||||
cost_yuan: float = 0.0
|
||||
"""本次 run 的总成本(人民币)。来源:observability.cost.cost_for_run(...)['cost_rmb']
|
||||
(new-api quota 权威口径折¥,非公开单价估);用途:成本判据②,撞 ★≤¥3/成功款 即触发退路;
|
||||
矩阵级 ¥/成功款 = Σcost_yuan / pass 款数。命名 cost_yuan 对应 G4 的 cost_rmb(同义)。"""
|
||||
|
||||
tokens_in: int = 0
|
||||
"""本次 run 输入 token 总数(prompt)。来源:studio result['tokens']['prompt']
|
||||
或 cost.tokens_by_model_from_records 汇总的 in;用途:成本审计与跨档对比。"""
|
||||
|
||||
tokens_out: int = 0
|
||||
"""本次 run 输出 token 总数(completion)。来源:result['tokens']['completion'];用途:同上。"""
|
||||
|
||||
tokens_cached: int = 0
|
||||
"""本次 run 命中缓存的 prompt token 总数。来源:result['tokens']['cached']
|
||||
(RecordingChatModel 按 Anthropic usage 口径取的命中数);用途:折¥时按 cache_ratio 折扣、对账缓存收益。"""
|
||||
|
||||
tokens_by_model: dict[str, dict[str, int]] = field(default_factory=dict)
|
||||
"""per-model token 明细(G4 原名 tokens_by_model)。来源:cost.tokens_by_model_from_records(...)
|
||||
产出的 {model: {in, out, cached}};用途:一次 run 跨多模型档(M3 证路 + 便宜档比成本)时,
|
||||
成本按档归属。单档 run 时与上面三个扁平字段冗余但不矛盾(扁平=全档汇总,本字段=分档明细)。"""
|
||||
|
||||
wall_seconds: float = 0.0
|
||||
"""端到端墙钟耗时(秒)。来源:studio result['wall_s'];用途:跑批吞吐/超时观测(非过门判据,
|
||||
超时由 C3 熔断 timeout 另判)。命名 wall_seconds 对应 G4 的 wall_s。"""
|
||||
|
||||
# ── 长程一致性组(G4 原名:file_count / total_loc + ctx_compressed / checkpoint_recovered / idempotency_clean)──
|
||||
# 直接喂判据④(12+ 文件工程过程无 checkpoint 丢失、半轮副作用幂等无脏)。
|
||||
file_count: int = 0
|
||||
"""交付源工程的文件数。来源:len(studio result['file_tree']) / source_project.fileTree;
|
||||
用途:长程一致性判据④的规模锚(mini-肥鹅 约 12 文件),太少=没真写出多文件富游戏。"""
|
||||
|
||||
total_loc: int = 0
|
||||
"""交付源工程总代码行数。来源:接线方对 source_project 各文件 content 计行;
|
||||
用途:同 file_count,作工程规模的长程一致性信号。"""
|
||||
|
||||
ctx_compressed: bool = False
|
||||
"""本次 run 是否发生过上下文压缩(长程对话超窗触发的 memory 压缩)。来源:接线方据 agent
|
||||
memory/middleware 观测(发生压缩=长程跨度大);用途:长程一致性判据④——压缩后是否仍连贯。"""
|
||||
|
||||
checkpoint_recovered: bool = False
|
||||
"""本次 run 是否从 checkpoint 恢复过(中断续跑)。来源:接线方据编排观测;
|
||||
用途:长程一致性判据④的「过程无 checkpoint 丢失」——恢复后工程是否未丢状态。"""
|
||||
|
||||
idempotency_clean: bool = True
|
||||
"""半轮副作用是否幂等无脏(同一步重放不产生重复/脏写)。来源:接线方据工具调用幂等校验;
|
||||
用途:长程一致性判据④的「半轮副作用幂等无脏」;默认 True(无脏),观测到脏写置 False。"""
|
||||
|
||||
# ── 终判与收口(取自 verdict / studio,便于行级直接读判定结果)──
|
||||
decision: Optional[Decision] = None
|
||||
"""终判三值(accept/fix/kill)。来源:verdict.decision;用途:与 pass_gate 互证,
|
||||
kill 时配 fail_stage/fail_system 看致死门。"""
|
||||
|
||||
finished: bool = False
|
||||
"""是否真 finish 交付源工程(agent 调 finish 组装了源工程)。来源:studio result['finished'];
|
||||
用途:区分「过门但没 finish(异常)」与「真交付」。"""
|
||||
|
||||
timestamp: float = 0.0
|
||||
"""记录时间戳(Unix 秒,**由调用方传入**,本类不硬取)。来源:接线方传 run 结束时刻;
|
||||
用途:台账时序、按时间窗聚合。留参数=测试可复现 + 调用方统一时钟口径。"""
|
||||
|
||||
# ── 自产 driver 安全三字段(G4「仅第二段」;第一段 driver 人预建、不采,留 None)──
|
||||
# 对应 Goodhart 防线(图 G5 第二段三层隔离):防写者写判自己卷子的 driver。
|
||||
driver_authored: Optional[bool] = None
|
||||
"""本次 run 是否由 agent 自产/扩展了取证 driver(仅第二段开放自产 driver 时采;第一段=None)。
|
||||
来源:接线方据第二段编排观测;用途:Goodhart 安全维度采集。"""
|
||||
|
||||
driver_rejected_by_review: Optional[int] = None
|
||||
"""自产 driver 被独立 adversarial 评审拒绝的次数(仅第二段)。来源:第二段三层隔离评审计数;
|
||||
用途:防「写者写只读不暴露真实力的 driver」,被拒次数是防线生效信号。"""
|
||||
|
||||
driver_edits: Optional[int] = None
|
||||
"""agent 对 driver 的编辑次数(仅第二段)。来源:第二段编排观测;
|
||||
用途:防 agent 用改 driver 逃避卡死探测。"""
|
||||
|
||||
def to_jsonl_line(self) -> str:
|
||||
"""序列化成一行 JSONL(末尾不带换行;append_record 落盘时补 '\\n')。
|
||||
|
||||
- pass_gate 字段在 JSON 里写回 G4 的原名 'pass'(pass 是 Python 关键字,字段名只能避开,
|
||||
但落盘口径须对齐 G4 字段表,否则分析侧拿不到约定列名)。
|
||||
- dict/嵌套(tokens_by_model)原样落;None 原样保留(便于区分「未采」与「采到 0/False」)。
|
||||
- ensure_ascii=False:中文 brief_variant 等不转义,台账可读。
|
||||
"""
|
||||
d = asdict(self)
|
||||
# 字段名 → G4 落盘列名映射(仅 pass_gate 因关键字冲突需改名;其余字段名即 G4 列名)。
|
||||
d["pass"] = d.pop("pass_gate")
|
||||
return json.dumps(d, ensure_ascii=False, sort_keys=False)
|
||||
|
||||
@classmethod
|
||||
def from_jsonl_line(cls, line: str) -> "RunRecord":
|
||||
"""从一行 JSONL 反序列化回 RunRecord(to_jsonl_line 的逆;往返一致)。
|
||||
|
||||
- 把落盘列名 'pass' 映射回字段名 pass_gate。
|
||||
- 只取本数据类声明的字段(忽略未知多余键,向后兼容台账新增列;缺失字段用 dataclass 默认值兜)。
|
||||
"""
|
||||
raw: dict[str, Any] = json.loads(line)
|
||||
data = dict(raw) # 不就地改入参
|
||||
if "pass" in data:
|
||||
data["pass_gate"] = data.pop("pass")
|
||||
# 只喂本类已声明的字段名,过滤未知键(防台账演进出现新列时构造报错)。
|
||||
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
||||
filtered = {k: v for k, v in data.items() if k in known}
|
||||
return cls(**filtered)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# append_record —— 唯一的显式落盘出口(JSONL 台账追加;本模块唯一副作用)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
def append_record(path: str, rec: RunRecord) -> None:
|
||||
"""把一条 RunRecord 追加进 JSONL 台账文件(每行一条记录)。
|
||||
|
||||
设计:
|
||||
- JSONL(每行一个 JSON 对象)= run 级「每款一行」的天然落地形态,append-only 不重写整文件,
|
||||
多 run 顺序追加即成矩阵级底料,分析侧逐行读、聚合算过门率/¥/收敛/fail_system 分布。
|
||||
- 父目录不存在时自动创建(mkdir -p 语义),避免接线方忘了建目录就丢数据。
|
||||
- 文本模式 'a' + utf-8:中文 brief_variant 可读;flush 由 with 块关闭时保证落盘。
|
||||
|
||||
错误路径(可追溯):本函数不吞 IO 异常——落盘失败应让接线方感知(采集数据丢了是要查的),
|
||||
故不 try/except 静默;由调用方决定重试/告警(采集是 best-effort 旁路,失败不应阻断生成主链,
|
||||
但也不该静默丢数,这条权衡留给接线方按上下文处置)。
|
||||
|
||||
:param path: JSONL 台账文件路径(如 results/spike-runs.jsonl)。
|
||||
:param rec: 一条 RunRecord。
|
||||
"""
|
||||
import os
|
||||
|
||||
# 自动建父目录(path 可能含尚不存在的目录;dirname 为空表示当前目录,不需建)。
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as fp:
|
||||
fp.write(rec.to_jsonl_line() + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover —— 本地自检:构造一条 + 序列化往返一致(无副作用、无网络)
|
||||
# 桩数据:模拟一次过门的成功 run(M3 档、面包变体),验证 to/from jsonl 往返一致。
|
||||
_r = RunRecord(
|
||||
run_id="feie-001#1",
|
||||
model="MiniMax-M3",
|
||||
stage="play",
|
||||
brief_variant="面包店",
|
||||
pass_gate=True,
|
||||
repairs=4,
|
||||
fail_stage=None,
|
||||
fail_system=None,
|
||||
cost_yuan=2.13,
|
||||
tokens_in=120000,
|
||||
tokens_out=42000,
|
||||
tokens_cached=8000,
|
||||
tokens_by_model={"MiniMax-M3": {"in": 120000, "out": 42000, "cached": 8000}},
|
||||
wall_seconds=187.4,
|
||||
file_count=12,
|
||||
total_loc=1840,
|
||||
ctx_compressed=False,
|
||||
checkpoint_recovered=False,
|
||||
idempotency_clean=True,
|
||||
decision="accept",
|
||||
finished=True,
|
||||
timestamp=1750000000.0,
|
||||
)
|
||||
_line = _r.to_jsonl_line()
|
||||
_back = RunRecord.from_jsonl_line(_line)
|
||||
assert _back == _r, "往返不一致:from_jsonl_line(to_jsonl_line(r)) != r"
|
||||
# 确认落盘列名是 G4 的 'pass'(非字段名 pass_gate)。
|
||||
assert '"pass": true' in _line.replace(" ", "").replace('"pass":true', '"pass": true') or '"pass":true' in _line
|
||||
print("[run_record] 自检通过:RunRecord 往返一致;JSONL 落盘列名对齐 G4。")
|
||||
print(_line)
|
||||
@ -260,20 +260,34 @@ def build_toolkit(session: Tier2Session) -> Toolkit:
|
||||
|
||||
# ── 取证:screenshot ──
|
||||
async def screenshot(phase: str = "play", label: str = "") -> str:
|
||||
"""截当前游戏画面,喂给 L3 软检与人工终审/debug。
|
||||
"""读取 CDP harness 真玩时落盘的那一帧游戏截图(喂给 L3 软检与人工终审/debug)。
|
||||
|
||||
边界铁律:截图只取证、**绝不进硬门判定**。L1/L2 判定零截图参与。
|
||||
|
||||
真截图由 run_gates 时的 CDP harness(play-phaser.cdp.cjs)在真浏览器里真截并落盘到
|
||||
evidence/{first-paint.png, after-play.png}(mini-desktop 真跑;6c6g 无 chrome 时不产图)。
|
||||
本工具据 phase 把『约定时相』映射到 harness 实际落盘的那两张真图(不再凭空拼一个不存在的路径):
|
||||
- 'title'/'first'/'boot' → first-paint.png(首帧渲染快照);
|
||||
- 其余('play'/'gameover'/…) → after-play.png(驱动真玩后快照)。
|
||||
返回 exists 字段表明真图是否已落盘——只有 run_gates 真跑过、且在 mini-desktop 有 chrome 时才为 True。
|
||||
|
||||
Args:
|
||||
phase: 截哪个时相(如 'title'/'play'/'gameover')。
|
||||
label: 截图标签(可选)。
|
||||
label: 截图标签(可选;仅作回显,不改落点——落点由 harness 真截的固定文件名决定)。
|
||||
"""
|
||||
# 真截图由 CDP harness 在真玩时落盘(U3/U6,mini-desktop);这里返回约定落点,标注取证用途。
|
||||
# 真截图由 CDP harness 在真玩时落盘(U3/U6,mini-desktop);本工具只读那张真图的真实路径,不再返回桩路径。
|
||||
ev_dir = run._workdir(session.game_id) / "evidence"
|
||||
shot = f"{phase}-{label or 'shot'}.png"
|
||||
# 约定时相 → harness 真落盘的固定文件名(harness saveScreenshot 的两个落点)。
|
||||
p = (phase or "play").lower()
|
||||
shot_name = "first-paint.png" if p in ("title", "first", "boot", "firstpaint", "first-paint") else "after-play.png"
|
||||
shot_path = ev_dir / shot_name
|
||||
exists = shot_path.exists()
|
||||
return json.dumps(
|
||||
{"path": str(ev_dir / shot), "phase": phase,
|
||||
"note": "截图只取证,绝不进 L1/L2 硬门判定(防 Goodhart)。真截图由 run_gates 时 CDP 落盘。"},
|
||||
{"path": str(shot_path), "phase": phase, "label": label,
|
||||
"exists": exists,
|
||||
"note": ("截图只取证,绝不进 L1/L2 硬门判定(防 Goodhart)。"
|
||||
+ ("真图已就位(CDP harness 真截落盘)。" if exists
|
||||
else "真图尚未落盘:run_gates 真跑后(mini-desktop chrome)才会产图。"))},
|
||||
ensure_ascii=False)
|
||||
|
||||
# ── 取证:query_asset ──
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user