Skip to main content
xbot
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage
Edit page

xbot 上下文管理 Phase 2 优化设计

⚠️ 演进说明(2026-03-27):本文档基于 Phase 1 的「执行视图隔离」原则设计,该原则已在后续重构(commit 45d6078)中被调整——当前实现直接持久化 engine 产生的 assistant + tool 消息。本 Phase 2 方案中依赖该原则的部分可能需要修订。本文档状态仍为「待审核」。

中书省拟 | 2026-03-19 状态:待陛下审核 前置文档:调研报告Phase 1 设计文档 目标:超越 Claude Code 的上下文管理能力


一、Phase 1 回顾与 Phase 2 定位

1.1 Phase 1 已完成基础

能力状态涉及文件
CompressResult 双视图(LLMView + SessionView)agent/compress.go:14-17
maybeCompress 使用 SessionView 持久化agent/engine.go
extractDialogueFromTail 尾部对话提取agent/compress.go
压缩阈值 0.7agent/agent.go Agent 结构体 compressionThreshold
thinTail 尾部旧工具组精简agent/compress.go
flushPending / truncateArgs 辅助函数agent/compress.go
CompressFunc 签名 func(ctx, msgs, client, model) (*CompressResult, error)agent/engine.go

1.2 Phase 2 设计目标:超越 Claude Code

Claude Code 的上下文管理策略(截至 2025 年):

Claude Code 能力xbot Phase 1xbot Phase 2 目标
静态 64-75% 阈值触发压缩✅ 0.7 静态阈值动态阈值:根据任务阶段、增长速率、工具模式自适应
整体 LLM 摘要压缩✅ + 分层渐进压缩(Offload → Evict → Compact)
保留最近 N 轮完整✅ thinTail(3)✅ + 话题感知保留
工具结果摘要化✅ extractDialogueFromTail✅ + 自动 Offload 大结果到磁盘 + 可召回
社区呼声的子任务级压缩(未实现)话题分区隔离 + 选择性压缩
压缩质量可观测关键信息完整性校验 + 质量评分
增长速率预测加权线性回归 + 指数增长检测

Phase 2 的核心差异化:从"被动压缩"进化为"主动上下文编排"——不是等上下文满了才压缩,而是持续感知、智能调度、分层管理。


二、整体架构

┌─────────────────────────────────────────────────────────────────┐
│                    xbot Phase 2 上下文管理引擎                    │
│                                                                 │
│  ┌─────────────┐   ┌──────────────────┐   ┌──────────────────┐ │
│  │ 智能触发引擎  │   │ 分层压缩引擎      │   │ 质量保障引擎      │ │
│  │             │   │                  │   │                  │ │
│  │ · 任务阶段   │──▶│ · Layer 1        │   │ · 关键信息校验    │ │
│  │   感知      │   │   Offload        │   │ · 结构化标记      │ │
│  │ · 增长速率   │   │   (execOne后)    │   │ · 质量评分        │ │
│  │   预测      │   │ · Layer 2 Evict  │   │                  │ │
│  │ · 工具模式   │   │ · Layer 3        │   └────────┬─────────┘ │
│  │   感知      │   │   Compact        │            │            │
│  │ · 压缩冷却   │   │                  │◀───────────┘            │
│  └──────┬──────┘   └──────┬───────────┘                         │
│         │                 │                                     │
│         ▼                 ▼                                     │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                  话题分区管理器                               ││
│  │                                                             ││
│  │  Topic 1: 上下文缓存 │ 压缩摘要 │ Offload 索引                ││
│  │  Topic 2: 上下文缓存 │ 压缩摘要 │ Offload 索引                ││
│  │  Current: 完整上下文(最近对话 + 活跃工具结果)                 ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

三、方向一:三层渐进压缩

3.1 设计思路

借鉴社区最佳实践(社区三层架构:Offload → Evict → Compact),但做了关键增强:

  1. Offload 不只是落盘——落盘后生成"可召回摘要",支持后续按需检索
  2. Evict 不是简单截断——根据信息密度分级驱逐,保留高密度信息
  3. 三层联动——各层之间有数据流转通道,Offload 的数据可被后续检索回填

与 Claude Code 的区别:Claude Code 只有一个 Compact 层(LLM 摘要),xbot Phase 2 提供三层渐进保护,避免一次性 LLM 压缩导致的信息损失。

3.2 Layer 1: Offload(大 tool result 自动落盘)

触发条件:单条 tool result 内容超过阈值(如 2000 tokens 或 10KB)

执行策略

// OffloadConfig Layer 1 配置
type OffloadConfig struct {
    MaxResultTokens int    // 单条 tool result 触发 offload 的 token 阈值(默认 2000)
    MaxResultBytes  int    // 单条 tool result 触发 offload 的字节阈值(默认 10240)
    StoreDir        string // offload 存储目录(默认 {DataDir}/offload_store/)
}

// OffloadedResult 已 offload 的 tool result 的元数据
type OffloadedResult struct {
    ID        string    // 唯一 ID(uuid)
    ToolName  string    // 工具名称
    Args      string    // 工具参数(截断)
    FilePath  string    // 落盘文件路径
    TokenSize int       // 原始 token 数
    Timestamp time.Time // offload 时间
    Summary   string    // 摘要(规则提取同步生成 + 可选 LLM 增强)
}

摘要生成策略(同步 + 可选异步增强)

工具执行返回大 result
  → 检测 tokens > MaxResultTokens
  → 【同步】写入 offload_store/{session_key}/{uuid}.json(原始内容完整保存)
  → 【同步】规则提取摘要(毫秒级,无 LLM 依赖):
      - 文件类:提取文件名 + 行数 + 前3行 + 后3行 + 关键函数名
      - Grep 类:提取匹配数量 + 前3条匹配(截断行)
      - Shell 类:提取退出码 + 最后5行输出
      - 通用:截取前300字符 + "...(N tokens 已 offload)"
  → 【同步】替换上下文中的 tool result 为摘要 + 引用标记
  → 【可选异步】用 LLM 生成更精确的摘要,回填 OffloadedResult.Summary
     (异步回填不阻塞主流程,摘要仅用于 offload_recall 的快速预览)

设计决策:摘要分为两层——同步规则摘要是必需的(保证主流程零延迟、信息不缺失),LLM 摘要是可选增强(提升可读性,但不影响功能)。

上下文替换示例

原始 tool result(5000 tokens):
  "package main\nimport (\n  \"fmt\"\n  ...200行代码...\n)\nfunc main() { ... }"

替换后(~200 tokens,同步规则摘要):
  "📂 [offload:offload_abc123] Read("main.go")
   文件: xbot/agent/main.go (245行, 5234 tokens)
   预览: package main — 定义了 Agent 结构体和 New() 构造函数
         关键函数: New(cfg Config), Run(), processMessage(), buildPrompt()
   原始内容已保存至 offload_store,可通过 offload_recall 工具按 ID 检索。"

offload_recall 工具设计

// offload_recall 工具定义
// 注册方式:在 tools/ 目录新增 offload_recall.go,通过 Tools.Registry 注册
type OffloadRecallArgs struct {
    ID string `json:"id" jsonschema:"description=offload 条目 ID(如 offload_abc123)"`
}

// 执行逻辑:
// 1. 从 offload_store/{session_key}/index.json 查找 ID 对应的文件路径
// 2. 读取完整 tool result 内容
// 3. 如果内容仍然过大(> MaxResultTokens),返回截断版本 + 原始文件路径
// 4. 返回格式:
//    "📂 [offload recall: {id}] {tool_name}({args})\n{完整内容或截断版本}"

// 与 HookChain 集成:
// - 无需特殊 Hook,作为普通工具注册到 Agent 的 Tools 列表中
// - LLM 在上下文中看到 [offload:xxx] 标记时,自然会使用 offload_recall 工具获取详情
// - 依赖 Issue #98 的 Tool Hook 机制(PreToolUse/PostToolUse)来拦截大结果

与 Phase 1 的衔接

  • Offload 发生在 Run() 循环的工具执行阶段(execOne 返回后),早于任何压缩触发
  • Offload 后的消息直接进入 messages 切片,后续的 thinTail / compressContext 操作无需感知 Offload 细节
  • extractDialogueFromTail 识别 offload 标记(📂 [offload:...]),不对其二次截断

3.3 Layer 2: Evict(上下文驱逐旧 tool payload)

触发条件:上下文使用率达到动态阈值的 80%(低于 Compact 触发的 100%),给予缓冲空间

执行策略

// EvictPolicy 驱逐策略
type EvictPolicy struct {
    // 保留最近 N 组完整 tool call/result
    KeepRecentGroups int // 默认 3(与 thinTail 对齐)

    // 信息密度评分:决定驱逐优先级
    // 低密度(大块代码转储、重复 grep 结果)优先驱逐
    // 高密度(错误信息、决策记录、小但关键的代码片段)最后驱逐
    DensityScorer func(msg llm.ChatMessage) float64
}

// defaultDensityScorer 默认信息密度评分函数
func defaultDensityScorer(msg llm.ChatMessage) float64 {
    score := 0.0
    content := msg.Content

    // 高密度信号(加分)
    if containsErrorPattern(content)    { score += 3.0 } // 错误信息
    if containsDecisionPattern(content) { score += 2.5 } // 决策记录
    if containsFilePath(content)        { score += 1.0 } // 文件路径引用
    if len([]rune(content)) < 500      { score += 1.5 } // 短内容天然密度高

    // 低密度信号(减分)
    if isLargeCodeDump(content)         { score -= 2.0 } // 大段代码转储
    if isRepetitiveGrepResult(content)  { score -= 1.5 } // 重复 grep 结果
    if msg.Role == "tool" && len([]rune(content)) > 3000 { score -= 2.0 }

    return score
}

驱逐过程

输入:messages(可能已包含 Offload 后的摘要消息)
  1. 从尾部保留 KeepRecentGroups 组完整 tool call/result
  2. 对剩余的 tool 消息按密度评分排序
  3. 从低密度开始驱逐:
     - 替换 tool result 为 "→ [evicted] 工具摘要(N tokens 已驱逐)"
     - 保留 assistant(tool_calls) 消息的结构(API 兼容性)
  4. 驱逐直到上下文降到阈值的 70% 以下

与 Phase 1 的衔接

  • Layer 2 是现有 thinTail增强版:thinTail 只按位置截断,Layer 2 按信息密度选择性截断
  • 建议替代:将 thinTail 逻辑升级为 Evict 逻辑,保留 API 接口不变

3.4 Layer 3: Compact(现有 LLM 摘要压缩)

触发条件:Layer 2 Evict 后仍超过动态阈值

增强点

  • 使用结构化压缩 prompt(见方向四),要求输出带标记的摘要
  • 话题感知压缩:压缩时尊重话题分区边界(见方向三)
  • 双视图输出:Phase 1 已实现,保持不变

3.5 分层执行流水线

// EvictCompactPipeline 分层压缩流水线
// 注:Layer 1 (Offload) 在 execOne 后立即触发,不在此 Pipeline 中编排
// Pipeline 仅编排 Layer 2 (Evict) 和 Layer 3 (Compact)
type EvictCompactPipeline struct {
    evictor  *Evictor           // Layer 2
    compFunc CompressFunc       // Layer 3(Phase 1 的 compressContext)
    quality  *QualityEvaluator  // 质量评估(方向四)
}

// Execute 执行压缩流水线,按需触发,不一定每层都执行
func (p *EvictCompactPipeline) Execute(ctx context.Context, 
    messages []llm.ChatMessage, 
    trigger TriggerInfo,
    client llm.LLM, model string) (*CompressResult, error) {
    
    currentTokens, _ := llm.CountMessagesTokens(messages, model)
    threshold := calculateDynamicThreshold(trigger)
    
    // 未达到 Evict 阈值(动态阈值的 80%),不需要压缩
    if float64(currentTokens) / float64(trigger.MaxTokens) < threshold * 0.8 {
        return nil, nil
    }
    
    // Layer 2: Evict
    evicted := p.evictor.Evict(messages, threshold * 0.7)
    evictTokens, _ := llm.CountMessagesTokens(evicted, model)
    
    // Layer 3: Compact(如果 Evict 后仍超阈值)
    var result *CompressResult
    if float64(evictTokens) / float64(trigger.MaxTokens) >= threshold {
        result, _ = p.compFunc(ctx, evicted, client, model)
    } else {
        // Evict 后已足够,直接构建双视图
        result = buildCompressResultFromMessages(evicted)
    }
    
    return result, nil
}

与现有 CompressFunc 的兼容性CompressFunc 签名不变,Pipeline 内部在调用时传入标准的参数。TriggerInfo 仅用于 Pipeline 内部的阈值计算,不修改 CompressFunc 的接口签名。现有使用 CompressFunc 的代码(如 agent/engine.go 中的 maybeCompress)无需修改签名,仅在需要动态阈值时通过 Pipeline 调用。

3.6 数据流总览

工具执行 → [Layer 1 Offload] → messages(大结果替换为规则摘要+标记)
              ↓ (单次工具执行后立即触发,同步)
         Run() 循环继续
              ↓ (maybeCompress 检测到上下文过大)
         [Layer 2 Evict] → messages(低密度 tool result 驱逐)
              ↓ (Evict 后仍超过动态阈值)
         [Layer 3 Compact] → CompressResult{LLMView, SessionView}
              ↓
         持久化 SessionView 到 session
         继续用 LLMView 调用 LLM

3.7 Offload Store 存储设计

offload_store/
  ├── {session_key}/
  │     ├── offload_abc123.json    ← 完整 tool result
  │     ├── offload_def456.json
  │     └── index.json             ← 索引文件(所有 offload 条目的元数据)
  └── cleanup_policy: 会话结束时 /new 时清理

索引文件结构

{
  "session_key": "feishu:oc_abc123",
  "entries": [
    {
      "id": "offload_abc123",
      "tool_name": "Read",
      "args": "{\"path\":\"main.go\"}",
      "file": "offload_abc123.json",
      "token_size": 5234,
      "created_at": "2026-03-19T01:00:00Z",
      "summary": "main.go 入口文件,定义 Agent 结构体..."
    }
  ]
}

四、方向二:智能压缩触发

4.1 设计思路

Claude Code 使用静态 64-75% 阈值,这是一个"一刀切"策略。但在实际使用中:

  • 信息积累期(刚接手任务):需要更多上下文空间来收集信息,不应过早压缩
  • 信息爆发期(大量文件读取):上下文快速增长,需要提前触发
  • 决策执行期(写代码):上下文稳定增长,但当前信息很关键,不应压缩
  • 任务收尾期(验证结果):需要历史决策信息,应保留更多历史

xbot Phase 2 引入动态阈值算法,综合考虑任务阶段、增长速率、工具调用模式,外加压缩冷却机制防止无限循环。

4.2 触发信息采集

// TriggerInfo 压缩触发所需的上下文信息
type TriggerInfo struct {
    // 基础信息
    MaxTokens    int       // 最大上下文 token 数
    CurrentTokens int      // 当前上下文 token 数
    
    // 任务阶段信息
    IterationCount int     // 当前 Run() 循环迭代次数
    ToolCallCount  int     // 本次 Run() 的工具调用总次数
    IsFirstUserMsg bool    // 是否是本次用户消息的第一轮迭代
    
    // 增长速率信息
    TokenHistory   []int   // 最近 N 次迭代的 token 数快照(滑窗)
    GrowthRate     float64 // 近期 token 增长速率(tokens/iteration)
    
    // 工具模式信息
    RecentTools    []string // 最近 5 次工具调用名称
    ToolPattern    ToolCallPattern // 识别出的工具调用模式
}

// ToolCallPattern 工具调用模式
type ToolCallPattern int

const (
    PatternConversation ToolCallPattern = iota // 纯对话(无工具调用)
    PatternReadHeavy                           // 读密集(大量 Read/Grep/Glob)
    PatternWriteHeavy                          // 写密集(大量 Edit/Shell 写操作)
    PatternMixed                               // 混合模式
    PatternSubAgent                            // SubAgent 调用
)

4.3 动态阈值算法

// calculateDynamicThreshold 计算动态压缩阈值
// 返回值范围 [0.5, 0.85],Claude Code 固定为 0.64-0.75
func calculateDynamicThreshold(info TriggerInfo) float64 {
    // 基础阈值
    baseThreshold := 0.70
    
    // === 因子 1: 任务阶段调节 ===
    stageFactor := 1.0
    switch {
    case info.IterationCount <= 3:
        // 信息积累期:宽容阈值,允许更多空间
        stageFactor = 0.85
    case info.IterationCount <= 10:
        // 活跃工作期:标准阈值
        stageFactor = 0.70
    case info.ToolCallCount > 20:
        // 长任务执行期:开始收窄
        stageFactor = 0.65
    default:
        stageFactor = 0.70
    }
    
    // === 因子 2: 增长速率预测 ===
    growthFactor := 1.0
    if info.GrowthRate > 0 {
        // 预测达到 90% 还需要多少轮
        remainingTokens := float64(info.MaxTokens*9/10 - info.CurrentTokens)
        iterationsToFull := remainingTokens / info.GrowthRate
        switch {
        case iterationsToFull < 2:
            // 即将爆满,立即触发(降低阈值)
            growthFactor = 0.50
        case iterationsToFull < 5:
            // 快速增长,提前触发
            growthFactor = 0.60
        case iterationsToFull > 15:
            // 增长缓慢,放宽阈值
            growthFactor = 0.80
        }
    }
    
    // === 因子 3: 工具模式调节 ===
    patternFactor := 1.0
    switch info.ToolPattern {
    case PatternReadHeavy:
        // 读密集:大量文件内容涌入,需要更积极的压缩
        if isScanningPhase(info.RecentTools) {
            patternFactor = 0.60 // 扫描阶段,积极压缩
        } else {
            patternFactor = 0.65
        }
    case PatternWriteHeavy:
        // 写密集:编辑操作通常结果较小,不需要太积极
        patternFactor = 0.75
    case PatternSubAgent:
        // SubAgent 返回结果可能较大,适当提前
        patternFactor = 0.65
    case PatternConversation:
        // 纯对话:增长缓慢,放宽
        patternFactor = 0.80
    case PatternMixed:
        patternFactor = 0.70
    }
    
    // === 综合计算 ===
    // 取各因子的最小值(最保守的策略获胜)
    dynamicThreshold := min(baseThreshold, stageFactor, growthFactor, patternFactor)
    
    // 限制范围
    dynamicThreshold = clamp(dynamicThreshold, 0.50, 0.85)
    
    return dynamicThreshold
}

func clamp(v, min, max float64) float64 {
    if v < min { return min }
    if v > max { return max }
    return v
}

4.4 增长速率预测

// TokenGrowthTracker token 增长速率追踪器
// 生命周期:存储在 Agent 结构体中,按 session key 索引
//   Agent.growthTrackers map[string]*TokenGrowthTracker
// 每次 Run() 开始时通过 sessionKey 获取或创建,跨 Run() 持久化
// /new 命令时清理对应 session 的 tracker
type TokenGrowthTracker struct {
    window   []tokenSnapshot // 滑动窗口
    maxSize  int             // 窗口大小(默认 10)
}

type tokenSnapshot struct {
    iteration int
    tokens    int
    timestamp time.Time
}

// Record 记录一次 token 快照
func (t *TokenGrowthTracker) Record(iteration int, tokens int) {
    t.window = append(t.window, tokenSnapshot{
        iteration: iteration,
        tokens:    tokens,
        timestamp: time.Now(),
    })
    if len(t.window) > t.maxSize {
        t.window = t.window[1:]
    }
}

// GrowthRate 计算 token 增长速率(tokens/iteration)
// 使用加权线性回归,近期数据权重更高
func (t *TokenGrowthTracker) GrowthRate() float64 {
    if len(t.window) < 2 {
        return 0
    }
    
    // 加权线性回归
    var sumX, sumY, sumXY, sumX2, sumW float64
    n := len(t.window)
    
    for i, snap := range t.window {
        w := float64(i+1) / float64(n) // 近期权重更高
        x := float64(snap.iteration)
        y := float64(snap.tokens)
        sumW += w
        sumX += w * x
        sumY += w * y
        sumXY += w * x * y
        sumX2 += w * x * x
    }
    
    // 斜率 = 增长速率
    slope := (sumW*sumXY - sumX*sumY) / (sumW*sumX2 - sumX*sumX)
    return slope
}

// IsExponentialGrowth 检测是否指数增长
// 指数增长意味着即将爆满,需要立即触发压缩
func (t *TokenGrowthTracker) IsExponentialGrowth() bool {
    if len(t.window) < 4 {
        return false
    }
    
    // 计算相邻增长的比率
    var ratios []float64
    for i := 1; i < len(t.window); i++ {
        delta := t.window[i].tokens - t.window[i-1].tokens
        if delta > 0 && t.window[i-1].tokens > 0 {
            ratios = append(ratios, float64(delta)/float64(t.window[i-1].tokens))
        }
    }
    
    if len(ratios) < 2 {
        return false
    }
    
    // 如果增长率在持续增大(加速增长),判定为指数增长
    accelerating := true
    for i := 1; i < len(ratios); i++ {
        if ratios[i] <= ratios[i-1] {
            accelerating = false
            break
        }
    }
    
    return accelerating
}

// Snapshots 返回当前窗口快照(供 TriggerInfo.TokenHistory 使用)
func (t *TokenGrowthTracker) Snapshots() []int {
    result := make([]int, len(t.window))
    for i, snap := range t.window {
        result[i] = snap.tokens
    }
    return result
}

4.5 压缩冷却机制

防止无限压缩循环:当动态阈值降到 0.5 且 Evict/Compact 无法降到阈值以下时,每次迭代都会触发压缩。

// CompressCooldown 压缩冷却管理器
type CompressCooldown struct {
    lastCompressIteration int  // 上次触发压缩时的迭代计数
    cooldownIterations    int  // 冷却轮数(默认 3)
}

// ShouldTrigger 判断是否应该触发压缩
// 返回 false 表示处于冷却期,应跳过本次压缩
func (c *CompressCooldown) ShouldTrigger(currentIteration int) bool {
    if c.lastCompressIteration == 0 {
        return true // 从未压缩过
    }
    return (currentIteration - c.lastCompressIteration) >= c.cooldownIterations
}

// RecordCompress 记录一次压缩触发
func (c *CompressCooldown) RecordCompress(iteration int) {
    c.lastCompressIteration = iteration
}

4.6 工具模式检测

// DetectToolPattern 检测当前工具调用模式
func DetectToolPattern(recentTools []string) ToolCallPattern {
    if len(recentTools) == 0 {
        return PatternConversation
    }
    
    readTools := map[string]bool{"Read": true, "Grep": true, "Glob": true, "WebSearch": true}
    writeTools := map[string]bool{"Edit": true, "Shell": true, "Write": true, "DownloadFile": true}
    
    readCount, writeCount, subAgentCount := 0, 0, 0
    for _, t := range recentTools {
        if readTools[t] { readCount++ }
        if writeTools[t] { writeCount++ }
        if t == "SubAgent" { subAgentCount++ }
    }
    
    total := len(recentTools)
    
    switch {
    case subAgentCount > total/2:
        return PatternSubAgent
    case readCount > total*3/4 && writeCount == 0:
        return PatternReadHeavy
    case writeCount > total/2:
        return PatternWriteHeavy
    case readCount > 0 && writeCount > 0:
        return PatternMixed
    default:
        return PatternConversation
    }
}

// isScanningPhase 判断是否处于代码扫描阶段
// 扫描阶段的特征:最近多次工具调用都是读取类,没有任何写入操作
func isScanningPhase(recentTools []string) bool {
    if len(recentTools) < 3 {
        return false
    }
    
    writeTools := map[string]bool{"Edit": true, "Shell": true, "Write": true}
    
    for _, t := range recentTools {
        if writeTools[t] {
            return false // 发现写入操作,不是扫描阶段
        }
    }
    
    return true // 所有最近工具调用都是读取类
}

4.7 集成到 maybeCompress

// Phase 2 增强版 maybeCompress(伪代码,展示修改点)
// recentToolCalls 从 toolsUsed []string 中取最近 N 条
//   func recentToolCalls(toolsUsed []string, n int) []string {
//       if len(toolsUsed) < n { n = len(toolsUsed) }
//       return toolsUsed[len(toolsUsed)-n:]
//   }

maybeCompress := func() {
    cc := cfg.AutoCompress
    if cc == nil || len(messages) <= 3 {
        return
    }
    
    // === Phase 2 新增:压缩冷却检查 ===
    cooldown := a.getCooldown(sessionKey) // 从 Agent.cooldowns map 中获取
    if !cooldown.ShouldTrigger(i) {
        growthTracker.Record(i, totalTokens) // 仍然记录快照
        return
    }
    
    // === Phase 2 新增:采集触发信息 ===
    currentTokens, _ := llm.CountMessagesTokens(messages, cfg.Model)
    toolDefs := cfg.Tools.AsDefinitionsForSession(sessionKey)
    toolTokens, _ := llm.CountToolsTokens(toolDefs, cfg.Model)
    totalTokens := currentTokens + toolTokens
    
    // growthTracker 来自 Agent.growthTrackers[sessionKey]
    // 生命周期:存储在 Agent 结构体中(map[string]*TokenGrowthTracker)
    // /new 时通过 delete(Agent.growthTrackers, sessionKey) 清理
    triggerInfo := TriggerInfo{
        MaxTokens:      cc.MaxContextTokens,
        CurrentTokens:  totalTokens,
        IterationCount: i,
        ToolCallCount:  len(toolsUsed),
        IsFirstUserMsg: i == 0,
        TokenHistory:   growthTracker.Snapshots(),
        GrowthRate:     growthTracker.GrowthRate(),
        RecentTools:    recentToolCalls(toolsUsed, 5), // 取最近 5 条工具调用
        ToolPattern:    DetectToolPattern(recentToolCalls(toolsUsed, 5)),
    }
    
    // === Phase 2 新增:动态阈值 ===
    dynamicThreshold := calculateDynamicThreshold(triggerInfo)
    threshold := int(float64(cc.MaxContextTokens) * dynamicThreshold)
    
    if totalTokens < threshold {
        // 记录 token 快照(用于增长速率追踪)
        growthTracker.Record(i, totalTokens)
        return
    }
    
    // === Phase 2 新增:指数增长紧急触发 ===
    if growthTracker.IsExponentialGrowth() {
        log.Ctx(ctx).Warn("Exponential token growth detected, forcing early compression")
        threshold = int(float64(cc.MaxContextTokens) * 0.50)
    }
    
    // 记录冷却
    cooldown.RecordCompress(i)
    
    // 执行压缩(复用 Phase 1 逻辑)
    // ... 后续不变 ...
}

4.8 与 Claude Code 的对比

维度Claude Codexbot Phase 2
阈值类型静态 64-75%动态 50-85%
增长感知✅ 加权线性回归 + 指数检测
模式感知✅ 5 种工具模式识别
阶段感知✅ 4 个任务阶段
紧急压缩✅ 指数增长立即触发
压缩冷却✅ 防止无限循环

五、方向三:对话分区隔离

5.1 设计思路

参考 Kubiya 的 Isolate 策略,但更进一步:不仅隔离任务,还要自动检测话题切换,实现无需用户显式操作的智能分区。

核心洞察:长会话中,用户经常在同一会话里讨论多个不相关的话题。传统压缩将所有历史一锅端,导致当前话题的关键信息可能被压缩丢失。

与 Claude Code 的区别:Claude Code(截至 2025 年)没有话题分区能力(社区 Issue #16960 强烈要求但未实现)。xbot Phase 2 实现自动话题检测 + 分区压缩,这是超越 Claude Code 的关键差异化功能

5.2 话题检测算法

// TopicDetector 话题检测器
type TopicDetector struct {
    // 话题切换判定参数
    CosineThreshold float64 // 余弦相似度阈值(默认 0.3,低于此值认为话题切换)
    MinSegmentSize  int     // 最小片段长度(默认 3 条消息,避免碎片化)
}

// TopicSegment 话题片段
type TopicSegment struct {
    ID          string             // 片段 ID
    StartIdx    int                // 起始消息索引(在 session 中的位置)
    EndIdx      int                // 结束消息索引
    MessageCount int               // 消息数
    Keywords    []string           // 提取的关键词(用于标记)
    Summary     string             // 压缩后的摘要(仅已压缩的片段有值)
    IsCurrent   bool               // 是否是当前活跃话题
}

// Detect 检测话题边界
// 输入:session 中的 user/assistant 消息列表
// 输出:话题片段列表
func (d *TopicDetector) Detect(messages []llm.ChatMessage) []TopicSegment {
    if len(messages) < d.MinSegmentSize*2 {
        return []TopicSegment{{ID: "topic_0", StartIdx: 0, EndIdx: len(messages) - 1, IsCurrent: true}}
    }
    
    // 步骤 1: 将消息按"对话轮次"分组(一轮 = user + assistant)
    turns := groupIntoTurns(messages)
    
    // 步骤 2: 为每个轮次提取特征向量
    // 使用轻量级特征(关键词 TF-IDF 或简单词袋),支持中英文混合
    turnFeatures := make([][]string, len(turns))
    for i, turn := range turns {
        turnFeatures[i] = extractKeywords(turn.Text())
    }
    
    // 步骤 3: 计算相邻轮次的相似度
    boundaries := []int{0}
    for i := 1; i < len(turnFeatures); i++ {
        similarity := cosineSimilarity(turnFeatures[i-1], turnFeatures[i])
        if similarity < d.CosineThreshold {
            lastBoundary := boundaries[len(boundaries)-1]
            if i - lastBoundary >= d.MinSegmentSize {
                boundaries = append(boundaries, i)
            }
        }
    }
    boundaries = append(boundaries, len(turns))
    
    // 步骤 4: 构建话题片段
    var segments []TopicSegment
    for i := 0; i < len(boundaries)-1; i++ {
        seg := TopicSegment{
            ID:       fmt.Sprintf("topic_%d", i),
            StartIdx: turns[boundaries[i]].StartMsgIdx,
            EndIdx:   turns[boundaries[i+1]-1].EndMsgIdx,
            Keywords: mergeKeywords(turnFeatures[boundaries[i]:boundaries[i+1]]),
            IsCurrent: i == len(boundaries)-2,
        }
        seg.MessageCount = seg.EndIdx - seg.StartIdx + 1
        segments = append(segments, seg)
    }
    
    return segments
}

5.3 轻量级特征提取(无 embedding 依赖,支持中英文)

为确保话题检测不增加 LLM/embedding 成本,使用轻量级关键词提取,并支持中文分词:

// extractKeywords 从文本中提取关键词(规则方法,无需 LLM)
// 支持中英文混合:英文用空格/正则分词,CJK 字符按 bigram 拆分
func extractKeywords(text string) []string {
    keywords := make(map[string]bool)
    
    // 1. 提取文件路径模式
    for _, fp := range filepathPattern.FindAllString(text, -1) {
        keywords[strings.ToLower(fp)] = true
    }
    
    // 2. 提取代码标识符(驼峰/下划线命名)
    for _, id := range identifierPattern.FindAllString(text, -1) {
        keywords[strings.ToLower(id)] = true
    }
    
    // 3. 提取英文单词(去除常见停用词)
    words := tokenizeEnglish(text)
    for _, w := range removeStopWords(words) {
        keywords[strings.ToLower(w)] = true
    }
    
    // 4. 提取 CJK 字符序列(中文/日文/韩文)
    // 策略:使用 bigram(相邻两个字符为一组),而非依赖外部分词库
    // 原因:避免引入 jieba 等重量级依赖,bigram 对话题切换检测足够
    // 示例:"上下文管理" → ["上下", "下文", "文管", "管理"]
    cjkChars := extractCJKChars(text) // 提取连续 CJK 字符序列
    for _, segment := range cjkChars {
        runes := []rune(segment)
        for j := 0; j < len(runes)-1; j++ {
            keywords[string(runes[j:j+2])] = true // bigram
        }
    }
    
    // 5. 转换为列表
    result := make([]string, 0, len(keywords))
    for k := range keywords {
        result = append(result, k)
    }
    return result
}

// extractCJKChars 从文本中提取连续的 CJK 字符序列
// 使用 Unicode 范围:\p{Han}(中文)、\p{Hiragana}+\p{Katakana}(日文)、\p{Hangul}(韩文)
var cjkPattern = regexp.MustCompile(`[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]+`)

func extractCJKChars(text string) []string {
    return cjkPattern.FindAllString(text, -1)
}

// tokenizeEnglish 提取英文单词
var englishWordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_]*`)

func tokenizeEnglish(text string) []string {
    return englishWordPattern.FindAllString(text, -1)
}

中文分词设计决策

方案优点缺点选择
jieba 分词精度高依赖 C 库,跨平台编译困难❌ 不引入
按空格分词简单中文无空格分隔,完全无效
bigram无依赖,适合话题切换检测精度不如完整分词✅ 采用
unigram最简单太细粒度,相似度计算噪声大

bigram 对话题切换检测是够用的——不同话题的关键词 bigram 重叠度自然低,即使没有精确分词也能有效检测边界。

5.4 选择性压缩策略

// SelectiveCompressor 选择性压缩器
type SelectiveCompressor struct {
    detector *TopicDetector
}

// Compress 选择性压缩:保留当前话题完整,压缩历史话题
func (sc *SelectiveCompressor) Compress(
    ctx context.Context,
    messages []llm.ChatMessage,
    client llm.LLM,
    model string,
) (*CompressResult, error) {
    
    // 1. 检测话题分区
    segments := sc.detector.Detect(messages)
    
    if len(segments) <= 1 {
        // 只有一个话题,走标准压缩(Phase 1 逻辑)
        return standardCompress(ctx, messages, client, model)
    }
    
    // 2. 分离当前话题和历史话题
    var currentMsgs, historyMsgs []llm.ChatMessage
    for _, seg := range segments {
        if seg.IsCurrent {
            currentMsgs = messages[seg.StartIdx : seg.EndIdx+1]
        } else {
            historyMsgs = append(historyMsgs, messages[seg.StartIdx:seg.EndIdx+1]...)
        }
    }
    
    // 3. 压缩历史话题(可按话题分别压缩,保留话题标记)
    compressedHistory := sc.compressHistoryByTopic(ctx, historyMsgs, segments, client, model)
    
    // 4. 构建双视图
    historySummary := buildTopicAnnotatedSummary(compressedHistory)
    summaryMsg := llm.NewUserMessage("[Previous conversation context]\n\n" + historySummary)
    
    // LLM View: 保留当前话题的 tool 消息(API 兼容)
    llmView := []llm.ChatMessage{summaryMsg}
    llmView = append(llmView, currentMsgs...)
    
    // Session View: 当前话题提取对话视图
    currentSessionView := extractDialogueFromTail(currentMsgs)
    sessionView := []llm.ChatMessage{summaryMsg}
    sessionView = append(sessionView, currentSessionView...)
    
    return &CompressResult{
        LLMView:     llmView,
        SessionView: sessionView,
    }, nil
}

5.5 话题切换感知(同步压缩,避免并发写入)

⚠️ 设计约束:话题压缩必须在 processMessage 的主流程中同步执行,不能用 go 异步。 原因:TenantSession 不是线程安全的,异步 goroutine 与主流程并发写入 session.AddMessage() 会导致数据损坏。 如果后续需要异步,必须先给 TenantSession 增加 sync.RWMutex(但当前版本保持同步,最小改动)。

// 在 processMessage 中集成话题切换检测
func (a *Agent) processMessage(ctx context.Context, msg bus.InboundMessage) (*bus.OutboundMessage, error) {
    // ... 现有逻辑 ...
    
    tenantSession, err := a.multiSession.GetOrCreateSession(msg.Channel, msg.ChatID)
    
    // === Phase 2 新增:话题切换检测(同步压缩) ===
    if a.topicDetector != nil && a.enableAutoCompress && a.enableTopicIsolation {
        history, _ := tenantSession.GetMessages()
        if len(history) > 10 { // 至少 10 条消息才检测(避免误判)
            segments := a.topicDetector.Detect(history)
            
            if len(segments) > 1 {
                latestSeg := segments[len(segments)-1]
                // 最新话题只有 1-2 条消息(刚切换)
                if latestSeg.MessageCount <= 2 {
                    log.Ctx(ctx).WithFields(log.Fields{
                        "old_topic": segments[len(segments)-2].Keywords,
                        "new_topic": latestSeg.Keywords,
                    }).Info("Topic switch detected, triggering synchronous selective compression")
                    
                    // ⚠️ 同步压缩(不能 go 异步,避免并发写入 session)
                    // 压缩耗时(LLM 调用)约 1-3 秒,在用户消息处理前期可接受
                    result, err := a.selectiveCompressor.Compress(ctx, history, a.client, a.cfg.Model)
                    if err == nil {
                        // 压缩成功,替换 session 消息
                        tenantSession.Clear()
                        for _, m := range result.SessionView {
                            tenantSession.AddMessage(m)
                        }
                    } else {
                        log.Ctx(ctx).WithError(err).Warn("Selective compression failed, continuing with full history")
                    }
                }
            }
        }
    }
    
    // ... 后续逻辑不变 ...
}

话题切换检测的误判缓解

防护层措施作用
最小历史len(history) > 10历史太短不检测
最小片段MinSegmentSize = 3避免碎片化话题
相似度阈值CosineThreshold = 0.3保守判定,减少假阳性
新话题短MessageCount <= 2只在刚切换时触发
压缩失败降级错误时继续使用完整历史不影响主流程

5.6 话题标注的压缩摘要格式

[Previous conversation context]

## 📁 Topic: 认证模块开发 (topic_0)
关键词: auth, login, jwt, middleware, token
摘要: 实现了 JWT 认证中间件。创建了 auth/middleware.go,包含 TokenValidator 和
RefreshToken 逻辑。修复了 token 过期后未正确刷新的 bug(#342)。使用了 RS256 算法。
关键文件: auth/middleware.go, auth/token.go, config/auth.yaml

## 📁 Topic: 数据库迁移 (topic_1)
关键词: migration, schema, postgres, user_table
摘要: 编写了数据库迁移脚本,新增 users 表(含 email, password_hash, created_at 字段)。
使用 golang-migrate 库。迁移 ID: 20260319_001_create_users。
关键文件: migrations/20260319_001_create_users.up.sql

---
↑ 以上为历史话题摘要,可按需召回详情(offload_store 中保留完整数据)

六、方向四:压缩质量保障

6.1 设计思路

压缩是有损操作,但信息损失应该可控、可度量。Phase 2 引入三层质量保障:

  1. 压缩前校验:提取关键信息指纹,压缩后验证是否保留
  2. 结构化标记:要求 LLM 压缩输出使用结构化标记,便于检索和校验
  3. 质量评分:量化压缩质量,低于阈值时触发重新压缩

与 Claude Code 的区别:Claude Code 没有压缩质量保障机制,压缩结果"黑盒化"。xbot Phase 2 让压缩透明、可审计。

6.2 关键信息指纹

// KeyInfoFingerprint 关键信息指纹
type KeyInfoFingerprint struct {
    // 文件路径(压缩后应保留所有被引用的文件路径)
    FilePaths []string `json:"file_paths"`
    
    // 函数/类型/变量名(压缩后应保留关键标识符)
    Identifiers []string `json:"identifiers"`
    
    // 错误信息(压缩后必须保留所有遇到的错误)
    Errors []string `json:"errors"`
    
    // 决策记录(压缩后应保留关键决策)
    Decisions []string `json:"decisions"`
    
    // 待办/未完成任务
    PendingTasks []string `json:"pending_tasks"`
}

// ExtractFingerprint 从消息列表中提取关键信息指纹
// 子函数实现说明:
// - extractPathsFromJSON: 从 JSON 字符串中提取 "path"/"file" 字段值
// - extractFilePaths: 从文本中用正则提取 ./xxx、/xxx、xxx.go 等路径模式
// - extractCodeIdentifiers: 提取驼峰/下划线命名的代码标识符
// - isErrorContext: 检测文本是否包含错误上下文(error, panic, failed, 40x, 50x)
// - extractErrorMessages: 从错误上下文中提取具体错误描述(error行/JSON错误消息)
// - extractDecisions: 提取决策标记("决定"、"decided"、"choose" 等模式后的内容)
func ExtractFingerprint(messages []llm.ChatMessage) KeyInfoFingerprint {
    fp := KeyInfoFingerprint{}
    seen := make(map[string]bool)
    
    addUnique := func(items []string, target *[]string) {
        for _, item := range items {
            if !seen[item] {
                seen[item] = true
                *target = append(*target, item)
            }
        }
    }
    
    for _, msg := range messages {
        text := msg.Content
        if msg.Role == "tool" {
            // 从工具参数中提取文件路径
            if paths := extractPathsFromJSON(msg.ToolArguments); len(paths) > 0 {
                addUnique(paths, &fp.FilePaths)
            }
        }
        
        addUnique(extractFilePaths(text), &fp.FilePaths)
        addUnique(extractCodeIdentifiers(text), &fp.Identifiers)
        
        if isErrorContext(text) {
            addUnique(extractErrorMessages(text), &fp.Errors)
        }
        
        addUnique(extractDecisions(text), &fp.Decisions)
    }
    
    return fp
}

6.3 压缩质量校验

// ValidateCompression 校验压缩质量
// 返回保留率 (0.0-1.0) 和丢失的关键信息列表
func ValidateCompression(original, compressed string, fp KeyInfoFingerprint) (float64, []string) {
    total := len(fp.FilePaths) + len(fp.Errors) + len(fp.Decisions)
    if total == 0 {
        return 1.0, nil // 无关键信息需保留
    }
    
    retained := 0
    var lost []string
    
    for _, path := range fp.FilePaths {
        if strings.Contains(compressed, path) {
            retained++
        } else {
            lost = append(lost, "文件路径: "+path)
        }
    }
    
    for _, err := range fp.Errors {
        if strings.Contains(compressed, err) || containsSemanticMatch(compressed, err) {
            retained++
        } else {
            lost = append(lost, "错误信息: "+err)
        }
    }
    
    for _, d := range fp.Decisions {
        if strings.Contains(compressed, d) || containsSemanticMatch(compressed, d) {
            retained++
        } else {
            lost = append(lost, "决策: "+d)
        }
    }
    
    return float64(retained) / float64(total), lost
}

// containsSemanticMatch 语义模糊匹配
// 用于检测压缩摘要中是否"语义上"保留了某条信息(即使措辞不同)
// 实现策略(轻量级,无 LLM 依赖):
//   1. 精确子串匹配(已在外层做)
//   2. 归一化后子串匹配:统一大小写、去除标点、去除空格
//   3. 关键词重叠度:将原始文本拆分为关键词(空格/标点分割),
//      检查压缩文本中包含的关键词比例是否 >= 0.6
//   4. 编辑距离(可选):对于短文本(<50字符),检查编辑距离/长度比 < 0.3
func containsSemanticMatch(compressed, target string) bool {
    // 策略 1: 归一化后子串匹配
    normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(compressed, " ", ""), ".", ""))
    normalizedTarget := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(target, " ", ""), ".", ""))
    if strings.Contains(normalized, normalizedTarget) {
        return true
    }
    
    // 策略 2: 关键词重叠度
    targetWords := splitToWords(target)
    if len(targetWords) == 0 {
        return false
    }
    matched := 0
    for _, w := range targetWords {
        if strings.Contains(normalized, strings.ToLower(w)) {
            matched++
        }
    }
    overlapRatio := float64(matched) / float64(len(targetWords))
    if overlapRatio >= 0.6 {
        return true
    }
    
    // 策略 3: 短文本编辑距离
    if len([]rune(target)) < 50 {
        // 简化版 Levenshtein 距离检查(仅比较归一化后的前缀/后缀)
        // 完整 Levenshtein 对长文本太慢,此处用启发式
        if strings.HasPrefix(normalized, normalizedTarget[:len(normalizedTarget)/2]) {
            return true
        }
    }
    
    return false
}

// splitToWords 将文本拆分为关键词(去除常见停用词和短词)
func splitToWords(text string) []string {
    // 按空格和标点分割
    parts := regexp.MustCompile(`[\s,.\-:;!?/\\|(){}[\]<>]+`).Split(text, -1)
    stopWords := map[string]bool{"the": true, "a": true, "an": true, "is": true, "are": true,
        "was": true, "were": true, "in": true, "on": true, "at": true, "to": true, "for": true,
        "of": true, "and": true, "or": true, "的": true, "了": true, "是": true, "在": true}
    var result []string
    for _, p := range parts {
        p = strings.ToLower(strings.TrimSpace(p))
        if len(p) > 1 && !stopWords[p] {
            result = append(result, p)
        }
    }
    return result
}

6.4 结构化压缩标记

要求 LLM 压缩输出使用以下结构化标记:

// 增强版压缩 prompt
const structuredCompressionPrompt = `You are a context compression expert. Compress the conversation history
into a structured summary.

## OUTPUT FORMAT (IMPORTANT: use these structured markers)

Use the following markers to categorize information:

@file:{path} — File references (e.g., @file:agent/compress.go)
@func:{name} — Function/method signatures (e.g., @func:compressContext())
@type:{name} — Type/struct definitions (e.g., @type:CompressResult)
@error:{description} — Errors encountered (e.g., @error:API returned 429 rate limit)
@decision:{description} — Decisions made (e.g., @decision:use dual-view architecture)
@todo:{description} — Pending tasks (e.g., @todo:add unit tests for extractDialogueFromTail)
@config:{key=value} — Configuration changes (e.g., @config:threshold=0.7)

## Compression Rules
1. Retain ALL key facts, decisions, and important details
2. Mark all file paths with @file:
3. Mark all function/type names with @func: or @type:
4. Mark all errors with @error: — these MUST be preserved
5. Mark decisions with @decision:
6. Mark unfinished tasks with @todo:
7. Maintain chronological order within each topic
8. If multiple topics were discussed, separate them with ## headers

## Conversation History (to compress)
` + "{{ .History }}"

压缩输出示例

## 认证模块开发

@decision:使用 JWT RS256 算法实现认证
@file:auth/middleware.go — 创建了 TokenValidator 中间件
  @func:ValidateToken() — 验证 JWT token,支持刷新
  @func:RefreshToken() — 使用 refresh_token 获取新的 access_token
@file:auth/token.go — Token 生成和验证工具函数
  @func:GenerateTokenPair() — 生成 access_token + refresh_token 对

@error:token 过期后返回 401 但未触发刷新流程
  修复:在 @file:auth/middleware.go 添加 RefreshMiddleware,检测 401 后自动刷新

@config:JWT_EXPIRY=24h, REFRESH_EXPIRY=7d

@todo:添加 token 黑名单机制(用于登出功能)
@todo:编写 middleware 单元测试

6.5 压缩质量评分

// CompressionQuality 压缩质量评估结果
type CompressionQuality struct {
    Score          float64  // 综合质量分 (0.0-1.0)
    KeyInfoRate    float64  // 关键信息保留率
    SizeReduction  float64  // 上下文大小缩减率
    MarkerCount    int      // 结构化标记数量
    LostItems      []string // 丢失的关键信息
    Recommendations []string // 改进建议
}

// EvaluateQuality 评估压缩质量
func EvaluateQuality(originalTokens, compressedTokens int, fp KeyInfoFingerprint, compressed string) CompressionQuality {
    quality := CompressionQuality{}
    
    // 1. 关键信息保留率(权重 50%)
    quality.KeyInfoRate, quality.LostItems = ValidateCompression("", compressed, fp)
    
    // 2. 大小缩减率(权重 30%)
    if originalTokens > 0 {
        quality.SizeReduction = 1.0 - float64(compressedTokens)/float64(originalTokens)
    }
    
    // 3. 结构化标记丰富度(权重 20%)
    markers := countStructuredMarkers(compressed)
    quality.MarkerCount = markers
    markerScore := math.Min(float64(markers)/20.0, 1.0) // 20 个标记为满分
    
    // 综合评分
    quality.Score = quality.KeyInfoRate*0.50 + quality.SizeReduction*0.30 + markerScore*0.20
    
    // 生成改进建议
    if quality.KeyInfoRate < 0.8 {
        quality.Recommendations = append(quality.Recommendations,
            fmt.Sprintf("关键信息保留率 %.0f%% 低于阈值,丢失: %v", quality.KeyInfoRate*100, quality.LostItems))
    }
    if quality.SizeReduction < 0.3 {
        quality.Recommendations = append(quality.Recommendations,
            "压缩率低于 30%,建议更激进地压缩")
    }
    if markerScore < 0.3 {
        quality.Recommendations = append(quality.Recommendations,
            "结构化标记不足,建议强化标记要求")
    }
    
    return quality
}

// countStructuredMarkers 统计压缩输出中的结构化标记数量
func countStructuredMarkers(text string) int {
    markers := []string{"@file:", "@func:", "@type:", "@error:", "@decision:", "@todo:", "@config:"}
    count := 0
    for _, m := range markers {
        count += strings.Count(text, m)
    }
    return count
}

6.6 质量保障集成到压缩流程

// 增强版 compressContext(集成质量保障)
func (a *Agent) compressContextWithQuality(ctx context.Context, 
    messages []llm.ChatMessage, client llm.LLM, model string) (*CompressResult, *CompressionQuality, error) {
    
    // Phase 1: 提取关键信息指纹(压缩前)
    fingerprint := ExtractFingerprint(messages)
    originalTokens, _ := llm.CountMessagesTokens(messages, model)
    
    // Phase 2: 执行压缩(使用结构化 prompt)
    result, err := a.compressContext(ctx, messages, client, model)
    if err != nil {
        return nil, nil, err
    }
    
    // Phase 3: 评估压缩质量
    compressedTokens, _ := llm.CountMessagesTokens(result.SessionView, model)
    compressedText := extractTextFromMessages(result.SessionView)
    
    quality := EvaluateQuality(originalTokens, compressedTokens, fingerprint, compressedText)
    
    // Phase 4: 质量不达标时重新压缩(最多重试 1 次)
    if quality.Score < 0.6 && quality.KeyInfoRate < 0.8 {
        log.Ctx(ctx).WithFields(log.Fields{
            "score":        quality.Score,
            "key_info_rate": quality.KeyInfoRate,
            "lost_items":   quality.LostItems,
        }).Warn("Compression quality below threshold, re-compressing with enhanced prompt")
        
        enhancedResult, err := a.compressContextEnhanced(ctx, messages, fingerprint, client, model)
        if err == nil {
            enhancedTokens, _ := llm.CountMessagesTokens(enhancedResult.SessionView, model)
            enhancedText := extractTextFromMessages(enhancedResult.SessionView)
            newQuality := EvaluateQuality(originalTokens, enhancedTokens, fingerprint, enhancedText)
            
            if newQuality.Score > quality.Score {
                return enhancedResult, &newQuality, nil
            }
        }
        // 增强版也失败,使用原始结果(有总比没有好)
    }
    
    return result, &quality, nil
}

七、实施方案

7.1 分阶段实施路线

阶段内容预估工作量依赖
P2.1智能压缩触发(动态阈值 + 冷却机制)2天
P2.2Layer 1 Offload(大 tool result 落盘 + offload_recall)2天
P2.3Layer 2 Evict(信息密度驱逐)2天
P2.4压缩质量保障(指纹+评分+语义匹配)2天
P2.5话题分区隔离(含中英文支持)3天P2.1
P2.6结构化压缩标记1天P2.4

建议实施顺序:P2.1 → P2.4 → P2.2 → P2.3 → P2.6 → P2.5

理由:

  1. P2.1(动态阈值+冷却)改动最小、收益最直接——改善压缩时机和稳定性
  2. P2.4(质量保障)为后续所有压缩操作提供质量基线
  3. P2.2+P2.3(分层压缩)需要修改压缩流水线
  4. P2.6(结构化标记)改善压缩输出格式
  5. P2.5(话题分区)最复杂,放最后

7.2 文件变更清单

文件变更类型说明
agent/compress.go修改增加 Evict、质量保障逻辑
agent/engine.go修改maybeCompress 集成动态阈值、增长追踪、冷却机制
agent/topic.go新增话题检测(含中英文分词)、分区管理、选择性压缩
agent/offload.go新增Offload store 管理、offload_recall 工具
agent/quality.go新增压缩质量评估(指纹提取、语义匹配、评分)
agent/trigger.go新增动态阈值算法、增长速率追踪、冷却机制、工具模式检测
llm/types.go修改ChatMessage 增加 OffloadRef 字段(可选)
agent/agent.go修改增加 Phase 2 配置项、growthTrackers map、cooldowns map
tools/offload_recall.go新增offload_recall 工具实现(供 Agent 工具调用)

7.3 配置项扩展

// Phase 2 新增配置项(全部有默认值,默认关闭实验性功能)
type Config struct {
    // ... 现有配置 ...
    
    // === Phase 2: 分层压缩 ===
    EnableOffload     bool    `json:"enable_offload"`      // 启用 Layer 1 Offload(默认 true)
    OffloadMaxTokens  int     `json:"offload_max_tokens"`   // 单条 tool result 触发 offload 的阈值(默认 2000)
    
    // === Phase 2: 智能触发 ===
    EnableDynamicThreshold bool `json:"enable_dynamic_threshold"` // 启用动态阈值(默认 true)
    CompressCooldownRounds  int  `json:"compress_cooldown_rounds"` // 压缩冷却轮数(默认 3)
    
    // === Phase 2: 话题分区 ===
    EnableTopicIsolation   bool    `json:"enable_topic_isolation"`    // 启用话题分区隔离(默认 false,实验性)
    TopicMinSegmentSize    int     `json:"topic_min_segment_size"`    // 最小话题片段长度(默认 3)
    TopicSimilarityThreshold float64 `json:"topic_similarity_threshold"` // 话题切换相似度阈值(默认 0.3)
    
    // === Phase 2: 质量保障 ===
    EnableQualityCheck     bool    `json:"enable_quality_check"`     // 启用压缩质量校验(默认 true)
    MinCompressionQuality  float64 `json:"min_compression_quality"`  // 最低压缩质量分(默认 0.6)
}

八、风险评估

风险影响概率缓解措施
Offload 磁盘 IO 影响性能工具执行后写入延迟增加offload 文件写入为同步但轻量(JSON 序列化+单文件写入,毫秒级);/new 时清理
话题检测误判正常对话被误认为话题切换MinSegmentSize=3 + 阈值 0.3 + 仅 >10 条时检测 + MessageCount<=2 才触发
动态阈值计算开销每次迭代增加计算增长追踪用滑窗 O(10);工具模式检测 O(5);均纳秒级
结构化标记 LLM 不遵守压缩输出不含 @file: 等标记prompt 强调 + 质量评分检测 + 不遵守时降级为无标记模式
多话题压缩 LLM 调用增加按话题分别压缩增加成本最多 3 个历史话题分别压缩;超出合并压缩
质量重新压缩的 LLM 成本不达标时需要额外调用最多重试 1 次;重新压缩 prompt 更紧凑
话题压缩同步执行延迟压缩耗时 1-3 秒阻塞主流程仅在话题切换时触发(低频);压缩失败时降级继续
中文 bigram 话题检测精度不如完整分词bigram 对话题边界检测够用;可后续升级为 jieba

九、预期效果

9.1 量化指标

指标Claude Codexbot Phase 1xbot Phase 2
上下文利用率~75%(静态阈值)~70%(静态阈值)50-85%(动态阈值)
大 tool result 处理摘要化摘要化Offload 到磁盘 + 可召回
话题隔离✅ 自动检测 + 选择性压缩
压缩质量可观测✅ 指纹校验 + 评分
压缩信息损失不可知不可知可度量(关键信息保留率)
提前压缩能力✅ 指数增长预测 + 提前触发
压缩冷却保护✅ 3 轮冷却防止无限循环
多语言话题检测✅ 中英文 bigram 混合支持

9.2 用户体验提升

  • 长任务稳定性:动态阈值 + 提前压缩 + 冷却保护,避免"上下文爆满→强制压缩→丢失关键信息"的恶性循环
  • 多话题流畅度:话题分区隔离确保切换话题时不会丢失之前话题的上下文
  • 大文件处理:Offload 让 Agent 可以读取任意大小的文件而不会撑爆上下文
  • 压缩透明度:质量评分让用户(和开发者)了解压缩效果

十、附录:与 Claude Code 的全面对比

维度Claude Code (2025)xbot Phase 2
压缩触发静态 64-75%动态 50-85%,感知任务阶段/增长速率/工具模式
压缩层级单层(LLM 摘要)三层渐进(Offload → Evict → Compact)
大结果处理LLM 摘要化Offload 到磁盘 + 可按 ID 召回
信息密度无概念按密度评分选择性驱逐
话题感知❌(社区强烈要求)✅ 自动检测 + 选择性压缩
子任务级压缩❌(社区强烈要求)✅ 话题分区实现类似效果
压缩质量黑盒指纹校验 + 质量评分 + 结构化标记
增长预测加权线性回归 + 指数增长检测
压缩冷却3 轮冷却防止无限循环
可观测性仅 token 数压缩质量分、关键信息保留率、丢失列表
压缩可恢复性❌(不可逆)✅(offload_store 保留原始数据)
多语言支持英文中英文混合话题检测