Files
DevFlow/docs/02-架构设计/专项设计/上下文分层组装器-落地方案-2026-10-10.md
T
lxy 8df4e33656 文档: 看板销账与登记 + 设计档六份 + 索引补录
- 看板: 907 补注/909~941 登记与销账(含 929 模型停用根因与 4 处次生缺口、931 队列丢失、933/935/937 新需求、939 上下文瓶颈清单、941 C-r0 行为变更待拍板)
- 归档: 全量完成态条目/段落 + 只读核验销账 4 段迁 todo归档/2026-10-10.md(机械搬运零措辞改动,未完成项零丢失)
- 设计档: 输入框列表预览与自动编号 / 定时消息与待发箱持久化(统一 ai_outbox 底座) / 对话内定时延时任务 / 消息渲染管线重构(P0-P2 分期) / 上下文分层组装器落地方案(记账基点单一真值,与并行写者职责划清) / FileTree 大目录方案(推荐后端分页,否决虚拟滚动) / u-relay 运维手册
- 索引与事实修正: docs/INDEX crate 数 8→13 + 四子树补录 25 条 + 专项设计补录 27 条;纠正「项目无独立 ARCHITECTURE.md」等过时声明;统一 bun 口径
- CLAUDE.md: 新增「命名与代码风格」节(变量/方法名精简+准确;DRY·简洁·扩展性·高内聚低耦合·单一真值来源)
2026-10-10 08:09:25 +08:00

21 KiB
Raw Blame History

上下文分层组装器 — 落地方案

日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点 定位: 《上下文根治设计-20261006.md》§一(L1/L2/L3)/§二(P1 瘦身)/§九(v1 锁定)/§十(检索面统筹)的落地细化,不另立设计。 覆盖 Wave 上下文走查实测五病灶:① history_tokens 与真实体量脱钩;② 每轮全量深克隆+展开 O(n²);③ 预算恒 128K;④ 裁剪后前缀缓存整体失配;⑤ L1/L2/L3 未落地、窗口外历史无召回。 前置提醒: crates/df-ai/** 正由另一写者改动(P1 工具结果外置 WIP,见 §六),本方案明确与其重叠面与对接协议。


一、现状勘察(锚点实测)

1.1 发送链路的真实数据流(五病灶的公共底座)

每一轮 LLM 请求的消息来源分四步,散落在两处:

步 锚点 动作
① 裁剪 crates/df-ai/src/context/mod.rs:185-273 build_for_request(sys_tokens) 按 history_tokens 判预算,超则从头部淘汰轮次单元
② 展开 src-tauri/src/commands/ai/agentic/mod.rs:1927-1944 逐条把 namespace:// 引用展开回真实内容
③ 拼装 agentic/mod.rs:1945-1952 前置 system_prompt,尾部 merge_volatile_tail(agentic/stream_relay.rs:789)
④ 瘦身 agentic/mod.rs:1968-1988 → stall_detector.rs:284-385 summarize_tool_results view-only 摘要压缩大 tool_result(extract_key_info)

1.2 五病灶逐条锚点

① history_tokens 与真实体量脱钩(根因,最重)

  • 工具结果在 push 前先被引用化:audit/mod.rs:1184-1191,should_use_namespace(namespace_store.rs:47-53)命中 → store()(namespace_store.rs:124-131)入内存,消息里只留短串 namespace://tool/{hash}。
  • ContextManager::push(context/mod.rs:101-143)对这条短串跑 estimate_message(context_helpers.rs:62-105),history_tokens += tokens 在 :108-110。
  • 但发送时才在 agentic/mod.rs:1927-1944 展开成真实体量(单条 max 150KB,见 P1 注释 context_helpers.rs:427-432)。
  • 结果:history_tokens 记的是引用串(~30 字符),真实发送是全文。预算门 context/mod.rs:198(history_tokens <= available)与压缩门 context_lifecycle.rs:133-135(available*6/10 < history_tokens)双双恒不触发 → 每轮全量重发 → 223:1。

② 每轮 O(n) 全量克隆+展开+压缩 → 会话累计 O(n²)

  • 未超预算分支 context/mod.rs:198-205 走 all_messages_clone()(:294-296,逐条深克隆);
  • 展开 agentic/mod.rs:1927-1944 对全部历史逐条 read_only + String 克隆;
  • 压缩 summarize_tool_results(stall_detector.rs:313-385)再遍历全部消息做内容变换。
  • 三者每轮各 O(n),会话累计 O(n²)。根因与①同源:门失效 → 窗口永不收敛 → n 随会话单调涨。

③ 预算恒 128K,与模型真实窗口无关

  • 主会话 PerConvState::new(commands/ai/mod.rs:1241)= ContextManager::new(ContextConfig::default()),max_tokens=128_000(context_helpers.rs:128-136),budget_limit 由它算(:138-143)。
  • 真实窗口在 ModelConfig.context_window(crates/df-ai-core/src/model.rs:138-139),但只在 router 用,而代码自陈该维度"恒过、失效"(agentic/mod.rs:2012 注释)。
  • 子代理同病:sub_agent.rs:119 也是固定 128_000。

④ 裁剪后前缀缓存整体失配

  • 超预算时 context/mod.rs:239-246 切 self.messages[trim_end..]——头部被切,整个请求前缀位移,provider 自动前缀缓存(DeepSeek/Anthropic)整段失效。
  • 已有部分缓解:淘汰摘要改尾部追加(context/mod.rs:259-261)、push_system_anchor 避让悬空头(:582-595)、tool_view_decisions 冻结压缩决策防跨轮翻转(stall_detector.rs:352-363)。但基础裁剪本身仍位移前缀,且淘汰摘要内容逐轮漂移(被淘汰区单调增长,:259 每轮即时重生成)。

⑤ L1/L2/L3 未落地 + 窗口外历史无召回

  • 组装是隐式的:system(L1 内容)由 build_runtime_prompt(agentic/mod.rs:3042)拼,历史由 build_for_request 拼,易变尾由 merge_volatile_tail 拼,无统一"组装器"对象、无可观测统计。
  • 检索仅覆盖知识库:hybrid_search(knowledge_inject.rs:421)→ build_knowledge_context(:508)→ build_knowledge_context_for_conv(:587);窗口外对话历史与压缩摘要无处可召回。

1.3 与既有设计的衔接

  • 本方案 = 根治设计 §一 的接口化落地;上下文三层记忆-设计-2026-10-04.md 的"记忆卡"(C-m1)是 L3 的记忆卡来源,本方案不重复其产出逻辑,只提供召回入口。
  • §九 v1 锁定范围(P1 瘦身+P2 页池+P3 压缩常规化+P4 评估)不变;本方案细化 P2,并把 §九"唯一存储/唯一调度器/唯一召回入口"三收敛落到具体接口。

二、目标与验收口径

目标

  1. 账实相符:预算/压缩门依据的 token 数 = 实际发送视图的体量(单一真值来源)。
  2. 组装显式化:L1 常驻 / L2 滑动窗口 / L3 检索召回 三段各有明确产出与稳定合并顺序;附带可观测统计。
  3. 前缀稳定:稳态下请求前缀只追加、不位移;裁剪以批量滞后发生。
  4. 预算随模型:上下文预算取自当前模型的 context_window,非恒定 128K。
  5. 可召回:窗口外历史/记忆卡经统一检索面按需注入,复用 knowledge_inject 管线。

验收口径

  • 账实相符:history_tokens 与"发送视图 estimate_message 求和"偏差 ≤ 5%(构造含大 namespace:// 引用的会话实测)。
  • 门生效:构造超预算会话,context_trimmed(context/mod.rs:263)按真实体量触发;压缩门 context_lifecycle.rs:133 在正确水位触发。
  • 前缀稳定:稳态连续 N 轮,请求消息数组的公共前缀长度 = 全量(无裁剪)或 = system 段(裁剪后仅在再裁剪时才变);provider 缓存命中率不再掉到个位数。
  • 预算随模型:context_window=256K 的模型下 budget_limit = 256K×0.85−8K 量级,不再恒 128K。
  • 可召回:窗口外某历史事实,仅凭关键词在下一轮被召回注入(带来源标注)。
  • 不倒退:单轮/短会话(未超预算)行为与改造前逐字节一致;df-ctx-* 开关全关 = 旧路径。

三、设计方案

3.0 核心判断

五病灶同源于一个断裂:"保留形态"与"发送形态"两套尺寸,门只看了前者。因此不新增存储、不另立调度——本方案只做三件事:

  1. 把"门所依据的尺寸"与"发送形态"对齐(记账基点 = 发送视图尺寸);
  2. 把隐式拼装收敛为一个显式组装器(三段产出 + 稳定顺序 + 统计);
  3. 把检索面从"仅知识库"扩为注册表(知识库 + 历史页 + 记忆卡同入口),落到 build_knowledge_context_for_conv 单点。

3.1 单一真值来源的 token 估值(治 ①,兼解 ②)

两法对比

法 做法 评价
A 记账点算"发送视图尺寸" 在内容产生点(及 reload)按 view_form(msg) 估 token——大工具结果按 extract_key_info 后的摘要+句柄尺寸入账 O(1)/条、纯函数、与发送形态同函数(单一真值)、不依赖 namespace LRU 状态
B 估值器识别 ref 查真实大小 estimate_message 遇 namespace:// 前缀 → 查 store 取真实内容再估 破坏 TokenEstimator 纯函数性(需注入 store)、每轮 O(n) 查+扫(加重②)、引用被淘汰 / 重启后查不到 → 退回串长仍脱钩

取 A。理由:A 让"记账"与"发送"共用同一个纯函数,是真值来源唯一化的最短路径;B 把复杂度塞进每轮热路径且对淘汰态脆弱。A 单点落在估值函数,正是 context_helpers.rs 的职责域(与既有 should_summarize_tool_result/extract_key_info 同居一文件,DRY)。

接口形态(新增,minimal)

// crates/df-ai/src/context_helpers.rs — 与 extract_key_info 同域,共享同一压缩逻辑
/// 发送视图尺寸(记账唯一口径):与发送路径"展开→summarize_tool_results"后的形态对齐。
/// 大 tool_result → extract_key_info 结果的 token;其余 → 内容 token。
pub fn view_form_tokens(estimator: &TokenEstimator, content: &str, tool_name: Option<&str>) -> u32;

// crates/df-ai/src/context/mod.rs — push 的带尺寸覆盖入口(记账用)
impl ContextManager {
    /// 同 push,但 token_count 用调用方给定的发送视图尺寸(工具名在产生点可得)。
    pub fn push_with_tokens(&mut self, message: ChatMessage, tokens: u32);
}

改动点

  • 产生点 audit/mod.rs:1184-1191:此处已握有真实 content 与 draft.name,算 view_form_tokens(...) 后走 push_with_tokens,取代 push 的 content 估值。删掉 push 的隐式 ref 估值这条唯一失真源。
  • 其余重估入口(replace_tool_result_content context/mod.rs:332、replace_last_active_user_content :418)同改走 view_form_tokens,保证单一口径。
  • reload 一致性:DB 存的是展开后的真实内容(namespace_store.rs:3-4 注释 / agentic/mod.rs:1922),故 restore_from_messages(context/mod.rs:310)须同样按 view_form_tokens 记账,否则"新会话(视图尺寸)≠ 重载(真实尺寸)"两态不一致。工具名从历史重建(build_id_to_name,stall_detector.rs:300)取得(见拍板点 2)。
  • ② 的化解:① 修好后门下移到位 → 裁剪按真实体量发生 → 发送窗口收敛为常数 W → 每轮克隆/展开/压缩从 O(n) 降到 O(W),会话累计从 O(n²) 降到 O(n·W)。②无需单独"优化",随①自愈。

3.2 分层组装的接口形态(治 ②④⑤)

现状:拼接散在 context/mod.rs:185-273(窗口裁剪)与 agentic/mod.rs:1945-1952(system+尾)。改为一个显式组装器,产出结构化的三段 + 易变尾 + 统计:

// src-tauri/src/commands/ai/agentic/assembler.rs (新文件;或收敛进现有 helpers.rs)
/// 组装器产出:有序三段 + 易变尾 + 可观测统计。
pub struct AssembledContext {
    pub resident: Vec<ChatMessage>,  // L1:system 段(见 3.2.1)
    pub window:   Vec<ChatMessage>,  // L2:滑动窗口(三元组对齐的发送视图)
    pub recall:   Vec<ChatMessage>,  // L3:检索召回(user 角色,易变,固定置尾)
    pub volatile: Option<String>,    // 既有易变尾(日期/清单/knowledge)
    pub trimmed:  bool,
    pub stats:    AssemblyStats,     // 页命中/召回数/换出量/system 估算
}

/// 合并顺序固定:resident → window → volatile → recall,全部只追加不位移前缀。
pub fn assemble(ctx: &ContextManager, sys_tokens: u32, recall: Vec<ChatMessage>, volatile: Option<String>)
    -> AssembledContext;

合并顺序与缓存友好的三条不变式

  1. L1 resident 最前且为 loop 不变量:build_runtime_prompt(agentic/mod.rs:3042)产出,pinned_goals/工作上下文/技能/DAG 均在此,整个 loop 文本不变 → 稳定缓存前缀最大块。
  2. L2 window 追加为主:build_for_request 的裁剪保留区(context/mod.rs:243-246)即窗口。三元组原子性沿用 build_eviction_units(:612-642)的单元边界,窗口切片必落单元边界。稳态只 push 追加,前缀不变。
  3. L3 recall + volatile 严格置尾(role=user):召回复用 merge_volatile_tail(stream_relay.rs:789)的"末尾 user 承载"范式——易变内容不进缓存前缀。召回绝不插进 window(避免劈三元组、位移前缀)。

与 build_for_request 的关系:build_for_request 保留为 L2 的纯函数内核(签名与三元组逻辑不动),组装器在其上"包一层":调 build_for_request(sys_tokens) 取窗口 → 前置 resident → 追尾 volatile/recall → 填 AssembledContext。即"内核不改行为,组装器负责编排"。调用点 agentic/mod.rs:1910-1953 从内联拼装改为 assemble(...) 单调用。

前缀稳定性(治④)

  • 滞后水位批量裁剪:trim 触发用高水位(high = available),一次裁到低水位(low = available × k,建议 k≈0.6),使裁剪稀疏发生;两次裁剪之间窗口 append-only,前缀稳定。替代现行"每轮才够就裁一单元"(context/mod.rs:211-219)。
  • 淘汰摘要冻结:现行摘要每轮重生成致漂移(:259)。改为与 tool_view_decisions 同款按内容哈希冻结(首次生成后沿用),前缀稳定。
  • 随①自愈:① 使门在正确水位触发,裁剪频次本就下降。

3.3 检索召回复用 knowledge_inject 管线(治⑤,兼§十统筹)

现状单入口:两个调用点(agentic/helpers.rs:150、commands/chat.rs:398)都经 build_knowledge_context_for_conv(knowledge_inject.rs:587),内部 hybrid_search(:421)+ merge_hybrid_results(:468)排序。

改为注册表(§十"召回入口统一"的落地)

// src-tauri/src/commands/ai/knowledge_inject.rs — 扩为召回面
type RecallItem = (df_storage::models::KnowledgeRecord, RecallKind); // 复用既有 record 形态
enum RecallKind { Knowledge, HistoryPage, MemoryCard }

/// 各源实现同一检索签名;合并复用 merge_hybrid_results 的排序/去重/limit 语义。
async fn recall(query, limit, ...) -> Vec<RecallItem>  // 内部:知识库(既有) + 历史页(新) + 记忆卡(新)
  • 入口不动:build_knowledge_context_for_conv(:587)与 inject_knowledge_into_prompt(:633)保持签名,内部从"只查知识库"扩为"查注册表全部源",输出按源标注([知识]/[历史]/[记忆卡])。调用点零变更。
  • HistoryPageSource(v1 当前会话):query 复用末条 active user 文本(knowledge_inject.rs:592-601 已取);扫描当前 conv.messages 中 !is_active(compressed/archived) 与窗口外消息,关键词重叠排序取 top-K。零新存储——数据已在 ContextManager.messages 全量保留(context/mod.rs:104-107)。
  • MemoryCardSource:压缩摘要(ChatMessage::system(&summary.nl_summary),context_lifecycle.rs:294 尾部追加)与 上下文三层记忆 C-m1 的记忆卡,作为可召回条目注册。v1 可与 HistoryPageSource 合并实现。
  • 配额闸(§十原则4):召回页每轮 ≤K(建议 5),配额独立于知识注入既有上限,防信息炸弹。

3.4 预算随模型窗口(治③)

  • 预算来源改为当前模型的 ModelConfig.context_window(df-ai-core/src/model.rs:138-139)——该值已在 AiProviderRecord.model_configs(df-storage/src/models.rs:306+,经 deserialize_model_configs 解析)可得,loop 顶部已持 provider_config。
  • 落地:ContextManager 增 set_max_tokens 或在派发前用模型窗口重建 ContextConfig;sys_tokens 估算点(agentic/mod.rs:1115)不变。
  • 缺省回退:当前模型无 context_window/为默认值时,沿用 128K(零回归)。

3.5 边界总览(三段职责)

L1 resident  ← build_runtime_prompt (agentic/mod.rs:3042)      常驻,loop 不变量,缓存前缀
L2 window    ← build_for_request  (context/mod.rs:185)          滑动,三元组对齐,记账=view_form
L3 recall    ← recall registry    (knowledge_inject.rs:587 扩)  按需,置尾,配额闸

四、分期(每期独立可上线、可回退)

P2.1 — 真值 token 估值(治①,兼解②)【S,最先】

  • 新增 view_form_tokens(context_helpers.rs)+ push_with_tokens(context/mod.rs)。
  • 改 audit/mod.rs:1191 push 站点与重估入口(context/mod.rs:332/:418)、reload 路径(:310)。
  • 开关 df-ctx-accurate-tokens(默认 true;false = 旧 content-based 记账,逐字节回退)。
  • 验收:账实相符 + 门生效两条。此期不动发送内容,零行为变化。

P2.2 — 组装器结构化(治④,为 P2.3 铺路)【M】

  • 新增 AssembledContext + assemble();agentic/mod.rs:1910-1953 内联拼装改单调用;加 AssemblyStats 日志/事件。
  • trim 滞后水位 + 淘汰摘要冻结。
  • 行为等价重构(除 trim 时机稀疏化,可单开关 df-ctx-trim-hysteresis 默认 true)。
  • 验收:前缀稳定 + 不倒退两条。

P2.3 — L3 检索召回(治⑤)【M】

  • knowledge_inject.rs 扩为召回注册表 + HistoryPageSource(v1 当前会话)+ 源标注;配额闸 ≤K。
  • 开关 df-ctx-recall(默认 off,择机灰度开)。
  • 验收:可召回一条。

P2.4 — 预算随模型窗口(治③)【S】

  • 接 ModelConfig.context_window;缺省回退 128K。开关 df-ctx-model-window(默认 true)。
  • 验收:预算随模型一条。

P2.5 — 页池统一【北极星,触发制,不在本方案实施】

  • 触发条件:单会话版实测后仍有跨会话/内存痛点(§九问题2);或将 namespace_store(内存)与 tool_offload(磁盘)合流为唯一内容寻址页池(§九问题1 收敛)。
  • 本方案只预留接口边界(见 §六),不实施。

顺序:P2.1(最大杠杆、最小改动)→ P2.2(结构)→ P2.4(廉、高值,可与 P2.2 并行)→ P2.3。


五、影响面(五维)

  • 兼容:P2.1 只改记账,不改发送内容;老会话 reload 后按 view_form_tokens 重算(与新建会话口径一致,依赖 reload 路径同改)。P2.2 行为等价重构。P2.3/P2.4 均带开关,关=旧路径。无 schema 变更。
  • 破坏:裁剪切点由"即时"变"滞后批量"(P2.2)——行为变更但语义等价(仍保三元组、仍有淘汰摘要);开关可退。无不可逆操作。
  • 安全:检索召回仅扩"只读召回面",注入内容受既有配额闸约束;不引入新命令执行面。召回的历史页来自本会话已保留内容,无跨权限面(v1 单会话)。
  • 资源:① 修正后 history_tokens 上升到真实量级,裁剪更积极 → 发送 token 下降(目标 §一)。召回新增每轮 ≤K 页注入(可控)。无新持久化。
  • 回退开关:df-ctx-accurate-tokens / df-ctx-trim-hysteresis / df-ctx-recall / df-ctx-model-window(均 KV 热读,默认值见各期)。观测:AssemblyStats(召回命中/换出量/window token/trim 次数)+ 既有 context_trimmed 日志。

六、与 P1 工具结果瘦身(另一写者)的边界与对接

P1 现状(WIP,git diff):crates/df-ai/src/lib.rs 加 pub mod tool_offload;;context_helpers.rs 加 READ_FILE_OFFLOAD_BYTES:432 / TOOL_OFFLOAD_HANDLE_MIN_BYTES:436,重构 extract_key_info(:472) 为"小文件豁免 + 超阈值压缩并落盘句柄"。消费点 stall_detector.rs:342-351、sub_agent.rs。

职责二分(不双实现)

归属 内容
P1(内容变换) 大工具结果如何变小:extract_key_info / offload_with_handle(tool_offload.rs:46)/ namespace_store 引用化
本方案(记账+组装) 变小后占多少、发什么、如何召回:view_form_tokens 记账基点、组装器、召回面

对接协议(冻结接口)

  1. 单一压缩函数:本方案的 view_form_tokens 内部复用 P1 的 extract_key_info(同域同 crate),不另写压缩逻辑。P1 若改阈值/句柄格式,本方案自动跟随——一处定义。
  2. 单一存储形态(§九问题1):namespace_store(内存)与 tool_offload(磁盘)是同一原文的两种介质,构成双实现隐患。本方案 v1 不碰二者,只在 §3.3 召回面与 §二验收中把"结果页"抽象为内容哈希寻址(handle 与 ref key 均已是内容哈希:namespace_store.rs:353 / tool_offload.rs:37)——为 P2.5 合流预留,不在 v1 制造第三套。
  3. 文件重叠:context_helpers.rs 双方都动(P1 改 extract_key_info/常量;本方案加 view_form_tokens)。约定:本方案新增函数不改 P1 既有函数签名;view_form_tokens 以只读方式调用 extract_key_info。串行合并:P1 先合(其 extract_key_info 形态是本方案记账的输入),本方案在 P1 之上加记账点。
  4. 执行序:P2.2/P2.3/P2.4 与 P1 无重叠文件(改 src-tauri,除消费 P1 常量),可并行;P2.1 与 P1 同文件,待 P1 合入后落。

七、拍板点清单

# 决策 选项 建议
1 记账基点(单一真值来源) A 发送视图尺寸(view_form,与发送同函数);B 保留内容/引用串尺寸 A。B 无法消除账实脱钩;B 是当前病灶本身
2 reload 路径口径 A 重建 id→name,reload 亦按 view_form(精确一致);B reload 按真实内容保守估(实现简,与新建会话口径不完全一致) A。一致性是"单一真值"的前提;build_id_to_name 已存在可复用
3 组装器落地形态 A 新增 AssembledContext 结构 + assemble() 单入口;B 保留 build_for_request 返回 tuple,拼接仍分散 A。B 延续"拼接分散",统计/顺序无处安放
4 L3 召回 v1 半径 A 仅当前会话窗口外页(in-memory 关键词,零新存储);B 纳入跨会话/向量检索 A。跨会话/向量是 §九 v2+ 北极星(触发制),v1 零新存储先证价值

八、与既有文档关系

  • 本方案 = 《上下文根治设计-20261006.md》§一/§二/§九/§十的接口细化与实施序,不改其 v1 范围与北极星定位。
  • 上下文三层记忆-设计-2026-10-04.md:其 C-m1 记忆卡是 L3 的 MemoryCardSource 数据源,本方案只提召回入口(§3.3),不重复卡片产出。
  • namespace淘汰治理-设计-2026-10-04.md:namespace_store 的 pin/EvictionLog/owner 治理不变;P2.5 页池统一时作为 RAM 层纳入。