黑白梦黑白梦

toggle navtoggle nav
  • 文章
  • 专栏
  • 文章
  • 专栏

Agent 会话与记忆管理:上下文剪裁与双存储实践

发布于 2026-05-31

对于大模型对话 Agent 的会话与记忆管理,黑白梦博客助手采用了一套“双存储解耦架构”:以 Redis 作为轻量级的短期运行快照,结合 LangGraph 的 Checkpoint 机制实现动态上下文剪裁;以 MySQL 作为长期全局档案,实现精准溯源与冷启动恢复。

为什么需要 Checkpoint 与上下文剪裁?

在构建对话 Agent 时,LLM 受限于最大上下文窗口(以 token 计)。随着对话轮次的增加,消息列表会持续增长,这不仅会导致超出上下文窗口引发请求报错,还会因为旧的或无关内容分散模型注意力,从而降低回答质量,同时也会拖慢响应速度并增加 API 费用。因此,主动管理对话历史(上下文剪裁,Context Trimming) 是构建生产级 Agent 的必备能力。

在 LangGraph 中,Checkpoint(检查点) 是管理状态和记忆的核心机制。它相当于“引擎的运行快照”,用于持久化保存多轮对话的中间状态。通过结合上下文剪裁策略(如利用中间件动态裁剪或删除历史消息),我们可以在写入 Checkpoint 时精简保存的信息,确保大模型始终能够高效运作。

基于上述理念,本项目采用 双存储解耦架构 结合 流式安全机制 来管理长文本问答场景下的记忆和上下文。

核心架构设计

在生产环境中,我们将 LangGraph Checkpoint(短期运行记忆)与 MySQL(长期全局档案)进行了严格分离:

┌─────────────────────────────────────────────────┐
│                   每轮对话                        │
│                                                   │
│  用户消息 ──→ Agent(流式运行,交替产出 Token 和工具)│
│                  │                                 │
│                  ├──→ Checkpoint(运行快照 - Redis) │
│                  │     · 使用 @before_model 动态裁剪 │
│                  │     · ToolMessage 超长自动截断     │
│                  │     · 有生命周期(TTL 7天)         │
│                  │                                 │
│                  └──→ MySQL 数据库(全量历史)       │
│                        · 仅在流式正常结束后完整追加    │
│                        · 提供给 /history 接口查询     │
│                        · 永久保留,用于冷启动恢复      │
└─────────────────────────────────────────────────┘

Redis Checkpoint 的角色是“引擎的运行快照”,而不是“唯一的历史存储库”。

  • 存储精简工作状态:只保留最近几轮对话,供 LLM 下一轮调用使用。
  • 防止上下文爆炸:通过 @before_model 返回 RemoveMessage(id=REMOVE_ALL_MESSAGES) 永久丢弃旧消息和异常的 AI 空消息。

MySQL 的角色:

  • 充当最终的真理来源(Source of Truth)。
  • 保留完整的原始消息对话,包含结构化的 Citation(引用链接)数据。

关键实现机制

Checkpoint 基础配置与会话隔离

在实际代码中,Checkpoint 的接入非常轻量。在初始化构建图(Graph)时,只需将持久化组件(生产环境中的 RedisSaver)作为 checkpointer 传入即可。

而在每次对话流式运行时,最关键的是会话隔离。我们通过结合用户 ID 和会话 ID 生成唯一的 thread_id(即 user_id:session_id),并通过 config 参数传递给 Agent:

# 运行时注入配置,Checkpoint 将根据 thread_id 自动读取/保存对应的历史状态
config = {"configurable": {"thread_id": f"{user_id}:{session_id}"}}
async for event in agent.astream({"messages": [HumanMessage(content=query)]}, config):
    # ... 处理流式输出

这样,LangGraph 底层会自动在持久化层维护这个 thread_id 对应的消息快照,为多轮对话提供连续的上下文。

动态 Compaction 与截断 (@before_model)

在 src/agent/core.py 中,我们利用了 @before_model 中间件,直接修改传给模型的输入以及底层的 Checkpoint 状态:

  1. Token 精准估算:采用 _count_mixed_tokens 处理中英混合场景,并将 tool_calls 的参数计入 Token 消耗中。
  2. 长工具输出截断:为了防止例如 get_article_content 返回的数千字文章填满整个 Checkpoint 导致后续彻底无法对话,中间件会主动将内容超过 2000 字符的 ToolMessage 截断。
  3. 彻底清理废弃消息(Compaction 压缩):由于 LangGraph 默认是追加(Append-only)的历史消息记录,如果直接裁剪,旧的消息快照仍会滞留在 Checkpoint 历史中。我们在剪裁中间件中通过返回 RemoveMessage(id=REMOVE_ALL_MESSAGES),通知 LangGraph 彻底清空当前会话的全部历史消息,再把精心裁剪/截断后的 *trimmed 消息列表重新写入。这种“连根拔起、重新打包”的 Compaction 机制,确保了 Redis 中存储的 Checkpoint 大小极其精简,完全避免了状态数据膨胀。

冷启动恢复 (Cold Start Recovery)

当 Redis 中的 Checkpoint 因 TTL 到期被清理,或者 Redis 重启导致丢失时,用户依然可以继续之前的话题:

我们在 stream_chat 初始化时会使用 aget_state 嗅探状态,若为空,则触发自愈机制:

async def _cold_start_recovery(config: dict, user_id: str, session_id: str):
    """从 MySQL 加载历史消息,重建 Checkpoint"""
    history = await load_recent_messages(user_id, session_id, limit=20)
    if not history:
        return
    
    seed_messages = []
    for msg in history:
        if msg.role == "human":
            seed_messages.append(HumanMessage(content=msg.content))
        elif msg.role == "ai":
            seed_messages.append(AIMessage(content=msg.content))
    
    # 核心:使用 aupdate_state 注入 Checkpoint 状态
    if seed_messages:
        await agent.aupdate_state(config, {"messages": seed_messages})

这保证了用户体验的连贯性,这是一种有损但高效的恢复(只会恢复最近 20 条对白,且不再还原复杂的 ToolMessage 中间态)。

流式断流防护 (Stream Safety)

在流式输出(SSE)过程中,客户端随时可能断开连接。为了防止将“说到一半”的残缺回答写入 MySQL 污染历史:

  • 引入了 stream_completed 标记,默认值为 False。
  • 只有当 agent.astream 完整遍历完毕,才将其置为 True。
  • 若中途抛出 asyncio.CancelledError 或异常,直接退出。
  • 最后只在 stream_completed == True 且有产出内容时,才一并把用户的提问和 AI 的完整回答、引用数据通过 save_chat_round 写入 MySQL(单事务保证一致性)。

MySQL 历史表是否存储 AI Tool

在当前的 MySQL 设计中,我们特意 只存储了用户的提问 (Human) 和 AI 的最终文本回复 (AI) 以及 citations,而丢弃了中间的 AIMessage (带着 tool_calls) 和 ToolMessage (工具返回结果)。以下是详细分析:

当前架构的职责划分

存储层 存储内容 用途
Redis Checkpoint 完整的 LangGraph 状态(含 Human/AI/Tool 所有消息) 运行时上下文管理、消息剪裁、Agent 执行
MySQL 精简的 human + ai 对话记录 + citations 持久化展示层历史、冷启动恢复、前端 /history 接口

不需要加入的理由

  • 前端不需要 Tool 调用细节:/history 接口返回的是给前端展示的“对话气泡”,用户看到的应该是“我问了什么 → AI 回答了什么”。Tool 调用是 Agent 的内部推理过程(如 search_blog → ToolMessage),对终端用户无意义,展示出来反而是噪音。
  • 引用已覆盖:工具返回的结构化引用(文章标题、链接)已经通过 citations 字段随 AI 消息一起保存了,用户需要的溯源信息已完整。
  • 存储成本极高且复杂度增加:get_article_content 等工具返回的文章全文动辄数千字,存入 MySQL 会导致存储量暴增。此外,还需要额外的 role 类型、tool_call_id 等字段,甚至大改 MySQL 的 ChatMessage 模型。
  • 冷启动恢复不需要 Tool 消息:只恢复 HumanMessage 和 AIMessage 是完全正确的降级。恢复 Tool 消息反而会破坏 LangGraph 的状态一致性(因为 ToolMessage 必须关联到特定的 AIMessage.tool_calls,而 tool_call_id 在恢复后会丢失)。
  • Checkpoint 已完整覆盖:在 Redis 未过期(7 天 TTL)的会话中,LangGraph 的 Checkpoint 里已经包含了完整的 Tool 调用链路供运行时使用。

如果未来确实需要怎么办?

只有以下场景才值得存 Tool 消息:

  • 需要做运营审计(追踪 AI 调了哪些工具、返回了什么)。
  • 需要在前端展示“思考过程”(类似 ChatGPT 的工具调用可视化,当前前端无此需求)。

若有调试/审计需求,更好的做法是:

  • 方案 A:利用现有日志(如 logger.info("tool_search_blog_start ...") 已经在记录了)。
  • 方案 B:单独建一张 tool_invocations 审计表,与对话历史解耦,绝不入侵核心的 chat_messages 业务表。

小结:在这一套体系下,Checkpoint 管“此刻的运行上下文”,MySQL 管“过去的完整归档”。当前的 human + ai + citations 已经是最优的持久化粒度,搭配使用保证了系统长期的健壮性与稳定性。

目录
为什么需要 Checkpoint 与上下文剪裁?核心架构设计关键实现机制Checkpoint 基础配置与会话隔离动态 Compaction 与截断 (@before_model)冷启动恢复 (Cold Start Recovery)流式断流防护 (Stream Safety)MySQL 历史表是否存储 AI Tool当前架构的职责划分不需要加入的理由如果未来确实需要怎么办?

©2015-2026 黑白梦 粤ICP备15018165号

联系: heibaimeng@foxmail.com