多 Agent 客服的短期记忆改造:用 Redis + Checkpointer 管理会话状态

多 Agent 客服的短期记忆改造:用 Redis + Checkpointer 管理会话状态

前言

mall-customer 的短期记忆原本是一个手写的 Redis 封装:把对话消息按会话 ID 存进 Redis list,读的时候取最近 N 条。封装本身是通的,但它和 Agent 图之间是断开的------消息存下来了,模型看不到。用户第一轮说"我要退款",第二轮说"给它升级优先级",客服会把第二句当成一件新的事处理:要么答非所问,要么再建一张新工单。

Agent State 本来就是用来记录整个图执行过程中的状态的,其中就包括消息------它完全可以替代原来那套短期记忆,只需要把这份状态持久化下来。框架已经提供了现成的做法------checkpointer:编译图时传入它,每个节点执行后的状态都会自动持久化,下一轮自动恢复,存进去的状态就是下一轮要用的。

所以这次改造没有重写短期记忆,只是把自建的 Redis 封装删掉,状态存取交给 checkpointer。过程中还清掉了状态模型里的两个冗余字段,并解决了暴露出来的两个问题。

一、旧短期记忆的问题

memory/short_term.py 短期记忆就围绕三个核心操作:

python 复制代码
async def add_message(self, session_id: str, role: str, content: str) -> None:
    message = {"role": role, "content": content, "timestamp": datetime.now().isoformat()}
    key = f"smartcs:short_term:{session_id}"
    await r.rpush(key, json.dumps(message, ensure_ascii=False))  # 追加一条消息
    await r.ltrim(key, -self.max_turns, -1)                      # 只保留最近 20 条
    await r.expire(key, self.ttl_seconds)                        # 30 分钟过期

Redis 里确实存放着最近的对话,/api/history 也能读回来。但它和图是断开的,具体有两个问题:

  • 存了,但没用上。 /api/chat 调用 graph.ainvoke() 时,初始 state 只带本轮这一条新消息:"messages": [HumanMessage(content=request.message)]。Redis 里存着 20 条历史,图里只读得到 1 条------历史从未注入状态,多轮追问缺少上下文。
  • 两套状态各自维护。 短期记忆里存的是消息文本;图的内部还有 intent(路由给哪个 Agent)、compliance_passed(合规是否通过)这类编排状态。这些字段既不落 Redis、也不进下一轮,进程一重启就丢失。

二、Checkpointer 如何持久化会话状态

checkpointer 存到哪、怎么存:graph.compile(checkpointer=...) 之后,每次执行都会以 thread_id 为键,把每个 superstep 之后的完整状态快照写入存储;下一轮用同一个 thread_id 调用 ainvoke() 时,框架先恢复上次的状态,再把这一轮的输入按 reducer(messages 字段上是 add_messages)合并进去;任何时刻都可以用 graph.aget_state() 读出当前状态。

三、换成 Redis Checkpointer

依赖与前置条件

  • requirements.txt 增加一行 langgraph-checkpoint-redis>=0.5.0(本次接入用的 0.5.2);
  • 这个 checkpointer 不是普通的 KV 存储:它用 RedisJSON 保存状态、用 RediSearch 建索引,所以需要 Redis Stack 或 Redis 8+ 。本地 compose 用的本来就是 redis/redis-stack-server 镜像,不用额外改;
  • REDIS_URL 环境变量补上密码。

初始化与降级

新的 memory/short_term_memory.py 里,初始化都放在 setup() 方法中:

python 复制代码
# Agent State 检查点:TTL 30 分钟(读取/写入均刷新过期时间)
CHECKPOINT_TTL = {"default_ttl": 30, "refresh_on_read": True}


class ShortTermMemory:
    async def setup(self) -> None:
        """初始化 checkpointer:优先 Redis,失败降级进程内 MemorySaver"""
        from langgraph.checkpoint.memory import MemorySaver
        from langgraph.checkpoint.redis.aio import AsyncRedisSaver

        stack = AsyncExitStack()
        try:
            checkpointer = await stack.enter_async_context(
                AsyncRedisSaver.from_conn_string(self._redis_url, ttl=CHECKPOINT_TTL)
            )
            await checkpointer.asetup()
            self._stack, self._checkpointer = stack, checkpointer
        except Exception as e:
            print(f"[ShortTermMemory] Redis checkpointer 初始化失败,降级为内存 MemorySaver: {e}")
            await stack.aclose()
            self._stack, self._checkpointer = None, MemorySaver()

几个实现细节:

  • ttl 控制的是会话的生命周期:default_ttl 的单位是分钟,refresh_on_read: True 表示读取时也续期------会话持续使用就一直存活,30 分钟没有读写才过期;
  • from_conn_string() 是异步上下文管理器,这里用 AsyncExitStack 手动进入,好处是进出成对:连接建立失败时,已申请的资源会被回滚掉,然后降级为进程内的 MemorySaver,服务正常启动。降级只保证可用性------内存版重启即失效,多进程之间也不共享(当前版本里 MemorySaver 是 InMemorySaver 的兼容别名);
  • asetup() 负责建立 RediSearch 索引和 JSON 文档结构,启动时执行一次,幂等。

接线:thread_id、/api/chat 与 /api/history

在 api/main.py 的 lifespan 里,先初始化短期记忆,再把 checkpointer 传给图:

python 复制代码
@asynccontextmanager
async def lifespan(app: FastAPI):
    global graph

    # 短期记忆:Redis 持久化 Agent State,Redis 不可用时自动降级内存
    await short_term_memory.setup()

    graph = create_supervisor_graph(
        working_memory=working_memory,
        long_term_memory=long_term_memory,
        checkpointer=short_term_memory.checkpointer,
    )
    ...
    yield
    await short_term_memory.aclose()

create_supervisor_graph 的参数里,原来传的是 short_term_memory 对象,现在传 checkpointer------图不再依赖具体的存储实现:

python 复制代码
def create_supervisor_graph(..., checkpointer: Any | None = None, enable_checkpointing: bool = True):
    ...
    if checkpointer is None and enable_checkpointing:
        from langgraph.checkpoint.memory import MemorySaver
        checkpointer = MemorySaver()
    compiled = graph.compile(checkpointer=checkpointer)
    return compiled

/api/chat 里,会话 ID 直接当作 thread_id。这一轮的初始 state 只包含新消息和业务字段,历史对话由框架恢复:

python 复制代码
session_id = request.session_id or str(uuid.uuid4())
config = {"configurable": {"thread_id": session_id}}

initial_state = {
    "messages": [HumanMessage(content=request.message)],
    "user_id": request.user_id,
    "session_id": session_id,
    ...
}
result = await graph.ainvoke(initial_state, config=config)

在 /api/history 里,通过读取图的状态快照获取对话历史:

python 复制代码
async def get_history(self, graph: Any, session_id: str, last_n: int = 20) -> list[dict]:
    """读取会话对话历史 --- 从 LangGraph Agent State(checkpointer)获取"""
    from langchain_core.messages import AIMessage, HumanMessage, SystemMessage

    try:
        snapshot = await graph.aget_state({"configurable": {"thread_id": session_id}})
    except Exception:
        snapshot = None

    messages = (snapshot.values or {}).get("messages", []) if snapshot else []
    role_map = {HumanMessage: "user", AIMessage: "assistant", SystemMessage: "system"}
    history = [
        {"role": role_map.get(type(msg), "system"), "content": msg.text}
        for msg in messages
    ]
    return history[-last_n:]

只保留最近 20 条

TTL 控制的是会话何时过期,不管状态里累积了多少消息------如果只有 TTL,持续时间长的会话,检查点里的消息会一直累积。旧实现用 ltrim 把列表裁到 20 条,新方案需要补上同样的条数限制。

做法是在图的入口加一个 trim_history 节点,紧跟本轮输入合并之后执行:消息数超过窗口的部分裁掉------用 RemoveMessage 把最旧的几条标记移除(而不是让 reducer 静默丢弃),经 add_messages 缩减之后再做路由。这样后续节点(Supervisor 路由、子 Agent、合规)读到的就是同一个窗口,checkpointer 落盘的也是有界状态:

python 复制代码
# 短期记忆滑动窗口:Agent State 中最多保留最近 20 条消息(约 10 轮对话),
# 超出部分由 trim_history 节点用 RemoveMessage 移除,防止状态无限增长
MESSAGE_WINDOW_SIZE = 20


def trim_history(state: AgentState) -> dict[str, Any]:
    """滑动窗口:把 Agent State 的消息裁剪到最近 MESSAGE_WINDOW_SIZE 条

    作为图的入口节点执行,紧接着本轮的输入合并之后------保证后续所有节点的
    LLM 只看得到窗口内的消息,checkpointer 持久化的也是有界状态。
    通过 RemoveMessage 走 add_messages reducer 裁剪,而不是直接覆盖列表。
    """
    messages = state.get("messages", [])
    overflow = len(messages) - MESSAGE_WINDOW_SIZE
    if overflow <= 0:
        return {}

    removals = [RemoveMessage(id=m.id) for m in messages[:overflow] if m.id]
    return {"messages": removals}

接线时把入口从 supervisor_route 改为 trim_history:

python 复制代码
graph.add_node("trim_history", trim_history)   # 滑动窗口:裁剪超窗口的历史消息
...
graph.set_entry_point("trim_history")
graph.add_edge("trim_history", "supervisor_route")

和 ltrim 的差别在于:ltrim 是物理删除,被裁掉的消息无法找回;RemoveMessage 是往状态里提交一次"删除指令",由 add_messages reducer 处理,语义上仍是一次状态更新------和第四节合规撤回是同一套机制。窗口大小 20 条(约 10 轮对话),沿用旧实现的默认值。

四、状态模型调整:从 sub_results 到 messages

接入 checkpointer 之前,AgentState 里还有 sub_results 和 final_response 两个字段(节选):

python 复制代码
class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    user_id: str
    session_id: str
    intent: str
    sub_results: dict[str, Any]   # 各子 Agent 的结果,按 Agent 名分桶
    compliance_passed: bool
    final_response: str           # 合成节点拼出来的最终回复
    current_agent: str
    retry_count: int

sub_results 和 final_response 的问题在于:

  • 子 Agent 处理完,不是往消息流里写回答,而是把结果写进 sub_results["knowledge_rag"] 这样的字典;这些内容不是消息,也不会被恢复成对话上下文;
  • final_response 是合成节点拼好之后再写回状态的,和 messages 里最后追加的那条 AIMessage 是同一份内容,存了两遍。

既然会话状态都放在 messages 里,这两个字段就是多余的。改造后,子 Agent 的结果直接作为 AIMessage 写入 messages------它既是要给用户的回复,也是下一轮对话历史的组成部分:

python 复制代码
return {
    **state,
    "messages": [AIMessage(content=result)],
}

合成节点不再做拼接:末尾的 AIMessage 就是最终回复。

改造过程中处理了两个边界情况,均来自实际运行:

其一,合规不通过时,子 Agent 的结果要撤回。 该结果已经写入 messages;如果合规不通过只是追加一句转人工话术,违规内容会留在会话历史中,下一轮仍会作为上下文送入模型。因此需要用 RemoveMessage 将其移除:

python 复制代码
if not state.get("compliance_passed", True):
    final_response = (
        "抱歉,您的请求涉及敏感内容,已转交人工客服处理。"
        "工单编号已自动生成,请留意后续通知。"
    )
    updates: list[BaseMessage] = []
    if isinstance(last, AIMessage) and last.id:
        updates.append(RemoveMessage(id=last.id))   # 撤回子 Agent 的结果
    updates.append(AIMessage(content=final_response))
    return {**state, "messages": updates}

RemoveMessage 配合 add_messages reducer,按消息 id 移除已写入的消息------状态统一到 messages 之后,撤回才能这样实现。

其二,合规通过时,不再追加第二份"最终回复"。 合成节点最初沿用旧逻辑:无条件把 final_response 追加进 messages,导致子 Agent 的回复与合成节点追加的回复同时存在,会话历史中出现两条内容相同的 AI 消息。修复方式是在追加前判断末尾是否为子 Agent 已写入的回复:

python 复制代码
if isinstance(last, AIMessage) and bool(last.content):
    return state   # 子 Agent 已写入回复,直接收尾

配套地,合规节点的审查对象也从"sub_results 拼接串"改成了"messages 末尾的 AIMessage";末尾没有子 Agent 的回复时(比如直达合规审查)直接跳过:

python 复制代码
last = messages[-1] if messages else None
if not isinstance(last, AIMessage) or not last.content:
    return {**state, "compliance_passed": True}
compliance_result = await self.full_check(last.content)

改造后的整张图:

flowchart TD U([用户消息]) --> TH[trim_history<br/>裁剪到 20 条] TH --> R[supervisor_route<br/>意图路由] R -->|业务咨询| K[knowledge_rag] R -->|办理 / 投诉| T[ticket_handler] R -->|闲聊寒暄| C[chitchat] K --> CC[compliance_check] T --> CC C --> CC CC --> S[synthesize] S --> E([末尾 AIMessage<br/>即最终回复]) CP[(Redis Checkpointer<br/>thread_id = session_id)] -.->|每个 superstep 读写| TH

五、多轮工单:记忆的存储与消费

状态改造完成、checkpointer 接入之后,多轮场景的测试显示上下文问题并没有完全解决。

测试会话中依次发送三条消息:

  • 第一条:"我上周买的超声波香薰机坏了,我要申请退款" → 建单成功:TK-20261011-B35059;
  • 第二条:"太慢了,给它升级为紧急优先级" → 又建了一张新单:TK-20261011-78F6D1;
  • 第三条:"查一下它的进度" → 再建一张新单:TK-20261011-80BF76。

三条消息,三张工单。检查点里已存有完整历史,为什么升级优先级和查进度的请求仍在新建工单?

原因在 ticket_handler 的分析逻辑:分析请求时,只有最后一条消息进入了 LLM 的 prompt:

python 复制代码
last_message = messages[-1].content
ticket_info = await self.analyze_request(last_message)   # 历史从未传入

修复分两层:

  • 把历史注入分析 prompt。 取最近 10 条对话格式化成文本,和当前消息一起交给 LLM:
python 复制代码
# 关键修复:把对话历史一并交给分析 LLM,否则它只看得到最新一条消息,
# 无法得知用户指代的已有工单(记忆存了却用不上)
history_text = self._format_history(messages[:-1])
ticket_info = await self.analyze_request(last_message, history_text)

同时在系统 prompt 里把规则写死:指代、查询、处理已存在 工单的消息("那个工单""它""刚才的单子""查一下进度""给它升级优先级"),action 必须是 query 或 update,并从对话历史里提取工单号;此时严禁新建工单。

  • 正则兜底: LLM 可能漏带 ticket_id,因此在历史消息中由新到旧扫描,用正则提取最近出现过的工单号作为兜底:
python 复制代码
TICKET_RE = re.compile(r"TK-\d{8}-[A-Fa-f0-9]{6}")

def _extract_ticket_id(self, messages: list[Any]) -> str | None:
    """从对话历史(由新到旧)中提取最近出现的已有工单号。"""
    for m in reversed(messages):
        content = m.content if isinstance(m.content, str) else str(m.content)
        match = TICKET_RE.search(content)
        if match:
            return match.group(0)
    return None

# 双保险:LLM 没带 ticket_id 时,直接从对话历史里提取
ticket_id = ticket_info.get("ticket_id") or self._extract_ticket_id(messages)

判定逻辑变成:

flowchart TD M([用户消息]) --> A[analyze_request<br/>携带最近 10 条历史] A --> Q{action} Q -->|create| C[创建新工单] Q -->|update / query| T[ticket_id 取自 LLM<br/>缺失时正则兜底提取] T -->|update| U[update_priority<br/>更新优先级] T -->|query| S[query_ticket<br/>查询状态]

六、补充一个 chitchat 节点

改造验证过程中发现一类不必要的开销:用户只发送一句"你好",消息也会被路由到 knowledge_rag,多出一次无意义的知识库检索。

解决办法是加一个 chitchat 节点,专门处理打招呼、感谢、告别这类非业务消息,不检索知识库,直接生成回复。行为约束写在系统 prompt 中:

python 复制代码
CHITCHAT_SYSTEM_PROMPT = """你是一个友好的电商客服,负责处理用户的寒暄与闲聊。
...
回答规则:
1. 简短友好,符合客服场景,不超过3句话
2. 不检索、不编造任何业务信息(商品价格、订单状态等一律不回答)
3. 不做任何业务承诺(退款、赔付、优惠等)
4. 用户若提及具体业务问题,礼貌引导其重新描述,由知识库回答
"""

改动共三处:Supervisor 的路由 prompt 增加 chitchat 选项和路由规则(闲聊 → chitchat,不要为此检索知识库);图上注册 chitchat 节点,和另外两个子 Agent 一样汇聚到合规检查;route_map 增加对应映射。

七、验证

验证环境为本地 docker compose 启动的中间件(含 redis-stack-server),mall-customer 用 python -m api.main 直接启动,测试用例直接调用 Python 服务的接口,不经过 Java 后端,测的就是短期记忆本身。以下均为实际运行结果(模型回复每次生成略有不同)。

多轮上下文与历史读取

新建一个会话,依次发送两轮:

text 复制代码
[轮1] user: 你好,我叫小明,记住我的名字
[轮1] assistant: 你好小明,很高兴认识你!有什么需要帮忙的,比如查订单、退换货或商品咨询,都可以告诉我。

[轮2] user: 我叫什么名字?
[轮2] assistant: 你叫小明呀~我记住啦。有什么电商相关的问题需要我帮忙吗?

第二轮回答正确引用了第一轮的信息。随后读 /api/history,返回 4 条消息,角色顺序为 user / assistant / user / assistant,与对话一致;last_n=2 时只返回最近两条。

跨进程重启的状态恢复

会话建立后,终止 mall-customer 进程并重启,再读 /api/history,4 条消息完整返回。重启后继续追问:

text 复制代码
user: 我刚才说我叫什么名字来着?
assistant: 你刚才说你叫小明呀~我记着呢。有什么需要帮忙的吗?

多轮工单:修复前后

同一个场景(建单 → 要求升级优先级 → 查进度)在修复前后分别验证:

  • 修复前:三条消息建了三张工单(TK-20261011-B35059 / TK-20261011-78F6D1 / TK-20261011-80BF76),升级优先级和查进度均被当作新诉求;
  • 修复后:第一条消息建单 TK-20261011-6C4751(中等优先级);第二条"太慢了,给它升级为紧急优先级"返回"工单已更新成功!工单号: TK-20261011-6C4751 ⚡ 优先级: 紧急";第三条"查一下它的进度"直接查回同一张单,更新时间正是升级优先级操作的时间。全程只存在一张工单。

合规撤回与消息去重

这两条路径涉及不适合展示给用户的内容,无法通过对话直接验证,因此用 FakeListChatModel 编写临时冒烟脚本,按图的三条出口分别断言:

  • 合规通过:历史里只有一条 AI 回复,没有重复追加;
  • 合规不通过:子 Agent 的违规结果被 RemoveMessage 撤回,messages 里查不到,只留转人工话术;
  • 直达合规:追加兜底话术"抱歉,暂时无法处理您的请求,请稍后重试。";
  • 三轮累积对话:第 1 轮的回复仍在、第 2 轮违规内容没有泄漏、第 3 轮的兜底话术正常,messages 长度与轮次严格对应。

以上断言全部通过。

Redis 里存了什么

一个会话在 Redis 里的实际键(会话 ID test-stm-3ffa40b6):

text 复制代码
checkpoint:test-stm-3ffa40b6:__empty__:{checkpoint_id}
checkpoint_latest:test-stm-3ffa40b6:__empty__
checkpoint_write:test-stm-3ffa40b6:__empty__:{checkpoint_id}:{task_id}:{序号}
write_keys_zset:test-stm-3ffa40b6:__empty__:{checkpoint_id}
  • checkpoint: 是每个 superstep 的状态快照;
  • checkpoint_write: 是各节点执行的写入记录(channel 更新);
  • checkpoint_latest: 指向该会话最新的检查点;
  • write_keys_zset: 记录某次检查点关联的写入键,交给 saver 统一管理 TTL;
  • 中间那段 __empty__ 是空命名空间(checkpoint_ns)在存储层的占位,子图场景才会取别的值;
  • 取样时 TTL 剩余 1797 秒------正好是 default_ttl: 30 分钟,且会随读写刷新。

窗口部分,验证会话连续进行了 15 轮对话,状态中的消息数在第 10 轮达到 20 后不再增长:每轮入口先裁到 20 条再处理本轮,轮末最多到 21(20 条窗口 + 本轮回复),下一轮入口再次裁剪;到第 15 轮时,最早的几轮对话已滑出窗口。向一个会话直接写入 22 条消息再触发一轮,最早 2 条被 RemoveMessage 移除,从第 3 条开始保留。

降级路径同样验证过:把 REDIS_URL 指向一个不存在的端口启动,服务打印降级日志后正常启动,checkpointer 类型从 AsyncRedisSaver 变为 InMemorySaver:

text 复制代码
[ShortTermMemory] Redis checkpointer 初始化失败,降级为内存 MemorySaver: ...
Redis 不可用时 => InMemorySaver
Redis 可用时   => AsyncRedisSaver

小结

这次改造去掉了手写的状态存取,交给框架来做:

  • 短期记忆不再由业务代码维护消息列表,AsyncRedisSaver 以 thread_id 为键持久化整份会话状态:TTL 30 分钟失效,trim_history 节点把状态裁剪到最近 20 条,Redis 不可用时自动降级进程内存储;
  • AgentState 里只留 messages 一条主线:子 Agent 的结果直接作为消息存入短期记忆,合规撤回用 RemoveMessage,合成节点不再重复追加;
  • 子 Agent 消费历史是另一件事:工单 Agent 现在分析请求时会带上最近 10 条对话,并用正则从历史里兜底提取工单号,"给它升级优先级"能落到那张已有的工单上。

上一篇剩下的待办里,这篇解决的是"短期记忆"一项;工单持久化(目前仍是内存存储)、业务数据接入、回复的 Markdown 渲染还在后面的计划里。

相关链接

相关推荐
高级程序源1 小时前
springboot校园外卖平台13676-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·tomcat·maven·课程设计
韩振方1 小时前
删掉一个 Pod 后,谁把它补回来?
后端
Gopher_HBo1 小时前
grpc客户端调用链
后端
时间海8711 小时前
对象键清洗之前,先拦住重名
javascript·后端
歌谣1 小时前
07|【30岁前端转型全栈】前后端遇到报错怎么处理?当然是console.log和System.out.println?
后端
对象存储与RustFS1 小时前
HelixDB 上手:Agent 记忆新方案,基于 RustFS 对象存储,附完整 docker-compose 部署示例
后端·rust·开源
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (十八):ChannelOutboundBuffer 写缓冲区 —— 发送队列与流量控制
java·后端·架构
谢亮_vipxieliang1 小时前
Go gRPC基础与实战
开发语言·后端·golang
god_cvz1 小时前
第二章:5、RAG进阶优化
后端·ai编程