news 2026/10/27 2:03:41

Agent 每日一题:企业级 RAG 多轮对话如何处理?状态管理 + 缓存复用 + 校验兜底

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Agent 每日一题:企业级 RAG 多轮对话如何处理?状态管理 + 缓存复用 + 校验兜底

1. 面试题还原

面试官问:多轮对话如何处理?

一个场景题:如果一个员工问「工作时长是多少?年假是多少?调休是多少?」,接着又问「这个怎么算?」——你的「这个」是怎么处理的?怎么让大模型知道「这个」指代什么?

这道题表面在问「多轮对话」,实际在考三件事:

  • 指代消解:能不能把「这个」还原成上一轮的具体实体(工作时长 / 年假 / 调休)。
  • 状态管理:多轮信息存在哪、怎么更新、怎么不串味。
  • 工程兜底:模型抽错了怎么办、缓存怎么复用、答不出来怎么降级。

一句话总结答题主线:状态管理 + 缓存复用 + 校验兜底。

2. 为什么多轮对话难:问题本质

单轮 RAG 的链路是「问题 → 检索 → 生成」,问题本身是自包含的。多轮对话里,用户的问题往往是不完整的:

轮次用户输入是否自包含缺失信息
第 1 轮工作时长是多少?是无
第 2 轮年假是多少?是无
第 3 轮调休是多少?是无
第 4 轮这个怎么算?否「这个」指代谁

如果直接把「这个怎么算?」丢给检索,向量库里全是「这个」「怎么算」这种无信息量的词,召回必然跑偏。所以多轮对话的核心不是「让模型更聪明」,而是在检索之前把问题补全成自包含的 query。

3. 核心思路:把「指代」变成「槽位」

不要指望大模型在生成阶段自己「记住」上下文,正确做法是:把对话状态从模型里抽出来,落到一张显式的状态表上。

状态表的基本形态:

{"session_id":"sess_10086","slots":{"current_topic":"调休","last_entity":"调休","last_intent":"query_rule","mentioned_entities":["工作时长","年假","调休"]},"turn":4}

关键设计原则:

  • 每个指代映射一个字段:这个/它/上面那个统一映射到last_entity,而不是让模型自由发挥。
  • 状态表本身从模型里抽出来:模型只负责「抽取」,不负责「存储」。存储由业务侧掌控,可审计、可回滚、可测试。
  • 同槽位换值,更新状态表:第 2 轮问年假,last_entity从「工作时长」覆盖为「年假」;第 3 轮问调休,再覆盖为「调休」。第 4 轮的「这个」自然就指向「调休」。

4. 状态管理:状态表怎么建、怎么更新

4.1 状态表字段设计

字段含义示例
current_topic当前讨论主题调休
last_entity最近一次提到的实体,指代消解的首选目标调休
last_intent上一轮意图query_rule
mentioned_entities本会话提到过的实体列表,用于「上面那个」这类模糊指代[工作时长, 年假, 调休]
pending_slot待补全的槽位(多轮填槽场景)null
turn轮次计数4

4.2 每轮的处理流程

含指代

不含指代

用户输入

指代检测:是否含 这个/它/上面那个

查状态表 last_entity

正常抽取实体与意图

改写为自包含 query

更新状态表:同槽位换值

进入检索与生成

以场景题为例,第 4 轮的改写过程:

  • 原始输入:这个怎么算?
  • 命中指代规则:这个→last_entity
  • 状态表读出:last_entity = 调休
  • 改写结果:调休怎么算?

改写后的 query 才是真正送进检索的 query,召回准确率立刻回到单轮水平。

4.3 同槽位换值的注意点

  • 覆盖而非追加:last_entity是「最近一次」,必须覆盖,否则指代会指向最早提到的实体。
  • 保留历史列表:mentioned_entities用于处理「刚才说的第二个」这类序号指代。
  • 话题切换要重置:用户从「调休」跳到「报销流程」时,current_topic更新,last_entity同步更新,避免跨话题串味。

5. 缓存复用:别让每轮都重新算

多轮对话里大量请求是重复或近似重复的,缓存能显著降本增效。

5.1 三层缓存

层级缓存内容Key 设计命中收益
L1 改写缓存指代改写结果session_id + turn + raw_query避免重复调模型做改写
L2 检索缓存检索到的文档块normalized_query的哈希省一次向量检索
L3 答案缓存最终回答normalized_query + 知识库版本直接返回,省整条链路

其中L2 检索缓存值得单独说明一下:

  • 缓存内容:L2 存的不是最终答案,而是向量检索返回的文档块(chunk)列表,也就是「这个 query 该召回哪几段知识」这一步的结果。
  • Key 设计:用normalized_query的哈希做 Key。normalized_query是改写并标准化后的自包含 query(比如「调休怎么算?」),先做归一化(去空格、统一大小写、统一标点),再取哈希作为缓存键。这样同一个问题无论来自哪个会话、哪一轮,只要表述一致,就会落到同一个 Key 上。
  • 命中收益:命中后直接拿到文档块,省掉一次向量检索(embedding 编码 + 向量库近邻搜索),既降延迟又省算力。
  • 注意:L2 的 Key 通常还要叠加知识库版本号(如hash(normalized_query) + KB_VERSION),否则知识库更新后会返回过期的文档块。

一句话:L2 缓存的是「query → 召回文档块」的映射,用标准化 query 的哈希当 Key,命中就跳过向量检索这一步。

哈希到底怎么算:一个可跑的例子

上面说「用normalized_query的哈希做 Key」,很多人卡在这一步:哈希到底怎么算?其实就三步——归一化 → 编码成字节 → 取摘要。

importhashlibimportreimportunicodedatadefnormalize(query:str)->str:# 1) Unicode 归一化:全角转半角、兼容字符统一(NFKC)q=unicodedata.normalize("NFKC",query)# 2) 去首尾空白,压缩连续空白为一个空格q=re.sub(r"\s+"," ",q).strip()# 3) 统一小写(英文场景;中文不受影响)q=q.lower()# 4) 去掉句末标点,避免「调休怎么算?」和「调休怎么算」算成两个 Keyq=q.rstrip("??。.!!")returnqdefquery_hash(query:str)->str:normalized=normalize(query)# 必须显式指定 utf-8,否则不同机器默认编码不同,哈希会漂移digest=hashlib.sha256(normalized.encode("utf-8")).hexdigest()returndigest[:16]# 截断到 16 位,碰撞概率足够低,Key 也更好读defbuild_cache_key(query:str,kb_version:str)->str:returnf"rag:l2:{query_hash(query)}:{kb_version}"

跑一下看效果:

print(normalize(" 调休怎么算? "))# 调休怎么算print(normalize("调休怎么算"))# 调休怎么算print(query_hash("调休怎么算?"))# 3f9a1c0b7d2e4a58(示例值)print(query_hash("调休怎么算"))# 3f9a1c0b7d2e4a58(与上一行相同)print(build_cache_key("调休怎么算?","v2024_10"))# rag:l2:3f9a1c0b7d2e4a58:v2024_10

几个容易踩的坑,顺手记一下:

  • 一定要先归一化再哈希。直接对原始字符串取哈希,「调休怎么算?」「调休怎么算」「 调休怎么算? 」会得到三个完全不同的 Key,缓存命中率直接崩掉。
  • 编码必须写死utf-8。str.encode()不传参数时用的是平台默认编码,Windows 和 Linux 上算出来的字节可能不一样。
  • 用sha256而不是 Python 内置的hash()。内置hash()每个进程启动时带随机盐(PYTHONHASHSEED),重启后同一个字符串的哈希就变了,缓存全部失效;而且它只在单进程内有效,多实例部署根本对不上。
  • 截断要谨慎。截到 16 位十六进制(64 bit)在千万级 Key 规模下碰撞概率仍然极低;但如果你的缓存量级到十亿,建议保留 32 位。
  • 版本号放在哈希外面。KB_VERSION拼在 Key 尾部而不是参与哈希,这样知识库更新时只需换版本号,旧 Key 自然不再被访问,配合 TTL 就能自动清理。

如果不想自己写,也可以直接用hashlib.blake2b(normalized.encode("utf-8"), digest_size=8).hexdigest(),比 sha256 更快,8 字节摘要正好对应 16 位十六进制。

5.2 缓存复用的关键:用改写后的 query 做 Key

这是最容易踩坑的地方。如果拿原始 query「这个怎么算?」做缓存 Key,不同会话的「这个」会互相污染。正确做法是用改写后的自包含 query 做 Key,并且匹配策略上以精确匹配为主、语义匹配为辅:先拿标准化后的 query 做精确匹配,精确匹配不中再用语义相似度兜底,但必须设置相似度阈值(例如 0.92),低于阈值一律视为未命中,宁可多算一次也不能返回错答案。

SIM_THRESHOLD=0.92# 语义兜底阈值,低于此值视为未命中defbuild_cache_key(state,rewritten_query):# 改写后的 query 已自包含,可跨会话复用normalized=normalize(rewritten_query)returnf"rag:{hash(normalized)}:{KB_VERSION}"deflookup(cache,rewritten_query,embedder):normalized=normalize(rewritten_query)# 1) 精确匹配为主hit=cache.get_exact(normalized)ifhitisnotNone:returnhit# 2) 语义匹配为辅,必须过阈值vec=embedder.encode(normalized)cand,score=cache.get_nearest(vec)ifcandisnotNoneandscore>=SIM_THRESHOLD:returncandreturnNone

这样「调休怎么算?」无论来自哪个会话,都能命中同一份缓存。

5.3 缓存失效

  • 全局缓存必须带 TTL 或知识库版本号:KB_VERSION变化时全局缓存立即失效,同时设置 TTL 兜底,防止知识库热更新未改版本号时长期返回旧答案。
  • 会话缓存只在当前用户生效:L1 改写缓存按session_id(或user_id)隔离,单个会话的缓存不跨用户共享,避免 A 用户的指代结果被 B 用户命中。
  • 复用命中要加语义比对:即使 Key 命中,也要对命中结果与当前 query 做一次语义比对,确认指向同一实体后再复用,防止「调休怎么算」误命中「年假怎么算」。

6. 校验兜底:模型抽错了怎么办

状态管理依赖模型抽取,模型一定会抽错。企业级系统必须有兜底。

6.1 三道校验

  1. 改写后规则校验:改写结果必须先过规则校验——query 是否完整(无残留「这个」「它」等指代词)、指代是否正确(改写出的实体是否与状态表last_entity一致),任一不通过则不走缓存、直接降级。
  2. 指代可解析性校验:改写后如果last_entity为空,说明状态表没值,不能硬改写,应反问用户「您指的是工作时长、年假还是调休?」。
  3. 实体合法性校验:抽取出的实体必须在知识库的实体白名单内,不在白名单则丢弃并降级为原始 query 检索。
  4. 答案一致性校验:生成答案前先检查答案与改写实体是否一致,防止「问调休答年假」;同时把状态表注入 prompt,明确告知模型当前实体与轮次,避免模型复用前一轮的上下文自行发挥。

6.2 兜底策略

是

否

是

否

改写后 query

实体是否在白名单

正常检索生成

降级:用原始 query 检索

召回是否为空

反问用户澄清指代

生成并标注低置信

白名单

流程图里的「实体是否在白名单」,指的是知识库里真实存在、且允许被检索的实体集合。它不是模型凭感觉判断的,而是业务侧提前维护好的一份清单,用来回答一个问题:模型抽出来的这个实体,我们到底有没有对应的知识?

以场景题为例,白名单大致长这样:

{"entity_whitelist":["工作时长","年假","调休","加班费","报销流程","社保缴纳"],"kb_version":"v2024_10"}

白名单通常有三个来源,实践中会合并使用:

  • 知识库文档的元数据:每篇文档入库时打上entity标签(如「调休规则.md」→entity=调休),把所有标签去重就是最原始的白名单。
  • 业务字典 / 主数据:HR 系统里的假期类型、报销科目这类枚举值,本身就是权威的实体列表,直接同步过来。
  • 人工维护的别名表:把「年休假」「annual leave」这类同义说法映射到标准实体「年假」,避免模型抽出别名后匹配不上。

校验逻辑很简单,就是一次集合查询:

defcheck_entity(entity:str,whitelist:set[str],alias_map:dict[str,str])->str|None:ifnotentity:returnNone# 先做别名归一,再查白名单canonical=alias_map.get(entity,entity)returncanonicalifcanonicalinwhitelistelseNone

几个关键点:

  • 白名单要跟着知识库版本走。KB_VERSION更新时白名单同步刷新,否则会出现「知识库已经有这个实体,但白名单还没加」的误降级。
  • 白名单是兜底闸门,不是召回手段。它只负责「这个实体能不能用」,不负责「该召回哪段知识」,后者仍然交给向量检索。
  • 不在白名单不等于一定错。可能是知识库确实没覆盖,这时降级用原始 query 检索、召回为空就反问用户,比硬编一个实体去检索安全得多。

一句话:白名单就是「我们有哪些实体可以答」的权威清单,模型抽出的实体必须落在里面才允许继续往下走。

核心原则:宁可反问,不可瞎猜。指代消解错了,答案越流畅越危险。

7. 完整链路串讲

把三块拼起来,一次多轮请求的完整链路是:

命中

未命中

命中

未命中

命中

未命中

用户输入

L1 改写缓存

自包含 query

指代检测 + 状态表读取

改写并回写状态表

L2 检索缓存

文档块

向量检索

L3 答案缓存

返回答案

生成 + 一致性校验

对应到场景题:

  • 第 1~3 轮:正常抽取实体,last_entity依次覆盖为工作时长 → 年假 → 调休。
  • 第 4 轮「这个怎么算?」:命中指代规则,读出last_entity = 调休,改写为「调休怎么算?」,检索命中调休规则文档,生成答案。
  • 若状态表为空或实体非法:反问澄清,不硬答。

8. 面试答题模板

被问到这道题,可以按这个结构答:

  1. 点题:多轮对话的核心是把不完整 query 补全为自包含 query,靠的是显式状态表,而不是让模型自己记。
  2. 状态管理:每个指代映射一个字段,同槽位换值更新状态表,状态表从模型里抽出来由业务侧维护。
  3. query 改写:精确匹配为主、语义匹配为辅,语义兜底必须设阈值;改写结果先过规则校验(完整性 + 指代正确性)再进检索。
  4. 缓存复用:用改写后的 query 做缓存 Key,三层缓存(改写 / 检索 / 答案);全局缓存带 TTL 或知识库版本号,版本更新即失效;会话缓存按用户隔离;复用命中再做一次语义比对。
  5. 校验兜底:指代可解析性、实体合法性、答案一致性三道校验,生成前把状态表注入 prompt 避免模型复用前一轮上下文,宁可反问不可瞎猜。
  6. 收尾:强调可观测性——状态表可审计、缓存命中率可监控、兜底触发率可告警,这才是企业级。## 9. 小结

多轮对话不是「把历史拼进 prompt」这么简单。企业级 RAG 的做法是:状态外置、指代显式、缓存分层、兜底优先。状态表让指代有据可查,缓存让成本可控,校验让错误可拦。三者缺一,系统在真实流量下都会出问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 2:03:36

通义千问对话bx-ua

声明 本文章中所有内容仅供学习交流使用,不用于其他任何目的,抓包内容、敏感网址、数据接口 等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关! 有相关问题请第一时间点击头像看简介…

作者头像 李华
网站建设 2026/10/27 2:03:36

大白话模型.2:LLM的工作原理

LLM(大语言模型)工作原理 1. LLM 核心原理 预测下一个词——概率模型。 抛开复杂的算法,其底层运行机制极其简单:给定前文,计算下一个最可能出现的词是什么。 而我们输入的内容是提示词(Prompt&#xf…

作者头像 李华
网站建设 2026/10/27 2:02:56

Git学习(1):从零搭建版本控制体系

🔥keyipatience:个人主页 🎬作者简介:C/C后端开发学习者 🌟专栏传送门:《c》《linux》《c高阶数据结构》《c数据结构与算法》《前端学习》 ⭐️patience is key in life Git 初识 传统保存版本方式的痛点 平时写文档…

作者头像 李华
网站建设 2026/10/27 2:02:56

从一张学生表到 Fiori 应用,我用 Managed RAP 跑通了一个完整的 Student Management Application

在 Eclipse ADT 里按下 Ctrl + F3,看着一个刚创建的 ZSTD_TABLE_DEMO 被激活时,这个应用其实还远远称不上一个真正的 SAP RAP Application。 那时数据库里只有一张很简单的学生表,里面保存学生姓名、班级、年龄、学校名称几个字段。真正让我开始理解 RAP 的,不是把这张表建…

作者头像 李华
网站建设 2026/10/27 2:02:01

关卡数据问题:出生点坐标拆包报错看不懂?新手一步步排查

大家好,我是 SiKi老师。关卡工具读出生点坐标,代码里一句 x, y, z pos,跑到一半抛出 not enough values to unpack,却看不出是哪一条记录出了问题。先别急着在外面套 try,我会先在拆包之前检查长度和类型,…

作者头像 李华