新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区 - 目标快照: 恢复引导按最终有效意图生成,旧目标不复活 - 知识检索项目维度软排序,WorkingContext 持久化迁移 - 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入 - 压缩与缓存能力在主循环/子代理/提示词三面接线 - 两份专项设计文档与看板销账
This commit is contained in:
@@ -1714,6 +1714,61 @@ async fn unwind_to_result<F: std::future::Future>(fut: F) -> Result<F::Output, S
|
||||
}
|
||||
}
|
||||
|
||||
/// 展开请求视图里的 namespace 引用:store 命中直取原文;未命中记入返回列表
|
||||
/// (下标 + 引用路径)交懒回填,条目本身维持引用原样待替换。
|
||||
fn collect_namespace_misses(history: &mut [ChatMessage], store: &df_ai::namespace_store::NamespaceStore) -> Vec<(usize, String)> {
|
||||
let mut misses = Vec::new();
|
||||
for (idx, m) in history.iter_mut().enumerate() {
|
||||
if df_ai::namespace_store::is_namespace_ref(&m.content) {
|
||||
let path = m.content.clone();
|
||||
match store.read_only(&path) {
|
||||
Some(original) => m.content = original.to_string(),
|
||||
None => misses.push((idx, path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
misses
|
||||
}
|
||||
|
||||
/// 回填应用:找回的引用替换为原文;无原文(真淘汰:LRU 淘汰且消息里无原文)维持
|
||||
/// 既有富提示/EVICTED_PLACEHOLDER 语义,不误填。
|
||||
fn apply_namespace_views(
|
||||
history: &mut [ChatMessage],
|
||||
misses: &[(usize, String)],
|
||||
store: &df_ai::namespace_store::NamespaceStore,
|
||||
recovered: &HashMap<String, String>,
|
||||
conv_id: &str,
|
||||
) {
|
||||
for (idx, path) in misses {
|
||||
history[*idx].content = match recovered.get(path) {
|
||||
Some(original) => original.clone(),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
path = %path,
|
||||
"[namespace] 引用已淘汰,替换为提示文案"
|
||||
);
|
||||
store
|
||||
.eviction_rich_notice(path)
|
||||
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string())
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// namespace 懒回填的原文源:该会话 DB messages 原文(落库约定存展开后原文)。
|
||||
/// 仅回填触发时调用(常态零 DB 开销);读失败返回空列表,回填退化为占位语义不阻塞本轮。
|
||||
async fn load_conv_message_contents(app_handle: &AppHandle, conv_id: &str) -> Vec<String> {
|
||||
let state = app_handle.state::<AppState>();
|
||||
match state.ai_messages.list_by_conversation(conv_id).await {
|
||||
Ok(records) => records.into_iter().map(|r| r.content).collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(conv_id = %conv_id, error = %e, "[namespace] 回填原文拉取失败,维持占位");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// run_agentic_loop 主循环体拆解(纯搬运,行为零变更,2026-10-01)
|
||||
@@ -1945,8 +2000,20 @@ async fn prepare_round_inputs(
|
||||
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
|
||||
// build_for_request 读 per_conv.messages。
|
||||
// conv_id 来源:run_agentic_loop 入参。
|
||||
let messages = {
|
||||
let session = session_arc.lock().await;
|
||||
// 缓存友好上下文总开关(KV `df-ai-cache-friendly-context`,默认开):开则 build_for_request
|
||||
// 走块状裁剪 + system 锚点 view 层转 user;关则两者旁路(与旧路径逐字节等价)。
|
||||
// 锁外读值拷贝(下方 build 锁段无 await,对齐 context_lifecycle 的开关读取范式)。
|
||||
let cache_friendly = {
|
||||
let st = app_handle.state::<AppState>();
|
||||
super::prompt::cache_friendly_context_enabled(&st).await
|
||||
};
|
||||
let (mut history_msgs, namespace_misses) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
// 缓存友好注入:先于下方只读借用(可变短借用与后续只读借用不冲突);
|
||||
// 条目已消失则跳过,防 conv() 惰性创建孤儿状态
|
||||
if session.conv_read(conv_id).is_some() {
|
||||
session.conv(conv_id).messages.set_cache_friendly(cache_friendly);
|
||||
}
|
||||
let conv = match session.conv_read(conv_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
@@ -1960,35 +2027,30 @@ async fn prepare_round_inputs(
|
||||
// namespace_store 引用化进 messages;持久化(conversation.rs)已展开写 DB,但发 LLM 的
|
||||
// 实时请求此前未展开 → LLM 每轮只看到 "namespace://..." 占位符,AI 实际拿不到工具结果。
|
||||
// 此处展开,namespace 退化为内存/持久化层优化,LLM 永远看真实内容。
|
||||
// read_only None(条目被 LRU 淘汰 / 重启后 namespace 清空 / 跨会话残留引用)→
|
||||
// 替换为 EVICTED_PLACEHOLDER 提示文案(非保留无意义 namespace:// URI)+ warn 日志。
|
||||
for m in &mut history_msgs {
|
||||
if df_ai::namespace_store::is_namespace_ref(&m.content) {
|
||||
let path = m.content.clone();
|
||||
if let Some(original) = session.namespace_store.read_only(&path) {
|
||||
m.content = original.to_string();
|
||||
} else {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
path = %path,
|
||||
"[namespace] 引用已淘汰,替换为提示文案"
|
||||
);
|
||||
m.content = session
|
||||
.namespace_store
|
||||
.eviction_rich_notice(&path)
|
||||
.unwrap_or_else(|| df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
// read_only None(条目被 LRU 淘汰 / 重启后 store 清空)→ 记入未命中,下方懒回填;
|
||||
// 仍无原文才落占位文案(见 apply_namespace_views)。
|
||||
let namespace_misses = collect_namespace_misses(&mut history_msgs, &session.namespace_store);
|
||||
(history_msgs, namespace_misses)
|
||||
};
|
||||
// namespace 懒回填(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
|
||||
// 未命中时才触发——从该会话 DB messages 原文按内容哈希找回并重建 store 条目,
|
||||
// 避免载入即全量扫描;消息里无原文的真淘汰维持既有富提示/占位。
|
||||
// DB 拉取在会话锁外(锁内禁长 I/O),回填与视图替换短暂重入锁。
|
||||
if !namespace_misses.is_empty() {
|
||||
let contents = load_conv_message_contents(app_handle, conv_id).await;
|
||||
let mut session = session_arc.lock().await;
|
||||
let refs: Vec<String> = namespace_misses.iter().map(|(_, p)| p.clone()).collect();
|
||||
let recovered = session.namespace_store.backfill_from_messages(&refs, &contents);
|
||||
apply_namespace_views(&mut history_msgs, &namespace_misses, &session.namespace_store, &recovered, conv_id);
|
||||
}
|
||||
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);
|
||||
}
|
||||
let messages = msgs;
|
||||
|
||||
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
|
||||
//
|
||||
@@ -3813,3 +3875,71 @@ mod empty_round_guard_tests {
|
||||
assert!(empty_round_should_park(4, 3));
|
||||
}
|
||||
}
|
||||
|
||||
// namespace 懒回填契约(跨会话召回设计 缺口3):重启后 store 清空,旧引用首次展开
|
||||
// 未命中时从 DB messages 原文按内容哈希回填;二次展开走 store 不再触发回填;
|
||||
// 消息里无原文的真淘汰维持占位不误填。全链 = collect/apply 两纯函数 + store 回填 API。
|
||||
#[cfg(test)]
|
||||
mod namespace_backfill_tests {
|
||||
use super::{apply_namespace_views, collect_namespace_misses};
|
||||
use df_ai::namespace_store::{NamespaceStore, EVICTED_PLACEHOLDER};
|
||||
use df_ai::provider::ChatMessage;
|
||||
|
||||
// 引用路径取自热 store(模拟重启前生成),与生产 store() 同源
|
||||
fn ref_path_of(content: &str) -> String {
|
||||
NamespaceStore::new(1 << 20).store("read_file", content)
|
||||
}
|
||||
|
||||
fn history_with_ref(path: &str) -> Vec<ChatMessage> {
|
||||
vec![ChatMessage::tool_result("tc1", path)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miss_backfills_from_message_original_and_expands() {
|
||||
let original = "文件A的内容行\n".repeat(300);
|
||||
let path = ref_path_of(&original);
|
||||
let mut store = NamespaceStore::default(); // 重启后空 store
|
||||
let mut history = history_with_ref(&path);
|
||||
let misses = collect_namespace_misses(&mut history, &store);
|
||||
assert_eq!(misses.len(), 1, "store 空应记未命中");
|
||||
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
|
||||
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
|
||||
assert_eq!(history[0].content, original, "展开应得到原文而非占位");
|
||||
assert_eq!(
|
||||
store.read_only(&path).map(str::to_owned),
|
||||
Some(original),
|
||||
"store 应已回填该条目",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_expansion_hits_store_without_rebackfill() {
|
||||
let original = "x".repeat(4096);
|
||||
let path = ref_path_of(&original);
|
||||
let mut store = NamespaceStore::default();
|
||||
let mut history = history_with_ref(&path);
|
||||
let misses = collect_namespace_misses(&mut history, &store);
|
||||
let recovered = store.backfill_from_messages(&[path.clone()], &[original.clone()]);
|
||||
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
|
||||
// 下一轮重建视图:store 已命中,未命中为空 → 回填阶段(含 DB 拉取)不再触发
|
||||
let mut history2 = history_with_ref(&path);
|
||||
let misses2 = collect_namespace_misses(&mut history2, &store);
|
||||
assert!(misses2.is_empty(), "二次展开应走 store,无未命中");
|
||||
assert_eq!(history2[0].content, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_eviction_keeps_placeholder_without_wrong_fill() {
|
||||
let original = "y".repeat(4096);
|
||||
let path = ref_path_of(&original);
|
||||
let mut store = NamespaceStore::default();
|
||||
let mut history = history_with_ref(&path);
|
||||
let misses = collect_namespace_misses(&mut history, &store);
|
||||
// 消息里无对应原文(仅无关内容)→ 回填为空,维持占位,store 不误填
|
||||
let recovered = store.backfill_from_messages(&[path.clone()], &["无关消息".to_string()]);
|
||||
assert!(recovered.is_empty(), "无对应原文不应产生回填结果");
|
||||
apply_namespace_views(&mut history, &misses, &store, &recovered, "conv-t");
|
||||
assert_eq!(history[0].content, EVICTED_PLACEHOLDER);
|
||||
assert_eq!(store.read_only(&path), None, "真淘汰不得误填 store");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user