前言:一个让人崩溃的真实场景

想象一下这个场景:用户在一个知识库问答会话中,连续问了 30 个问题。前 20 轮在讨论“项目 A 的技术架构”,第 21 轮突然问了一句——“那个数据库连接池的配置参数是多少?”

此时你的 Agent 系统面临一个经典困境:

  • 如果保留全部历史:30 轮对话轻松撑爆 6000 Token 上限
  • 如果只保留最近 5 轮:“那个数据库”指代的是 15 轮前讨论的内容,信息彻底丢失

这不是理论推演,而是我在 AI Agent 项目中真实踩过的坑。本文将完整复盘我们团队在构建知识服务 Agent 时,关于短期记忆/上下文管理的整套策略设计与工程实践。


一、为什么短期记忆管理如此重要?

在 AI Agent 系统中,短期记忆(Short-term Memory) 管理是决定用户体验和系统成本的核心环节。

1.1 三个无法回避的约束

约束说明若不管理
成本约束LLM 按 Token 计费,历史越长成本越高单次问答费用失控
性能约束模型有上下文窗口上限(如 128K),且推理时间随输入长度超线性增长用户等待时间暴增
效果约束研究表明,关键信息在上下文中的位置会影响模型召回准确率(Lost-in-the-Middle 现象)即使没超限,中间的历史信息也可能被遗忘

1.2 短期记忆 vs 长期记忆:分工明确

在进入具体策略前,先厘清一个概念边界:

维度短期记忆(本篇文章主题)长期记忆(后续文章详述)
存储位置当前会话上下文数据库(pgvector)
生命周期单次会话跨会话持久化
内容粒度完整对话轮次提炼后的结构化信息
典型内容“你刚才说支持 PDF 导入”“用户偏好技术文档风格”

一句话总结:短期记忆保证“此刻的连贯”,长期记忆实现“跨会话的传承”。


二、我们的核心策略:四层防御体系

经过多轮迭代和压测,我们最终构建了一套**“滑动窗口 + Token 截断 + 指代补全 + LLM 摘要”**四层联动的上下文管理体系。

整体架构图

text

用户输入
    │
    ▼
┌─────────────────────────────────────────────────┐
│  第一层:指代补全(规则 + LLM 双重保障)         │
│  “它支持导出吗?” → “知识库A支持导出PDF吗?”    │
└─────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────┐
│  第二层:滑动窗口(保留最近 25 条消息)           │
│  超出 25 条的消息进入“待压缩区”                  │
└─────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────┐
│  第三层:LLM 智能摘要(压缩“待压缩区”)          │
│  将 20 轮历史 → 1 条摘要消息                     │
└─────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────┐
│  第四层:Token 硬截断(上限 6000)               │
│  安全兜底,绝不让上下文超限                      │
└─────────────────────────────────────────────────┘
    │
    ▼
  最终上下文 → Router Agent

下面逐层拆解每层的设计思路和代码实现。


三、第一层:指代补全——让 AI 听懂“它”、“这个”、“那个”

3.1 业务痛点

中文对话中充斥着指代词,这是人类的语言习惯。但对 LLM 来说,一个脱离上下文的“它”毫无意义。

典型场景

  • 用户第 1 轮:“帮我查一下 Go 语言的内存管理机制”
  • 用户第 2 轮:“它的垃圾回收算法是什么?”

如果直接把“它的垃圾回收算法”拿去检索或路由,召回率几乎为零。

3.2 双层兜底策略

我们采用 “规则优先 + LLM 兜底” 的双模式设计,兼顾速度与准确率。

策略 A:规则引擎(快速路径)

go

// 规则指代补全:处理高频模式,无需调用 LLM
func (s *ContextService) ruleBasedCoreference(query string, history []Message) string {
    // 1. 如果问题中不包含指代词,直接返回原问题
    if !hasCoreference(query) {
        return query
    }
    
    // 2. 从历史最后一句 Assistant 回答中提取关键实体
    lastAssistant := getLastAssistantMessage(history)
    if lastAssistant == nil {
        return query
    }
    
    // 3. 匹配高频指代模式
    patterns := []struct {
        match   string
        extract func(string) string
    }{
        {"它", extractLastNoun},      // “它”→ 上一句最后一个实体
        {"这个", extractLastNoun},    // “这个”→ 同上
        {"那个", extractLastEntity},  // “那个”→ 上一句的完整实体
        {"上面", extractLastParagraph}, // “上面”→ 上一段核心观点
    }
    
    for _, p := range patterns {
        if strings.Contains(query, p.match) {
            replacement := p.extract(lastAssistant.Content)
            if replacement != "" {
                return strings.Replace(query, p.match, replacement, -1)
            }
        }
    }
    
    return query
}

优点:毫秒级响应,不消耗 Token,覆盖 70% 的指代场景。

策略 B:LLM 改写(智能路径)

当规则引擎无法判断时(如“结合刚才说的,再详细解释一下”),启用 LLM 改写。

go

// LLM 指代补全:高准确率,但需要一次额外调用
func (s *ContextService) llmRewrite(query string, history []Message) (string, error) {
    prompt := `你是指代消解专家。请根据对话历史,将当前问题改写为包含完整主语的独立问句。

对话历史:
{{.History}}

当前问题:{{.Query}}

要求:
1. 只输出 JSON 格式:{"rewritten": "改写后的完整问题"}
2. 不要添加任何额外解释
3. 保持原意的同时补全所有指代`

    // 调用 ChatModel,Temperature=0,强制 JSON 输出
    resp, err := s.model.Chat(ctx, &ChatRequest{
        Messages:   buildPrompt(prompt, history, query),
        Temperature: 0,
        ResponseFormat: &JSONFormat{Schema: rewriteSchema},
    })
    if err != nil {
        // 降级:LLM 失败时返回原问题,不阻塞主流程
        return query, nil
    }
    
    return resp.Rewritten, nil
}

3.3 实际效果

场景原问题规则引擎结果LLM 结果
简单指代“它的文档在哪?”“Go 语言官方文档在哪?”—(规则命中,不调用LLM)
复杂指代“结合刚才那个案例,分析一下”(规则无法匹配)“结合 React 性能优化案例,分析一下懒加载策略”

最终方案:规则优先(覆盖 70%),LLM 兜底(覆盖剩余 30%),两者结合后指代补全准确率从 62% 提升至 94%。


四、第二层:滑动窗口——控制上下文的基本盘

4.1 设计思路

滑动窗口是最经典的上下文管理策略。我们的配置是:保留最近 25 条消息

为什么是 25 条?

  • 经过统计,我们产品中 85% 的用户问答在 25 轮内能自然结束
  • 25 条消息约对应 3~5 个完整的问答周期,足以覆盖绝大多数多轮对话场景
  • 超过 25 条的历史,交给“摘要层”处理,而非直接丢弃

4.2 实现细节

go

const MaxWindowSize = 25

func (s *ContextService) slidingWindow(messages []Message) []Message {
    if len(messages) <= MaxWindowSize {
        return messages
    }
    
    // 保留最近的 25 条消息
    window := messages[len(messages)-MaxWindowSize:]
    
    // 但确保 System Prompt 始终保留在首位
    // 如果第一条是 System 消息,需要特殊处理
    if len(messages) > 0 && messages[0].Role == "system" {
        // 将 System 消息合并到窗口最前面
        return append([]Message{messages[0]}, window...)
    }
    
    return window
}

4.3 一个重要设计决策:是否保留 System Prompt?

我们选择了**“System Prompt 永远不被淘汰”**。因为 System Prompt 定义了 Agent 的角色、知识边界和回复风格,是整个对话的“宪法”,丢失后会导致 Agent 行为失控。


五、第三层:LLM 智能摘要——让“被遗忘”的历史有价值

5.1 核心思想

超出滑动窗口的历史消息,不是简单地丢掉,而是压缩成摘要。这个摘要会作为“背景信息”保留在上下文中。

效果对比

方式25 轮对话后的上下文Token 占用
纯滑动窗口只保留最近 25 条~5000 Token
滑动窗口 + 摘要摘要(20轮) + 最近25条~2500 Token
全量保留45 条完整消息~8000 Token(超限)

5.2 摘要策略

go

type SummaryStrategy struct {
    TriggerThreshold int // 触发摘要的消息条数,设为 25
    SummaryModel     string
    MaxSummaryTokens int // 摘要最大 Token,设为 500
}

func (s *SummaryService) summarizeHistory(messages []Message) (string, error) {
    // 只对超出滑动窗口的部分做摘要
    if len(messages) <= s.TriggerThreshold {
        return "", nil
    }
    
    toSummarize := messages[:len(messages)-s.TriggerThreshold]
    
    prompt := `请将以下对话历史压缩为一段简洁的背景摘要(不超过 500 字)。

历史对话:
{{.History}}

要求:
1. 保留关键决策、技术选型、用户偏好和已完成的事项
2. 按时间顺序组织,逻辑清晰
3. 只输出摘要文本,不要添加额外格式`

    return s.llm.Summarize(ctx, prompt)
}

5.3 摘要更新的触发时机

我们在以下时机触发摘要更新:

  1. 每轮问答结束后:异步检查是否需要重新生成摘要
  2. 检测到消息数超过阈值时:增量更新,而非全量重新生成
  3. 会话闲置 30 分钟后:如果用户重新激活会话,先更新摘要

5.4 增量摘要优化(避免重复计算)

go

// 增量摘要:只对新增的超窗消息做摘要,再与旧摘要合并
func (s *SummaryService) incrementalUpdate(existingSummary string, newMessages []Message) string {
    // 1. 为新消息生成摘要
    newSummary := s.summarizeBlock(newMessages)
    
    // 2. 调用 LLM 合并旧摘要和新摘要
    merged := s.mergeSummaries(existingSummary, newSummary)
    
    return merged
}

这比每次全量重新生成节省了约 60% 的 Token 消耗。


六、第四层:Token 硬截断——最后的安全防线

6.1 为什么需要硬截断?

即使有了滑动窗口和摘要,依然存在两个风险:

  1. 摘要本身可能较长:500 字摘要 ≈ 700 Token,加上 25 条消息,可能仍接近上限
  2. 用户上传的文档片段较大:KnowledgeSearchTool 可能返回大块文本

因此,我们需要一个硬截断机制作为最后的保险。

6.2 我们的配置

go

const (
    MaxContextTokens = 6000   // 硬性上限
    SafetyBuffer     = 500    // 安全缓冲,预留空间给工具输出
)

// 实际可用 Token = 6000 - 500 = 5500
// 工具输出最多占用 500 Token

6.3 截断策略的优先级

当总 Token 超过 6000 时,按以下优先级丢弃:

text

System Prompt (优先级 0,永不丢弃)
    ↓
最近 5 条消息(优先级 1,尽量保留)
    ↓
摘要内容(优先级 2,可以精简)
    ↓
更早的消息(优先级 3,从最早开始丢弃)

为什么最近 5 条消息优先级高于摘要? 因为摘要已经是压缩后的信息,而最近的消息包含最直接的对话状态,对模型理解当前问题至关重要。

6.4 实现代码

go

func (s *ContextService) truncateByToken(ctx context.Context, messages []Message, toolOutputs []string) []Message {
    maxTokens := MaxContextTokens - SafetyBuffer
    
    // 1. 分别计算各部分的 Token
    systemTokens := s.countTokens(messages[0])  // System 消息
    recentMessages := messages[:5]
    restMessages := messages[5:]
    summaryToken := s.countTokens(s.currentSummary)
    toolTokens := sumToken(toolOutputs)
    
    // 2. 计算基础占用
    baseTokens := systemTokens + s.countTokens(recentMessages) + toolTokens + 500 // 预留思考空间
    
    // 3. 如果基础占用已经超限,降低最近消息数量
    if baseTokens > maxTokens {
        // 极端情况:连 System + 1条消息都放不下,强制裁剪
        return s.forceTruncate(messages)
    }
    
    // 4. 剩余 Token 分配给摘要和其他消息
    remaining := maxTokens - baseTokens
    
    // 5. 按优先级填充:摘要 > 早期消息
    result := s.fillContext(recentMessages, restMessages, s.currentSummary, remaining)
    
    return result
}

七、完整数据流:一次问答的上下文之旅

让我们用一个完整的时序图,展示这四层策略是如何协作的:

text

用户:“它支持导出PDF吗?”
    │
    ▼
【第一层:指代补全】
    │  规则引擎检测到“它”→ 提取上一轮实体“知识库A”
    │  输出:“知识库A支持导出PDF吗?”
    ▼
【第二层:滑动窗口】
    │  当前会话共 28 条消息 > 25
    │  取最近 25 条 + 系统提示词
    │  前 3 条进入“待压缩区”
    ▼
【第三层:LLM 摘要】
    │  异步触发:将前 3 条消息 + 旧摘要合并
    │  新摘要:“用户正在搭建个人知识库,已导入 15 份 PDF 文档...”
    ▼
【第四层:Token 截断】
    │  统计:系统(200) + 摘要(400) + 25条消息(4800) + 工具预留(500) = 5900
    │  5900 < 6000 ✅ 安全通过
    ▼
    最终上下文 → Router Agent → ReAct/Plan-Execute → 生成回答

八、效果数据与踩坑经验

8.1 上线后关键指标

指标优化前(仅有滑动窗口)优化后(四层联动)
上下文超限率23%(经常报错)0.3%(几乎绝迹)
多轮对话准确率71%89%
单问答平均 Token72004300(下降 40%)
用户重新提问率18%6%

8.2 三个血泪教训

教训 1:摘要不能替代原始消息

我们曾经尝试完全用摘要替代被淘汰的历史,结果发现用户追问细节时,摘要无法还原精确信息。最终方案:摘要只作为背景补充,不替代任何保留在窗口中的原始消息。

教训 2:摘要更新必须异步

初期我们在主问答链路上同步生成摘要,导致用户等待时间从 3 秒飙升至 8 秒。解决方案:摘要更新改为异步 goroutine 执行,每轮问答结束后在后台更新,下次问答时使用新摘要。

教训 3:重要信息需要“钉住”

有些信息虽然是早期提出的,但用户后续仍会频繁引用(如“我们用的是 PostgreSQL”)。我们增加了 “钉选(Pin)”机制:用户或系统可以标记关键信息,使其永不进入摘要层,始终保留在上下文中。


九、未来演进方向

9.1 动态窗口大小

根据对话的“信息密度”动态调整窗口大小:

  • 技术讨论(信息密度高)→ 窗口缩小至 15 条
  • 日常问答(信息密度低)→ 窗口扩大至 30 条

9.2 层级摘要

将摘要分为多个层级:

  • L1(极简摘要):500 Token,每 50 轮生成一次
  • L2(详细摘要):1000 Token,每 10 轮生成一次
  • L3(原始消息):保留最近 25 条

根据问题复杂度自动选择注入哪个层级的摘要。

9.3 与长期记忆的深度融合

当摘要中的信息被反复引用时,自动提升为长期记忆(存入 pgvector),实现“短期记忆 → 长期记忆”的自动沉淀。


十、总结:上下文管理的四个核心原则

原则说明对应策略
容量有界承认上下文窗口有限,主动管理Token 硬截断
近重远轻近期信息权重高,远期信息可压缩滑动窗口 + 摘要
智能压缩压缩而非丢弃,保留信息价值LLM 摘要
指代消解让每个问题独立完整,降低上下文依赖指代补全

最后一句话送给所有在做 Agent 的同行:

上下文管理不是“该丢多少”的数学题,而是“该记住什么”的价值判断。好的上下文策略,是在成本和准确率之间找到最优平衡点。而所谓平衡,就是让 AI 在有限的 6000 Token 里,永远带着最关键的上下文前进。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐