Files
DevFlow/docs/02-架构设计/F-15-上下文管理增强设计-2026-06-16.md

23 KiB
Raw Blame History

AI Chat 上下文管理增强设计

性质: 功能设计(经代码勘察确认可行性 + 多角度分析) 日期: 2026-06-16 关联: Agent 架构说明(现有裁剪机制盘点依据) 用途: 实施依据,实施方据此文档执行,不再二次设计


1. 背景与问题

1.1 现有裁剪机制的致命缺陷

当前 ContextManager.build_for_requestcontext.rs:219超预算时的行为

history_tokens > available_budget
  ↓
从最旧消息开始丢弃(按三元组原子单元),保护最近 6 条PROTECT_COUNT
  ↓
被丢弃的消息 → 彻底不发给 LLM零保留
  ↓
trimmed=true → agentic.rs:226 `_trimmed` → 直接忽略,无任何动作

问题:丢掉的消息里可能有「用户说要改 XX 文件的 YY 函数」「AI 已经用了方案 B 不是方案 A」这类关键上下文。丢弃 = 遗忘 = AI 重复读文件 / 改错地方。

1.2 现有清理机制的缺陷

ai_chat_clearcommands.rs:360session.messages.clear() + DB clear_messagesmessages='[]'历史彻底丢失,无法回溯。

1.3 用户需求(三条)

  1. 会话分段:清理上下文不删记录,同一对话内产生 a'/a''/a''' 多段DB 全量保留LLM 只看当前段
  2. 手动压缩:用户主动发起,把当前消息让 LLM 总结成摘要,摘要替代原始消息作为上下文起点
  3. 智能裁剪:超预算自动触发压缩,不直接丢弃,用摘要保留旧消息语义

2. 代码勘察(事实依据)

2.1 数据流真相链

DB (ai_conversations.messages JSON 列)
  ↕ save_conversation / switchConversation → restore_from_messages
ContextManager (内存真相源, Vec<TrackedMessage>)
  ↓ build_for_request(sys_tokens)
  ↓   → sanitize_messages (step 0: filter !is_active + step 1-3: 畸形三元组自愈)
  ↓   → 超预算时裁剪旧消息(保护最近 PROTECT_COUNT=6 条)
LLM 请求 = [system_prompt] + [裁剪后的 active 消息]

2.2 关键代码位置

机制 位置 行为
ChatMessage.status df-ai-core/provider.rs:59 Option<String>,现有值:None/"active"/"truncated"
is_active() df-ai-core/provider.rs:79 !matches!(status, Some("truncated"))反面排除模式
sanitize_messages context.rs:284 step 0 调 is_active() 过滤,唯一的发送视图过滤点
build_for_request context.rs:219 先 sanitize 再裁剪,返回 (messages, trimmed: bool)
push() context.rs:174 追加消息 + 计 token不区分 status 全量计入 history_tokens
clear() context.rs:170 清空 messages Vec + history_tokens=0
restore_from_messages context.rs:414 从 DB JSON 全量恢复,push 全量消息含 !active 的token 全量计入
ai_chat_clear commands.rs:360 session.clear() + DB clear_messages 置 messages='[]'
build_for_request 调用点 agentic.rs:226 let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens)trimmed 被忽略
前端过滤 useAiConversations.ts:79 .filter(m => m.status !== 'truncated')硬编码字符串
title.rs LLM 调用 title.rs:111-140 generate_title_via_llm完整的非流式 complete() 调用范例,压缩可直接复用此模式
build_provider_for secret.rs 构造 provider 句柄(含 keyring 解析title.rs 和压缩共用

2.3 ContextConfig 预算参数

// context.rs:88-94
pub struct ContextConfig {
    max_tokens: u32,        // 128_000
    output_reserve: u32,    // 8_192
    safety_ratio: f32,      // 0.85
}
// budget_limit() = (128_000 - 8_192) × 0.85 ≈ 102_000 tokens

2.4 消息分组(裁剪原子性)

// context.rs:115-123
enum MessageGroup {
    Standalone,       // 普通 User/Assistant 文本
    ToolCallHead,     // Assistant 带 tool_calls三元组的头
    ToolResultTail,   // Tool 结果消息(三元组的尾)
}

build_eviction_unitscontext.rs:526已实现按三元组分组Head + 所有 Tail + 紧随的文本 Assistant 作为不可分割的淘汰单元。分段标记和压缩可复用此分组逻辑保证原子性


3. 统一设计

3.1 ChatMessage.status 值域(统一)

None / "active"           → 正常消息,进 LLM 上下文 + 前端可见
"truncated"               → UX-09 编辑软删,不进上下文 + 前端隐藏
"archived_segment"        → 会话分段标记,不进上下文 + 前端折叠可见
"compressed"              → 被压缩替代,不进上下文 + 前端折叠可见

3.2 is_active() 改为正面白名单

// 改前(反面排除,每加一个新状态都要改)
pub fn is_active(&self) -> bool {
    !matches!(self.status.as_deref(), Some("truncated"))
}

// 改后(正面白名单,新状态自动不 active
pub fn is_active(&self) -> bool {
    matches!(self.status.as_deref(), None | Some("active"))
}

影响面sanitize_messages step 0 调 is_active() 过滤,改后 archived_segmentcompressed 自动被过滤,零额外改动

3.3 push() / restore_from_messages() 的 token 计算修正

问题:当前 push() 不区分 status 全量计入 history_tokensrestore_from_messages 从 DB 恢复时 push 全量消息(含 compressed/archived_segment导致 token 虚高 → build_for_request 误判超预算 → 不必要的裁剪。

修正

// push() 加 status 守卫
pub fn push(&mut self, message: ChatMessage) {
    let tokens = self.estimator.estimate_message(&message);
    let group = classify_group(&message);
    // 仅 active 消息计入 token 预算compressed/archived_segment 不进 LLM 上下文)
    if message.is_active() {
        self.history_tokens += tokens;
    }
    self.messages.push(TrackedMessage { message, token_count: tokens, group });
}

注意all_messages_clone() 仍返回全量(含 !active持久化不受影响。build_for_request 先 sanitize 过滤再算预算,行为一致。


4. 三大功能设计

4.1 会话分段(清理上下文不删记录)

语义:用户点「清理上下文」→ 当前所有 active 消息标记 archived_segment → 后续新消息作为新段继续同一对话。

IPCai_chat_clear_context

后端逻辑

pub async fn ai_chat_clear_context(state: State<'_, AppState>) -> Result<(), String> {
    let conv_id = {
        let mut session = state.ai_session.lock().await;
        let id = session.active_conversation_id.clone();
        // 标记当前所有 active 消息为 archived_segment
        for tm in session.messages.messages_mut() {
            if tm.message.is_active() {
                tm.message.status = Some("archived_segment".to_string());
                // token 从预算中扣除
                session.messages.history_tokens = 
                    session.messages.history_tokens.saturating_sub(tm.token_count);
            }
        }
        id
    };
    // 落库(全量含标记)
    if let Some(id) = conv_id {
        save_conversation(&state.ai_session, &state.db, &id, None, None).await;
    }
    Ok(())
}

不插分隔线 system 消息——前端按 status 渲染分隔即可,避免无意义的 system 消息污染 DB。

前端渲染archived_segment 消息折叠为灰色分隔条,点击展开。

┌─────────────────────────────────────────┐
│ ▸ --- 上下文已清理 (12 条消息) ---       │  ← 折叠态
├─────────────────────────────────────────┤
│ [当前段消息正常展示]                      │
└─────────────────────────────────────────┘

关键约束:标记时按三元组原子标记(一个 assistant(tool_call) + 其所有 tool_result 必须在同一段)。复用 build_eviction_units 的分组逻辑。

4.2 手动上下文压缩LLM 摘要)

语义:用户点「压缩上下文」→ 当前段所有 active 消息发给 LLM 生成摘要 → 摘要作为 system 消息替代原始消息。

IPCai_chat_compress_context

后端逻辑

pub async fn ai_chat_compress_context(state: State<'_, AppState>) -> Result<String, String> {
    // ① 取当前 active 消息 + provider 配置
    let (active_msgs, provider_config, conv_id) = { /* lock session, extract */ };
    
    // ② 构建 summary prompt复用 title.rs 的 generate_title_via_llm 模式)
    let provider = build_provider_for(&provider_config)?;
    let summary = compress_via_llm(&*provider, &provider_config.default_model, active_msgs).await?;
    
    // ③ 原始消息标记 compressed + 插入摘要 system
    {
        let mut session = state.ai_session.lock().await;
        for tm in session.messages.messages_mut() {
            if tm.message.is_active() {
                tm.message.status = Some("compressed".to_string());
            }
        }
        // 插入摘要作为新的上下文起点
        session.messages.push(ChatMessage::system(&format!("## 上下文摘要\n\n{}", summary)));
    }
    
    // ④ 落库
    save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
    
    Ok(summary)
}

摘要 prompt(放 prompt.rs

pub fn compress_prompt(lang: &str) -> &'static str {
    match lang {
        "en" => "Summarize the following conversation context. Preserve:\n\
                 1) User's core intent and requirements\n\
                 2) Key decisions made (which approach, which files)\n\
                 3) Files modified/created with what changes\n\
                 4) Important constraints or preferences\n\
                 Be concise. Output structured summary only.",
        _ => "请将以下对话总结为关键上下文摘要,保留:\n\
              1) 用户的核心意图和需求\n\
              2) 已做的关键决策(用了什么方案、改了哪些文件)\n\
              3) 已修改/创建的文件及变更内容\n\
              4) 重要约束或偏好\n\
              简洁输出结构化摘要,不要输出其他内容。",
    }
}

前端:压缩按钮 + loading 态(AiCompressing / AiCompressed 事件)+ 压缩后摘要卡片(可展开看原始消息)。

关键约束

  • 压缩是单向的(不能"解压缩"恢复 LLM 上下文,但 DB 原始消息保留可查看)
  • 压缩后 history_tokens 重算(仅含摘要 + 后续新消息)

4.3 智能裁剪(自动压缩)

语义agentic loop 每轮 build_for_request 前检测,超预算时自动压缩被淘汰的旧消息,而非直接丢弃。

核心约束:压缩不能在 build_for_request 内同步做(它是纯函数 + 持 session 锁 + LLM 调用 1-5 秒)。

落点agentic loop 循环体顶部,build_for_request 之前。

// agentic.rs run_agentic_loop 循环体内
for iteration in start_iteration..max_iterations {
    // ... stop_flag 检查 + 对话一致性校验 ...

    // ★ 智能压缩检查build_for_request 之前)
    {
        let session = session_arc.lock().await;
        let budget = session.messages.config().budget_limit();
        let available = budget.saturating_sub(sys_tokens);
        if session.messages.history_tokens() > available 
           && session.messages.has_compressible_messages(PROTECT_COUNT + 4) 
           && !session.messages.is_compressing()  // 防重入
        {
            // 标记 compressing 防重入
            session.messages.set_compressing(true);
            drop(session);
            
            // emit 前端「正在压缩…」
            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressing { 
                conversation_id: conv_id.clone() 
            });
            
            // 取被淘汰消息 → LLM 摘要 → 标记 compressed → 插入摘要
            compress_old_messages(
                &*provider, &provider_config.default_model, 
                &session_arc, sys_tokens, &conv_id, &llm_concurrency,
            ).await;
            
            let mut session = session_arc.lock().await;
            session.messages.set_compressing(false);
            drop(session);
            
            // emit 前端「压缩完成」
            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressed { 
                conversation_id: conv_id.clone() 
            });
        }
    }

    // 正常 build_for_request此时已压缩不超预算或缓解
    let messages = { /* build_for_request as before */ };
    // ... stream_llm ...
}

compress_old_messages 核心逻辑(抽为公共函数,手动/自动共用):

async fn compress_old_messages(
    provider: &dyn LlmProvider,
    model: &str,
    session_arc: &Arc<Mutex<AiSession>>,
    sys_tokens: u32,
    conv_id: &str,
    llm_concurrency: &LlmConcurrency,
) {
    // ① 计算淘汰边界(保护区 + 余量)
    let compress_end = {
        let session = session_arc.lock().await;
        let protect = session.messages.len().saturating_sub(PROTECT_COUNT + 4);
        protect
    };
    if compress_end == 0 { return; }

    // ② 取被淘汰的 active 消息
    let (to_compress, lang) = {
        let session = session_arc.lock().await;
        let msgs: Vec<ChatMessage> = session.messages.iter()
            .take(compress_end)
            .filter(|m| m.is_active())
            .map(|m| m.message.clone())
            .collect();
        (msgs, session.agent_language.as_deref().unwrap_or("zh"))
    };
    if to_compress.is_empty() { return; }

    // ③ LLM 摘要(复用 compress_prompt + complete(),对齐 title.rs 模式)
    let summary = compress_via_llm(provider, model, to_compress, lang, llm_concurrency).await;
    
    match summary {
        Some(s) => {
            // ④ 标记原始消息 compressed + 插入摘要 system
            let mut session = session_arc.lock().await;
            for i in 0..compress_end {
                if session.messages[i].message.is_active() {
                    session.messages[i].message.status = Some("compressed".to_string());
                    session.messages.history_tokens = 
                        session.messages.history_tokens
                            .saturating_sub(session.messages[i].token_count);
                }
            }
            // 插入摘要在压缩点
            session.messages.insert_at(compress_end, ChatMessage::system(
                &format!("## 上下文摘要\n\n{}", s)
            ));
            // 落库
            drop(session);
            save_conversation(session_arc, &db, conv_id, None, None).await;
        }
        None => {
            // LLM 摘要失败 → 降级为原有裁剪行为(不阻塞 loop
            tracing::warn!("自动压缩失败,降级为原有裁剪");
        }
    }
}

阈值参数

COMPRESSION_TRIGGER: history_tokens > budget_limit × 0.6 时触发
COMPRESS_KEEP_RECENT: PROTECT_COUNT(6) + 4 = 10 条(保护区外再留余量)

幂等has_compressible_messages() 检查保护区外是否存在 status=None/active 的消息。已标 compressed 的不参与二次压缩。is_compressing() 标志防重入。

降级LLM 摘要失败时,build_for_request 仍走原有裁剪逻辑(丢弃旧消息),不阻塞 loop。


5. 数据流(修订后)

ContextManager (内存)
  ├── messages[0..k]: status="compressed" (已压缩,不进 build_for_request)
  ├── messages[k]: system "## 上下文摘要\n..." (压缩摘要active)
  ├── messages[k+1..k+1+m]: status="archived_segment" (分段标记,不进)
  ├── messages[k+1+m..]: status=None (当前活跃段)
  
build_for_request:
  → sanitize_messages
    → step 0: filter is_active() → 剩 [摘要system] + [当前活跃消息]
    → step 1-3: 畸形三元组自愈
  → 超预算时仍裁剪(兜底,正常情况压缩后不超)

6. 三个功能的关系

用户场景时间线:
┌──────────────────────────────────────────────────┐
│ 段 a': 初始对话(改了5个文件)                      │
│   ↓ 用户点「清理上下文」                           │
│ 段 a'': 新话题(基于之前的修改继续)                  │
│   ↓ 对话变长,自动触发智能压缩                      │
│ 段 a''(压缩态): 旧消息被摘要替代                    │
│   ↓ 继续对话                                      │
│ 段 a''(续): 新消息追加在摘要后                      │
│   ↓ 用户主动点「压缩上下文」                        │
│ 段 a''(再压缩): 全部 active 消息被摘要替代           │
└──────────────────────────────────────────────────┘
维度 会话分段 手动压缩 智能裁剪
触发 用户点「清理上下文」 用户点「压缩上下文」 build_for_request 前自动检测
范围 当前段全部 active 消息 当前段全部 active 消息 仅被淘汰的旧消息(保护区外)
摘要 不生成摘要 全量摘要 部分摘要(仅淘汰部分)
status archived_segment compressed compressed
语义 开始新话题,旧的不可见 压缩全部,摘要替代 自动节约 token

三者共享底层机制:is_active() 过滤 + status 标记 + compress_via_llm 公共函数。


7. 实施清单

7.1 基础层(其他都依赖)

# 改动 文件 说明
B1 is_active() 改白名单 df-ai-core/provider.rs:79 matches!(status, None | Some("active"))
B2 push() 不计 !active 消息 token df-ai/context.rs:174 if message.is_active() { history_tokens += tokens }
B3 压缩 prompt 模板 commands/ai/prompt.rs compress_prompt(lang) 函数
B4 compress_via_llm 公共函数 commands/ai/ 新文件或 title.rs 扩展 复用 title.rs 的 complete() 模式
B5 ContextManager 辅助方法 df-ai/context.rs has_compressible_messages() / messages_mut() / history_tokens() / insert_at()

7.2 功能层

# 改动 文件 依赖
F1 IPC ai_chat_clear_context(会话分段) commands.rs + lib.rs B1
F2 IPC ai_chat_compress_context(手动压缩) commands.rs + lib.rs B1-B4
F3 agentic loop 自动压缩 agentic.rs B1-B5 + F2 的 compress_old_messages
F4 AiCompressing / AiCompressed 事件 mod.rs F3

7.3 前端层

# 改动 文件 说明
U1 clearContext() / compressContext() API api/ai.ts 两个新 invoke
U2 clearContext() / compressContext() 方法 useAiPanel.ts 调 API + 更新 state
U3 清理上下文按钮 + 压缩按钮 AiChat.vue 现有垃圾桶旁加两个按钮
U4 分段折叠渲染 AiChat.vue + useAiConversations.ts:79 archived_segment 过滤+折叠分隔条
U5 压缩摘要卡片 AiChat.vue compressed 消息折叠为摘要卡片
U6 自动压缩 loading 提示 useAiEvents.ts AiCompressing/AiCompressed 事件处理
U7 i18n aiChat.ts zh/en clearContext/compressContext/compressing/compressed 等 key

7.4 实施顺序

阶段1基础: B1 → B2 → B3 → B4 → B5
阶段2手动功能: F1 + F2 → U1-U5 + U7  (用户可立即用)
阶段3自动: F3 + F4 → U6  (智能裁剪)

阶段 1-2 完成后用户已有「会话分段 + 手动压缩」完整能力。阶段 3 是锦上添花(自动触发)。


8. 风险与约束

8.1 is_active() 改白名单的兼容性

改前 !matches!(Some("truncated")) = 除了 truncated 都 active。 改后 matches!(None | Some("active")) = 只有 None/active 才 active。

兼容性:现有 status 值只有 None/"active"/"truncated" 三种。改后 None"active" 仍 active"truncated" 仍不 active。零行为变化。新增的 archived_segment/compressed 自动不 active正是期望行为

8.2 工具调用三元组跨段/跨压缩

分段标记和压缩时,一个 assistant(tool_call) + 其 tool_result 必须在同一组。

保障:复用 build_eviction_unitscontext.rs:526已实现的三元组分组逻辑。标记时按组原子操作。

8.3 压缩后 token 计数

压缩后 ContextManager 的 history_tokens 只含摘要 system + 后续 active 消息。push() 修正后B2restore_from_messages 从 DB 恢复时 compressed 消息不计入 token。

8.4 智能压缩的延迟

LLM complete() 调用 1-5 秒,在 agentic loop 内同步等待。用户感知为「正在压缩上下文…」loading。

缓解:仅在超预算 60% 时触发(不是每轮),典型对话全程不触发。

8.5 摘要质量

LLM 可能遗漏关键信息。compress_prompt 四段式结构化(意图/决策/文件/约束最大化保留关键信息。摘要质量直接影响后续对话质量prompt 模板放 prompt.rs 可迭代调优。

8.6 前端渲染复杂度

现有消息列表是 v-for="msg in store.state.messages" 线性渲染,无分组概念。

最小方案archived_segment / compressed 消息各自折叠为分隔条/卡片,不引入复杂分组逻辑。useAiConversations.ts:79 过滤条件从 status !== 'truncated' 扩展为 !status || status === 'active'(与后端 is_active 白名单对齐archived_segment/compressed/truncated 全部从线性列表过滤掉,由专门的折叠组件渲染。


9. 涉及文件汇总

文件 改动类型
crates/df-ai-core/src/provider.rs B1: is_active() 改白名单
crates/df-ai/src/context.rs B2: push() token 修正 + B5: 辅助方法
src-tauri/src/commands/ai/prompt.rs B3: compress_prompt 模板
src-tauri/src/commands/ai/title.rs 或新文件 B4: compress_via_llm 公共函数
src-tauri/src/commands/ai/commands.rs F1+F2: 两个新 IPC
src-tauri/src/commands/ai/agentic.rs F3: loop 顶部自动压缩检查
src-tauri/src/commands/ai/mod.rs F4: AiCompressing/AiCompressed 事件
src-tauri/src/lib.rs 注册新 IPC
src/api/ai.ts U1: 两个新 invoke
src/composables/ai/useAiPanel.ts U2: clearContext/compressContext
src/composables/ai/useAiConversations.ts U4: 过滤条件扩展
src/composables/ai/useAiEvents.ts U6: 压缩事件处理
src/components/AiChat.vue U3+U4+U5: 按钮 + 分段折叠 + 摘要卡片
src/i18n/{zh-CN,en}/aiChat.ts U7: i18n key