AI应用高级工程师 · 第 08 / 12 章

状态、记忆与多轮会话

多轮对话看起来像模型“记住了”,真实系统里却是应用保存并重新选择信息。本章把会话状态、短期上下文、长期记忆、并发冲突和隐私删除讲清楚。

Session StateLong-term MemoryTTLConcurrency
上一章
Agent工作流已经能分步骤执行并保存检查点。
→
本章解决
哪些信息应该保存在哪里,下一轮怎样恢复,多个请求同时更新时怎样不互相覆盖。
→
下一章
用评测、Tracing和线上指标证明整个系统是否可靠。
学完后的结果:你能设计清晰的会话与记忆数据模型,避免无限堆聊天、错误记忆、并发覆盖和隐私泄露。

本章目录

阅读过程中可按 Ctrl + Z 快速返回当前页面的本章目录;也可以点击右下角按钮。

本章思维导图

知识流向:先完成左侧基础,再进入中间机制,最后用右侧工程控制把它变成可上线能力。节点可直接点击跳转。

MODULE 01

状态与记忆边界

按照本模块顺序完成以下知识点,再进入下一模块。

M01状态、上下文、历史与记忆的边界

概念存在哪里作用
State 状态应用数据库/状态存储任务当前做到哪一步,哪些操作已完成
Context 上下文本次模型请求模型这一轮实际能看到的信息
History 历史聊天消息数据库保存过去消息,按需选取进入上下文
Memory 记忆受控长期存储跨会话保存经确认的偏好或事实
生活中的例子:上下文像本次会议桌上的材料;历史像档案柜;状态像项目进度表;记忆像确认过的长期客户档案。

M02多轮请求状态恢复

session_id定位会话
→
读取最近消息和摘要
→
加入当前问题/RAG/工具结果
→
调用模型
→
保存新消息与状态

模型通常不会自行访问你的历史数据库;是后端在每次请求前选择材料并发送。session_id由应用生成和校验,不能信任用户随便传入别人的会话ID。

生活中的例子:像酒店凭房卡号找回住宿记录,再把本次新需求补进当前服务单。

M03短期上下文裁剪

上下文窗口有限,不能无限发送全部历史。默认优先级:System安全规则 → 当前任务状态 → 当前问题 → 相关工具结果/RAG证据 → 最近必要对话 → 较早对话摘要。

删除重复、寒暄和已失效的中间推理。
将较早对话压缩为带来源的事实摘要。
保留未解决约束、用户已确认决定和真实工具结果。
为模型输出预留Token,超限时拒绝或分段。

摘要也是模型输出,可能漏掉否定词或金额;高风险事实必须从业务数据库重查。

生活中的例子:行李箱装不下所有物品时,先保留证件、药品和本次必需品,而不是随机丢东西。
MODULE 02

长期记忆管理

按照本模块顺序完成以下知识点,再进入下一模块。

M04长期记忆的保存边界

适合保存

用户明确确认的语言偏好、常用格式、稳定业务配置,并记录来源、创建时间、置信来源和删除入口。

默认不保存

模型猜测的身份、临时情绪、密码、银行卡、一次性验证码、未经同意的敏感信息。

写入原则:长期记忆由应用策略控制;模型可以提议“这可能值得记住”,程序按同意、敏感级别和有效期决定是否写入。

生活中的例子:可以记住顾客喜欢中文回复,不应记住一次性验证码或模型猜出的身份。

M05记忆的数据结构与来源追踪

{
 "memory_id":"mem-21",
 "user_id":"u-7",
 "type":"response_preference",
 "value":"喜欢先看结论再看原理",
 "source_message_id":"msg-99",
 "confirmed_by_user":true,
 "created_at":"2026-09-15",
 "expires_at":null
}

每条记忆需要稳定ID、归属用户、类型、来源、确认状态、创建与过期时间。这样才能解释“为什么记住”、修改和删除。

生活中的例子:像病历必须写来源、日期和医生签名,不能只留一句无法追溯的结论。

M06TTL、过期和遗忘机制

数据生命周期设置 TTL是Time To Live,表示数据还能存活多久。登录会话、临时任务和缓存适合TTL;稳定偏好可能无固定TTL,但也要允许用户删除。

会话状态:30分钟无活动后过期
Agent临时草稿:任务结束后7天删除
缓存答案:5分钟或知识版本改变立即失效
用户偏好:用户可查看、修改、删除

TTL到期是程序或存储系统执行删除,不是让模型“忘掉”。

生活中的例子:TTL像食品保质期,到期由仓库规则清理,不是让店员凭感觉忘记。
MODULE 03

一致性、隔离与隐私

按照本模块顺序完成以下知识点,再进入下一模块。

M07并发状态覆盖问题

用户快速发送两条消息,两个后端进程可能同时读取版本10,各自写入版本11,后写入的会覆盖前一个结果。这叫Lost Update,丢失更新。

默认做法:同一会话使用队列串行处理,或使用version字段做乐观锁。

UPDATE session SET state=?, version=11
WHERE session_id=? AND version=10

影响0行:说明别人已经更新,重新读取再决定,而不是强行覆盖。
生活中的例子:两个人同时编辑同一张表,后保存的人可能覆盖前一个;版本号能发现冲突。

M08记忆究竟放在哪里:默认存储架构与读写过程

先给结论:记忆通常不保存在大模型“脑子里”,而是保存在你自己的应用数据库中。退款客服项目默认用 PostgreSQL 保存权威记录,用 Redis 保存短期会话和缓存副本,只有需要“按意思找回”时才把记忆的向量与 memory_id 写进 向量库。每次调用模型前,由后端把需要的记忆读出来放进本轮 Prompt。

1. 四个位置分别保存什么

位置物理上是什么保存内容是否权威典型生命周期
PostgreSQL等关系数据库后端连接的持久化数据库服务用户、会话、消息、任务状态、长期记忆正文、来源、确认状态、版本、过期时间和审计记录是,默认真相来源按业务政策长期保存,支持事务、修改和删除
Redis独立运行的高速内存数据服务当前会话状态、最近消息缓存、分布式锁、幂等键、流式进度通常不是几分钟到几小时,通过TTL自动过期
向量数据库Milvus、Qdrant或pgvector等语义检索存储记忆向量、memory_id、user_id/tenant_id和少量过滤字段;可选保存短文本通常不是,属于检索索引跟随PostgreSQL中的权威记忆创建、更新和删除
本轮模型请求后端发送给模型API的messages/prompt本轮选中的少量历史、摘要、记忆和当前问题不是存储库只服务这次调用;下一轮要由后端重新读取和发送
生活中的例子:PostgreSQL像正式客户档案,Redis像前台手边的临时便签,向量库像按意思查档案的目录卡,Prompt像这次接待时摊在桌面上的几页材料。桌面材料用完不会自动变成永久档案。

2. 一条长期记忆在数据库里的真实形状

用户说“以后回答先给结论”,并确认希望长期保存。后端可以在 PostgreSQL 的 user_memories 表写入:

user_memories
┌──────────────────┬────────────────────────────────┐
│ memory_id        │ mem-21                         │
│ tenant_id        │ company-a                      │
│ user_id          │ u-7                            │
│ type             │ response_preference            │
│ value            │ 喜欢先看结论再看原理           │
│ source_message_id│ msg-99                         │
│ confirmed        │ true                           │
│ version          │ 1                              │
│ status           │ active                         │
│ created_at       │ 2026-09-21 10:30:00            │
│ expires_at       │ null                           │
└──────────────────┴────────────────────────────────┘

value 是记忆正文;source_message_id 证明从哪句话得来;confirmed 表示是否由用户确认;status/version 用于修改和并发控制;expires_at 决定是否过期。这些不能只放在向量库里,因为还要可靠地修改、审计和删除。

3. 为什么向量库里还会有一份

如果只有“语言=中文”“回复格式=先结论”这类固定字段,可以直接按 user_id 从 PostgreSQL 查询,不需要向量库。只有记忆数量很多、用户问法不固定,需要按语义寻找时,才增加向量索引:

向量库中的索引记录
{
  "memory_id": "mem-21",
  "tenant_id": "company-a",
  "user_id": "u-7",
  "embedding": [0.018, -0.227, ...],
  "status": "active"
}

搜索命中 mem-21 后,后端最好再回 PostgreSQL 检查它是否仍为 active、有没有过期、用户是否仍有权限,再取得权威正文。向量库中的记录更像索引,不应成为唯一真相来源。

生活中的例子:图书馆目录卡写着“某本书在A区12号”,但目录卡不是书本身。找到目录后仍要去书架确认书是否存在、是否已下架。

4. 写入记忆时,程序实际做什么

用户明确表达偏好
→
模型可提议记忆
→
程序检查同意/敏感性/重复
→
写PostgreSQL
→
可选写向量索引
并清理Redis缓存
模型可以输出“候选记忆”,但不能直接写数据库。
后端检查用户是否同意、内容是否敏感、是否重复、允许保存多久。
在PostgreSQL事务中写入权威记录,获得稳定的 memory_id。
如果需要语义检索,调用Embedding模型并把向量和 memory_id 写入向量库。
删除或刷新Redis中的旧缓存,避免下一轮仍读到修改前的偏好。

模型没有数据库写权限:模型最多提出“值得记住”,真正写入由后端工具和确定性策略执行。否则Prompt注入可能诱导系统保存错误或敏感信息。

5. 下一轮对话时,记忆怎样回到模型眼前

用户新问题
→
鉴权得到
tenant_id/user_id
→
读Redis短期状态
读PostgreSQL固定偏好
→
必要时向量检索
长期记忆候选
→
权限/过期/来源校验
→
组装Prompt
调用模型
# 通用伪代码:运行在AI应用后端,不在模型内部
identity = authenticate(request)
session = redis.get(f"session:{identity.user_id}:{session_id}")
preferences = postgres.load_active_preferences(
    tenant_id=identity.tenant_id,
    user_id=identity.user_id,
)

memory_candidates = vector_store.search(
    query=current_question,
    filter={
        "tenant_id": identity.tenant_id,
        "user_id": identity.user_id,
        "status": "active",
    },
)

verified_memories = postgres.verify_and_load(memory_candidates)

messages = build_prompt(
    system_rules=SYSTEM_RULES,
    session_state=session,
    preferences=preferences,
    memories=verified_memories,
    current_question=current_question,
)

answer = llm.chat(messages=messages)

模型为什么表现得像“记得”?因为后端在这次 API 请求的 messages 中重新放入了“用户喜欢先看结论”等内容。模型如果没收到这些内容,通常就不知道。

6. 系统究竟怎样判断“当前需要哪几条记忆”

关键不是让模型先阅读全部记忆再挑选。那仍然等于全量加载。正确做法是在调用聊天模型之前,由后端程序和数据库完成分层检索,只把最终入选的少量文字放入 Prompt。

要做到这一点,写入记忆时就不能只存一句正文,还要同时保存便于筛选的字段:

字段含义选择记忆时怎样使用
scope作用范围,如 global、project、session判断是每轮固定偏好、某项目记忆,还是只属于当前会话
typepreference、task_fact、decision等类型决定使用精确查询还是语义检索
subject_key同一事实的稳定键例如 refund_project.framework,用于发现新旧冲突
project_id归属哪个项目当前正在退款项目时,优先限制到该项目
importance业务设定的重要级别安全约束、用户明确决定比闲聊摘要优先
version/status版本与是否有效同一subject只取最新active版本
created_at/last_used_at创建和最近使用时间在相关度接近时,可让较新或常用记忆优先
embedding记忆正文的语义向量与当前问题向量比较,召回意思相关的任务记忆

7. 第一步:先分三条通道,不是所有记忆都用同一种查法

A. 固定记忆

语言、回复格式、无障碍需求等少量稳定偏好。用SQL按 user_id + type 精确读取,通常每轮带上,不需要向量搜索。

B. 当前会话状态

正在处理的订单号、工作流步骤、刚确认的选择。用 session_id 从Redis或状态表精确读取,只属于当前会话。

C. 大量长期经历

以前讨论过的项目、决定和任务事实。数量可能很多,才使用“metadata过滤 + 向量召回 + 重排”。

生活中的例子:医生看诊时,姓名和过敏史是固定通道;今天量出的体温属于当前就诊通道;多年病历才需要按“这次症状”搜索相关记录。三类资料不是混成一堆再随机挑。

8. 第二步:把当前问题变成“检索条件”

假设用户问:“继续修改上周的退款客服方案,把回答改成先给结论。”后端从登录状态和当前会话中已经知道:

tenant_id = "company-a"
user_id   = "u-7"
project_id = "refund-assistant"
session_id = "session-88"
current_question = "继续修改上周的退款客服方案,把回答改成先给结论"

这些确定性ID直接用作数据库过滤;当前问题和必要的任务摘要则用于生成查询向量。不能让向量相似度代替身份权限过滤。

# 在Embedding API或本地Embedding模型中执行
memory_query_text = """
当前项目:退款客服助手
当前任务:修改上周方案
用户问题:继续修改上周的退款客服方案,把回答改成先给结论
"""

query_vector = embedding_model.embed(memory_query_text)

9. 第三步:先做硬过滤,再做向量Top-k

向量库不是在全公司的所有记忆中搜索。程序同时传入查询向量和确定性过滤条件:

candidates = memory_vector_store.search(
    vector=query_vector,
    filter={
        "tenant_id": "company-a",
        "user_id": "u-7",
        "project_id": "refund-assistant",
        "status": "active",
    },
    top_k=20,
)

数据库先排除其他用户、其他项目、已删除和失效记忆,再在剩余集合中比较语义相似度。top_k=20 表示只拿20条候选供下一步精排,不代表这20条都会进入Prompt。

10. 第四步:候选怎样重新打分

向量相似度只回答“意思像不像”,还要结合重要性、新鲜度和明确的业务规则。下面的总分公式只是一个可解释起点,权重必须用真实评测调整:

final_score =
    0.65 × semantic_relevance   # 与当前问题的语义相关度
  + 0.20 × importance           # 用户确认决定/安全约束更重要
  + 0.15 × recency              # 同等相关时较新内容优先
候选记忆语义相关重要性新鲜度规则处理结果
A:退款客服采用RAG方案0.930.900.80active保留,约0.90
B:用户喜欢先给结论0.681.000.95固定偏好,强制通道已读取去重后保留一份
C:旧方案使用纯关键词搜索0.880.700.20被新版本 superseded淘汰
D:用户上周讨论健身计划0.150.500.90低于相关阈值淘汰
E:退款操作必须二次确认0.761.000.85安全约束保留

实际项目可以用什么做重排?简单项目用普通程序公式和规则即可;记忆正文较复杂时,可以把20条候选交给第5章讲过的Reranker,根据“当前问题—记忆”相关性重新排序。无论哪种方式,都不是让最终聊天模型先看1000条。

11. 第五步:新旧记忆冲突怎样处理

同一个事实必须有稳定的 subject_key。例如:

旧记忆:
subject_key = "refund_project.retrieval_strategy"
version = 1
value = "只使用关键词搜索"
status = "superseded"

新记忆:
subject_key = "refund_project.retrieval_strategy"
version = 2
value = "使用向量+BM25+Rerank"
status = "active"

后端按 subject_key 分组,只留下最高的active版本。不能仅凭“新记忆更相似”解决冲突,因为旧事实也可能与当前问题非常相似。

12. 第六步:用Token预算把候选装进Prompt

经过过滤和重排后可能还剩8条,但本轮给“记忆区”只分配800 Token。程序按优先级逐条加入,下一条放不下就跳过、截短或使用已有摘要;绝不能挤掉System安全规则和当前问题。

记忆区预算:800 Token

固定偏好:
- 使用中文                           8 Token
- 回答先给结论                     12 Token

任务记忆候选:
- RAG方案决定                     120 Token
- 工具操作必须二次确认             80 Token
- 上周未完成事项                   150 Token
- 页面风格决定                     90 Token
- 很长的历史讨论摘要              600 Token  ← 放不下,跳过或压缩

最终只发送前面6条,总计约460 Token。

为什么不是固定“取5条”就结束?五条短记忆和五条长记忆占用完全不同。工程上要同时设置候选数、最低相关阈值和Token预算。

13. 把整套逻辑写成接近真实的代码

def select_memories(identity, session_id, project_id, question):
    # 1. 精确读取:少量固定偏好
    fixed = postgres.query_memories(
        tenant_id=identity.tenant_id,
        user_id=identity.user_id,
        scope="global",
        type="preference",
        status="active",
    )

    # 2. 精确读取:当前会话状态
    session_state = redis.get(
        f"session:{identity.tenant_id}:{identity.user_id}:{session_id}"
    )

    # 3. 当前问题变成向量
    query_text = build_memory_query(
        project_id=project_id,
        session_state=session_state,
        question=question,
    )
    query_vector = embedding_model.embed(query_text)

    # 4. 硬过滤后做语义召回
    candidates = vector_store.search(
        vector=query_vector,
        filter={
            "tenant_id": identity.tenant_id,
            "user_id": identity.user_id,
            "project_id": project_id,
            "status": "active",
        },
        top_k=20,
    )

    # 5. 回权威库校验版本、过期、来源和权限
    verified = postgres.verify_memory_candidates(candidates)

    # 6. 去重、消除新旧冲突并重新排序
    ranked = rank_and_resolve_conflicts(
        verified,
        question=question,
    )

    # 7. 固定偏好 + 相关记忆,在预算内装入Prompt
    return pack_by_token_budget(
        fixed=fixed,
        ranked=ranked,
        max_memory_tokens=800,
    )

真正的“需要”是谁判断的?不是某一个神奇模型一次决定,而是多层共同决定:身份和状态字段做硬过滤,Embedding做粗召回,规则或Reranker做精排,版本字段解决冲突,Token预算决定最终能装多少。

生活中的例子:图书管理员先凭借书证限定你能看的馆藏,再按书名主题搜索20本,排除旧版和重复版,优先选择最相关的新版本,最后根据桌面空间只拿几本给你。这个过程就是“加载当前需要的少量记忆”。

14. 如何证明这套选择逻辑有效

准备记忆检索评测集:每条包含当前问题、身份、项目、当时有效记忆和人工标注的应召回/不应召回 memory_id。比较 Recall@k、错误用户记忆泄露数、旧版本命中数、最终Prompt Token、延迟和答案质量。调 top_k、阈值和权重时用开发集,最终用独立测试集验收。

默认推荐:固定偏好用结构化SQL精确读取;当前会话用session_id精确读取;大量任务记忆用“metadata硬过滤 → 向量Top-20 → 规则/重排 → 冲突消解 → 800 Token预算”。数字只是教学起点,必须经过真实评测调整。

15. 一个项目中的默认数据分工

推荐

PostgreSQL:永久消息、长期记忆、状态和审计。
Redis:30分钟会话、锁、幂等和缓存。
向量库:大量长期记忆的语义索引。
对象存储:用户上传的图片、音频和大附件。

不推荐

所有聊天永远塞进Prompt;把Redis当唯一永久库;只存向量不存原始记忆;让LLM自行决定读写;删除PostgreSQL后忘记清理向量索引和缓存。

16. 删除一条记忆时要删哪些地方

删除不能只隐藏网页上的文字。后端应先把 PostgreSQL 权威记录标记删除或按政策物理删除,再删除对应 memory_id 的向量索引、清除 Redis 缓存,并按照备份保留政策完成后续清理。各副本通过同一个 memory_id 关联。

最终原则:权威内容只认一个来源,其他位置是缓存或索引。否则PostgreSQL说“用户喜欢中文”,向量库却残留“用户喜欢英文”,系统无法判断谁是真的。

M09隐私、租户隔离和删除

读取记忆前必须用当前登录user_id和tenant_id过滤;导出、删除、保留期限和用途要符合产品政策。日志中避免记录原始敏感内容,可保存脱敏摘要和引用ID。

删除必须贯穿副本:主数据库、向量索引、缓存、搜索索引和依法应删除的备份流程都要考虑。只删聊天界面不等于真正删除。

生活中的例子:像搬家后要求销毁旧地址资料,不能只撕掉桌面一张纸,缓存和副本也要处理。

M10本章完整记忆链

鉴权会话
→
读取状态/必要历史
→
裁剪上下文+检索记忆
→
模型响应
→
带版本保存+生命周期管理

数据库保存,程序选择,模型只看到本轮被选中的部分;长期记忆必须可追踪、可修改、可删除。

生活中的例子:像秘书先从档案中选出本次会议需要的材料,会后再按版本把新决定归档。

本章总结:从哪里来,解决什么,为什么继续

上一章留下的问题怎样让会话连续且数据一致
本章形成的能力数据库负责保存,程序负责选择,模型只看到本轮上下文;记忆必须可追踪、可删除。
下一章必须继续解决怎样证明整个系统的质量

本章参考的权威资料

课程讲的是跨厂商通用工程方法;具体参数会更新,真正开发时再以所选厂商最新官方文档为准。

← 上一章Agent工作流与可靠执行下一章 →评测、可观测性与持续改进
↑ 返回本章目录Ctrl+Z