# 上下文管理演进 — 任务技术设计 > 创建: 2026-07-20 | 状态: 设计阶段 | 关联文档: `上下文管理演进与发散思考-2026-07-20.md` --- ## 目录 - [T2 工具命名空间(详细设计)](#t2-工具命名空间详细设计) - [T4 工作流 DAG 注入(详细设计)](#t4-工作流-dag-注入详细设计) - [T3 L3 结构化摘要(设计概要)](#t3-l3-结构化摘要设计概要) --- ## T2 工具命名空间(详细设计) ### 现有流程 vs 新流程 ``` 现有流程: AiToolRegistry.execute(name, args) → 工具执行 → 全量结果 String → push(ChatMessage::tool_result(全量内容)) ← 不管大小,全塞进消息队列 → build_for_request 时,大 tool_result 撑爆预算 → 压缩/裁剪 → 丢失细节 新流程: AiToolRegistry.execute(name, args) → 工具执行 → 全量结果 String → if should_use_namespace(全量内容) → store_to_namespace(key, 全量内容) ← 存入独立存储 → push(ChatMessage::tool_result(引用路径)) ← 主队列只插引用 else → push(ChatMessage::tool_result(全量内容)) ← 小结果不动 → build_for_request 时,引用只占 ~20 tokens → 压缩不影响 namespace 内容 → 模型通过引用路径按需读取(或系统自动补全) ``` ### 数据结构 ```rust // crates/df-ai/src/namespace_store.rs /// namespace 引用路径格式: "namespace://tool_name/args_hash" /// 例: "namespace://read_file/a1b2c3d4" pub const NAMESPACE_REF_PREFIX: &str = "namespace://"; /// 落入 namespace 的字节阈值(> 2048 bytes) pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048; /// 落入 namespace 的行数阈值(> 50 行) pub const NAMESPACE_LINE_THRESHOLD: usize = 50; /// namespace 存储 /// /// 内存中为 HashMap,提供常规 CRUD; /// 外部可注入持久化实现(接 SQLite / 内存 fallback)。 /// 设计为独立于 ContextManager 的结构——不被压缩/裁剪影响。 pub struct NamespaceStore { /// key = 引用路径 hash, value = 原始工具结果 entries: HashMap, /// 总字节数上限(防内存爆炸,超限淘汰最旧条目) max_bytes: usize, current_bytes: usize, } pub struct NamespaceEntry { pub key: String, // 完整引用路径 pub tool_name: String, pub args: serde_json::Value, pub content: String, // 原始工具执行结果 pub content_summary: String, // extract_key_info 后的摘要(供 LLM 预览) pub created_at: Instant, pub access_count: u64, } ``` ### 关键决策点 #### 决策 1:何时进 namespace ```rust pub fn should_use_namespace(content: &str, tool_name: &str) -> bool { // 1. 明确标记为"大结果"的工具(read_file / list_directory 几乎总是大) if is_always_large_tool(tool_name) { return true; } // 2. 按大小阈值判定 content.len() > NAMESPACE_BYTE_THRESHOLD || content.lines().count() > NAMESPACE_LINE_THRESHOLD } fn is_always_large_tool(tool_name: &str) -> bool { matches!(tool_name, "read_file" | "list_directory" | "grep" | "diff_files") } ``` #### 决策 2:模型看到什么 LLM 在 tool_result 消息中看到的不是全量内容,而是: ``` 当前(小结果原样,大结果原样): [tool_result] 342 行文件内容...(占 2000 tokens) 新流程(引用路径 + 摘要预览): [tool_result] [tool_result] 文件: config.rs 行数: 342 关键节点: timeout:15 (行15), pool_size:10 (行42) ``` **LLM 的理解能力验证**:如果模型需要读取完整内容,system prompt 需说明: ``` [上下文说明] 当工具结果以 格式返回时,表示完整内容已存储。 如果你需要查看完整内容,回复 /read_namespace path 系统将自动拉取完整内容替换当前引用。 ``` 或自动更激进——当 LLM 的回复引用某 namespace 路径时,系统在下一轮自动拉取: ``` LLM: "config.rs 第 15 行的 timeout 需要改" 系统检测: /read_namespace 触发 → 从 namespace 拉取 config.rs 完整内容 → 下轮 tool_result 消息中替换为完整内容 → LLM 看到原文,精确引用行号 ``` #### 决策 3:持久化与生命周期 ```rust impl NamespaceStore { /// 存入 namespace pub fn store(&mut self, key: &str, tool_name: &str, args: &Value, content: &str) -> String { let summary = extract_key_info(content); // 复用现有 extract_key_info let entry = NamespaceEntry { key: key.to_string(), tool_name: tool_name.to_string(), args: args.clone(), content: content.to_string(), content_summary: summary, created_at: Instant::now(), access_count: 0, }; self.current_bytes += content.len(); // 超限淘汰:从最旧开始删 while self.current_bytes > self.max_bytes { // ... LRU 淘汰 } format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key) } /// 读取完整内容 pub fn read(&mut self, full_path: &str) -> Option<&str> { let key = self.parse_key(full_path)?; let entry = self.entries.get_mut(&key)?; entry.access_count += 1; Some(entry.content.as_str()) } /// 读取摘要(用于自动补全判断) pub fn read_summary(&self, full_path: &str) -> Option<&str> { let key = self.parse_key(full_path)?; self.entries.get(&key).map(|e| e.content_summary.as_str()) } } ``` ### 接线点:process_tool_calls 改动点位于 `audit/mod.rs::process_tool_calls`,工具执行完成后的 push 路径: ```rust // current (audit/mod.rs ~380行附近) pub(crate) async fn process_tool_calls(..., conv_id: &str) -> usize { // ... 现有审批/执行逻辑 ... for (index, draft) in tc_list { // ... 执行工具 ... let result = tools_arc.execute(&draft.name, &draft.args).await; // ★ 新增:namespace 判断 let content = if should_use_namespace(&result, &draft.name) { let session = session_arc.lock().await; let ns = &mut session.namespace_store; // AiSession 新增字段 let ref_path = ns.store(&draft.name, &draft.args, &result); ref_path // ← 主队列只推引用路径 } else { result // ← 小结果原样推 }; // 后续 push 不变 let msg = ChatMessage::tool_result(draft.tool_call_id.clone(), &content); session.conv(conv_id).messages.push(msg); } } ``` ### AiSession 新增字段 ```rust // src-tauri/src/commands/ai/mod.rs 或 state.rs pub struct AiSession { pub conversations: HashMap, pub current_conv: Option, pub generating: HashSet, // ★ 新增 pub namespace_store: NamespaceStore, } ``` ### 边界情况推演 #### 情况 1:模型不理解为 namespace 引用 ``` LLM 收到 [tool_result] LLM 回复: "读了 config.rs,但我不知道内容是什么" → 用户体验差 ``` **对策**:引用路径不是结束。在 `process_tool_calls` push 引用时,额外 push 一条 assistant 消息或修改 tool_result 内容格式: ``` [tool_result] 文件 config.rs (342 行) | 关键行: timeout:15 (行15), pool_size:10 (行42) | 完整内容: ``` 即:`extract_key_info(已有)+ 引用路径` 混合格式。LLM 可以靠摘要感知文件内容,只有需要精确行号时才触发 `/read_namespace`。 #### 情况 2:同一文件在 2 轮内被多次引用 ``` 第 3 轮: read_file("config.rs") → namespace 存 342 行 → 引用 第 5 轮: 模型需要第 15 行 → /read_namespace → 系统拉取 ``` 第 5 轮拉取后,第 6 轮应该做什么? **方案 A(推荐)**:拉取后仅在**当轮** `tool_result` 替换为完整内容。下一轮重新压缩时,如果内容大再次进入 namespace。 ``` 第 5 轮: [tool_result] config.rs 342 行全文(/read_namespace 触发的) 第 6 轮: 压缩 → config.rs 内容被压缩掉 → 正常 第 7 轮: 模型再次需要 → 再次 /read_namespace → namespace 还在 ``` **方案 B**:拉取后一直保留在消息队列中。 → 不推荐,回到了 tool_result 膨胀的老路。 #### 情况 3:namespace 内存爆炸 ``` 最大对话: 500 轮,平均每轮存 2 个 namespace 条目,每条约 3k bytes → 500 × 2 × 3k = 3MB ``` 设 `max_bytes = 10MB`(宽松上限),最旧条目自动淘汰。淘汰后如果有模型再次引用: ``` LLM: /read_namespace namespace://read_file/a1b2c3 系统: 条目已淘汰 → 重新执行工具 → 重新 namespace 存储 → 返回内容 ``` 等价于「cache miss」,对用户透明。 #### 情况 4:DB 持久化需不需要存 namespace **不需要**。namespace 是运行时缓存优化,不是持久化真相源。全量消息已在 `save_conversation` 写入 `ai_messages` 表(uncompressed),压缩后的摘要也在 system 消息中。namespace 淘汰后,可通过 DB 重新构建,但需要保证: ```rust // save_conversation 中 if msg.content.starts_with(NAMESPACE_REF_PREFIX) { // 写 DB 前将引用替换回原始内容(从 namespace 中取) // 替代方案:DB 也存引用路径,恢复时从 namespace 重建 } ``` **建议**:DB 存引用路径。恢复时 namespace 可能已淘汰,此时: 1. 重新执行工具(不可行——工具可能有副作用) 2. DB 保留原文(`save_conversation` 时展开引用) **结论**:DB 存原文。save 时 namespace 条目肯定存在(刚执行完),展开引用写入 DB。恢复时直接读 DB 原文,namespace 只服务于运行时。 ```rust // save_conversation 中展开引用 let content = if msg.content.starts_with(NAMESPACE_REF_PREFIX) { namespace_store.read(&msg.content) .unwrap_or(&msg.content) // 兜底:用引用路径自身(可能性低) } else { &msg.content }; record.content = content.to_string(); ``` ### 推演:T2 在 T-heavy 场景中的行为 ``` 第 3 轮: read_file("config.rs") → 342 行 应进 namespace ✓ 主队列: [tool_result] 文件 config.rs (342行), 行15:timeout, 行42:pool 主队列 token: ~80(→原来 2000) 节省: 1920 tokens 第 4-7 轮: 另有 3 次大工具结果 → 每次省 ~1500-2000 tokens 第 8 轮: 压缩触发 当前: 主队列总 history_tokens ≈ 5000(含 4 条引用) → 未达压缩阈值(原应为 20000+) 压缩不触发 ✓ 用户无感知 第 15 轮: 用户回来 "config.rs 的 timeout 在第几行?" LLM 在压缩摘要中仍能找到"行15:timeout" ← 摘要来自 extract_key_info → 精确回答 ✓ 不需要 /read_namespace 第 16 轮: "删掉那行,改成 30" LLM 需要完整文件结构来做 diff → /read_namespace namespace://read_file/xxx → 系统拉取完整 342 行 → 第 16 轮 tool_result 出现完整内容 → 第 17 轮压缩 → 完整内容又被 namespace 吸收 → 回到摘要+引用 ``` **总节省**:15 轮对话中,原本平均每轮 4000 tokens 的大 tool_result 占用 → 现在每轮 ~100 tokens 引用 + 摘要。**累积节省 ~60k tokens,约 $0.18(Sonnet)。** --- ## T4 工作流 DAG 注入(详细设计) ### 现有流程 vs 新流程 ``` 现有: run_agentic_loop system_prompt 拼接: [系统指令] + [工具定义] + [pinned_goals] + [知识注入] → build_for_request → LLM 只能从消息历史中推断"当前在做什么" 新流程: run_agentic_loop system_prompt 拼接前: if conv.workflow_id != None: dag = df_workflow::Dag::load(conv.workflow_id) dag_block = render_dag_to_system_block(dag) system_prompt = dag_block + system_prompt_原有内容 → build_for_request(system_prompt + ...) → LLM 看到结构化 DAG,"任务进展"一目了然 ``` ### 数据结构 ```rust // src-tauri/src/commands/ai/workflow_context.rs /// 工作流上下文块 — 注入 system prompt 的 DAG 摘要 pub struct WorkflowContextBlock { pub workflow_id: String, pub workflow_name: String, pub total_nodes: usize, pub completed_nodes: usize, pub current_node: Option, pub next_nodes: Vec, } /// DAG 节点摘要 — 不进完整 DAG,只进关键上下文 pub struct WorkflowNodeSummary { pub node_id: String, pub node_type: String, // "AINode" | "ScriptNode" | "HumanNode" | ... pub label: String, // 用户或 LLM 设定的节点名称 pub status: String, // "completed" | "running" | "pending" | "blocked" pub output_summary: Option, // 节点输出的摘要(关键产出) } ``` ### 关键决策点 #### 决策 1:DAG 全量注入还是摘要注入 ``` DAG 可能很大(50+ 节点)。全量注入 ≈ 2000+ tokens,不可接受。 ``` **方案**:只注入「当前活跃路径」。从 DAG 的起始节点到当前节点 + 后续 2 层子节点。其余节点不注入。 ```mermaid graph LR subgraph "DAG 全量(50 节点)" N1["N1 ✅"] --> N2["N2 ✅"] N1 --> N3["N3 ✅"] N2 --> N4["N4 🚧 当前"] N2 --> N5["N5 pending"] N3 --> N6["N6 pending"] N4 --> N7["N7 pending"] N5 --> N8["N8 pending"] N6 --> N9["N9 pending"] N3 --> N10["N10 pending"] N4 --> N11["N11 pending"] end subgraph "注入内容(当前活跃路径,5 节点)" I1["N1: 读源码 ✅"] I2["N2: 分析依赖 ✅"] I3["N4: 改配置 🚧"] I4["N5: 验证配置 pending"] I5["N7: 提交变更 pending"] end ``` ```rust pub fn build_active_path(dag: &Dag, current_node_id: &str) -> WorkflowContextBlock { let path = dag.path_to_root(current_node_id); // 到根的全路径 let next = dag.children(current_node_id, 2); // 后续 2 层 WorkflowContextBlock { workflow_id: dag.id.clone(), workflow_name: dag.name.clone(), total_nodes: dag.nodes.len(), completed_nodes: dag.nodes.iter().filter(|n| n.status == "completed").count(), current_node: summarize_node(dag.get_node(current_node_id)), next_nodes: next.into_iter().map(|n| summarize_node(n)).collect(), } } ``` #### 决策 2:注入位置 注入 system prompt 的最前方(优先级高于工具定义): ``` [system] [工作流] 审批模块改造 (ID: wf-abc) ✅ 1/3 读 config.rs 源码 — 产出: timeout:15 位于行15 🚧 2/3 改 timeout 配置 — 当前步骤 ⬜ 3/3 验证配置生效 ────────────────────────────────────── [工具定义] 30 个工具的 JSON schema... [目标提示] ... [system prompt 原有内容] ``` **理由**:工作流状态是会话的最上层语境。模型先看到"我们在做什么",再看到"有什么工具可用"。 #### 决策 3:无工作流时的行为 ```rust if let Some(wf_id) = &conv.workflow_id { if let Ok(dag) = df_workflow::Dag::load(db, wf_id) { let block = build_active_path(&dag, &conv.current_node_id); system_prompt = format!("{}\n{}", block.to_system_text(), system_prompt); } // Dag::load 失败 → 静默跳过(workflow 可能被删了) } // workflow_id == None → 行为完全不变 ``` ### 推演:T4 在 T-resume 场景中的行为 ``` 上午: 用户在工作流 wf-abc 中: N1: 读源码 ✅ N2: 分析依赖 ✅ N3: 改配置 🚧(当前) 最后操作: 改了一半配置,离开 下午回来: 现有: 消息历史被压缩 → 摘要"用户讨论过配置修改" → LLM 答:"你讨论了配置,要继续吗?" → 用户需要重新说明 T4: system prompt 注入: [工作流] 审批模块改造 ✅ 1/3 读 config.rs — timeout:15 ✅ 2/3 分析 db.rs 依赖 — 连接池:10 🚧 3/3 改 timeout 配置 — 已设为30,未验证 → LLM: "你上午在改 timeout 配置,已经改了还没验证, 要继续验证还是改其他?" → 用户直接继续,不需要重新说明 ``` **精度依赖**:`WorkflowNodeSummary.output_summary` 是关键。如果工作流节点在完成时已有结构化的产出记录(`read_file → 行号:timeout:15`),LLM 就能精确回溯。这需要 df-workflow 的节点在完成时主动记录产出摘要——目前不一定有。 **缺口**:如果工作流节点跑完但没有产出摘要(`output_summary: None`),LLM 只能看到"节点已完成",不知道完成了什么。`output_summary` 需要在 `AiNode::execute` 完成时自动产生: ```rust // df-nodes/src/ai_node.rs: 执行完成后 if let Some(workflow_node_id) = current_workflow_node { let summary = extract_key_info(&result); // 复用 workflow::update_node_output(db, workflow_node_id, summary); } ``` 这个联动在 T4 之前需要确保。 ### T4 + T2 组合推演 ``` T2 提供了 namespace 精确行号回溯 T4 提供了工作流节点结构化映射 组合: LLM: "config.rs 的 timeout 在哪一行?" T4 DAG 节点: "N1: 读 config.rs" T2 namespace: 关联 namespace://read_file/xxx 到 N1 的产出 → LLM 从 DAG 节点取到 "timeout:15 位于行15" → 不需要调用 /read_namespace → 精确行号回答,零额外 token 如果 T2 没有、T4 没有: LLM: 从压缩摘要猜 → 可能错 如果 T2 有、T4 没有: LLM: 从 DAG 节点取出 "读 config.rs",但需要/read_namespace 拿行号 → 多一次 tool round 如果 T2 没有、T4 有: LLM: DAG 节点只有 "读源码 ✅",没有行号细节 → 重新 read_file 组合后: 最大精度,最小 token 开销。 ``` --- ## T3 L3 结构化摘要(设计概要) ### 改 compress_via_llm 返回类型 ```rust // current pub(crate) async fn compress_via_llm(...) -> Result; // new pub struct CompressedSummary { /// JSON 卡片序列化字符串 pub json_card: String, /// 自然语言摘要(向前兼容) pub nl_summary: String, } pub(crate) async fn compress_via_llm(...) -> Result; ``` ### 改 compress_prompt ```rust pub(crate) fn compress_prompt(lang: &str) -> &'static str { match lang { "en" => "You are a conversation summarizer. Compress the following conversation \ into a structured summary that preserves the essential context for \ continuing the work. \ \n\ **You MUST output TWO parts separated by a delimiter:**\n\ \n\ Part 1 — JSON (between <<>> and <<>>):\n\ {\n\ \"topics\": [\"topic1\", \"topic2\"],\n\ \"decisions\": [{\"what\": \"...\", \"why\": \"...\"}],\n\ \"unresolved\": [\"question1\"],\n\ \"key_files\": [{\"path\": \"...\", \"change\": \"...\"}],\n\ \"token_saved\": \n\ }\n\ \n\ Part 2 — Natural language summary (between <<>> and <<>>):\n\ A concise paragraph summarizing the conversation.\n\ \n\ Rules:\n\ - JSON must be valid.\n\ - Keep the natural language summary concise; prefer bullet points.\n\ - Preserve file paths, identifiers, and error messages verbatim in both parts.\n\ - Drop small talk; keep only technically load-bearing facts.\n\ - Do NOT invent facts.\n", _ => { // 中文版本同上,翻译为中文 } } } ``` ### insert_at 处的消费 ```rust // context_lifecycle.rs: 压缩成功后 match compress_outcome { Ok(Some(summary)) => { // ★ 新格式:JSON + NL 双格式嵌入 system 消息 let system_text = format!( "{}<<>>\n{}\n<<>>\n{}", SUMMARY_MARKER, summary.json_card, summary.nl_summary, ); conv.messages.insert_at(0, ChatMessage::system(&system_text)); // ... } Ok(None) => { /* noop */ } Err(e) => { // LLM 压缩失败 → 关键词摘要兜底(同前) // 关键词摘要是纯文本格式,不需要 JSON 结构 // → insert_at(0, system(keyword_fallback)) ← 与旧行为一致 } } ``` ### 退化检测适配 当前 `clean_summary` 函数只处理纯文本退化。新格式引入后,退化检测需增加 JSON 解析验证: ```rust fn clean_summary(raw: &str) -> Result { // 提取 JSON 段 let json = extract_between(raw, "<<>>", "<<>>")?; let nl = extract_between(raw, "<<>>", "<<>>")?; // 验证 JSON 合法性 let card: SummaryCard = serde_json::from_str(&json) .map_err(|e| format!("JSON 解析失败: {}", e))?; // 退化检测 NL 段 if is_degenerated_repetition(&nl) { return Err("NL 段退化重复".to_string()); } Ok(CompressedSummary { json_card: json, nl_summary: nl, }) } ``` ### 推演:T3 在检索场景中的行为 ``` 无 T3(当前): 压缩摘要: "用户讨论了审批配置,把 timeout 改成了 30" LLM 读取后知道"改过",但具体决策原因不清晰 → 需要推测或 ask user 有 T3: system 消息中嵌入: <<>> {"decisions": [{"what": "timeout: 15→30", "why": "用户反馈15min太短"}]} <<>> 自然语言: 用户讨论审批配置,决定超时改为 30 分钟 LLM 直接从 JSON 读取决策原因: → "15→30,原因是用户反馈太短" → 精确回答 ✓ → 不需要 ask user ``` **收益量化**:每次压缩后,JSON 卡片占 ~300 tokens,自然语言占 ~200 tokens。比纯自然语言的 ~400 tokens 多了 ~100 tokens。但 JSON 的结构化让 LLM 的检索精度从「需要推理」变为「可以直接读」,减少了后续追问的轮次。 --- ## T1 动态压缩阈值(设计概要) 代码改动精确到行: ```rust // src-tauri/src/commands/ai/agentic/mod.rs: 调用处(line ~1117) if maybe_auto_compress( &session_arc, &conv_id, &app_handle, &provider, &provider_config, &llm_concurrency, iteration, sys_tokens, // ★ 新增参数 ).await { ``` ```rust // src-tauri/src/commands/ai/agentic/context_lifecycle.rs: 函数签名(line ~72) pub(super) async fn maybe_auto_compress( session_arc: &Arc>, conv_id: &str, app_handle: &AppHandle, provider: &Box, provider_config: &AiProviderRecord, llm_concurrency: &LlmConcurrency, iteration: usize, sys_tokens: u32, // ★ 新增 ) -> bool { ``` ```rust // 同上,line ~88-92 触发条件 let budget = mgr.budget_limit(); let available = budget.saturating_sub(sys_tokens); // ★ 新增 let should = protect_start > 0 && (available as u64) * 6 / 10 < history_tokens as u64 // ★ 改 budget→available && mgr.has_compressible_messages(protect_start); ``` --- ## 实施顺序验证(依赖关系) ```mermaid graph LR T1["T1: 动态阈值
~4 行改动"] --> T2["T2: 命名空间
~300 行"] T1 -.->|可选前置| T3["T3: 结构化摘要
~100 行"] T2 --> T4["T4: 工作流DAG
~500 行"] T3 --> T5["T5: WorkingContext
~500 行"] T2 -.-> T4 T3 -.-> T4 style T1 fill:#c8e6c9 style T2 fill:#c8e6c9 style T3 fill:#fff9c4 style T4 fill:#fff9c4 style T5 fill:#ffccbc ``` **并发策略(AI coding 多 agent 并行)**: | 并行流 | 任务 | 前置 | 可独立启动? | |--------|------|------|------------| | 流 A | T1 + T2 | T1 无前置,T2 无前置 | ✅ 立即,T1 与 T2 可并行编码 | | 流 B | T3 | 无(与 T2 独立) | ✅ 可同时启动 | | 流 C | T4 | 依赖 T2 的 namespace 接口签名(非实现) | ⚠️ 接口定义后即可启动 | | 流 D | T5 | 依赖 T3 的 CompressedSummary 结构 | ⚠️ T3 验收后 |