新增: 消息级溯源 P2 切 ai_messages + AI Chat 跑题改进 P0-P2 + 标题诊断 + 苛刻测

消息级溯源 P2(一次性切读 b + 备份保留):
- 批次A 读路径切 ai_messages(state.rs AppState.ai_messages + 映射 ChatMessage↔AiMessageRecord +
  switch/export load 切 + fallback 兜底)
- 批次B 写路径切 ai_messages(replace_conversation 单事务全量重写 + save/clear 切 + 旧 messages 备份)
- 摘要/压缩不改 updated_at(save_conversation touch_updated_at,compress false 防时间分组跳变)

AI Chat 跑题改进 P0-P2(5 改进,治跑题 5 根因,三原则优雅/可靠/易迭代):
- P0 系统提示聚焦(## 聚焦准则独立段)+ 意图接入 loop(filter_tool_defs 收敛工具 29→5-10,三重 fallback)
- P1 压缩增强(compress prompt 主题锚点 + 失败关键词兜底 extract_keyword_summary)+
  工具结果压缩(should_summarize + extract_key_info view-only 不改持久化)
- P2 主题检测(TrackedMessage.topic + 双高置信保守标记 + tokenize 2-gram 修复中文锚点)
- title LLM complete 失败诊断日志(助定位返空根因)
- 跑题 P0 审查🟡修复(Debug 加 Data 域 + threshold 注释)

苛刻测 38 条(边界/对抗:全漂移/全停用词/全错误行/2KB 边界/连续主题切换/中文 2-gram/emoji)
df-ai 227 passed + workspace EXIT 0
This commit is contained in:
2026-06-20 03:54:58 +08:00
parent 4e3a11f925
commit de049704c6
11 changed files with 2434 additions and 44 deletions

View File

@@ -8,6 +8,13 @@ use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::TokenEstimator;
// 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。
// 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。
use df_ai::context_helpers::{
extract_key_info, extract_keyword_summary, should_summarize_tool_result,
};
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
use df_ai::intent::{filter_tool_defs, IntentRecognizer};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
@@ -57,6 +64,32 @@ const PROTECT_COUNT: usize = 6;
/// partial_text不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
///
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
pub const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 改进4 常量开关:tool_result 大输出是否做 view-only 摘要压缩(默认 true)。
///
/// true(默认):build_for_request 后送 stream 前,遍历 history 中 tool_result,
/// 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info
/// 替换(保留错误行 + 首尾各 5 行)。**仅 messages clone 视图,不改 ContextManager
/// 持久化**(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。
/// false(关闭):tool_result 原样送 LLM(旧行为)。排障/对比用。
pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
/// 改进5 常量开关:是否启用主题切换系统标记(默认 true)。
///
/// true(默认):push 时末两条 user 消息 topic 都非 None 且不同(双高置信)→
/// pending_topic_marker 置位 → agentic loop 顶部读取并 insert 一条 system 软提示
/// `"── 用户已切换话题(从「{old}」到「{new}」),请以新话题为准 ──"`(软提示,不强制 LLM)。
/// false(关闭):loop 顶部跳过读取/insert(置 false 即可观察无主题标记效果)。
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
pub const TOPIC_MARKER_ENABLED: bool = true;
// ============================================================
// 重构第一批(2026-06-19):GeneratingGuard 抽离到 guard.rs(纯结构搬迁,行为零变更)。
// run_agentic_loop 内仍 `GeneratingGuard::new(...)`,路径从本模块改 super::guard。
@@ -420,7 +453,55 @@ pub(crate) async fn run_agentic_loop(
}
_ => resolved_model,
};
let tool_defs = tools_arc.tool_definitions();
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
//
// 机制化收敛跑题:取末条 active user 消息 → IntentRecognizer 识别 → 置信 ≥ 阈值
// 则按 intent domain 过滤工具子集(减少 LLM 在无关工具上分心/误用)。
// 三重 fallback(改进2 B §可靠):
// 1. 置信 < INTENT_CONF_THRESHOLD(0.7)→ 全量
// 2. subset 空(Chat/Unknown/Conversation)→ filter_tool_defs 内部回全量
// 3. 过滤后 < 3 条(疑似漂移/误收敛)→ 回全量
// 关键安全:filter 仅改 LLM 可见 tool_defs,**不改执行**——audit 走 tools_arc.get/execute
// 完整 registry,LLM 即使幻觉一个被滤掉的工具名,audit 也能查到/拒绝。
//
// INTENT_CONF_THRESHOLD(常量开关):阈值,低于此值不收敛(回全量)。
// 注意:conf 截断到 1.0(intent.rs),单关键词 weight=1.0 即达 conf=1.0,故阈值=1.0
// 时 conf>=1.0 仍过滤(非关闭)。真关闭收敛:置 >1.0(如 1.1)。调低 = 更激进收敛。
let user_text: String = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| {
let msgs = c.messages.all_messages_clone();
msgs.iter()
.rev()
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.map(|m| m.content.clone())
})
.unwrap_or_default()
};
let (intent, conf) = IntentRecognizer::recognize(&user_text);
const INTENT_CONF_THRESHOLD: f32 = 0.7;
let all_defs = tools_arc.tool_definitions();
let total = all_defs.len(); // 提前记录全量数(all_defs 将 move 进 tool_defs)
let tool_defs = if conf >= INTENT_CONF_THRESHOLD {
let filtered = filter_tool_defs(&all_defs, &intent);
if filtered.len() < 3 {
all_defs // 兜底:过滤<3(漂移/误收敛)回全量
} else {
filtered
}
} else {
all_defs // 低置信 fallback 全量
};
tracing::info!(
conv_id = %conv_id,
intent = intent.as_str(),
conf,
filtered = tool_defs.len(),
total,
"[ai] 意图收敛工具"
);
// 停止信号副本stream_llm 与每轮迭代共享读取,避免重复加锁
// notify 同取一份 Arc 引用B-260615-14stream_llm select! 监听 notified() 即时唤醒
// F-260616-09 B 批2:取 per_conv 的 stop_flag/notify(设计 §4.2 :446)。
@@ -467,7 +548,7 @@ pub(crate) async fn run_agentic_loop(
total_tokens: tokens.total(),
};
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
save_conversation(&session_arc, &db, &conv_id, Some(&usage), None).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), None, true).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
@@ -476,6 +557,56 @@ pub(crate) async fn run_agentic_loop(
return;
}
// 改进5: 主题切换系统标记(保守,双高置信才标,软提示非强制)。
//
// push 时若末两条 user 消息的 topic 都非 None 且不同(Intent 高置信推断的双 topic),
// ContextManager 已置位 pending_topic_marker("old|new")。loop 顶部读取并消费
// (take 一次性清空,防重复 insert),insert 一条 system 软提示告知 LLM 用户已切换话题。
//
// 保守设计:
// - 双高置信:两条 topic 都非 None(都达 0.7 阈值)才标,任一 None(低置信未标)不标。
// - 软提示:仅 insert 一条 system 消息,不强制 LLM 行为(LLM 仍可按自己理解响应)。
// - TOPIC_MARKER_ENABLED(常量开关)false → 跳过(排障/对比用)。
// topic 不参与裁剪/压缩(只供检测),insert_at(0) 同压缩摘要定位(首位 system)。
if TOPIC_MARKER_ENABLED {
let topic_marker_raw: Option<String> = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
tracing::warn!(
stale_conv = %conv_id,
"[ai] conv 已删除,loop 退出(主题标记段入口)"
);
return;
}
let conv = session.conv(&conv_id);
conv.messages.take_topic_marker()
};
if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| {
// 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。
let mut parts = s.splitn(2, '|');
let old = parts.next()?.to_string();
let new = parts.next()?.to_string();
Some((old, new))
}) {
let marker_text = format!(
"── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──",
old_topic, new_topic
);
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));
tracing::info!(
conv_id = %conv_id,
iteration,
old_topic = %old_topic,
new_topic = %new_topic,
"[ai] 主题切换标记已 insert(软提示,保守双高置信)"
);
}
}
}
// B-260615-11: 旧 loop 污染防护——每轮开始校验对话一致性。
// 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧,
// 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。
@@ -577,6 +708,15 @@ pub(crate) async fn run_agentic_loop(
(active_msgs, lang)
};
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
extract_keyword_summary(&active_msgs)
} else {
String::new()
};
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
@@ -621,15 +761,46 @@ pub(crate) async fn run_agentic_loop(
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
}
Err(e) => {
// LLM 失败 → 消息状态完全不变(未改 status / 未扣 token)。
// set_compressing(false) 复位 + emit AiError(message 不含 api_key)。
// 不阻塞 loop:继续走下方 build_for_request 裁剪路径(保最近 6 条)。
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
//
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) +
// 消息状态不变,降级 build_for_request 裁剪)。
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
tracing::warn!(
conv_id = %conv_id,
error = %e,
"[ai] 自动压缩失败,降级走原裁剪(build_for_request)"
keyword_fallback_len = keyword_fallback.len(),
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
if KEYWORD_FALLBACK_ENABLED {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
true
} else {
false
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
);
}
}
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e),
error_type: Some(ErrorType::Unknown),
@@ -660,6 +831,68 @@ pub(crate) async fn run_agentic_loop(
msgs
};
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
//
// 遍历 messages(history clone,已含 system prompt + sanitize 后历史)中的 role=Tool 消息,
// 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info 替换:
// 保留错误行(error/panic/失败/.rs:N)+ 首/尾各 5 行,中间省略。
//
// **view-only**:messages 是 build_for_request 返回的 clone,改它只影响本轮 LLM 请求视图,
// 不改 ContextManager 持久化(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。
// 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。
//
// TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
// 历史总 token 用于占比判定(history_tokens 即 build_for_request 前的快照,
// 此处 messages 已裁剪过,用 estimated_prompt 不准;改用 history_tokens 快照更稳)。
// 注:用 conv.messages.history_tokens() 快照做占比基准(裁剪前的真相),避免循环依赖。
let history_tokens_snapshot: u32 = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.map(|c| c.messages.history_tokens())
.unwrap_or(0)
};
let est = TokenEstimator::default();
let mut compressed_bytes: usize = 0;
let mut original_bytes: usize = 0;
let mut summarized_count: usize = 0;
let msgs: Vec<ChatMessage> = messages
.into_iter()
.map(|mut m| {
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
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;
}
original_bytes += content_len;
// tool_name:无 tool_calls 关联(本消息是 tool_result,无 name 字段),用 call_id 或占位。
let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string());
let compressed = extract_key_info(&m.content, &tool_name);
compressed_bytes += compressed.len();
summarized_count += 1;
m.content = compressed;
m
})
.collect();
if summarized_count > 0 {
tracing::info!(
conv_id = %conv_id,
iteration,
summarized_count,
original_bytes,
compressed_bytes,
"[ai] tool_result view-only 摘要压缩(不改持久化)"
);
}
msgs
} else {
messages
};
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
// 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body),
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
@@ -829,7 +1062,7 @@ pub(crate) async fn run_agentic_loop(
}
}
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 标题生成后台化(失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
@@ -895,7 +1128,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
@@ -920,7 +1153,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// B-260615-26: 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成,
// 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续)
guard.disarm();
@@ -949,7 +1182,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位
guard.disarm();
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached {
@@ -979,7 +1212,7 @@ pub(crate) async fn run_agentic_loop(
let llm_concurrency = llm_concurrency.clone();
let resolved_model = resolved_model.clone();
tauri::async_runtime::spawn(async move {
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 知识提炼:需读已落库的对话消息,故在 save 之后
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
tracing::warn!("知识提炼触发失败(非阻断): {}", e);