我最早做聊天机器人时最怕用户来一句你刚刚不是已经说过了吗我只能让用户把背景重新交代一遍。后来我才意识到这不是模型不行而是应用的Memory 记忆机制没搭好。LLM 本身是无状态的每次请求在模型眼里都是第一次见面LangChain 里的 Memory 模块解决的正是把第一次见面变成老熟人这件事。这篇内容我打算把用 LangChain 做记忆机制的完整思路、代码样例和踩坑记录都写出来适合正在做智能客服、私人助手、知识库问答这类应用的开发者参考也适合那些刚接触 LLM 工程的初学者用来建立记忆体系的概念框架。1. 先把记忆这个词拆开AI 的记忆不只是聊天记录1.1 没有记忆的 AI体验为什么像金鱼做过对话应用的开发者基本都体会过那种尴尬用户花了大段文字交代了自己的身份、需求和偏好结果模型在下一轮回复里完全当成陌生人处理。比如用户说我的订单号是 A12345帮我查一下物流状态AI 回复了物流信息用户再追问那如果我要改收件地址呢AI 可能反问请问您的订单号是多少。这个问题的根不在模型能力而在于应用层没有做上下文管理。大多数大模型 API 的调用是纯粹的输入一段文本输出一段文本模型自身不保存任何跨请求状态。所谓让 AI 记住你说白了就是我们在应用层把之前的对话、事实、偏好找回来在每次调用模型之前把它们重新拼进 Prompt 里。这也解释了为什么记忆机制看起来简单但做起来有很多细节。你可以把它理解为两个人聊天时一方需要不断翻阅聊天记录和对方名片才能在对话中表现得像记得你。翻得太多速度慢还费钱翻得太少又显得健忘。LangChain 的 Memory 相关模块本质上是帮你把翻记录这件事工程化。1.2 三层记忆模型我习惯把 AI 的记忆拆成三层这三层对应不同的存储位置和使用方式。短期会话记忆Session Memory指的是最近几轮对话的原始记录通常直接塞进 Prompt 的对话历史区域。它解决的是刚才聊到哪儿了的问题。由于语言模型对上下文长度有硬性限制短期记忆不能无限增长需要配合窗口或裁剪策略。摘要记忆Summary Memory是把早期长对话压缩成一段结构化摘要。当对话超过一定轮数原始记录太占空间继续全部塞进上下文既不经济也可能被截断。此时用摘要记忆保留关键信息比如用户身份、核心诉求、已经确认的结论。摘要记忆解决的是几个小时后还能不能对上号的问题。长期事实记忆Long-term Memory一般存在向量数据库、键值存储或关系型数据库里包括用户画像、历史偏好、过往关键事件。它不追求把每句话都存下来而是提取出可以复用的事实。在每次对话开始前系统根据当前问题召回相关记忆片段注入 Prompt。长期记忆解决的是下次再来 AI 还认不认识你的问题。三个层次不是互相替代而是同时配合。只用短期记忆对话稍长就会超出上下文窗口只用摘要记忆细节会丢只靠长期记忆连刚才都记不住。实际项目里三者的使用比例和触发规则需要根据应用场景去调。1.3 业务场景决定你要做几层做智能客服时用户画像和历史订单是长期记忆最近三到五轮对话是短期记忆一通会话结束后可能还需要生成一份服务摘要。做知识库问答时对话历史的比重可以放低重点是把用户常问的问题、已反馈过的答案存入长期记忆用来做推荐和追问。做陪伴型聊天或私教类应用时摘要记忆变得尤其重要因为这类产品最需要懂我而懂来自于对长期闲聊内容的持续归纳。所以别急着写代码先想清楚产品需要哪几层记忆。经常有人一上来就堆一堆记忆组件结果 Prompt 里塞满了无用的历史又贵又慢。记忆的核心不是存得越多越好而是在合适的时候把合适的信息拿出来。2. LangChain 内置 Memory 组件哪些能用哪些已经过时2.1 内置记忆类型横向对比LangChain 从早期版本开始就提供了一批 Memory 类名字非常直观。我列一个表说明它们的核心逻辑方便你做选型参考。组件名称核心逻辑适合场景主要问题ConversationBufferMemory保存全部对话原始消息短会话、调试阶段无限增长很快爆上下文和预算ConversationBufferWindowMemory只保留最近 k 轮原始消息客服闲聊、轻量对话窗口外的关键信息直接丢失ConversationSummaryMemory持续生成对话摘要长对话、需要保留全局信息摘要本身消耗 token且细节会失真ConversationSummaryBufferMemory前段摘要 后段原始消息混合长对话同时兼顾细节配置较复杂摘要更新逻辑要盯紧ConversationEntityMemory抽取实体并维护实体属性用户画像简单场景实体抽取不稳定中文场景尤其容易翻车VectorStoreRetrieverMemory按语义相似度召回历史片段知识问答、长期记忆召回需要额外搭向量库有召回粒度问题从实践角度看ConversationBufferMemory是很多人初学时最先接触的组件也是最快翻车的组件。它在 Demo 里看着一切正常一旦对话超过十几轮每次请求携带的 token 数量就会飞速上涨接着就是延迟增加、成本增加甚至模型开始遗忘早期信息。ConversationBufferWindowMemory稍微好一点但窗口截断很粗暴用户在第 20 轮提到的偏好到了第 21 轮可能就丢了。2.2 Token 成本的账必须自己算我拿一个常见场景来算笔账你就知道为什么不能无脑用 Buffer 了。假设你的对话平均每轮用户输入 150 tokenAI 回复 350 token合计 500 token。第一次请求的 Prompt 只有当前这轮内容第十次请求如果带上前 9 轮全部消息就需要额外携带大约 4500 token 的历史第五十次请求时历史部分已经来到 24500 token。如果只算输入价格500 token 和 25000 token 的 Prompt 成本差距大概是 50 倍。如果你的应用有几千个日活用户每人每天聊几十轮这个差距就是决定项目能不能盈利的量级。所以做记忆机制的第一个原则是历史消息必须设上限不能用全部当默认策略。要么用窗口截断要么用摘要压缩要么两者结合。2.3 旧版 Memory 类和新版 Runnable API 的取舍在 LangChain 早期版本里比较常见的写法是ConversationChain(llmllm, memorymemory)然后在链内自动维护消息历史。这套 API 在简单场景下很顺但接入多链路、多工具、复杂 Prompt 模板时就失控了因为历史消息的注入时机和格式不够透明。新版本更推荐把消息历史当作 Runnable 流程中的一个外部依赖来管理典型工具是RunnableWithMessageHistory。它不再隐性地维护记忆而是让你明确提供根据 session_id 获取历史对象的方法历史消息注入的位置也由你在 Prompt 模板里显式控制。这种设计对调试更友好也方便把历史存储换成 Redis、数据库或者任何自定义方案。我的建议是新项目直接用 Runnable 这套思路别再抱着旧 Chain 写法不放。旧 Memory 类不是完全不能用但你得清楚它是怎么注入历史、什么时候清理状态否则出了问题很难排查。3. 从代码层面跑通真正的多轮记忆3.1 用 RunnableWithMessageHistory 搭一个带记忆的对话入口这里给一个最小可运行示例整体思路是先用 ChatPromptTemplate 定义带history占位符的 Prompt再用 LangChain 的RunnableWithMessageHistory包裹模型链让每次调用都能自动带上对应 session 的消息历史。import os from langchain_core.chat_history import InMemoryChatMessageHistory from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_openai import ChatOpenAI model ChatOpenAI(modelyour-model-name, temperature0.3) prompt ChatPromptTemplate.from_messages([ (system, 你是一位有记忆能力的助手请利用对话历史来理解用户背景并自然作答。), MessagesPlaceholder(variable_namehistory), (human, {input}), ]) chain prompt | model history_store {} def get_session_history(session_id: str): if session_id not in history_store: history_store[session_id] InMemoryChatMessageHistory() return history_store[session_id] conversation RunnableWithMessageHistory( runnablechain, get_session_historyget_session_history, input_messages_keyinput, history_messages_keyhistory, ) result conversation.invoke( {input: 我叫小王最近在研究记忆机制}, config{configurable: {session_id: user-001}}, ) print(result.content)这段代码里最关键的是get_session_history函数。每次调用时框架会拿着session_id找到对应的历史对象然后自动把消息序列填充到history占位符中。第一次调用时历史为空第二次开始模型就能看到上一轮的对话了。需要留意的是history_store这个字典只是演示用它放在进程内存里服务一重启全没了。多实例部署时也不能这么写否则不同机器上的用户会拿到不同的历史。生产环境至少要换成 Redis 这类共享存储。3.2 session_id 是记忆的抽屉分隔线很多人第一次看到configurable: {session_id: ...}会忽略它的重要性。session_id 决定了哪些消息属于同一个会话。它可以是 user_id、设备号、或者user-时间戳这样的组合值关键是不能让不同会话的消息混在一起否则就是数据串台的严重事故。实际项目中我通常会把 session_id 设计为多个维度的组合用户身份、场景类型、客户端实例。比如用户 A 在 App 内和网页端的对话应该分成不同 session否则两边互相污染。又比如客服坐席场景中同一个用户可能在多个会话里穿插提问单靠 user_id 做 session_id 会导致历史混乱。另外还要注意 ID 的字符安全session_id 经常出现在日志、数据库索引、URL 参数里包含特殊字符时可能带来意外问题。我一般建议统一做一层简单归一化比如格式化为业务标识_用户标识_时间戳。3.3 限制历史消息体积窗口裁剪加摘要对话一长历史体积必然失控因此我习惯在入口处做两层控制第一层是滑动窗口只保留最近 N 轮原始消息第二层是把更早的消息压缩成摘要。最简单的实现是直接在 get_session_history 返回自定义历史类时做窗口截断class WindowedHistory: def __init__(self, max_messages: int 8): self.messages [] self.max_messages max_messages def add_message(self, message) - None: self.messages.append(message) if len(self.messages) self.max_messages: self.messages.pop(0) def get_session_history(session_id: str): if session_id not in history_store: history_store[session_id] WindowedHistory(max_messages8) return history_store[session_id]这个类每次只保留最近 8 轮消息超过便从头部丢弃。它的问题是粗暴如果用户在第 6 轮报了订单号第 9 轮还在说这个订单模型就拿不到最初的信息了。所以我一般只在记忆压力不大的场景下单独用窗口更完整的方案是窗口加摘要混合把早期关键事实提炼出来放进系统 Prompt。摘要可以用模型循环对历史做归纳每次达到窗口上限时把窗口外的历史喂给模型生成一段摘要下一次调用时带着前次摘要和新窗口内容。LangChain 自带的 summary buffer 思路也是这个但你最好自己控制摘要的生成时机避免每轮调用都触发一次模型总结白白烧钱。3.4 把历史存储换到 Redis进程内存的存储方式只能用于学习和单机 Demo。生产环境里至少要用 Redis 做历史存储既解决多实例共享问题也能顺手设置过期时间。import os from langchain_redis import RedisChatMessageHistory def get_session_history(session_id: str): return RedisChatMessageHistory( session_idsession_id, redis_urlos.getenv(REDIS_URL, redis://localhost:6379), ttl3600, # 1 小时无访问自动过期 )如果你不想引入额外依赖也可以自己实现一个历史类把消息序列化成 JSON 写入数据库每次读取时反序列化。核心接口只需要两个方法messages属性和add_message方法LangChain 的框架层并不会特别要求你必须继承某个基类。对已有系统的侵入程度很小这是我把历史存储独立出来的原因。4. 真正记住用户三层长期记忆的组合方案4.1 为什么单个记忆组件撑不起长期记忆很多人以为把对话历史存进数据库就是长期记忆其实不对。原始对话记录只能算日志不是记忆。如果用户三个月回来说上次你说可以打折这次怎么不行了系统得能从一大摞聊天记录里精准找到当时的承诺、原因和条件这已经不是简单的读取历史能做到的而是需要检索、归纳和画像维护。单个组件的问题在于Buffer 类只解决短时上下文Summary 类能压缩信息但没有跨会话长期存储VectorStore 能按语义召回但不保证召回的是关键事实。真正的长期记忆需要一条完整链路过滤、抽取、存储、召回、更新、遗忘。4.2 一套可落地的三层架构我目前用得比较顺的组合方案是这样的短期保留最近 N 轮原始消息中期保留按会话生成的摘要长期存储用户画像和可复用事实。每次用户提问时系统并行从摘要库和向量库召回相关信息和最近对话一起拼进 Prompt。def build_memory_prompt(user_id: str, user_query: str) - str: recent_messages recent_session_store.load(user_id, limit8) profile user_profile_store.load(user_id) related_memories memory_retriever.invoke(user_query, top_k5) memory_text \n.join( f[过往事实] {m.page_content} for m in related_memories ) prompt f用户画像{profile}\n与当前问题相关的过往事实\n{memory_text}\n prompt f最近对话\n{recent_messages}\n prompt f当前问题{user_query} return prompt这段思路的重点是把不同来源的信息分段拼装而不是一股脑塞在一起。用户画像里放的是稳定信息比如用户是学生偏好性价比商品上次购买日期是某月某日过往事实里放的是从历史中抽取出来的片段比如用户曾询问过某个型号的保修政策最近对话则保持原样让模型知道上下文。这套架构的好处是每一层都有明确的更新逻辑。会话结束后用模型对新对话做一次摘要把新事实合并进用户画像向量库则定期摄入重要的历史片段。这样既不丢核心信息又控制了每次 Prompt 的体积。4.3 实体记忆的翻车现场LangChain 的ConversationEntityMemory能自动抽取实体并维护属性听起来很适合做用户画像但实际用下来坑不少。中文环境下实体抽取对模型依赖很高同一个小王在上下文里可能被识别成多个实体代词指代很容易张冠李戴。我以前做过一次测试用户先说我喜欢看悬疑片后说其实我也爱科幻片实体记忆直接把两个类型都挂在同一个实体上结果并没有问题但一旦用户表达的是对某个品牌的负面评价实体记忆却把品牌名和喜欢关联在一起画像就偏了。所以我的建议是实体记忆更适合做辅助信号不要作为用户画像的唯一来源。真正要维护的结构化画像应该由你定义的业务字段决定比如年龄段、偏好、购买意向然后通过人工设计的抽取逻辑从对话中提取。把完全不可控的自动抽取结果直接当作事实存储迟早会给你惹麻烦。4.4 记忆更新的频率与冲突策略记忆不是写入后永远不变用户会改偏好会换需求旧信息必须能过期。我在设计用户画像时会为每个字段加一个最后更新时间和置信度。置信度低的记忆默认不作为强约束条件只有在后续对话中被多次确认后才升级为高置信状态。比如用户某天提到我不吃辣但两周后又说今天试了家川菜还不错。此时系统不应该盲目用新信息覆盖旧信息而应该记录为口味变化待确认。更稳妥的做法是同时保留两条事实和各自的时间戳让模型在综合判断时考虑时间因素。这个思路和搜索引擎的排序很像时效性、相关性、可信度三者要平衡。5. 真实项目里踩过的 Memory 深坑和我的应对习惯5.1 无限累积导致长聊后 token 暴涨我最早做某个客服 Demo 时用了最简单的 Buffer 记忆。前几轮还很正常聊到 20 轮左右响应开始变慢再往后直接报上下文超限错误。排查时发现每次请求的 Prompt 已经从几百 token 涨到两万多 token整个流程就是带着全部历史在跑。那次之后我养成了习惯每次调用模型前在日志里打一行 token 统计观察历史消息的体积曲线。一旦发现某个 session 的历史持续增长就说明裁剪策略失效了。现在我在所有带记忆的链上都默认加窗口限制即使当时看起来用不到。这个习惯救我很多次。5.2 多用户串台记忆挂到了别人头上还有一次线上问题更严重两个测试账号竟然看到了彼此的对话记录。排查到最后发现是某处代码把 ChatMessageHistory 对象放在了全局变量里没有按 session_id 分离实例。A 用户和 B 用户共用同一个历史对象消息互相追加AI 在回复 B 时能看到 A 的前几轮对话。这类问题在单机开发时不容易暴露因为测试用户一般只有一两个。一旦并发量上来或者在容器里跑多个副本全局缓存和进程内存的隔离问题会非常明显。现在我要求自己做到能用共享存储就不用本地变量必须用本地缓存时也一定按 session_id 做键隔离并加过期清空机制防止内存泄漏。5.3 敏感信息留痕的问题记忆机制把对话存下来也意味着把用户说过的所有内容都留了下来。真实场景里用户可能随口报出手机号、验证码、账号密码、身份证号这类敏感数据。如果这些信息被原封不动写进长期记忆无论对用户还是对开发者都是一种风险。我的处理原则是入库之前先做一轮脱敏和过滤。可以用正则识别常见敏感格式也可以让模型在生成用户画像摘要时自动忽略敏感片段。比如手机号保留前三位和后两位中间打星号验证码、卡号直接不记对话中的明文密码一律清理。另外长期记忆存储要严格控制访问权限不能因为某个接口需要读历史就把整个记忆库暴露出去。5.4 记忆的遗忘与过期策略长期记忆必须有删除机制这是很多人忽略的。用户有可能要求清空记忆也有可能某条记忆过时后产生了误导。我的做法是给每条长期记忆加上过期时间TTL同时提供主动清除入口。在 Prompt 里也可以显式告诉模型用户画像信息可能已经过时如果对话中出现与之矛盾的内容以当前对话为准。这句话虽然简单却能避免模型顶着旧画像硬答。我见过不少应用在用户明确说我现在不吃辣了之后依然因为旧画像而推荐辣味菜品问题的根不在模型理解能力而在记忆更新逻辑没有给新信息足够的优先级。把记忆机制做好本质上是在做一套信息生命周期管理。存什么、留多久、怎么召回、何时更新、何时遗忘每一步都要有明确的规则。我在实际项目里最深的体会是记忆系统不是一个开箱即用的组件而是需要结合业务认真设计的工程模块。先把这三层架构搭起来再针对自己的场景去调整细节你会明显感觉到 AI 应用从功能可用到体验像人的跨越。
阅读完成 · 觉得有帮助?