feat(saa): U1 用对 M3 — Anthropic 协议 client(flag 旁挂)+ 多 Gen 取答案 + per-role thinking budget

003-U1「用对 agentic M3」:
- AigcExecutorProperties: saaModelProtocol(默认 openai)/saaAnthropicBase(host 根)/saaThinkingBudget
- SaaStudioGraph: RoleModelFactory + anthropicModel(host 根 baseUrl·x-api-key·Proxy.NO_PROXY·per-role budget=min(全局,maxTokens-1024)≥1024 才开 thinking) + openAiFactory(逐字等价旧路) + factory-based build 重载
- SaaGraphDispatcher: ensureGraph 按 saaModelProtocol flag 选 openai/anthropic 工厂(openai 默认路调用不变)
- SaaStudioNodes: pickAnswerText 遍历 getResults 取「无 signature 末块」为答案(单 Gen=旧路字节等价),替 callAndRecord/judgeVision 的 getResult().getText()

实测定死(spike + 创始人 2026-06-19 网关治本):x-api-key→M3 原生 thinking 分离 block(getResults size=2,thinking 带 signature);Bearer / 带 /anthropic 前缀 = 坑。
验证全绿:编译;烟测 size2 取对答案;P0-2 专项(全 11 角色 assemble 不崩,SaaAnthropicAssembleTest 2/0/0);回归 43/0/0(openai 字节零变);依赖树 spring-ai-anthropic 1.1.2 无漂移/无 autoconfigure。
flag 默认 openai 现行字节零变;不碰 gd-runtime/build-from-source(Plan B 域)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-19 06:18:55 -07:00
parent b6cadd8394
commit 19f666cd5d
8 changed files with 617 additions and 25 deletions

View File

@ -47,6 +47,13 @@
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-graph-core</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-starter-graph-observation</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId></dependency>
<!-- Anthropic 协议客户端Plan A · U1 M3 client版本由上方 spring-ai-bom:1.1.2 托管,勿写 version
刻意用 bare `spring-ai-anthropic`(裸库,非 `-starter`):仅要 AnthropicApi/AnthropicChatModel/AnthropicChatOptions
这套低层 API避免 `-starter` 的 AnthropicAutoConfiguration 在 Spring 上下文里抢注 ChatModel bean与现有
OpenAI 路并存时会冲突。U1 client 经 new-api **host 根 /v1/messages**baseUrl=host 根、x-api-key 鉴权;
lili-mac spike 1.1.2 实测纠偏:**绝不加 /anthropic 前缀**,否则命中 SPA catch-all 返 HTML 假挂起)驱动
MiniMax-M3 走 Anthropic Messages 协议thinking 原生分离),与 OpenAI-兼容路 flag 旁挂。 -->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-anthropic</artifactId></dependency>
<!-- Spring Cloud 基础环境 -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -312,22 +312,39 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
}
synchronized (this) {
if (compiledGraph == null) {
// new-api 上游baseUrl host 剥末尾 /v1坑见 §7-1apiKey 取执行器配置密钥经环境变量注入
String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase());
OpenAiApi api = OpenAiApi.builder().baseUrl(baseUrl).apiKey(properties.getApiKey()).build();
// checkpointstep5开关开 DataSource 在席 懒建权威单 MysqlSaver首个 dispatch 才触 DDL
// dispatcher=http 永不到此否则降级内存态saverConfig=null一图一 saver register 一个
SaverConfig saverConfig = buildSaverConfigIfEnabled();
// 固定架构 B2透传救场阶梯 stage2 额外救场轮saaStage2ExtraRepairs 8 build 重载recursionLimit 5+3 重算
compiledGraph = SaaStudioGraph.build(api, SaaStudioGraph.Models.stage1(), gameRuntimeRoot,
// Plan A/U1用对 M3 saaModelProtocol flag per-role 模型工厂
// openai默认= OpenAI 兼容口径OpenAiApi+OpenAiChatModel字节零变
// anthropic = Anthropic Messages 协议thinking 原生分离 M3 thinking 内联污染布线唯一源仍是 assemble两路共用
boolean anthropic = "anthropic".equalsIgnoreCase(properties.getSaaModelProtocol());
String upstreamDesc;
SaaStudioGraph.RoleModelFactory mf;
if (anthropic) {
// Anthropic baseUrl=host 绝不加 /anthropic勿用 stripV1Suffixspike 实测x-api-key 鉴权budget<maxTokens client fail-fast
String anthropicBase = properties.getSaaAnthropicBase();
mf = SaaStudioGraph.anthropicFactory(anthropicBase, properties.getApiKey(),
properties.getSaaThinkingBudget());
upstreamDesc = "anthropic(" + anthropicBase + ", thinkingBudget=" + properties.getSaaThinkingBudget() + ")";
} else {
// OpenAI 兼容路默认baseUrl host 剥末尾 /v1坑见 §7-1apiKey 经配置注入openAiFactory 逐字调 model(api,...)字节等价改造前
String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase());
OpenAiApi api = OpenAiApi.builder().baseUrl(baseUrl).apiKey(properties.getApiKey()).build();
mf = SaaStudioGraph.openAiFactory(api);
upstreamDesc = "openai(" + baseUrl + ")";
}
// 固定架构 B2透传救场阶梯 stage2 额外救场轮saaStage2ExtraRepairs factory-based build 重载recursionLimit 5+3 重算
compiledGraph = SaaStudioGraph.build(mf, SaaStudioGraph.Models.stage1(), gameRuntimeRoot,
properties.getSaaMaxRepairs(), properties.getSaaMaxPlayerRounds(),
properties.getSaaStage2ExtraRepairs(),
properties.getSaaSourceMode(), // plan U3factory(默认,iife) | gamedef(真结构化源)
saverConfig, observationRegistry);
log.info("[saa-dispatch] SAA 图构造完成缓存复用maxRepairs={}, stage2Extra={}, maxPlayerRounds={}, "
+ "newApiBase={}, checkpoint={}, observation={}",
log.info("[saa-dispatch] SAA 图构造完成缓存复用protocol={}, maxRepairs={}, stage2Extra={}, maxPlayerRounds={}, "
+ "upstream={}, checkpoint={}, observation={}",
anthropic ? "anthropic" : "openai",
properties.getSaaMaxRepairs(), properties.getSaaStage2ExtraRepairs(),
properties.getSaaMaxPlayerRounds(), baseUrl,
properties.getSaaMaxPlayerRounds(), upstreamDesc,
saverConfig != null ? "MysqlSaver(GRAPH_CHECKPOINT/GRAPH_THREAD)" : "内存态(off/无DataSource)",
(observationRegistry != null && observationRegistry != ObservationRegistry.NOOP) ? "on" : "off");
}

View File

@ -10,10 +10,17 @@ import com.alibaba.cloud.ai.graph.exception.GraphStateException;
import com.alibaba.cloud.ai.graph.observation.GraphObservationLifecycleListener;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.net.Proxy;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
@ -148,6 +155,105 @@ public final class SaaStudioGraph {
.build();
}
/**
* per-role 模型工厂Plan A/U1用对 M3flag 旁挂的缝角色名+采样面 {@link ChatModel}抽象成函数
* 使 {@link #assemble} 的节点布线与底层走 OpenAI 兼容还是 Anthropic 协议解耦
*
* <p><b>回归命门</b>openai 工厂{@link #openAiFactory(OpenAiApi)}逐字调现行 {@link #model(OpenAiApi, String, Double, int)}
* 产出与改造前完全相同的 {@link OpenAiChatModel} 对象 默认 openai <b>运行时字节等价</b>anthropic 工厂
* {@link #anthropicFactory(String, String, int)} {@link AnthropicChatModel}thinking 原生分离 flag=anthropic 时注入
*
* @param modelName 角色模型名per-role deepseek-v4-flash / MiniMax-M3
* @param temperature 采样温度null=不下发用服务端默认anthropic 路同口径透传
* @param maxTokens 最大补全 tokensper-role code/fix=16000player_vision=900
* @return 该角色的 {@link ChatModel}OpenAI Anthropic 实现
*/
@FunctionalInterface
public interface RoleModelFactory {
ChatModel create(String modelName, Double temperature, int maxTokens);
}
/**
* OpenAI 兼容口径的 per-role 工厂默认路逐字调 {@link #model(OpenAiApi, String, Double, int)}运行时字节等价改造前
*
* @param api 共用 new-api 上游baseUrl=host apiKey 已设
* @return openai per-role 工厂
*/
public static RoleModelFactory openAiFactory(OpenAiApi api) {
return (name, temperature, maxTokens) -> model(api, name, temperature, maxTokens);
}
/**
* 建一个经 <b>Anthropic Messages 协议</b>指向 new-api {@link AnthropicChatModel}Plan A/U1用对 M3thinking 原生分离
* OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出根因<b>lili-mac spike 1.1.2 实测定死配方</b>
* <ul>
* <li><b>baseUrl = new-api host </b> {@code http://100.64.0.8:3000}completionsPath 保持默认 {@code /v1/messages}
* 真实打 {@code .../v1/messages}<b>绝不加 {@code /anthropic} 前缀</b>命中 SPA catch-all HTML假挂起
* <b>亦不复用 {@link #stripV1Suffix}</b>那是 OpenAI 口径的坑 Anthropic 协议无关</li>
* <li><b>鉴权 = {@code apiKey(key)} {@code x-api-key} </b>实测x-api-key Generation 原生 thinking block
* signature=推理 / signature=答案<b>勿用 Authorization Bearer</b>实测退化为字面 {@code <think>} 内联正是要躲的坑</li>
* <li><b>代理旁路</b> {@link SimpleClientHttpRequestFactory} {@link Proxy#NO_PROXY} + 连接/读超时
* {@code AnthropicApi.builder().restClientBuilder(...)} 注入1.1.2 {@code AnthropicChatModel$Builder} <b> customHeaders</b>
* 自定义只能经 restClientBuilder</li>
* <li><b>thinking</b>{@code thinking(ENABLED, budget)}<b>{@code budget < maxTokens} 硬约束</b>client fail-fast 断言
* 勿信网关宽松否则重演原截断 bug阻塞 {@code .call()}保留默认 RetryTemplate流式 thinking bug #4407 不用</li>
* </ul>
*
* @param anthropicBase Anthropic 协议 baseUrlnew-api host /anthropic 前缀= AigcExecutorProperties.saaAnthropicBase
* @param apiKey new-api 密钥 x-api-key 密钥经配置注入绝不入 repo
* @param thinkingBudget thinking 预算 tokens < 各角色 maxTokens= AigcExecutorProperties.saaThinkingBudget
* @return anthropic per-role 工厂
*/
public static RoleModelFactory anthropicFactory(String anthropicBase, String apiKey, int thinkingBudget) {
return (name, temperature, maxTokens) -> anthropicModel(anthropicBase, apiKey, name, temperature, maxTokens, thinkingBudget);
}
/**
* 构造单个 Anthropic 协议角色模型配方见 {@link #anthropicFactory}
*
* @param anthropicBase Anthropic 协议 baseUrlhost /anthropic
* @param apiKey new-api 密钥x-api-key
* @param modelName 角色模型名
* @param temperature 采样温度null=不下发
* @param maxTokens 最大补全 tokens
* @param thinkingBudget thinking 预算全局上限per-role 自适应 clamp {@code min(全局, maxTokens-余量)} {@code < maxTokens}
* 容不下 Anthropic 最小(1024) 的小判定角色则关 thinkingP0-2
* @return Anthropic 角色模型
*/
public static AnthropicChatModel anthropicModel(String anthropicBase, String apiKey, String modelName,
Double temperature, int maxTokens, int thinkingBudget) {
// 代理旁路 + 连接/读超时macOS/容器系统代理clash 会把 Tailscale IP 走代理502NO_PROXY 直连 new-api
SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory();
rf.setProxy(Proxy.NO_PROXY);
rf.setConnectTimeout(10_000);
rf.setReadTimeout(120_000);
AnthropicApi api = AnthropicApi.builder()
.baseUrl(anthropicBase) // host completionsPath 默认 /v1/messages绝不加 /anthropic勿用 stripV1Suffix
.apiKey(apiKey) // x-api-key 勿用 Authorization Bearer
.restClientBuilder(RestClient.builder().requestFactory(rf)) // 1.1.2 customHeaders自定义只能经 restClientBuilder
.build();
// P0-2 per-role thinking budget 自适应替原 fail-fast assemble 给判定类角色硬编码小 maxTokens
// playerVision=900/classify=1000/narrative=2000/playerText=4000单一全局 budget(默认8192) 撞之即抛anthropic assemble
// 正解budget = min(全局, maxTokens-答案余量) < maxTokens守协议约束容不下 Anthropic 最小 thinking(1024)
// 的小判定角色不开 thinking判定/审查非生成无需推理生成角色(maxTokens 16000)仍用全局 8192
final int MIN_THINKING = 1024; // Anthropic thinking budget_tokens 下限
final int ANSWER_RESERVE = 1024; // 给答案/工具调用留的最小补全空间
int effectiveBudget = Math.min(thinkingBudget, maxTokens - ANSWER_RESERVE);
AnthropicChatOptions.Builder opts = AnthropicChatOptions.builder()
.model(modelName)
.maxTokens(maxTokens);
if (effectiveBudget >= MIN_THINKING) {
opts.thinking(AnthropicApi.ThinkingType.ENABLED, effectiveBudget); // 生成角色thinking 原生分离 block
} // elsemaxTokens 太小的判定类角色不开 thinking避免 budgetmaxTokens 判定/审查无需推理
if (temperature != null) {
opts.temperature(temperature); // null = 不下发 openai 路同口径
}
return AnthropicChatModel.builder()
.anthropicApi(api)
.defaultOptions(opts.build()) // 生产保留默认 RetryTemplate流式 thinking bug #4407 只阻塞 .call()
.build();
}
/**
* new-api host 根末尾的 {@code /v1}Spring AI OpenAiApi 自拼 completionsPath=/v1/chat/completions
* baseUrl /v1 404 /v1/v1/...坑见迁移设计 §7-1同一 .envNEWAPI_BASE_URL=.../v1两侧通用须 Java 侧剥之
@ -201,7 +307,7 @@ public final class SaaStudioGraph {
* <b>P0-4</b>deterministic 路本期 fail-loud END sourceProject 真构建管线待 B7+引擎线 E2不静默走会忽略
* sourceProject 的旧 buildregenerate-module 路走 generate本期可达modify 非孤儿真做事真改源/真置终态无空壳
*
* @param api 共用 new-api 上游baseUrl=host apiKey 已设
* @param mf per-role 模型工厂{@link #openAiFactory} 默认路·字节等价 / {@link #anthropicFactory} flag=anthropic
* @param models 角色模型名含固定架构 classify/narrative
* @param gameRuntimeRoot game-runtime harness/few-shot/证据所在build/play 子进程 cwd
* @param maxRepairs stage1 修复轮上限对齐 studio.py:max_repairs 默认 5
@ -210,29 +316,29 @@ public final class SaaStudioGraph {
* @return 未编译的全图 {@link #build} CompileConfig 后编译
* @throws GraphStateException 图结构非法节点/边布线错误编译期暴露
*/
private static StateGraph assemble(OpenAiApi api, Models models, Path gameRuntimeRoot,
private static StateGraph assemble(RoleModelFactory mf, Models models, Path gameRuntimeRoot,
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs,
String sourceMode)
throws GraphStateException {
// == per-role 采样面temperature/maxTokens逐字对齐 Python ==
// == per-role 采样面temperature/maxTokens逐字对齐 Python mf 工厂建 ChatModelopenai 默认路字节等价anthropic flag 旁挂==
// designPython config.build_model 不传 temperature 服务端默认 temperature=null不下发
// code/fix_client.chat temperature=0.0max_tokens=16000确定性产出
// player_textstudio.py:167 max_tokens=4000temperature=0.3
// player_visionstudio.py:160 max_tokens=900temperature=0.3
OpenAiChatModel designModel = model(api, models.design(), null, 16000);
OpenAiChatModel codeModel = model(api, models.code(), 0.0, 16000);
OpenAiChatModel fixModel = model(api, models.fix(), 0.0, 16000);
ChatModel designModel = mf.create(models.design(), null, 16000);
ChatModel codeModel = mf.create(models.code(), 0.0, 16000);
ChatModel fixModel = mf.create(models.fix(), 0.0, 16000);
// P1-5救场 stage2 强档 code/fix 16000/temperature=0仅模型名换强档generate modelTier 真切使升档生效
OpenAiChatModel codeStage2Model = model(api, models.codeStage2(), 0.0, 16000);
OpenAiChatModel fixStage2Model = model(api, models.fixStage2(), 0.0, 16000);
ChatModel codeStage2Model = mf.create(models.codeStage2(), 0.0, 16000);
ChatModel fixStage2Model = mf.create(models.fixStage2(), 0.0, 16000);
// U4 回退档同采样面 0.0/16000仅模型名换跨家generateNode 主耗尽重试后再试一档
OpenAiChatModel codeFallbackModel = model(api, models.codeFallback(), 0.0, 16000);
OpenAiChatModel fixFallbackModel = model(api, models.fixFallback(), 0.0, 16000);
OpenAiChatModel playerTextModel = model(api, models.playerText(), 0.3, 4000);
OpenAiChatModel playerVisionModel = model(api, models.playerVision(), 0.3, 900);
ChatModel codeFallbackModel = mf.create(models.codeFallback(), 0.0, 16000);
ChatModel fixFallbackModel = mf.create(models.fixFallback(), 0.0, 16000);
ChatModel playerTextModel = mf.create(models.playerText(), 0.3, 4000);
ChatModel playerVisionModel = mf.create(models.playerVision(), 0.3, 900);
// 固定架构新增两角色B2classify物理优先分类温度低求稳narrative-reviewer叙事审查温度低
OpenAiChatModel classifyModel = model(api, models.classify(), 0.0, 1000);
OpenAiChatModel narrativeModel = model(api, models.narrative(), 0.3, 2000);
ChatModel classifyModel = mf.create(models.classify(), 0.0, 1000);
ChatModel narrativeModel = mf.create(models.narrative(), 0.3, 2000);
// == state schema key ReplaceStrategy节点 return 覆盖写对齐 dossier §1-22==
KeyStrategyFactory keyFactory = () -> {
@ -408,6 +514,11 @@ public final class SaaStudioGraph {
* 组装并编译全图plan 2026-06-18-001 U3 全量重载显式 {@code sourceMode}=factory|gamedef
* 布线唯一源 = {@link #assemble}所有 build 重载共用
*
* <p><b>本重载 = OpenAI 兼容路</b>默认行为字节不变内部 delegate {@link #build(RoleModelFactory, Models, Path,
* int, int, int, String, SaverConfig, ObservationRegistry)} 并传 {@link #openAiFactory(OpenAiApi)}逐字调现行
* {@link #model(OpenAiApi, String, Double, int)}运行时字节等价anthropic = dispatcher flag 改传
* {@link #anthropicFactory(String, String, int)}U1 旁挂
*
* @param sourceMode 生成产物形态factory=iife 现行 / gamedef=真结构化源dispatcher AigcExecutorProperties.saaSourceMode
*/
public static CompiledGraph build(OpenAiApi api, Models models, Path gameRuntimeRoot,
@ -415,7 +526,33 @@ public final class SaaStudioGraph {
String sourceMode,
SaverConfig saverConfig, ObservationRegistry observationRegistry)
throws GraphStateException {
StateGraph graph = assemble(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds,
// openai 工厂 delegate字节等价改造前openAiFactory 逐字调 model(api,...)
return build(openAiFactory(api), models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs,
sourceMode, saverConfig, observationRegistry);
}
/**
* 组装并编译全图Plan A/U1 协议旁挂全量重载显式 {@link RoleModelFactory}=openai 默认 / anthropic flag
* <b>布线唯一源仍是 {@link #assemble}</b>openai/anthropic 两路共用同一布线 per-role 模型实现不同杜绝 split-brain
*
* @param mf per-role 模型工厂{@link #openAiFactory} / {@link #anthropicFactory}
* @param models 角色模型名
* @param gameRuntimeRoot game-runtime
* @param maxRepairs stage1 修复轮上限
* @param maxPlayerRounds player 顾问轮上限
* @param stage2ExtraRepairs stage2 额外修复轮上限
* @param sourceMode 生成产物形态factory|gamedef
* @param saverConfig checkpoint saver 配置null=默认内存态
* @param observationRegistry 观测注册表null=不埋点
* @return 已编译图
* @throws GraphStateException 图结构非法
*/
public static CompiledGraph build(RoleModelFactory mf, Models models, Path gameRuntimeRoot,
int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs,
String sourceMode,
SaverConfig saverConfig, ObservationRegistry observationRegistry)
throws GraphStateException {
StateGraph graph = assemble(mf, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds,
stage2ExtraRepairs, sourceMode);
CompileConfig.Builder cc = CompileConfig.builder()

View File

@ -6,11 +6,13 @@ import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.core.JsonPointer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.core.io.ByteArrayResource;
@ -231,12 +233,78 @@ final class SaaStudioNodes {
return false;
}
/** Anthropic thinking 块的在场标志键(实测 1.1.2thinking Generation 的 {@code AssistantMessage.getMetadata()} 含 {@code signature} 键)。 */
private static final String ANTHROPIC_THINKING_SIGNATURE_KEY = "signature";
/**
* {@link ChatResponse} Generation 中取答案文本Plan A/U1用对 M3<b>纯函数无网络可单测</b>
*
* <p><b>背景lili-mac spike 1.1.2 实测</b> Anthropic Messages 协议 + thinking ENABLED M3单次阻塞
* {@code .call()} 回来的 {@link ChatResponse#getResults()} <b> Generation</b>size2一个是 thinking 推理块
* {@code AssistantMessage.getMetadata()} {@code signature} {@code getText()}=推理文本一个是真答案
* metadata <b>不含</b> {@code signature} {@code getText()}=结构化答案若沿用旧 {@code getResult().getText()}
* 只取首个 Generation会取到 thinking 推理而非答案污染 JSON/```js 抽取
*
* <p><b>识别答案靠 {@code signature} 键的<u>存在性</u>不靠 {@code "thinking"} </b>实测 metadata 只见
* {@code signature} {@code thinking} 从尾向前找第一个 metadata <b>不含 signature</b> Generation 取其文本
* 末个答案优先全是 thinking异常则兜底取末个 Generation
*
* <p><b>回归保证命门</b>OpenAI 兼容路 {@code getResults().size()==1} 直接取 {@code gens.get(0).getText()}
* <b>与旧 {@code getResult().getOutput().getText()} 字节等价</b>{@code getResult()} 即返首个 Generation
* design/classify/nreview/player 等任一走单 Generation 的路<b>行为完全不变</b>
*
* @param resp 模型阻塞调用响应OpenAI Generation / Anthropic+thinking Generation
* @return 答案文本空响应返 ""绝不抛交由调用方按节点语义兜底
*/
static String pickAnswerText(ChatResponse resp) {
if (resp == null) {
return "";
}
List<Generation> gens = resp.getResults();
if (gens == null || gens.isEmpty()) {
return "";
}
// GenerationOpenAI 兼容路 / Anthropic thinking 等价旧 getResult().getText()回归保证
if (gens.size() == 1) {
return textOf(gens.get(0));
}
// GenerationAnthropic+thinking从尾向前找第一个 metadata 不含 signature Generation=答案 thinking 推理
for (int i = gens.size() - 1; i >= 0; i--) {
Generation g = gens.get(i);
if (!hasThinkingSignature(g)) {
return textOf(g);
}
}
// 兜底全是 thinking 异常态取末个 Generation 文本best-effort不抛
return textOf(gens.get(gens.size() - 1));
}
/** 取 Generation 的输出文本空安全null 归 "")。 */
private static String textOf(Generation g) {
if (g == null || g.getOutput() == null) {
return "";
}
String t = g.getOutput().getText();
return t == null ? "" : t;
}
/** 判该 Generation 是否为 Anthropic thinking 推理块({@code AssistantMessage.getMetadata()} 含 {@code signature} 键)。 */
private static boolean hasThinkingSignature(Generation g) {
if (g == null || g.getOutput() == null) {
return false;
}
AssistantMessage out = g.getOutput();
Map<String, Object> meta = out.getMetadata();
return meta != null && meta.containsKey(ANTHROPIC_THINKING_SIGNATURE_KEY);
}
/** 调模型并把 usage 累计回 state对齐 RecordingChatModel含单次 180s 超时 + ≤3 重试。返回 [文本, inDelta, outDelta]。 */
private static Object[] callAndRecord(ChatModel model, String system, String user) throws Exception {
return callWithTimeoutAndRetry(() -> {
Prompt prompt = new Prompt(List.of(new SystemMessage(system), new UserMessage(user)));
ChatResponse resp = model.call(prompt);
String text = resp.getResult().getOutput().getText();
// U1 Generation 重组取答案Anthropic+thinking 路答案在 signature GenerationOpenAI Generation 字节等价旧行为
String text = pickAnswerText(resp);
long in = 0, out = 0;
Usage u = resp.getMetadata().getUsage();
if (u != null) {
@ -695,7 +763,8 @@ final class SaaStudioNodes {
Prompt prompt = new Prompt(List.of(new SystemMessage(SaaPrompts.playerSystem(persona)), um));
return callWithTimeoutAndRetry(() -> {
ChatResponse resp = visionModel.call(prompt);
String text = resp.getResult().getOutput().getText();
// U1 Generation 重组取答案 callAndRecord视觉位走 Anthropic+thinking 时答案在 signature Generation
String text = pickAnswerText(resp);
long in = 0, out = 0;
Usage u = resp.getMetadata().getUsage();
if (u != null) {

View File

@ -197,6 +197,33 @@ public class AigcExecutorProperties {
*/
private String saaSourceMode = "factory";
// ===== SAA 模型协议Plan A · U1用对 M3flag 旁挂默认 openai 字节零变=====
/**
* SAA 图角色模型走的上游协议Plan A/U1plan 2026-06-19-002{@code openai}=现行 OpenAI 兼容口径
* {@code OpenAiApi}+{@code OpenAiChatModel} new-api {@code /v1/chat/completions}<b>默认·行为字节不变</b>
* {@code anthropic}= new-api Anthropic Messages 协议{@code AnthropicChatModel}{@code /v1/messages}thinking 原生分离
* M3OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出根因根因 v3用对 M3
* <b> dispatcher=saa 生效</b>openai dispatcher 不构造 Anthropic 客户端U1 旁挂零回归
*/
private String saaModelProtocol = "openai";
/**
* SAA Anthropic 协议 baseUrlsaaModelProtocol=anthropic 专用 <b>new-api host </b>
* {@code http://100.64.0.8:3000}{@code AnthropicApi} 自拼 completionsPath 默认 {@code /v1/messages} 真实打
* {@code .../v1/messages}<b>绝不加 {@code /anthropic} 前缀</b>lili-mac spike 1.1.2 实测{@code /anthropic/v1/messages}
* 命中 new-api SPA catch-all HTMLSpring AI 解析失败假挂起<b>亦不复用 OpenAI 路的 stripV1Suffix</b>
* 那是 OpenAI 兼容口径的坑 Anthropic 协议无关内网地址按项目规则可入库
*/
private String saaAnthropicBase = "http://100.64.0.8:3000";
/**
* SAA Anthropic 协议 thinking 预算 tokenssaaModelProtocol=anthropic 专用 thinking 时的 budget_tokens
* <b>Anthropic 协议硬约束 {@code budget_tokens < max_tokens}</b>client fail-fast 断言勿信网关宽松否则重演原截断 bug
* 默认 8192< code/fix 角色 maxTokens=16000openai 协议下本字段不读
*/
private Integer saaThinkingBudget = 8192;
/**
* 阈值关系铁律校验HJ-AGENT-LOOP-EXEC-002 §5.2 精确式执行器装配时调用误配置 fail-fast
*

View File

@ -0,0 +1,100 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import org.junit.jupiter.api.Test;
import org.springframework.ai.anthropic.AnthropicChatModel;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* SaaAnthropicAssembleTest Plan A/U1 <b>P0-2 专项命门</b><b>模型无关单测</b>纯逻辑<b>无网络/ key</b>普通 {@code mvn test} 即跑
*
* <p><b>验证对象</b>{@link SaaStudioGraph#anthropicModel} <b>per-role thinking budget 自适应</b>替原 fail-fast
* 对抗验证抓到的 P0-2assemble 给判定类小角色硬编码小 {@code maxTokens}{@code playerVision=900 / classify=1000 /
* narrative=2000 / playerText=4000}而单一全局 {@code thinkingBudget}默认 8192{@code AigcExecutorProperties.saaThinkingBudget}
* 撞之<b>修前</b> {@code anthropicModel} {@code budget >= maxTokens} 直接 {@code throw IllegalArgumentException}
* anthropic {@link SaaStudioGraph#build(SaaStudioGraph.RoleModelFactory, SaaStudioGraph.Models, Path, int, int, int, String,
* com.alibaba.cloud.ai.graph.checkpoint.config.SaverConfig, io.micrometer.observation.ObservationRegistry) assemble} 内对小角色抛
* job failed<b>修后</b>{@code effectiveBudget = min(全局, maxTokens-1024)} {@code < maxTokens}守协议约束
* 容不下 Anthropic 最小 thinking(1024) 的小判定角色不开 thinking<b>全程不抛</b>
*
* <p><b>安全性</b>{@link SaaStudioGraph#anthropicModel} / {@link SaaStudioGraph#anthropicFactory} 只构造
* {@code AnthropicChatModel} client 对象 {@code AnthropicApi} + {@code defaultOptions}<b>绝不调网关</b> {@code .call()}
* 节点工厂{@code designNode/generateNode/...}<b></b> {@code gameRuntimeRoot} 句柄路径在运行时才 resolve
* dummy key + dummy 路径构造全图安全不触网/不触盘这把per-role budget clamp 不抛与真模型/真网关解耦坐实
*
* @author 造梦AIPlan A · U1 · P0-2 专项
*/
class SaaAnthropicAssembleTest {
/** dummy Anthropic 协议 baseUrlhost 根口径;只构造 client绝不真打。 */
private static final String DUMMY_BASE = "http://100.64.0.8:3000";
/** dummy key绝不真鉴权anthropicModel 只构造 client不发请求。 */
private static final String DUMMY_KEY = "dummy-key";
/** 默认全局 thinking budget= AigcExecutorProperties.saaThinkingBudget 默认 8192即撞小角色 maxTokens 的那个值。 */
private static final int THINKING_BUDGET = 8192;
/**
* P0-2 核心命门 {@link SaaStudioGraph#anthropicFactory} 构造<b>全图</b>触发 {@code assemble} 内全 11 角色
* {@code mf.create(...)} maxTokens 极小的 playerVision=900 / classify=1000 / narrative=2000 / playerText=4000
* <b>断不抛任何异常</b>修前小角色必抛 {@code IllegalArgumentException}budget 8192 >= maxTokens修后 clamp 不抛
*/
@Test
void anthropic_full_graph_assembles_without_throwing_for_small_maxtokens_roles() {
// 全局 8192 budget anthropic per-role 工厂撞小角色 maxTokens 的那把刀
SaaStudioGraph.RoleModelFactory anthropicFactory =
SaaStudioGraph.anthropicFactory(DUMMY_BASE, DUMMY_KEY, THINKING_BUDGET);
// dummy game-runtime 路径节点工厂只存句柄不在构造期 resolve/读盘运行时才用故无需真实存在
Path dummyRuntime = Paths.get("/tmp/saa-p0-2-dummy-runtime");
// assemble build 内同步构造全 11 角色 ChatModeldesignNode/generateNode/.../playerNode/nreviewNode/classifyNode
// 修前playerVision(900)/classify(1000)/narrative(2000) 等小 maxTokens 角色 anthropicModel IllegalArgumentException
// assemble 整图构造失败修后 per-role clamp budget<maxTokens 或关 thinking全程不抛
// factory 形态现行默认 sourceMode
CompiledGraph factoryGraph = assertDoesNotThrow(() -> SaaStudioGraph.build(
anthropicFactory, SaaStudioGraph.Models.stage1(), dummyRuntime,
/*maxRepairs*/ 5, /*maxPlayerRounds*/ 1, /*stage2ExtraRepairs*/ 3,
/*sourceMode*/ "factory", /*saverConfig*/ null, /*observationRegistry*/ null),
"P0-2 回归anthropic 工厂构造全图(factory 形态)不得抛——小判定角色 maxTokens<budget 时应 clamp/关 thinking而非 fail-fast 抛");
assertNotNull(factoryGraph, "factory 形态全图应编译成功");
// gamedef 形态真结构化源路同走 assemble 11 角色构造一并验 P0-2 修复对两形态都成立
CompiledGraph gamedefGraph = assertDoesNotThrow(() -> SaaStudioGraph.build(
anthropicFactory, SaaStudioGraph.Models.stage1(), dummyRuntime,
5, 1, 3, "gamedef", null, null),
"P0-2 回归anthropic 工厂构造全图(gamedef 形态)同样不得抛");
assertNotNull(gamedefGraph, "gamedef 形态全图应编译成功");
}
/**
* P0-2 直证最精准直接对 assemble 里硬编码小 maxTokens <b>各判定角色</b>逐一调
* {@link SaaStudioGraph#anthropicModel}全局 budget=8192<b>断每个都不抛</b>且产出非空 client
* 修前每一个都必抛 {@code IllegalArgumentException}budget 8192 >= maxTokens of 900/1000/2000/4000
*/
@Test
void anthropic_model_clamps_budget_for_each_small_role_without_throwing() {
// assemble 里各角色硬编码 maxTokens逐字对照 SaaStudioGraph.assemble
// playerVision=900classify=1000narrative=2000playerText=4000 < 全局 budget 8192修前必抛
int[] smallMaxTokens = {900, 1000, 2000, 4000};
String[] roleHint = {"playerVision(900)", "classify(1000)", "narrative(2000)", "playerText(4000)"};
for (int i = 0; i < smallMaxTokens.length; i++) {
final int maxTokens = smallMaxTokens[i];
final String hint = roleHint[i];
AnthropicChatModel m = assertDoesNotThrow(() -> SaaStudioGraph.anthropicModel(
DUMMY_BASE, DUMMY_KEY, "MiniMax-M3", /*temperature*/ 0.3, maxTokens, THINKING_BUDGET),
"P0-2小角色 " + hint + " 全局 budget=" + THINKING_BUDGET
+ " 撞 maxTokens 应 clamp/关 thinking 不抛(修前抛 IllegalArgumentException");
assertNotNull(m, "小角色 " + hint + " 应构造出非空 AnthropicChatModel client");
}
// 生成角色maxTokens=16000 > budget 8192本就合法clamp budget=min(8192,16000-1024)=8192 仍开 thinking不抛
AnthropicChatModel gen = assertDoesNotThrow(() -> SaaStudioGraph.anthropicModel(
DUMMY_BASE, DUMMY_KEY, "deepseek-v4-flash", 0.0, 16000, THINKING_BUDGET),
"生成角色(maxTokens=16000)应正常构造(clamp 后 budget=8192<16000thinking 开)");
assertNotNull(gen, "生成角色应构造出非空 client");
}
}

View File

@ -0,0 +1,146 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* SaaAnthropicSmokeTest Plan A / U1 Anthropic 协议 M3 client <b>正式门控集成烟测</b>用对 M3根因HJ-AGI / plan003
*
* <p><b>验证对象</b>{@link SaaStudioGraph#anthropicModel} 构造的 client new-api Anthropic Messages 协议调 MiniMax-M3+
* {@link SaaStudioNodes#pickAnswerText(ChatResponse)} Generation 重组取答案<b>真打网关</b>gated默认跳过 NEWAPI_KEY + 可达 new-api
*
* <p><b>实测定死配方lili-mac 真跑 spring-ai-anthropic 1.1.2 实证本测试照此构造务必勿改</b>
* <ol>
* <li><b>baseUrl = new-api host </b> {@code http://100.64.0.8:3000}completionsPath 保持默认 {@code /v1/messages}
* 真实打 {@code .../v1/messages}<b>绝不加 {@code /anthropic} 前缀</b>{@code /anthropic/v1/messages} 命中
* new-api SPA catch-all <b>HTTP 200 + index.html</b>Spring AI 解析 JSON 失败后落默认 RetryTemplate 指数退避
* 表象call() 永久挂起<b>勿复用 OpenAI 路的 stripV1Suffix</b>那是 OpenAI 兼容口径的坑 Anthropic 协议无关</li>
* <li><b>鉴权 = {@code apiKey(NEWAPI_KEY)} {@code x-api-key} </b>实测<b>x-api-key {@code getResults().size()==2}
* 原生 thinking block</b>gen#0 {@code AssistantMessage.getMetadata()} {@code signature} =推理 / gen#1 signature=答案
* <b>Authorization Bearer {@code size==1} 字面 {@code <think>} 内联</b>污染结构化输出正是要躲的坑 client x-api-key本测试只验这一路</li>
* <li><b>1.1.2 {@code AnthropicChatModel$Builder} 没有 customHeaders</b>自定义头唯一干净入口 = {@code AnthropicApi.builder()
* .restClientBuilder(...)}{@link SaaStudioGraph#anthropicModel} 经此注入 NO_PROXY+超时的 RestClient</li>
* <li><b>{@code budget < maxTokens} 硬约束</b>Anthropic 协议client fail-fast 断言勿信网关宽松否则重演原截断 bug</li>
* <li><b>{@code ChatResponse.getResults() -> List<Generation>}必须遍历 getResults()不能只 getResult()</b>thinking ENABLED
* 下取真答案 = {@link SaaStudioNodes#pickAnswerText}从尾向前找第一个 metadata 不含 signature Generation</li>
* </ol>
*
* <p><b>纪律</b> 门控 {@code -Danthropic.smoke=1}无开关/ key 时跳过不算 fail 密钥经环境变量 {@code NEWAPI_KEY}
* 绝不入库/打印/提交 断言 client 配方真生效 + pickAnswerText 取到答案
*
* <p><b>运行lili-mac game-cloud reactor NEWAPI_KEY env绝不打印</b>
* <pre>
* export NEWAPI_KEY=$(grep -oE 'sk-[A-Za-z0-9_-]{20,}' docs/内网凭据与端点.md | head -1)
* mvn -pl game-module-aigc/game-module-aigc-server -am \
* -Dtest=SaaAnthropicSmokeTest -Danthropic.smoke=1 -DfailIfNoTests=false \
* -DargLine='-Dhttp.nonProxyHosts=100.64.0.8|localhost|127.0.0.1 -Dhttps.nonProxyHosts=100.64.0.8|localhost|127.0.0.1' \
* test
* </pre>
* <b>实测坑必带 -DargLine 那行</b>macOS 系统代理 = clash 127.0.0.1:7897surefire fork JVM 自动继承 Tailscale IP
* 100.64.0.8 clash 代理 <b>502 TransientAiException</b>{@link SaaStudioGraph#anthropicModel} 内已设
* {@code SimpleClientHttpRequestFactory.setProxy(NO_PROXY)} 旁路{@code -DargLine} nonProxyHosts 是双保险
*
* @author 造梦AIPlan A · U1 Anthropic client 门控烟测
*/
class SaaAnthropicSmokeTest {
/** new-api Anthropic 协议 baseUrl = <b>host 根</b>completionsPath 默认 /v1/messages → 真实打 .../v1/messages绝不加 /anthropic。 */
private static final String ANTHROPIC_BASE_URL =
System.getenv().getOrDefault("NEWAPI_BASE_URL_RAW", "http://100.64.0.8:3000");
/** 目标模型(经 new-api 转发到 MiniMax-M3走 Anthropic Messages 协议)。 */
private static final String MODEL = "MiniMax-M3";
/** 省钱预算maxTokens 给上限budget 必须 < maxTokensAnthropic 协议约束client 侧 fail-fast 断言)。 */
private static final int MAX_TOKENS = 4096;
private static final int THINKING_BUDGET = 2048;
/** thinking 块在场标志键(与 {@link SaaStudioNodes} 内常量同口径)。 */
private static final String SIGNATURE_KEY = "signature";
@Test
@EnabledIfSystemProperty(named = "anthropic.smoke", matches = "1",
disabledReason = "Plan A U1 Anthropic client 门控烟测:需 NEWAPI_KEY + 可达 new-api host 根 /v1/messages开关 -Danthropic.smoke=1")
void anthropic_client_recipe_and_pick_answer_text() {
String key = System.getenv("NEWAPI_KEY");
assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)");
assertTrue(!key.isBlank(), "NEWAPI_KEY 为空");
// 用生产同款工厂构造 client定死配方host baseUrl + x-api-key + thinking ENABLED + NO_PROXYbudget<maxTokens fail-fast
AnthropicChatModel model = SaaStudioGraph.anthropicModel(
ANTHROPIC_BASE_URL, key, MODEL, /*temperature*/ 0.3, MAX_TOKENS, THINKING_BUDGET);
// 简单 prompt要点推理诱发 thinking但答案短省钱
Prompt prompt = new Prompt("用一句话回答9.11 和 9.8 哪个大?先简要想一下再给结论。");
System.out.println("================ Plan A U1 Anthropic client 烟测证据 ================");
System.out.println("[base] " + ANTHROPIC_BASE_URL + " (completionsPath 默认 /v1/messages → 真实 .../v1/messages无 /anthropic)");
System.out.println("[model] " + MODEL + " thinking=ENABLED budget=" + THINKING_BUDGET + " maxTokens=" + MAX_TOKENS + " 鉴权=x-api-key");
// 阻塞 .call()保留默认 RetryTemplate流式 thinking bug #4407 不用 stream
ChatResponse resp = model.call(prompt);
// 响应形证据必须遍历 getResults()不止 getResult()
List<Generation> gens = resp.getResults();
int genCount = gens == null ? 0 : gens.size();
System.out.println("[generationCount] resp.getResults().size() = " + genCount);
boolean sawThinkingSignature = false;
for (int i = 0; i < genCount; i++) {
Generation g = gens.get(i);
AssistantMessage out = g.getOutput();
String text = (out == null || out.getText() == null) ? "" : out.getText();
String head = text.length() > 120 ? text.substring(0, 120).replace("\n", "\\n") : text.replace("\n", "\\n");
Map<String, Object> meta = (out == null) ? null : out.getMetadata();
boolean hasSig = meta != null && meta.containsKey(SIGNATURE_KEY);
sawThinkingSignature |= hasSig;
System.out.println(" [gen#" + i + "] textLen=" + text.length()
+ " hasSignature=" + hasSig + " metaKeys=" + (meta == null ? "(null)" : meta.keySet()));
System.out.println(" textHead120=\"" + head + "\"");
// 内联 <think> 探测x-api-key 路不应出现若出现则网关方言变了需复核配方
if (text.contains("<think>")) {
System.out.println(" >> [警示] text 含字面 <think>thinking 内联进正文,非原生 block——配方可能失效请复核");
}
}
// usage成本证据
try {
var u = resp.getMetadata() == null ? null : resp.getMetadata().getUsage();
if (u != null) {
System.out.println("[usage] prompt=" + u.getPromptTokens()
+ " completion=" + u.getCompletionTokens() + " total=" + u.getTotalTokens());
}
} catch (Exception ignore) {
// usage 取不到不连坐证据打印
}
// pickAnswerText 取答案生产同款重组
String answer = SaaStudioNodes.pickAnswerText(resp);
System.out.println("[pickAnswerText] len=" + answer.length()
+ " head120=\"" + (answer.length() > 120 ? answer.substring(0, 120).replace("\n", "\\n") : answer.replace("\n", "\\n")) + "\"");
System.out.println("==================================================================");
// 断言坐实配方真生效
// Generation x-api-key + thinking ENABLED 实测 size2thinking + 答案
assertTrue(genCount >= 2,
"x-api-key + thinking ENABLED 应返 ≥2 Generationthinking + 答案);实得 " + genCount
+ "——可能:端点路径错(应 host 根 /v1/messages,非 /anthropic) / 网关未开 Claude 兼容路 / 鉴权方言变 / 代理坑");
// 存在含 signature thinking 推理块原生 thinking 分离非内联 <think>
assertTrue(sawThinkingSignature,
"应存在含 signature 键的 thinking Generation原生 thinking block 分离);缺则 thinking 未原生分离(配方失效)");
// pickAnswerText 取到 signature 的答案 Generation且非空生产重组语义真生效
assertFalse(answer.isBlank(), "pickAnswerText 应取到非空答案文本(无 signature 的答案 Generation");
}
}

View File

@ -0,0 +1,89 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* SaaPickAnswerTextTest {@link SaaStudioNodes#pickAnswerText(ChatResponse)} <b>模型无关单测</b>Plan A/U1
*
* <p>纯逻辑<b>无网络</b>构造假 {@link ChatResponse}普通 {@code mvn test} 即跑覆盖两形
* <ol>
* <li><b>OpenAI 兼容路</b> Generation直接取 gen0<b>字节等价旧 {@code getResult().getText()} 行为</b>回归保证</li>
* <li><b>Anthropic+thinking </b> Generation末个 metadata <b>不含 signature</b> Generation=答案
* 跳过含 signature thinking 推理块实测识别靠 {@code signature} 键存在性不靠 {@code "thinking"} </li>
* </ol>
* 这把取答案逻辑与底层协议OpenAI/Anthropic解耦验证无需真模型/真网关即可坐实 U1 重组语义正确 + 回归不破
*
* @author 造梦AIPlan A · U1
*/
class SaaPickAnswerTextTest {
/** thinking 块在场标志键(与 {@link SaaStudioNodes} 内常量同口径Anthropic thinking Generation 的 metadata 含此键)。 */
private static final String SIGNATURE_KEY = "signature";
/** 建一条「无 metadata signature」的 Generation=答案 Generation / 或 OpenAI 单 Generation。 */
private static Generation answerGen(String text) {
return new Generation(new AssistantMessage(text));
}
/** 建一条「含 metadata signature」的 Generation=Anthropic thinking 推理块)。 */
private static Generation thinkingGen(String text) {
AssistantMessage msg = AssistantMessage.builder()
.content(text)
.properties(Map.of("messageType", "ASSISTANT", SIGNATURE_KEY, "sig-abc123"))
.build();
return new Generation(msg);
}
@Test
void openai_single_generation_is_byte_equivalent_to_old_getResult() {
// OpenAI 兼容路 Generation 直接取 gen0等价旧 getResult().getOutput().getText()
ChatResponse resp = new ChatResponse(List.of(answerGen("{\"entities\":[]}")));
assertEquals("{\"entities\":[]}", SaaStudioNodes.pickAnswerText(resp),
"单 Generation 必须字节等价旧 getResult().getText()(回归命门)");
}
@Test
void anthropic_thinking_picks_the_no_signature_answer_generation() {
// Anthropic+thinkinggen#0=thinking signature是推理gen#1=答案 signature
ChatResponse resp = new ChatResponse(List.of(
thinkingGen("让我想一想:先比较小数部分……"),
answerGen("```js\nexport default function(){}\n```")));
String picked = SaaStudioNodes.pickAnswerText(resp);
assertEquals("```js\nexport default function(){}\n```", picked,
"多 Generation 须取无 signature 的答案 Generation而非含 signature 的 thinking 推理块");
assertTrue(!picked.contains("让我想一想"), "绝不能取到 thinking 推理文本(那会污染 ```js/JSON 抽取)");
}
@Test
void anthropic_picks_last_no_signature_when_multiple_answers() {
// 多个无 signature 极端取末个末答案优先对齐从尾向前找第一个无 signature
ChatResponse resp = new ChatResponse(List.of(
thinkingGen("推理…"),
answerGen("早期答案"),
answerGen("最终答案")));
assertEquals("最终答案", SaaStudioNodes.pickAnswerText(resp), "应取末个无 signature 的答案 Generation");
}
@Test
void all_thinking_falls_back_to_last_generation_without_throwing() {
// 兜底异常态全是 thinking 取末个 Generation绝不抛
ChatResponse resp = new ChatResponse(List.of(thinkingGen("推理A"), thinkingGen("推理B")));
assertEquals("推理B", SaaStudioNodes.pickAnswerText(resp), "全 thinking 时兜底取末个best-effort 不抛)");
}
@Test
void empty_or_null_returns_empty_string() {
// 空响应/ Generation 列表 ""交调用方按节点语义兜底绝不抛/NPE
assertEquals("", SaaStudioNodes.pickAnswerText(null), "null 响应返空串");
assertEquals("", SaaStudioNodes.pickAnswerText(new ChatResponse(List.of())), "空 Generation 列表返空串");
}
}