给 AI 智能体装上"短期记忆":我是怎么做对话历史压缩的

做对话系统绕不开一个问题:聊久了 AI 就"忘事"。本文结合我实际项目中的短期记忆实现,从设计思路到代码细节,聊聊怎么让智能体在有限的上下文窗口里"记住该记的,忘掉不该带的"。


一、问题:聊着聊着就"爆"了

用过 ChatGPT 或者接入过大模型 API 的朋友应该有体感——对话轮数一多,要么报错说上下文超长,要么回答质量肉眼可见地变差。

前者好理解,模型的上下文窗口就那么大(128K、200K),消息一直往里塞,总有装不下的一天。

后者更隐蔽一些。就算窗口没满,当历史消息太长时,模型的"注意力"会被大量无关信息稀释,对当前问题的回答反而不如短对话时精准。这在学术上叫 “Lost in the Middle”——模型对中间位置的信息记忆力最差。

还有一个很现实的问题:Token 是按量计费的。 每次请求都把完整历史塞进去,成本随对话长度线性增长,在商业场景下根本扛不住。

所以我需要一套机制来解决这个问题。这套机制,在我的项目里叫短期记忆


二、什么是短期记忆?

先说概念。

你可以把短期记忆想象成你的工作记忆——你在开会的时候,脑子里同时装着的信息是有限的。你不会把过去一个月所有会议的纪要都塞进脑子里,而是只记着跟当前议题相关的上下文,再加上一些之前讨论的关键结论

短期记忆做的就是同一件事:

在每次请求 LLM 之前,动态地整理对话历史——保留最近几轮的完整对话,把更早的内容压缩成一段摘要,控制总 Token 在合理范围内。

有几个关键特性:

  • 请求级无状态:每次用户发消息时,都重新估算 Token 数,按需触发压缩。压缩结果只用于本次请求,不"污染"原始数据。
  • 阈值驱动:默认在上下文窗口的 50% 处设一道"警戒线",超过才压缩,没到就原样透传。
  • 降级兜底:如果压缩用的 LLM 挂了,对话也不能挂——降级为纯文本截断归档,保证流程不中断。

三、压缩流程:一次请求里到底发生了什么?

让我用一个具体例子来走一遍。

假设用户和 AI 已经聊了 12 轮,现在发了第 13 轮问题。此时对话历史大概长这样:

[system] 你是一个知识库助手,可以检索文档回答问题...
[user]   第 1 轮问题:帮我查一下 XX 规范
[asst]   第 1 轮回答:根据 XX 规范...
[user]   第 2 轮问题:那 YY 标准呢?
[asst]   第 2 轮回答:YY 标准规定...
...
[user]   第 12 轮问题:能总结一下吗?
[asst]   第 12 轮回答:好的,总结如下...
[user]   第 13 轮问题(当前轮):第三条能展开说说吗?

当 Token 数超过窗口的 50% 时,压缩开始。整个过程分 5 步:

Step 1:拆出 system 消息

开头的 system prompt 永远保留,不参与压缩。

Step 2:切出"当前轮"

找到最后一条 user 消息——它以及它后面的所有消息(可能包含工具调用链)都属于"当前轮",原样保留,不压缩

这一步很重要。当前轮是 LLM 马上要处理的内容,压缩它会导致语义丢失。

Step 3:从尾部回扫,在预算内保留最近的历史

压缩目标 = 触发阈值 × 60%(比如窗口 160K,阈值 80K,目标就是 ~48K)。

扣除 system、当前轮、摘要预留的 Token 后,剩下的预算用来从历史尾部往前"装"消息——能装多少装多少,直到预算用完。

Step 4:预算外的历史 → LLM 摘要

装不下的早期历史,交给 LLM 生成一段摘要。摘要要求保留关键事实、工具执行结果、用户意图和错误处理过程,目标是原文长度的 30% 以下。

Step 5:拼装最终上下文

[system prompt]                                          ← 永远保留
[Memory Summary - 16 earlier messages consolidated]      ← LLM 生成的摘要
[第 10 轮 user]                                          ← 预算内保留的历史
[第 10 轮 assistant]
[第 11 轮 user]
[第 11 轮 assistant]
[第 12 轮 user]
[第 12 轮 assistant]
[第 13 轮 user:第三条能展开说说吗?]                      ← 当前轮,原样保留

最终效果:20 多条消息被压成了 8 条,但 LLM 通过摘要"知道"之前聊过什么,通过保留的最近几轮"看清"当前的上下文。


四、双轨制:两种模式,两套实现

我的项目有两种对话模式,它们的内部架构差异很大:

  • Agent 模式:基于 eino ADK 框架,支持工具调用和多步推理,有一套中间件机制(Handlers)
  • quick-answer 模式:走 RAG Pipeline 链路,本质是一个 compose.Graph没法挂载 ADK 中间件

这意味着短期记忆不能用一套代码覆盖两种场景。所以我做了"双轨制":

Agent 模式 quick-answer 模式
实现 SummaryMiddleware(封装官方 summarization 中间件) Consolidator(自研压缩器)
挂载方式 挂进 ChatModelAgentConfig.Handlers,每次模型调用前自动触发 chat_service 加载历史后手动调用
触发时机 每次 LLM 推理前(BeforeModelRewriteState 构建 pipeline 上下文之前

虽然实现路径不同,但核心压缩逻辑是一样的——拆 system、切当前轮、预算回扫、LLM 摘要、降级兜底。

下面以 Consolidator 为主线讲细节,因为它是从零手写的,更能暴露设计决策。


五、tool_call 配对保护:一个特别容易踩的坑

这是我觉得最值得分享的一个细节。

在 Agent 模式中,一次工具调用在消息列表里长这样:

assistant: "我来查一下知识库"    ← 带 tool_call 字段的 assistant 消息
tool: "检索到 3 条相关结果..."   ← tool result 消息

这两条消息是一个配对。assistant 消息里有一个 tool_call(包含调用 ID 和函数名),tool 消息里有一个对应的 tool_call_id。LLM 在后续推理时,需要看到这个配对才能理解"工具调了什么、结果是什么"。

问题来了:如果你在压缩时只保留了 assistant 而丢了 tool,或者反过来,会怎样?

LLM 会看到一条"悬空"的 tool_call 引用——它知道自己调了工具,但看不到结果。这种情况下,Agent 可能会:

  • 重复调用同一个工具(浪费 Token 和时间)
  • 编造一个工具结果(幻觉)
  • 直接报错

我的解决方案:从历史尾部回扫时,遇到 tool 消息就把连续的 tool 组和触发它们的前驱 assistant 作为一个整体 来计算。要么整组保留,要么整组压缩,绝不拆开。

if msg.Role == schema.Tool {
    // 1. 先算当前 tool 消息的 Token
    groupTokens := msgTokens
    groupSize := 1
    
    // 2. 往前找:还有没有连续的 tool 消息(一次调用可能触发多个工具)
    j := i - 1
    for j >= 0 && history[j].Role == schema.Tool {
        groupTokens += c.estimator.EstimateMessage(history[j])
        groupSize++
        j--
    }
    
    // 3. 再往前找一条:触发这些 tool 的 assistant(带 tool_call 的那条)
    if j >= 0 && history[j].Role == schema.Assistant {
        groupTokens += c.estimator.EstimateMessage(history[j])
        groupSize++
    }
    
    // 4. 整组判断:预算够就全留,不够就全压
    if tokens + groupTokens > budget {
        break  // 整组不放进去
    }
    tokens += groupTokens
    keepCount += groupSize
    i -= groupSize
}

这个逻辑的核心思想是:[assistant(tool_call)] + [tool_1] + [tool_2] + ... 视为一个不可分割的"原子单元"。

这个细节看起来不大,但在实际使用中,因为 tool 配对被拆掉而导致的 Agent 行为异常,是非常难排查的。 因为从消息列表的结构上看,它"看起来"是完整的,只有 Agent 在推理时才会暴露出问题。


六、降级策略:宁可牺牲质量,也不中断对话

做工程最怕什么?——线上挂了。

短期记忆的压缩过程本身需要调用 LLM 来生成摘要。但 LLM 是不可靠的——可能超时、可能返回空内容、可能网络抖动。如果摘要失败就直接报错,那整轮对话就中断了。

用户不会因为你的"摘要服务挂了"就原谅你的"对话中断"。

我的原则是:宁可降级,也不阻断。

6.1 三层降级防线

第 1 层:重试(3 次,每次 60s 超时)
    ↓ 全部失败
第 2 层:降级模型(再试 3 次)
    ↓ 全部失败
第 3 层:原文归档(纯文本截断,不调 LLM)

原文归档的实现很简单——把旧消息按角色格式化成纯文本,单条消息截断到 500 字符:

// 降级后的摘要长这样:
"[Memory Summary - 16 earlier messages archived (LLM summarization unavailable)]

Raw conversation archive:

- User: 帮我查一下 XX 规范
- Assistant: 根据 XX 规范...
- User: 那 YY 标准呢?
- Assistant [tools: kb_search]: 我来检索一下...
- Tool[kb_search]: 检索到 3 条结果...
..."

质量当然不如 LLM 生成的摘要——没有提炼、没有归纳,只是原始文本的截断拼接。但至少:

  1. Token 数被控制住了(单条截断 500 字符)
  2. 对话能继续
  3. 最近几轮完整对话仍然保留

6.2 Agent 模式的额外包装

在 Agent 模式中,我用了 eino ADK 官方的 summarization 中间件。但有个问题:官方中间件在摘要彻底失败时,会直接返回错误,导致整轮 Agent 中断。

这不可接受。所以我在外面又包了一层 degradableMiddleware

type degradableMiddleware struct {
    *adk.BaseChatModelAgentMiddleware  // 嵌入 no-op 基类
    inner         adk.ChatModelAgentMiddleware  // 官方中间件
    preserveTurns int
    timeout       time.Duration
}

func (m *degradableMiddleware) BeforeModelRewriteState(...) {
    // 用独立超时上下文调用官方中间件
    _, newState, err := m.inner.BeforeModelRewriteState(summaryCtx, state, mc)
    if err == nil {
        return ctx, newState, nil  // 成功,正常返回
    }
    
    // 失败 → 降级为原文归档,不报错
    logger.Warnf("[MemorySummary] 摘要生成失败,降级为原文归档: %v", err)
    degraded := *state
    degraded.Messages = degradeMessages(state.Messages, m.preserveTurns)
    return ctx, &degraded, nil  // 返回降级后的状态,对话继续
}

踩坑记录:一开始我试图在官方中间件的 Finalize 回调里做降级,后来发现源码中 Finalize 只在摘要成功生成后才会被调用——失败时根本走不到那里。正确做法是在外层用 BaseChatModelAgentMiddleware 包一层,在 BeforeModelRewriteState 里拦截错误。


七、增量压缩:避免每次都从头算

上面讲的都是单次请求内的压缩逻辑。但还有一个性能问题:

如果用户聊了 50 轮,每次请求都要加载全部 50 轮历史 → 估算 Token → 触发压缩 → 调 LLM 生成摘要,这个开销太大了。

尤其是 LLM 摘要那一步——每次都要把大量历史消息喂给摘要模型,Token 消耗和延迟都很可观。

7.1 核心思路:记录"压缩到了哪里"

解决方案是引入一张 session_summaries 表,持久化每个会话的压缩摘要和压缩边界

type SessionSummary struct {
    SessionID       uint   // 所属会话
    Content         string // 摘要内容(LLM 生成或降级原文归档)
    SummaryType     string // "llm" 或 "raw"
    LastMessageID   uint   // 压缩边界:摘要已覆盖到的最后一条消息 ID
    CompressedCount int    // 累计已压缩的消息条数
}

LastMessageID 是关键——它记录了"摘要压缩到了哪条消息"。下次请求时,只需要加载 ID 大于这个值的消息作为增量。

7.2 工作流程

第 1 次请求:
  加载 12 轮历史(24 条消息)→ 全量压缩 → 写回摘要
  摘要内容: "用户询问了 XX 规范、YY 标准..."
  压缩边界: LastMessageID = 20(前 10 轮已并入摘要)

第 2 次请求:
  读摘要 → 只加载 ID > 20 的消息(第 11-12 轮 + 当前轮 = 5 条)
  拼装: [旧摘要] + [5 条增量]
  估算 Token → 超阈值 → 增量压缩
  LLM 输入: "旧摘要" + "新增的 5 条消息" → 输出: "合并后的新摘要"
  写回: 新摘要 + 新边界 LastMessageID = 25

第 3 次请求:
  读摘要 → 只加载 ID > 25 的消息...
  ...

每次压缩的 LLM 输入只有"旧摘要 + 增量",不再包含全部历史。 压缩成本只与增量大小有关,与历史总长度无关。聊 50 轮和聊 5 轮,单次压缩的开销是一样的。

7.3 增量合并的 Prompt 设计

增量压缩时,给 LLM 的指令有一个关键要求:

The new summary MUST be the complete, self-contained summary that includes BOTH the existing summary content and the new information.

新摘要必须是自包含的——读完它就不需要旧摘要了。否则就会出现"摘要的摘要的摘要"这种链条,任何一环丢失信息都会导致后续全部出错。

7.4 降级时也要保留旧摘要

增量模式下有个细节:如果 LLM 合并失败降级了,旧的摘要不能丢

if err != nil {
    if oldSummary != "" {
        // 旧摘要保留 + 增量以原文归档追加
        newSummary = oldSummary + "\n\n[Raw archive of newly added messages]\n\n" + rawArchive(toConsolidate)
    } else {
        // 首次压缩就失败,全部降级为原文归档
        newSummary = c.rawArchiveSummary(toConsolidate)
    }
}

这样即使降级,之前几轮压缩积累的摘要信息也不会丢失。


八、Agent 模式 vs quick-answer 模式:实现差异

虽然核心逻辑一致,但两种模式的实现路径确实有差异,展开说说。

8.1 Agent 模式:封装官方中间件

Agent 模式使用的是 eino ADK 的官方 summarization 中间件。我没有直接用它,而是在外面做了一层封装工厂 NewSummaryMiddleware,原因有三:

  1. 简化配置:对外只暴露业务关心的参数(上下文窗口、保留轮数、超时等),内部消化官方中间件的各种细节
  2. 自定义 Finalize:官方默认行为是把全部历史压成摘要,我额外保留了最近 N 轮完整对话作为"双保险"
  3. 降级包装:上面提到的 degradableMiddleware
// 对外暴露的配置——简洁明了
type SummaryOptions struct {
    CreateModel      func(ctx context.Context) (model.BaseModel[*schema.Message], error)
    MaxContextTokens int           // 默认 160000
    PreserveTurns    int           // 默认 5
    Timeout          time.Duration // 默认 60s
    Retries          int           // 默认 3
    FailoverRetries  int           // 默认 3
    EmitEvents       bool          // 是否推送压缩事件到前端
}

自定义 Finalize 的逻辑:先调用官方的 DefaultFinalize(它会做用户消息回填和 preamble 拼装),然后从尾部保留最近 N 轮完整对话追加到结果中。

8.2 quick-answer 模式:手动调用 Consolidator

quick-answer 模式走的是 RAG Pipeline,这是一个 compose.Graph 结构,没法挂载 ADK 中间件。所以在 chat_service 中,加载历史消息之后、构建 pipeline 上下文之前,手动调用压缩:

// chat_service.go 中的调用逻辑
consolidator := memory.NewConsolidator(createModel, tokenEstimator, maxTokens, 0)

currentTokens := tokenEstimator.EstimateString(summaryContent) + 
                 tokenEstimator.EstimateMessages(incremental)

if consolidator.ShouldConsolidate(currentTokens) {
    newSummary, count, isRaw := consolidator.ConsolidateIncremental(ctx, summaryContent, incremental)
    // 写回摘要 + 用新摘要拼装历史
}

九、设计取舍:几个常见问题的回答

9.1 为什么阈值是 50%?

经验值。太低(30%)→ 频繁压缩,每次都调 LLM,延迟和成本都上去了。太高(80%)→ 压缩后剩余空间不够,可能压完还是超限。50% 在实际测试中是个比较舒服的平衡点。

9.2 为什么保留最近 5 轮?

也是实测出来的。5 轮(约 10 条消息)足以覆盖大多数"当前话题"的上下文。保留太多会挤占摘要和当前轮的 Token 预算,保留太少会让 LLM 丢失近期对话的细节。

9.3 摘要模型能复用主对话模型吗?

技术上可以,但不建议。摘要生成用的是 temperature=0.3(低温度 = 更保守 = 更忠实于原文),而主对话模型可能用的是 temperature=0.7(更有创造性)。如果复用,需要在调用时切换参数,容易出错。独立配置摘要模型工厂更稳妥。

9.4 摘要为什么不存进 messages 表?

摘要是上下文管理的中间产物,不是真实的对话内容。如果混进 messages 表,加载历史时就会引入"非真实对话"的消息,增加逻辑复杂度。用独立的 session_summaries 表存储,职责边界更清晰。

9.5 压缩会不会丢信息?

会。摘要本质上是有损压缩——LLM 会保留关键信息,但细节会丢失。这就是为什么我们保留最近 5 轮完整对话作为"双保险":近期细节不丢,远期信息靠摘要。

另外,所有原始消息都完整保存在数据库里(messages 表),摘要只是运行时的"工作记忆"。如果需要回溯原始对话,随时可以查。


十、整体数据流:一张图串起来

用户发送消息
    │
    ▼
① 读 session_summaries 表 → 获取旧摘要 + 压缩边界
    │
    ├─ 有摘要 → 只加载边界之后的增量消息
    └─ 无摘要 → 加载最近 N 轮历史
    │
    ▼
② 拼装:[旧摘要] + [增量消息] + [当前轮]
    │
    ▼
③ 估算 Token
    │
    ├─ 未超阈值 → 直接使用
    └─ 超过阈值 → 触发增量压缩
         │
         ├─ LLM 合并旧摘要 + 增量 → 新摘要
         └─ 失败 → 降级(旧摘要 + 原文归档)
    │
    ▼
④ 写回新摘要到 session_summaries 表
    │
    ▼
⑤ 拼装最终上下文:[摘要] + [保留的增量] + [当前轮]
    │
    ▼
⑥ 发送给 LLM → 生成回答

十一、模块文件组织

internal/memory/
├── types.go                    # 常量定义 + 配置结构
├── consolidator.go             # Consolidator 核心实现(quick-answer 模式)
├── consolidator_test.go        # Consolidator 单元测试
├── summary_middleware.go       # Agent 模式中间件(封装官方 summarization)
├── summary_middleware_test.go  # 中间件单元测试
├── incremental_context.go     # 增量压缩入口(BuildAgentContext)
├── incremental_context_test.go # 增量压缩单元测试
├── degrade.go                  # 降级包装层
└── raw_archive.go              # 原文归档 + 工具函数

十二、总结

回顾一下短期记忆的核心设计:

  1. 请求级无状态压缩:每次请求重新估算 Token,按需压缩,压缩结果不污染原始数据
  2. 双轨制实现:Agent 模式用中间件封装,quick-answer 模式手动调用 Consolidator,核心逻辑一致
  3. 增量压缩:通过持久化压缩边界,每次只处理增量,成本与历史长度无关
  4. tool_call 配对保护:工具调用和结果作为原子单元,要么整组保留,要么整组压缩
  5. 三层降级兜底:重试 → 降级模型 → 原文归档,保证对话不中断

最后说一句我的感受:好的短期记忆不是"记住一切",而是"在该忘的时候优雅地忘,在该记的时候绝不丢"。 压缩不是目的,让模型在有限的上下文窗口里"看得更清楚"才是。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐