优化: 缓存命中字段解析与提示词前缀稳定化

- openai 兼容口径补解析嵌套 cached_tokens
- cache 显示区分无数据与真零,估算轮不渲染
- 轨迹展开折叠空过程记录行降噪
- token 统计行恒完整可见不截断
- openai_compat 纳入 system 稳定易变拆分
- 系统警示与主题标记改尾部注入不位移前缀
- 工具结果视图压缩按入库时刻冻结
- 会话内工具集冻结仅增不减
- anthropic 缓存补消息级断点,设置页加开关
- 补 intent 测试对 Agent domain 覆盖
This commit is contained in:
lxy
2026-10-01 19:35:21 +08:00
parent 2187ffe173
commit 423acfdd00
14 changed files with 499 additions and 99 deletions
+13 -6
View File
@@ -11,7 +11,9 @@ use df_ai::provider::ChatMessage;
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::prompt::{
build_system_prompt_parts, cache_enabled, cache_split_provider_type, get_active_provider,
};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, AiSession, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
@@ -20,9 +22,14 @@ use super::conv_state::ConvState;
///
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
/// 「lock → per_conv.contains_key 守卫 → messages.insert_at(0, system) → tracing::info」。
/// 「lock → per_conv.contains_key 守卫 → messages.push(system) → tracing::info」。
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
///
/// **尾部追加而非 insert_at(0)**:历史头部插入会位移整条前缀,叠加警示文本逐条不同,
/// 自动前缀缓存(DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。警示语义都是
/// 「提示当前轮」,追加到消息流末尾对 LLM 同样可见(紧邻本轮 tool_result 之后),
/// 且不破坏既有前缀字节。文本统一不含轮次计数(同类警示文本字节稳定)。
///
/// `log_tag` 为日志正文(不带 "[ai] " 前缀,helper 统一补);各站点特有字段
/// (iteration/loop_count 等)由调用方编入 tag 文本保留定位信息,helper 统一打 conv_id 字段。
pub(crate) async fn insert_system_notice(
@@ -34,7 +41,7 @@ pub(crate) async fn insert_system_notice(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(text));
conv.messages.push(ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
@@ -121,10 +128,10 @@ pub async fn try_continue_agent_loop(
};
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id_owned = conv_id.to_string();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分,
// OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
// prompt caching 拆分模式组装(开关关 → 与旧路径逐字等价;anthropic 系显式缓存 /
// openai_compat 前缀稳定)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
let cache_on = cache_enabled(state).await
&& matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat");
&& cache_split_provider_type(&provider_config.provider_type);
let (system_prompt, base_volatile) =
build_system_prompt_parts(state, &lang, &[], &[], cache_on).await;
+83 -24
View File
@@ -680,22 +680,66 @@ pub(crate) async fn run_agentic_loop(
// 分离:即使将来 PLANNING_ENABLED 翻 true,plan_hint 内部 PLAN_HINT_ENABLED 关闭时
// filter_tool_defs_planned 仍退扁平(双层开关,任一关闭均退旧行为)。
let tool_defs: Vec<df_ai::provider::ToolDefinition> = if pure_greeting {
Vec::new() // 纯问候 → 空工具集(tools=None,强制纯文本回复)
} else if conf >= INTENT_CONF_THRESHOLD {
let filtered = if df_ai::planner::PLANNING_ENABLED {
// Phase 1:plan_hint 编排排序。intent_label 供 plan_hint 备用(当前规则纯关键词驱动)。
filter_tool_defs_planned(&all_defs, &intent, intent.as_str(), &user_text)
} else {
// 旧行为:intent 收敛扁平子集(零行为变更,flag 默认关走此路)。
filter_tool_defs(&all_defs, &intent)
};
if filtered.len() < 3 {
all_defs // 兜底:过滤<3(漂移/误收敛)回全量
} else {
filtered
}
Vec::new() // 纯问候 → 空工具集(tools=None,强制纯文本回复;仅作用当轮,不写冻结集)
} else {
all_defs // 低置信 fallback 全量
let computed: Vec<df_ai::provider::ToolDefinition> = if conf >= INTENT_CONF_THRESHOLD {
let filtered = if df_ai::planner::PLANNING_ENABLED {
// Phase 1:plan_hint 编排排序。intent_label 供 plan_hint 备用(当前规则纯关键词驱动)。
filter_tool_defs_planned(&all_defs, &intent, intent.as_str(), &user_text)
} else {
// 旧行为:intent 收敛扁平子集(零行为变更,flag 默认关走此路)。
filter_tool_defs(&all_defs, &intent)
};
if filtered.len() < 3 {
all_defs.clone() // 兜底:过滤<3(漂移/误收敛)回全量
} else {
filtered
}
} else {
all_defs.clone() // 低置信 fallback 全量
};
// 跨轮工具集冻结(前缀稳定):tools 数组在请求中位于 system 之后、消息之前,
// 每轮按末条 user 消息重新裁剪会让数组跨轮变化 → 前缀字节断,自动前缀缓存
// (DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。改按会话冻结:首次裁剪结果
// 入冻结集,后续轮只增不减(新意图带来的工具并入,已并入的不移除)。
// 顺序稳定:冻结集是 all_defs 的子集过滤,all_defs 顺序恒定 → 数组字节只增不改位。
let mut frozen_names = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| c.frozen_tool_names.clone())
};
let mut changed = false;
match frozen_names.as_mut() {
Some(prev) => {
for d in &computed {
if !prev.contains(&d.function.name) {
prev.push(d.function.name.clone());
changed = true;
}
}
}
None => {
if !computed.is_empty() {
frozen_names = Some(computed.iter().map(|d| d.function.name.clone()).collect());
changed = true;
}
}
}
if changed {
let mut session = session_arc.lock().await;
// contains_key 守卫防 conv() 惰性建(循环入口已桥接,正常恒存在,防御性保留)。
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).frozen_tool_names = frozen_names.clone();
}
}
match &frozen_names {
Some(names) => all_defs
.into_iter()
.filter(|d| names.contains(&d.function.name))
.collect(),
None => computed,
}
};
tracing::info!(
conv_id = %conv_id,
@@ -1294,10 +1338,12 @@ async fn prepare_round_inputs(
"── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──",
old_topic, new_topic
);
// 尾部追加而非 insert_at(0):头部插入位移整条前缀破坏 prompt 缓存,
// 语义「提示当前轮」放末尾同样生效(紧邻本轮内容,LLM 必见)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&marker_text));
conv.messages.push(ChatMessage::system(&marker_text));
tracing::info!(
conv_id = %conv_id,
iteration,
@@ -1429,15 +1475,26 @@ async fn prepare_round_inputs(
// 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。
//
// TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。
// 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转
// (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
let history_tokens_snapshot: u32 = {
let (history_tokens_snapshot, mut view_decisions) = {
let session = session_arc.lock().await;
session
.conv_read(conv_id)
.map(|c| c.messages.history_tokens())
.unwrap_or(0)
let conv = session.conv_read(conv_id);
(
conv.map(|c| c.messages.history_tokens()).unwrap_or(0),
conv.map(|c| c.tool_view_decisions.clone()).unwrap_or_default(),
)
};
summarize_tool_results(messages, history_tokens_snapshot, conv_id, iteration)
let messages =
summarize_tool_results(messages, history_tokens_snapshot, &mut view_decisions, conv_id, iteration);
if !view_decisions.is_empty() {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
session.conv(conv_id).tool_view_decisions = view_decisions;
}
}
messages
} else {
messages
};
@@ -1767,7 +1824,7 @@ async fn heartbeat_loop(
/// 注入目标验证续轮 nudge(强制续轮,非软提示)。
///
/// 文案随 goal_verify_rounds 递进:第 1 次请验证,第 2 次(将达上限)明确最后机会。
/// 复用 insert_stall_warning 的 insert_at(0, system) 范式 + fetch_goal_summary 拼目标。
/// 复用 insert_stall_warning 的尾部追加 system 范式 + fetch_goal_summary 拼目标。
async fn insert_goal_verify_nudge(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -1789,7 +1846,9 @@ async fn insert_goal_verify_nudge(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&nudge_text));
// 尾部追加而非 insert_at(0):nudge 紧邻本轮内容语义不变,且不位移既有前缀字节
// (insert_at(0) 会把整条历史后移,自动前缀缓存/Anthropic 显式缓存全失效)。
conv.messages.push(ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)"
@@ -223,9 +223,14 @@ fn is_failure_content(content: &str) -> bool {
// ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ──
// 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。
// 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。
//
// 压缩决策冻结(前缀稳定):判定结果按消息内容哈希记入 `view_decisions`(首次见到时按
// 当轮阈值判定并写入),后续轮只查表沿用、不再随 history_tokens 增长翻转——否则同一条
// 历史第 N 轮发全文、第 N+1 轮变摘要,轮中前缀必断(自动前缀缓存/显式缓存全失效)。
pub(crate) fn summarize_tool_results(
messages: Vec<ChatMessage>,
history_tokens_snapshot: u32,
view_decisions: &mut std::collections::HashMap<u64, bool>,
conv_id: &str,
iteration: usize,
) -> Vec<ChatMessage> {
@@ -246,10 +251,6 @@ pub(crate) fn summarize_tool_results(
return m;
}
let content_len = m.content.len();
let content_tokens = est.estimate_text(&m.content);
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
return m;
}
// 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。
let resolved_name = m
.tool_call_id
@@ -259,6 +260,7 @@ pub(crate) fn summarize_tool_results(
.unwrap_or("tool");
// read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。
// read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。
// 豁免判定在决策记录之前(豁免消息不进决策表,每轮恒豁免无漂移)。
if resolved_name == "read_file" {
tracing::debug!(
conv_id = %conv_id, iteration,
@@ -267,6 +269,21 @@ pub(crate) fn summarize_tool_results(
);
return m;
}
// 冻结决策:首次见到按当轮阈值判定并记录,后续轮直接沿用(不再随阈值翻转)。
let key = tool_view_decision_key(&m.content);
let compress = match view_decisions.get(&key) {
Some(&decided) => decided,
None => {
let content_tokens = est.estimate_text(&m.content);
let decided =
should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens);
view_decisions.insert(key, decided);
decided
}
};
if !compress {
return m;
}
original_bytes += content_len;
let compressed = extract_key_info(&m.content, resolved_name);
compressed_bytes += compressed.len();
@@ -283,3 +300,12 @@ pub(crate) fn summarize_tool_results(
}
msgs
}
/// tool_result 压缩决策键:内容稳定哈希(u64)。键只做「同内容同决策」关联,
/// 非密码学用途;碰撞的后果仅是 view-only 视图多压/少压一条,可自愈。
fn tool_view_decision_key(content: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut h);
h.finish()
}
@@ -460,7 +460,8 @@ async fn run_sub_agent_loop_inner(
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
let (messages, estimated_prompt) = {
let session = session_arc.lock().await;
// mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂)
let mut session = session_arc.lock().await;
let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
@@ -482,8 +483,13 @@ async fn run_sub_agent_loop_inner(
let mut msgs = vec![ChatMessage::system(&system_prompt)];
msgs.extend(history_msgs);
let history_tokens = conv.messages.history_tokens();
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)
let msgs = summarize_tool_results(msgs, history_tokens, &child_conv_id, round as usize);
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转。
let mut view_decisions = conv.tool_view_decisions.clone();
let msgs = summarize_tool_results(msgs, history_tokens, &mut view_decisions, &child_conv_id, round as usize);
if !view_decisions.is_empty() {
session.conv(&child_conv_id).tool_view_decisions = view_decisions;
}
let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
let est = TokenEstimator::default();
let estimated_prompt: u32 = msgs.iter().map(|m| est.estimate_message(m)).sum();
+9 -14
View File
@@ -39,7 +39,7 @@ use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use super::super::prompt::{build_system_prompt_parts, cache_enabled};
use super::super::prompt::{build_system_prompt_parts, cache_enabled, cache_split_provider_type};
use super::super::{AiChatEvent, ApprovalKind, SessionState};
@@ -312,11 +312,6 @@ fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<Strin
(proj, task)
}
/// 当前 provider 是否 anthropic 协议(仅此类型启用 prompt caching 拆分,OpenAI 路径零影响)。
fn is_anthropic_type(provider_type: &str) -> bool {
matches!(provider_type, "anthropic" | "anthropic_compat")
}
/// 把段追加到易变尾段(以 `\n\n---\n` 分隔,对齐旧 system 拼段风格)。首段直接赋。
fn append_volatile(volatile: String, seg: &str) -> String {
if volatile.is_empty() {
@@ -389,8 +384,8 @@ pub async fn ai_regenerate(
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
@@ -783,8 +778,8 @@ pub async fn ai_chat_send(
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
@@ -1809,8 +1804,8 @@ pub async fn ai_chat_edit(
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
@@ -2069,8 +2064,8 @@ pub async fn ai_chat_force_send(
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
+17
View File
@@ -1003,6 +1003,21 @@ pub struct PerConvState {
pub workflow_id: Option<String>,
/// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG)
pub workflow_dag_summary: Option<String>,
/// 跨轮工具集冻结(前缀稳定):首次意图裁剪结果按工具名冻结,后续轮只增不减。
///
/// 治「每轮按末条 user 消息重新 intent 裁工具 → tools 数组跨轮变化 → 前缀断」:
/// tools 数组在请求里位于 system 之后、消息之前,数组字节一变,自动前缀缓存
/// (DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。新意图带来的工具并入冻结集,
/// 已并入的不移除。None = 尚未冻结(首轮裁剪时写入);纯问候空工具集不进本字段
/// (仅作用当轮请求)。随会话销毁不落库(对齐 pinned_goals 语义)。
pub frozen_tool_names: Option<Vec<String>>,
/// tool_result 视图压缩决策冻结(content 稳定哈希 → 是否压缩)。
///
/// 治「压缩阈值随 history_tokens 增长漂移 → 同一条历史第 N 轮发全文、第 N+1 轮变摘要
/// → 轮中前缀断」:决策按消息首次进入请求视图时刻一次性判定并冻结,后续轮沿用同一视图。
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
}
/// 单个目标记录(含状态追踪)。
@@ -1066,6 +1081,8 @@ impl PerConvState {
working_context: df_ai::context_helpers::WorkingContext::new(),
workflow_id: None,
workflow_dag_summary: None,
frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(),
}
}
}
+17 -4
View File
@@ -498,11 +498,24 @@ async fn build_custom_section(state: &AppState) -> String {
String::new()
}
/// 读取 Anthropic prompt caching 开关(AppState settings KV,默认关)并同步 df-ai 进程级开关。
/// 当前 provider 是否支持 prompt caching 拆分(同一 settings 开关下的两类受益方):
/// - anthropic / anthropic_compat:显式缓存(system+消息级 cache_control 断点);
/// - openai_compat:自动前缀缓存(DeepSeek/GLM/Qwen 等,无需 cache_control 标记,
/// 只要求前缀字节稳定,拆分后易变段挪到消息流末尾即命中)。
pub(crate) fn cache_split_provider_type(provider_type: &str) -> bool {
matches!(
provider_type,
"anthropic" | "anthropic_compat" | "openai_compat"
)
}
/// 读取 prompt caching 拆分开关(AppState settings KV `df-ai-anthropic-cache`,默认关)
/// 并同步 df-ai 进程级显式缓存开关。
///
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的 anthropic 端点显式开启。
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的端点显式开启。
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
@@ -526,8 +539,8 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
/// 不会失效已缓存前缀(跨天缓存仍命中)。
///
/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`,
/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
pub(crate) async fn build_system_prompt_parts(
state: &AppState,
lang: &str,