短期记忆与上下文管理

短期记忆与上下文管理

2026年9月21日·#编程学习/langchain学习笔记LangChain/AI·7703 字 39 分钟
浏览量加载中...
AI 摘要- DeepSeek

模型本身无状态,记忆要靠 State + Checkpointer + Thread ID 三件套;InMemorySaver 与 PostgresSaver 的用法、thread_id 隔离的实测、上下文变长后的裁剪/删除/摘要三种管理手段

封面图
短期记忆与上下文管理 - 封面图

为什么需要记忆#

大模型本身是”无状态”的——它不记忆任何上下文。也就是说,每次 agent.invoke() 都是全新的开始,不记得之前的对话。

# 第一次调用
agent.invoke({"messages": [HumanMessage("你好,我叫小明")]})
# 第二次调用(同一个 agent 对象!)
agent.invoke({"messages": [HumanMessage("我叫什么?")]})
# 模型:我不知道你叫什么 ← 因为历史消息根本没传给它

而我们平时用 ChatGPT 那种”多轮对话”体验,背后其实是应用层在每次请求时把历史消息一起发给模型。

图:多轮对话里模型能接上前文,靠的是把历史消息一起送过去

上下文工程与三种上下文#

在 LangChain 里:

  • 记忆(Memory) 是专门负责”存储历史交互信息”的组件,核心作用是「保存上下文」和「提供上下文」
  • 上下文工程(Context Engineering) 负责”合理组织”这些记忆和任务信息,让 LLM 的响应更连贯、更贴合需求——这是 Agent 能实现复杂多轮交互的核心基础

图:无记忆的调用 vs 有记忆、有上下文优化的调用(LangChain 实现)

LangGraph 提供了三种管理上下文的方法(上下文工程是构建在 LangGraph 之上的):

上下文类型描述可变性生命周期访问方法
动态运行时上下文单次运行中会演变的可变数据动态单次运行LangGraph state 对象
动态跨会话上下文对话间共享的持久数据(用户偏好、历史洞察、知识条目)动态跨对话LangGraph store 对象
静态运行时上下文启动时传入的用户元数据、工具、数据库连接静态单次运行LangGraph context 对象

记忆分为两类:

类型别名作用范围
短期记忆会话级记忆 / thread-scoped memory单个对话线程(Thread)内,换了 thread_id 记忆即消失
长期记忆跨会话级记忆会话线程间共享,范围是任意自定义命名空间(下一篇)
Note

版本变化:LangChain v0.x 用专用的 xxxMemory 类管理记忆;v1.x 因为 Agent 构建在 LangGraph 图结构之上,改用 state(短期) 和 store(长期) 构建记忆系统——更简单、功能更统一。

图:上下文工程做的事——从全部历史里筛出最相关的几轮,再把老的压成摘要

短期记忆的三件套#

LangChain 1.x 的短期记忆是三者的组合:

组件职责
State(会话内部状态)默认存储历史消息列表 messages,通过 State 管理历史消息
Checkpointer(持久化机制)把 State 作为检查点持久化保存,检查点是某个时刻的 State 快照
Thread ID(会话作用域)唯一标识 State,运行时按 thread_id 读写 State 快照

像玩 RPG 游戏的”自动存档”:你不需要手动保存,系统在关键节点自动记录,下次进入游戏随时可以从上次的存档点继续。

基于内存:InMemorySaver#

最便捷的方式,适合快速测试或调试:

from langgraph.checkpoint.memory import InMemorySaver
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
checkpointer = InMemorySaver()
agent = create_agent(
model=model,
checkpointer=checkpointer, # ← 1. 加检查点
)
config = {"configurable": {"thread_id": "1"}} # ← 2. 指定会话 ID
agent.invoke({"messages": [HumanMessage("你好,我叫小明")]}, config=config)
agent.invoke({"messages": [HumanMessage("我叫什么?")]}, config=config) # ← 3. 用同一个 config
# 这次它就能答上来了
Tip

实测记忆的效果:同一个 thread_id 的第二轮,发给模型的消息是 ['你好,我叫小明', '好的。', '我叫什么?']——历史被自动带上了。 换成 thread_id="2" 再问同样的问题,发给模型的只剩 ['我叫什么?']——记忆是按 thread_id 隔离的,新会话就是一张白纸。

基于外部存储:PostgresSaver#

内存里的检查点在进程结束就没了,生产环境不可接受,所以要换成持久化的外部存储(如 PostgreSQL)。

LangChain 支持的 checkpointer 后端列表见官方文档「Persistence」。以 PostgreSQL 为例:

Terminal window
pip install langgraph-checkpoint-postgres
Note

数据库环境准备:课程是在**云服务器(Ubuntu 系统)**上装 PostgreSQL 的,具体步骤见资料包 02-资料 下的《Linux云服务器安装与PostgreSQL安装》。连接串里的用户名、密码、IP 都要换成自己的:

postgresql://langchain_user:abcd1234@118.195.128.47:5432/langchain_db?sslmode=disable

118.195.128.47 是课程云服务器的 IP,本地跑就换成 localhost。

from langgraph.checkpoint.postgres import PostgresSaver
DB_URL = "postgresql://langchain_user:abcd1234@<你的IP>:5432/langchain_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
checkpointer.setup() # ← 初始化数据库表结构(第一次要跑)
agent = create_agent(model=model, checkpointer=checkpointer)
...
Warning

课程强调:依赖版本可能冲突,课程环境的依赖都在 requirements.txt 里预置了,不要重复安装。自己项目里装之前先看版本。

两种方式的对比#

InMemorySaverPostgresSaver
存储位置进程内存外部数据库
进程重启❌ 数据丢失✅ 保留
跨进程共享❌ 不支持✅ 支持
适用开发调试、快速验证生产环境

上下文变长了怎么办:三种管理手段#

内存和上下文都会无限膨胀——消息越来越多(token 消耗增加,甚至超过模型的 token 限制),响应变慢、成本上升。课程把”为什么必须治理”归纳成四条:

#理由
1LLM 的上下文窗口是有限的,完整历史可能无法装入,导致上下文丢失或错误
2即便模型的上下文窗口够大,多数 LLM 在长上下文场景仍然表现不佳——模型会被陈旧或离题的内容”分散注意力”
3高昂的 token 花费
4需要对历史记录进行压缩、清理、重组等管理动作

三种处理手段:

手段时机特点适合
裁剪调用模型前控制”模型能看到多少”,通常保留系统初始消息 + 最近若干条成本敏感、对旧上下文依赖不强
删除模型调用完成后永久从状态里移除消息明确要遗忘、清理、重置某段历史
摘要达到阈值时把早期历史压缩成摘要替换原文(保语义不保原文)长会话的折中方案

裁剪:before_model + REMOVE_ALL_MESSAGES#

from typing import Any
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import before_model
from langchain_core.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime
@before_model
def trim_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
messages = state["messages"]
if len(messages) <= 3:
return None
first_msg = messages[0] # 保留开头(比如系统提示)
recent_messages = messages[-3:] # 保留最近几条
new_messages = [first_msg, *recent_messages]
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES), # 先全部清掉
*new_messages, # 再放回要保留的
]
}
agent = create_agent(model=model, middleware=[trim_messages], checkpointer=InMemorySaver())
Tip

实测效果(keep=最近 3 条):消息数涨到 5 条时触发裁剪 → 裁剪后仍回到 5 条,之后每轮都稳定在 5 条,不再无限增长。

裁剪的坑:人机消息要成对保留#

消息列表是人机交替的(HumanMessage → AIMessage → HumanMessage → ……)。如果裁剪时不管这一层,切出来的片段很可能是”从一条 AIMessage 开头”的——模型看到的第一条就是自己的话,等于上下文对不齐。课程用一个小技巧解决:按奇偶决定切几条。

@before_model
def trim_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
messages = state["messages"]
if len(messages) <= 3:
return None
first_msg = messages[0]
# 目标是让"切出来的片段以 AIMessage 开头"(因为前面要接一条 HumanMessage):
# 总数是偶数 → 最后 3 条正好以 A 开头([A, H, A]);总数是奇数 → 取 4 条才以 A 开头([A, H, A, H])
recent_messages = messages[-3:] if len(messages) % 2 == 0 else messages[-4:]
new_messages = [first_msg] + recent_messages
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}

课程用 [H1, A1, H2, A2, H3](5 条,奇数)演示:messages[-4:] 取出 [A1, H2, A2, H3],加上第一条 H1 之后,模型看到的还是 [H1, A1, H2, A2, H3]——这一轮一条都没剪掉(长度 5 时刚好回到原样)。这正说明”随便切几条”会出问题:奇偶判断的用意是让片段以 AI 消息开头,这样拼上保留的首条之后整份列表仍然严格人机交替;否则模型看到的第一条就是自己的话,上下文会对不齐。

Tip

本机假服务端实测(同一份中间件跑四轮对话,抓每次真正发往模型的 messages):

轮次用户输入裁剪前 state 长度切出的片段实际发给模型的消息
1你好,我是老王1不触发[H: 你好,我是老王]
2从现在起,你叫小王3不触发[H, A, H]
3今天天气不错5(奇数)[-4:][H1, A1, H2, A2, H3]——5 条,等于没剪
4告诉我,你是谁?我是谁?7(奇数)[-4:][H1, A2, H3, A4, H5]——这次真剪掉了

换掉奇偶判断、固定写 messages[-3:] 再跑一遍,第 3 轮发给模型的消息变成 [H1, H2, A2, H3](4 条)——开头是一条 HumanMessage,后面紧跟着又是 HumanMessage,人机不再交替,这正是要避免的。

结论:len(messages) % 2 这个判断不是”玄学”——它保证切出来的片段永远从 HumanMessage 开始,人机成对。

删除:after_model + RemoveMessage(id=…)#

from langchain.agents.middleware import after_model
from langchain_core.messages import RemoveMessage
@after_model
def delete_old_messages(state: AgentState, runtime: Runtime) -> dict | None:
messages = state["messages"]
if len(messages) > 5:
to_delete = len(messages) - 5
# 用 RemoveMessage 标记删除,返回出去让框架真正移除
return {"messages": [RemoveMessage(id=m.id) for m in messages[:to_delete]]}
return None
agent = create_agent(model=model, middleware=[delete_old_messages], checkpointer=InMemorySaver())

关键区别:裁剪是”这次调用不给模型看”,删除是”从状态里彻底抹掉”(永久生效)。

图:两条路线——Summarize 把早期历史压成摘要,Filter 只保留要用的消息

摘要:官方推荐的做法#

消息裁剪和删除都会导致上下文缺失,影响回答质量和用户体验。相比之下,摘要是更适合长会话的折中方案:保语义,不保原文。

官方推荐直接用内置的 SummarizationMiddleware(第 19 篇详细讲过它的参数和实测效果),不必自己写。

自定义过滤策略#

裁剪、删除、摘要只是三种”教科书套路”。课程强调:通过中间件可以随意更改消息列表,因此可以实现任意的过滤策略(课程未演示,留作练习)。

这一点很关键——因为 before_model / after_model 拿到的是完整的 state["messages"],返回的又是”新的消息列表”,所以”怎么筛”完全由你决定:

过滤策略思路
按角色筛只保留 HumanMessage / 丢掉中间的工具往返消息
按内容筛命中关键词(订单号、身份证、金额)的消息永远保留,其余按时间裁剪
按重要性筛给消息打标记,重要消息进”白名单”,裁剪时优先保住
按类型筛丢掉 ToolMessage 这类冗长的中间过程,只留人机对话
Warning

自定义过滤没有内置保护:删错了就真的丢了。判断”哪条重要”这件事,最终还是得靠你自己写规则(或者像下一篇那样,把重要事实搬进长期记忆里——那才是它真正该待的地方)。

了解一下 state#

state 是 Agent 底层有状态运行图的状态信息,是 AgentState 类型的实例:

class AgentState(TypedDict, Generic[ResponseT]):
"""State schema for the agent."""
messages: Required[Annotated[list[AnyMessage], add_messages]]
jump_to: NotRequired[Annotated[JumpTo | None, EphemeralValue, PrivateStateAttr]]
structured_response: NotRequired[Annotated[ResponseT, OmitFromInput]]

AgentState 是 TypedDict 的子类,所以可以按字典的方式读写。它有三个字段(实测 AgentState.__annotations__ 正是这三个):

字段说明
messages截止当前节点的历史会话消息记录,Required(必填)
jump_to跳转到运行图的指定节点(第 21 篇的流程跳转),可以为 None
structured_response启用结构化输出时,结构化后的内容记录在这里(第 16 篇)

亲眼看看 jump_to 和 structured_response 的变化#

课程用一个”伪造工具调用 + 三个钩子打点”的例子,把这两个字段在一轮运行中的取值变化抓了出来。核心是:用 @before_model(can_jump_to=["tools"]) 人工造一条带 tool_calls 的 AIMessage,然后 jump_to="tools" 跳过模型、直奔工具节点。

from typing import Any, Callable
from pydantic import BaseModel, Field
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import after_agent, before_model, wrap_tool_call
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain.tools import tool
from langchain.tools.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
from langgraph.types import Command
class WeatherInfo(BaseModel):
"""城市天气情况"""
city: str = Field(description="城市名称")
temperature: str = Field(description="气温")
desc: str = Field(description="当日天气概述")
@tool(parse_docstring=True)
def get_weather(city: str):
"""获取当日天气
Args:
city: 城市名称
"""
return f"[{city}] 今天气温9~16度,万里无云,天气不错适合外出"
@before_model(can_jump_to=["tools"])
def direct_tool_call(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
last_msg = state["messages"][-1]
if isinstance(last_msg, HumanMessage) and "天气" in last_msg.text and "北京" in last_msg.text:
fake_tool_call = AIMessage( # 人工伪造一条"模型想调工具"的消息
content="人工构造的消息",
tool_calls=[{"name": "get_weather", "args": {"city": "北京"}, "id": "direct_call_id"}],
)
return {"messages": [fake_tool_call], "jump_to": "tools"} # ← 直接跳工具节点
return None
@wrap_tool_call
def first_check(
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
print("=" * 30, '-> In first_check Middleware <-', "=" * 30)
print(f"{request.state.get('jump_to', None) = }")
print(f"{request.state.get('structured_response', None) = }")
return handler(request)
@after_agent
def final_check(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print("=" * 30, '-> In final_check Middleware <-', "=" * 30)
for msg in state["messages"]:
msg.pretty_print()
print(f"{state.get('jump_to', None) = }")
print(f"{state.get('structured_response', None) = }")
return None
agent = create_agent(
model=model,
response_format=WeatherInfo, # ← 开启结构化输出
middleware=[direct_tool_call, first_check, final_check],
tools=[get_weather],
)
agent.invoke({"messages": [HumanMessage("请帮我查询北京当日天气")]})

输出(课程实测,两个打点各打一次):

============================== -> In first_check Middleware <- ==============================
request.state.get('jump_to', None) = 'tools'
request.state.get('structured_response', None) = None
============================== -> In final_check Middleware <- ==============================
================================ Human Message =================================
请帮我查询北京当日天气
================================== Ai Message ==================================
人工构造的消息
Tool Calls:
get_weather (direct_call_id)
Call ID: direct_call_id
Args:
city: 北京
================================= Tool Message =================================
Name: get_weather
[北京] 今天气温9~16度,万里无云,天气不错适合外出
================================== Ai Message ==================================
{"city":"北京","temperature":"9~16℃","desc":"万里无云,天气不错适合外出"}
============================== -> 消息打印完毕 <- ==============================
state.get('jump_to', None) = None
state.get('structured_response', None) = WeatherInfo(city='北京', temperature='9~16℃', desc='万里无云,天气不错适合外出')

课程的分析(三条):

  1. 我们在模型调用之前通过 jump_to 直接跳转至工具节点
  2. 在工具调用前打印状态信息,此时的 jump_to 非空
  3. 在 Agent 执行完毕之后打印状态信息,此时的 structured_response 非空,而 jump_to 为空
Tip

本机假服务端实测:jump_to 的两段式变化完全复现——

============================== -> In first_check Middleware <- ==============================
request.state.get('jump_to', None) = 'tools'
request.state.get('structured_response', None) = None
...
============================== -> 消息打印完毕 <- ==============================
state.get('jump_to', None) = None

说明这两个字段都是**“过程性”的**:jump_to 是临时跳转意图(EphemeralValue + PrivateStateAttr,跳完就清掉,所以 after_agent 里已经是 None);structured_response 是跑完才写进去的最终产物(OmitFromInput,只在输出侧可见)。

Note

print(f"{state.get('jump_to', None) = }") 用的是 f-string 的 = 说明符——它会连”表达式本身”一起打出来,所以输出长这样:state.get('jump_to', None) = None。调试状态时非常省事。 顺带一提:f-string 表达式里嵌套同种引号(f"{d.get("k")}")在 Python 3.12+(PEP 701) 才是合法的,老版本会报 SyntaxError。本课环境是 Python 3.12+,可以放心写。

常见问题#

Q1:为什么我的 Agent 不记得? 逐条检查:

检查项错误示例
加 checkpointer 了吗?create_agent(model=model, tools=[]) → 不会记住
调用时传 config 了吗?有 checkpointer 但 invoke 不传 config → 不会记住
两次的 thread_id 一样吗?thread_id="1" 和 thread_id="2" 是两个会话

Q2:InMemorySaver 会丢数据吗? 会!它只存在内存里:同一进程内有效,程序重启后丢失、不同进程无法共享。要持久化就换 SQLite / PostgreSQL。

Q3:内存会无限增长吗? 会!默认保存所有消息,所以必须做上下文管理(裁剪 / 删除 / 摘要)。

Q4:怎么清空某个会话的历史? 目前 InMemorySaver 没有提供删除 API,临时方案是换一个新的 thread_id(或重新创建 Agent)。

相关#

练习题#

一、知识回顾(读完直接做下面的实践题)#

  1. 大模型本身是无状态的,每次调用都是全新的开始,所谓”多轮对话”靠的是应用层每次把历史消息一起发给模型;负责”存储历史交互信息”的组件叫记忆(Memory),负责把它们合理组织起来的是**上下文(Context Engineering)**工程。LangGraph 把上下文分成三种:单次运行中会演变的可变数据用 state 对象、对话间共享的持久数据用 store 对象、启动时传入的静态元数据用 context 对象。
  2. 短期记忆又称会话级记忆(thread-scoped),只活在单个对话线程里——换了会话 ID,记忆就消失;长期记忆在会话线程间共享,范围是任意自定义命名空间。LangChain v0.x 用专用的 xxxMemory 类管记忆,v1.x 改用 state(短期)和 store(长期)。
  3. 短期记忆三件套:State(默认存 messages 的会话内部状态)、Checkpointer(把 State 作为检查点持久化的机制)、Thread ID(唯一标识 State 的会话作用域);“检查点”就是某个时刻的 State 快照。
  4. 启用记忆两步:创建 Agent 时传 checkpointer 参数(内存实现是 InMemorySaver());调用时传 config={"configurable": {"thread_id": ...}}。排查”它为什么不记得”就看这三样:没加checkpointer、调用时没传config、两次的 thread_id 不一样;想清空某个会话的历史,目前的临时方案是换一个新的 thread_id(它没有提供删除 API)。
  5. 内存检查点在进程结束就没了(重启数据丢失、跨进程不共享),生产要换成外部存储:以 PostgreSQL 为例装 langgraph-checkpoint-postgres,用法是 PostgresSaver.from_conn_string(DB_URL) 上下文管理器 + 先跑一次 checkpointer.setup() 建表;连接串形如 postgresql://用户:密码@主机:5432/库名?sslmode=disable,本地跑把 IP 换成 localhost。
  6. 上下文必须治理的四条理由:①LLM 的上下文窗口有限;②即便窗口够大,多数 LLM 在长上下文下仍然表现不佳——会被陈旧或离题的旧内容”分散注意力”;③token 花费高昂;④需要对历史记录做压缩、清理、重组等管理动作。
  7. 上下文管理三手段:裁剪发生在调用模型前,只控制”模型能看到多少”;删除发生在模型调用完成后、会把消息从状态里永久移除;摘要在达到阈值时把早期历史压成摘要,特点是”保语义不保原文”(官方推荐直接用内置的 SummarizationMiddleware)。裁剪和删除都靠 RemoveMessage:清空全部用 id=REMOVE_ALL_MESSAGES,删除指定的单条用 id=消息的 **id**。
  8. 裁剪的坑在于消息列表是”人机交替”的,随手切一段可能让模型看到的第一条就是自己的话;课程的小技巧是”按奇偶决定切几条”:messages[-3:] if len(messages) % 2 == 0 else messages[-4:],这样”保留的首条 + 切出来的片段”拼起来,整份列表仍然严格人机交替(不会出现两条同类消息挨在一起)。
  9. 裁剪/删除/摘要只是三种教科书套路——before_model / after_model 拿到的是完整的 state["messages"]、返回的又是”新的消息列表”,所以可以实现任意的过滤策略(按角色筛、按内容把命中关键词的永远保留、按重要性进白名单、按类型丢掉冗长的中间过程);但要小心:自定义过滤没有内置保护,删错了就真的丢了。
  10. AgentState 是 TypedDict 的子类(所以能按字典读写),三个字段是 messages(Required)、jump_to、structured_response;jump_to 是带 EphemeralValue + PrivateStateAttr 的”临时跳转(过程性)意图”(跳完就被清掉),structured_response 带 OmitFromInput、只在输出侧看得见;调试状态还可以用 f-string 的 = 说明符,把表达式本身也一起打出来。

二、裸写题#

  • 2-1 让 Agent 记住你 给 Agent 装上内存版检查点,用同一个会话 ID 连续问两轮:“你好,我叫小明” → “我叫什么?“,确认它答得上来;再换一个新的会话 ID 问同样的问题,观察它是不是”失忆”了。把每轮之后状态里的消息条数打印出来,并解释这个数字为什么会那样变。

    提示(先自己想,实在想不出再点开)

    一级 · 思路:记忆 = 检查点 + 会话 ID 的组合,两者缺一不可 二级 · 方法:create_agent(model=model, checkpointer=InMemorySaver());调用时传 config={"configurable": {"thread_id": "1"}} 三级 · 骨架:两次调用传同一份 config 就是同一个会话;打印 len(response["messages"]) 最直观

  • 2-2 裁剪:给上下文”按住”上限 写一个”模型调用前”的钩子:消息数超过 4 条时,只保留第一条 + 最近几条。跑 5 轮对话,确认消息数被按住、不再无限增长。 追加要求:切出来的那一段必须从用户消息开始(不能让模型一上来就看见自己的话)。跑通之后回答:为什么会切出”以 AI 消息开头”的片段?“取最近几条”到底取几条才刚好?

    提示(先自己想,实在想不出再点开)

    一级 · 思路:裁剪发生在模型调用前,做法是”返回一份新的消息列表”;但消息是”人机交替”的,切点选错就会切出”以 AI 消息开头”的片段 二级 · 方法:@before_model 里返回 {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), 首条, *最近几条]};最近几条写 messages[-3:] if len(messages) % 2 == 0 else messages[-4:](调用模型前,本轮的用户消息已经在列表里了,所以总数通常是奇数) 三级 · 骨架:用一个循环跑 5 轮,每轮打印 len(response["messages"]),看它涨到某个值之后就不动了

  • 2-3 用删除实现”永久遗忘” 写一个”模型调用完成后”的钩子:状态里的消息超过 5 条就删掉最早的几条。跑 3~4 轮,每轮把”剩余消息总数”和”还留着的用户消息”都打印出来,然后回答:删除和上一题的”裁剪”到底差在哪?

    提示(先自己想,实在想不出再点开)

    一级 · 思路:删除是”从状态里真删”,所以下一轮也看不到它——看状态本身有没有变小就知道差别 二级 · 方法:@after_model + {"messages": [RemoveMessage(id=m.id) for m in messages[:to_delete]]}(逐条标记,不能用”清空全部”那个标记) 三级 · 骨架:to_delete = len(messages) - 5,打印 [m.content for m in state["messages"] if m.type == "human"] 观察最早那条永久消失

  • 2-4 亲眼看看状态里那两个特殊字段 写一个”模型调用前”的钩子:当用户消息里同时出现”天气”和”北京”时,跳过模型的首轮思考、自己伪造一条”模型要调工具”的消息,把控制权直接交给工具节点(顺便给这个 Agent 开上结构化输出,用一个只有城市/气温/概述三个字段的小结构)。 再挂两个打点:① 用”包裹工具调用”的钩子打印状态里的两个特殊字段;② 用”Agent 跑完之后”的钩子打印完整消息流 + 这两个字段。 最后回答:为什么工具调用时”跳转意图”有值,而 Agent 跑完之后它变成空、另一个字段反倒有值了?

    提示(先自己想,实在想不出再点开)

    一级 · 思路:跳转要先”声明能跳到哪”,再在返回值里给出跳转目标和伪造的消息;两个打点分别抓”调用中”和”结束后”两个时刻 二级 · 方法:@before_model(can_jump_to=["tools"]) 返回 {"messages": [AIMessage(content=..., tool_calls=[{"name": ..., "args": {...}, "id": ...}])], "jump_to": "tools"};打点用 @wrap_tool_call(看 request.state)和 @after_agent(看 state) 三级 · 骨架:state.get("jump_to", None) 与 state.get("structured_response", None),两个打点各打印一次做对比

参考答案(做完再点开)
# ========== 本篇所有答案共用的开头(模型创建) ==========
import os
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
load_dotenv(override=True)
model = init_chat_model(
model="deepseek-v4-flash",
model_provider="openai",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL"),
)
# ---------- 2-1 让 Agent 记住你 ----------
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(model=model, checkpointer=InMemorySaver())
same = {"configurable": {"thread_id": "1"}} # 同一个会话 ID
r1 = agent.invoke({"messages": [HumanMessage("你好,我叫小明")]}, config=same)
print(f"[同会话] 第 1 轮后 state 消息数: {len(r1['messages'])}")
r2 = agent.invoke({"messages": [HumanMessage("我叫什么?")]}, config=same)
print("[同会话] 回答:", r2["messages"][-1].content)
print(f"[同会话] 第 2 轮后 state 消息数: {len(r2['messages'])}") # 历史被自动带上了
other = {"configurable": {"thread_id": "2"}} # 换一个会话 ID
r3 = agent.invoke({"messages": [HumanMessage("我叫什么?")]}, config=other)
print("[新会话] 回答:", r3["messages"][-1].content)
print(f"[新会话] state 消息数: {len(r3['messages'])}") # 只有本轮,新会话是白纸
# ---------- 2-2 裁剪:首条 + 最近几条(保证人机成对) ----------
from typing import Any
from langchain.agents import AgentState
from langchain.agents.middleware import before_model
from langchain_core.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime
@before_model
def trim_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
messages = state["messages"]
if len(messages) <= 4:
return None
first_msg = messages[0]
# 让片段以 AI 消息开头:偶数取 3 条([A,H,A])、奇数取 4 条([A,H,A,H])
recent_messages = messages[-3:] if len(messages) % 2 == 0 else messages[-4:]
print(f" [裁剪] {len(messages)} 条 → 首条 + 最近 {len(recent_messages)} 条")
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES), # 先全部清掉
first_msg,
*recent_messages, # 再放回要保留的
]
}
agent_trim = create_agent(model=model, middleware=[trim_messages], checkpointer=InMemorySaver())
cfg_trim = {"configurable": {"thread_id": "trim"}}
for i in range(1, 6):
rt = agent_trim.invoke({"messages": [HumanMessage(f"第 {i} 句话")]}, config=cfg_trim)
print(f"第 {i} 轮后 state 消息数: {len(rt['messages'])}")
# 涨到 6 条之后被按住,稳定在同一水平,不再无限增长
# ---------- 2-3 删除:永久遗忘 ----------
from langchain.agents.middleware import after_model
@after_model
def delete_old_messages(state: AgentState, runtime: Runtime) -> dict | None:
messages = state["messages"]
if len(messages) > 5:
to_delete = len(messages) - 5
print(f" [删除] 永久删除最早的 {to_delete} 条")
# 逐条标记删除,返回出去让框架真正移除
return {"messages": [RemoveMessage(id=m.id) for m in messages[:to_delete]]}
return None
agent_del = create_agent(model=model, middleware=[delete_old_messages], checkpointer=InMemorySaver())
cfg_del = {"configurable": {"thread_id": "delete"}}
for i in range(1, 4):
rd = agent_del.invoke({"messages": [HumanMessage(f"第 {i} 句话")]}, config=cfg_del)
left = [m.content for m in rd["messages"] if m.type == "human"]
print(f"第 {i} 轮后 state 消息数: {len(rd['messages'])},剩下的用户消息 {left}")
# 第 3 轮触发删除:最早那条真的从 state 里消失了(下一轮也再也看不到)
# ---------- 2-4 亲眼看看 state 里那两个字段 ----------
from pydantic import BaseModel, Field
from langchain.agents.middleware import after_agent, wrap_tool_call
from langchain_core.messages import AIMessage
from langchain.tools import tool
from langchain.tools.tool_node import ToolCallRequest
class WeatherInfo(BaseModel):
"""城市天气情况"""
city: str = Field(description="城市名称")
temperature: str = Field(description="气温")
desc: str = Field(description="当日天气概述")
@tool(parse_docstring=True)
def get_weather(city: str):
"""获取当日天气
Args:
city: 城市名称
"""
return f"[{city}] 今天气温9~16度,万里无云,天气不错适合外出"
@before_model(can_jump_to=["tools"]) # ← 要跳转就得先声明能跳到哪
def direct_tool_call(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
last_msg = state["messages"][-1]
if isinstance(last_msg, HumanMessage) and "天气" in last_msg.text and "北京" in last_msg.text:
fake_tool_call = AIMessage( # 人工伪造一条"模型想调工具"的消息
content="人工构造的消息",
tool_calls=[{"name": "get_weather", "args": {"city": "北京"}, "id": "direct_call_id"}],
)
return {"messages": [fake_tool_call], "jump_to": "tools"} # ← 跳过模型,直奔工具节点
return None
@wrap_tool_call
def check_before_tool(request: ToolCallRequest, handler):
print("--- 工具调用前 ---")
print(f"{request.state.get('jump_to', None) = }") # 此刻跳转意图还在
print(f"{request.state.get('structured_response', None) = }")
return handler(request)
@after_agent
def check_after_agent(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print("--- Agent 跑完之后 ---")
for msg in state["messages"]:
print(f" {type(msg).__name__:15s} {str(msg.content)[:50]}")
print(f"{state.get('jump_to', None) = }") # 跳完就清掉了 → None
print(f"{state.get('structured_response', None) = }") # 跑完才写进去 → 非空
return None
agent_state = create_agent(
model=model,
response_format=WeatherInfo, # 开启结构化输出,结果写进 structured_response
middleware=[direct_tool_call, check_before_tool, check_after_agent],
tools=[get_weather],
)
agent_state.invoke({"messages": [HumanMessage("请帮我查询北京当日天气")]})

三、综合题#

  • 3-1 给”带记忆的客服助手”加一条白名单 在”带记忆的客服助手”(内存检查点 + 系统提示词”记住用户告诉你的订单号”)基础上,写一个自己的过滤规则(不是教科书式的裁剪),要求同时满足:

    1. 最早的那条消息永远保留;
    2. 任何提到订单号的消息永远保留;
    3. 其余只保留最近 4 条,中间那些无关内容要真的丢掉(每轮打印过滤前后各有多少条)。

    然后按这个顺序跑:先打个招呼 → 报订单号 → 立刻追问(应该答得上)→ 聊 5 轮无关内容 → 最后再问一次订单号。 再做一个对照实验:把第 2 条规则去掉、只留”最早一条 + 最近 4 条”,重跑一遍,看看最后那次还答不答得上来;解释这个现象,并说明这类关键事实本该存在哪里。

    提示(先自己想,实在想不出再点开)

    一级 · 思路:过滤就是”自己决定返回哪份消息列表”;白名单的做法是——先算出一批要保留的消息,剩下的逐条删掉 二级 · 方法:@before_model 里用 {m.id for m in messages if "订单号" in str(m.content)} 收集白名单,再用 RemoveMessage(id=m.id) 删掉 keep_ids 之外的消息 三级 · 骨架:keep_ids = {messages[0].id} | 白名单 | {m.id for m in messages[-4:]};dropped = [m for m in messages if m.id not in keep_ids]——把”白名单”那一项去掉,就是对照实验

参考答案(做完再点开)
# ========== 综合题答案(模型创建部分同上) ==========
import os
from typing import Any
from dotenv import load_dotenv
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import before_model
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, RemoveMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.runtime import Runtime
load_dotenv(override=True)
model = init_chat_model(
model="deepseek-v4-flash",
model_provider="openai",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL"),
)
# ---------- 3-1 客服助手:给上下文管理加一条"白名单" ----------
KEEP_KEYWORD = "订单号"
@before_model
def filter_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
"""自定义过滤:首条 + 命中关键词的消息永久保留,其余只留最近 4 条"""
messages = state["messages"]
if len(messages) <= 6:
return None
# 注意:system_prompt= 传进去的系统提示并不在 state["messages"] 里,
# 所以这里的 messages[0] 就是最早的那条用户消息
keep_ids = {messages[0].id} # ① 最早的一条
keep_ids |= {m.id for m in messages if KEEP_KEYWORD in str(m.content)} # ② 关键事实白名单
keep_ids |= {m.id for m in messages[-4:]} # ③ 最近 4 条
dropped = [m for m in messages if m.id not in keep_ids]
if not dropped:
return None
print(f" [过滤] {len(messages)} 条 → 保留 {len(keep_ids)} 条、丢掉 {len(dropped)} 条")
# 没进白名单的消息——永久删掉(这里没有内置保护,规则全靠自己写)
return {"messages": [RemoveMessage(id=m.id) for m in dropped]}
SYSTEM = "你是客服助手,记住用户告诉你的订单号等关键信息,并在被问到时准确回答。"
agent_cs = create_agent(
model=model,
system_prompt=SYSTEM,
middleware=[filter_messages],
checkpointer=InMemorySaver(),
)
cfg_cs = {"configurable": {"thread_id": "cs-1"}}
print("[1] 先打个招呼")
agent_cs.invoke({"messages": [HumanMessage("你好,在吗?")]}, config=cfg_cs)
print("[2] 报订单号")
print(" AI:", agent_cs.invoke({"messages": [HumanMessage("我的订单号是 A12345")]}, config=cfg_cs)["messages"][-1].content[:60])
print("[3] 立刻追问(记得)")
print(" AI:", agent_cs.invoke({"messages": [HumanMessage("我的订单号是多少?")]}, config=cfg_cs)["messages"][-1].content[:60])
print("[4] 再聊 5 轮无关内容")
for q in ["今天天气怎么样?", "你们发货快吗?", "支持退货吗?", "有优惠活动吗?", "客服几点下班?"]:
agent_cs.invoke({"messages": [HumanMessage(q)]}, config=cfg_cs)
print("[5] 最后再问一次订单号")
print(" AI:", agent_cs.invoke({"messages": [HumanMessage("我的订单号是多少?")]}, config=cfg_cs)["messages"][-1].content[:80])
# 白名单规则生效 → 订单号还在;把 KEEP_KEYWORD 那条规则去掉再跑一遍,[5] 就答不上来了
# ——这就是"关键事实该进长期记忆"的原因(下一篇)

评论区

[ 标签 ]
# AI37# AI 编程2# AI工具1# AI绘图1# AI辅助开发2# Ajax7# Apifox1# AstrBot3# Astro2# CC Switch1# CDN2# Claude Code1# claudecode2# ClaudeCode1# Cloudflare2# CloudFlare2# CloudFlare-ImgBed3# coc4# CSS10# DeepSeek6# deepseek2# DELETE1# Docker5# EdgeOne3# Gist1# git1# GitHub1# hexo-circle-of-friends1# HTML9# HTTP10# ImageManager1# Java23# java13# JavaScript11# JavaWeb113# JDBC7# JSON2# JUnit1# LangChain25# Linux4# Logback1# Maven17# Muse Spark1# Mybatis1# MyBatis18# MySQL45# MySql1# NapCat1# Node.js1# obsidian2# Obsidian5# OpenCode4# ORM1# PathVariable1# PicGo1# PyCharm1# Python70# RequestBody1# RequestMapping1# RESTful风格1# skills1# Slf4j1# SpringBoot37# SpringMVC5# SQL2# Streamlit5# Svelte2# TailwindCSS1# Telegram3# Tlias2# Vercel1# vscode2# Vue20# Waline3# Web3# WebDAV1# Web基础6# Web开发6# WinSCP1# YAML1# 三层架构1# 中二宣言1# 书籍1# 使用文档10# 写作1# 函数2# 刷步数1# 前端52# 动态1# 动漫1# 包1# 单词2# 博客8# 博客工作流1# 博客开发2# 参数接收1# 友链1# 反思2# 后端48# 图床6# 地图1# 备份2# 大模型1# 奇思妙想1# 存储1# 学习方法6# 学校1# 宝塔面板3# 宝宝10# 实战4# 对象1# 导航栏1# 工具2# 布局2# 开发1# 开发工具1# 开发规范1# 开心1# 异常处理1# 影视2# 微信1# 性能优化2# 总结1# 想法15# 感受1# 感悟11# 指南1# 提示词工程2# 插件5# 故障排除1# 效率工具2# 教程10# 数据分析9# 数据库27# 数据结构1# 文件操作2# 斩神1# 日常95# 日志框架1# 朋友圈1# 朱元璋1# 模块1# 模板1# 正则表达式2# 测试3# 游戏2# 爬虫7# 生活迁移1# 电影2# 电脑1# 碎碎念1# 视觉识别1# 类1# 类型注解1# 网络基础2# 网络教室1# 羊毛2# 脚本2# 脚本工具1# 自动化2# 蓝奏云1# 表单1# 订阅推荐2# 记录2# 评论系统1# 词根1# 词缀1# 说说1# 足迹1# 跑步2# 路径参数1# 转载2# 运动1# 部署11# 部落冲突1# 配置1# 随机图1# 面向对象5# 音乐3# 音标1# 项目实战11# 饮食1# 驼峰命名1# 高德地图1
[ 公告 ]

如果你喜欢,那么欢迎来到我的世界!

了解更多
[ 音乐 ]
封面

音乐

暂未播放

0:000:00
暂无歌词
找不到相关结果。
[ contents ]
[ 全部文章 ]