承接目录治理:上轮只压缩内容未减文件数,闭线工作记录仍平铺致目录看着仍一堆(创始人指出)。本次执行 B2 归档。 64 个 ≤06-15 闭线档(25 压缩桩 + 闭线 review/report/纪要/edit-plan)git mv 入 docs/agent-specs/_archive/(文件名不变、仍 git 跟踪可查)。热目录 ≤06-15 仅留 13 活档(决策/纲领/SoT/活spike)+13 个 06-16 在飞。 活资产 20 处旧路径引用(.agents/docs/memory/_index)同步改 _archive/,引用断裂复测=0;_index 活地图 + 治理档状态收口。约束:0 个 06-16 被移、orchestrator 等未跟踪在飞档零误纳。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33 KiB
Python agentic 生成系统 → SAA(Spring AI Alibaba / Java)迁移设计
类型:迁移设计(只读分析 + 目标图设计,不含完整实现代码)。 范围:把「已跑通的 Python agentic 生成系统」(wg1/gen-worker)迁移并充分发挥 SAA 强项地适配到 SAA 裸图编排,随后做「Python 生成游戏 vs SAA 生成游戏」对比测试。 基线证据:Python 侧
wg1/gen-worker/worker/**(逐行已读);Java 契约game-cloud/game-module-aigc/**(dispatcher 接缝已读);SAA 源码/root/oss/spring-ai-alibaba@ v1.1.2.2(核心类文件位已核);SAA 用法/坑见2026-06-15-SAA-能力API接入-dossier.md。
0. 结论先行(决策级)
- 迁移本质 = 换「worker 躯体」,不换「契约骨架」。 Java 执行器侧已有干净接缝:
AigcGenerateExecutor.dispatchGeneric组 §6.1 job-in →WorkerDispatchClient.dispatch投递 → worker 异步产engineBundle→ 回调DifyCallbackReqVO到/dify/callback-internal(AigcGenerateExecutor.java:435-494、WorkerDispatchClient.java:69-107、DifyCallbackReqVO.java:25-67)。SAA 图只替换 job→engineBundle 这段躯体,两端契约逐字段不动。 - 躯体对应 = Python 的 L2 studio 闭环 → SAA「裸
StateGraph+ 自定义NodeAction」。 Python 已自证:design→[code|fix 循环]→九门真玩→player 顾问轮(agent_loop/studio.py:197-314)。这是确定性多步编排,不是「LLM+工具自治」——故用裸图 + 条件边回环 + state 计数器,不用 ReactAgent/LoopAgent(dossier §0-1)。 - build + 九门 harness 子进程整体复用,禁重写。
serve-and-play.sh→static-serve+headless Chrome→play.cdp.cjs(九门 A–I)+build.mjs(esbuild)是模型无关、已沉淀的确定性地板(game-runtime/games/_wg1-gen/_shared/play.cdp.cjs:10-26)。SAA 在 build/play 节点内ProcessBuilder调同一套 shell(dossier §4-3),不进 SAA 图、不用 sandbox 模块。 - SAA 比 Python 手写编排净增 5 项强项:MySQL checkpoint 可恢复/续跑、Hook+state 双重限次、节点级流式进度(SSE)、子图封装(player 顾问轮)、图可视化与可观测埋点。逐条映射见 §5。
- 部署两形态二选一:① 进程内
SaaGraphDispatcher(消灭 HTTP+HMAC 回调跳,dossier §0-5 首选);② Java worker 进程顶替 Pythonservice.py,沿用/generateHTTP 端点(drop-in,零改 Java 执行器)。建议对比测试期走形态②(隔离变量、与 Python 并排),生产收口走形态①。
1. 现有 Python agentic 系统精确画像(带 文件:行)
1.1 两层闭环:L1 裸路 vs L2 agentic studio
| 维度 | L1 裸路(worker/run.py) |
L2 agentic studio(worker/agent_loop/studio.py) |
|---|---|---|
| 角色 | 单模型直出(无 design/无 player) | design → code/fix 多角色 + player 顾问 |
| 框架 | 裸 openai 客户端,零 AgentScope(_client.py:1-5 纪律) |
AgentScope 2.0.1 进程内多 agent(studio.py:31-32) |
| 管线 | generate→validate→scaffold→build→play→retry(run.py:97-136) |
design→[code|fix 环]→九门→player 顾问轮(studio.py:213-280) |
| 重试上限 | max_retries(默认 2,run.py:88) |
max_repairs(默认 5)+ player_rounds(默认 1,studio.py:197) |
| 产出落盘 | results/<id>.json(run.py:154) |
results/<id>.<stage>.json(studio.py:312) |
现行生产入口 = L2:service.py:173 在 HTTP job 处理里调 run_studio(...)(L1 是早期裸路/冒烟,studio 复用其 scaffold/build/play,studio.py:8)。迁移目标锚定 L2。
1.2 L2 管线步骤(逐节点,studio.py)
- design(设计 agent)
studio.py:214→roles.DESIGN_SYSTEM(roles.py:8-32)。一句话 brief → 设计稿 + 末尾自产 ```gatespec JSON 块(gate-H 推广:让确定性门适用任意游戏)。 - gatespec 抽取与归一
studio.py:218→_extract_gatespec(studio.py:72-102):正则抠 ```gatespec 块,json_repair兜脏 JSON,expectLatch缺省补 True,paddle-intercept.ballPath的.y→.x安全归一。抽出的exportState/driver/controlCheck/assertAfterPlay/expectLatch合入play_spec(studio.py:219-223)。 - code / fix 循环
studio.py:231-265:首轮 role=code,后续 role=fix;prompt.build_messages(enriched, retry_feedback=feedback)(prompt.py:110-119)把上轮失败原因回喂。每轮:validate.extract_code(validate.py:13-28)→ 抠 ```js 块;validate.validate(validate.py:103-111)= 静态契约扫描(static_check,validate.py:55-77:默认导出/init·update·render·destroy 四方法/ctx 用法/getEngine 必现/12 条禁用词)+node --checkESM 语法门(validate.py:80-100);run.scaffold(run.py:33-42)落generated-factory.js+ 拷 entry-bundle/index 模板 + 写 play-spec.json;run.build(run.py:45-53)ProcessBuilder调node scripts/build.mjs ... --global-name=__GameBundle;run.play(run.py:56-72)ProcessBuilder调serve-and-play.sh,读回evidence/verdict.json。
- 九门判定(确定性硬地板)
studio.py:259→seven_ok = rc==0 and verdict.pass。未过 →run._verdict_feedback(verdict)(run.py:75-85)摘失败守卫回喂 → 继续 fix 环。 - player 顾问轮(七门过后才跑,软门)
studio.py:267-280→run_player_panel(studio.py:171-193):M3 视觉位(_judge_vision读 first-paint/after-play 截图,studio.py:148-162)+ flash 文本位(_judge_text,studio.py:165-168)多人格评判 → 若 verdict=fix 且player_round_used<player_rounds,把体验问题回喂再修一轮(七门仍是硬地板)。 - 成本结算
studio.py:282-295→cost.py按 new-api/api/pricing+/api/statusquota 公式逐模型拆账(cost.py:54-66)。
1.3 模型路由
- 配置即旋钮:
models.yaml三阶段(stage1 便宜 DeepSeek-flash / stage1_mmx 跨家 MiniMax-M2.7 / stage2 强档 DeepSeek-v4-pro),每阶段 5 角色(design/code/fix/player_text/player_vision)+_default;视觉位恒 MiniMax-M3(models.yaml:1-40)。 - 取值:
config.model_name_for(role, stage)(config.py:51-56)。 - 客户端:
config.build_model(config.py:59-68)建 AgentScopeOpenAIChatModel,base_url落OpenAICredential(指 new-api);子类RecordingChatModel(config.py:26-42)记每次调用 usage 供成本/对比。 - 唯一出口 = new-api OpenAI 兼容网关
_client.py:31BASE_URL=http://100.64.0.8:3000/v1;代理旁路铁律:import openai 前把网关 host 并入NO_PROXY(_client.py:35-44),否则本机 clash 代理转走→502(spike 头号坑)。 AGENT_MODEL_NAME不是选模型机制(dossier §4-7);选模型靠每节点指定 model 名。
1.4 重试/修复逻辑(迁移的核心循环语义)
- 失败即回喂重出全量(非 diff):
prompt.py:114-118明示「不要只给 diff」。 - 失败分桶 → 不同回喂文案:extract 失败 / validate 失败(静态错误列表)/ build 失败(esbuild 日志前 600 字)/ 九门失败(
_verdict_feedback摘未过守卫)/ player fix(体验问题列表)——studio.py:243-279。 - 计数器双层:repair 环
max_repairs+ player 顾问player_rounds,均 state 内自增(studio.py:231、275-276)。 - 网关瞬时错误重试:
_client.chat内tries=3(_client.py:60,94-97)。
1.5 harness(build + 九门)如何被调
- 入口:
run.play(game_id, port, cdp_port)(run.py:56-72)subprocess.run(["bash", "serve-and-play.sh", game_id, port, cdp_port], timeout=180)。 - serve-and-play.sh(
game-runtime/games/_wg1-gen/_shared/serve-and-play.sh:1-68):净场杀端口 → 起static-serve.cjs(serve game-runtime 根)→ 起 headless Chrome(--remote-debugging-port+--remote-allow-origins=*+--autoplay-policy)→ 就绪轮询 →node play.cdp.cjs <gameId> --base --cdp→ trap 清理,返 play 退出码。 - build:
run.build(run.py:45-53)node scripts/build.mjs <entry> <out> --global-name=__GameBundle;build.mjs:23-31锁参 esbuild(bundle+minify+iife+es2019+无 sourcemap),输出bundle.iife.js+.meta.json。 - 九门 A–I(
play.cdp.cjs:10-20,全过才 PASS,判定汇总play.cdp.cjs:543-667):- A 装载
__genBooted ∧ __gameHostEngineInitFired ∧ 无 bootError - B 未捕获
__genInternals.uncaught 空 - C 掌帧 500ms 内
frame增量 ∈ [20,45] - D 真渲染
#game-engine有色像素bright>50 ∧ maxCh>80 - E 活性 真玩前后帧哈希集
distinct≥2 - F 真接线
__engineCalls命中spec.expectedEngineCallPrefixes任一前缀(证经getEngine()真调引擎,非自绘) - G 输入有效 同 seed 起无输入对照实例推进到同帧号,整帧哈希不同
- H 机制进展 + latch
spec.assertAfterPlay[]全过 +expectLatch时真玩到phase='gameover'且驻留600ms;依赖游戏_forensicsView()导出 state(未声明=SKIP 向后兼容) - I 控制跟手
spec.controlCheck声明时连点验控制体平滑逼近(未声明=SKIP)
- A 装载
- 适配性真玩驱动:
spec.driver存在=读 state 自动出招(runDriver,play.cdp.cjs:204-212分发 8 类:paddle-intercept/tap-targets/flap-to-gap/seek-x/tap-pairs/key-cycle/drag-aiming/aim-fire),解「盲打固定坐标打不动技巧游戏」假阴性;否则走spec.inputs固定序列。
1.6 job / callback 契约(迁移的 I/O 边界)
- job-in(§6.1):Java
dispatchGeneric组(AigcGenerateExecutor.java:464-481):job_id, tier="L1", kind="generate", brief, model, budget{maxYuan,maxLlmCalls}, gameId, templateId, renderCtx{template_schema,banned_list,findings}, idempotency_key, deadline_ms, callback{type,target}, traceId。Pythonservice.do_POST收(service.py:239-262)。 - 握手:worker 立即回 202(
service.py:262),后台线程异步跑(service.py:261);忙时 409(串行锁,service.py:254-257,serve 4320/CDP 9222 不可并发)。 - callback-out:
build_callback_payload(service.py:91-123)组DifyCallbackReqVO形态:traceId, status(succeeded|failed), templateId, gameConfig{templateId,title,theme,engineDriven}, assets:[], engineBundle(成功路=bundle.iife.js 全文) | failureReason(失败路);HMAC-SHA256 签名(service.py:135-143,逐字节对账 JavaCallbackSignatureVerifier)POST 回/dify/callback-internal。Java 侧DifyCallbackReqVO.engineBundle(DifyCallbackReqVO.java:53-66)= P3 additive 字段,resolveEngineBundleText取值写GamePackage.engineBundle落包入 feed。 - 出厂自检:bundle 必含
__GameBundle全局名,否则判失败不落坏包(service.py:184-195)。
1.7 prompt 与证据口径
- prompt:
prompt.SYSTEM(prompt.py:24-107)= 模块形状(默认导出 createGame,五方法 +_forensicsView)+ 能力面(ctx.getEngine/getInput/time/random、boot.mainContext)+ 12 条硬禁止(禁 import/Math.random/Date.now/addEventListener/rAF...)+ 硬规则(闭包态/受控时钟/getEngine 容错/destroy 幂等/必调一次引擎能力/每帧可见变化)+ P0 latch 终态硬约束(gameover 驻留不自动重开)+ few-shot 参照(读_goldenpath-probe/factory.js防漂移,prompt.py:9-20)。 - 证据四件套:
verdict.json(九门 guards + pass)+first-paint.png+after-play.png(play.cdp.cjs:26),落games/_wg1-gen/<id>/evidence/;结果 JSON 含attempts[](每轮 usage/stage_fail/guards)、seven_gate_verdict、player.panel、tokens_by_model、cost(studio.py:297-311)。
2. Python → SAA 映射表
| # | Python 构件(文件:行) | SAA 构件 | 性质 |
|---|---|---|---|
| 1 | L2 主编排 run_studio 循环 studio.py:197-314 |
StateGraph(name, KeyStrategyFactory) + CompiledGraph |
需适配(命令式循环→声明式图) |
| 2 | design agent 调用 studio.py:214 |
render/design 节点 = node_async(NodeAction) 调 design model |
直接对应 |
| 3 | _extract_gatespec studio.py:72-102 |
design 节点内纯 Java 逻辑(正则 + Jackson 容错解析;json_repair→自写宽松解析或 JsonNode 容错) |
需适配(无 json_repair 等价;做最小宽松解析) |
| 4 | code/fix 循环体 studio.py:231-265 |
generate 节点(首轮 code)+ repair 节点(fix)+ addConditionalEdges 回边 |
需适配(用 state role/repair_count 区分) |
| 5 | validate.validate 静态+node --check validate.py:103-111 |
validate 节点(静态契约 = 纯 Java 正则;node --check = ProcessBuilder) |
直接对应(子进程复用) |
| 6 | run.scaffold run.py:33-42 |
scaffold 节点 = 纯 Java 文件 IO(写 factory + 拷模板 + 写 play-spec) |
直接对应 |
| 7 | run.build(esbuild 子进程)run.py:45-53 |
build 节点 = NodeAction 内 ProcessBuilder("node","scripts/build.mjs",...) |
复用·禁重写(骨架抄 LocalFilesystemBackend.java:457-507,dossier §4-3) |
| 8 | run.play(九门 CDP 子进程)run.py:56-72 |
play 节点 = ProcessBuilder("bash","serve-and-play.sh",...) + 读 verdict.json |
复用·禁重写 |
| 9 | 九门 verdict.pass 判定 play.cdp.cjs:666 |
条件边 addConditionalEdges("play", s→pass?"emit":cnt>=N?"giveup":"repair") |
SAA 更强(确定性门作条件边,图天然支持) |
| 10 | repair 计数 max_repairs studio.py:231 |
state repair_count 计数器 + recursionLimit 硬刹车(dossier §4-2) |
SAA 更强(Hook 可选叠加限次) |
| 11 | player 顾问轮 run_player_panel studio.py:171-193 |
子图 player-panel(vision+text 两节点)或单 player 节点 + 回边 |
SAA 更强(子图封装/可并行 vision|text) |
| 12 | 失败回喂 prompt.build_messages(retry_feedback) prompt.py:110 |
repair 节点读 state feedback 注入 user prompt(state 即传参) |
直接对应 |
| 13 | 模型路由 config.model_name_for + models.yaml config.py:51-56 |
N 个 OpenAiChatModel bean(共用 OpenAiApi,仅 defaultOptions.model 异),节点 @Qualifier 取(dossier §1-24) |
需适配(YAML 阶段→Spring 配置/Map<role,model>) |
| 14 | _client.chat(裸 openai,base_url=new-api)_client.py:60-98 |
上游 OpenAiApi.builder().baseUrl(NEW_API) + OpenAiChatModel(dossier §1-24) |
直接对应(new-api OpenAI 兼容同构) |
| 15 | RecordingChatModel usage 记录 config.py:26-42 |
ChatModel 调用后读 ChatResponse.metadata().usage(),写入 state token 累计 |
需适配 |
| 16 | cost.py quota 折算 cost.py:54-66 |
复用 Python cost.py 子进程或 Java 重写 HttpClient 调 /api/pricing+/api/status |
需适配(测而不闸,建议先复用脚本) |
| 17 | 网关代理旁路 _client.py:35-44 |
不适用(JVM 不经 clash;直连 new-api,无此坑) | 直接对应(坑消失) |
| 18 | service.py HTTP 壳 + HMAC 回调 service.py:155-262 |
形态①去掉(进程内 SaaGraphDispatcher 直调 handleCallback);形态②Spring @RestController /generate + 后台线程池跑图 |
需适配(见 §3.4) |
| 19 | 串行锁 service.py:80 |
形态②Semaphore(1) 守 serve 4320/CDP 9222(图执行在后台 ExecutorService,dossier §3-45) |
直接对应 |
| 20 | 结果落盘 results/*.json studio.py:312 |
节点写 state;emit 节点从 state 组 DifyCallbackReqVO(+ 诊断落盘可选) |
直接对应 |
| — | 新增(Python 无) | MysqlSaver checkpoint(同 threadId 续跑/time-travel,dossier §4-5) | SAA 净增 |
| — | 新增(Python 无) | 节点级 streaming 进度(stream()→Flux<NodeOutput>,SSE 推前端,dossier §4-4) |
SAA 净增 |
3. SAA 目标图设计
3.1 节点 + 边组织
flowchart TD
START([START]) --> render[render<br/>取 job→state:brief/model/gameId/budget]
render --> design[design 节点<br/>设计稿+自产 gatespec→merge play_spec]
design --> generate[generate 节点<br/>code 模型出工厂源码]
generate --> validate[validate 节点<br/>静态契约+node --check 子进程]
validate -->|ok| scaffold[scaffold 节点<br/>落 factory+模板+play-spec]
validate -->|fail & cnt<N| repair[repair 节点<br/>fix 模型回喂失败原因]
scaffold --> build[build 节点<br/>ProcessBuilder esbuild]
build -->|ok| play[play 节点<br/>ProcessBuilder serve-and-play 九门]
build -->|fail & cnt<N| repair
play -->|九门 pass| player{{player-panel 子图<br/>vision+text 顾问·软门}}
play -->|fail & cnt<N| repair
play -->|fail & cnt>=N| giveup[giveup 节点<br/>status=failed]
repair --> generate
player -->|无体验问题 或 player轮用尽| emit[emit 节点<br/>读 bundle.iife.js→组 DifyCallbackReqVO]
player -->|有问题 & playerRound<M| repair
giveup --> END([END])
emit --> END
关键边语义(条件边靠 EdgeAction→String + mappings Map,dossier §1-21):
validate/build/play三处失败回边共用同一判据:ok?下一步 : repair_count>=maxRepairs?"giveup" : "repair";失败原因写 statefeedback,repair 节点读取注入 prompt。play成功边 →player;player输出 verdict=fix 且player_round<maxPlayerRounds→ 回repair(九门仍是硬地板,player 是软门,与studio.py:267-280同语义)。repair节点统一自增repair_count后回generate(fix 与 code 同节点、靠 staterole区分模型;或拆两节点,建议同节点 + role 标志省图复杂度)。
3.2 用到的 SAA 强项(逐项对应本管线)
| SAA 强项 | 本管线落点 | 证据 |
|---|---|---|
| 图编排(条件/回环/子图) | 九门作条件边、repair 回环、player 子图 | dossier §0-1、§4-2 |
| MySQL checkpoint 恢复 | 单 job 多步昂贵(design+多轮 LLM+九门 ~数十秒);崩溃后同 threadId 续跑 | dossier §4-5(MysqlSaver 唯一权威 saver,releaseThread(false)) |
| Hook 限次 + state 计数器双保险 | repair_count 主限次 + recursionLimit 硬刹车(优雅终止非异常) |
dossier §4-2、§5-3 |
| streaming 进度 | 节点 apply 返回塞 Flux → stream() 展开 → SSE 推前端「设计中/写码中/真玩中」 |
dossier §4-4(无 streamEvents,用 stream()+StreamingOutput) |
| 确定性九门作条件边 | verdict.json pass→边路由,零猜测 |
play.cdp.cjs:666 |
| 子图封装 | player-panel(vision|text 可并行)封为子图,主图一个节点引用 | dossier §2(嵌套子图本期可用) |
| 模型节点指 new-api | N 个 OpenAiChatModel 共用 OpenAiApi(baseUrl=new-api) |
dossier §1-24 |
| 可观测埋点 | 节点级埋点/图可视化,替代 Python 散落 print | dossier §2 |
3.3 关键节点骨架草图(伪代码级,非实现)
state schema(KeyStrategyFactory,dossier §1-22 用 ReplaceStrategy/AppendStrategy)
keys: brief, model(role→modelName map), gameId, templateId, budget,
designText, playSpec(JSON), factorySrc, verdict(JSON),
feedback, role(code|fix), repairCount, playerRound,
tokensIn/Out, status(succeeded|failed), failureReason, engineBundle
generate / repair 节点(NodeAction,dossier §1-22 写靠 return Map)
// 伪代码:读 state 拼 prompt(首轮 code/重出 fix 回喂 feedback),调对应模型,抽 ```js
Map apply(OverAllState s){
String role = s.value("role","code"); // code|fix
ChatModel m = role.equals("fix") ? fixModel : codeModel; // @Qualifier 取,全指 new-api
String user = buildUser(s.value("brief"), s.value("designText"), s.value("feedback")); // 回喂全量重出
String text = m.call(systemPrompt, user); // systemPrompt = prompt.SYSTEM 等价
String src = extractJsBlock(text); // 等价 validate.extract_code
recordUsage(m, s); // 累计 token 入 state
return Map.of("factorySrc", src);
}
validate 节点(静态 = 纯 Java,node --check = 子进程)
Map apply(OverAllState s){
List<String> errs = staticCheck(s.value("factorySrc")); // 默认导出/四方法/getEngine/12 禁用词
if(errs.isEmpty()) errs = nodeCheck(src); // ProcessBuilder("node","--check",tmp.mjs)
return errs.isEmpty()? Map.of("validateOk",true)
: Map.of("validateOk",false,"feedback","校验未过:\n"+join(errs));
}
build / play 节点(ProcessBuilder,禁重写 harness;骨架抄 LocalFilesystemBackend.java:457-507)
Map apply(OverAllState s){ // play 节点
Process p = new ProcessBuilder("bash",
"games/_wg1-gen/_shared/serve-and-play.sh", gameDir, "4320","9222")
.directory(gameRuntimeRoot).start();
boolean done = p.waitFor(180, SECONDS); // 超时 destroyForcibly(对齐 timeout=180)
if(!done){ p.destroyForcibly(); return Map.of("playPass",false,"feedback","真玩超时"); }
JsonNode verdict = mapper.readTree(Path.of(gameDir,"evidence","verdict.json").toFile());
boolean pass = p.exitValue()==0 && verdict.path("pass").asBoolean(false);
return Map.of("verdict",verdict,"playPass",pass,
"feedback", pass? "" : summarizeFailedGuards(verdict)); // 等价 _verdict_feedback
}
条件边(EdgeAction,dossier §1-21 mappings 不可空)
graph.addConditionalEdges("play",
edge_async(s -> {
if (Boolean.TRUE.equals(s.value("playPass"))) return "ok";
return s.value("repairCount",0) >= MAX_REPAIRS ? "giveup" : "repair";
}),
Map.of("ok","player", "repair","repair", "giveup","giveup"));
emit 节点(组回调,复用现有写入路径)
Map apply(OverAllState s){
String bundle = readFile(gameDir+"/bundle.iife.js"); // 读 __GameBundle iife 全文
if(!bundle.contains("__GameBundle")) return Map.of("status","failed","failureReason","bundle_missing_global_name");
return Map.of("status","succeeded","engineBundle",bundle); // → DifyCallbackReqVO.engineBundle
}
3.4 与 Java 契约的对接(两形态)
- 形态①进程内
SaaGraphDispatcher(生产收口首选,dossier §0-5/§3-45):实现GenerationDispatcher(与WorkerDispatchClient同接口位),dispatch(job)把 job 转 state,在后台ExecutorServicecompiledGraph.invoke(state, RunnableConfig.threadId(traceId))(自带阻塞,禁 Tomcat/Reactor 线程),出口直接进程内调difyCallbackService.handleCallback(reqVO)——消灭 HTTP 跳 + HMAC(同信任域)。图执行不包大@Transactional,跑完单独短事务落库。 - 形态②Java worker 进程(对比测试期 drop-in):Spring
@RestController暴露POST /generate(顶替 Pythonservice.py),收 §6.1 job→202 握手→后台线程跑图→组DifyCallbackReqVO+HMAC POST 回/dify/callback-internal。Java 执行器零改(aigc.executor.worker-url指向新 Java worker 即可),与 Python worker 并排,隔离变量做对比。
4. 复用 vs 重写边界(硬约束)
| 组件 | 决策 | 理由 |
|---|---|---|
serve-and-play.sh / play.cdp.cjs(九门 A–I)/ static-serve.cjs |
子进程复用·禁重写 | 模型无关的确定性地板;重写 = 引入新假阴性/假绿风险,且 Java 无 CDP+esbuild 等价生态现货 |
scripts/build.mjs(esbuild 锁参) |
子进程复用·禁重写 | 锁参影响 SIZES 口径;Java 无 esbuild 等价 |
entry-bundle/index 模板、_goldenpath-probe/factory.js few-shot |
复用(读文件) | 装载契约/few-shot 防漂移 |
cost.py 成本折算 |
建议子进程复用(或后续 Java 重写) | 测而不闸;Python 脚本已沉淀,先复用省事 |
run.scaffold 文件 IO / validate.static_check 静态扫描 / extract_code |
重写进 SAA 节点(纯 Java) | 轻逻辑,无生态依赖,进图后受 checkpoint/可观测管辖 |
run_studio 命令式循环 / code-fix-player 编排 |
重写为 SAA 裸图 | 迁移主体;命令式→声明式图换取可恢复/限次/流式 |
_client.chat / config.build_model 模型调用 |
重写为 SAA OpenAiChatModel |
上游 OpenAI 兼容指 new-api,同构 |
service.py HTTP 壳 + HMAC |
形态①去掉 / 形态②Java 重写 | 见 §3.4 |
_extract_gatespec 的 json_repair 兜脏 |
重写为最小宽松解析 | Java 无 json_repair;做「strip ```gatespec→Jackson 容错→缺省补 expectLatch/ballPath 归一」最小等价 |
5. 「充分发挥 SAA 强项」清单(相比 Python 手写编排)
- 可恢复(checkpoint/resume):Python
run_studio一旦进程崩,整 job 从零重跑(results/*.json仅事后产物)。SAA MysqlSaver +threadId(traceId)→ 崩溃后同 threadId 二次 invoke 即从最后节点续跑(dossier §4-5);与现有 watchdog 收尸(AigcGenerateExecutor.java:620-644)互补:watchdog 兜超时、checkpoint 兜续跑。 - 限次双保险:Python 靠裸
for attempt in range(max_repairs+1)计数(studio.py:231)。SAA = staterepair_count(业务语义主限次)+recursionLimit(引擎级硬刹车,优雅终止非异常,dossier §5-3);ReAct 那种无限循环坑(dossier §5-3)在裸图下天然不触发。 - 节点级流式进度:Python 仅 stdout
print(run.py:176、studio.py:338),前端无实时进度。SAAstream()→Flux<NodeOutput>+StreamingOutput(dossier §4-4,注意无 streamEvents)→ 出口@PostMapping(produces=TEXT_EVENT_STREAM)把「设计中→写码中→打包中→真玩中→第 k 次修复」推前端,直接对接 studio 创作页进度条。 - 子图封装:Python
run_player_panel是内联函数(studio.py:171)。SAA 把 player-panel(vision|text)封为子图(dossier §2 嵌套子图本期可用),主图一节点引用;vision/text 两位可并行(Python 当前串行studio.py:180-192)→ 顾问轮提速。 - 可观测 + 可视化:Python 编排是黑盒命令式流。SAA 节点级埋点 + 图结构可导出(dossier §2 可观测埋点),排障/审计/给非工程同事讲清「九门卡在哪个节点」远优于读 print 日志。
- 确定性门即一等公民边:九门
verdict.pass在 Python 是if分支(studio.py:262);在 SAA 是addConditionalEdges的路由谓词——把「能玩才算 pass」这条铁律固化进图拓扑,杜绝顺手改 if 绕过门。
6. 风险与待定 + 迁移分步
6.1 风险与待定
| 级别 | 项 | 说明 / 缓解 |
|---|---|---|
| 高 | AgentScope 多角色语义损失 | Python design/code/fix/player 是 AgentScope Agent(studio.py:127-134,max_iters=1 单轮)。SAA 裸图下每角色=一个节点调一次模型,单轮语义天然等价(无工具自治),低风险;但 player 的「人格 + JSON 评判」需在节点内复刻 roles.player_system(roles.py:38-57),按字段对齐。 |
| 高 | build/play 子进程环境耦合 mini-desktop | serve-and-play.sh 依赖 Chrome+node+esbuild+lsof(serve-and-play.sh:11-44),只在 mini-desktop(x86 e2e 机)跑。SAA worker(无论形态①②)必须与 harness 同机部署(all localhost),cwd=game-runtime 根。形态①把图嵌进 game-cloud → game-cloud 须能在 mini-desktop 起且能 fork 子进程(确认权限/PATH)。 |
| 中 | MysqlSaver 注入 yudao Druid + 一图一 saver | dossier §3-13/§5-5:MysqlSaver 收 DataSource(注 Druid),但一图只能注册一个 saver,注册俩抛异常;Redis 走业务旁路或自写双写装饰器。首次接需验 CREATE_IF_NOT_EXISTS 建表与 yudao Flyway 不冲突。 |
| 中 | 依赖冲突(Boot 3.5.14 / Redisson 4.4.0) | dossier §3:不引 SAA Boot BOM(让 3.5.14 胜)、不用 RedisSaver(避 Redisson 3.x↔4.x)。形态②独立 worker 可用独立 Boot 版避此坑——对比期形态②额外降风险。 |
| 中 | json_repair 无 Java 等价 | 便宜模型 gatespec/player JSON 常脏(studio.py:80-90 靠 json_repair 兜)。Java 需最小宽松解析(截块→Jackson 容错→字段缺省);脏到 Jackson 也解不动时按「gatespec 缺失→退 brief 默认 play_spec」降级(与 _extract_gatespec 返 None 同语义)。 |
| 低 | 代理旁路坑消失 | _client.py:35-44 的 NO_PROXY 坑是 Python+clash 本机特有,JVM 不经 clash,迁移后此坑不存在(净简化)。 |
| 待定 | 对比测试口径 | 「Python vs SAA 生成游戏」对比需固定:同 brief 集 + 同 model(stage1)+ 同九门 harness + 同 maxRepairs;比较维度 = 九门通过率/平均修复轮数/wall/¥/player 评分。建议形态②并排跑同一批 briefs(wg1/gen-worker/briefs/*.json 16 款现货)。 |
6.2 迁移分步(可执行,供后续实现)
- 门 E(最便宜命题,dossier §3-47):aigc-server pom 加 3 BOM + graph-core(当前 pom 尚无 SAA 依赖,已核
game-module-aigc-server/pom.xml)→dependency:tree断言(Boot 全 3.5.14 / Redisson 4.4.0 / 无 3.22.0 泄漏)→ 纯 Java helloStateGraph节点 →mvn package+ 单体启动门(SAA 与 yudao 自动配置共存无 BeanCreation 失败)→invoke跑一节点。先不接模型/saver/job 链。 - 模型节点接 new-api:建 N 个
OpenAiChatModelbean(共用OpenAiApibaseUrl=new-api,per-role model 名),一个 generate 节点真出 ```js,断言能拿到工厂源码 + usage。 - validate/scaffold/build/play 四节点接子进程:build/play 用
ProcessBuilder调既有 harness(mini-desktop,cwd=game-runtime),读回verdict.json;单测验九门 verdict 能解析、退出码正确传递。 - 组装裸图 + 条件边:render→design→generate→validate→[repair 环]→scaffold→build→play→player→emit/giveup,state
repair_count/feedback回喂;跑通一款 brief(如 breakout)端到端过九门。 - 接 MysqlSaver checkpoint:注 Druid DataSource,
releaseThread(false),验同 threadId 续跑 + time-travel。 - 接 streaming 进度(可选,对比期可缓):节点塞 Flux,SSE 出口验前端收到逐节点进度。
- 形态②Java worker
/generate上线:Spring@RestController+ 后台线程池 +Semaphore(1)串行 + HMAC 回调;aigc.executor.worker-url切到 Java worker,并排 Python worker。 - 对比测试:同批 briefs 各跑 Python worker / SAA worker,按 §6.1 待定口径出对比表(九门通过率/修复轮数/wall/¥/player 分)。
- 形态①收口(生产):对比验证 SAA 不残废后,实现进程内
SaaGraphDispatcher(去 HTTP+HMAC),灰度切HttpWorkerDispatcher | SaaGraphDispatcher。
验证状态:本设计为只读分析 + 目标图设计,未写实现代码、未跑构建。Python 侧画像与 Java 契约边界均带 文件:行 证据(已逐行读);SAA 核心类文件位已核(
spring-ai-alibaba-graph-core);SAA 用法/坑引自 dossier(四路源码取证)。下一步可验证项 = 迁移分步①门 E(最便宜、隔离「SAA 库能进 game-cloud」单命题)。
7. 实测进度与坑(增量更新)
step 2 已过(2026-06-15)— SAA→new-api→生成源码 最小链
- 证据:
game-module-aigc-server内SaaNewApiGenerateSmokeTest绿(1 passed);deepseek-v4-flash@100.64.0.8:3000真出 breakout 工厂源码(5779 chars,export default createGame);usage prompt=336 / completion=5717。 - 文件:
.../aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java
实测迁移坑(后续节点必看)
- 【高频坑】Spring AI
OpenAiApibaseUrl 不要带/v1:Spring AI 自拼completionsPath=/v1/chat/completions,baseUrl 带/v1→ 404/v1/v1/...。而 openai-python 的 base_url 要带/v1。同一.env(NEWAPI_BASE_URL=.../v1)两侧通用须 Java 侧stripV1Suffix()剥末尾/v1→ baseUrl 用 host 根。 -amreactor 跑单测:上游模块无匹配测试会 "No tests matching" 失败 → 加-Dsurefire.failIfNoSpecifiedTests=false。- key:
NEWAPI_KEY在 mini-desktop/root/games-development-ai/wg1/gen-worker/.env(gitignored,worktree 内无);跑测试经环境变量传入。 - SAA model API(v1.1.2.2 javap 实证):
OpenAiApi.builder().baseUrl(host根).apiKey()→OpenAiChatModel.builder().openAiApi().defaultOptions(OpenAiChatOptions.builder().model().temperature().maxTokens())→model.call(new Prompt(List.of(SystemMessage,UserMessage))).getResult().getOutput().getText();usage=resp.getMetadata().getUsage().getPromptTokens()。