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 上下文管理改进:设计文档

⚠️ 演进说明(2026-03-27):本文档描述的 Phase 1 设计核心原则「执行视图隔离 — Tool call/result 仅在 Run() 内存中存在」已在后续重构(commit 45d6078)中被调整。当前实现改为直接持久化 engine 产生的 assistant + tool 消息到 session,以确保下一轮对话拥有完整上下文。本文档作为历史设计参考保留。

中书省拟 | 2026-03-19 状态:待陛下审核 前置文档:《调研报告》context-management-research.md


一、设计目标

1.1 核心原则

Tool call 消息只在处理一个用户 message 期间存在。用户新消息来的时候,上下文只有 user prompt 和 assistant 回复。

原则说明
对话视图纯净Session 持久层只存 user + assistant 消息
执行视图隔离Tool call/result 仅在 Run() 内存中存在
压缩不泄漏自动压缩持久化时剥离 tool 消息
信息不丢失Tool 执行的关键结果融入 assistant 回复或压缩摘要

1.2 与现有系统协同

  • ✅ 与现有 Letta 三层记忆系统(Core/Archival/Recall)无冲突
  • ✅ 与记忆整理(consolidation)机制兼容
  • ✅ 与中间件管道(pipeline)架构兼容

二、现状分析

2.1 当前消息流(问题路径)

用户消息 → buildPrompt()
           ├── session.GetHistory()  ← 可能含 tool 消息(已泄漏)
           └── pipeline.Run()        ← 注入 system/memory/skills
         → Run()
           ├── LLM 调用 → tool call → tool result → LLM 调用 → ...
           ├── maybeCompress()
           │     ├── compressContext()  → 压缩结果含 tool 消息
           │     └── session.Clear() + session.AddMessage()  ← tool 消息泄漏到 session ❌
           └── 返回 finalContent
         → session.AddMessage(userMsg)
         → session.AddMessage(assistantMsg)  ← 正常路径无问题 ✅

2.2 问题代码定位

compress.go:compressContext() — 核心问题函数:

// 当前注释(有问题):
// 核心原则:
// 1. 保留所有 tool 消息(tool_calls 和 tool result 必须配对,否则 API 报错)  ← 仅为 API 兼容
// 2. 把压缩后的摘要作为 user prompt 直接调用 LLM
// 3. 保留 system 消息和最近的对话轮次

返回的 compressed 消息列表中包含 thinnedTail(最近 3 组 tool call/result),这些消息被持久化到 session 后,下次 GetHistory() 就会加载出来。

engine.go:maybeCompress() — 持久化逻辑:

// 持久化压缩结果到 session
if cfg.Session != nil {
    cfg.Session.Clear()
    for _, msg := range compressed {  // ← compressed 含 tool 消息
        if msg.Role == "system" { continue }
        cfg.Session.AddMessage(msg)  // ← tool 消息写入 session ❌
    }
}

三、设计方案:双视图架构

3.1 架构概览

┌─────────────────────────────────────────────────────────┐
│                    LLM View (内存)                       │
│  ┌───────────┐  ┌──────────────────┐  ┌──────────────┐  │
│  │ System    │  │ Session History  │  │ Current Turn │  │
│  │ Prompt    │  │ (user+assistant) │  │ Tool Calls   │  │
│  │ + Memory  │  │ + Compressed     │  │ + Results    │  │
│  │ + Skills  │  │   Summary        │  │              │  │
│  └───────────┘  └──────────────────┘  └──────────────┘  │
│       ↓               ↓                    ↓           │
│  ─ ─ ─ ─ ─ ─ ─ → 发送给 LLM ← ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
└─────────────────────────────────────────────────────────┘
                          ↕ Run() 期间同步
┌─────────────────────────────────────────────────────────┐
│               Session View (持久化)                      │
│  ┌──────────────────────────────────────────────────┐   │
│  │ user_msg_1 → assistant_msg_1                     │   │
│  │ user_msg_2 → assistant_msg_2                     │   │
│  │ [compressed_summary] (user role)                 │   │
│  │ user_msg_N → assistant_msg_N                     │   │
│  │ ⚠️ 不含任何 tool/assistant(tool_calls) 消息       │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

3.2 关键设计:compressContext 改造

改造前

compressContext(messages) → compressed messages(含 tool)
  → 持久化到 session(tool 泄漏)
  → 继续用于 LLM 调用

改造后

compressContext(messages) → compressed messages(含 tool,用于 LLM 继续调用)
                           + sessionMessages(纯 user/assistant,用于持久化)

具体实现:compressContext 返回两个切片:

// CompressResult 压缩结果,区分 LLM 视图和 Session 视图
type CompressResult struct {
    // LLMView 用于继续当前 Run() 循环的 LLM 调用
    // 可能包含 tool 消息(API 兼容性需要)
    LLMView []llm.ChatMessage
    
    // SessionView 用于持久化到 session
    // 只包含 user + assistant(无 tool)消息
    // tool 执行的关键信息已融入压缩摘要
    SessionView []llm.ChatMessage
}

3.3 compressContext 详细设计

func (a *Agent) compressContext(ctx context.Context, messages []llm.ChatMessage, 
    client llm.LLM, model string) (*CompressResult, error) {
    
    // 第一步:找到尾部安全切割点(不变)
    tailStart := findSafeTailStart(messages)
    
    // 第二步:分离 thinnedTail(用于 LLM View)
    thinnedTail := thinTail(messages[tailStart:], 3)
    
    // 第三步:从 thinnedTail 提取对话摘要(用于 Session View)
    tailSummary := extractDialogueFromTail(thinnedTail)
    
    // 第四步:压缩旧历史(不变)
    compressed := compressOldHistory(messages[:tailStart], client, model)
    
    // 构建两个视图
    llmView := []llm.ChatMessage{systemMsg}
    llmView = append(llmView, userSummaryMsg(compressed))
    llmView = append(llmView, thinnedTail...)  // 含 tool 消息
    
    sessionView := []llm.ChatMessage{}
    sessionView = append(sessionView, userSummaryMsg(compressed))  // 压缩摘要
    sessionView = append(sessionView, tailSummary...)  // 尾部对话摘要
    
    return &CompressResult{
        LLMView:    llmView,
        SessionView: sessionView,
    }, nil
}

3.4 extractDialogueFromTail 设计

从 thinnedTail(含 tool 消息)中提取纯对话视图:

// extractDialogueFromTail 从含 tool 消息的尾部提取 user/assistant 对话
// 每个 tool group 的效果被摘要为 assistant 消息的一部分
func extractDialogueFromTail(tail []llm.ChatMessage) []llm.ChatMessage {
    var result []llm.ChatMessage
    var pendingToolSummary strings.Builder  // 累积当前轮的 tool 执行摘要
    
    for _, msg := range tail {
        switch {
        case msg.Role == "user":
            flushPending(&result, &pendingToolSummary)
            result = append(result, llm.NewUserMessage(msg.Content))
            
        case msg.Role == "assistant" && len(msg.ToolCalls) > 0:
            // assistant 发起了 tool call,记录工具名称
            flushPending(&result, &pendingToolSummary)
            if msg.Content != "" {
                // assistant 有文本内容,先记录
                pendingToolSummary.WriteString(msg.Content + "\n")
            }
            for _, tc := range msg.ToolCalls {
                pendingToolSummary.WriteString(fmt.Sprintf("🔧 %s(%s)\n", tc.Name, truncateArgs(tc.Arguments, 100)))
            }
            
        case msg.Role == "assistant":
            flushPending(&result, &pendingToolSummary)
            result = append(result, llm.NewAssistantMessage(msg.Content))
            
        case msg.Role == "tool":
            // tool result,累积摘要(截断长内容)
            toolContent := truncateRunes(msg.Content, 200)
            pendingToolSummary.WriteString(fmt.Sprintf("  → %s\n", toolContent))
        }
    }
    flushPending(&result, &pendingToolSummary)
    return result
}

// flushPending 将累积的 tool 执行摘要作为 assistant 消息添加到结果
func flushPending(result *[]llm.ChatMessage, builder *strings.Builder) {
    if builder.Len() == 0 {
        return
    }
    *result = append(*result, llm.NewAssistantMessage(builder.String()))
    builder.Reset()
}

输出示例

输入(thinnedTail):
  [user] "帮我查看 main.go"
  [assistant, tool_calls: [Read("main.go")]]
  [tool] "package main\nimport (..."
  [assistant] "这是 main.go 的内容,主要功能是..."

输出(SessionView):
  [user] "帮我查看 main.go"
  [assistant] "🔧 Read(main.go)\n  → package main\nimport (...\n这是 main.go 的内容,主要功能是..."

3.5 maybeCompress 持久化改造

maybeCompress := func() {
    // ... 触发判断逻辑不变 ...
    
    result, compressErr := cc.CompressFunc(ctx, messages, cfg.LLMClient, cfg.Model)
    // ...
    
    // LLM View:继续当前 Run
    messages = result.LLMView
    
    // Session View:持久化到 session(不含 tool 消息)
    if cfg.Session != nil {
        cfg.Session.Clear()
        for _, msg := range result.SessionView {
            cfg.Session.AddMessage(msg)  // 纯 user/assistant
        }
    }
}

3.6 处理 Input-too-long 强制压缩

同 maybeCompress 逻辑,使用 result.SessionView 持久化,result.LLMView 继续调用。

3.7 handleCompress (/compress 命令) 改造

同 maybeCompress 逻辑。用户手动 /compress 时,Session View 写入 session,LLM View 仅在内存中。


四、压缩策略优化

4.1 压缩阈值调整

// 当前
CompressionThreshold: 0.8  // 80% 触发

// 建议(参考 Claude Code 64-75% 策略)
CompressionThreshold: 0.7  // 70% 触发,留 30% completion buffer

4.2 压缩 Prompt 优化

当前的压缩 prompt 较为通用,建议增加:

compressionPrompt := `You are a context compression expert. ...

## Compression Rules
1. Retain ALL key facts, decisions, and important details
2. Keep track of what the user has asked for and what has been done
3. Preserve any file paths, code snippets, or technical details
4. Maintain the logical flow and context of the conversation
5. Note any errors or issues that were encountered
6. **For tool execution sequences**: summarize what was done and the result,
   but don't include raw tool call/request format
7. Preserve any open questions or unfinished tasks
8. Include the current working directory and any important state
`

4.3 Tail 保留策略

// 当前
thinTail(tail, 3)  // 保留最近 3 组 tool group

// 建议不变,但增加日志
// 最近 3 组 tool call 对应的对话轮次,确保当前任务有足够上下文继续

五、清理已有脏数据

5.1 Session 数据迁移

需要清理已有 session 中泄漏的 tool 消息:

// cleanupToolMessages 清理 session 中的 tool 消息
func cleanupToolMessages(tenantSession *session.TenantSession) error {
    messages, err := tenantSession.GetAllMessages()
    if err != nil {
        return err
    }
    
    var clean []llm.ChatMessage
    for _, msg := range messages {
        // 跳过 tool 消息和含 tool_calls 的 assistant 消息
        if msg.Role == "tool" {
            continue
        }
        if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
            // 如果 assistant 消息只有 tool_calls 无 content,跳过
            if strings.TrimSpace(msg.Content) == "" {
                continue
            }
            // 如果有 content,保留但移除 ToolCalls
            msg.ToolCalls = nil
        }
        clean = append(clean, msg)
    }
    
    tenantSession.Clear()
    for _, msg := range clean {
        tenantSession.AddMessage(msg)
    }
    return nil
}

建议在 Agent 启动时或在 /new 命令中自动执行清理。


六、实施计划

Phase 1: 核心修复(优先级最高)

任务文件预估
新增 CompressResult 类型agent/compress.go
改造 compressContext 返回双视图agent/compress.go
新增 extractDialogueFromTailagent/compress.go
改造 maybeCompress 持久化逻辑agent/engine.go
改造 input-too-long 压缩逻辑agent/engine.go
改造 handleCompressagent/compress.go
更新 CompressFunc 类型签名agent/engine.go

Phase 2: 策略优化

任务说明
压缩阈值调整为 0.7New() 中的默认值
优化压缩 promptcompressContext 中的 prompt 模板
启动时清理脏数据session 包增加迁移函数

Phase 3: 测试

测试说明
单元测试:extractDialogueFromTail验证各种 tail 组合的输出
单元测试:compressContext 双视图验证 LLMView 含 tool、SessionView 不含
集成测试:正常流程无 tool 泄漏模拟多轮对话,检查 session
集成测试:自动压缩无 tool 泄漏触发压缩后检查 session
回归测试:/compress 命令手动压缩后检查 session

七、风险评估

风险影响缓解措施
extractDialogueFromTail 信息丢失assistant 回复可能丢失 tool 执行细节关键 tool 结果保留 200 字符摘要
compressContext 双视图不一致LLM View 和 Session View 信息不同步共享压缩摘要文本,确保信息一致性
压缩时机过早任务未完成就触发压缩保留 completion buffer(70% 阈值)
脏数据清理影响现有会话清理后已有对话上下文丢失只清理 tool 消息,保留所有 user/assistant

八、预期效果

8.1 上下文纯净度

压缩前 session 状态(❌ 当前):
  [user] "帮我修改代码"
  [assistant, tool_calls: [Read(...), Edit(...)]]  ← 不应出现
  [tool] "package main..."                          ← 不应出现
  [assistant] "已修改完成"
  [user] "再帮我检查一下"
  [assistant, tool_calls: [Grep(...)]]             ← 不应出现
  ...

压缩后 session 状态(✅ 目标):
  [user] "帮我修改代码"
  [assistant] "🔧 Read(main.go)\n  → package main...\n🔧 Edit(main.go)\n  → 已修改\n已修改完成"
  [user] "再帮我检查一下"
  [assistant] "🔧 Grep(pattern=\"error\")\n  → 找到 3 处匹配\n检查结果如下..."

8.2 上下文效率提升

  • Session 存储大小减少 ~40-60%(移除 tool result 的大段内容)
  • GetHistory 加载更快(更少消息条数)
  • 上下文窗口利用率提升(更多空间给有用内容)

8.3 用户体验改善

  • ✅ 新消息的上下文干净,只有对话内容
  • ✅ LLM 不会因历史 tool call 而产生困惑
  • ✅ 压缩后对话连贯性保持