1. 本章目标 #
第 4 章用手写 history 列表做过多轮对话;第 10 章为 HITL、限流已经用过 checkpointer + thread_id,但那时重点是「暂停与恢复」,不是「怎么设计会话记忆」。
真实客服、办公助理、Copilot 类产品几乎都要回答:
- 用户第二轮问「刚才那个订单呢?」——Agent 能不能接上?
- 两个用户同时聊天——会话会不会串线?
- 聊了 50 轮之后——上下文窗口撑不住怎么办?
- 进程重启 / 换实例——记忆还在不在?
本章要补的能力分三块:
| 块 | 解决什么 | 对应节 |
|---|---|---|
| 持久化 | 多轮状态存哪、怎么恢复 | §3~§5 |
| 隔离 | 谁的历史算谁的 | §4 |
| 控长 | 窗口满了怎么裁 / 怎么摘要 | §6~§7 |
本章目标:
用 checkpointer 与 thread 管理 Agent 短期记忆,并学会裁剪 / 摘要策略,做出带多轮记忆的客服 Agent。
学完你应能:
- 解释短期记忆(thread 内
messages状态)与长期记忆(跨 thread 的store,第 16 章)的区别 - 用
checkpointer+thread_id实现多轮会话(不再手写history) - 用
get_state调试与读取会话快照 - 了解
InMemorySaver与生产持久化 checkpointer 的选型边界 - 用
@before_model裁剪或SummarizationMiddleware控制上下文长度 - 用自定义
AgentState槽位保住姓名、订单号等「不能记错」的字段 - 产出带记忆的客服 Agent
记忆这一层的典型症状是「代码没报错,但产品像失忆」。下面几条是本章实测出来的、和直觉不太一样的结论,先看一眼再往下读:
| 你可能以为 | 实际情况 | 见 |
|---|---|---|
| 每轮重复传整段 history 会导致消息翻倍 | 传消息对象不会(按 id 去重);自己重建成 dict 才会真重复 | §3.2 |
SummarizationMiddleware 装上就会摘要 |
trigger 没达到就完全不工作,示例里常配得太高以致从未触发 |
§7.3 |
| 摘要出来的是中文要点 | 默认 summary_prompt 是英文的,还带 ## ARTIFACTS 这类写给编程 Agent 的字段 |
§7.3 |
系统提示在 messages[0] 里 |
用 system_prompt= 注入时不进 messages,首条通常是第一轮 Human |
§7.1 |
忘传 state_schema 会报错 |
静默丢弃自定义槽位,get_state 里只剩 messages |
§7.4.5 |
| 摘要只是省点 token | 实测连聊 8 轮:不摘要 33 条 vs 摘要 12 条 | §8.3 |
前置依赖: 第 4 章(消息类型)· 第 8 章(工具)· 第 10 章(checkpointer 在 HITL/限流中的用法,可先扫一眼 §5、§7.2)。
参考文档:
2. 短期记忆是什么 #
上一章的客服 Agent 若每次 invoke 都不带 checkpointer,用户第二轮问「刚才那个订单」时,模型看不到第一轮——不是模型笨,是状态没接上。本章要解决的,就是这类「同一场对话内的连续性」。
官方把 Memory 分成两层,不要混:
| 类型 | 范围 | 典型存什么 | 本文 |
|---|---|---|---|
| 短期记忆 | 单个 thread(一条会话线)内 | 对话 messages、中间件计数、HITL 暂停点 |
本章 |
| 长期记忆 | 跨 thread / 跨会话 | 用户偏好、档案、向量知识库 | 第 16 章(官方 long-term memory);文档型知识库另见第 12~15 章 |
用一个客服场景区分两者:
| 用户说 | 需要的记忆类型 | 为什么 |
|---|---|---|
| 「刚才那个 A1002 到哪了?」 | 短期(本 thread 历史) | 上一轮刚查过,在 messages 里 |
| 「我上次退货为什么被拒?」(上周另一场聊天) | 长期(跨 thread / 档案) | 当前 thread 里没有上周对话 |
| 「退换货政策是什么?」 | 外部知识(RAG,第 12 章起) | 不在对话里,在文档 / 向量库 |
短期记忆最常见形态就是 conversation history(对话历史):SystemMessage → HumanMessage → AIMessage → ToolMessage → … 随轮次增长(见第 4 章)。
LangChain Agent 把短期记忆放进图状态里的 messages 键,由 checkpointer 按 thread_id 持久化:
用户每发一条消息 → invoke
│
Agent 从 checkpointer 读出该 thread 已有 state
│
追加本轮 Human / AI / Tool …
│
写回 checkpointer
│
下次同一 thread_id 再 invoke → 自动带上前文三个容易误解的点:
- 模型没有自带「记忆」——是你(或 checkpointer)每次把历史再喂给它。
- 短期记忆 ≠ 向量库——RAG 检索到的文档块是外部知识;
messages是「这场对话里说过什么」。 - 开了 checkpointer 不等于能无限聊——历史仍会撞上下文窗口,需要 §6~§7 的控长策略。
thread_id = "customer-001"
│
├─ invoke 1 → messages: [H, A]
├─ invoke 2 → messages: [H, A, H, A, Tool, A]
└─ invoke 3 → … 持续增长 → 需要 §7 控长读本章时,把 checkpointer 想成「每场对话的记事本」:thread_id 是笔记本编号,messages 是写进去的内容。笔记本可以换(新 thread),纸可以撕(trim / 删除),也可以先誊写摘要再撕原件(Summarization)——但这些都只针对同一场对话。
3. checkpointer:会话状态的存取层 #
checkpointer 是 LangGraph 的检查点存储:每次 Agent 跑完一步(或一轮 invoke),会把当前图状态(至少含 messages)序列化存起来;下次同一 thread_id 再进来,先读再追加。
它和数据库 ORM 的直觉类似:你不必在业务层手写 SELECT history / INSERT message,驾驭层(create_agent)会在每次 invoke 边界替你完成读写。
3.1. 为什么需要 checkpointer #
第 4 章手写 history 时,你要自己:
- 每轮
appendHuman / AI - 存 Redis / 数据库
- 区分不同用户
- HITL 暂停后恢复(第 10 章)
create_agent(..., checkpointer=...) 把这些交给 LangGraph:状态读写 + thread 隔离 成为驾驭层(create_agent)的一等能力。
手写 history |
checkpointer |
|
|---|---|---|
| 多轮追加 | 业务代码维护 | Agent 自动合并 |
| 跨请求 | 自己序列化存储 | checkpointer 负责 |
HITL / thread_limit |
难 | 第 10 章已依赖 |
| 适用 | 单文件脚本 | 客服、助理、生产 Agent |
什么时候仍用手写 history?只有单次脚本、无跨请求、无 HITL,且你完全掌控消息列表时。一旦要 Web 多轮、暂停恢复或限流累计,就应切到 checkpointer。
3.2. 最小多轮示例 #
下面两段 invoke 之间没有传 history 变量——第二轮能答「我是谁」,全靠 checkpointer 在幕后合并状态。读代码时盯住三处:checkpointer=、稳定的 thread_id、每轮只传一条新用户消息。
# 从 langchain.agents 导入创建 Agent 的工厂函数
from langchain.agents import create_agent
# InMemorySaver 是最简单的 checkpointer 实现(存在进程内存里)
from langgraph.checkpoint.memory import InMemorySaver
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 创建 Agent,关键是把 checkpointer 传进去
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 本示例不需要工具
tools=[],
# 系统提示,设定角色
system_prompt="你是简洁的中文客服助手。",
# 有了它,多轮状态才会被存下来
checkpointer=InMemorySaver(),
)
# thread_id 标识一条会话线;两轮用同一个 config 才能接上历史
config = {"configurable": {"thread_id": "customer-001"}}
# 第一轮:用户自报家门
agent.invoke(
# 只传本轮这一句
{"messages": [{"role": "user", "content": "你好,我是小王,订单 A1002 查过了吗?"}]},
# 带上 thread_id
config=config,
)
# 第二轮:只发新问题,不必手动拼 history
result = agent.invoke(
# 注意这里没有任何历史消息
{"messages": [{"role": "user", "content": "我刚才说我是谁?订单号多少?"}]},
# 同一个 config,历史由 checkpointer 自动带上
config=config,
)
# 打印最终回答
print(result["messages"][-1].content)真实运行结果:
您刚才说您是"小王",订单号是"A1002"。请问您具体想处理什么问题呢?用 get_state 看这一刻 checkpoint 里存的东西(get_state 的用法见 §5):
# 读取该 thread 的 checkpoint 快照,看真实存下来的是什么
snapshot = agent.get_state(config)
# snapshot.values 是一个字典,键就是 state 里的字段(至少有 messages)
print("\n----- checkpoint 里的完整轨迹 -----")
# 逐条打印,序号 + 消息类型 + 内容前 80 字
for i, msg in enumerate(snapshot.values["messages"]):
print(i, type(msg).__name__, getattr(msg, "content", "")[:80])条数=4
[0] HumanMessage: '你好,我是小王,订单 A1002 查过了吗?'
[1] AIMessage: '您好,小王。订单 A1002 已经查到了。请问您是想确认物流进度,还是需要处理售后问题呢?'
[2] HumanMessage: '我刚才说我是谁?订单号多少?'
[3] AIMessage: '您刚才说您是"小王",订单号是"A1002"。'两轮 4 条,干净地一问一答。顺便记住一个细节,后面 §7.1 调裁剪规则时会用到:
用
create_agent(system_prompt=...)注入的系统提示,不会出现在messages里。 实测这份messages里没有任何SystemMessage,messages[0]就是第一轮的用户消息。
要点:
checkpointer=InMemorySaver()在创建 Agent 时配置一次- 每次
invoke只传本轮新消息(一个HumanMessage) config["configurable"]["thread_id"]必须稳定,同一会话用同一个 id
3.3 重传整段 #
「每轮重传整段 history」到底会不会重复?
先看把上一轮返回的 messages 原样再传一遍:
# 第一轮:用户自报家门
result = agent.invoke(
# 只传本轮这一句
{
"messages": [
{"role": "user", "content": "你好,我是小王,订单 A1002 查过了吗?"}
]
},
# 带上 thread_id
config=config,
)
# 第二轮:只发新问题,不必手动拼 history
result = agent.invoke(
# 注意这里没有任何历史消息
{
"messages": [
*result["messages"],
{"role": "user", "content": "我刚才说我是谁?订单号多少?"},
]
},
# 同一个 config,历史由 checkpointer 自动带上
config=config,
)
# 打印最终回答
print(result["messages"][-1].content)
# 读取该 thread 的 checkpoint 快照,看真实存下来的是什么
snapshot = agent.get_state(config)
# snapshot.values 是一个字典,键就是 state 里的字段(至少有 messages)
print("\n----- checkpoint 里的完整轨迹 -----")
# 逐条打印,序号 + 消息类型 + 内容前 80 字
for i, msg in enumerate(snapshot.values["messages"]):
print(i, msg.id, type(msg).__name__, getattr(msg, "content", "")[:80])结果没有重复,state 里仍是 4 条:
[0] HumanMessage id=97fb573e-...: '我叫小王。'
[1] AIMessage id=lc_run--019ffa52-...: '你好,小王!很高兴认识你。'
[2] HumanMessage id=456dfd51-...: '我叫什么?'
[3] AIMessage id=lc_run--019ffa52-...: '你刚才告诉我你叫小王...'原因是 messages 用的 add_messages reducer 会按消息 id 去重:已经存在的 id 视为「更新同一条」,而不是新增。上一轮返回的对象都带着 id,所以重传等于无操作。
真正会翻倍的是另一种写法——业务代码自己维护一个 dict 列表当历史。dict 里没有 id,框架只能当成新消息:
# 从 langchain.agents 导入 create Agent 的工厂函数
from langchain.agents import create_agent
# InMemorySaver 是最简单的 checkpointer 实现(存在进程内存里)
from langgraph.checkpoint.memory import InMemorySaver
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 创建 Agent,关键是把 checkpointer 传进去
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 本示例不需要工具
tools=[],
# 系统提示,设定角色
system_prompt="你是简洁的中文客服助手。",
# 有了它,多轮状态才会被存下来
checkpointer=InMemorySaver(),
)
# thread_id 标识一条会话线;两轮用同一个 config 才能接上历史
config = {"configurable": {"thread_id": "customer-dup-demo"}}
# 第一轮:先正常跑一轮,拿到助手回复
r = agent.invoke(
# 只传本轮这一句
{"messages": [{"role": "user", "content": "我叫小王。"}]},
# 带上 thread_id
config=config,
)
# 危险写法:手工把历史重建成 dict(没有 id)
manual_history = [
# 复述用户上一句
{"role": "user", "content": "我叫小王。"},
# 复述助手上一句
{"role": "assistant", "content": r["messages"][-1].content},
]
# 把重建的历史 + 新问题一起传进去
result = agent.invoke(
# 前两条没有 id,框架只能当新消息处理
{"messages": [*manual_history, {"role": "user", "content": "我叫什么?"}]},
# 同一条会话线
config=config,
)
# 打印最终回答
print(result["messages"][-1].content)
# 读取该 thread 的 checkpoint 快照,观察消息是否翻倍
snapshot = agent.get_state(config)
print("\n----- checkpoint 里的完整轨迹(可见翻倍) -----")
# 逐条打印,序号 + id + 消息类型 + 内容前 80 字
for i, msg in enumerate(snapshot.values["messages"]):
print(i, msg.id, type(msg).__name__, getattr(msg, "content", "")[:80])
这次真重复了,4 条变 6 条,第 0/2 条内容一模一样但 id 不同:
您叫小王。请问有什么可以帮您的吗?
----- checkpoint 里的完整轨迹(可见翻倍) -----
0 1207d903-795b-4c7d-8910-5525b49b38e3 HumanMessage 我叫小王。
1 lc_run--01a023df-4bdb-7ac2-98fb-3f1df5ea1c17-0 AIMessage 您好,小王!很高兴为您服务。请问有什么可以帮您的吗?
2 fa181873-a747-45e0-a27c-e45839eb6130 HumanMessage 我叫小王。
3 c480cd33-fbab-4939-8513-8e5e3e2be633 AIMessage 您好,小王!很高兴为您服务。请问有什么可以帮您的吗?
4 fc2e0884-60d7-4807-a770-4f377467dc3b HumanMessage 我叫什么?
5 lc_run--01a023df-503e-70d3-a34b-0cb013198922-0 AIMessage 您叫小王。请问有什么可以帮您的吗?小结成一句话:
| 你传的东西 | 结果 |
|---|---|
| 只传本轮新消息(推荐) | 正常追加 |
| 重传上一轮返回的消息对象 | 按 id 去重,不会重复 |
| 自己重建的 dict 历史 | 真重复,token 翻倍、模型看到自己说过两次 |
所以「只传新消息」仍是应当遵守的规范——既为避免第三种情况,也因为传一句话比传整段历史省带宽、更好排查。若你在别处看到「重传必然翻倍」,要知道它取决于消息有没有 id。
一次 invoke 在记忆视角下的数据流:
config.thread_id = "customer-001"
│
▼
checkpointer.get("customer-001") → 已有 messages(可能为空)
│
▼
追加本轮 HumanMessage → Agent 运行 → 产生 AI / Tool …
│
▼
checkpointer.put("customer-001", 更新后的 state)
│
▼
返回 result(通常含完整 messages,含历史)3.4. InMemorySaver 是什么 #
测试时首选 InMemorySaver():零配置、无外部依赖,专注验证「多轮逻辑对不对」。
| 属性 | 说明 |
|---|---|
| 存储位置 | 当前 Python 进程内存 |
| 依赖 | 无数据库 |
| 进程重启 | 状态丢失 |
| 多实例部署 | 各进程内存不共享,不能做负载均衡下的统一会话 |
和「把 history 放 Python 全局变量」的区别: 全局 dict 也能多轮,但和 LangGraph 的 interrupt / resume、middleware 计数、官方 get_state 不对齐;走 checkpointer 才是驾驭层(create_agent)的完整路径。
3.5. 生产 checkpointer 怎么选 #
从开发到上线,通常只换 checkpointer 实现,Agent 代码(tools、middleware、system_prompt)尽量不动;thread_id 生成规则也保持不变。
| 实现 | 安装 | 适用 |
|---|---|---|
| PostgresSaver | pip install -U langgraph-checkpoint-postgres "psycopg[binary]" |
生产默认推荐 |
| SQLite(同步/异步包) | 见 Checkpointer libraries | 单机、边缘、集成测试 |
| MongoDB 等 | 对应 langgraph-checkpoint-* 包 |
已有文档库栈时 |
Postgres 最小形态(需本地已有 PostgreSQL;表结构由 setup() 创建):
# 导入 os 模块,用于读取环境变量
import os
# 从 langchain 导入创建 Agent 的工厂函数
from langchain.agents import create_agent
# 导入基于 PostgreSQL 的检查点存储实现
from langgraph.checkpoint.postgres import PostgresSaver
# 导入 dotenv,用于从 .env 文件加载 API Key 等配置
from dotenv import load_dotenv
# 加载 .env 中的环境变量,override=True 表示覆盖已存在的同名变量
load_dotenv(override=True)
# 数据库连接串,优先读取 .env 里的 POSTGRES_URI
# sslmode=disable 表示本机连接不启用 SSL
# connect_timeout=5 表示 5 秒连不上就报错,避免无响应
DB_URI = os.getenv(
"POSTGRES_URI",
"postgresql://postgres:postgres@localhost:5432/langrag"
"?sslmode=disable&connect_timeout=5",
)
# PostgresSaver 是上下文管理器,with 块结束时会自动归还数据库连接
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
# 首次运行会在数据库中创建 checkpoint 相关的表
# 这个方法可以重复调用,已存在的表不会被重建
checkpointer.setup()
# 创建 Agent,把 checkpointer 传进去即可获得持久化记忆
agent = create_agent(
# 使用的模型,与本文其他章节保持一致
model="deepseek:deepseek-v4-flash",
# 本示例不需要工具,传空列表
tools=[],
# 系统提示,设定助手的角色
system_prompt="你是简洁的中文客服助手。",
# 关键:用 PostgresSaver 替代 InMemorySaver
checkpointer=checkpointer,
)
# thread_id 标识一条会话线,同一个 id 的多次调用共享历史
config = {"configurable": {"thread_id": "customer-001"}}
# 第一轮:用户提供自己的信息
agent.invoke(
{
"messages": [
{"role": "user", "content": "你好,我是小王,我的订单号是 A1002。"}
]
},
config=config,
)
# 第二轮:只发新问题,不需要手动拼接历史
# 历史消息由 checkpointer 从数据库中读出并自动合并
result = agent.invoke(
{"messages": [{"role": "user", "content": "我刚才说我是谁?订单号多少?"}]},
config=config,
)
# 打印最后一条消息的内容,应该能正确复述姓名和订单号
print(result["messages"][-1].content)
psql -U postgres -d postgres
\l
\c langrag
\dt
\d checkpoints
SELECT * from checkpoints;| 场景 | 推荐 |
|---|---|
| 本地 / CI | InMemorySaver |
| 单机小规模上线 | SQLite 类 checkpointer |
| 多实例 + 要 HITL 恢复 | Postgres / 云数据库 |
| 已有 Mongo 栈 | 对应 langgraph-checkpoint-mongodb 等 |
选型口诀:
开发时 用
InMemorySaver;生产用 DB checkpointer;thread_id由业务层生成(用户 id + 会话 id)。
4. thread_id:会话线程与隔离 #
Thread(线程) 在 LangGraph 里 ≈ 邮箱里的一串往来邮件:同一 thread 内消息有序、状态连续;不同 thread 互不干扰。
Web 产品里可以这样映射:
| 产品动作 | thread_id 策略 |
|---|---|
| 用户打开「新对话」 | 生成新 uuid,旧 thread 只读归档 |
| 刷新页面继续聊 | 前端 localStorage / 服务端 session 带回同一 id |
| 工单系统 | 一个工单一个 thread,关单后不再写入 |
| 匿名访客 | guest-{uuid},登录后可选择「合并」到 user-{id}(进阶,需业务层迁移 state) |
用户 A session-aaa ──► thread_id = "userA:2026-08-12"
用户 B session-bbb ──► thread_id = "userB:2026-08-12"4.1. 怎么设计 thread_id #
thread_id 是字符串,框架不解析其内部结构——user-42:chat-9f3a 和 abc 对 LangGraph 等价,只要唯一且稳定。设计时想三问:
| 做法 | 示例 | 说明 |
|---|---|---|
| 每用户一条长期线 | f"user-{user_id}" |
简单;所有历史进同一线程,需控长(§6) |
| 每「打开客服窗口」一条 | f"user-{user_id}:chat-{uuid}" |
用户点「新对话」就换新 thread |
| 与业务单绑定 | f"ticket-{ticket_id}" |
工单闭环内记忆不串 |
- 谁在使用?(用户 id / 访客 id)
- 哪一段对话算一场?(窗口 / 工单 / 永久一条线)
- 要不要允许「清空记忆」?(新 thread vs 删 messages)
不要把 thread_id 设成常量 "1" 上线——所有用户会共享同一段 messages。本文用固定 id 可以,接口层必须换成业务 id。
4.2. 隔离演示 #
下面用「秘密数字」做对照实验:同一 Agent、同一模型,只改 thread_id,答案应不同。这是验收记忆是否串线最快的方法之一。
# 从 langchain.agents 导入创建 Agent 的工厂函数
from langchain.agents import create_agent
# 内存版 checkpointer
from langgraph.checkpoint.memory import InMemorySaver
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 注意:只创建了一个 Agent 实例,两个线程共用它
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 不需要工具
tools=[],
# 提示模型记住用户说的数字
system_prompt="你是助手。用户说秘密数字时记住,被问到就回答。",
# 记忆的开关
checkpointer=InMemorySaver(),
)
# 线程 1:秘密 42
agent.invoke(
# 告诉它一个数字
{"messages": [{"role": "user", "content": "记住,我的秘密数字是 42。"}]},
# 写入 thread-alice 这条线
config={"configurable": {"thread_id": "thread-alice"}},
)
# 在同一条线上追问,应该答得出来
r1 = agent.invoke(
{"messages": [{"role": "user", "content": "我的秘密数字是几?"}]},
# 仍然是 thread-alice
config={"configurable": {"thread_id": "thread-alice"}},
)
# 线程 2:从未说过 42
r2 = agent.invoke(
# 问同样的问题
{"messages": [{"role": "user", "content": "我的秘密数字是几?"}]},
# 换成 thread-bob,这条线是全新的
config={"configurable": {"thread_id": "thread-bob"}},
)
# 对照两个线程的回答
print("Alice:", r1["messages"][-1].content)
print("Bob:", r2["messages"][-1].content)预期:Alice 能答 42(或引用前文);Bob 应表示不知道——证明 checkpoint 按 thread 隔离。
注意这里只有一个 Agent 对象,隔离完全由 thread_id 决定。Web 服务里全局共享一个 Agent 实例是正常做法,用户之间不会串线的前提是每个请求带对的 thread_id。
若 Bob 也答 42,优先查:thread_id 是否写错、是否忘了 checkpointer、是否误把 history 写在 Agent 外全局变量里。
4.3. 与第 9 章 context 的分工 #
初学者常把 context(第 9 章 invoke(..., context=OfficeContext(...)))和 thread_id 混为一谈。记一句:
| 机制 | 传参方式 | 生命周期 | 典型用途 |
|---|---|---|---|
thread_id + checkpointer |
config={"configurable": {...}} |
持久在 thread 内 | 对话历史、HITL 状态 |
context + context_schema |
invoke(..., context=...) |
单次 invoke | 当前用户角色、部门(第 9 章) |
两者可并存:context 放「这次请求的角色 / 租户」;thread_id 放「这场对话说过什么」。
反模式: 把整段对话历史塞进 context 每轮重传——等于放弃 checkpointer,且 context_schema 并非为无限 messages 设计。历史应走 messages + checkpointer。
5. 读取状态:get_state #
「Agent 失忆了」是集成时最高频的 bug 之一。排障别猜,用 agent.get_state(config) 看 checkpoint 里真实存了什么——比 print(result["messages"][-1]) 可靠,因为后者只是本次返回值,不一定等于持久化后的全貌(尤其 HITL 暂停时)。
5.1 get_state #
# 从 langchain.agents 导入创建 Agent 的工厂函数
from langchain.agents import create_agent
# 内存版 checkpointer
from langgraph.checkpoint.memory import InMemorySaver
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 创建带记忆的 Agent
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 不需要工具
tools=[],
# 让模型配合记数字
system_prompt="你是助手。用户说秘密数字时记住,被问到就回答。",
# 记忆开关
checkpointer=InMemorySaver(),
)
# thread_id 标识一条会话线,两轮共用同一个 config 才能接上历史
config = {"configurable": {"thread_id": "demo-001"}}
# 第一轮:把秘密数字告诉它
agent.invoke(
{"messages": [{"role": "user", "content": "我的秘密数字是 42,记住它。"}]},
config=config,
)
# 第二轮:只发新问题,历史由 checkpointer 自动合并
result = agent.invoke(
{"messages": [{"role": "user", "content": "我的秘密数字是多少?"}]},
config=config,
)
# 这是给用户看的那一句
print("最后一条回复:", result["messages"][-1].content)
# 读取该 thread 的 checkpoint 快照,看真实存下来的是什么
snapshot = agent.get_state(config)
# snapshot.values 是一个字典,键就是 state 里的字段(至少有 messages)
print("\n----- checkpoint 里的完整轨迹 -----")
# 逐条打印,序号 + 消息类型 + 内容前 80 字
for i, msg in enumerate(snapshot.values["messages"]):
print(i, type(msg).__name__, getattr(msg, "content", "")[:80])
# 条数应随轮次增长;不涨说明 checkpointer 或 thread_id 有问题
print("\nmessages 条数:", len(snapshot.values["messages"]))
# next 非空说明图没跑完(例如 HITL 停在 interrupt),此时不能当最终结果验收
print("snapshot.next:", snapshot.next)正常跑完时 snapshot.next 是空元组:
messages 条数: 4
snapshot.next: ()snapshot 上常用的三样东西:
| 方法 / 属性 | 作用 |
|---|---|
agent.get_state(config) |
读取该 thread_id 当前快照 |
snapshot.values |
状态字典;["messages"] 是完整轨迹(含 ToolMessage),自定义槽位也在这里(§7.4) |
snapshot.next |
图停在哪些待执行节点;空元组 = 已跑完 |
snapshot.next 非空时,说明图还没跑完(例如第 10 章 HITL 在等 Command(resume=...))。此时不要当作「最终回答」验收记忆。
5.2 get_state_history #
还有两个进阶方法,排查「历史被谁改坏了」时很有用:
| 方法 | 作用 |
|---|---|
agent.get_state_history(config) |
倒序遍历这条 thread 的历次 checkpoint,能看出某一轮之后 messages 是怎么变的 |
from langgraph.types import StateSnapshot
# 取得该 thread 的历史快照(每步一个 state),通常只有最近几步
history = list[StateSnapshot](
reversed(list(agent.get_state_history(config)))
) # 明确将 generator 转为 list
print("\n历史快照数:", len(history))
for idx, snap in enumerate[StateSnapshot](history, start=1):
print(f"\n=== Step {idx} ===")
msgs = snap.values["messages"]
for i, msg in enumerate(msgs):
print(f" [{i}] {type(msg).__name__}: {getattr(msg, 'content', '')[:80]}")
5.3 update_state #
| 方法 | 作用 |
|---|---|
agent.update_state(config, values) |
手工改写状态(比如客服后台强行修正一个槽位值),慎用 |
# 取得当前 state
snapshot = agent.get_state(config)
print("\n--- 手工改写前 ---")
for i, msg in enumerate(snapshot.values["messages"]):
print(i, type(msg).__name__, getattr(msg, "content", "")[:80])
# 假设要手动修正上一条 AI 回复,改成 "你的秘密数字是 100。"
messages = list(snapshot.values["messages"])
# 找到最后一条 AI 回复,将其内容替换
from langchain_core.messages.ai import AIMessage
for i in range(len(messages) - 1, -1, -1):
if isinstance(messages[i], AIMessage):
# 构建一个内容被修正的 AIMessage,保留其它字段(如 id)
new_msg = AIMessage(
content="你的秘密数字是 100。", id=getattr(messages[i], "id", None)
)
messages[i] = new_msg
break
# 用 update_state 覆盖写回去(只传有更改的字段即可)
agent.update_state(config, {"messages": messages})
# 再读回 state,确认已生效
snapshot2 = agent.get_state(config)
print("\n--- 手工改写后 ---")
for i, msg in enumerate(snapshot2.values["messages"]):
print(i, type(msg).__name__, getattr(msg, "content", "")[:80])5.4 和 result["messages"] 的关系 #
| 时机 | 优先看什么 |
|---|---|
| 正常跑完一轮 | result["messages"][-1] 给用户看;get_state 核对条数 |
| 怀疑重复 / 丢失 | get_state 逐条打印类型与 content 前缀 |
| HITL 暂停 | get_state + version="v2" 的 interrupts(第 10 章 §7.2) |
验收多轮记忆时建议固定三步:
get_state看 messages 条数是否随轮次增长- 新开
thread_id对照,确认没有串线 - 问「上一轮提到的 X」验证模型读到的历史是否足够
若第 1 步条数不涨:checkpointer 或 thread_id 有问题。
若条数涨但第 3 步答错:可能是控长裁掉了关键消息(见 §6~§7),或模型本身未利用历史。
6. 上下文窗口:为什么「能记住」还不够 #
checkpointer 解决存得下、接得上;上下文窗口(context window) 解决模型一次能读多少。两者独立——你可以把 200 条 messages 存进 Postgres,但模型可能只能有效利用最近几万 token。
对话越长,messages 越多,会遇到:
| 问题 | 后果 |
|---|---|
| 超过模型 context window | 报错或截断 |
| 未超窗但过长 | 变慢、变贵、易被旧内容干扰 |
| 含大量 ToolMessage | token 膨胀(查单返回整段 JSON 等) |
Token 从哪来(直觉): 每条 HumanMessage / AIMessage / ToolMessage 都占 token;工具返回一大段 JSON 时,往往比用户一句「查 A1002」贵一个数量级。长会话控长时,有时先缩短 tool 返回比删用户闲聊更有效(第 8 章工具设计 + 本章 middleware)。
何时该上控长(经验值,按模型调整):
| 信号 | 建议 |
|---|---|
| messages 条数 > 30~50 | 评估 Summarization 或 trim |
| 单次 invoke 明显变慢 / 变贵 | 查 Trace 里 input token |
| 模型开始「忘记」早期约定 | 摘要或自定义 state 槽位 |
| 尚未生产上线 | 先 get_state 看长度,再定 trigger |
官方常见策略,本章四种都会动手实现,最后一列是对应小节:
| 策略 | 思路 | 本章 |
|---|---|---|
| Trim(裁剪) | 只保留最近 N 条 / N token | §7.1 |
| Delete(删除) | 用 RemoveMessage 从 state 永久删掉 |
§7.2 |
| Summarize(摘要) | 旧对话压成摘要消息 | §7.3 |
| 自定义 state | 关键字段单独开槽位,不参与压缩 | §7.4 |
原则:
先保证 thread 记忆正确,再谈裁剪;裁剪要比「整段丢失」更可控。
7. 控长实战 #
控长 middleware 改的是写入 checkpointer 的状态:裁掉或摘要后的 messages 会持久化,下一轮 invoke 读到的就是缩短版历史。因此要在丢信息与省 token 之间做有意识的权衡。
三种策略怎么选:
还要精确记得很久以前的原话?
├─ 否 → trim(裁剪,§7.1)或 delete(删除,§7.2)
└─ 是 → Summarization(摘要,§7.3)压大意
+ 自定义 state 槽位(§7.4)保精确字段
工具返回特别长?
└─ 优先缩短 tool(工具)输出 + 适当 trim(裁剪)7.1. @before_model:调模型前裁剪 #
@before_model 在每次模型调用之前执行(第 10 章 middleware 时序里:PII / 限流之后、进模型之前)。它读到的 state["messages"] 是 checkpoint 当前完整列表;返回的 dict 会合并回 state,trim 既影响本次模型输入,也影响之后持久化的历史。
下面示例:保留第一条(通常是 system 或首条锚点消息)+ 最近 4 条,避免窗口爆炸。
# Any 用于标注返回的 dict 值类型
from typing import Any
# create_agent 与 AgentState(state 的类型标注)
from langchain.agents import create_agent, AgentState
# before_model 装饰器:把普通函数变成「调模型前执行」的中间件
from langchain.agents.middleware import before_model
# RemoveMessage 用于从 state 里删消息
from langchain.messages import RemoveMessage
# 内存版 checkpointer
from langgraph.checkpoint.memory import InMemorySaver
# REMOVE_ALL_MESSAGES 是一个特殊常量,表示「清空全部」
from langgraph.graph.message import REMOVE_ALL_MESSAGES
# Runtime 是中间件第二个参数的类型
from langgraph.runtime import Runtime
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 装饰器把函数注册成「每次调模型前」执行的中间件
@before_model
def trim_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
"""保留首条 + 最近 4 条消息,防止上下文过长。"""
# 从 state 里取出当前完整消息列表
messages = state["messages"]
# 还不够长就什么都不做
if len(messages) <= 5:
return None # 不改动
# 保留第一条作为锚点(这里是第一轮用户消息)
first_msg = messages[0]
# 保留最近 4 条,维持对话连贯
recent = messages[-4:]
# 返回的 dict 会合并进 state
return {
"messages": [
# 先清空整个列表
RemoveMessage(id=REMOVE_ALL_MESSAGES),
# 再写回想保留的那些
first_msg,
# * 展开最近 4 条
*recent,
]
}
# 创建 Agent 并挂上裁剪中间件
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 不需要工具,避免裁剪时切断 tool_calls 配对(见 §7.2)
tools=[],
# 系统提示
system_prompt="你是客服助手,回答尽量简短。",
# 把上面的中间件挂上
middleware=[trim_messages],
# 记忆开关
checkpointer=InMemorySaver(),
)
# 固定一条会话线
config = {"configurable": {"thread_id": "trim-demo"}}
# 连续说四句,把历史撑长
for user in [
"我叫小陈。",
"帮我查订单 A1001。",
"再查 A1002。",
"我刚才叫什么?",
]:
# 每轮只传新消息
agent.invoke({"messages": [{"role": "user", "content": user}]}, config=config)
# 第五轮:此时历史已被裁剪过
result = agent.invoke(
{"messages": [{"role": "user", "content": "总结一下我们聊过什么。"}]},
config=config,
)
# 打印最终回答
print(result["messages"][-1].content)在中间件里加一行 print(len(messages)) 就能看到裁剪的节奏(实测):
[trim] 进来时 1 条 ← 第 1 轮,不裁
[trim] 进来时 3 条 ← 第 2 轮,不裁
[trim] 进来时 5 条 ← 第 3 轮,刚好等于 5,不裁
[trim] 进来时 7 条 → 裁到 5 条 ← 第 4 轮开始裁
[trim] 进来时 7 条 → 裁到 5 条 ← 第 5 轮注意最后的稳定状态是 6 条而不是 5 条:裁剪发生在调模型之前,裁到 5 条后模型又追加了一条回答。所以「裁到 N 条」的实际观测值通常是 N+1。
注意:
- 返回
None表示不修改 state RemoveMessage(id=REMOVE_ALL_MESSAGES)先清空再写入新列表,是 LangGraphadd_messagesreducer 下的合法批量替换方式- 裁剪是写回 checkpoint 的,不只影响本次模型输入——下一轮读到的就是缩短版历史,被裁掉的内容再也找不回来
「保留首条」在这个例子里帮了大忙
裁剪之后再问「我叫什么名字?」,实测模型答对了:
您叫小陈。这看起来和「裁剪会丢信息」矛盾,其实是因为姓名恰好是第一条消息,被 first_msg 保住了。看一眼裁剪后的 state 就明白:
[0] HumanMessage: '我叫小陈。' ← 靠「保留首条」活下来
[1] AIMessage: '好的,小陈。查询 A1002 同样需要...'
[2] HumanMessage: '我刚才叫什么?'
[3] AIMessage: '您刚才告诉我您叫小陈。'
[4] HumanMessage: '总结一下我们聊过什么。'
[5] AIMessage: '我们聊过:您自我介绍叫小陈...'这带来两个实用结论:
- 「保留首条」是个廉价又有效的技巧,前提是重要信息真的出现在第一条。用户习惯开场自我介绍时,这个假设常常成立。
- 但它非常脆弱。 如果用户先寒暄两句才说姓名,或者中途才给出收货地址,首条就锚不住关键信息了。想稳定保住这类字段,得用 §7.4 的自定义槽位。
关于「首条」到底是什么,前面 §3.2 已经实测过:用 create_agent(system_prompt=...) 注入的系统提示不在 messages 里,所以 messages[0] 就是第一轮的用户消息。如果你是自己往 messages 里塞了 SystemMessage,那首条才是它。调 trim 规则之前,先用 get_state 把列表打印出来看清楚,别凭想象写下标。
7.2. RemoveMessage 与删除策略 #
LangGraph 的 messages 使用 add_messages reducer:普通 append 是追加;
RemoveMessage 则是「从 state 里删掉指定 id」或「清空再写」。
这是永久改变 checkpoint 里的历史,不是只影响单次模型输入。
| API | 作用 |
|---|---|
RemoveMessage(id=msg.id) |
删除指定消息 |
RemoveMessage(id=REMOVE_ALL_MESSAGES) |
清空全部 messages 再追加(常与 trim 联用) |
删除时必须保证剩余 history 合法,否则下一跳模型调用会报错:
- 多数供应商要求 history 以 user 消息开头(system 另算)
- 带
tool_calls的AIMessage后面必须跟对应ToolMessage(第 4 章)
第二条是实践中最容易踩的:messages[-4:] 这种「按条数切」的写法,完全可能刚好切在 AIMessage(tool_calls=...) 和它的 ToolMessage 之间,保留下来的历史里就有一个「发起了工具调用却没有结果」的悬空调用。这类错误在第 10 章 §8 见过同款报错:
openai.BadRequestError: An assistant message with 'tool_calls' must be followed by
tool messages responding to each 'tool_call_id'.注意 §7.1 的示例是 tools=[],压根没有 ToolMessage,所以按条数切很安全。一旦 Agent 带了工具,就必须改成按对切:
# 一个更安全的裁剪:从目标位置往前找,直到落在「安全边界」上
def safe_slice(messages: list, keep: int) -> list:
"""返回最近 keep 条,但保证不切断 tool_calls 与 ToolMessage 的配对。"""
# 先按条数取一个初始起点
start = max(len(messages) - keep, 0)
# 若起点正好是 ToolMessage,说明它的发起者被切掉了,往前挪
while start > 0 and type(messages[start]).__name__ == "ToolMessage":
# 往前一条,直到把配对的 AIMessage 一起包含进来
start -= 1
# 返回安全的切片
return messages[start:]因此生产上更常见的是:trim 保留成对片段,而不是随机删中间几条。
合法 history 小抄(删/裁后自检):
✓ Human → AI → Human → AI
✓ Human → AI(tool_calls) → Tool → AI
✗ AI(tool_calls) 后面没有 Tool
✗ 只剩 Tool 开头7.3. SummarizationMiddleware:摘要代替硬删 #
硬删旧消息会丢信息;摘要在触发阈值时调用另一个模型,把远期对话压成一条(或若干条)摘要消息,再与 keep 指定的近期原文拼接。对用户来说,相当于「早期的细节模糊了,但大意还在」——比 trim 更适合长客服会话。
触发后大致流程:
messages 超长 → 达到 trigger
│
Summarization 模型读旧消息 → 生成摘要 Human/AI 对
│
state 里旧消息被替换/压缩,保留 keep 条近期原文
│
主 Agent 继续用缩短后的 history 调模型先说一个很容易上当的地方
# Any 用于标注返回的 dict 值类型
from typing import Any
# create_agent 与 AgentState(state 的类型标注)
from langchain.agents import create_agent, AgentState
# before_model 装饰器:把普通函数变成「调模型前执行」的中间件
from langchain.agents.middleware import before_model, SummarizationMiddleware
# RemoveMessage 用于从 state 里删消息
from langchain.messages import RemoveMessage
# 内存版 checkpointer
from langgraph.checkpoint.memory import InMemorySaver
# REMOVE_ALL_MESSAGES 是一个特殊常量,表示「清空全部」
from langgraph.graph.message import REMOVE_ALL_MESSAGES
# Runtime 是中间件第二个参数的类型
from langgraph.runtime import Runtime
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 装饰器把函数注册成「每次调模型前」执行的中间件
@before_model
def trim_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
"""保留首条 + 最近 4 条消息,防止上下文过长。"""
# 从 state 里取出当前完整消息列表
messages = state["messages"]
# 还不够长就什么都不做
if len(messages) <= 5:
return None # 不改动
# 保留第一条作为锚点(这里是第一轮用户消息)
first_msg = messages[0]
# 保留最近 4 条,维持对话连贯
recent = messages[-4:]
# 返回的 dict 会合并进 state
return {
"messages": [
# 先清空整个列表
RemoveMessage(id=REMOVE_ALL_MESSAGES),
# 再写回想保留的那些
first_msg,
# * 展开最近 4 条
*recent,
]
}
# 反例:装了 SummarizationMiddleware,但根本不会触发
agent = create_agent(
# 主对话模型
model="deepseek:deepseek-v4-flash",
# 不需要工具
tools=[],
middleware=[
# 挂上摘要中间件
SummarizationMiddleware(
# 摘要模型
model="deepseek:deepseek-v4-flash",
# 4000 token 才触发 —— 问题就出在这一行
trigger=("tokens", 4000),
# 保留最近 20 条
keep=("messages", 20),
),
],
# 记忆开关
checkpointer=InMemorySaver(),
# 系统提示
system_prompt="你是客服助手。",
)
# 固定一条会话线
config = {"configurable": {"thread_id": "summary-demo"}}
# 三轮短对话,加起来不过一百来 token
agent.invoke({"messages": [{"role": "user", "content": "我叫小周。"}]}, config=config)
# 第二轮
agent.invoke({"messages": [{"role": "user", "content": "我喜欢猫。"}]}, config=config)
# 第三轮:让它复述前两轮的信息
result = agent.invoke(
{"messages": [{"role": "user", "content": "我叫什么?喜欢什么?"}]}, config=config
)
# 打印回答 —— 看起来完全正常,所以特别容易误判
print(result["messages"][-1].content)跑起来回答完全正确:
您叫小周,喜欢猫。对吗?😊于是很容易得出「摘要生效了」的结论。但看一眼 state 就露馅了:
条数=6
[0] HumanMessage: '我叫小周。'
[1] AIMessage: '您好,小周!很高兴认识您...'
[2] HumanMessage: '我喜欢猫。'
[3] AIMessage: '哈哈,喜欢猫的人通常都很有爱心呢...'
[4] HumanMessage: '我叫什么?喜欢什么?'
[5] AIMessage: '您叫小周,喜欢猫。对吗?'6 条原文,一条摘要都没有。 三轮短对话离 4000 token 还差得远,中间件从头到尾没干活;回答正确,纯粹是因为普通多轮记忆就够用了。
教训:验收摘要不能看回答对不对,要看
get_state里有没有出现摘要消息、条数有没有掉下来。 阈值配得比对话总长还高,中间件等于没装。
让它真的触发
想在演示里看到效果,把阈值压到几条消息就行。下面用 trigger=("messages", 6)、keep=("messages", 2):
# 从 langchain.agents 导入创建 Agent 的工厂函数
from langchain.agents import create_agent
# 内置的摘要中间件
from langchain.agents.middleware import SummarizationMiddleware
# 内存版 checkpointer
from langgraph.checkpoint.memory import InMemorySaver
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 创建带摘要能力的 Agent
agent = create_agent(
# 主对话模型
model="deepseek:deepseek-v4-flash",
# 本示例不需要工具
tools=[],
middleware=[
SummarizationMiddleware(
# 做摘要的模型,可以换成更便宜的
model="deepseek:deepseek-v4-flash",
# 演示用的极低阈值:messages 到 6 条就摘要
trigger=("messages", 6),
# 摘要后只保留最近 2 条原文
keep=("messages", 2),
),
],
# 摘要结果同样要落到 checkpoint 里
checkpointer=InMemorySaver(),
# 系统提示
system_prompt="你是客服助手,回答简短。",
)
# 固定一条会话线
config = {"configurable": {"thread_id": "summary-force"}}
# 连说四句,把条数推过阈值
for t in ["我叫小周。", "我喜欢猫。", "我住在杭州。", "我的订单是 A1002。"]:
# 每轮只传新消息
agent.invoke({"messages": [{"role": "user", "content": t}]}, config=config)
# 每轮打印条数,观察什么时候掉下来
print(f"说完「{t}」后条数: {len(agent.get_state(config).values['messages'])}")实测输出,注意第四轮条数从 6 掉回 4——这就是摘要触发的信号:
说完「我叫小周。」后条数: 2
说完「我喜欢猫。」后条数: 4
说完「我住在杭州。」后条数: 6
说完「我的订单是 A1002。」后条数: 4 ← 触发了摘要长什么样:默认提示词是英文的
触发之后打印 state,会看到一条很长的消息取代了前面的原文:
[0] HumanMessage: 'Here is a summary of the conversation to date:...' ← 摘要
[1] AIMessage: '杭州是个好地方呢,环境很美!🌿...'
[2] HumanMessage: '我的订单是 A1002。'
[3] AIMessage: '好的小周,已记住您的订单号 A1002 😊...'把那条摘要完整打出来,就是本节最值得知道的一个坑:
Here is a summary of the conversation to date:
## SESSION INTENT
The user is in an initial customer-service greeting phase and has not yet stated a
specific request or problem. The overall task is to identify what the user needs help
with; no concrete goal has been defined yet.
## SUMMARY
- The user's name is 小周 (Xiao Zhou).
- The user likes cats.
- The user lives in Hangzhou (杭州).
- The assistant greeted the user and asked how they could help...
## ARTIFACTS
None.
## NEXT STEPS
- Acknowledge the user's shared details (name, cats, Hangzhou) to build rapport.
- Prompt the user again to state what they need help with...三个要点:
- 摘要是英文的。 对话全程中文,摘要却是英文——因为
SummarizationMiddleware的默认summary_prompt是英文写的。这不会让程序报错,但会让后续每一轮都多带一段英文进上下文,中文模型读英文摘要也可能引入措辞偏差。 - 默认提示词是给编程 Agent 设计的。 看
## ARTIFACTS(产出的文件)和## NEXT STEPS(下一步做什么)——这是「让编码助手在超长会话里接力」的模板。客服场景里ARTIFACTS永远是None,纯属浪费 token。 - 摘要以
HumanMessage的形式插入,不是SystemMessage。也就是说模型会认为「用户告诉了我这段总结」。
换成中文摘要提示词
解决办法是显式传 summary_prompt。提示词里必须包含 {messages} 占位符,中间件会把待压缩的历史填进去:
# 自定义中文摘要提示词,{messages} 是必须保留的占位符
CN_SUMMARY_PROMPT = """请把下面的客服对话历史压缩成中文要点,供后续对话继续使用。
要求:
1. 只输出要点,不要寒暄,不要解释你在做什么。
2. 必须保留:用户姓名、订单号、地址、电话、已确认的金额与承诺。
3. 用简短条目列出用户诉求与已完成的处理。
4. 无法确定的信息不要猜测,直接省略。
对话历史:
{messages}
"""
# 创建 Agent,把中文提示词传给摘要中间件
agent = create_agent(
# 主对话模型
model="deepseek:deepseek-v4-flash",
# 不需要工具
tools=[],
middleware=[
# 挂上摘要中间件
SummarizationMiddleware(
# 摘要模型
model="deepseek:deepseek-v4-flash",
# 演示用低阈值
trigger=("messages", 6),
# 保留最近 2 条原文
keep=("messages", 2),
# 关键:换掉默认的英文提示词
summary_prompt=CN_SUMMARY_PROMPT,
)
],
# 记忆开关
checkpointer=InMemorySaver(),
# 系统提示
system_prompt="你是客服助手,回答简短。",
)同样四轮对话,摘要变成了干净的中文要点:
Here is a summary of the conversation to date:
- 用户姓名:小周
- 地址:杭州
- 其他信息(订单号、电话、金额、承诺):无注意开头那句 Here is a summary of the conversation to date: 仍然是英文——它是中间件拼接摘要消息时硬编码的前缀,不受 summary_prompt 控制。只有正文受你的提示词影响,这点无伤大雅。
另外,中文提示词里「必须保留订单号」的要求被认真执行了(明确写了「无」而不是遗漏)。摘要质量高度依赖提示词写得细不细——把业务上不能丢的字段一条条列出来,效果远好于笼统的「请总结」。
参数速查
| 参数 | 含义 |
|---|---|
model |
执行摘要的模型(必填,可与主 Agent 模型不同) |
trigger |
何时摘要:("tokens", N) / ("messages", N) / ("fraction", f);默认 None 表示不主动触发 |
keep |
摘要后保留多少近期原文,默认 ("messages", 20) |
summary_prompt |
摘要提示词,须含 {messages};默认是英文 |
token_counter |
自定义 token 计数函数,默认用近似算法 |
trim_tokens_to_summarize |
送去做摘要的历史本身也会被截断,默认上限 4000 token |
trigger 还支持列表形式,任一条件满足即触发,适合「既怕条数多又怕 token 大」的场景:
# 消息超过 20 条,或 token 超过 3000,任一满足就摘要
trigger=[("messages", 20), ("tokens", 3000)]("fraction", f) 是按模型上下文窗口的比例触发,比如 ("fraction", 0.7) 表示用到七成窗口就压缩——换模型时不用重算绝对数字,比较省心。
如果你在旧代码里看到别的参数名,它们仍然可用,只是会给出弃用警告:
max_tokens_before_summary is deprecated. Use trigger=('tokens', value) instead.
messages_to_keep is deprecated. Use keep=('messages', value) instead.调参建议(客服场景起点):
| 参数 | 保守(少摘要) | 积极(长聊) |
|---|---|---|
trigger |
("messages", 24) |
("tokens", 3000) |
keep |
("messages", 12) |
("messages", 8) |
| 摘要模型 | 与主模型相同 | 更小 / 更便宜模型 |
summary_prompt |
中文,列明必保字段 | 同左,可再压缩长度 |
一个容易忽略的成本项:摘要本身要调一次模型。trigger 设得太激进,会出现「聊两句就压缩一次」,反而更慢更贵。先用 get_state 观察真实增长速度,再定阈值。
与 §7.1 对比:
@before_model trim |
SummarizationMiddleware |
|
|---|---|---|
| 旧内容 | 直接丢弃 | 压进摘要 |
| 实现成本 | 自己写规则 | 内置,调参数即可 |
| 适合 | 规则简单、允许遗忘 | 长会话、还要保留要点 |
长会话客服推荐默认:checkpointer + SummarizationMiddleware,必要时再叠 trim。
两者同时开时,注意 middleware 顺序:一般 Summarization 负责「压缩远期」,trim 负责「硬保最近 N 条」——具体以实测 Trace 中进模型的 messages 为准。
但请记住摘要的本质是有损压缩:上面那条中文摘要保住了姓名和地址,是因为提示词里点名要求了。没被点名的细节(用户的具体措辞、某个被否决的方案)就真的没了。摘要解决「大意还在」,不解决「一个字都不能错」——后者要靠下一节的槽位。
7.4. 自定义 state:给关键信息开专用槽位 #
前面三种策略有个共同软肋:都在压缩 messages,压缩必然有损。trim 直接丢,摘要变成「大意还在、原话没了」。长会话里最容易出这类事故:
用户第 1 轮说了收货地址,第 30 轮下单时,Agent 把地址记错了。
问题不在模型笨,而在你把「必须精确」的信息交给了一个会被压缩的容器。这类字段应单独存:
| 信息类型 | 存哪 | 例子 |
|---|---|---|
| 对话过程、语气、上下文 | messages(可裁剪 / 可摘要) |
闲聊、追问、工具轨迹 |
| 必须精确、不能丢的字段 | 自定义 state 槽位 | 姓名、订单号、收货地址、已确认的金额 |
一句话心智:
messages是聊天记录,自定义 state 是这场会话的「工单表单」。表单里的字段不会因为聊得久而模糊。
自定义 state 同样存在 checkpointer 里、同样按 thread_id 隔离,所以它是短期记忆的一部分(跨会话的用户档案属于长期记忆,见第 16 章)。
用起来固定三步,缺一步就不生效:
① 定义槽位:继承 AgentState 加字段
↓
② 写入槽位:middleware 或工具返回 {"字段": 值}
↓
③ 读出槽位:拼进系统提示,模型才看得见第 ③ 步最常被漏掉——只写不读,槽位就只是数据库里的死值,模型根本不知道它存在。
7.4.1. 第一步:定义槽位 #
继承官方的 AgentState,把自己要的字段加上去。AgentState 本质是 TypedDict,字段用 NotRequired 标注表示「一开始可以没有」(需 Python 3.11+;更早版本从 typing_extensions 导入)。
# NotRequired 表示这个键可以不存在(Python 3.11+ 内置)
from typing import NotRequired
# 官方的 Agent 状态类型
from langchain.agents import AgentState
# 继承 AgentState,原有的 messages 等字段自动保留
class CustomerState(AgentState):
# 用户姓名:会话开始时还不知道,所以用 NotRequired
customer_name: NotRequired[str]
# 当前咨询的订单号
order_id: NotRequired[str]再用 state_schema 告诉 create_agent 用你的 state:
# 创建 Agent 时指定自定义 state
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 工具列表
tools=[],
# 关键:把默认 AgentState 换成扩展后的 CustomerState
state_schema=CustomerState,
)注意两点:
- 不要重新定义
messages,继承过来的那个带add_messagesreducer,自己覆盖会破坏追加/删除语义 - 槽位字段默认是「后写覆盖前写」,不像
messages会累加
补充一点类型细节:AgentState 本质是 TypedDict(typing_extensions._TypedDictMeta),运行时只是个普通字典——state["messages"] 和 state.get("customer_name") 都能用。NotRequired 只影响静态类型检查,运行时不会强制校验;忘写它不会报错,但 IDE 会一直提示「缺少必填键」。
7.4.2. 第二步:写入槽位 #
写入方式和 §7.1 的 trim 完全一样——在 middleware 里返回一个 dict,键就是槽位名。下面演示了本节的核心论点:
故意把 messages 裁到只剩 2 条(姓名早就被丢掉了),但因为姓名和订单号进了槽位,最后一轮仍然答得出来。
# re 用于正则抽取
import re
# Any 标注 dict 值类型;NotRequired 标注可选的 state 字段
from typing import Any, NotRequired
# create_agent 与 AgentState
from langchain.agents import create_agent, AgentState
# before_model:调模型前的钩子;dynamic_prompt:动态系统提示;ModelRequest:请求对象
from langchain.agents.middleware import before_model, dynamic_prompt, ModelRequest
# RemoveMessage 用于删消息
from langchain.messages import RemoveMessage
# 内存版 checkpointer
from langgraph.checkpoint.memory import InMemorySaver
# 清空全部消息的特殊常量
from langgraph.graph.message import REMOVE_ALL_MESSAGES
# 中间件第二个参数的类型
from langgraph.runtime import Runtime
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# ① 定义槽位
class CustomerState(AgentState):
# 姓名槽位,一开始可以不存在
customer_name: NotRequired[str]
# 订单号槽位
order_id: NotRequired[str]
# 简易抽取规则;真实项目里更常用工具或小模型抽取(见 §7.4.4)
# 匹配「我叫」后面 2~4 个汉字
NAME_RE = re.compile(r"我叫([\u4e00-\u9fa5]{2,4})")
# 匹配一个大写字母 + 4 位数字,如 A1002
ORDER_RE = re.compile(r"([A-Z]\d{4})")
# ② 写入槽位:从最新一条用户消息里抽取,返回的 dict 会合并进 state
@before_model
def extract_slots(state: CustomerState, runtime: Runtime) -> dict[str, Any] | None:
"""把姓名 / 订单号抽进槽位,保证后续裁剪也丢不掉。"""
# 取最新一条消息(本轮用户输入)
last = state["messages"][-1]
# 多模态消息的 content 可能是 list,这里只处理纯文本
text = last.content if isinstance(last.content, str) else ""
# 收集本轮要更新的槽位
update: dict[str, Any] = {}
# 姓名只认第一次,避免后面闲聊把它改掉
if not state.get("customer_name"):
# 海象运算符:匹配成功就把结果赋给 m
if m := NAME_RE.search(text):
# group(1) 是括号里捕获的姓名
update["customer_name"] = m.group(1)
# 订单号以最新提到的为准,所以不加 if not 判断
if m := ORDER_RE.search(text):
# 写入订单号槽位
update["order_id"] = m.group(1)
# 返回 None 表示这一轮没有要更新的槽位
return update or None
# 故意用很激进的裁剪:只留最近 2 条,用来证明槽位确实独立于 messages
@before_model
def trim_history(state: CustomerState, runtime: Runtime) -> dict[str, Any] | None:
# 取出当前消息列表
messages = state["messages"]
# 本来就很短就不动
if len(messages) <= 2:
return None
# 清空后只写回最近 2 条
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *messages[-2:]]}
# ③ 读出槽位:把槽位拼进系统提示,模型才看得见
@dynamic_prompt
def prompt_with_slots(request: ModelRequest) -> str:
# request.state 就是当前状态字典
state = request.state
# 提示词的固定部分
lines = ["你是客服助手,回答简短。"]
# 槽位里有姓名就拼一行进去
if name := state.get("customer_name"):
lines.append(f"当前用户姓名:{name}")
# 槽位里有订单号就拼一行进去
if order := state.get("order_id"):
lines.append(f"当前用户订单号:{order}")
# 用换行拼成最终的系统提示
return "\n".join(lines)
# 组装 Agent
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 本示例不需要工具
tools=[],
# 关键:声明自定义 state,否则槽位会被静默丢弃(见 §7.4.5)
state_schema=CustomerState,
# 顺序要紧:先抽槽位,再裁剪。反了就会先把信息删掉再去抽,抽到空
middleware=[extract_slots, trim_history, prompt_with_slots],
# 记忆开关
checkpointer=InMemorySaver(),
)
# 固定一条会话线
config = {"configurable": {"thread_id": "slot-demo"}}
# 前四轮:姓名在第 1 轮说,订单号在第 2 轮说,之后聊别的把它们「挤出」历史
for text in ["我叫小陈。", "帮我看看订单 A1002。", "今天天气不错。", "顺便问下退货政策。"]:
# 每轮只传新消息
agent.invoke({"messages": [{"role": "user", "content": text}]}, config=config)
# 最后一轮:历史里已经没有姓名和订单号了,只能靠槽位回答
result = agent.invoke(
{"messages": [{"role": "user", "content": "我叫什么?我的订单号是多少?"}]},
config=config,
)
# 打印模型回答
print("回答:", result["messages"][-1].content)
# 验收:messages 很短,但槽位完好
snap = agent.get_state(config)
# 消息条数应该被裁得很短
print("messages 条数:", len(snap.values["messages"]))
# 槽位里的姓名应该还在
print("customer_name:", snap.values.get("customer_name"))
# 槽位里的订单号应该还在
print("order_id:", snap.values.get("order_id"))实际运行输出(模型措辞会有差异):
回答: 您好,小陈,您的订单号是A1002。请问还有什么可以帮您?
messages 条数: 3
customer_name: 小陈
order_id: A1002这三行输出就是本节的全部价值:历史只剩 3 条、姓名的原话早已不在 messages 里,但回答依然精确。
不用只听结论,把 extract_slots 从 middleware 列表里删掉(其余一模一样)再跑一次,模型立刻答不上来:
回答: 抱歉,我无法获取您的姓名和订单号。请提供订单号,我来为您查询。同一个模型、同样激进的裁剪,差别只在于有没有把关键字段另存一份。这就是槽位存在的理由。
顺便确认一下槽位真的进了 checkpoint——打印 snapshot.values 的全部键:
['messages', 'customer_name', 'order_id']自定义字段和 messages 并列存在同一个快照里,按同一个 thread_id 持久化。所以换 checkpointer(比如上生产换 Postgres)时,槽位会跟着一起持久化,不需要额外做什么。
7.4.3. 第三步:读出槽位 #
上面示例用 @dynamic_prompt 完成了读出。为什么必须做这一步?自定义槽位不会自动进入模型输入——模型只能看到系统提示 + messages,state 里的其他字段对它是不可见的。
读出的三种常见方式:
| 方式 | 做法 | 适合 |
|---|---|---|
@dynamic_prompt(推荐入门) |
把槽位拼成一句话加进系统提示 | 姓名、订单号这类短字段 |
| 工具读 state | 工具签名里接 runtime: ToolRuntime,读 runtime.state |
工具需要知道「当前订单是哪个」 |
| 业务代码读 | agent.get_state(config).values["order_id"] |
落库、埋点、前端展示 |
第三种值得单独强调:槽位对你的后端代码也可读。比如「用户确认了收货地址」——前端要展示、订单系统要落库,靠解析模型自然语言既不可靠也不优雅,读槽位才是确定性的。
7.4.4. 让工具写槽位 #
正则抽取只适合演示,一到真实场景就跪(「我姓陈」「叫我小陈就行」都匹配不到)。更可靠的做法是让模型自己决定何时记录——给它一个专门写槽位的工具。
工具写 state 的方式是返回 Command 对象,在 update 里同时给出:要更新的槽位,以及本次工具调用对应的 ToolMessage(后者不能省,否则 history 里 tool_calls 没有配对返回,下一跳模型调用会报错,见 §7.2)。
# NotRequired 标注可选的 state 字段
from typing import NotRequired
# create_agent 与 AgentState
from langchain.agents import create_agent, AgentState
# 动态系统提示相关
from langchain.agents.middleware import dynamic_prompt, ModelRequest
# ToolMessage 是工具执行结果的消息类型
from langchain.messages import ToolMessage
# tool 装饰器;ToolRuntime 用于在工具里拿到运行时信息
from langchain.tools import tool, ToolRuntime
# 内存版 checkpointer
from langgraph.checkpoint.memory import InMemorySaver
# Command 用来让工具直接改写 state
from langgraph.types import Command
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 定义带订单号槽位的 state
class CustomerState(AgentState):
# 订单号槽位
order_id: NotRequired[str]
# 工具返回 Command,即可同时写 state 和补 ToolMessage
# runtime: ToolRuntime 由框架注入,模型不需要(也看不到)这个参数
@tool
def remember_order(order_id: str, runtime: ToolRuntime) -> Command:
"""把用户当前咨询的订单号记入会话槽位。订单号形如 A1001。"""
# 归一化:去空格并转大写
key = order_id.strip().upper()
# 返回 Command 而不是字符串,就能同时更新 state
return Command(
update={
# 写入自定义槽位
"order_id": key,
# 必须补上与本次 tool_call 配对的 ToolMessage
"messages": [
# tool_call_id 从 runtime 里取,用来和模型的调用请求配对
ToolMessage(f"已记住订单 {key}。", tool_call_id=runtime.tool_call_id)
],
}
)
# 读出槽位:让模型知道「当前订单已经记下来了,是哪个」
@dynamic_prompt
def prompt_with_slot(request: ModelRequest) -> str:
# 从 state 里读订单号槽位
order = request.state.get("order_id")
# 提示词基础部分,顺便引导模型去调工具
base = "你是客服助手。用户第一次提到订单号时,调用 remember_order 记下来。"
# 槽位有值就附加上去
if order:
return f"{base}\n当前用户订单号:{order}"
# 槽位为空就只返回基础提示
return base
# 组装 Agent
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 把写槽位的工具挂上
tools=[remember_order],
# 声明自定义 state
state_schema=CustomerState,
# 挂上读槽位的动态提示
middleware=[prompt_with_slot],
# 记忆开关
checkpointer=InMemorySaver(),
)
# 固定一条会话线
config = {"configurable": {"thread_id": "tool-slot-demo"}}
# 第一轮:模型应主动调用 remember_order 把订单号写进槽位
agent.invoke(
{"messages": [{"role": "user", "content": "帮我记一下,我的订单号是 A1002。"}]},
config=config,
)
# 直接读 state 验收,不依赖模型怎么措辞
snap = agent.get_state(config)
# 槽位里应该出现 A1002
print("槽位 order_id:", snap.values.get("order_id"))
# 第二轮:槽位已注入系统提示,模型无需重新翻历史
result = agent.invoke(
{"messages": [{"role": "user", "content": "我的订单号是多少?"}]},
config=config,
)
# 打印回答
print("回答:", result["messages"][-1].content)实际运行输出:
槽位 order_id: A1002
回答: 您的订单号是 **A1002**。请问还需要其他帮助吗?😊第一轮的完整轨迹如下,可以看清 Command 是怎么同时干两件事的:
[0] HumanMessage: '帮我记一下,我的订单号是 A1002。'
[1] AIMessage: '' → remember_order({'order_id': 'A1002'}) ← 模型决定调工具
[2] ToolMessage: '已记住订单 A1002。' ← Command 里补的那条
[3] AIMessage: '好的,我已经记住了您的订单号 **A1002**。'注意 [1] 的 content 是空字符串——模型这一步只发出工具调用,不说话,这是正常的(第 8 章讲过)。
漏了 ToolMessage会怎样
这是本节唯一容易犯的错。把 update 里的 messages 那一项删掉:
# 反例:只写槽位,不补 ToolMessage
@tool
def bad_remember(order_id: str, runtime: ToolRuntime) -> Command:
"""记订单号(故意漏 ToolMessage)。"""
# update 里只有槽位,少了 "messages" 那一项
return Command(update={"order_id": order_id.strip().upper()})好消息是这个错误会立刻响亮地报出来,报错信息还把修法写在里面了:
ValueError: Expected to have a matching ToolMessage in Command.update for tool
'bad_remember', got: []. Every tool call (LLM requesting to call a tool) in the message
history MUST have a corresponding ToolMessage. You can fix it by modifying the tool to
return `Command(update=[ToolMessage("Success", tool_call_id=runtime.tool_call_id)])`原因是 §7.2 讲的那条规则:历史里每个 tool_calls 都必须有配对的 ToolMessage。工具一旦改成返回 Command,框架就不再帮你自动生成这条消息——责任转移到了你手上。
记法:普通工具返回字符串,框架替你造
ToolMessage;工具返回Command,你得自己造。
两种写入方式怎么选:
| middleware 写(§7.4.2) | 工具写(本节) | |
|---|---|---|
| 触发时机 | 每轮固定执行,确定性 | 由模型判断,可能不调用 |
| 抽取能力 | 靠你写的规则,语言变化就漏 | 模型理解语义,鲁棒得多 |
| 额外成本 | 无(不调模型) | 多一次工具调用 |
| 适合 | 格式固定的字段(订单号、手机号) | 需要理解语义的字段(地址、诉求、意图) |
生产上两者常并用:格式化字段用 middleware 兜底,语义字段交工具。
7.4.5. 边界与常见坑 #
自定义 state 很好用,但它不是万能筐。先划清边界:
| 该放槽位 | 不该放槽位 |
|---|---|
| 少量、精确、要复用的字段 | 整段对话历史(那是 messages 的活) |
| 本次会话内有效的信息 | 跨会话的用户档案(属长期记忆 / store) |
| 后端也要读的结构化结果 | 大块文档、检索结果(放 RAG,第 12 章起) |
最坑的一个:忘传 state_schema 不会报错
这是本节唯一的静默失败,值得单独拎出来。把 state_schema=CustomerState 从 create_agent 里删掉,其余代码一字不改:
# 反例:中间件照样返回槽位,但没声明 state_schema
agent = create_agent(
# 模型标识
model="deepseek:deepseek-v4-flash",
# 不需要工具
tools=[],
# 这里少了 state_schema=CustomerState
middleware=[extract_slots, prompt_with_slots],
# 记忆开关
checkpointer=InMemorySaver(),
)跑起来一切正常,没有任何警告。但去看 state:
snapshot.values 键: ['messages']
customer_name: Noneextract_slots 明明返回了 {"customer_name": "小陈"},这个键却被悄悄丢掉了——因为默认的 AgentState 里没有这个字段,框架不知道该往哪存。于是你会看到一个特别费解的现象:抽取逻辑打印日志说抽到了,槽位却永远是空的。
排查口诀:槽位读出来是 None 时,先看 get_state(config).values 有没有这个键。 键都不存在 → 忘了 state_schema;键存在但值是 None → 抽取逻辑没匹配上。
常见坑:
| 现象 | 原因 | 处理 |
|---|---|---|
| 槽位写了,但模型完全不知道 | 漏了第 ③ 步「读出」 | 用 @dynamic_prompt 注入系统提示 |
get_state 里看不到自定义字段 |
忘了传 state_schema=,静默丢弃 |
在 create_agent 里补上 |
| 槽位被后面的闲聊改乱 | 每轮无条件覆盖 | 写入前判断 if not state.get(...) |
| 裁剪后槽位也没了 | middleware 顺序反了,先裁再抽 | 抽取 middleware 放在裁剪之前 |
工具写完 state 后报 ValueError |
Command.update 里漏了 ToolMessage |
补 tool_call_id=runtime.tool_call_id 的 ToolMessage |
换 thread_id 后槽位空了 |
槽位是thread 内的短期记忆 | 跨会话信息要走长期记忆 |
口诀:
会话里「不能记错」的字段,别指望 messages 记住——开个槽位存起来,再拼回提示词。
8. 实战:带记忆的客服 Agent #
本节把第 8 章工具与第 11 章记忆与控长串成一条可演示链路。重点验收的不是「又写了两个 tool」,而是同一 thread_id 下三轮对话能否闭环——尤其是第三轮「刚才那个订单」能否指代 A1002。
组装关系:
lookup_order / lookup_policy(第 8 章)
+
InMemorySaver + thread_id(§3~§4)
+
SummarizationMiddleware(§7.3,可选)
→ build_customer_agent()能力清单:
| 能力 | 工具 / 机制 |
|---|---|
| 查订单 | lookup_order |
| 查退换货政策 | lookup_policy |
| 多轮记忆 | InMemorySaver + thread_id |
| 长聊控长 | SummarizationMiddleware(可选) |
8.1. memory_customer_service.py #
build_customer_agent(enable_summary=True) 把 Summarization 做成开关,便于 A/B:先关摘要跑通多轮,再打开看 get_state 里 messages 条数变化。
# 从 langchain.agents 导入创建 Agent 的工厂函数
from langchain.agents import create_agent
# 内置摘要中间件,用于长会话控长
from langchain.agents.middleware import SummarizationMiddleware
# tool 装饰器,把普通函数变成工具
from langchain.tools import tool
# 内存版 checkpointer(生产请换 Postgres,见 §3.4)
from langgraph.checkpoint.memory import InMemorySaver
# 从 dotenv 导入环境变量加载函数
from dotenv import load_dotenv
# 加载 .env 中的 API key
load_dotenv(override=True)
# 用字典模拟订单数据库
ORDERS = {
# 订单号 -> 物流状态
"A1001": "已发货,预计明天送达",
"A1002": "运输中,预计后天送达",
}
# 注册为工具:查订单
@tool
def lookup_order(order_id: str) -> str:
"""按订单号查询物流状态。订单号形如 A1001。"""
# 归一化:去空格并转大写,容忍用户输入 a1002
key = order_id.strip().upper()
# 查字典
status = ORDERS.get(key)
# 查不到就返回明确的错误文本,让模型知道该追问
if not status:
return f"错误:未找到订单 {order_id}。"
# 查到则返回结构清晰的一句话
return f"订单 {key}:{status}。"
# 注册为工具:查政策
@tool
def lookup_policy(topic: str) -> str:
"""查询退换货与售后政策摘要。"""
# 演示用固定文案;真实项目这里应接 RAG(第 12 章起)
return (
"退换货:签收 7 日内可无理由退货;"
"质量问题 15 日内可换货;"
"跨境订单以页面公示为准。"
)
# 工厂函数:把摘要做成开关,方便 A/B 对照
def build_customer_agent(*, enable_summary: bool = True):
# 中间件列表,默认为空
middleware = []
# 只有开启摘要时才挂 SummarizationMiddleware
if enable_summary:
middleware.append(
SummarizationMiddleware(
# 摘要模型,可换更便宜的
model="deepseek:deepseek-v4-flash",
# messages 到 12 条就压缩
trigger=("messages", 12),
# 压缩后保留最近 8 条原文
keep=("messages", 8),
)
)
# 返回组装好的 Agent
return create_agent(
# 主对话模型
model="deepseek:deepseek-v4-flash",
# 两个业务工具
tools=[lookup_order, lookup_policy],
# 记忆开关:没有它就没有多轮
checkpointer=InMemorySaver(),
# 上面按开关拼出来的中间件
middleware=middleware,
system_prompt=(
# 角色设定
"你是电商客服助手。"
# 工具选择规则
"查订单用 lookup_order;问政策用 lookup_policy。"
# 防幻觉约束
"依据工具返回回答,不要编造物流状态。"
# 显式鼓励利用历史,这是多轮指代能work的关键
"用户提到过的订单号,后续轮次可主动关联。"
),
)
# 只有直接运行本文件时才执行下面的演示
if __name__ == "__main__":
# 构建 Agent(默认开启摘要)
agent = build_customer_agent()
# 固定一条会话线
config = {"configurable": {"thread_id": "cs-demo-001"}}
# 三轮对话,第三轮故意用「刚才那个订单」考验记忆
turns = [
"你好,我订单 A1002 到哪了?",
"退换货几天内可以退?",
"刚才那个订单,大概哪天到?",
]
# 逐轮对话
for text in turns:
# 打印用户输入
print(f"\n用户: {text}")
# 每轮只传新消息,历史由 checkpointer 合并
result = agent.invoke(
{"messages": [{"role": "user", "content": text}]},
config=config,
)
# 打印助手回答
print(f"助手: {result['messages'][-1].content}")
# 调试:查看 thread 内消息条数
snap = agent.get_state(config)
# 条数应随轮次增长(未触发摘要时)
print(f"\n[debug] thread 内 messages 条数: {len(snap.values['messages'])}")8.2. 真实运行结果 #
用户: 你好,我订单 A1002 到哪了?
助手: 您好!您的订单 A1002 目前**运输中**,预计**后天送达**。请您留意物流更新哦~
用户: 退换货几天内可以退?
助手: 您好,关于退换货政策:
- **签收 7 日内**可无理由退货;
- **质量问题 15 日内**可换货;
- 跨境订单以页面公示为准。
您是在关注订单 A1002 的退换货吗?如需进一步说明可以告诉我~
用户: 刚才那个订单,大概哪天到?
助手: 您好,根据订单 A1002 的最新物流信息,它目前**运输中**,预计**后天送达**。
[debug] thread 内 messages 条数: 10第二轮那句「您是在关注订单 A1002 的退换货吗?」很有意思——用户这一轮完全没提订单号,模型是从历史里主动关联出来的。这正是多轮记忆想要的效果。
完整轨迹(get_state 打印):
[0] HumanMessage: '你好,我订单 A1002 到哪了?'
[1] AIMessage: '' → lookup_order({'order_id': 'A1002'})
[2] ToolMessage: '订单 A1002:运输中,预计后天送达。'
[3] AIMessage: '您好!您的订单 A1002 目前运输中...'
[4] HumanMessage: '退换货几天内可以退?'
[5] AIMessage: '' → lookup_policy({'topic': '退换货'})
[6] ToolMessage: '退换货:签收 7 日内可无理由退货...'
[7] AIMessage: '您好,关于退换货政策...'
[8] HumanMessage: '刚才那个订单,大概哪天到?'
[9] AIMessage: '您好,根据订单 A1002 的最新物流信息...'注意第三轮([8]→[9])没有再调一次 lookup_order:模型直接从 [2] 的 ToolMessage 里读到了答案。这是省钱的好事,但也意味着如果物流状态在两轮之间变了,用户看到的是旧数据。对时效敏感的字段,应在系统提示里明确要求「每次询问状态都重新查询」,或者把结果的时间戳带进工具返回值。
8.3. 摘要到底省了多少 #
enable_summary 这个开关不是摆设。用同一套代码连聊 8 轮(每轮都会触发一次工具调用),只改这个开关:
# 对照实验:只有 enable_summary 不同
for flag in (False, True):
# 分别构建开/关摘要的 Agent
a = build_customer_agent(enable_summary=flag)
# 用不同 thread_id 避免互相污染
c = {"configurable": {"thread_id": f"len-{flag}"}}
# 连聊 8 轮
for i in range(8):
a.invoke(
{"messages": [{"role": "user", "content": f"第{i + 1}个问题:退货要几天?"}]},
config=c,
)
# 打印最终条数
print(f"enable_summary={flag}: 8 轮后 messages 条数 = {len(a.get_state(c).values['messages'])}")实测差距相当明显:
enable_summary=False: 8 轮后 messages 条数 = 33
enable_summary=True: 8 轮后 messages 条数 = 1233 条 vs 12 条。 关掉摘要时是 33 而不是 16——因为每轮都有一次工具调用,一轮实际产生 4 条消息(Human、带 tool_calls 的 AI、Tool、最终 AI)。这解释了为什么带工具的 Agent 比纯聊天更容易撞上下文窗口:ToolMessage 是 token 膨胀的主要来源,往往比用户那句话长得多。
开启摘要后条数稳定在 trigger 附近波动,不再线性增长。这就是控长的意义。
8.4. 验收清单 #
- 多轮指代:第三轮「刚才那个订单」能答 A1002 / 运输中(不要求字面复述第一轮原话)。
- 工具轨迹:
get_state里可见tool_calls与ToolMessage。 - 换 thread 隔离:
thread_id换成cs-demo-002后,不应记得 A1002。 - 控长(若开启 Summarization):连续多轮后
messages条数不会无限线性暴涨(相对enable_summary=False对照)。
第 3 条的实测结果,可以作为「隔离生效」的标准答案:
# 换一个全新的 thread_id,注意 agent 对象没变
cfg2 = {"configurable": {"thread_id": "cs-demo-002"}}
# 问和第三轮一模一样的问题
r2 = agent.invoke(
# 这句话在旧 thread 里能答出 A1002
{"messages": [{"role": "user", "content": "刚才那个订单,大概哪天到?"}]},
# 但这里用的是新会话线
config=cfg2,
)
# 打印回答,应该表示不知道是哪个订单
print(r2["messages"][-1].content)您好!为了帮您查询准确的物流信息,我需要知道您的**订单号**(形如 A1001)。
麻烦您提供一下订单号,我这就帮您查预计到达时间~模型反过来找用户要订单号——说明新 thread 里确实一片空白。这里用的是同一个 Agent 对象,隔离完全由 thread_id 决定,与 Agent 实例无关。
与第 10 章护栏 Agent 的关系:本章 Agent 偏记忆与对话体验;把 PIIMiddleware、ModelCallLimitMiddleware 等从第 10 章叠进来就能上线,无需改 checkpointer 用法——记忆层与护栏层是正交的。
9. 实用约定与坑 #
上线前把下面约定当成 Code Review 清单;「常见坑」表对应集成测试里优先写的 5 个用例。
| 约定 | 说明 |
|---|---|
| 每轮只传新用户消息 | 省带宽、好排查;自己重建 dict 历史会真重复(§3.2) |
thread_id 业务生成 |
禁止全员共用 "1" |
Demo 用 InMemorySaver |
生产换 Postgres 等 |
| 摘要阈值要实测再定 | 配得比对话总长还高 = 中间件从未工作(§7.3) |
| 摘要提示词换成中文 | 默认 summary_prompt 是英文且面向编程 Agent(§7.3) |
| 裁剪 / 摘要会丢细节 | 姓名、订单号、地址等关键字段开自定义 AgentState 槽位(§7.4) |
| 槽位「写了要读」 | 写进 state 还需 @dynamic_prompt 注入,模型才看得见(§7.4.3) |
自定义槽位必须配 state_schema |
忘了会被静默丢弃(§7.4.5) |
HITL / thread_limit |
与记忆共用 checkpointer(第 10 章) |
调试看 get_state |
不要只看最后一条 AI 回复 |
坑分成两类看,静默的那类才危险。
静默类(不报错,但行为不对):
| 现象 | 原因 | 处理 |
|---|---|---|
| 第二轮「失忆」 | 未配 checkpointer 或 thread_id 变了 |
检查创建参数与 config |
| 用户 A 看到用户 B 的历史 | thread_id 冲突 |
按用户 / 会话生成唯一 id |
| 装了摘要中间件但条数一直线性涨 | trigger 阈值远高于实际对话长度,从未触发 |
用 get_state 看真实增长,把阈值调到合理区间(§7.3) |
| 上下文里多出一段英文 | 摘要用了默认英文 summary_prompt |
传中文 summary_prompt(§7.3) |
槽位永远是 None,抽取日志却说抽到了 |
忘传 state_schema,键被静默丢弃 |
先看 get_state().values 有没有这个键(§7.4.5) |
| 槽位有值但模型不用 | 只写没读 | 用 @dynamic_prompt 拼进系统提示(§7.4.3) |
| 自己重建 dict 历史后模型像在自言自语 | 无 id 的消息被当成新消息,历史出现重复 | 只传本轮新消息(§3.2) |
| 状态查询返回的是过期数据 | 模型直接复用了旧 ToolMessage,没重新调工具 |
提示里要求重新查询,或在返回值带时间戳(§8.2) |
响亮类(会抛异常,好定位):
| 现象 | 原因 | 处理 |
|---|---|---|
| 重启服务后全忘 | 用了 InMemorySaver |
换持久 checkpointer |
trim 后模型调用报 tool_calls must be followed by tool messages |
按条数切,切断了 tool_calls 与 ToolMessage 的配对 |
按对保留(§7.2 的 safe_slice)或改用摘要 |
工具报 Expected to have a matching ToolMessage in Command.update |
工具返回 Command 却没补 ToolMessage |
补 tool_call_id=runtime.tool_call_id(§7.4.4) |
ValueError: Checkpointer requires ... thread_id |
配了 checkpointer 却没传 thread_id |
在 config 里补上(第 10 章 §5.1) |
设计类(不是 bug,是要想清楚的取舍):
| 问题 | 取舍 |
|---|---|
| 摘要仍答不对很早以前的信息 | 摘要是有损压缩;不能错的字段改存槽位(§7.4) |
| 摘要本身也要花钱 | trigger 太激进会「聊两句压一次」,反而更慢更贵 |
| 保留首条能锚住关键信息吗 | 只在用户开场就自我介绍时成立,很脆弱(§7.1) |
口诀:
thread 管一场对话;checkpointer 管怎么存;控长管能聊多久。
10. 练习 #
每题做完建议打印 get_state 的 messages 条数;涉及隔离的题务必用两个 thread_id 对照。
机制验证类(跑一遍就能纠正直觉,建议全做):
- 新 thread:同一 Agent 用两个
thread_id各聊一轮,交叉提问,确认隔离。 - get_state:每轮后打印 messages 条数与最后两条类型。
- 重传是否真重复:照 §3.2 做两组对照——重传
result["messages"]对象 vs 手工重建 dict 历史,用get_state数条数,验证「按 id 去重」。 - 摘要有没有触发:先用
trigger=("tokens", 4000)聊三轮,打印 state 确认一条摘要都没有;再改成("messages", 6)重跑,观察条数从 6 掉回 4。 - 摘要是什么语言:把触发后的那条摘要消息完整打印出来,确认默认提示词产出的是英文 +
## ARTIFACTS;然后传中文summary_prompt再跑一次对比。 - 忘传
state_schema:把 §7.4.2 的state_schema=CustomerState删掉,确认程序不报错但get_state().values里只有messages。
能力构建类:
- trim 对照:去掉
trim_messages/ Summarization,连聊 15 轮,观察 token / 延迟变化(可选 LangSmith)。 - 指代理解:先报订单号,第三轮只问「那个单子到哪了」,验收是否调工具或引用前文。
- 叠护栏:给 §8 Agent 加第 10 章
PIIMiddleware("email"),输入带邮箱,确认脱敏仍多轮可用(注意第 10 章 §6.4 那个 PII 与工具冲突的坑)。 - 自定义 state:照 §7.4.2 跑通示例后,把
extract_slots从middleware里删掉再跑一次,对比最后一轮的回答差异。 - 槽位读出:在 §7.4.2 基础上注释掉
prompt_with_slots,验证「只写不读」时模型是否还答得出姓名。 - 裁剪切断工具对:给 §7.1 的 Agent 加上
lookup_order工具,保持按条数裁剪,多聊几轮直到报tool_calls must be followed by...,再用 §7.2 的safe_slice修好。 - (扩展)工具写槽位:给 §7.4.4 的 Agent 再加一个
remember_address工具,写入address槽位,并用get_state验收;先故意漏掉ToolMessage看报错,再补齐。
11. 本章小结 #
记忆章节最容易「代码跑通但产品失忆」——验收务必用 thread 隔离 + 指代问句 + get_state 三件套,不要只看最后一句话顺不顺。
- 短期记忆 = 单 thread 内的
messages状态;长期记忆(跨 thread 的store)见第 16 章,文档型知识库(RAG)见第 12~15 章。 checkpointer+thread_id是官方推荐的多轮方案;每轮只传新消息。隔离由thread_id决定,全局共享一个 Agent 实例是正常的。messages按消息id去重:重传带 id 的消息对象不会翻倍,自己重建的 dict 历史才会(§3.2)。InMemorySaver适合 Demo;生产用 Postgres 等持久 checkpointer,槽位会一起持久化。get_state用于调试完整轨迹与 HITL 中间态;snapshot.next为空元组才算跑完。- 长会话要 trim / 删除 / 摘要。裁剪要按对切,别切断
tool_calls与ToolMessage(§7.2)。 - 摘要有两个必须动手改的默认值:
trigger不设或设太高会完全不触发;summary_prompt默认是英文且面向编程 Agent,中文客服务必替换(§7.3)。 - 自定义 state 槽位(§7.4)存「不能记错」的字段,三步齐全才生效:定义
state_schema→ middleware/工具写入 →@dynamic_prompt读出。漏state_schema会静默失败。 - 工具想写 state 就返回
Command(update=...),必须自己补ToolMessage(§7.4.4)。 context(第 9 章) 管单次请求上下文;thread_id管会话历史——分工不同,可并存。- 本章产出:带记忆的客服 Agent(查单 + 政策 + 多轮指代);实测摘要把 8 轮对话从 33 条压到 12 条。
一句话记住这章的验收方法:
别问「模型答得对不对」,问「
get_state里存的是什么」。 回答正确可能只是因为对话还太短,机制根本没生效。
下一章:Document Loaders 文档加载——把 PDF、Word、网页里的公司资料读成统一的 Document 对象,开始 RAG 主线(第 12~15 章)。
顺便说清一件事,免得你翻目录时困惑:本章管的是「这场对话说过什么」;「换了 thread_id 也不能忘」的用户档案属于长期记忆,排在第 16 章(RAG 四章之后)。为什么不紧接着讲?因为长期记忆后面要用向量检索做语义查找(store 的 index 参数),嵌入模型和相似度检索是第 14 章的内容。先学完 RAG 再回头看长期记忆,就不必「先跳过、以后再补」了。