:别再当金鱼——给 Agent 装一套「记忆」)
LangChain 从入门到实战05别再当金鱼——给 Agent 装一套「记忆」做到第 04 篇我们的链还是一问一答的「金鱼」你问第 2 句时模型早把第 1 句忘了。真实应用不行——用户上一句刚说「我叫小王」这一句就问「我叫什么」它必须答得上来。这一篇我们把记忆接进链里让对话跨轮次「有状态」。一、记忆的本质把「历史」拼回上下文大模型本身是无状态的——它每次只看到当前这封 prompt。所谓「让 Agent 记住」其实就一句话把往期对话当作历史消息和当前提问一起重新塞进 prompt。就这么朴素。LangChain 抽象成三件套InMemoryChatMessageHistory保存历史消息这里有了多少个「容器」MessagesPlaceholder(chat_history)在 prompt 里占「历史该放哪」的位置RunnableWithMessageHistory把上面两个接进链上自动把存的历史和当前 question 一起送给模型importosfromlangchain.chat_modelsimportinit_chat_modelfromlangchain_core.promptsimportChatPromptTemplate,MessagesPlaceholderfromlangchain_core.chat_historyimportInMemoryChatMessageHistoryfromlangchain_core.runnables.historyimportRunnableWithMessageHistoryfromdotenvimportload_dotenv load_dotenv()llminit_chat_model(deepseek-chat,model_provideropenai,api_keyos.getenv(DEEPSEEK_API_KEY),base_urlhttps://api.deepseek.com/v1,temperature0)# ① prompt 里留一个【历史消息】的空位promptChatPromptTemplate.from_messages([(system,你是贴心助理。),MessagesPlaceholder(chat_history),# 这一行历史消息都塞这里(human,{question}),])# ② 每个会话一个独立的历史容器stores{}defget_session_history(session_id:str):ifsession_idnotinstores:stores[session_id]InMemoryChatMessageHistory()returnstores[session_id]# ③ 用 RunnableWithMessageHistory 把它包装成「有状态的链」# 注意链尾直接用 llm返回 AIMessage 对象方便历史里同时保留 human/ai 成对消息chain(prompt|llm)with_memRunnableWithMessageHistory(chain,get_session_history,input_messages_keyquestion,# 本次新输入是哪个 keyhistory_messages_keychat_history,# 历史塞进哪个占位符)# ④ 调用时额外带 config[configurable][session_id]print(with_mem.invoke({question:记住我叫小王是做 Java 的。},config{configurable:{session_id:u-1001}},))print(with_mem.invoke({question:我叫什么},config{configurable:{session_id:u-1001}},))# 第二次它会根据历史回答「你叫小王」对照 Java 视角session_id就像HttpSession id——存用户维度的一份会话状态get_store就是「按 session 取/建 session」的 DAO。不同用户传不同session_id各聊各的互不串台。二、窗口记忆只留最近的 N 条控制 token历史无限攒会撑爆上下文。最朴素的克制办法是只保留最近 N 条消息——这就是窗口记忆。实现超简单每次存储就裁剪fromlangchain_core.chat_historyimportInMemoryChatMessageHistoryclassTrimmingHistory(InMemoryChatMessageHistory):只保留最近 keep 条消息keep 传偶数确保不截断半轮对话human/ai 成对。def__init__(self,keep:int6):super().__init__()self.keepkeepdefadd_message(self,message)-None:super().add_message(message)iflen(self.messages)self.keep:self.messagesself.messages[-self.keep:]# 从老到新只留最后 keep 条接线方式把第一节get_session_history里返回的容器换成它即可让每个会话只记住最近 6 条defget_session_history(session_id:str):ifsession_idnotinstores:stores[session_id]TrimmingHistory(6)# 换成窗口版容器returnstores[session_id]三、怎么选窗口 or 摘要做权衡窗口记忆简单、省 token、可控缺点是会忘掉「太老但重要」的梗比如开聊时说的项目背景。摘要记忆保留空间给长期信息但实现复杂通常要「每次对话后让模型把历史压成一段摘要再拼接」会额外花一次调用、也容易失真。新手阶段、对话轮次不多时先上窗口记忆就够等要长期保留核心约束再去接摘要 or 向量化记忆。别一开始就过度设计。顺带说个行业动向LangChain 已在RunnableWithMessageHistory上打上 deprecated 标记官方建议改用 LangGraph 的「检查点持久化」Checkpointer来做记忆——因为它能捎带自动保存「节点中间状态」比单纯塞历史更接近生产需求。这正好是我们第二套课程《LangGraph 从入门到实战》要展开的。生产中别把记忆放内存进程一重启就丢、多实例不共享。LangChain 提供了「持久化内存」实现接口不变换容器即可# 以 Redis 为例跨进程、跨重启、多实例共享# pip install langchain-redisfromlangchain_redisimportRedisChatMessageHistory stores{}defget_session_history(session_id:str):ifsession_idnotinstores:stores[session_id]RedisChatMessageHistory(session_idsession_id,redis_urlredis://localhost:6379)returnstores[session_id]换成RedisChatMessageHistory/PostgresChatMessageHistory会话状态就落到了独立存储——跟你在 Java 里把用户会话从 HashMap 换成 Redis 是同一个演进路径。四、工程化对照Java 视角InMemoryChatMessageHistory≈ 一个「会话对象的内存 Map」——用一个session_id→ListMessage的 HashMap跟你HttpSession的存法一模一样。窗口记忆 ≈ 「滚动 window」/只留 recent N等价于「Deque限长」。历史自动拼进 prompt ≈ AOP 切面RunnableWithMessageHistory 像拦截器一样在调用前自动往请求里补上历史业务代码只需要传 question。小结能力解决了什么MessagesPlaceholderprompt 里为历史腾位置RunnableWithMessageHistory自动拼历史 当前问题链变成有状态session_id多用户互不串扰窗口裁剪TrimmingHistory控 token不失状态下一篇预告Agent 光会聊天没用得会「干活」——第06篇《工具调用Tool Calling》教模型怎么「伸手出去调用一个函数」让它能查天气、算数、查数据库。我们下回见。