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:
parent
b6cadd8394
commit
19f666cd5d
@ -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>
|
||||
|
||||
@ -312,22 +312,39 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
|
||||
}
|
||||
synchronized (this) {
|
||||
if (compiledGraph == null) {
|
||||
// new-api 上游:baseUrl 取 host 根(剥末尾 /v1,坑见 §7-1);apiKey 取执行器配置(密钥经环境变量注入)。
|
||||
String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase());
|
||||
OpenAiApi api = OpenAiApi.builder().baseUrl(baseUrl).apiKey(properties.getApiKey()).build();
|
||||
// checkpoint(step5):开关开 ∧ 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、勿用 stripV1Suffix,spike 实测);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-1);apiKey 经配置注入。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 U3:factory(默认,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");
|
||||
}
|
||||
|
||||
@ -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「用对 M3」flag 旁挂的缝):把「角色名+采样面 → {@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 最大补全 tokens(per-role,如 code/fix=16000、player_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「用对 M3」;thinking 原生分离,
|
||||
* 治「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 协议 baseUrl(new-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 协议 baseUrl(host 根、无 /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) 的小判定角色则关 thinking,P0-2)
|
||||
* @return Anthropic 角色模型
|
||||
*/
|
||||
public static AnthropicChatModel anthropicModel(String anthropicBase, String apiKey, String modelName,
|
||||
Double temperature, int maxTokens, int thinkingBudget) {
|
||||
// 代理旁路 + 连接/读超时:macOS/容器系统代理(clash 等)会把 Tailscale IP 走代理→502;NO_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)
|
||||
} // else:maxTokens 太小的判定类角色不开 thinking(避免 budget≥maxTokens 崩;判定/审查无需推理)
|
||||
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)。同一 .env(NEWAPI_BASE_URL=.../v1)两侧通用须 Java 侧剥之。
|
||||
@ -201,7 +307,7 @@ public final class SaaStudioGraph {
|
||||
* <b>P0-4</b>:deterministic 路本期 fail-loud 到 END(「从 sourceProject 真构建」管线待 B7+引擎线 E2;不静默走会忽略
|
||||
* sourceProject 的旧 build);regenerate-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 工厂建 ChatModel:openai 默认路字节等价;anthropic flag 旁挂)==
|
||||
// design:Python config.build_model 不传 temperature → 服务端默认 → temperature=null(不下发);
|
||||
// code/fix:_client.chat temperature=0.0、max_tokens=16000(确定性产出);
|
||||
// player_text:studio.py:167 max_tokens=4000、temperature=0.3;
|
||||
// player_vision:studio.py:160 max_tokens=900、temperature=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);
|
||||
// 固定架构新增两角色(B2):classify(物理优先分类,温度低求稳)、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()
|
||||
|
||||
@ -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.2:thinking 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>(size≥2):一个是 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 "";
|
||||
}
|
||||
// 单 Generation(OpenAI 兼容路 / Anthropic thinking 关):等价旧 getResult().getText()(回归保证)。
|
||||
if (gens.size() == 1) {
|
||||
return textOf(gens.get(0));
|
||||
}
|
||||
// 多 Generation(Anthropic+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」的 Generation;OpenAI 单 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) {
|
||||
|
||||
@ -197,6 +197,33 @@ public class AigcExecutorProperties {
|
||||
*/
|
||||
private String saaSourceMode = "factory";
|
||||
|
||||
// ===== SAA 模型协议(Plan A · U1「用对 M3」;flag 旁挂,默认 openai 字节零变)=====
|
||||
|
||||
/**
|
||||
* SAA 图角色模型走的上游协议(Plan A/U1,plan 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 原生分离)
|
||||
* 调 M3——治「OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出」根因(根因 v3「用对 M3」)。
|
||||
* <b>仅 dispatcher=saa 生效</b>;openai 时 dispatcher 不构造 Anthropic 客户端(U1 旁挂、零回归)。
|
||||
*/
|
||||
private String saaModelProtocol = "openai";
|
||||
|
||||
/**
|
||||
* SAA Anthropic 协议 baseUrl(saaModelProtocol=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 返 HTML→Spring AI 解析失败假挂起);<b>亦不复用 OpenAI 路的 stripV1Suffix</b>
|
||||
* (那是 OpenAI 兼容口径的坑,与 Anthropic 协议无关)。内网地址按项目规则可入库。
|
||||
*/
|
||||
private String saaAnthropicBase = "http://100.64.0.8:3000";
|
||||
|
||||
/**
|
||||
* SAA Anthropic 协议 thinking 预算 tokens(saaModelProtocol=anthropic 专用):开 thinking 时的 budget_tokens。
|
||||
* <b>Anthropic 协议硬约束 {@code budget_tokens < max_tokens}</b>(client 侧 fail-fast 断言,勿信网关宽松、否则重演原截断 bug);
|
||||
* 默认 8192(< code/fix 角色 maxTokens=16000)。openai 协议下本字段不读。
|
||||
*/
|
||||
private Integer saaThinkingBudget = 8192;
|
||||
|
||||
/**
|
||||
* 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast)
|
||||
*
|
||||
|
||||
@ -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-2:assemble 给判定类小角色硬编码小 {@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 造梦AI(Plan A · U1 · P0-2 专项)
|
||||
*/
|
||||
class SaaAnthropicAssembleTest {
|
||||
|
||||
/** dummy Anthropic 协议 baseUrl(host 根口径;只构造 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 角色 ChatModel(designNode/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=900、classify=1000、narrative=2000、playerText=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<16000,thinking 开)");
|
||||
assertNotNull(gen, "生成角色应构造出非空 client");
|
||||
}
|
||||
}
|
||||
@ -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:7897,surefire 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 造梦AI(Plan 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 必须 < maxTokens(Anthropic 协议约束,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_PROXY;budget<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 实测 size≥2:thinking + 答案)。
|
||||
assertTrue(genCount >= 2,
|
||||
"x-api-key + thinking ENABLED 应返 ≥2 Generation(thinking + 答案);实得 " + 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)");
|
||||
}
|
||||
}
|
||||
@ -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 造梦AI(Plan 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+thinking:gen#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 列表返空串");
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user