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,随机意味着每次都是新会话,等于没开记忆。

一次 invoke 内部的记忆读写循环

想知道某个 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 里读写。

state 的三条读写入口与持久化路径

工具里读状态靠 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 两个阈值

短期记忆这一层,说到底是把"模型无状态"这个事实接住。模型本身不记事,是你在外面替它记。记在哪儿、记多久、什么时候删,这三件事想清楚了,剩下都是配置。

posted @ 2026-11-05 17:30  starzy  阅读(5)  评论(0)    收藏  举报