无图背景色决策改走独立文本档 gpt-5-mini

无源图路径此前继承 state.llm_client()(Ark/豆包),选色能力弱、且线上从未
调用过 VectorEngine。新增独立常量 EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL,
让无图决策也走 VectorEngine gpt-5-mini(Responses 协议 + reasoning=low),与
有图视觉档区分、便于各自调参;gpt5 客户端未配置时才降级回默认文本客户端。
附两个 #[ignore] 真机联调测试,验证有图/无图两条路径真实调用 VectorEngine。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:01:57 +00:00
parent d125f9ca54
commit 899687e6e0
2 changed files with 163 additions and 18 deletions
@@ -94,25 +94,28 @@ pub(crate) async fn resolve_editor_screen_background_color(
.map(|report| report.allowed.clone())
.unwrap_or_else(|| EDITOR_SCREEN_BACKGROUND_COLORS.to_vec());
// 默认 llm_client 是纯文本模型(Ark),收到图片分片会被上游 400 拒绝;
// 带图决策必须走 VectorEngine 视觉客户端,没有视觉客户端时降级为纯文本决策。
let (llm_client, vision_model) = if has_source_image {
match vision_llm_client {
Some(client) => (
Some(client),
Some(crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL),
),
None => {
// 决策统一走 VectorEngine gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
// 都不继承 Ark 默认文本模型(豆包,选色能力弱)。仅当 gpt5 客户端未配置时才降级回默认
// llm_client;该默认客户端是纯文本模型(Ark),收到图片分片会被上游 400 拒绝,故先丢弃图片分片。
let (llm_client, decision_model) = match vision_llm_client {
Some(client) if has_source_image => (
Some(client),
Some(crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL),
),
Some(client) => (
Some(client),
Some(crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL),
),
None => {
if has_source_image {
warn!(
kind = input.kind.label(),
"editor_screen_background_vision_client_missing_downgrade_to_text"
);
input.source_image_data_url = None;
(llm_client, None)
}
(llm_client, None)
}
} else {
(llm_client, None)
};
// 默认兜底色被过滤掉时,改用危险度最小的候选兜底。
@@ -152,15 +155,15 @@ pub(crate) async fn resolve_editor_screen_background_color(
None => LlmMessage::user(user_prompt.as_str()),
};
// 预算要够推理模型(如 gpt-5-mini)先花几百 token 推理、再吐 JSON 答案;
// 实测 low 档推理约 320~384 token,取 1024 留足余量。非推理模型遇 stop 提前结束,不会多花。
// 实测 low 档推理约 320~384 token,取 1024 留足余量。降级客户端遇 stop 提前结束,不会多花。
let mut request = LlmTextRequest::new(vec![LlmMessage::system(system_prompt), user_message])
.with_max_tokens(1024)
.with_request_timeout_ms(EDITOR_SCREEN_BACKGROUND_DECISION_TIMEOUT_MS);
if let Some(vision_model) = vision_model {
// 视觉模型 gpt-5-mini 是推理模型:走 Responses 协议并压到 low 推理档,
// 否则默认档会把预算全烧在推理上、返回空答案。
if let Some(decision_model) = decision_model {
// gpt-5-mini 是推理模型(有图视觉档 / 无图文本档均适用):走 Responses 协议并压到 low
// 推理档,否则默认档会把预算全烧在推理上、返回空答案。
request = request
.with_model(vision_model)
.with_model(decision_model)
.with_responses_api()
.with_response_reasoning_effort(LlmResponseReasoningEffort::Low);
}
@@ -526,4 +529,143 @@ mod tests {
"默认兜底色与前景撞色时应改用危险度最小的候选"
);
}
// 真机联调:按 build_creative_agent_gpt5_client 的方式组 VectorEngine 客户端,直接跑
// resolve_editor_screen_background_color 的完整代码路径(无图文本档 + 有图视觉档),
// 验证决策请求真的打到 VectorEngine 并被解析成候选色(decision.fallback == false)。
// 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 VECTOR_ENGINE_* 才有意义。
// 运行:cargo test -p api-server --manifest-path server-rs/Cargo.toml \
// editor_screen_background_decision::tests::live -- --ignored --nocapture
fn read_live_env(key: &str) -> Option<String> {
use std::collections::BTreeMap;
use std::path::PathBuf;
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("..");
let mut map: BTreeMap<String, String> = BTreeMap::new();
for name in [".env.local", ".env.secrets.local", ".env"] {
let path = repo_root.join(name);
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || !trimmed.contains('=') {
continue;
}
let (k, v) = trimmed.split_once('=').unwrap();
let v = v.trim().trim_matches('"').trim_matches('\'');
// 先出现的文件优先(.env.local > .env.secrets.local > .env),与服务端 dotenv 顺序一致。
map.entry(k.trim().to_string()).or_insert_with(|| v.to_string());
}
}
std::env::var(key).ok().or_else(|| map.get(key).cloned())
}
fn build_live_vector_engine_client() -> Option<LlmClient> {
use platform_llm::{LlmConfig, LlmProvider};
let base_url = read_live_env("VECTOR_ENGINE_BASE_URL")?;
let api_key = read_live_env("VECTOR_ENGINE_API_KEY")?;
// 与 state.rs build_creative_agent_gpt5_client 一致:规整到以 /v1 结尾。
let base_url = if base_url.trim_end_matches('/').ends_with("/v1") {
base_url.trim_end_matches('/').to_string()
} else {
format!("{}/v1", base_url.trim_end_matches('/'))
};
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
base_url,
api_key,
crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL.to_string(),
60_000,
0,
500,
)
.expect("live VectorEngine LlmConfig should build");
Some(LlmClient::new(config).expect("live VectorEngine LlmClient should build"))
}
fn solid_source_image_data_url() -> String {
use base64::Engine as _;
use image::{Rgba, RgbaImage};
let image = RgbaImage::from_pixel(64, 64, Rgba([120, 180, 120, 255]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image)
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
.expect("test image should encode");
format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&bytes)
)
}
#[tokio::test]
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
async fn live_screen_background_decision_hits_vector_engine_without_image() {
let Some(client) = build_live_vector_engine_client() else {
panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
};
let decision = resolve_editor_screen_background_color(
None,
Some(&client),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::Character,
screen_color: Some("auto".to_string()),
prompt: "赛博朋克风格的机械猫,霓虹蓝紫主体".to_string(),
icon_descriptions: Vec::new(),
reference_count: 1,
source_image_data_url: None,
},
)
.await
.expect("live 无图决策应成功");
eprintln!(
"[live 无图] mode={:?} hex={} label={} attempts={} fallback={}",
decision.mode, decision.color.hex, decision.color.label, decision.attempts, decision.fallback
);
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
assert!(
!decision.fallback,
"若走到兜底说明 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
);
assert!(decision.attempts >= 1);
}
#[tokio::test]
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
async fn live_screen_background_decision_hits_vector_engine_with_image() {
let Some(client) = build_live_vector_engine_client() else {
panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
};
let decision = resolve_editor_screen_background_color(
None,
Some(&client),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::CharacterAnimation,
screen_color: Some("auto".to_string()),
prompt: "角色起跳动作".to_string(),
icon_descriptions: Vec::new(),
reference_count: 1,
source_image_data_url: Some(solid_source_image_data_url()),
},
)
.await
.expect("live 有图决策应成功");
eprintln!(
"[live 有图] mode={:?} hex={} label={} attempts={} fallback={}",
decision.mode, decision.color.hex, decision.color.label, decision.attempts, decision.fallback
);
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
assert!(
!decision.fallback,
"若走到兜底说明视觉 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
);
assert!(decision.attempts >= 1);
}
}
@@ -1,6 +1,9 @@
pub(crate) const RPG_STORY_LLM_MODEL: &str = "doubao-seed-character-251128";
pub(crate) const CREATION_TEMPLATE_LLM_MODEL: &str = "deepseek-v3-2-251201";
pub(crate) const PUZZLE_LEVEL_NAME_VISION_LLM_MODEL: &str = "gpt-4o-mini";
// 抠图背景色决策的视觉模型。gpt-5-mini 是推理模型,会先花若干 token 做推理,
// 抠图背景色决策的视觉模型(有源图)。gpt-5-mini 是推理模型,会先花若干 token 做推理,
// 故决策请求的 max_tokens 需留足推理开销(见 editor_screen_background_decision)。
pub(crate) const EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL: &str = "gpt-5-mini";
// 抠图背景色决策的文本模型(无源图)。独立于视觉档,便于两条路径分别调参;
// 硬编码而非继承 Ark 默认文本模型(豆包,选色能力弱)。同为 gpt-5-mini 推理模型,同样留足推理预算。
pub(crate) const EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL: &str = "gpt-5-mini";