新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -1,59 +1,511 @@
//! 上下文管理器 — 管理对话上下文和 token 预算
use std::collections::VecDeque;
//!
//! 职责:
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
use serde::{Deserialize, Serialize};
use crate::provider::ChatMessage;
use crate::provider::{ChatMessage, MessageRole};
// ============================================================
// Token 估算器(零依赖粗估)
// ============================================================
/// Token 粗估器 — 字符级近似计数,无 tokenizer 依赖
///
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
#[derive(Debug, Clone)]
pub struct TokenEstimator {
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token中英混合偏保守
pub chars_ratio: f32,
/// 每条消息固定开销role 标记 + 格式)
pub per_message_overhead: u32,
/// 每个 tool_call 的额外开销name + arguments JSON 结构)
pub per_tool_call_overhead: u32,
}
impl Default for TokenEstimator {
fn default() -> Self {
Self {
chars_ratio: 0.35,
per_message_overhead: 4,
per_tool_call_overhead: 30,
}
}
}
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let content_tokens = (msg.content.chars().count() as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销role=Assistant 时可能有)
if let Some(ref calls) = msg.tool_calls {
for call in calls {
total += self.per_tool_call_overhead;
total += (call.function.name.chars().count() as f32 * self.chars_ratio).ceil() as u32;
total += (call.function.arguments.chars().count() as f32 * self.chars_ratio).ceil() as u32;
}
}
// tool_call_id 开销role=Tool 时有)
if msg.tool_call_id.is_some() {
total += 3;
}
total
}
/// 估算纯文本字符串的 token 数(用于 system prompt
pub fn estimate_text(&self, text: &str) -> u32 {
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
}
}
// ============================================================
// 上下文窗口配置
// ============================================================
/// 上下文窗口配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextConfig {
/// 最大 token 数
/// 窗口上限 token 数(默认 128_000
pub max_tokens: u32,
/// 保留的系统提示 token 数
pub system_reserve: u32,
/// 输出预留 token 数(窗口中留给模型生成的部分,默认 8_192
pub output_reserve: u32,
/// 安全系数 0.0~1.0(默认 0.85,留 15% 余量)
pub safety_ratio: f32,
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
max_tokens: 128_000,
system_reserve: 4_000,
output_reserve: 8_192,
safety_ratio: 0.85,
}
}
}
impl ContextConfig {
/// 预算上限 = (max_tokens - output_reserve) × safety_ratio
pub fn budget_limit(&self) -> u32 {
(self.max_tokens.saturating_sub(self.output_reserve) as f32 * self.safety_ratio) as u32
}
}
// ============================================================
// 消息分组(淘汰时保持工具调用三元组原子性)
// ============================================================
/// 消息在逻辑上的分组标签,用于淘汰时保持原子性
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MessageGroup {
/// 普通 User / Assistant 文本消息(可独立淘汰)
Standalone,
/// Assistant 带 tool_calls是三元组的头
ToolCallHead,
/// Tool 结果消息,是三元组的尾
ToolResultTail,
}
/// 带有 token 缓存和分组信息的消息条目
struct TrackedMessage {
message: ChatMessage,
token_count: u32,
group: MessageGroup,
}
fn classify_group(msg: &ChatMessage) -> MessageGroup {
match msg.role {
MessageRole::Tool => MessageGroup::ToolResultTail,
MessageRole::Assistant => {
if msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
MessageGroup::ToolCallHead
} else {
MessageGroup::Standalone
}
}
_ => MessageGroup::Standalone,
}
}
// ============================================================
// 上下文管理器
// ============================================================
/// 上下文管理器
///
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
pub struct ContextManager {
/// 消息历史
messages: VecDeque<ChatMessage>,
/// 配置
messages: Vec<TrackedMessage>,
/// 当前历史总 token 数(不含 system prompt
history_tokens: u32,
config: ContextConfig,
estimator: TokenEstimator,
}
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
const PROTECT_COUNT: usize = 6;
impl ContextManager {
/// 创建上下文管理器
pub fn new(config: ContextConfig) -> Self {
Self {
messages: VecDeque::new(),
messages: Vec::new(),
history_tokens: 0,
config,
estimator: TokenEstimator::default(),
}
}
/// 加消息
/// 加消息(自动计算 token 并更新缓存)
///
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
pub fn push(&mut self, message: ChatMessage) {
self.messages.push_back(message);
// TODO: 当超过 token 预算时,淘汰旧消息
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
self.history_tokens += tokens;
self.messages.push(TrackedMessage {
message,
token_count: tokens,
group,
});
}
/// 获取当前消息列表
pub fn messages(&self) -> &VecDeque<ChatMessage> {
&self.messages
}
/// 清空上下文
/// 清空所有消息
pub fn clear(&mut self) {
self.messages.clear();
self.history_tokens = 0;
}
/// 消息数量
pub fn len(&self) -> usize {
self.messages.len()
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
/// 当前历史占用的 token 数(不含 system prompt
pub fn history_tokens(&self) -> u32 {
self.history_tokens
}
/// 预算上限
pub fn budget_limit(&self) -> u32 {
self.config.budget_limit()
}
// ── 核心:构建请求消息(受控裁剪版本)──
/// 构建发送给 LLM 的消息列表
///
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
/// 返回 (消息列表, 是否发生了裁剪)。
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
let budget = self.budget_limit();
let available = budget.saturating_sub(sys_tokens);
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
if sys_tokens > budget {
tracing::warn!(
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
sys_tokens, budget
);
}
// 未超预算 → 直接返回全量
if self.history_tokens <= available {
return (self.all_messages_clone(), false);
}
// 超预算 → 视图裁剪(不修改 self.messages保证 all_messages_clone 仍返回全量)
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
let units = self.build_eviction_units(protect_start);
let mut removed: u64 = 0;
let mut trim_end = 0;
for unit in &units {
if self.history_tokens.saturating_sub(removed as u32) <= available {
break;
}
removed += unit.token_sum as u64;
trim_end = unit.end;
}
if trim_end == 0 {
tracing::warn!(
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT
);
return (self.all_messages_clone(), false);
}
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
.iter()
.map(|t| t.message.clone())
.collect();
tracing::info!(
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
(msgs, true)
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
self.messages.iter().map(|t| t.message.clone()).collect()
}
/// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
self.clear();
for msg in messages {
self.push(msg);
}
}
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result
/// 返回 true 如果找到并替换了
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
let pos = self.messages.iter().position(|t| {
matches!(t.message.role, MessageRole::Tool)
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
});
let Some(i) = pos else { return false };
// 先更新 content再重估 token 并校正总量
let old_tokens = self.messages[i].token_count;
self.messages[i].message.content = new_content.to_string();
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
true
}
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
self.messages.iter().map(|t| &t.message)
}
// ── 内部方法 ──
/// 构建淘汰单元列表
///
/// 每个单元是连续消息范围 [start, end),保证:
/// - 工具调用三元组ToolCallHead + ToolResultTail* + 紧随的文本 Assistant在同一单元
/// - 保护区内的消息不纳入任何单元
fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
while i < protect_start {
let mut token_sum = 0u32;
if self.messages[i].group == MessageGroup::ToolCallHead {
// 收集完整三元组Head + 后续所有 ToolResultTail + 紧随的文本 Assistant
token_sum += self.messages[i].token_count;
i += 1;
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
token_sum += self.messages[i].token_count;
i += 1;
}
// 紧随的 Standalone Assistant工具调用的最终文本回复
if i < protect_start
&& self.messages[i].group == MessageGroup::Standalone
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
{
token_sum += self.messages[i].token_count;
i += 1;
}
} else {
// Standalone / ToolResultTail理论上孤立 Tail 不该出现,按单条处理)
token_sum += self.messages[i].token_count;
i += 1;
}
units.push(EvictionUnit { end: i, token_sum });
}
units
}
}
/// 淘汰单元:连续消息范围 [..end) + token 总和
struct EvictionUnit {
end: usize,
token_sum: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ToolCall;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
max_tokens,
output_reserve: 0,
safety_ratio: 1.0,
}
}
#[test]
fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("你好"));
mgr.push(ChatMessage::assistant("你好啊"));
let (msgs, trimmed) = mgr.build_for_request(10);
assert!(!trimmed);
assert_eq!(msgs.len(), 2);
}
#[test]
fn over_budget_trims_old() {
// 小预算强制裁剪20 条超预算,触发裁剪且保留保护区
let mut mgr = ContextManager::new(cfg(200));
for i in 0..20 {
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
}
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed, "超预算应触发裁剪");
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
// 保护区:最新一条必保留
assert_eq!(
msgs.last().unwrap().content,
"这是第 19 条较长的消息用于撑爆预算",
"保护区最新消息被误裁"
);
// 裁剪是视图:内存全量不变
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
}
#[test]
fn tool_triplet_kept_atomic() {
// 三元组不可分离Head 与 Tail 同进同出,永不从中间切断
// 布局6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
let mut mgr = ContextManager::new(cfg(95));
for i in 0..6 {
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
}
mgr.push(ChatMessage::assistant_with_tools(
"调工具",
vec![ToolCall::new("tc1", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
mgr.push(ChatMessage::assistant("完成"));
for i in 0..6 {
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
}
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
assert!(trimmed1, "分支一应触发裁剪");
assert_eq!(
has_head(&msgs_keep),
has_tail(&msgs_keep),
"分支一三元组被切断: head={} tail={}",
has_head(&msgs_keep),
has_tail(&msgs_keep)
);
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
assert!(trimmed2, "分支二应触发裁剪");
assert_eq!(
has_head(&msgs_drop),
has_tail(&msgs_drop),
"分支二三元组被切断: head={} tail={}",
has_head(&msgs_drop),
has_tail(&msgs_drop)
);
// 裁剪是视图:两次 build 都不应改变内存全量
assert_eq!(
mgr.all_messages_clone().len(),
15,
"裁剪污染了内存全量"
);
}
fn has_head(msgs: &[ChatMessage]) -> bool {
msgs.iter()
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
}
fn has_tail(msgs: &[ChatMessage]) -> bool {
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
}
#[test]
fn replace_tool_result_updates_tokens() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::tool_result("tc1", ""));
let before = mgr.history_tokens();
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
let after = mgr.history_tokens();
assert!(after > before);
}
#[test]
fn restore_rebuilds_token_cache() {
let mut mgr = ContextManager::new(cfg(100_000));
let src = vec![
ChatMessage::user("测试消息一"),
ChatMessage::assistant("回复一"),
ChatMessage::user("测试消息二"),
];
mgr.restore_from_messages(src);
assert!(mgr.history_tokens() > 0);
assert_eq!(mgr.len(), 3);
}
#[test]
fn empty_history_returns_empty() {
let mgr = ContextManager::new(cfg(100_000));
let (msgs, trimmed) = mgr.build_for_request(10);
assert!(!trimmed);
assert!(msgs.is_empty(), "空历史应返回空列表");
}
#[test]
fn protect_zone_returns_full_when_untrimmable() {
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
let mut mgr = ContextManager::new(cfg(10));
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
mgr.push(ChatMessage::user("第二条撑爆预算的长消息"));
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
assert_eq!(msgs.len(), 2, "兜底返回全部保护区消息");
}
#[test]
fn system_over_budget_trims_to_protect_zone() {
// system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
let mut mgr = ContextManager::new(cfg(200));
for i in 0..10 {
mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
}
let (msgs, _trimmed) = mgr.build_for_request(195);
assert!(
msgs.len() <= PROTECT_COUNT,
"system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
PROTECT_COUNT,
msgs.len()
);
}
}