芥末
发布于 2026-03-09 / 0 阅读
0
0

Agent 长短期记忆的生产级落地架构:短期状态、异构存储与一致性兜底

LLM(大语言模型)本身是无状态的。每次调用模型,本质上都是把一段 Prompt 和对话消息发给模型,让它根据当前输入生成结果。模型不会天然记住用户上一轮说过什么,也不会自动维护用户画像、禁忌、偏好、任务进度。

Agent 想要像一个长期可用的助手,就必须在模型外部搭建记忆系统。

记忆系统要解决两类问题:

记忆类型解决的问题生命周期典型数据
短期记忆当前会话怎么保持连贯,正在做的任务进展到哪一步一次会话内,通常分钟到小时级最近几轮对话、当前任务状态、临时约束
长期记忆跨会话记住稳定事实和历史经验天、月甚至更长用户姓名、过敏史、偏好、历史方案、长期标签

只用“滑动窗口 + 向量数据库”很容易做出 Demo,但离生产系统还差很远。核心原因有两个:

  1. 滑动窗口会丢掉早期但重要的信息。
  2. 向量检索只能找语义相似内容,不能保证事实精确。

例如用户一开始说“我对花生严重过敏”,中间连续聊了几十轮需求细节,再问“你还记得我的饮食禁忌吗”。如果只保留最近几轮对话,这个关键信息很可能已经被截断。

再例如用户保存过手机号、生日、过敏史这类强事实数据,用向量库去召回并不可靠。向量检索适合找“意思接近”的内容,不适合保证“号码、日期、禁忌”这类字段 100% 精确。

生产级 Agent 记忆架构的核心思路不是把所有记忆都塞进一个向量库,而是把不同类型的数据放到不同存储里,再按优先级组装给模型。

flowchart TB
    A[用户请求] --> B[短期记忆]
    A --> C[长期记忆]

    B --> B1[最近对话流]
    B --> B2[Session 状态机]

    C --> C1[强事实结构化数据]
    C --> C2[半结构化历史文本]
    C --> C3[非结构化语义片段]
    C --> C4[实体关系]

    B1 --> D[Prompt 组装]
    B2 --> D
    C1 --> D
    C2 --> D
    C3 --> D
    C4 --> D

    D --> E[LLM 推理]
    E --> F[返回结果]

短期记忆不能只靠滑动窗口

最简单的短期记忆实现,是把历史对话放进 messages 数组:

[
  {"role": "user", "content": "我叫林冲,对花生过敏"},
  {"role": "assistant", "content": "好的,我会记住你的花生过敏信息"},
  {"role": "user", "content": "帮我整理一个产品需求文档"}
]

问题在于模型上下文窗口是有限的。对话越来越长时,很多系统会用 FIFO(First In First Out,先进先出)策略删除最早的消息,只保留最近 N 轮。

这种做法只能保证“最近聊过什么”,不能保证“重要信息还在”。

短期记忆更合理的设计,是在 Redis 里拆成两层:

层级存储内容作用是否直接进入 Prompt
最近对话流最近 5 到 10 轮原始对话保持语气、指代、上下文连贯放入 messages
Session 状态机当前会话里的关键事实、约束、任务状态防止核心信息被截断放入 System Prompt 或高优先级上下文

Redis 最近对话流

最近对话流只负责上下文连贯,不承担长期事实记忆。

可以用 Redis List 保存:

key: agent:sess:{session_id}:messages
type: list
ttl: 30min / 2h / 1day,按业务决定
max_length: 10 到 20 条消息

写入时控制长度:

def append_message(redis, session_id, role, content):
    key = f"agent:sess:{session_id}:messages"

    message = {
        "role": role,
        "content": content,
        "ts": int(time.time())
    }

    redis.rpush(key, json.dumps(message, ensure_ascii=False))
    redis.ltrim(key, -10, -1)
    redis.expire(key, 3600)

这层数据可以直接进入 messages,让模型知道最近发生了什么。

Session 状态机

Session 状态机保存“不能因为窗口截断而丢失”的信息,例如:

  • 用户当前身份
  • 当前任务目标
  • 已确认约束
  • 明确禁忌
  • 待办事项
  • 本轮会话里刚发生的状态变更

一个状态对象可以长这样:

{
  "session_id": "s_10001",
  "version": 7,
  "user_profile": {
    "name": "林冲",
    "role": "产品经理"
  },
  "constraints": [
    {
      "type": "allergy",
      "value": "peanut",
      "confidence": 1.0,
      "source": "user_explicit",
      "updated_at": "2026-06-07T10:20:00+08:00"
    }
  ],
  "task_state": {
    "current_goal": "整理产品需求文档",
    "stage": "collecting_requirements"
  }
}

这部分内容不要混在普通历史对话里,而要放到更高优先级的位置,例如 System Prompt:

你正在与用户进行同一个会话。必须遵守以下会话状态:

用户信息:
- 姓名:林冲
- 职业:产品经理

当前约束:
- 用户对花生严重过敏,涉及饮食推荐时必须排除花生及含花生成分的食品。

当前任务:
- 用户正在整理产品需求文档,当前阶段是需求收集。

这样即使最近对话流里已经看不到第一轮消息,模型仍然能拿到关键状态。

短期记忆的关键点是:最近对话负责连贯,Session State 负责核心状态。两者不能互相替代。

flowchart LR
    U[用户输入] --> P[轻量抽取器]
    P --> S[(Redis Session State)]
    U --> M[(Redis 最近对话流)]

    S --> B[Prompt Builder]
    M --> B

    B --> L[LLM]
    L --> R[回复用户]

    R --> M

长期记忆不能只靠向量数据库

RAG(Retrieval-Augmented Generation,检索增强生成)常见做法是:把历史内容切块,生成 Embedding(向量化表示),写入向量数据库;用户再次提问时,用当前问题做向量检索,把相似片段塞进 Prompt。

这套机制适合召回语义相关内容,但不适合保存强事实。

原因很简单:向量检索的目标是相似,不是精确。

例如:

  • “我手机号是 138 开头”与“他手机号是 139 开头”在语义空间里可能很接近。
  • “我不能吃海鲜”与“我喜欢海鲜自助”都围绕海鲜这个主题,可能被召回到一起。
  • “我去年去过东京”与“我下周要去东京”语义相关,但时间状态完全不同。

如果这些信息直接影响模型决策,纯向量召回就有风险。

生产系统应该按数据性质拆存储。

记忆类型推荐存储适合保存什么召回方式优先级
强事实结构化数据MySQL / PostgreSQL / MongoDB姓名、手机号、生日、过敏史、明确禁忌、用户标签主键、索引、条件查询最高
半结构化长文本Elasticsearch历史总结、需求文档、方案记录、关键词强相关内容BM25、关键词、过滤条件
非结构化语义片段Milvus / Qdrant / pgvector情绪片段、表达风格、模糊偏好、经验片段向量相似度检索
实体关系Neo4j / NebulaGraph家庭成员、同行人、组织关系、复杂关联图遍历按业务决定

向量库不是不能用,而是不能放在最高优先级。它适合作为补充记忆,用来扩展上下文,而不是覆盖结构化事实。

长期记忆的异构存储设计

一个可落地的长期记忆系统,通常至少包含三类表或索引。

强事实表

强事实数据需要可校验、可更新、可覆盖,适合用关系型数据库或文档数据库保存。

CREATE TABLE user_memory_fact (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    fact_type VARCHAR(64) NOT NULL,
    fact_key VARCHAR(128) NOT NULL,
    fact_value JSON NOT NULL,
    confidence DECIMAL(5,4) NOT NULL DEFAULT 1.0,
    source VARCHAR(64) NOT NULL,
    version BIGINT NOT NULL DEFAULT 1,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_user_fact (user_id, fact_type, fact_key)
);

示例数据:

user_idfact_typefact_keyfact_value
10001profilename"林冲"
10001allergypeanut{"level": "severe"}
10001traveldestination{"city": "Tokyo", "time": "next_week"}

这种数据必须通过确定性查询读取,不能交给向量库猜。

半结构化文本索引

历史对话总结、需求文档、过往方案这类内容不一定有固定字段,但经常需要关键词命中,适合放进 Elasticsearch。

{
  "user_id": 10001,
  "memory_type": "requirement_summary",
  "title": "智能客服需求讨论记录",
  "content": "用户希望客服机器人支持工单查询、退款进度跟踪和多轮补充信息收集。",
  "tags": ["客服", "工单", "退款"],
  "updated_at": "2026-06-07T11:00:00+08:00"
}

Elasticsearch 的 BM25 排序对关键词检索很友好。用户问“上次说的退款工单流程是什么”,关键词命中往往比单纯向量召回更稳定。

向量记忆

向量库适合存放不容易结构化、但对生成有帮助的内容。

{
  "user_id": 10001,
  "memory_type": "style_preference",
  "content": "用户喜欢简洁、偏工程化的回答,不喜欢长篇背景铺垫。",
  "embedding": [0.013, -0.028, 0.102],
  "metadata": {
    "source": "dialogue_summary",
    "created_at": "2026-06-07T11:30:00+08:00"
  }
}

检索向量记忆时必须加标量过滤,例如先限定 user_idmemory_type、时间范围,再做向量相似度搜索:

results = vector_store.search(
    embedding=query_embedding,
    filter={
        "user_id": 10001,
        "memory_type": {"$in": ["style_preference", "experience"]},
        "created_at": {"$gte": "2026-01-01"}
    },
    top_k=5
)

不加过滤直接全库向量搜索,会显著增加误召回概率。

同步读链路:先组装记忆,再调用模型

用户请求进入 Agent 后端时,不应该直接把输入丢给模型。更合理的顺序是先读取和整理记忆,再构造 Prompt。

主链路需要控制 RT(Response Time,响应时间),所以读操作要尽量并发完成。

sequenceDiagram
    participant C as 客户端
    participant A as Agent API
    participant R as Redis
    participant DB as MySQL/MongoDB
    participant ES as Elasticsearch
    participant V as 向量数据库
    participant L as LLM

    C->>A: 发送用户输入
    A->>R: 读取最近对话和 Session State
    A->>DB: 查询强事实记忆
    A->>ES: 关键词检索历史文本
    A->>V: 向量检索语义片段

    R-->>A: 返回短期记忆
    DB-->>A: 返回结构化事实
    ES-->>A: 返回文本结果
    V-->>A: 返回语义结果

    A->>A: 合并、去重、排序、截断
    A->>L: 发送 Prompt 和 messages
    L-->>A: 返回生成结果
    A-->>C: 返回回复

Prompt 组装时要明确优先级:

优先级从高到低:

1. 本轮用户输入中明确表达的状态变更
2. Session State 中的当前会话状态
3. MySQL / MongoDB 中的强事实长期记忆
4. Elasticsearch 中召回的历史文本
5. 向量库召回的语义补充片段

如果低优先级信息与高优先级信息冲突,必须以高优先级信息为准。

一个简化版组装逻辑可以这样写:

async def build_agent_context(user_id, session_id, user_input):
    # 本轮输入先做轻量抽取,避免“刚说完就忘”
    current_delta = extract_session_delta(user_input)

    redis_task = get_session_memory(session_id)
    facts_task = get_user_facts(user_id)
    es_task = search_es_memories(user_id, user_input)
    vector_task = search_vector_memories(user_id, user_input)

    session_memory, user_facts, es_hits, vector_hits = await asyncio.gather(
        redis_task,
        facts_task,
        es_task,
        vector_task
    )

    session_state = merge_state(
        old_state=session_memory["state"],
        delta=current_delta
    )

    retrieved_memories = rerank_and_cut(
        es_hits=es_hits,
        vector_hits=vector_hits,
        max_tokens=1200
    )

    system_prompt = render_system_prompt(
        session_state=session_state,
        user_facts=user_facts,
        retrieved_memories=retrieved_memories
    )

    messages = session_memory["recent_messages"]

    return system_prompt, messages

这里有一个很重要的细节:本轮输入里的状态变更,要在调用模型前进入 Session State。

例如用户说“我改主意了,下周去东京,不吃海鲜”,本轮回复就应该立刻遵守“东京”和“不吃海鲜”。长期存储可以异步更新,但当前会话状态不能等消息队列慢慢消费。

异步写链路:用事件驱动更新长期记忆

记忆写入不适合全部放在主链路里。每轮对话都同步调用大模型总结、写数据库、写向量库,会让接口延迟和算力成本同时上升。

更常见的做法是:主链路完成回复后,把对话事件投递到 MQ(Message Queue,消息队列),由后台消费者异步分析和落盘。

flowchart LR
    A[主链路生成回复] --> B[投递记忆事件到 MQ]
    B --> C[记忆消费者]
    C --> D[轻量分类器]

    D -->|无记忆价值| X[丢弃]
    D -->|强事实变更| E[更新 MySQL/MongoDB]
    D -->|历史文本| F[写入 Elasticsearch]
    D -->|语义片段| G[生成 Embedding 写向量库]
    D -->|关系变更| H[写入图数据库]

    E --> I[ACK]
    F --> I
    G --> I
    H --> I

事件结构可以设计成这样:

{
  "event_id": "evt_20260607_0001",
  "user_id": 10001,
  "session_id": "s_abc",
  "user_input": "我改主意了,下周去东京,不吃海鲜",
  "assistant_output": "好的,我会按东京行程并避开海鲜来规划。",
  "created_at": "2026-06-07T12:00:00+08:00"
}

消费者拿到事件后,不要直接做大模型总结。更省成本的方式是先做轻量分类:

分类结果处理方式
普通闲聊不落长期记忆
临时上下文只更新 Redis Session State
强事实新增或变更写结构化数据库
长文本经验总结后写 Elasticsearch
模糊偏好生成 Embedding 后写向量库
实体关系写图数据库

例如分类器识别到“下周去东京,不吃海鲜”包含状态变更,可以输出标准指令:

{
  "actions": [
    {
      "type": "upsert_fact",
      "fact_type": "travel",
      "fact_key": "destination",
      "fact_value": {
        "city": "Tokyo",
        "time": "next_week"
      }
    },
    {
      "type": "upsert_fact",
      "fact_type": "diet_constraint",
      "fact_key": "seafood",
      "fact_value": {
        "avoid": true,
        "source": "user_explicit"
      }
    }
  ]
}

后台再根据 type 路由到对应存储。这样既减少无效写入,也避免每轮对话都触发昂贵的大模型总结。

一致性兜底:异步更新不能让 Agent 读到旧状态

异步写链路一定会遇到一致性问题。MQ 积压、消费者失败、数据库更新冲突,都可能导致长期记忆还没更新,而用户已经发起下一轮请求。

解决办法不是放弃异步,而是给不同重要级别的数据设置兜底策略。

问题兜底方案适用场景
长期记忆尚未更新Session State 优先于长期记忆同一会话内刚发生的状态变更
MQ 消费延迟写 Redis 临时事实缓存,设置短 TTL几分钟内必须生效的新状态
零容错数据不能延迟主链路同步写强事实库手机号、安全禁忌、严重过敏史
并发更新互相覆盖版本号 + 乐观锁多设备、多会话同时修改记忆
消费失败重试队列 + 死信队列 + 补偿任务后台落盘失败或超时

Session State 永远比旧长期记忆优先

同一会话里,用户刚说过的新状态必须优先使用。

例如长期记忆里保存的是:

{
  "destination": "Shanghai"
}

用户本轮说:

我改主意了,下周去东京。

哪怕 MySQL 还没更新,Session State 也应该变成:

{
  "destination": "Tokyo",
  "source": "current_session",
  "updated_at": "2026-06-07T12:00:00+08:00"
}

Prompt 组装时必须以 Session State 为准。

Redis 临时事实缓存

对需要短时间内立即生效、但又不想阻塞主链路的状态,可以写一份临时缓存:

key: agent:user:{user_id}:pending_facts
ttl: 5min

读取长期记忆时,合并顺序为:

Redis pending_facts > Session State > MySQL facts > ES / Vector

当 MQ 消费成功并写入数据库后,消费者再清理这份临时缓存。

关键数据同步写

不是所有记忆都应该异步。

像严重过敏史、手机号、安全限制、支付相关偏好这类零容错数据,应该同步写入强事实库,写成功后再返回或再继续后续流程。这样会增加一点延迟,但可以避免更严重的错误。

乐观锁防止旧数据覆盖新数据

强事实表里需要版本号:

UPDATE user_memory_fact
SET fact_value = :new_value,
    version = version + 1,
    updated_at = NOW()
WHERE user_id = :user_id
  AND fact_type = :fact_type
  AND fact_key = :fact_key
  AND version = :old_version;

如果更新行数为 0,说明数据已经被其他请求改过。消费者要重新读取最新数据,再决定是否重试或丢弃旧事件。

进阶优化:关系、过滤、离线画像和生命周期

基础架构稳定后,还可以继续补几类能力。

图数据库保存实体关系

关系型数据库适合保存单个用户的事实,但多实体关系会变复杂。

例如:

用户 A 的母亲是 B。
B 对海鲜过敏。
A 下周要和 B 一起去东京。

当用户 A 让 Agent 推荐餐厅时,系统要知道同行人 B 也有饮食禁忌。这类关系可以放到图数据库里:

graph LR
    A[用户A] -- 母亲 --> B[用户B]
    A -- 同行 --> T[东京旅行]
    B -- 过敏 --> S[海鲜]

图数据库更适合做关系遍历,再把结果变成约束写入 Prompt。

标量过滤再做向量检索

向量检索必须限制范围。推荐顺序是:

按 user_id 过滤
按 memory_type 过滤
按时间范围过滤
按业务标签过滤
再做向量相似度检索

这样可以避免把其他用户、其他场景、过期记忆召回进来。

实时链路和离线链路分工

实时链路负责明确表达的新事实,例如“我不吃海鲜”“我的目的地改成东京”。

离线链路适合从大量历史行为里挖掘隐性偏好,例如用户长期更喜欢简洁回答、经常选择预算型酒店、常在工作日晚上咨询某类问题。

两条链路不要混在一起:

链路数据来源更新频率适合内容
实时链路当前对话事件秒级到分钟级明确事实、当前状态
离线链路历史行为、对话日志小时级到天级隐性偏好、统计画像

记忆生命周期分级

不是所有记忆都应该永久保存。

记忆类型建议生命周期
当前会话状态会话结束后过期
临时计划任务完成后过期
用户明确事实长期保存,允许用户修改
模糊偏好定期衰减或重新计算
低价值闲聊不保存

长期记忆越多,召回成本和冲突概率越高。记忆系统不只要会“记住”,也要会“遗忘”。

落地检查清单

Agent 记忆系统可以按这份清单设计:

模块关键设计
短期记忆Redis 保存最近对话流和 Session State,两层分离
长期强事实MySQL / MongoDB 保存,确定性查询,最高优先级
历史文本Elasticsearch 保存,用关键词和 BM25 召回
语义记忆向量数据库保存,只做补充,必须加标量过滤
关系记忆图数据库保存多实体关系
读链路多路并发召回,合并去重,按优先级组装 Prompt
写链路MQ 异步更新,轻量分类后按类型落盘
成本控制避免每轮大模型总结,只在状态变更时触发记忆更新
一致性Session State 优先、Redis 临时缓存、关键数据同步写、版本号乐观锁
生命周期按记忆类型设置 TTL、衰减和删除策略

一句话概括:Agent 记忆不是一个向量库问题,而是一个后端状态管理问题。

LLM 负责推理和生成,Redis、数据库、搜索引擎、向量库、消息队列负责记忆的读取、更新和一致性控制。把模型当成计算节点,而不是存储节点,Agent 才能在真实业务里稳定工作。


评论