记忆不是模型的属性,而是 调用前后的一次读写。LLM 本身无状态:每次请求都是独立的。所谓「记住对话」,就是在调用前把历史消息拼进 prompt,调用后把这一轮的新问答追加回某个存储。RunnableWithMessageHistory 做的就是自动化这套 read-then-append 流程,而不是给模型注入魔法。

一、Memory:用 RunnableWithMessageHistory 管理多轮对话

现代 LangChain 的记忆方案由三个部件拼成:① 一个带 MessagesPlaceholder 的 prompt 模板,用来给历史留出注入位置;② 一个 ChatMessageHistory 存储(内存版用 InMemoryChatMessageHistory,生产用 Redis / SQL 等后端);③ 用 RunnableWithMessageHistory 把链和「按 session_id 取历史」的工厂函数绑在一起。

# 安装核心包 + OpenAI 适配(也可换成 langchain-anthropic 等)
pip install -U langchain langchain-core langchain-openai

# 配置 API key(任选其一)
export OPENAI_API_KEY="sk-..."
import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

# 1) prompt 里用 MessagesPlaceholder("history") 给历史留出注入位置
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个简洁友好的助手,用中文回答。"),
    MessagesPlaceholder(variable_name="history"),  # 历史消息会被展开到这里
    ("human", "{input}"),
])

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
chain = prompt | model  # 一条普通 LCEL 链,本身无记忆

# 2) 用一个 dict 做最简存储:session_id -> 历史对象
store = {}

def get_session_history(session_id: str) -> InMemoryChatMessageHistory:
    """工厂函数:按 session_id 返回(或新建)历史存储。
    生产环境把这里换成 Redis/SQL 后端即可,签名不变。"""
    if session_id not in store:
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]

# 3) 用 RunnableWithMessageHistory 包裹链
chat = RunnableWithMessageHistory(
    chain,
    get_session_history,
    input_messages_key="input",    # 链输入里哪个 key 是本轮用户消息
    history_messages_key="history",  # 注入到 prompt 哪个 placeholder
)

# 4) 调用时通过 config 传 session_id(这是记忆隔离的唯一键)
cfg = {"configurable": {"session_id": "user-001"}}
print(chat.invoke({"input": "我叫小明,记住了吗?"}, config=cfg).content)
print(chat.invoke({"input": "我叫什么名字?"}, config=cfg).content)  # 应答出「小明」

# 换一个 session_id,历史互相隔离
cfg2 = {"configurable": {"session_id": "user-002"}}
print(chat.invoke({"input": "我叫什么名字?"}, config=cfg2).content)  # 不知道,因为是新会话

从旧版 ConversationBufferMemory 迁移

LangChain 0.3+ 已将 ConversationBufferMemoryConversationChain 等有状态记忆类标记为 deprecated。它们的问题在于:记忆状态藏在对象内部,难以序列化、难以按用户隔离、和 LCEL 的无状态组合范式冲突。迁移核心是把「藏在对象里的 buffer」改成「显式按 session_id 存取的 history 工厂」。

旧版(deprecated)新版(LCEL)差异要点
ConversationBufferMemoryInMemoryChatMessageHistory + RunnableWithMessageHistory状态从对象内部移到显式存储,按 session_id 隔离
ConversationChainprompt | model 再用 RunnableWithMessageHistory 包裹不再有专用 Chain 类,任意 LCEL 链都能加记忆
memory.load_memory_variables()get_session_history(session_id).messages读历史变成显式调用工厂函数
ConversationBufferWindowMemory(k=N)在工厂或链里裁剪 history(trim_messages)窗口/摘要逻辑改为对 messages 列表做变换
ConversationSummaryMemorytrim_messages + 自定义摘要节点摘要不再内置,自己用一个 LLM 步骤压缩历史
口诀记忆搬家:把藏在对象里的 buffer,搬到按 session_id 取的 history

二、执行模式:stream / astream / ainvoke / batch

LCEL 的 Runnable 接口天生提供 统一的多模态执行 API:同一条链,不改一行组合逻辑,就能 invoke(同步整段)、stream(同步逐块)、ainvoke(异步整段)、astream(异步逐块)、batch(批量并行)。选哪个只取决于运行环境(同步/异步)和交付形态(要不要边出边显示、要不要批量),与链本身无关。

模式返回典型场景
invoke一次性完整结果脚本、后端单次调用、不需要打字机效果
stream同步生成器,逐 token 产出命令行、同步 Web 框架里的打字机效果
ainvoke协程(await 得完整结果)FastAPI / asyncio 服务里的单次调用
astream异步生成器,逐 token 产出异步服务里的 SSE / WebSocket 流式推送
batch结果列表,默认并行执行批量打分、离线处理一堆输入
口诀同步还是异步选 a 前缀,要不要逐块选 stream,要不要批量选 batch

stream:同步逐 token 流式输出

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("用三句话讲讲 {topic}")
model = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
chain = prompt | model | StrOutputParser()  # StrOutputParser 让流式直接吐字符串

# stream 返回同步生成器,每个 chunk 是新增的一小段文本
for chunk in chain.stream({"topic": "LangChain 的记忆机制"}):
    print(chunk, end="", flush=True)  # 打字机效果,end="" 不换行,flush 立即刷出
print()

ainvoke + astream:异步调用与异步流

import asyncio
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("一句话解释 {topic}")
chain = prompt | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser()

async def main():
    # 1) ainvoke:异步整段调用,await 拿完整结果
    result = await chain.ainvoke({"topic": "协程"})
    print("[ainvoke]", result)

    # 2) astream:异步逐 token,配合 async for 消费
    print("[astream] ", end="")
    async for chunk in chain.astream({"topic": "事件循环"}):
        print(chunk, end="", flush=True)
    print()

# 在 FastAPI 等异步框架里直接 await;脚本里用 asyncio.run 驱动
asyncio.run(main())

batch:批处理与 max_concurrency 并发控制

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("给「{word}」写一个英文同义词,只输出单词")
chain = prompt | ChatOpenAI(model="gpt-4o-mini", temperature=0) | StrOutputParser()

inputs = [{"word": w} for w in ["快乐", "悲伤", "愤怒", "惊讶", "恐惧", "信任"]]

# batch 默认并行执行所有输入(不是 for 循环 invoke),返回顺序与输入一致的结果列表
# config 里的 max_concurrency 限制同时在飞的请求数,防止打爆 API 限速
results = chain.batch(inputs, config={"max_concurrency": 3})
for item, out in zip(inputs, results):
    print(item["word"], "->", out)

# 异步批处理:abatch,签名一致,用在异步环境
# results = await chain.abatch(inputs, config={"max_concurrency": 3})
推荐做法
  • 用 RunnableWithMessageHistory 包裹现有 LCEL 链来加记忆,而不是回退到旧 Memory 类
  • 每个用户 / 会话用独立 session_id,通过 config 的 configurable 传入
  • 异步框架里统一用 ainvoke / astream / abatch
  • 批处理永远显式设置 max_concurrency
  • 历史用 trim_messages 设上界,避免上下文爆炸
不推荐
  • 不要在异步 handler 里调用同步 invoke,会阻塞事件循环
  • 不要新写 ConversationBufferMemory / ConversationChain(已废弃)
  • 不要无限累积历史而不裁剪
  • 不要不限流就 batch 大量输入
常见误区
  • 忘记传 session_id 导致 ValueError
  • 流式时链中间有非流式步骤,导致那一步攒齐才往下走、体感卡顿
  • 把 store 用普通 dict 放在多进程部署里,进程间历史不共享——生产要用 Redis 等外部存储
  • batch 不设 return_exceptions=True 时,单条失败会让整批抛错

同一条链能在同步脚本、异步服务、批处理三种场景无改动复用;多轮对话能按 session_id 正确隔离并记住上下文;高并发批处理不触发 429。

记忆不是模型记住了什么,而是你在每次调用前后,决定把什么读进 prompt、把什么写回存储。

— LangChain 实战心法

下一章进入 可观测性与上线:用 Callbacks 追踪链内部每一步、定位幻觉与性能瓶颈,盘点高频踩坑,并用 LangServe 把链一键变成带 OpenAPI 文档的生产级 HTTP 服务。