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 每轮的处理流程
以场景题为例,第 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 三道校验
- 改写后规则校验:改写结果必须先过规则校验——query 是否完整(无残留「这个」「它」等指代词)、指代是否正确(改写出的实体是否与状态表
last_entity一致),任一不通过则不走缓存、直接降级。 - 指代可解析性校验:改写后如果
last_entity为空,说明状态表没值,不能硬改写,应反问用户「您指的是工作时长、年假还是调休?」。 - 实体合法性校验:抽取出的实体必须在知识库的实体白名单内,不在白名单则丢弃并降级为原始 query 检索。
- 答案一致性校验:生成答案前先检查答案与改写实体是否一致,防止「问调休答年假」;同时把状态表注入 prompt,明确告知模型当前实体与轮次,避免模型复用前一轮的上下文自行发挥。
6.2 兜底策略
白名单
流程图里的「实体是否在白名单」,指的是知识库里真实存在、且允许被检索的实体集合。它不是模型凭感觉判断的,而是业务侧提前维护好的一份清单,用来回答一个问题:模型抽出来的这个实体,我们到底有没有对应的知识?
以场景题为例,白名单大致长这样:
{"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. 完整链路串讲
把三块拼起来,一次多轮请求的完整链路是:
对应到场景题:
- 第 1~3 轮:正常抽取实体,
last_entity依次覆盖为工作时长 → 年假 → 调休。 - 第 4 轮「这个怎么算?」:命中指代规则,读出
last_entity = 调休,改写为「调休怎么算?」,检索命中调休规则文档,生成答案。 - 若状态表为空或实体非法:反问澄清,不硬答。
8. 面试答题模板
被问到这道题,可以按这个结构答:
- 点题:多轮对话的核心是把不完整 query 补全为自包含 query,靠的是显式状态表,而不是让模型自己记。
- 状态管理:每个指代映射一个字段,同槽位换值更新状态表,状态表从模型里抽出来由业务侧维护。
- query 改写:精确匹配为主、语义匹配为辅,语义兜底必须设阈值;改写结果先过规则校验(完整性 + 指代正确性)再进检索。
- 缓存复用:用改写后的 query 做缓存 Key,三层缓存(改写 / 检索 / 答案);全局缓存带 TTL 或知识库版本号,版本更新即失效;会话缓存按用户隔离;复用命中再做一次语义比对。
- 校验兜底:指代可解析性、实体合法性、答案一致性三道校验,生成前把状态表注入 prompt 避免模型复用前一轮上下文,宁可反问不可瞎猜。
- 收尾:强调可观测性——状态表可审计、缓存命中率可监控、兜底触发率可告警,这才是企业级。## 9. 小结
多轮对话不是「把历史拼进 prompt」这么简单。企业级 RAG 的做法是:状态外置、指代显式、缓存分层、兜底优先。状态表让指代有据可查,缓存让成本可控,校验让错误可拦。三者缺一,系统在真实流量下都会出问题。