发布于 2026-05-31
对于大模型对话 Agent 的会话与记忆管理,黑白梦博客助手采用了一套“双存储解耦架构”:以 Redis 作为轻量级的短期运行快照,结合 LangGraph 的 Checkpoint 机制实现动态上下文剪裁;以 MySQL 作为长期全局档案,实现精准溯源与冷启动恢复。
在构建对话 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 的角色是“引擎的运行快照”,而不是“唯一的历史存储库”。
@before_model 返回 RemoveMessage(id=REMOVE_ALL_MESSAGES) 永久丢弃旧消息和异常的 AI 空消息。MySQL 的角色:
在实际代码中,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 对应的消息快照,为多轮对话提供连续的上下文。
@before_model)在 src/agent/core.py 中,我们利用了 @before_model 中间件,直接修改传给模型的输入以及底层的 Checkpoint 状态:
_count_mixed_tokens 处理中英混合场景,并将 tool_calls 的参数计入 Token 消耗中。get_article_content 返回的数千字文章填满整个 Checkpoint 导致后续彻底无法对话,中间件会主动将内容超过 2000 字符的 ToolMessage 截断。RemoveMessage(id=REMOVE_ALL_MESSAGES),通知 LangGraph 彻底清空当前会话的全部历史消息,再把精心裁剪/截断后的 *trimmed 消息列表重新写入。这种“连根拔起、重新打包”的 Compaction 机制,确保了 Redis 中存储的 Checkpoint 大小极其精简,完全避免了状态数据膨胀。当 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 中间态)。
在流式输出(SSE)过程中,客户端随时可能断开连接。为了防止将“说到一半”的残缺回答写入 MySQL 污染历史:
stream_completed 标记,默认值为 False。agent.astream 完整遍历完毕,才将其置为 True。asyncio.CancelledError 或异常,直接退出。stream_completed == True 且有产出内容时,才一并把用户的提问和 AI 的完整回答、引用数据通过 save_chat_round 写入 MySQL(单事务保证一致性)。在当前的 MySQL 设计中,我们特意 只存储了用户的提问 (Human) 和 AI 的最终文本回复 (AI) 以及 citations,而丢弃了中间的 AIMessage (带着 tool_calls) 和 ToolMessage (工具返回结果)。以下是详细分析:
| 存储层 | 存储内容 | 用途 |
|---|---|---|
| Redis Checkpoint | 完整的 LangGraph 状态(含 Human/AI/Tool 所有消息) | 运行时上下文管理、消息剪裁、Agent 执行 |
| MySQL | 精简的 human + ai 对话记录 + citations |
持久化展示层历史、冷启动恢复、前端 /history 接口 |
/history 接口返回的是给前端展示的“对话气泡”,用户看到的应该是“我问了什么 → AI 回答了什么”。Tool 调用是 Agent 的内部推理过程(如 search_blog → ToolMessage),对终端用户无意义,展示出来反而是噪音。citations 字段随 AI 消息一起保存了,用户需要的溯源信息已完整。get_article_content 等工具返回的文章全文动辄数千字,存入 MySQL 会导致存储量暴增。此外,还需要额外的 role 类型、tool_call_id 等字段,甚至大改 MySQL 的 ChatMessage 模型。HumanMessage 和 AIMessage 是完全正确的降级。恢复 Tool 消息反而会破坏 LangGraph 的状态一致性(因为 ToolMessage 必须关联到特定的 AIMessage.tool_calls,而 tool_call_id 在恢复后会丢失)。只有以下场景才值得存 Tool 消息:
若有调试/审计需求,更好的做法是:
logger.info("tool_search_blog_start ...") 已经在记录了)。tool_invocations 审计表,与对话历史解耦,绝不入侵核心的 chat_messages 业务表。小结:在这一套体系下,Checkpoint 管“此刻的运行上下文”,MySQL 管“过去的完整归档”。当前的
human+ai+citations已经是最优的持久化粒度,搭配使用保证了系统长期的健壮性与稳定性。