feat(aigc/worker): U3 trace 扩展键 + U4 缓存命中落账(B5/B9)

6c6g 后端 Tier0 计划 U3+U4(docs/plans/2026-06-17-001-...)。execution §5.8 trace+cost 落值。
- U3 extractTraceQuietly(Java)+ _extract_trace(worker)additive 抽 modelTier/escalationEvents/giveupDumpPath;
  仅真升档态(stage2/escalationEvents 非空)落 modelTier + 形态校验(非空 str/非空 list)→ 字节兼容(无扩展键键集与扩前一致)、Java/Python 两路同口径
- U4 cost.py 缓存命中折¥:两套字段取 max(DeepSeek prompt_cache_hit_tokens / MiniMax prompt_tokens_details.cached_tokens)
  + cache_ratio 折真实 quota;worker usage 捕获(_safe_int 兜脏不抛);pricing 不可达 tokens-only+costFallback 标记;不阻断仅观测
- 诚实边界:SAA Java 路无 cache 字段源→省略不伪造(follow-up);AgentScope 丢 DeepSeek 顶层字段(待真跑对账);worker 生产路完整落账

验证:mini-desktop Java 12+(SaaGraphDispatcherTraceTest)+ Python 73(test_cost 56/wg1_groupb 17)全绿;
codex 评审 NO-MERGE→2 P0(字节兼容/usage 阻断)+2 P1(pricing 口径/两路一致)全修后绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-17 22:26:27 +00:00
parent d57032a6d3
commit 069ae73e97
9 changed files with 736 additions and 40 deletions

View File

@ -538,6 +538,15 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
// playerK_PLAYER_PANELplayer 软门评判诊断用/非法则省略
putPlayerIfPresent(trace, s);
// U3/B5 救场阶梯扩展键additive worker _extract_trace 同口径缺则省略键存量 trace_json 字节兼容
// 严格逐键 putIfPresentmodelTier 真升档态stage2 escalationEvents 非空才落默认 stage1 省略
// P0-1无扩展键时键集与扩前一致K_ESCALATION_EVENTS/K_GIVEUP_DUMP_PATH 未发生救场时缺 省略
// 任一键解析失败不连坐外层 try 已兜底
putEscalationKeysIfPresent(trace, s);
// cacheHitSAA callAndRecord 现只读 prompt/completion tokenSpring AI Usage 接口无 cache 字段
// 无命中数据源 不伪造整段省略 cost 省略同因没有就不挂避免 D11/审核台误读
// SAA 缓存命中捕获 = follow-up须改 callAndRecord native usage + token state 超本单元范围
return trace.isEmpty() ? null : trace;
} catch (Exception e) {
// 命门抽取异常一律吞 + warn null reqVO.trace=null persistTraceQuietly 守卫旁路 trace_json NULL
@ -692,6 +701,46 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
}
}
/**
* U3/B5 救场阶梯扩展键 additive 抽取modelTier / escalationEvents / giveupDumpPath
*
* <p><b>口径对齐 worker {@code _extract_trace}</b>camelCase 键名一致worker 同段亦逐键 present-then-put
* <ul>
* <li>{@code modelTier}{@link SaaStudioNodes#K_MODEL_TIER}render stage1escalate stage2<b>真升档态才塞</b>
* {@code modelTier=="stage2"} <b></b> escalationEvents 非空数组时落未升档默认 stage1无救场事件 <b>省略该键</b>
* P0-1 修复render 默认落 stage1 是初值噪声正常路落它会破无扩展键时键集与扩前一致的字节兼容 worker 同口径</li>
* <li>{@code escalationEvents}{@link SaaStudioNodes#K_ESCALATION_EVENTS} JSON 数组串escalate append
* {tierBefore,tierAfter,atFailCount,ts}解析为非空数组则原样塞未升档/ "[]"/非数组 省略键</li>
* <li>{@code giveupDumpPath}{@link SaaStudioNodes#K_GIVEUP_DUMP_PATH}giveup 节点产U5 真落盘非空则塞
* 未放弃 省略</li>
* </ul>
*
* <p><b>字节兼容</b> additive未升档/未救场时三键<b>全省略</b>modelTier 默认 stage1 不再落
* 存量及正常路 trace_json 键集与扩前一致{@code ReadinessScorer} 不读这三键仅诊断/成本台账展示两路同读不破
* 单键解析失败不连坐escalationEvents JSON 该键省略modelTier/giveupDumpPath 照常
*/
private static void putEscalationKeysIfPresent(Map<String, Object> trace, OverAllState s) {
// escalationEventsJSON 数组串 非空数组则原样塞List//非数组/ JSON 则省略键单键失败不连坐
// 先解析它因为是否真升档要据它 stage2判定决定 modelTier 是否落
JsonNode events = parseTrace(s.value(SaaStudioNodes.K_ESCALATION_EVENTS, String.class).orElse(""));
boolean hasEvents = events != null && events.isArray() && events.size() > 0;
if (hasEvents) {
trace.put("escalationEvents", TRACE_MAPPER.convertValue(events, java.util.List.class));
}
// modelTier终态模型档stage1|stage2真升档态才塞stage2 escalationEvents 非空
// 默认 stage1未升档render 初值噪声则省略P0-1无扩展键时键集与扩前一致字节兼容
String modelTier = s.value(SaaStudioNodes.K_MODEL_TIER, String.class).filter(v -> !v.isBlank()).orElse(null);
boolean escalated = "stage2".equals(modelTier) || hasEvents;
if (modelTier != null && escalated) {
trace.put("modelTier", modelTier);
}
// giveupDumpPath放弃 dump 落盘路径非空才塞未放弃则省略
String giveupPath = s.value(SaaStudioNodes.K_GIVEUP_DUMP_PATH, String.class).filter(v -> !v.isBlank()).orElse(null);
if (giveupPath != null) {
trace.put("giveupDumpPath", giveupPath);
}
}
/** 兜底失败回调图执行线程异常时调handleCallback 再异常也只留痕不抛,避免拖垮后台线程)。 */
private void safeFailedCallback(Map<String, Object> job, String traceId, String cause) {
try {

View File

@ -337,4 +337,127 @@ class SaaGraphDispatcherTraceTest {
Integer score = new ReadinessScorer().score(trace);
assertNotNull(score, "cost 省略不应让算分报错efficiency 中性 0.5");
}
// ============================== 用例8U3/B5 救场阶梯扩展键 additive + 字节兼容 ==============================
/**
* U3/B5 字节兼容succeededStub <b>未设</b> 救场阶梯键K_MODEL_TIER/K_ESCALATION_EVENTS/K_GIVEUP_DUMP_PATH
* 均无初值 抽出 trace <b>不含</b> modelTier/escalationEvents/giveupDumpPath/cacheHit 四键
* 守门未升档/未救场的存量回放trace_json 键集与扩前一致additive 不破存量
*/
@Test
void extract_noEscalationKeys_omitsExtensionKeys() throws Exception {
Map<String, Object> trace = invokeExtract(succeededStub(), 8000L);
assertFalse(trace.containsKey("modelTier"), "未设 K_MODEL_TIER → modelTier 省略(字节兼容)");
assertFalse(trace.containsKey("escalationEvents"), "未升档 → escalationEvents 省略(字节兼容)");
assertFalse(trace.containsKey("giveupDumpPath"), "未放弃 → giveupDumpPath 省略(字节兼容)");
// SAA 路无缓存命中数据源callAndRecord 只读 prompt/completion token cacheHit 整段省略不伪造
assertFalse(trace.containsKey("cacheHit"), "SAA 路无命中数据源 → cacheHit 省略(不伪造)");
}
/**
* P0-1 字节兼容核心守门render 节点默认落 {@code K_MODEL_TIER="stage1"}初值未升档<b>无救场事件</b>
* 抽出 trace <b>不含</b> modelTier
*
* <p>守门正常未升档路render 已落 stage1 初值 trace_json 键集必须与扩前一致modelTier=stage1
* 初值噪声落它会平白给每条正常 trace 多一个 modelTier 无扩展键时键集与扩前一致本用例坐实
* 默认 stage1 modelTier 省略 stage2 escalationEvents 非空才落 worker {@code _extract_trace} 同口径
*/
@Test
void extract_renderDefaultStage1_omitsModelTier() throws Exception {
// 模拟 render 节点真实初态K_MODEL_TIER="stage1"render out.put 落的初值无救场事件/无放弃
Map<String, Object> init = new HashMap<>();
init.put(SaaStudioNodes.K_STATUS, "succeeded");
init.put(SaaStudioNodes.K_REPAIR_COUNT, 0);
init.put(SaaStudioNodes.K_GAME_ID, "saa-render-default");
init.put(SaaStudioNodes.K_MODEL_TIER, "stage1"); // render 默认初值未升档
OverAllState s = newStubState(init);
Map<String, Object> trace = invokeExtract(s, 5000L);
assertNotNull(trace);
assertFalse(trace.containsKey("modelTier"),
"render 默认 stage1未升档→ modelTier 省略(字节兼容:键集与扩前一致)");
assertFalse(trace.containsKey("escalationEvents"), "无救场事件 → escalationEvents 省略");
assertFalse(trace.containsKey("giveupDumpPath"), "未放弃 → giveupDumpPath 省略");
}
/**
* P0-1 边界stage1 escalationEvents <b>非空</b>极端态事件已 append modelTier 尚未翻 stage2
* 仍判为真升档态modelTier=stage1 也落 escalationEvents 同读诚实反映轨迹
*/
@Test
@SuppressWarnings("unchecked")
void extract_stage1WithEvents_keepsModelTier() throws Exception {
Map<String, Object> init = new HashMap<>();
init.put(SaaStudioNodes.K_STATUS, "failed");
init.put(SaaStudioNodes.K_REPAIR_COUNT, 5);
init.put(SaaStudioNodes.K_MODEL_TIER, "stage1"); // 尚未翻档但已有救场事件
init.put(SaaStudioNodes.K_ESCALATION_EVENTS,
"[{\"tierBefore\":\"stage1\",\"tierAfter\":\"stage2\",\"atFailCount\":5,\"ts\":\"2026-06-17T00:00:00Z\"}]");
OverAllState s = newStubState(init);
Map<String, Object> trace = invokeExtract(s, 6000L);
assertEquals("stage1", trace.get("modelTier"), "escalationEvents 非空 → 判真升档态modelTier 照落");
List<Map<String, Object>> events = (List<Map<String, Object>>) trace.get("escalationEvents");
assertNotNull(events, "escalationEvents 非空应抽出");
assertEquals(1, events.size());
}
/**
* U3/B5 additive 抽取喂救场升档后的桩modelTier=stage2 + escalationEvents 数组串 + giveupDumpPath
* 抽出 trace modelTier=stage2escalationEventsList 含一条升档事件giveupDumpPath
* 守门救场升档/放弃轨迹随 trace 落库 worker _extract_trace 同口径camelCase
*/
@Test
@SuppressWarnings("unchecked")
void extract_withEscalationKeys_extractsAdditive() throws Exception {
Map<String, Object> init = new HashMap<>();
init.put(SaaStudioNodes.K_STATUS, "failed");
init.put(SaaStudioNodes.K_REPAIR_COUNT, 6);
init.put(SaaStudioNodes.K_GAME_ID, "saa-escalated");
// 救场升档真实形态escalate 节点已把 modelTier=stage2 append 一条事件JSON 数组串
init.put(SaaStudioNodes.K_MODEL_TIER, "stage2");
init.put(SaaStudioNodes.K_ESCALATION_EVENTS,
"[{\"tierBefore\":\"stage1\",\"tierAfter\":\"stage2\",\"atFailCount\":5,\"ts\":\"2026-06-17T00:00:00Z\"}]");
// giveup 节点产的 dump 落盘路径U5 真落盘此处验抽取
init.put(SaaStudioNodes.K_GIVEUP_DUMP_PATH, "games/_wg1-gen/saa-escalated/evidence/giveup-dump.json");
OverAllState s = newStubState(init);
Map<String, Object> trace = invokeExtract(s, 30000L);
assertNotNull(trace);
assertEquals("stage2", trace.get("modelTier"), "modelTier 应抽出升档后档位");
List<Map<String, Object>> events = (List<Map<String, Object>>) trace.get("escalationEvents");
assertNotNull(events, "escalationEvents 应抽出为 List");
assertEquals(1, events.size(), "应含一条升档事件");
assertEquals("stage1", events.get(0).get("tierBefore"));
assertEquals("stage2", events.get(0).get("tierAfter"));
assertEquals("games/_wg1-gen/saa-escalated/evidence/giveup-dump.json", trace.get("giveupDumpPath"));
}
/**
* U3/B5 边界escalationEvents 为脏 JSON / 空数组 该键省略 modelTier/giveupDumpPath 仍正常抽出单键失败不连坐
*/
@Test
void extract_escalationEventsDirtyOrEmpty_omitsOnlyThatKey() throws Exception {
// 空数组 "[]" escalationEvents 省略size==0 不塞 JSON 同样省略
Map<String, Object> init = new HashMap<>();
init.put(SaaStudioNodes.K_STATUS, "failed");
init.put(SaaStudioNodes.K_REPAIR_COUNT, 5);
init.put(SaaStudioNodes.K_MODEL_TIER, "stage2");
init.put(SaaStudioNodes.K_ESCALATION_EVENTS, "[]"); // 空数组 省略
init.put(SaaStudioNodes.K_GIVEUP_DUMP_PATH, "games/_wg1-gen/x/evidence/giveup-dump.json");
OverAllState s = newStubState(init);
Map<String, Object> trace = invokeExtract(s, 1000L);
assertEquals("stage2", trace.get("modelTier"), "空 escalationEvents 不连坐 modelTier");
assertFalse(trace.containsKey("escalationEvents"), "空数组 → escalationEvents 省略");
assertTrue(trace.containsKey("giveupDumpPath"), "空 escalationEvents 不连坐 giveupDumpPath");
// JSON escalationEvents 省略整体不抛
init.put(SaaStudioNodes.K_ESCALATION_EVENTS, "{不是数组");
OverAllState s2 = newStubState(init);
Map<String, Object> trace2 = invokeExtract(s2, 1000L);
assertNotNull(trace2, "脏 escalationEvents 不应让抽取崩");
assertFalse(trace2.containsKey("escalationEvents"), "脏 JSON → escalationEvents 省略");
assertEquals("stage2", trace2.get("modelTier"), "脏 escalationEvents 不连坐 modelTier");
}
}

View File

@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
"""tests/test_cost.py —— U4/B9 缓存命中落成本台账 + U3 trace 扩展键字节兼容自动化用例。
为什么 AST 隔离加载而非直接 import对齐 wg1_groupb_test.py 范式
- cost.py 顶部 `import _client` import openai+ `import httpx`6c6g python3 无这些依赖
- 本测试只验 `cache_hit_tokens` / `compute`纯算术零网络/零外部依赖 service._extract_trace dict 操作
故用 AST 隔离把这三个函数体单独 exec 出来绕开模块级 import 副作用6c6g mini-desktop 同款可跑
- compute 体内只用 dict.get / 算术cache_hit_tokens 体内只用 getattr/isinstance_extract_trace 体内纯 dict均零外部依赖
覆盖执行版 §5.8 / 计划 U4 test scenarios
U4- happy命中 token 存在两套字段之一 折扣计入 totalRmb低于不计缓存的账
U4- edge两套字段都缺 miss 全价计cached=0与扩前口径一致
U4- edge两套字段都在 max
U4- cache_ratio /NoneMiniMax-M2.7 视为 1.0不折扣全价不报错
U4- cached 截断到 [0, prompt_tokens] usage 命中超 prompt 不致负 quota
U4- AgentScope ChatUsage.cache_input_tokens 路命中可被 cache_hit_tokens 捕获
U3- _extract_trace cacheHit 落值promptCacheHitTokens + source
U3- 字节兼容result 无扩展键 trace 省略 modelTier/escalationEvents/giveupDumpPath/cacheHit与扩前键集一致
U3- result 有扩展键 trace 抽出additive 生效
U3- P0-1modelTier="stage1"render 默认无事件省略stage1+事件照落 Java 同口径
U3- P1-2/空形态空串 modelTier非数组/ list escalationEvents空白 giveupDumpPath只省略键不落脏值镜像 Java
P1-1cost.costFallback 标记随 trace 透传tokens-only 降级 totalRmb
P0-2config.RecordingChatModel._safe_int 对脏值/None/负数绝不抛usage 解析不中断生成
P2-1studio._classify_cache_source 据命中模型名归类独立于 pricing 是否可达
运行python3 wg1/gen-worker/tests/test_cost.py退出码 0=全过 mini-desktop pytest -q亦被收集为 test_*
"""
import ast
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] # wg1/gen-worker
_passed = 0
_failed = 0
def _check(name, cond, detail=""):
global _passed, _failed
if cond:
_passed += 1
print(f" [PASS] {name} {detail}")
else:
_failed += 1
print(f" [FAIL] {name} {detail}")
def _exec_funcdef(src, node, ns):
"""把一个 FunctionDef 节点单独 exec 进 ns去装饰器——静态方法的 @staticmethod 脱离类无意义且会报错)。"""
seg = ast.get_source_segment(src, node)
# 去掉前导装饰器行(如 @staticmethodFunctionDef.lineno 指向 def 行装饰器在其上方get_source_segment 含装饰器。
lines = seg.splitlines()
while lines and lines[0].lstrip().startswith("@"):
lines.pop(0)
# 顶格化类内方法有缩进exec 顶层不允许缩进)。
import textwrap
exec(textwrap.dedent("\n".join(lines)), ns)
def _load_funcs_from(src_path, names):
"""AST 隔离加载指定函数(绕开模块级 import 副作用。names 内函数体须零外部依赖。
支持两种 模块级顶层函数 类内方法递归进 ClassDef 去装饰器+顶格化后单独 exec
"""
src = src_path.read_text(encoding="utf-8")
tree = ast.parse(src)
ns = {}
found = {}
def _scan(body):
for node in body:
if isinstance(node, ast.FunctionDef) and node.name in names and node.name not in found:
_exec_funcdef(src, node, ns)
found[node.name] = ns[node.name]
elif isinstance(node, ast.ClassDef):
_scan(node.body) # 递归进类体找方法
_scan(tree.body)
missing = [n for n in names if n not in found]
if missing:
raise RuntimeError(f"{src_path.name} 未找到函数:{missing}")
return found
# 模拟 usage 对象openai 原始 usage 形态:属性式取值 + 嵌套 prompt_tokens_details
class _FakeDetails:
def __init__(self, cached_tokens=None):
if cached_tokens is not None:
self.cached_tokens = cached_tokens
class _FakeUsage:
"""可配 DeepSeek 顶层 prompt_cache_hit_tokens / MiniMax 嵌套 prompt_tokens_details.cached_tokens /
AgentScope cache_input_tokens 任意子集"""
def __init__(self, prompt_cache_hit_tokens=None, cached_tokens=None, cache_input_tokens=None):
if prompt_cache_hit_tokens is not None:
self.prompt_cache_hit_tokens = prompt_cache_hit_tokens
if cached_tokens is not None:
self.prompt_tokens_details = _FakeDetails(cached_tokens)
if cache_input_tokens is not None:
self.cache_input_tokens = cache_input_tokens
# 固定 pricing/口径(与生产 /api/pricing 真值同形deepseek 低 cache_ratio、M2.7 cache_ratio=None
PRICING = {
"deepseek-v4-flash": {"model_ratio": 0.06, "completion_ratio": 1.75, "cache_ratio": 0.025},
"MiniMax-M3": {"model_ratio": 0.3, "completion_ratio": 4, "cache_ratio": 0.2},
"MiniMax-M2.7": {"model_ratio": 0.15, "completion_ratio": 4, "cache_ratio": None},
}
QPU = 500000
USD = 7.3
def test_cache_hit_tokens(cache_hit_tokens):
print("\n=== U4 cache_hit_tokens两套字段兼容取 max ===")
# ② 两套字段都缺 → 0。
_check("两套字段都缺→0", cache_hit_tokens(_FakeUsage()) == 0)
_check("None usage→0", cache_hit_tokens(None) == 0)
# 单字段DeepSeek 顶层。
_check("仅 DeepSeek prompt_cache_hit_tokens",
cache_hit_tokens(_FakeUsage(prompt_cache_hit_tokens=120)) == 120)
# 单字段MiniMax 嵌套。
_check("仅 MiniMax cached_tokens",
cache_hit_tokens(_FakeUsage(cached_tokens=88)) == 88)
# ⑥ AgentScope ChatUsage.cache_input_tokens。
_check("仅 AgentScope cache_input_tokens",
cache_hit_tokens(_FakeUsage(cache_input_tokens=77)) == 77)
# ③ 两套都在 → 取 max。
_check("两套都在→取 max",
cache_hit_tokens(_FakeUsage(prompt_cache_hit_tokens=50, cached_tokens=200)) == 200)
_check("三路都在→取 max",
cache_hit_tokens(_FakeUsage(prompt_cache_hit_tokens=50, cached_tokens=200, cache_input_tokens=300)) == 300)
# dict 形态_client.chat 返回值或已序列化 usage兼容。
_check("dict 形态 prompt_cache_hit_tokens",
cache_hit_tokens({"prompt_cache_hit_tokens": 33}) == 33)
_check("dict 嵌套 details",
cache_hit_tokens({"prompt_tokens_details": {"cached_tokens": 44}}) == 44)
def test_compute_cache_discount(compute):
print("\n=== U4 computecache_ratio 折扣口径 ===")
P, C = 10000, 2000 # prompt / completion
# 全价cached=0旧口径
full = compute("deepseek-v4-flash", P, C, PRICING, QPU, USD)
# ① 命中 8000 token → 折扣deepseek cache_ratio=0.025,命中段近乎免单)→ rmb < 全价。
hit = compute("deepseek-v4-flash", P, C, PRICING, QPU, USD, cached_tokens=8000)
_check("命中折扣 < 全价deepseek 0.025", hit["rmb"] < full["rmb"],
f"hit={hit['rmb']} full={full['rmb']}")
# 手算校验quota = ((P-8000)+8000*0.025 + C*1.75)*0.06。
expect_quota = ((P - 8000) + 8000 * 0.025 + C * 1.75) * 0.06
_check("命中折扣 quota 手算吻合", abs(hit["quota"] - round(expect_quota, 2)) < 0.01,
f"got={hit['quota']} expect={round(expect_quota, 2)}")
_check("compute 落 cached_tokens 字段", hit["cached_tokens"] == 8000)
_check("compute 落 cache_ratio 字段", hit["cache_ratio"] == 0.025)
# ② cached=0 退化为旧口径quota == (P + C*completion_ratio)*model_ratio与扩前字节兼容
legacy_quota = (P + C * 1.75) * 0.06
_check("cached=0 退化旧口径(字节兼容)", abs(full["quota"] - round(legacy_quota, 2)) < 0.01,
f"got={full['quota']} expect={round(legacy_quota, 2)}")
# ④ cache_ratio=NoneM2.7)→ 视为 1.0命中不折扣quota 与全价相等)。
m27_full = compute("MiniMax-M2.7", P, C, PRICING, QPU, USD)
m27_hit = compute("MiniMax-M2.7", P, C, PRICING, QPU, USD, cached_tokens=5000)
_check("cache_ratio=None→不折扣命中=全价)", abs(m27_hit["quota"] - m27_full["quota"]) < 0.01,
f"hit={m27_hit['quota']} full={m27_full['quota']}")
_check("cache_ratio=None 回退 1.0", m27_hit["cache_ratio"] == 1.0)
# ⑤ cached 截断到 [0, prompt_tokens]:命中超 prompt → 截到 promptquota 不为负。
over = compute("deepseek-v4-flash", 100, 50, PRICING, QPU, USD, cached_tokens=99999)
_check("命中超 prompt→截断到 prompt", over["cached_tokens"] == 100)
_check("截断后 quota ≥ 0", over["quota"] >= 0, f"quota={over['quota']}")
# 负命中 → 截到 0。
neg = compute("deepseek-v4-flash", 100, 50, PRICING, QPU, USD, cached_tokens=-5)
_check("负命中→截到 0", neg["cached_tokens"] == 0)
# MiniMax-M3 命中折扣cache_ratio=0.2)。
m3_full = compute("MiniMax-M3", P, C, PRICING, QPU, USD)
m3_hit = compute("MiniMax-M3", P, C, PRICING, QPU, USD, cached_tokens=6000)
_check("M3 命中折扣 < 全价0.2", m3_hit["rmb"] < m3_full["rmb"],
f"hit={m3_hit['rmb']} full={m3_full['rmb']}")
def test_extract_trace_compat(extract):
print("\n=== U3 _extract_tracecacheHit 落值 + 扩展键字节兼容 ===")
# 最小 run_studio 形态 result必填子集 + cache_hit
base = {
"game_id": "saa-x", "stage": "stage1", "pass": True, "repairs": 0,
"wall_s": 12.3, "models": {"code": "deepseek-v4-flash"}, "attempts": [{"attempt": 0, "role": "code"}],
"tokens": {"prompt": 10000, "completion": 2000},
"cache_hit": {"promptCacheHitTokens": 8000, "source": "deepseek"},
}
t = extract(base)
# ⑦ cacheHit 落值。
_check("cacheHit 落值 promptCacheHitTokens",
t.get("cacheHit", {}).get("promptCacheHitTokens") == 8000, f"cacheHit={t.get('cacheHit')}")
_check("cacheHit 落 source", t.get("cacheHit", {}).get("source") == "deepseek")
# ⑧ 字节兼容:无扩展键 result → trace 不含 modelTier/escalationEvents/giveupDumpPath且无 cacheHit。
no_ext = dict(base)
no_ext.pop("cache_hit")
t2 = extract(no_ext)
for k in ("modelTier", "escalationEvents", "giveupDumpPath", "cacheHit"):
_check(f"无扩展键→省略 {k}", k not in t2, f"keys={sorted(t2.keys())}")
# ⑨ 有扩展键 result → 抽出additive
with_ext = dict(base)
with_ext["modelTier"] = "stage2"
with_ext["escalationEvents"] = [{"tierBefore": "stage1", "tierAfter": "stage2", "atFailCount": 5, "ts": "2026-06-17T00:00:00Z"}]
with_ext["giveupDumpPath"] = "games/_wg1-gen/saa-x/evidence/giveup-dump.json"
t3 = extract(with_ext)
_check("有扩展键→modelTier=stage2", t3.get("modelTier") == "stage2")
_check("有扩展键→escalationEvents 抽出", isinstance(t3.get("escalationEvents"), list) and len(t3["escalationEvents"]) == 1)
_check("有扩展键→giveupDumpPath 抽出", t3.get("giveupDumpPath", "").endswith("giveup-dump.json"))
# ⑩ P0-1 字节兼容modelTier="stage1"render 默认初值)且无 escalationEvents → modelTier 省略(与 Java 同口径)。
s1 = dict(base)
s1["modelTier"] = "stage1"
t5 = extract(s1)
_check("P0-1 默认 stage1 无事件→modelTier 省略", "modelTier" not in t5, f"keys={sorted(t5.keys())}")
# ⑪ P0-1 边界stage1 但 escalationEvents 非空 → 判真升档态modelTier=stage1 照落(与 Java 同口径)。
s1e = dict(base)
s1e["modelTier"] = "stage1"
s1e["escalationEvents"] = [{"tierBefore": "stage1", "tierAfter": "stage2", "atFailCount": 5, "ts": "x"}]
t6 = extract(s1e)
_check("P0-1 stage1+事件→modelTier 照落", t6.get("modelTier") == "stage1")
_check("P0-1 stage1+事件→escalationEvents 抽出", isinstance(t6.get("escalationEvents"), list))
# ⑫ P1-2 形态校验(镜像 Java 严格口径):脏/空形态只省略键、不落脏值。
dirty = dict(base)
dirty["modelTier"] = "" # 空串 → 省略(须非空串)
dirty["escalationEvents"] = "not-a-list" # 非 list → 省略(须非空 list
dirty["giveupDumpPath"] = " " # 纯空白 → 省略(须非空串)
t7 = extract(dirty)
_check("P1-2 空串 modelTier→省略", "modelTier" not in t7)
_check("P1-2 非数组 escalationEvents→省略", "escalationEvents" not in t7)
_check("P1-2 空白 giveupDumpPath→省略", "giveupDumpPath" not in t7)
# P1-2空 list escalationEvents → 省略(与 Java size>0 同口径stage2 仍照落modelTier 不连坐)。
empty_ev = dict(base)
empty_ev["modelTier"] = "stage2"
empty_ev["escalationEvents"] = []
t8 = extract(empty_ev)
_check("P1-2 空 list escalationEvents→省略", "escalationEvents" not in t8)
_check("P1-2 空 list 不连坐 modelTier(stage2)", t8.get("modelTier") == "stage2")
# P1-1costFallback 标记随 trace.cost 透传tokens-only 降级口径)。
fb = dict(base)
fb["cost"] = {"error": "pricing unreachable", "costFallback": True,
"by_model": {"deepseek-v4-flash": {"in": 100, "out": 50, "cached": 0}}}
t9 = extract(fb)
_check("P1-1 costFallback 透传", t9.get("cost", {}).get("costFallback") is True, f"cost={t9.get('cost')}")
_check("P1-1 降级 cost 无 totalRmb", "totalRmb" not in t9.get("cost", {}))
# snake_case 兜底(万一上游写 snake 键)。
snake = dict(base)
snake.pop("cache_hit")
snake["cache_hit"] = {"prompt_cache_hit_tokens": 6000, "source": "minimax"}
t4 = extract(snake)
_check("cacheHit snake 内键兜底", t4.get("cacheHit", {}).get("promptCacheHitTokens") == 6000)
# 空/None 兜底(与原有口径一致)。
_check("空 result→None", extract(None) is None and extract({}) is None)
def test_safe_int_no_throw(safe_int):
"""P0-2RecordingChatModel._safe_int 对脏值/None/负数绝不抛——usage 解析不得中断生成主链。"""
print("\n=== P0-2 _safe_int脏 usage 不抛(异常按 0 ===")
_check("合法整数→原值", safe_int(123) == 123)
_check("合法字符串数→int", safe_int("456") == 456)
_check("None→0", safe_int(None) == 0)
_check("负数→0不致负 quota", safe_int(-5) == 0)
_check("脏字符串→0不抛", safe_int("abc") == 0)
_check("空串→0不抛", safe_int("") == 0)
_check("dict 脏值→0不抛 TypeError", safe_int({"x": 1}) == 0)
_check("list 脏值→0不抛", safe_int([1, 2]) == 0)
_check("float→截 int", safe_int(3.9) == 3)
def test_classify_cache_source(classify):
"""P2-1_classify_cache_source 据命中模型名归类,独立于 pricing 是否可达。"""
print("\n=== P2-1 _classify_cache_sourcesource 独立于 pricing ===")
# 无命中cached=0→ none。
_check("无命中→none", classify({"deepseek-v4-flash": [100, 50, 0]}) == "none")
# deepseek 命中 → deepseek。
_check("deepseek 命中→deepseek", classify({"deepseek-v4-flash": [100, 50, 30]}) == "deepseek")
# minimax 命中 → minimax含 minimax / m3 / m2 前缀)。
_check("MiniMax-M3 命中→minimax", classify({"MiniMax-M3": [100, 50, 30]}) == "minimax")
_check("M2.7 前缀命中→minimax", classify({"m2.7-xyz": [100, 50, 30]}) == "minimax")
# 旧 2 元素容错(无 cached→ none不抛。
_check("旧 2 元素容错→none", classify({"deepseek-v4-flash": [100, 50]}) == "none")
# 空 per_model → none。
_check("空 per_model→none", classify({}) == "none" and classify(None) == "none")
def main():
print("U4/B9 缓存命中落成本台账 + U3 trace 扩展键 自动化用例")
funcs = _load_funcs_from(ROOT / "worker" / "cost.py", ["cache_hit_tokens", "compute"])
extract = _load_funcs_from(ROOT / "worker" / "service.py", ["_extract_trace"])["_extract_trace"]
# P0-2从 config.py 类内静态方法 AST 提取 _safe_int零外部依赖仅 builtins
safe_int = _load_funcs_from(ROOT / "worker" / "agent_loop" / "config.py", ["_safe_int"])["_safe_int"]
# P2-1从 studio.py 顶层提取 _classify_cache_source零外部依赖纯 dict/str 操作)。
classify = _load_funcs_from(ROOT / "worker" / "agent_loop" / "studio.py", ["_classify_cache_source"])["_classify_cache_source"]
test_cache_hit_tokens(funcs["cache_hit_tokens"])
test_compute_cache_discount(funcs["compute"])
test_extract_trace_compat(extract)
test_safe_int_no_throw(safe_int)
test_classify_cache_source(classify)
print(f"\n=== 汇总:通过 {_passed} / 失败 {_failed} ===")
sys.exit(0 if _failed == 0 else 1)
if __name__ == "__main__":
main()

View File

@ -57,11 +57,35 @@ def get_client():
return openai.OpenAI(api_key=get_api_key(), base_url=BASE_URL, max_retries=2, timeout=180.0)
def _cache_hit_from_usage(usage):
"""从 openai 原始 usage 抽命中缓存的 prompt token兼容两套字段取 maxU4/B9
DeepSeek `usage.prompt_cache_hit_tokens` / MiniMax `usage.prompt_tokens_details.cached_tokens` max
字段全缺/异常 0 miss 不报错逻辑与 cost.cache_hit_tokens 同口径但本处只依赖 openai 原始对象
不反向 import cost避免 _clientcost 循环依赖cost import _client
"""
if usage is None:
return None
try:
cands = []
ds = getattr(usage, "prompt_cache_hit_tokens", None)
if ds is not None:
cands.append(int(ds))
details = getattr(usage, "prompt_tokens_details", None)
cached = getattr(details, "cached_tokens", None) if details is not None else None
if cached is not None:
cands.append(int(cached))
return max(cands) if cands else 0
except Exception:
return 0
def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0):
"""单次裸调用 chat.completions。确定性产出 temperature=0瞬时网关错误(502 burst)重试。
返回 dict{content, prompt_tokens, completion_tokens, total_tokens, model, wall_s, id, finish_reason}
usage 字段用 openai 2.41.1 口径prompt_tokens / completion_tokens供成本折算与 token 取证
返回 dict{content, prompt_tokens, completion_tokens, prompt_cache_hit_tokens, total_tokens, model, wall_s, id, finish_reason}
usage 字段用 openai 2.41.1 口径prompt_tokens / completion_tokens供成本折算与 token 取证
prompt_cache_hit_tokensU4/B9 新增additive = 命中缓存的 prompt token两套字段取 max缺则 0 cost.py 折缓存价
"""
client = get_client()
last_err = None
@ -86,6 +110,8 @@ def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_
"finish_reason": getattr(choice, "finish_reason", None),
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
# U4/B9命中缓存的 prompt tokenadditive 键;旧 dict 消费方不受影响,新口径供 cost 折缓存价)。
"prompt_cache_hit_tokens": _cache_hit_from_usage(usage),
"total_tokens": getattr(usage, "total_tokens", None),
"model": model,
"wall_s": round(wall, 3),

View File

@ -86,16 +86,17 @@ def run_calibration(labels_path, stage):
brief = _load_brief(s["brief_ref"])
verdict = _load_verdict(gid)
ut = _judge_user_text(brief, verdict)
# 视觉位(吃截图)
# 视觉位吃截图。U4/B9 后 _judge_* 返回 (in,out,cached) 三元组——校准只关心 in/outcached 占位忽略
# P2-2校准成本有意取「全价上界」不折缓存——校准是离线评估账走保守上界即可不必对齐生产逐款精账
try:
vj, (vi, vo) = studio._judge_vision(vis_model, PERSONA_VISION, ut, gid)
vj, (vi, vo, _vca) = studio._judge_vision(vis_model, PERSONA_VISION, ut, gid)
per_model[vis_model][0] += vi
per_model[vis_model][1] += vo
except Exception as e:
vj = {"error": str(e)[:180]}
# 文本位(仅运行数据)
try:
tj, (ti, to) = studio._judge_text(txt_model, PERSONA_TEXT, ut)
tj, (ti, to, _tca) = studio._judge_text(txt_model, PERSONA_TEXT, ut)
per_model[txt_model][0] += ti
per_model[txt_model][1] += to
except Exception as e:
@ -146,15 +147,17 @@ def run_calibration(labels_path, stage):
}
# ── 成本(测而不闸;复用 cost.py 的 new-api quota 口径)──
# P2-2此处 compute 不传 cached_tokens默认 0→ 成本是「全价上界」(不折缓存命中),与生产 run_studio 的
# 精账(折 cache_ratio有意不同——校准为离线评估账走保守上界即可。
try:
pricing = cost.fetch_pricing()
qpu, usd = cost.fetch_status()
rws, total = {}, 0.0
for name, (i, o) in per_model.items():
c = cost.compute(name, i, o, pricing, qpu, usd)
c = cost.compute(name, i, o, pricing, qpu, usd) # 不传 cached → 全价上界P2-2
rws[name] = {"in": i, "out": o, "rmb": c["rmb"]}
total += c["rmb"]
cost_info = {"by_model": rws, "total_rmb": round(total, 5)}
cost_info = {"by_model": rws, "total_rmb": round(total, 5), "costNote": "fullPriceUpperBound"}
except Exception as e:
cost_info = {"error": str(e)[:180],
"by_model": {k: {"in": v[0], "out": v[1]} for k, v in per_model.items()}}

View File

@ -24,21 +24,49 @@ _CFG_PATH = Path(__file__).resolve().parents[1] / "models.yaml"
class RecordingChatModel(OpenAIChatModel):
"""记录每次调用 usage 的模型子类per-agent token 取证,供成本/Stage 对比)。"""
"""记录每次调用 usage 的模型子类per-agent token 取证,供成本/Stage 对比)。
U4/B9records 元素由 (in, out) 扩为 (in, out, cached) 3 = 命中缓存的 prompt token
AgentScope 2.0.1 ChatUsage 已从 `prompt_tokens_details.cached_tokens` 提取 `cache_input_tokens`MiniMax 路命中由此兜
DeepSeek `prompt_cache_hit_tokens` AgentScope ChatUsage 时被丢弃AgentScope 只读 cached_tokens
故本处兜底再尝试 getattr(u, "prompt_cache_hit_tokens")两者取 max cost.cache_hit_tokens 同口径
向后兼容旧消费方usage_sum / studio._agent_reply只读 r[0]/r[1]3-tuple 不破"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.records = [] # [(input_tokens, output_tokens)]
self.records = [] # [(input_tokens, output_tokens, cached_tokens)]
@staticmethod
def _safe_int(v):
"""脏值/None/负数 → 0合法数 → int绝不抛P0-2usage 解析不得中断生成主链)。"""
try:
n = int(v)
return n if n >= 0 else 0
except (TypeError, ValueError):
return 0
async def __call__(self, messages, **kwargs):
resp = await super().__call__(messages, **kwargs)
u = resp.get("usage") if hasattr(resp, "get") else getattr(resp, "usage", None)
if u is not None:
self.records.append((int(getattr(u, "input_tokens", 0) or 0),
int(getattr(u, "output_tokens", 0) or 0)))
# 命中 token取 ChatUsage.cache_input_tokensAgentScope 已从 cached_tokens 提取)与
# DeepSeek 顶层 prompt_cache_hit_tokens若 AgentScope 未透传则为 None的 max全缺/脏→0miss 全价)。
# P0-2逐字段 _safe_int脏值/None/负数→0绝不抛——异常按 miss(0),与 cost.cache_hit_tokens 同纪律。
cands = []
ci = getattr(u, "cache_input_tokens", None)
if ci is not None:
cands.append(self._safe_int(ci))
ds = getattr(u, "prompt_cache_hit_tokens", None)
if ds is not None:
cands.append(self._safe_int(ds))
cached = max(cands) if cands else 0
self.records.append((self._safe_int(getattr(u, "input_tokens", 0)),
self._safe_int(getattr(u, "output_tokens", 0)),
cached))
return resp
def usage_sum(self):
# 仅汇总 in/out向后兼容命中 token 由 studio._agent_reply 经 records[2] 单独汇总。
return (sum(r[0] for r in self.records), sum(r[1] for r in self.records))

View File

@ -125,14 +125,41 @@ def _parse_judge(raw):
# ── AgentScope agent 单轮调用(生成域:design/code/fix)─────────────────
def _rec_cached(r):
"""从一条 RecordingChatModel.records 取命中 token3-tuple 第 3 位);旧 2-tuple 容错返 0。"""
return r[2] if len(r) >= 3 else 0
def _classify_cache_source(per_model):
"""据 per_model{model_name: [in,out,cached]}里「有命中cached>0」的模型名归类缓存命中来源。
返回 deepseek|minimax|noneP2-1独立于 pricing 是否可达只看命中模型名不绑 cost.compute 成功路径
避免promptCacheHitTokens>0 source=none自相矛盾多模型同时命中时后者覆盖 trace.cacheHit.source
仅展示用非精确账无任何命中 none
"""
source = "none"
for name, toks in (per_model or {}).items():
# toks = [in, out, cached];旧 2 元素容错(无 cached 视为 0
cached = toks[2] if len(toks) >= 3 else 0
if cached and cached > 0:
lname = (name or "").lower()
if "deepseek" in lname:
source = "deepseek"
elif "minimax" in lname or lname.startswith("m3") or lname.startswith("m2"):
source = "minimax"
return source
async def _agent_reply(name, system, model, user_text):
"""新建一次性 Agent(空 toolkit 单轮)→ reply。返回 (文本, (in_tok,out_tok))。"""
"""新建一次性 Agent(空 toolkit 单轮)→ reply。返回 (文本, (in_tok,out_tok,cached_tok))。
U4/B9 3 cached = 本轮命中缓存的 prompt token汇总自 model.records[2]RecordingChatModel 已捕获"""
before = len(getattr(model, "records", []) or [])
agent = Agent(name=name, system_prompt=system, model=model,
react_config=ReActConfig(max_iters=1))
resp = await agent.reply(user_msg(user_text))
recs = (getattr(model, "records", []) or [])[before:]
return text_of(resp), (sum(r[0] for r in recs), sum(r[1] for r in recs))
return text_of(resp), (sum(r[0] for r in recs), sum(r[1] for r in recs), sum(_rec_cached(r) for r in recs))
# ── player panel(裸客户端:M3 视觉位 + flash 文本位)──────────────────
@ -147,7 +174,9 @@ def _play_summary(verdict):
def _judge_vision(model_name, persona, user_text, game_id):
"""M3 多模态:文本 + 首帧/玩后截图 → 评判。返回 (判定dict, (in,out))。"""
"""M3 多模态:文本 + 首帧/玩后截图 → 评判。返回 (判定dict, (in,out,cached))。
U4/B9 3 cached = 命中缓存的 prompt tokencost.cache_hit_tokens 兼容两套字段取 max"""
client = _client.get_client()
content = [{"type": "text", "text": user_text}]
ev = GEN_DIR / game_id / "evidence"
@ -160,13 +189,16 @@ def _judge_vision(model_name, persona, user_text, game_id):
{"role": "user", "content": content}]
r = client.chat.completions.create(model=model_name, messages=msgs, max_tokens=900, temperature=0.3, stream=False)
u = r.usage
return _parse_judge(r.choices[0].message.content or ""), (u.prompt_tokens, u.completion_tokens)
return _parse_judge(r.choices[0].message.content or ""), (u.prompt_tokens, u.completion_tokens, cost.cache_hit_tokens(u))
def _judge_text(model_name, persona, user_text):
"""文本位:仅运行数据 → 评判(_client.chat;reasoning 档给足 max_tokens)。"""
"""文本位:仅运行数据 → 评判(_client.chat;reasoning 档给足 max_tokens)。返回 (判定dict, (in,out,cached))。
U4/B9cached _client.chat 返回的 prompt_cache_hit_tokens已两套字段取 max"""
r = _client.chat(model_name, roles.player_system(persona), user_text, max_tokens=4000, temperature=0.3)
return _parse_judge(r["content"]), (r.get("prompt_tokens") or 0, r.get("completion_tokens") or 0)
return _parse_judge(r["content"]), (
r.get("prompt_tokens") or 0, r.get("completion_tokens") or 0, r.get("prompt_cache_hit_tokens") or 0)
def run_player_panel(game_id, brief, verdict, stage):
@ -177,15 +209,15 @@ def run_player_panel(game_id, brief, verdict, stage):
vis_model = config.model_name_for("player_vision", stage)
txt_model = config.model_name_for("player_text", stage)
panel, problems = {}, []
tbm = defaultdict(lambda: [0, 0]) # model_name -> [in, out]
tbm = defaultdict(lambda: [0, 0, 0]) # model_name -> [in, out, cached]U4/B9第 3 位命中 token
for tag, fn, mname in (
("vision", lambda: _judge_vision(vis_model, "急性子休闲玩家,凭第一眼观感和反馈下判断", base, game_id),
vis_model),
("text", lambda: _judge_text(txt_model, "细致的完整度审查者,只信运行数据、不脑补", base), txt_model),
):
try:
verdict_obj, (i, o) = fn()
tbm[mname][0] += i; tbm[mname][1] += o
verdict_obj, (i, o, ca) = fn()
tbm[mname][0] += i; tbm[mname][1] += o; tbm[mname][2] += ca
panel[tag] = {"model": mname, **verdict_obj}
if str(verdict_obj.get("verdict")) == "fix":
problems += [str(p) for p in (verdict_obj.get("problems") or [])]
@ -209,11 +241,13 @@ async def run_studio(game_id, brief, play_spec, stage="stage1", max_repairs=5,
attempts = []
tin, tout = 0, 0
per_model = defaultdict(lambda: [0, 0]) # model_name -> [in,out],per-model 成本取证
tcached = 0 # U4/B9全程命中缓存的 prompt token 累计(落 trace.cacheHit.promptCacheHitTokens
per_model = defaultdict(lambda: [0, 0, 0]) # model_name -> [in,out,cached]per-model 成本取证(含命中 token
# ① 设计
design_text, (di, do) = await _agent_reply("design", roles.DESIGN_SYSTEM, design_model, brief)
tin += di; tout += do; per_model[design_name][0] += di; per_model[design_name][1] += do
design_text, (di, do, dca) = await _agent_reply("design", roles.DESIGN_SYSTEM, design_model, brief)
tin += di; tout += do; tcached += dca
per_model[design_name][0] += di; per_model[design_name][1] += do; per_model[design_name][2] += dca
enriched = brief + "\n\n## 设计稿(设计 agent 产出)\n" + design_text
# gate-H 推广:设计 agent 自产 gate-spec → 合入 play_spec覆盖 brief 默认),使确定性门适用任意游戏(不再手写 brief
gatespec = _extract_gatespec(design_text)
@ -235,8 +269,9 @@ async def run_studio(game_id, brief, play_spec, stage="stage1", max_repairs=5,
mname = code_name if role == "code" else fix_name
system, user = prompt.build_messages(enriched, retry_feedback=feedback)
rec = {"attempt": attempt, "role": role, "model": mname, "stage_fail": None}
text, (ci, co) = await _agent_reply(role, system, model, user)
tin += ci; tout += co; per_model[mname][0] += ci; per_model[mname][1] += co
text, (ci, co, cca) = await _agent_reply(role, system, model, user)
tin += ci; tout += co; tcached += cca
per_model[mname][0] += ci; per_model[mname][1] += co; per_model[mname][2] += cca
rec["usage"] = {"in": ci, "out": co}
src = validate.extract_code(text)
@ -268,8 +303,12 @@ async def run_studio(game_id, brief, play_spec, stage="stage1", max_repairs=5,
# 七门过 → player panel(顾问;七门才是硬地板)
passed = True
player_result = run_player_panel(game_id, brief, verdict, stage)
for mn, (pi, po) in player_result["tokens_by_model"].items():
tin += pi; tout += po; per_model[mn][0] += pi; per_model[mn][1] += po
for mn, toks in player_result["tokens_by_model"].items():
# toks = [in, out, cached]U4/B9 起含命中 token旧 2 元素容错补 0
pi, po = toks[0], toks[1]
pca = toks[2] if len(toks) >= 3 else 0
tin += pi; tout += po; tcached += pca
per_model[mn][0] += pi; per_model[mn][1] += po; per_model[mn][2] += pca
rec["player"] = player_result["panel"]
attempts.append(rec)
probs = player_result["problems"]
@ -281,19 +320,28 @@ async def run_studio(game_id, brief, play_spec, stage="stage1", max_repairs=5,
break
# per-model ¥(测而不闸;复用 cost.py 的 new-api quota 口径,精确按各模型拆账)
# U4/B9传 per_model 第 3 位 cached → cost.compute 按 cache_ratio 折真实 quota命中越多越省;
# /api/pricing 不可达 → except 走「tokens-only 降级」(只 by_model token + costFallback=true 标记,无 total_rmbP1-1
# + 不阻断生成best-effort
cost_info = None
# P2-1命中来源归类「独立于 pricing 是否可达」——据 per_model 命中cached>0的模型名定 source
# 不绑 cost.compute 成功路径。否则 pricing 失败 + tcached>0 时会出现 promptCacheHitTokens>0 而 source="none" 的自相矛盾。
cache_source = _classify_cache_source(per_model)
try:
pricing = cost.fetch_pricing()
qpu, usd = cost.fetch_status()
rows, total = {}, 0.0
for name, (i, o) in per_model.items():
c = cost.compute(name, i, o, pricing, qpu, usd)
rows[name] = {"in": i, "out": o, "rmb": c["rmb"]}
for name, (i, o, ca) in per_model.items():
c = cost.compute(name, i, o, pricing, qpu, usd, cached_tokens=ca)
rows[name] = {"in": i, "out": o, "cached": ca, "rmb": c["rmb"]}
total += c["rmb"]
cost_info = {"by_model": rows, "total_rmb": round(total, 5), "gate_rmb": cost.GATE_RMB}
except Exception as e:
cost_info = {"error": str(e)[:200],
"by_model": {k: {"in": v[0], "out": v[1]} for k, v in per_model.items()}}
# P1-1 取价失败降级 = 诚实的「tokens-only 降级」:只剩 token含 cached+ error + costFallback=true 标记;
# 不折¥(无 pricing 无从算 total_rmb、不阻断§5.8 error 路best-effort。costFallback 标记让
# _extract_trace / 审核台明确「本条 cost 是降级口径、无 total_rmb」避免误读成「成本为 0」。
cost_info = {"error": str(e)[:200], "costFallback": True,
"by_model": {k: {"in": v[0], "out": v[1], "cached": v[2]} for k, v in per_model.items()}}
# ── D9 反同质化W-G1 组B归一 brief/theme → 算签名 → 查 FS 历史登记表 → similarity 子段(随 trace 落库)──
# 总开关 AIGC_DEDUP_ENABLEDenv默认关关 = 不查重不告警不写 similarity§10 回滚,现行行为)。
@ -320,6 +368,9 @@ async def run_studio(game_id, brief, play_spec, stage="stage1", max_repairs=5,
"tokens": {"prompt": tin, "completion": tout},
"tokens_by_model": {k: v for k, v in per_model.items()},
"cost": cost_info,
# U4/B9缓存命中台账成本台账仅观测不阻断——命中 token 全程累计 + 来源归类;
# _extract_trace 据此落 trace.cacheHit{promptCacheHitTokens, source}。
"cache_hit": {"promptCacheHitTokens": tcached, "source": cache_source},
"wall_s": round(time.perf_counter() - t0, 1),
"models": {r: config.model_name_for(r, stage) for r in
("design", "code", "fix", "player_vision", "player_text")},

View File

@ -2,13 +2,17 @@
spike usage¥ 代码worker-reusables 地图明示 L1 新建
取证口径
- 网关 /api/pricing 给每模型 model_ratio / completion_ratio/api/status quota_per_unit=500000usd_exchange_rate=7.3
- new-api quota 公式quota = (prompt_tokens + completion_tokens × completion_ratio) × model_ratio × group_ratio
- 网关 /api/pricing 给每模型 model_ratio / completion_ratio / cache_ratio/api/status quota_per_unit=500000usd_exchange_rate=7.3
- new-api quota 公式含缓存折扣U4/B9
quota = ((prompt_tokens cached) + cached × cache_ratio + completion_tokens × completion_ratio) × model_ratio × group_ratio
其中 cached = 命中缓存的 prompt token cache_ratio 折扣非全价cache_ratio /None 视为 1.0不折扣回退全价
- USD = quota / quota_per_unit¥ = USD × usd_exchange_rate
口径说明
- group_ratio 取默认组 1.0/api/status 未暴露分组倍率default 组常为 1.0若非 1 须按实际乘
- 本折算不计 prompt 缓存折扣(cache_ratio) worker 仅记 prompt_tokens/completion_tokens 不记 cache 命中数
¥上界(实际 logs.quota 此值)精确 logs.quota 需网关 access token /api/log/selfsk- key 无管理权
- U4/B9 缓存折扣已计入(2026-06-17)worker 现于 usage 提取处捕获命中 token兼容两套字段取 max
`cache_hit_tokens()``compute()` cache_ratio 折真实 quota 折后 ¥命中越多越省cached=0未命中/字段缺
时退化为旧¥上界口径与扩前字节兼容精确 logs.quota 仍需网关 access token /api/log/selfsk- key 无管理权
本折算据 usage + pricing 自算与网关结算同公式缓存折扣口径一致
- reasoning 模型(deepseek-v4-flash ) reasoning_tokens 已计入 completion_tokens本折算已含其成本
"""
@ -51,17 +55,77 @@ def fetch_status():
return d.get("quota_per_unit", 500000), d.get("usd_exchange_rate", 7.3)
def compute(model, prompt_tokens, completion_tokens, pricing, qpu, usd_rate, group_ratio=1.0):
"""单条 usage → quota/USD/¥(上界,不计缓存折扣)。"""
def cache_hit_tokens(usage):
"""从一条 usage 抽「命中缓存的 prompt token 数」,兼容两套字段取 maxU4/B9执行版 §5.8)。
- DeepSeek 口径`usage.prompt_cache_hit_tokens`顶层
- MiniMax 口径`usage.prompt_tokens_details.cached_tokens`嵌套
两套同时存在则取 max不同厂商命名不同取大者保守计折扣与网关结算口径对齐
:param usage: 一条调用的 usage可为
openai 原始 usage 对象resp.usage含上述属性 / prompt_tokens_details 子对象
dict _client.chat 返回值或已序列化的 usage
AgentScope ChatUsage cache_input_tokens 字段已由 AgentScope cached_tokens 提取
:return: 命中 token int 0字段全缺/解析异常 0 miss 全价计不报错best-effort 不阻断
"""
if usage is None:
return 0
try:
def _get(obj, name):
# 兼容对象属性 / dict 键两种取值;缺则 None。
if obj is None:
return None
if isinstance(obj, dict):
return obj.get(name)
return getattr(obj, name, None)
candidates = []
# ① DeepSeek 顶层 prompt_cache_hit_tokens。
ds = _get(usage, "prompt_cache_hit_tokens")
if ds is not None:
candidates.append(int(ds))
# ② MiniMax 嵌套 prompt_tokens_details.cached_tokens。
details = _get(usage, "prompt_tokens_details")
cached = _get(details, "cached_tokens")
if cached is not None:
candidates.append(int(cached))
# ③ AgentScope ChatUsage.cache_input_tokens已从 cached_tokens 提取的归一字段MiniMax 路命中由此兜)。
ai = _get(usage, "cache_input_tokens")
if ai is not None:
candidates.append(int(ai))
return max(candidates) if candidates else 0
except Exception:
# best-effort任何解析异常一律按 miss0绝不阻断成本折算/生成主链。
return 0
def compute(model, prompt_tokens, completion_tokens, pricing, qpu, usd_rate, group_ratio=1.0, cached_tokens=0):
"""单条 usage → quota/USD/¥U4/B9按 cache_ratio 折缓存命中)。
缓存折扣口径 new-api 网关结算同公式命中的 cached prompt token cache_ratio 折价未命中部分全价
quota = ((prompt_tokens cached) + cached × cache_ratio + completion_tokens × completion_ratio) × model_ratio × group_ratio
- cache_ratio /None MiniMax-M2.7 未配 视为 1.0不折扣回退全价不报错
- cached 截断到 [0, prompt_tokens]防脏 usage 命中数超 prompt 数导致负 quota
- cached=0默认/未命中 退化为旧口径 (prompt + completion×completion_ratio)×model_ratio与扩前字节兼容
"""
m = pricing.get(model, {})
model_ratio = m.get("model_ratio", 0)
completion_ratio = m.get("completion_ratio", 1)
quota = (prompt_tokens + completion_tokens * completion_ratio) * model_ratio * group_ratio
# cache_ratio缺/None → 1.0(不折扣)。命中 token 截断到 [0, prompt_tokens](防脏数据负 quota
cache_ratio = m.get("cache_ratio")
if cache_ratio is None:
cache_ratio = 1.0
cached = max(0, min(int(cached_tokens or 0), int(prompt_tokens or 0)))
uncached = prompt_tokens - cached
# 缓存折扣量化的 prompt 段 = 未命中全价 + 命中按 cache_ratio 折价。
prompt_quota = uncached + cached * cache_ratio
quota = (prompt_quota + completion_tokens * completion_ratio) * model_ratio * group_ratio
usd = quota / qpu
rmb = usd * usd_rate
return {
"model": model, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens,
"model_ratio": model_ratio, "completion_ratio": completion_ratio,
"cached_tokens": cached, # 实际计入折扣的命中 token截断后
"model_ratio": model_ratio, "completion_ratio": completion_ratio, "cache_ratio": cache_ratio,
"quota": round(quota, 2), "usd": round(usd, 6), "rmb": round(rmb, 5),
}

View File

@ -99,6 +99,11 @@ def _extract_trace(result):
字段全可选worker 路径未定按必填子集统计完整率§5.3
- 必填子集run_studio 当前路径无条件产出 = 完整率分母pass/repairs/wallS/models/attempts/gameId/stage
- 可选子集路径相关/可能缺gatespec/sevenGateVerdict/cost/player/tokens/similarity
- U3/B5 扩展键additive/脏则省略键 SAA extractTraceQuietly 同口径字节兼容P1-2 严格形态校验
modelTier终态模型档 stage1|stage2须非空串且仅真升档态=stage2 escalationEvents 非空才落P0-1/
escalationEvents升档事件数组须非空 list 才落/ giveupDumpPath放弃 dump 路径须非空串才落/
cacheHit{promptCacheHitTokens, source}U4/B9 缓存命中台账worker 现走 Python 循环 SAA 救场阶梯
前三键暂不产出 省略 SAA 路同读 ReadinessScorer 不破cacheHit run_studio cache_hit 落值
:param result: run_studio 返回的 result dict嵌套结构非扁平 verdict studio.py:297-311
:return: 9d trace dictcamelCase result 为空时返回 None不塞 trace
@ -160,6 +165,9 @@ def _extract_trace(result):
cost_out["gateRmb"] = cost.get("gate_rmb")
if "error" in cost:
cost_out["error"] = cost.get("error")
# P1-1tokens-only 降级标记pricing 不可达时为 True——随 trace 落库,审核台据此知本条 cost 无 total_rmb、非 0 成本。
if cost.get("costFallback"):
cost_out["costFallback"] = True
trace["cost"] = cost_out
player = result.get("player")
@ -175,6 +183,38 @@ def _extract_trace(result):
# D9 相似度dupHit/titleNorm/sig/dupWith随 trace 落库无独立列、无独立面板§1.2 防越界)
trace["similarity"] = similarity
# ===== U3/B5 救场阶梯扩展键additive与 SAA 路 extractTraceQuietly 同口径;缺则省略键,字节兼容)=====
# 严格形态校验,镜像 Java putEscalationKeysIfPresentP1-2 口径对齐):脏值/空值只省略键、绝不落库。
# escalationEvents升档事件数组 [{tierBefore,tierAfter,atFailCount,ts}]。须为「非空 list」才落
# 非数组/空 list/缺 → 省略键(与 Java events.isArray() && size>0 同口径)。先算它,因 modelTier 是否落要据它。
escalation = (result.get("escalationEvents")
if result.get("escalationEvents") is not None else result.get("escalation_events"))
has_events = isinstance(escalation, list) and len(escalation) > 0
if has_events:
trace["escalationEvents"] = escalation
# modelTier终态模型档stage1|stage2。须为「非空字符串」镜像 Java filter(!isBlank)),且仅「真升档态」才落
# ——modelTier=="stage2" 或 escalationEvents 非空P0-1默认 stage1 是 render 初值噪声,落它破字节兼容)。
# worker 现走 Python 循环无救场阶梯 → 一般缺 → 省略。
model_tier = result.get("modelTier") if result.get("modelTier") is not None else result.get("model_tier")
if isinstance(model_tier, str) and model_tier.strip():
if model_tier == "stage2" or has_events:
trace["modelTier"] = model_tier
# giveupDumpPath放弃前完整 dump 落盘路径U5 产)。须为「非空字符串」(镜像 Java filter(!isBlank));现可能缺 → 省略。
giveup = (result.get("giveupDumpPath")
if result.get("giveupDumpPath") is not None else result.get("giveup_dump_path"))
if isinstance(giveup, str) and giveup.strip():
trace["giveupDumpPath"] = giveup
# ===== U4/B9 缓存命中台账({promptCacheHitTokens, source}=====
# run_studio 产 result.cache_hit已 camelCase 内键 promptCacheHitTokens + source缺则省略键additive
cache_hit = result.get("cacheHit") if result.get("cacheHit") is not None else result.get("cache_hit")
if isinstance(cache_hit, dict):
trace["cacheHit"] = {
"promptCacheHitTokens": cache_hit.get("promptCacheHitTokens",
cache_hit.get("prompt_cache_hit_tokens", 0)) or 0,
"source": cache_hit.get("source", "none"),
}
return trace