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

535 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI Chat 上下文管理增强设计
> 性质: 功能设计(经代码勘察确认可行性 + 多角度分析)
> 日期: 2026-06-16
> 关联: [Agent 架构说明](Agent架构说明-2026-06-14.md)(现有裁剪机制盘点依据)
> 用途: 实施依据,实施方据此文档执行,不再二次设计
---
## 1. 背景与问题
### 1.1 现有裁剪机制的致命缺陷
当前 `ContextManager.build_for_request`context.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_clear`commands.rs:360`session.messages.clear()` + DB `clear_messages``messages='[]'`**历史彻底丢失**,无法回溯。
### 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 预算参数
```rust
// 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 消息分组(裁剪原子性)
```rust
// context.rs:115-123
enum MessageGroup {
Standalone, // 普通 User/Assistant 文本
ToolCallHead, // Assistant 带 tool_calls三元组的头
ToolResultTail, // Tool 结果消息(三元组的尾)
}
```
`build_eviction_units`context.rs:526已实现按三元组分组Head + 所有 Tail + 紧随的文本 Assistant 作为不可分割的淘汰单元。**分段标记和压缩可复用此分组逻辑保证原子性**。
---
## 3. 统一设计
### 3.1 ChatMessage.status 值域(统一)
```
None / "active" → 正常消息,进 LLM 上下文 + 前端可见
"truncated" → UX-09 编辑软删,不进上下文 + 前端隐藏
"archived_segment" → 会话分段标记,不进上下文 + 前端折叠可见
"compressed" → 被压缩替代,不进上下文 + 前端折叠可见
```
### 3.2 is_active() 改为正面白名单
```rust
// 改前(反面排除,每加一个新状态都要改)
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_segment``compressed` 自动被过滤,**零额外改动**。
### 3.3 push() / restore_from_messages() 的 token 计算修正
**问题**:当前 `push()` 不区分 status 全量计入 `history_tokens``restore_from_messages` 从 DB 恢复时 push 全量消息(含 compressed/archived_segment导致 token 虚高 → `build_for_request` 误判超预算 → 不必要的裁剪。
**修正**
```rust
// 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` → 后续新消息作为新段继续同一对话。
**IPC**`ai_chat_clear_context`
**后端逻辑**
```rust
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 消息替代原始消息。
**IPC**`ai_chat_compress_context`
**后端逻辑**
```rust
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`
```rust
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` 之前。
```rust
// 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` 核心逻辑**(抽为公共函数,手动/自动共用):
```rust
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_units`context.rs:526已实现的三元组分组逻辑。标记时按组原子操作。
### 8.3 压缩后 token 计数
压缩后 ContextManager 的 `history_tokens` 只含摘要 system + 后续 active 消息。`push()` 修正后B2`restore_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 |