RAG 的对话状态管理:多轮对话中的上下文压缩与继承机制

对话状态是个工程对象,不是"聊天记录"

多轮对话 RAG 的文章大多聚焦在检索侧:怎么用历史改写当前查询、怎么消解指代。这些当然重要,但生产系统里多轮对话真正容易崩的地方在状态管理:会话状态被实现成一个 messages 数组原样传递,轮次一多就面临成本失控、上下文丢失、检索污染三重问题。把对话状态当成"聊天记录"来建模,是多轮 RAG 从 demo 走向生产的第一道坎。

状态的五层结构

生产级对话状态应该是一个分层的结构化对象,每层有不同的生命周期和消费方式:

  • 用户画像层:跨会话的长期偏好与属性(所属业务线、语言偏好、权限上下文)。生命周期以天到月计,来源是显式档案与长期行为统计。
  • 任务层:当前会话在办什么事、办到哪一步——任务槽位(预算 5000 元、城市杭州、目标机型轻薄本)与任务阶段(澄清中/检索中/已给出候选)。生命周期与会话一致,是澄清提问和检索过滤的直接依据。
  • 检索层:本会话已经发出过哪些查询、命中过哪些 chunk、哪些被引用、哪些被用户否定。这是最常被漏掉的层,但它恰恰是下一节要讲的检索继承机制的数据基础。
  • 事实层:会话内被用户显式确认的事实("对,我要的是企业版""时间就定在下周三")。它们必须逐字保留优先级最高,任何压缩都不允许触碰。
  • 上下文层:窗口内的原始轮次文本。这是唯一可以大幅压缩的层。

五层结构的全貌与各自的生命周期如下图:

flowchart TB
    subgraph L1["用户画像层 —— 生命周期:天到月(跨会话)"]
        P[偏好 / 业务线 / 权限上下文]
    end
    subgraph L2["任务层 —— 生命周期:与会话同寿"]
        T[任务槽位 + 任务阶段 + 任务栈]
    end
    subgraph L3["检索层 —— 生命周期:会话内滚动"]
        R[已见 chunk 集合 + 焦点栈 + 最近命中]
    end
    subgraph L4["事实层 —— 禁止压缩"]
        F[用户逐字确认的事实]
    end
    subgraph L5["上下文层 —— 唯一可大幅压缩"]
        C[窗口内原始轮次]
    end
    P --> T --> R
    T -->|槽位驱动| R
    R --> AS[上下文组装]
    F --> AS
    C -.软/硬压缩.-> C
    AS --> G[LLM 生成]

分层的关键收益是压缩策略可以按层定制:上下文层可以摘要化、检索层可以裁剪、任务层只能结构化更新、事实层禁止压缩。一个扁平的 messages 数组里做压缩,等于对五层不加区分地一起处理——摘要模型不小心把"预算 5000"概括成"预算几千",下一轮检索的 filter 就全错了。

为什么"全量历史塞 prompt"会崩

最简单的实现——把全部历史轮次和全部检索结果原样塞进 prompt——在长会话上有三个可量化的问题。第一是成本:上下文 token 随轮次线性增长,检索结果又在每轮追加,20 轮的会话单次推理成本可以是首轮的十倍以上。第二是质量:Lost in the Middle(Liu 等人 TACL 2024)的实验表明,长上下文中间位置的信息召回率显著低于首尾——历史轮次堆在中间,等于花了钱还买不到注意力。第三是检索污染:历史轮次里注入过的 chunk 如果继续留在上下文里,会被新一轮的查询改写模块"看到",改写出的查询偏向历史内容而不是用户的最新意图——这是多轮检索质量劣化的常见暗因,比 token 成本更隐蔽。

状态继承:让每一轮检索知道之前发生了什么

检索状态的继承:已见集合与增量检索

检索层状态最有价值的用途是增量检索:轮次 N 的检索不需要从零开始,它要知道轮次 1 到 N-1 已经注入过哪些 chunk。机制是维护一个会话级已见集合(chunk_id 集合),新一轮检索后把已见 chunk 降权或过滤,只补充新内容。这解决两个实际问题:重复注入浪费窗口(同一个制度段落被三轮检索各注入一次);以及更微妙的"不要重复"约束对排序的正向影响——用户第二轮问"那另一种方案呢",向量检索可能再次召回第一轮已给过的方案(语义确实相近),已见集合的惩罚让真正的新内容浮上来。

与已见集合配套的是实体焦点栈:当前对话正在讨论的实体集合(按最近提及排序),它是查询改写时指代消解的锚点——"它的价格呢"里的"它"从焦点栈顶取。焦点栈的更新规则要保守:只入明确实体链接成功的提及,栈深限制在 3 到 5 个,否则"它"的指代会在多个候选之间漂移。CAsT(Dalton 等人 2020 年起的对话式检索评测系列)的标注数据分析反复证明:多轮检索失分的最大来源是指代消解错误,而不是查询本身写得差——焦点栈正是把消解从"模型自由发挥"变成"结构化约束"的手段。

任务槽位驱动检索过滤器

任务层的槽位不应该只用于生成,它应该直接驱动检索参数:预算槽位映射成商品库的价格 filter,城市槽位映射成地域 filter,版本槽位映射成文档版本 scope(这与版本管理那一篇的 version_scope 衔接)。槽位到 filter 的映射是显式代码而不是 prompt 约定——prompt 约定的"请在检索时考虑预算"是靠模型自觉,槽位驱动 filter 是靠架构保证。两者的可靠性差距,在槽位冲突的场景(用户改了预算)里会放大成检索结果的可信度差距。

槽位还有第三个作用:触发澄清。槽位不完整且查询模糊时,系统该提问而不是硬答——"预算大概什么范围?"这类澄清的本质是任务层的完整性检查,把它做成显式规则(必要槽位缺失 + 查询歧义度高于阈值 → 澄清),比指望生成模型自己"懂得提问"稳定得多。

任务切换与休眠任务

真实会话不是单线程的:用户聊着报销制度,中途插进一个考勤问题,然后回到报销。状态管理要处理任务切换:任务层的实现应该是任务栈而不是单任务槽位——插入新问题时压栈新任务(识别依据是实体域突变加意图类型变化),旧任务转入休眠(槽位冻结保留);用户显式回切("回到刚才报销的事")或新任务完结时弹栈恢复。休眠任务要配时间预算:超过若干轮未回切的休眠任务直接关闭并归档,避免栈无限膨胀;归档时把槽位与已检索结果写入会话摘要——用户两天后回来问"接着上次说的",摘要提供了恢复点。任务栈的深度通常不超过 2 层就够:更深的嵌套说明用户自己在随意跳转,硬建模反而扩大误判面。

上下文压缩:结构化状态优于自由摘要

什么时候压、压什么

压缩的触发用双阈值:上下文层的 token 数超过预算的 70% 触发软压缩(旧轮次摘要化),超过 90% 触发硬压缩(只保留最近 3 轮原文 + 结构化状态)。压缩的对象永远只有上下文层;事实层原样保留;任务层不摘要而是结构化更新(槽位变化直接覆写字段);检索层裁剪成"已见 chunk id 集合 + 最近一轮的命中列表"。

为什么结构化状态比摘要可靠

常见的压缩实现是让 LLM 把旧对话总结成一段话。它有一个系统性的坑:摘要是有损压缩,而损失分布不可控——摘要模型无法知道"预算 5000"是任务槽位、"下周三"是事实层承诺,它可能把两者都概括掉或者模糊化。MemGPT(Packer 等人 2023)的分层内存设计、Generative Agents(Park 等人 2023)的记忆流加反思机制,都指向同一个结论:长期记忆的可靠性来自结构化组织,而非更聪明的自然语言总结。生产系统的做法是双轨:结构化状态(槽位、事实、已见集合)由代码精确维护,摘要只负责"对话氛围与意图演变"这类真正适合自然语言的部分,且摘要 prompt 必须显式列出"以下字段不许改动"的保护清单。

@dataclass
class SessionState:
    slots: dict          # 任务层:{"budget": 5000, "city": "杭州"}
    facts: list[str]     # 事实层:逐字保留的用户确认
    seen_chunks: set     # 检索层:已注入的 chunk id
    focus_stack: list    # 实体焦点栈,最近优先
    summary: str | None  # 上下文层:旧轮次摘要(可 None)

    def to_prompt(self) -> str:
        parts = []
        if self.summary:
            parts.append(f"[此前对话摘要]\n{self.summary}")
        parts.append("[当前任务状态]\n" + json.dumps(self.slots, ensure_ascii=False))
        if self.facts:
            parts.append("[已确认事实]\n" + "\n".join(self.facts))
        return "\n\n".join(parts)   # 事实层永远原样注入,绝不进摘要

压缩后的一致性校验

压缩完成的那一刻要过一道廉价的一致性校验:槽位字段在压缩产物里是否仍可追溯(预算、城市这类关键值有没有被摘要吞掉)、事实层条目数量是否与压缩前一致、已见集合是否原样保留。校验用正则与字段比对实现,不依赖模型判断,毫秒级完成。校验失败的压缩结果直接丢弃并回退到压缩前快照——宁可这一轮用未压缩的上下文硬顶,也不让一个丢槽位的坏摘要污染后续所有轮次。这道校验的成本可以忽略,拦下的却是整类"摘要悄悄丢状态"的静默故障:没有它,用户会在第三轮突然发现系统忘了预算,而你永远查不出是哪一次压缩丢的。

存储与恢复:会话的幂等与并发

状态快照与断线续聊

状态对象要带版本号落盘(每个用户轮次处理完写一次快照),移动端断线重连、服务重启、灰度发布滚动重启都依赖快照恢复。快照要记录消息序号,客户端重试或乱序到达时按序号幂等去重——移动网络下的重发是常态而非异常,没有序号去重的会话状态会出现"同一句话处理两遍、槽位被覆写两次"的幽灵 bug。另一个细节:快照写入与答案发出之间不是原子的,恢复逻辑要能容忍"快照停在答案发出前"的状态(重放最后一轮的检索而不是重问用户)。

压缩本身的成本账

压缩是一次 LLM 调用,有自己的延迟与成本。把它放在答案返回后的异步阶段执行(本轮答案先返回,压缩结果供下一轮使用),避免压缩延迟加在用户等待路径上;会话空闲超时(比如 10 分钟无新消息)也可以触发提前压缩,把下一轮的首字延迟压下来。成本上,压缩调用的频率是触发阈值(70%/90%)的直接函数——阈值调得太低,压缩调用反而成为新的成本大头。经验值是让一次压缩处理的"待压缩轮次"不少于 4 轮,摊薄调用成本。

小结

对话状态管理的核心是把状态从"聊天记录数组"升级为五层生命周期分明的结构化对象:画像层跨会话、任务层与会话同寿、检索层支撑增量检索、事实层禁止压缩、上下文层承担压缩火力。继承机制上,已见 chunk 集合实现增量检索并自然表达"不要重复",实体焦点栈把指代消解从模型自由发挥变成结构化约束——CAsT 的分析说明指代错误是多轮检索失分的最大来源,值得用架构手段硬保障。压缩策略上,结构化槽位与逐字事实必须由代码维护,摘要只处理意图演变这类软信息,触发走双阈值、执行走异步,别让压缩本身变成新的延迟源。工程底座是带消息序号的幂等快照,兜住断线、重试与滚动重启。这套设计的总原则可以概括为一句话:让每一轮对话都精确知道"之前确定过什么、检索过什么、正在办什么",而不是把一串文本丢给模型碰运气——多轮对话的可靠性来自状态的结构化,这正是它区别于"能聊天的 demo"的地方。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?