LangChain 之 Agent 短期记忆
大数据技术实践者 | LangChain Agent 实战系列 · 18
一、同一个问题问两次,第二次就忘了
上一篇把流式输出打通了,Agent 总算能边想边说。可把它接进真实业务,很快撞上另一件事。用户第一句说"我叫张三",第二句问"我叫什么",模型客客气气地回一句"抱歉,我不知道你的名字"。
这不是模型笨。每一次 invoke 都是一次独立的函数调用,你把消息列表递进去,它算完返回,中间那些对话在进程里没留下痕迹。等第二次调用,messages 里只有新的一句提问,前面那句自我介绍随着上一次调用的栈帧一起没了。
文档里管这个能力叫短期记忆(short-term memory),也叫线程范围记忆(thread-scoped memory)。名字听着像模型自带的,实际上跟模型没关系,是框架在调用外面套的一层状态管理。模型每次只能看见你当场递给它的那段文本,记得住记不住,取决于你递了什么。
要让 Agent 记住,得凑齐三样东西。一个装消息的容器,也就是 AgentState。一个标识会话的坐标,也就是 thread_id。一个把容器按坐标存起来的仓库,也就是 checkpointer。三样缺一样,记忆就是断的。
二、thread_id 决定它记得谁
最小可跑的记忆版本只有两行差别。
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model=llm,
tools=[get_weather],
checkpointer=InMemorySaver(), # 第一行,打开记忆
)
config = {"configurable": {"thread_id": "user_001"}} # 第二行,记忆挂哪儿
agent.invoke({"messages": [{"role": "user", "content": "我叫张三"}]}, config)
res = agent.invoke({"messages": [{"role": "user", "content": "我叫什么"}]}, config)
print(res["messages"][-1].content) # 你叫张三
checkpointer 打开记忆开关,thread_id 说明这段记忆算谁的。第二次调用没有重复粘贴历史,框架按 thread_id 取出上一次的状态,拼进这一轮的消息列表,模型就"想起来"了。
thread_id 换个值就是一段新会话。同一个用户前后两次咨询要接上,两次必须用同一个值;两个用户不能串,值就必须隔离。生产里常见的拼法是拿业务主键造一个可读的键,比如 user-{user_id}-{order_id}、ticket-{ticket_id}。别图省事用随机 UUID,随机意味着每次都是新会话,等于没开记忆。
想知道某个 thread 里到底存了什么,用 get_state 把它捞出来。
state = agent.get_state(config)
print(state.values["messages"])
返回的是 StateSnapshot,values 就是当前状态,messages 是完整消息列表。线上排查"它为什么忘了"或者"它为什么记住了不该记的",从这一步查比在提示词里反复写"请结合上下文"有用得多,至少你能看见模型到底拿到了什么。
三、checkpointer 决定它记多久
InMemorySaver 把状态存在 Python 进程的字典里。进程重启清零,多副本部署更麻烦,同一个用户的第二个请求被负载均衡打到另一台机器,他会发现自己又被忘了。
生产要换数据库。Postgres 的写法是这样。
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # 首次运行建表,之后再跑是幂等的
agent = create_agent(model=llm, tools=[get_user_info], checkpointer=checkpointer)
MySQL 用另一套包,写法几乎一样。
from langgraph.checkpoint.mysql.pymysql import PyMySQLSaver
DB_URI = "mysql+pymysql://root:123456@localhost:3306/langchain_db?charset=utf8mb4"
with PyMySQLSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup()
agent = create_agent(model=llm, tools=[get_user_info], checkpointer=checkpointer)
依赖分别是 pip install langgraph-checkpoint-postgres "psycopg[binary]" 和 pip install langgraph-checkpoint-mysql pymysql。官方把各家实现拆成了 langgraph-checkpoint-* 系列包,SQLite、Redis、Azure Cosmos DB 都有对应的包,选型时按团队现成的中间件走就行,没必要为记忆单独引一套存储。
有两个细节容易踩。setup() 负责建表,跑一次就够,别写在每次请求的路径上。with 语句别省,连接用完要关,尤其是在 Web 服务里,漏掉的连接比漏掉的 token 贵。
还有一层容量上的账。checkpointer 在每个执行步骤后存一份状态快照,不只是对话结束存一次,工具调用、模型返回、节点切换都会落一份。一段十几轮工具调用的会话,checkpoint 表里会有几十行。这不是浪费,它就是断点恢复能力的代价,但容量规划时得算进去,定期清理过期 thread 是必要的运维动作。
另一个坑跟并发有关。同一个 thread_id 被两个请求同时写,两次执行各自从同一个快照出发,最后落库的那一份覆盖掉另一份,用户在两个标签页里说话,回头看少了一半。框架保证单个 thread 内的快照连续,管不了你的业务并发。稳妥的做法是按 thread_id 加一把分布式锁,同一个会话的请求串行化,不同的 thread 各走各的。锁的粒度是会话,不是用户,一个用户同时开两个互不相干的会话不该互相等。
四、state 里能放的不只是 messages
默认的 AgentState 只有一个 messages。聊天够用,业务不够。用户等级、正在查的订单号、这一轮已经调了几次模型,这些东西也该跟着会话走,不该每轮让模型从历史里重新猜。
继承 AgentState 加字段,创建 Agent 时用 state_schema 挂上去。
from langchain.agents import AgentState, create_agent
class CustomerState(AgentState):
user_id: str
user_level: str
agent = create_agent(
model=llm,
tools=[get_benefit],
state_schema=CustomerState,
checkpointer=InMemorySaver(),
)
首轮把值传进去,同一个 thread 后面的调用自动带着。
agent.invoke(
{"messages": [{"role": "user", "content": "我的会员权益是什么"}],
"user_id": "u_123", "user_level": "VIP"},
config,
)
有个容易白查半小时的坑。state_schema 里没声明的字段,传了也进不了状态,不报错,就是静默丢掉。字段名要和类里对得上,语义类型也要对得上,别在一个声明成 str 的字段里塞 dict。
再往下就容易混了,state 和 context 到底谁是谁。
agent.invoke(
{"messages": [{"role": "user", "content": "北京天气"}], "user_level": "VIP"},
config,
context={"channel": "app", "region": "cn-east"},
)
两个都能在工具和中间件里读到,差别在生命周期。state 挂在 thread 上,由 checkpointer 持久化,第二次调用不传照样在。context 跟着这一次调用走,不进 checkpointer,下次不传就没了。
| 对比项 | State | Context |
|---|---|---|
| 定位 | 会话中不断演变的数据 | 一次调用的环境参数 |
| 典型内容 | 对话历史、当前订单号、调用计数 | 渠道、地域、租户、设备 |
| 是否自动持久化 | 是,由 checkpointer 负责 | 否,每次调用都要传 |
| 生命周期 | 与 thread 同寿命 | 与单次 invoke 同寿命 |
| 修改方式 | 工具、中间件、invoke 入参 | 只读,作为背景信息 |
判断标准很简单,问一句这东西会不会在对话里变。会变的放 state,一次定死整段对话不动的放 context。如果某个 context 值确实需要长期留着,就在 @after_model 里读出来写进 state,这是把环境参数钉进记忆的常规做法。
五、让工具和中间件读写记忆
读写的入口有三个。工具里读写,@before_model 里读写,@after_model 里读写。
工具里读状态靠 ToolRuntime,它是个隐藏参数,不会出现在给模型看的工具签名里。
from langchain.tools import tool, ToolRuntime
@tool
def get_benefit(runtime: ToolRuntime) -> str:
"""查询当前用户的会员权益"""
level = runtime.state["user_level"]
return "VIP 可免费退换,专属客服" if level == "VIP" else "普通会员积分翻倍"
模型只知道有这么个工具、干什么用的,不知道工具背后翻了状态。这条设计很值钱,意味着业务字段不必塞进提示词,也不会出现在模型的可见上下文里,少一层泄露风险。
要写状态,工具返回 Command。
from langgraph.types import Command
from langchain.messages import ToolMessage
@tool
def save_profile(runtime: ToolRuntime, name: str) -> Command:
"""保存用户姓名"""
return Command(update={
"user_name": name,
"messages": [ToolMessage(content=f"已保存 {name}", tool_call_id=runtime.tool_call_id)],
})
update 里必须补一条 ToolMessage 并带上 tool_call_id。少了它,消息序列里那个 tool_call 找不到对应结果,下一轮调模型直接报错。这是整套机制里最容易漏的一处,报错信息还不一定指向这里。
中间件有两个时机。@before_model 在调模型之前跑,适合裁剪历史、注入上下文。@after_model 在模型答完跑,适合从结果里抽信息回写状态。一个具体场景,用户问"订单 001 还有货吗",第一轮工具查订单,返回里带商品名;@after_model 把商品名写进 state;第二轮用户只说"那这个的库存呢",库存工具直接从 state 拿商品名,不必让模型复述一遍。
from langchain.agents.middleware import after_model
@after_model
def remember_product(state: AgentState, runtime: Runtime):
resp = state.get("structured_response")
if isinstance(resp, OrderResult):
return {"product_name": resp.product_name}
return None
返回 None 表示这一轮不动状态。这个约定要记牢,中间件返回一个意外的 dict 会把状态改坏,而且不报错。
消息列表能按 id 合并,靠的是 messages 这个 key 上挂的 reducer(默认 add_messages)。中间件里 return {"messages": state["messages"]} 看着像把原列表还回去,实际效果等于什么都没做,因为每条消息的 id 都在,合并时被识别成同一条。想真正删,必须用 RemoveMessage 明确标记,删和"没变化"在 reducer 眼里是两件事。
六、上下文撑爆之前,把话变短
短期记忆会自己长大。一轮问答至少两条消息,调一次工具再多两条,一条 AIMessage 带 tool_calls,一条 ToolMessage 带结果。二十轮下来几百条消息、几万 token,撞上上下文窗口是迟早的事。
撞上之后的表现并不统一。有的模型直接抛超长错误,有的悄悄截掉前面,还有的表面正常,回答质量肉眼可见地掉。三种里最难查的是第三种,没有报错,只有用户在群里说"今天这机器人怎么变傻了"。
处理办法有四类,代价各不同。
截断。 只留最近 N 条,前面的删掉。在 @before_model 里返回 RemoveMessage 就行。
from langchain.messages import RemoveMessage
@before_model
def trim_messages(state: AgentState, runtime: Runtime):
msgs = state["messages"]
if len(msgs) <= 5:
return None
keep = 3
if msgs[-keep].type == "tool": # 截断点落在工具链中间就多留一条
keep = 2
return {"messages": [RemoveMessage(id=m.id) for m in msgs[:-keep]]}
那个 keep 的判断不是可有可无。消息序列有硬约束,带 tool_calls 的 AIMessage 后面必须紧跟对应的 ToolMessage。截断点正好落在中间,模型收到一个孤立的 ToolMessage,直接报错。所以先看倒数第 N 条是不是 tool 类型,是就多留一条,把整条工具链包住。这条规则对四种策略都成立,不只是截断。
删除。 按 id 精确删,适合敏感信息处理,比如医疗咨询结束清掉记录。清全部用 REMOVE_ALL_MESSAGES,比逐条删快,底层是直接清空。
from langgraph.graph.message import REMOVE_ALL_MESSAGES
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}
摘要。 用模型把旧消息压成一段,保留信息的同时把 token 降下来。框架内置了 SummarizationMiddleware,不用自己写。
from langchain.agents.middleware import SummarizationMiddleware
SummarizationMiddleware(
model=small_llm, # 摘要模型,用便宜的小模型就够
trigger=("tokens", 4000), # 到 4000 token 触发
keep=("messages", 20), # 最近 20 条原样保留
)
trigger 还可以按消息条数触发,keep 也可以按 token 数保留。摘要那一步不需要推理能力,用便宜模型是纯粹的省钱,别在这儿用主模型。
自定义。 前三类都不合适时自己写,挂在 @before_model 或 @after_model 里。典型场景是消息里混了图片、附件、长表格,要按内容类型决定留谁。自定义最灵活,也最容易把工具链写断,前面那条底线照样适用。
| 策略 | 保留什么 | token 下降 | 信息损失 | 适合场景 |
|---|---|---|---|---|
| 截断 | 最近 N 条 | 快而狠 | 早期内容全丢 | 实时客服、闲聊 |
| 删除 | 指定消息 | 可控 | 精确可控 | 合规清理、敏感信息 |
| 摘要 | 摘要加近期原文 | 明显 | 细节被压缩 | 长任务、合同审查 |
| 自定义 | 按类型或规则 | 取决于实现 | 取决于实现 | 多模态、复杂业务 |
没有哪一种普适。客服场景早期信息价值低,截断够用还便宜。合同审查、长程任务这类前面埋了关键约束的,摘要更稳。带合规要求的,删除必须精确到 id,还要留审计记录。
七、速查表
| 你要做的事 | 用哪个 | 一句话说明 |
|---|---|---|
| 打开记忆 | create_agent(checkpointer=InMemorySaver()) |
测试用,进程重启即丢 |
| 生产持久化 | PostgresSaver / PyMySQLSaver + setup() |
建表一次,连接要关 |
| 标识会话 | {"configurable": {"thread_id": "..."}} |
同值接续,异值隔离 |
| 加业务字段 | class S(AgentState) + state_schema |
字段没声明就静默丢弃 |
| 工具里读 | runtime.state["key"] |
ToolRuntime 对模型不可见 |
| 工具里写 | Command(update={...}) |
必须补 ToolMessage 带 tool_call_id |
| 调模型前加工 | @before_model |
裁剪、注入 |
| 答完回写 | @after_model |
返回 None 表示不改 |
| 清理历史 | RemoveMessage(id=...) |
全清用 REMOVE_ALL_MESSAGES |
| 自动压缩 | SummarizationMiddleware |
trigger 与 keep 两个阈值 |
短期记忆这一层,说到底是把"模型无状态"这个事实接住。模型本身不记事,是你在外面替它记。记在哪儿、记多久、什么时候删,这三件事想清楚了,剩下都是配置。

浙公网安备 33010602011771号