AI Agent 上下文管理实战:从滑动窗口到智能摘要的演进之路
前言:一个让人崩溃的真实场景
想象一下这个场景:用户在一个知识库问答会话中,连续问了 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 摘要更新的触发时机
我们在以下时机触发摘要更新:
- 每轮问答结束后:异步检查是否需要重新生成摘要
- 检测到消息数超过阈值时:增量更新,而非全量重新生成
- 会话闲置 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 为什么需要硬截断?
即使有了滑动窗口和摘要,依然存在两个风险:
- 摘要本身可能较长:500 字摘要 ≈ 700 Token,加上 25 条消息,可能仍接近上限
- 用户上传的文档片段较大: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% |
| 单问答平均 Token | 7200 | 4300(下降 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 里,永远带着最关键的上下文前进。
更多推荐
所有评论(0)