优化: aichat效率批(save无变化捷径/断路器尾窗/system缓存指纹/会话列表刷新收敛) + 新增 LLM prompt caching(cache_control开关默认关/system稳定段易变段分离治缓存命中率) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:13 +08:00
parent 4df91ed155
commit 7eb2e25ed5
13 changed files with 698 additions and 180 deletions
+26 -5
View File
@@ -4,8 +4,8 @@
use tauri::{AppHandle, Emitter, Manager};
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider};
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
@@ -94,7 +94,12 @@ 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();
let system_prompt = build_system_prompt(state, &lang).await;
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分,
// OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
let cache_on = cache_enabled(state).await
&& matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat");
let (system_prompt, base_volatile) =
build_system_prompt_parts(state, &lang, &[], &[], cache_on).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
@@ -103,7 +108,23 @@ pub async fn try_continue_agent_loop(
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
// 知识注入:缓存模式拼到易变段末尾(追加消息流,不失效 system 缓存),否则拼 system
// (与旧路径逐字等价)。
let volatile_tail = if cache_on {
let knowledge = build_knowledge_context_for_conv(state, conv_id, &knowledge_config).await;
let mut tail = base_volatile;
if !knowledge.is_empty() {
tail = if tail.is_empty() { knowledge } else { format!("{}\n\n---\n{}", tail, knowledge) };
}
if tail.is_empty() { None } else { Some(tail) }
} else {
None
};
let system_prompt = if cache_on {
system_prompt
} else {
inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await
};
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
@@ -141,7 +162,7 @@ pub async fn try_continue_agent_loop(
};
tauri::async_runtime::spawn(async move {
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await;
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await;
});
}
+43 -2
View File
@@ -263,6 +263,15 @@ pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
/// 单轮并行失败爆发,又足够长以容忍偶发抖动。
pub const CIRCUIT_BREAKER_WINDOW: usize = 20;
/// BE2(AC-EFF-R2-1):断路器/探索熔断读消息的尾部窗口大小。
///
/// count_recent_failures / check_stall_breaker 只关心最近若干条消息(末尾工具结果 + 其前驱
/// assistant tool_calls / 最近几轮工具签名),用 ContextManager::recent_messages 只 clone 尾部
/// 切片,避免 all_messages_clone 每轮全量深克隆(长对话每轮 O(n) clone 浪费)。
/// 40:同轮工具回填通常 1~5 条,富余覆盖其定义调用与最近多轮;超窗工具映射退化为
/// unknown_tool(仅失败分组更粗)/ 签名样本截断(仍达 STALL_BREAKER_SAMPLE_SIZE=12),不误熔断。
pub const BREAKER_TAIL_N: usize = 40;
/// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations
/// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。
pub const CIRCUIT_BREAKER_ENABLED: bool = true;
@@ -598,6 +607,25 @@ async fn emit_retry_attempt(
tokio::time::sleep(delay).await;
}
/// 把 Anthropic prompt caching 易变尾段追加到请求消息流**末尾**(一条 user 消息)。
///
/// 多场景命中率设计要点:
/// - **放消息流末尾而非 system**:任何位于 system 之后的消息级缓存断点(含未来「多轮对话缓存」
/// 在末条 user 上打断点)都在易变段之前——易变内容(日期/清单/augmentation/知识)每日/每请求
/// 变化时,只影响其自身及之后(无),不失效已缓存的 system/历史前缀。若放首条 user,跨天日期
/// 变化会失效其后全部历史缓存。
/// - **用 user 角色而非 system**:anthropic_compat::convert_request 会把 System-role 消息抽到
/// 顶层 system(受 cache_control 缓存),易变段若为 system 会随请求变化失效整段 system 缓存。
/// 必须 user 承载,convert_request 对连续 user 做 merge_consecutive_users,追加安全。
/// - **不持久化**:仅注入本轮请求的 messages clone,per_conv.messages 不含易变段 → 每轮重建
/// 恰好一条,无重复。
fn merge_volatile_tail(messages: &mut Vec<ChatMessage>, tail: &str) {
if tail.is_empty() {
return;
}
messages.push(ChatMessage::user(tail));
}
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
///
/// 退出条件:
@@ -611,6 +639,9 @@ pub(crate) async fn run_agentic_loop(
app_handle: AppHandle,
provider_config: AiProviderRecord,
system_prompt: String,
// Anthropic prompt caching 易变尾段(缓存模式:日期/清单/augmentation/知识,追加到消息流末尾;
// None = 开关关,保持现状行为)。
volatile_tail: Option<String>,
conv_id: String,
knowledge_config: crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency,
@@ -1523,6 +1554,11 @@ pub(crate) async fn run_agentic_loop(
}
let mut msgs = vec![ChatMessage::system(&system_prompt)];
msgs.extend(history_msgs);
// Anthropic prompt caching:把易变尾段(日期/清单/augmentation/知识)追加到消息流末尾。
// 放末尾而非 system:易变内容不进缓存前缀,每日/每请求变化不失效 system 缓存(跨天仍命中)。
if let Some(tail) = &volatile_tail {
merge_volatile_tail(&mut msgs, tail);
}
msgs
};
@@ -2188,10 +2224,13 @@ async fn count_recent_failures(
conv_id: &str,
fail_window: &mut std::collections::VecDeque<String>,
) -> (u32, String) {
// BE2(AC-EFF-R2-1):只克隆尾部 BREAKER_TAIL_N 条,避免 all_messages_clone 全量深克隆
// (长对话每轮 O(n) clone 浪费)。窗口覆盖末尾 Tool 结果与其前驱 assistant tool_calls
// (同轮工具回填通常 1~5 条,40 足富余;超窗工具映射退化为 unknown_tool,仅失败分组更粗,不误熔断)。
let messages = {
let session = session_arc.lock().await;
match session.conv_read(conv_id) {
Some(conv) => conv.messages.all_messages_clone(),
Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N),
None => Vec::new(),
}
};
@@ -2424,8 +2463,10 @@ async fn check_stall_breaker(
// arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。
let signatures: Vec<String> = {
let session = session_arc.lock().await;
// BE2(AC-EFF-R2-1):只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用,
// 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。
let messages = match session.conv_read(conv_id) {
Some(conv) => conv.messages.all_messages_clone(),
Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N),
None => Vec::new(),
};
let mut sigs: Vec<String> = Vec::new();
+96 -23
View File
@@ -37,8 +37,8 @@ use super::super::audit::{audit_finalize, emit_data_changed};
use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::inject_knowledge_into_prompt;
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded};
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::{AiChatEvent, ApprovalKind, SessionState};
@@ -240,6 +240,20 @@ 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() {
seg.to_string()
} else {
format!("{}\n\n---\n{}", volatile, seg)
}
}
// ============================================================
// 发送 / 审批 / 控制
// ============================================================
@@ -301,24 +315,38 @@ pub async fn ai_regenerate(
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// 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);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
// 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。
let mut system_prompt = {
{
let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await
};
if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
@@ -352,7 +380,7 @@ pub async fn ai_regenerate(
};
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
});
Ok("ok".to_string())
@@ -633,12 +661,19 @@ pub async fn ai_chat_send(
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_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 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
}
// conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。
@@ -648,8 +683,16 @@ pub async fn ai_chat_send(
// 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{
let config = state.knowledge_config.lock().await.clone();
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。
// ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键:
@@ -679,7 +722,7 @@ pub async fn ai_chat_send(
};
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
});
Ok("ok".to_string())
@@ -1718,23 +1761,37 @@ pub async fn ai_chat_edit(
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// 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);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
// helper 读到的末条 active user 即编辑后的新文本,语义等价。
let mut system_prompt = {
{
let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await
};
if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
@@ -1761,6 +1818,7 @@ pub async fn ai_chat_edit(
app_handle,
provider_config,
system_prompt,
volatile_tail,
conv_id,
knowledge_config,
llm_concurrency,
@@ -1960,19 +2018,34 @@ 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);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_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)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
}
// conv_id 已在上方状态占用块得出。
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{
let config = state.knowledge_config.lock().await.clone();
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 每会话独立模型(与 ai_chat_send/regenerate 路径一致,持久读 per_conv)。
// force_send 语义 = 强制重发,尊重 per_conv 已有模型(override 持久)。
@@ -1992,7 +2065,7 @@ pub async fn ai_chat_force_send(
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await;
});
Ok("ok".to_string())
+62 -37
View File
@@ -251,7 +251,9 @@ async fn save_conversation_inner(
// 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
// 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = {
// BE1(AC-EFF-L0-1):无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs) = {
let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -264,50 +266,66 @@ async fn save_conversation_inner(
return;
}
let __wait = __lock_start.elapsed();
// 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let provider_id = session.active_provider_id.clone();
let created_at = session.active_conv_created_at.clone();
let conv = session.conv(conv_id);
let cloned = (
conv.messages.all_messages_clone(),
let msg_len = conv.messages.len();
let persisted_count = conv.messages.persisted_msg_count();
let needs_full_rewrite = conv.messages.needs_full_rewrite();
// 无变化捷径提前判定:消息数未增且非 dirty → DB ai_messages 与内存完全一致,
// 免 clone(下方直接走元数据 update_full,消息写入整段跳过)。
let cloned = if !needs_full_rewrite && msg_len == persisted_count {
Vec::new()
} else {
conv.messages.all_messages_clone()
};
let pinned_goals = conv.pinned_goals.clone();
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
(
provider_id,
created_at,
conv.pinned_goals.clone(),
conv.messages.persisted_msg_count(),
conv.messages.needs_full_rewrite(),
);
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
cloned
pinned_goals,
msg_len,
persisted_count,
needs_full_rewrite,
cloned,
)
};
let __lock_total = __lock_start.elapsed();
if __lock_total > std::time::Duration::from_millis(50) {
eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
eprintln!("[LOCK-DIAG] save 快照段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
}
// truncate 在锁外(clone 副本上操作,不影响 session 真相源)
// T2: 展开 namespace 引用为原文再落库
{
let session = session_arc.lock().await;
for m in &mut msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
if let Some(original) = session.namespace_store.read_only(&m.content) {
m.content = original.to_string();
// BE1:仅当消息确有可能变化(非无变化捷径)才对 clone 副本做 namespace 展开 + truncate。
let no_message_change = !needs_full_rewrite && msg_len == persisted_count;
if !no_message_change {
// truncate 在锁外(clone 副本上操作,不影响 session 真相源)
// T2: 展开 namespace 引用为原文再落库
{
let session = session_arc.lock().await;
for m in &mut persist_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
if let Some(original) = session.namespace_store.read_only(&m.content) {
m.content = original.to_string();
}
}
}
}
}
for m in &mut msgs {
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
m.content = truncate_for_persist(&m.content);
}
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
if let Some(parts) = m.parts.as_ref() {
m.parts = truncate_parts_for_persist(parts);
for m in &mut persist_msgs {
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
m.content = truncate_for_persist(&m.content);
}
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
if let Some(parts) = m.parts.as_ref() {
m.parts = truncate_parts_for_persist(parts);
}
}
}
let persist_msgs = msgs;
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
@@ -335,12 +353,19 @@ async fn save_conversation_inner(
// 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。
let now = now_millis();
let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
let total_len = persist_msgs.len();
let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs
.iter()
.enumerate()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at))
.collect();
// BE1:no_message_change 时 persist_msgs 为空,records 也空(下方无变化捷径直接跳过消息写入,
// 仅元数据 update_full);非无变化时 records 由全量 persist_msgs 构建(append 切片仍按 persisted_count)。
let records: Vec<df_storage::models::AiMessageRecord> = if no_message_change {
Vec::new()
} else {
persist_msgs
.iter()
.enumerate()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at))
.collect()
};
// BE1:total_len 用 clone 后权威条数(no_message_change 时用 lock 读的 msg_len,=persisted_count)。
let total_len = if no_message_change { msg_len } else { persist_msgs.len() };
let conv_repo = AiConversationRepo::new(db);
let msg_repo = df_storage::crud::AiMessageRepo::new(db);
+34 -24
View File
@@ -351,6 +351,39 @@ pub(crate) async fn build_knowledge_context(
out
}
/// 取末条 active user 消息文本 + id,构建知识库上下文段(DRY:注入 [`inject_knowledge_into_prompt`]
/// 与 Anthropic prompt caching 易变段共用)。
///
/// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤
/// 导致两值取自不同消息。auto_inject 关 / 无命中返回空串。
pub(crate) async fn build_knowledge_context_for_conv(
state: &AppState,
conv_id: &str,
config: &crate::state::KnowledgeConfig,
) -> String {
let (last_user_text, user_message_id) = {
let __lock_t = std::time::Instant::now();
let session = state.ai_session.lock().await;
let msgs = session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default();
let found = msgs
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold);
}
match found {
Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None),
}
};
build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await
}
/// 知识注入 system prompt 的单一入口(DRY:F-09 agentic + chat 五处合一)。
///
/// 把原本散落在 `try_continue_agent_loop`(agentic/mod.rs)+ `ai_chat_send` /
@@ -378,30 +411,7 @@ pub(crate) async fn inject_knowledge_into_prompt(
system_prompt: String,
config: &crate::state::KnowledgeConfig,
) -> String {
// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤
// 导致两值取自不同消息。
let (last_user_text, user_message_id) = {
let __lock_t = std::time::Instant::now();
let session = state.ai_session.lock().await;
let msgs = session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default();
let found = msgs
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_system_prompt_with_knowledge:383 持锁 {:?} (含 lock 等待)", __hold);
}
match found {
Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None),
}
};
let knowledge_context =
build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await;
let knowledge_context = build_knowledge_context_for_conv(state, conv_id, config).await;
if knowledge_context.is_empty() {
system_prompt
} else {
+234 -19
View File
@@ -1,5 +1,9 @@
//! 系统提示词构建 + 活跃提供商获取
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use df_storage::db::Database;
use df_storage::models::AiProviderRecord;
use crate::commands::err_str;
@@ -43,7 +47,24 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
fn env_profile_line() -> String {
let today = chrono::Local::now().format("%Y-%m-%d");
let (os, shell, interp) = if cfg!(target_os = "windows") {
format!("当前日期: {today} | {}", env_profile_stable_body())
}
/// env_profile_line 的静态稳定段(OS + shell + 执行姿势,不含日期)。
///
/// Anthropic prompt caching 模式把日期挪到易变段(env_profile_date_volatile,拼首条 user 消息),
/// 本稳定段留在 system 供缓存命中——日期每日变,留 system 会每日失效整段缓存。
fn env_profile_stable_body() -> String {
let (os, shell, interp) = env_os_shell_interp();
format!(
"运行环境: {os} | shell: {shell}\n\
姿: (//$) .ps1 .py , `powershell -File x.ps1` `python x.py` , -c (): {interp}\n\n"
)
}
/// 平台对应 (os, shell, 可用解释器) 三元组(cfg! 分支,见 env_profile_line 文档)。
fn env_os_shell_interp() -> (&'static str, &'static str, &'static str) {
if cfg!(target_os = "windows") {
("Windows", "PowerShell", "python(无 python3)、node")
} else if cfg!(target_os = "macos") {
("macOS", "bash/zsh", "python3、node")
@@ -51,11 +72,13 @@ fn env_profile_line() -> String {
("Linux", "bash", "python3、node")
} else {
("Unknown", "sh", "python3、node")
};
format!(
"当前日期: {today} | 运行环境: {os} | shell: {shell}\n\
姿: (//$) .ps1 .py , `powershell -File x.ps1` `python x.py` , -c (): {interp}\n\n"
)
}
}
/// Anthropic prompt caching 模式:日期易变段(拼到首条 user 消息,不进 system)。
fn env_profile_date_volatile() -> String {
let today = chrono::Local::now().format("%Y-%m-%d");
format!("今天是 {today}")
}
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
@@ -247,6 +270,98 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
}
}
// ── BE3(AC-EFF-L0-2): system prompt 会话内缓存 ──
//
// 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 +
// 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。
//
// 方案:按「数据指纹」缓存已拼好的 system 串(指纹 = 日期 + lang + custom_prompt + 活跃
// project/task 的 id+updated_at + 去重排除集)。任何依赖数据变更 → 指纹变 → 重建;未变直接
// 复用缓存串(省 2 次全行物化查询 + 字符串拼接)。
//
// 指纹键而非 conv_id:system prompt 内容本就与 conv_id 无关,同数据跨会话共享同一缓存串,
// 免 per-conv 生命周期清理(换会话/删会话零维护)。模块级静态(OnceLock+Mutex),不新增
// AppState 字段,进程内单例,懒初始化。
//
// 指纹查询只取轻量列(id+updated_at 两列,省 list_active 的全行物化);查询失败回退「强制重建」,
// 正确性不受损(只是多一次重建)。缓存按指纹封顶,超上限清空防长跑进程内存膨胀。
static SYSTEM_PROMPT_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
/// 缓存上限(条目)。system prompt 内容按指纹去重后通常仅 1~2 条;上限防御性封顶。
const SYSTEM_PROMPT_CACHE_MAX: usize = 32;
fn system_prompt_cache() -> &'static Mutex<HashMap<String, String>> {
SYSTEM_PROMPT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 活跃实体(project/task)数据指纹:轻量查 id+updated_at 两列,拼成指纹串。
/// 返回 None = 查询失败(调用方按「已变更」处理强制重建,正确性不受损)。
async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<String> {
let conn = db.conn().clone();
let result = tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard.prepare(sql).ok()?;
let rows = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let updated: String = row.get(1)?;
Ok(format!("{}@{};", id, updated))
})
.ok()?;
let mut out = String::new();
for r in rows {
out.push_str(&r.ok()?);
}
Some(out)
})
.await;
result.ok().flatten()
}
/// 计算 system prompt 缓存指纹(返回 None 表示指纹依赖查询失败,调用方按 miss 处理强制重建)。
///
/// 指纹覆盖全部依赖输入:
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint(
state: &AppState,
lang: &str,
excl_proj: &[String],
excl_task: &[String],
) -> Option<String> {
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let custom = state
.settings
.get("custom_prompt")
.await
.ok()
.flatten()
.unwrap_or_default();
let proj_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
)
.await?;
let task_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM tasks WHERE deleted_at IS NULL",
)
.await?;
let mut key = format!("{date}|{lang}|{custom}|{proj_fp}|{task_fp}");
for id in excl_proj {
key.push_str("|p:");
key.push_str(id);
}
for id in excl_task {
key.push_str("|t:");
key.push_str(id);
}
Some(key)
}
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
///
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path),
@@ -260,6 +375,11 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
///
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
/// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。
///
/// 注:caching 拆分后主链(send/regenerate/edit/force_send/续跑)已改走
/// [`build_system_prompt_parts`](开关关逐字等价),本 wrapper 暂零调用方——保留作预留 API
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
#[allow(dead_code)]
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
build_system_prompt_with_excluded(state, lang, &[], &[]).await
}
@@ -283,7 +403,15 @@ pub(crate) async fn build_system_prompt_with_excluded(
excluded_project_ids: &[String],
excluded_task_ids: &[String],
) -> String {
let (prefix, proj_label, task_label) = system_prompt_parts(lang);
// BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await;
if let Some(key) = &fp_key {
let cache = system_prompt_cache();
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
return cached.clone();
}
}
let (prefix, _, _) = system_prompt_parts(lang);
let mut prompt = env_profile_line();
prompt.push_str(prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
@@ -291,55 +419,142 @@ pub(crate) async fn build_system_prompt_with_excluded(
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang));
// 当前数据上下文(项目/任务全局清单)+ 自定义提示词 —— 拆出共用 helper,行为逐字等价。
prompt.push_str(&build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await);
prompt.push_str(&build_custom_section(state).await);
// BE3:未命中 → 存入缓存供下轮复用(按指纹封顶,超上限清空防长跑进程内存膨胀)。
if let Some(key) = fp_key {
let mut cache = system_prompt_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SYSTEM_PROMPT_CACHE_MAX {
cache.clear();
}
cache.insert(key, prompt.clone());
}
prompt
}
/// 项目/任务全局清单段(DRY:主构建 [`build_system_prompt_with_excluded`] 与 caching 易变段
/// [`build_system_prompt_parts`] 共用)。
///
/// 拼接项目段 + 任务段(含机制层注明语);无项目/任务返回空串。
/// 语义与旧 build_system_prompt_with_excluded 内联块逐字一致(纯提取,零行为变更)。
async fn build_context_lists(
state: &AppState,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
lang: &str,
) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new();
// 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await {
if !projects.is_empty() {
prompt.push_str(proj_label);
out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) {
continue;
}
prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description));
out.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description));
if let Some(ref dir) = p.path {
prompt.push_str(&format!(" 目录: {}\n", dir));
out.push_str(&format!(" 目录: {}\n", dir));
}
}
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
prompt.push_str(&projects_listed_note(lang, 20));
out.push_str(&projects_listed_note(lang, 20));
}
}
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
// 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。
if let Ok(tasks) = state.tasks.list_active().await {
if !tasks.is_empty() {
prompt.push_str(task_label);
out.push_str(task_label);
// 去重:被 @ 的任务跳过(同项目去重机理)
for tk in tasks.iter().take(20) {
if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue;
}
prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description));
out.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description));
}
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang));
out.push_str(&tasks_listed_note(lang));
}
}
out
}
// 自定义提示词(设置中配置,追加到系统 prompt 末尾)
/// 自定义提示词(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
async fn build_custom_section(state: &AppState) -> String {
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
if !custom.is_empty() {
let clean = custom.trim().trim_matches('"');
if !clean.is_empty() {
prompt.push_str("\n## 自定义指令\n");
prompt.push_str(clean);
prompt.push('\n');
return format!("\n## 自定义指令\n{}\n", clean);
}
}
}
String::new()
}
prompt
/// 读取 Anthropic prompt caching 开关(AppState settings KV,默认关)并同步 df-ai 进程级开关。
///
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的 anthropic 端点显式开启。
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-anthropic-cache")
.await
.ok()
.flatten()
.unwrap_or_default();
let enabled = v == "true" || v == "1";
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
enabled
}
/// 构建 system prompt(Anthropic prompt caching 模式)。
///
/// 返回 `(system 稳定段, 易变尾段)`:
/// - 开关关(`cache_enabled=false`)→ `(完整 system, 空串)`:与 [`build_system_prompt_with_excluded`]
/// 逐字等价(零行为变更,兼容非官方网关)。
/// - 开关开 → `(静态稳定段, 易变段)`:日期/项目/任务清单从 system 挪出,由上层把易变段
/// **追加到消息流末尾(user 消息,不进 system → 不失效 system 缓存)**。放消息流末尾而非
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
/// 不会失效已缓存前缀(跨天缓存仍命中)。
///
/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`,
/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。
pub(crate) async fn build_system_prompt_parts(
state: &AppState,
lang: &str,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
cache_enabled: bool,
) -> (String, String) {
if !cache_enabled {
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await;
return (full, String::new());
}
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义),
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
let (prefix, _, _) = system_prompt_parts(lang);
let mut stable = env_profile_stable_body();
stable.push_str(prefix);
stable.push_str(doc_exploration_strategy_section(lang));
stable.push_str(app_config_query_guidance_section(lang));
stable.push_str(&build_custom_section(state).await);
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
let volatile = if lists.is_empty() {
env_profile_date_volatile()
} else {
format!("{}\n\n{}", env_profile_date_volatile(), lists)
};
(stable, volatile)
}
/// 项目清单尾部注明语(中/英)。