你有没有遇到过这样的情况:和 AI 聊了十几轮,突然发现它"忘记"了之前说过的内容?或者 API 调用因为 Token 超限直接报错?

这是因为大模型有上下文长度限制,对话越长,历史消息越多,就越容易超限。结合我的理解,这篇文章分享一下如何实现上下文压缩,让你的 AI 应用既能"记住"关键信息,又能节省成本。


为什么需要上下文压缩?

大模型的上下文窗口是有限的。GPT-3.5 只有 4K tokens,GPT-4 最多 32K,即 Claude 3 有 200K,看起来很长,但实际对话中,10 轮对话就可能消耗几千 Token。

压缩的好处

  • 省钱:Token 越少,API 费用越低
  • 避免报错:不会因为超限导致调用失败
  • 提升质量:保留关键信息,让对话更连贯

压缩策略怎么选?

面对上下文压缩,我们有三种主流策略:

摘要压缩:让 AI 把历史对话总结成一段摘要。适合长对话场景,比如客服机器人、知识问答。优点是信息损失小,缺点是需要额外 API 调用。

滑动窗口:只保留最近 N 条消息。适合实时性要求高的场景,比如聊天机器人。优点是实现简单、零成本,缺点是可能丢失重要信息。

关键信息提取:从对话中提取实体、意图、偏好等结构化信息。适合任务型对话,比如订票助手、购物咨询。优点是信息结构化,缺点是需要设计合理的信息模型。


摘要压缩:让 AI 自己总结

摘要压缩的思路很直观:把历史对话丢给 AI,让它生成一段简洁的摘要。

实现要点

第一步,收集历史对话。把用户和 AI 的消息按时间顺序整理好。

第二步,构建提示词。告诉 AI:"请将以下对话压缩成一段简洁的摘要,保留关键信息。"

第三步,调用 AI 生成摘要。拿到摘要后,用它替换原来的历史消息。

核心代码

func summarizeHistory(messages []Message) (string, error) {
    prompt := "请将以下对话压缩成简洁摘要,保留关键信息:\n"
    for _, msg := range messages {
        prompt += fmt.Sprintf("%s: %s\n", msg.Role, msg.Content)
    }
    // 调用 AI API 生成摘要...
    return callAI(prompt)
}

什么时候触发压缩?

可以设置一个阈值,比如对话超过 15 轮时触发。也可以根据 Token 数量判断,超过总上下文的 70% 就压缩。

注意事项

摘要会丢失细节信息。如果用户问"我刚才说的那个餐厅叫什么",AI 可能答不上来。这时候可以设计一个追问机制:"抱歉,能再告诉我一次吗?"


滑动窗口:简单高效的方案

滑动窗口是最简单的策略:只保留最近 N 条消息,旧的直接丢弃。

为什么好用?

实现极其简单,几行代码就能搞定。不需要额外的 API 调用,零成本。对于大多数聊天场景,最近几轮对话已经足够。

核心代码

type Conversation struct {
    Messages []Message
    MaxSize  int
}

func (c *Conversation) AddMessage(msg Message) {
    c.Messages = append(c.Messages, msg)
    if len(c.Messages) > c.MaxSize {
        c.Messages = c.Messages[len(c.Messages)-c.MaxSize:]
    }
}

窗口大小怎么定?

一般建议 5-10 条。太小会丢失上下文,太大又起不到压缩效果。可以根据实际场景调整。

一个改进技巧

系统消息(System Prompt)要始终保留。它定义了 AI 的角色和行为准则,丢了会影响回复质量。

缺点也很明显

早期的重要信息会被丢弃。比如用户一开始说了"我叫张三",过了 10 轮后 AI 就不知道了。


关键信息提取:保留核心数据

关键信息提取是从对话中抽取出实体、意图、状态等结构化信息。

适合什么场景?

任务型对话最合适。比如订票助手需要知道出发地、目的地、日期;购物咨询需要知道预算、品牌偏好。

提取什么信息?

  • 用户信息:姓名、联系方式、身份
  • 意图识别:用户想做什么
  • 实体抽取:地点、时间、物品、金额
  • 偏好记录:喜欢什么、不喜欢什么

核心代码

type UserInfo struct {
    Name     string   `json:"name"`
    Intent   string   `json:"intent"`
    Entities []string `json:"entities"`
}

func extractKeyInfo(dialog string) (*UserInfo, error) {
    prompt := "从对话中提取关键信息,返回 JSON 格式:" + dialog
    // 调用 AI 提取并解析 JSON...
    return &UserInfo{}, nil
}

举个例子

用户说"我想订一张明天从北京到上海的机票,预算 1000 以内"。

提取结果:

意图:订机票
出发地:北京
目的地:上海
日期:明天
预算:1000 元以内

这些信息即使对话很长也不会丢失。


组合策略实践

单一策略各有优劣,实际项目中建议组合使用。

推荐的组合方案

对话初期(1-10 轮):不做任何压缩,保留完整上下文。

对话中期(10-20 轮):启用滑动窗口,同时开始提取关键信息。

对话后期(20 轮以上):触发摘要压缩,把早期对话压缩成摘要,关键信息单独保留。

func (m *Manager) Compress(messages []Message) []Message {
    switch {
    case len(messages) > 20:
        return compressBySummary(messages)  // 摘要压缩
    case len(messages) > 10:
        return messages[len(messages)-10:]  // 滑动窗口
    default:
        return messages  // 不压缩
    }
}

一个完整的上下文结构

系统消息(始终保留)
↓
关键信息摘要(用户姓名、意图、偏好等)
↓
历史对话摘要(早期对话的总结)
↓
最近 N 条对话(滑动窗口)
↓
当前用户输入

这样既保留了关键信息,又控制了上下文长度。


踩坑指南

Token 估算要提前做

中文大约 1.5-2 个字一个 Token,英文约 4 个字符一个 Token。压缩前先估算当前上下文大小,别等到报错了才处理。

func estimateTokens(text string) int {
    return len(text) / 2  // 中文粗略估算
}

系统消息不能丢

无论用哪种策略,系统消息(System Prompt)都必须保留。它定义了 AI 的角色和行为准则。

压缩时机要合理

可以在每次对话后检查,也可以达到阈值时触发。不建议每轮都压缩,会增加延迟和成本。

给用户留条后路

压缩可能导致信息丢失。可以设计一个机制,当 AI 不确定时主动追问:"抱歉,能再告诉我一次您的联系方式吗?"


写在最后

今天我们介绍了三种 AI 对话上下文压缩策略:

摘要压缩:让 AI 总结历史,保留语义完整性。适合长对话场景。

滑动窗口:只保留最近 N 条,简单高效。适合实时性要求高的场景。

关键信息提取:抽取实体和意图,结构化存储。适合任务型对话。

实际项目中,建议根据对话长度动态选择策略,既能控制成本,又能保证对话质量。