InterX - 多轮对话管理
前面几篇讲的都是单轮问答:用户问一个问题,系统检索、生成、返回。但真实的客服场景几乎不可能是单轮的。用户会追问、会指代、会上传图片、会中途换话题。
"空调制冷差怎么办?" → 回答了 4 步改善方法。 "那个按钮在哪?" → 哪个按钮?极速制冷的那个?还是电源键? "按了没反应怎么办?" → ……
多轮对话管理要解决的核心问题是:怎么在不干扰回答质量的前提下,让系统理解对话上下文。
记忆与回答解耦:核心设计决策
这是 InterX 多轮对话最重要的设计决策,也是最容易被忽略的。
很多系统的做法是简单地把历史对话拼接到当前问题前面,一起扔给 LLM 检索和回答。比如:
[历史] 用户: 空调制冷差 助手: 可以尝试清洁滤网...
[当前] 用户: 怎么清洗滤网?
→ 拼接后检索: "空调制冷差... 可以尝试清洁滤网... 怎么清洗滤网?"这样做的问题很明显:检索被历史噪声干扰了。历史回答中的"清洁滤网"和当前问题重复,但历史问题中的"空调制冷差"和当前问题无关。更糟糕的是,答案层也被历史信息污染,LLM 可能会把历史回答的内容当成新问题的背景。
InterX 的做法是记忆上下文仅暴露给查询改写步骤,答案层接收改写后的独立问题:
用户: "空调制冷差" → answer("空调制冷效果差怎么办?")
用户: "怎么清洗滤网?" → 记忆上下文 + "怎么清洗滤网?"
↓ LLM 改写
"怎么清洗[刚才讨论的空调型号]的滤网?"
↓
answer("怎么清洗[型号]的滤网?") ← 独立完整问题关键点:记忆只用于理解"用户在说什么",不参与"怎么回答"。 查询改写步骤拿到历史上下文后,把带指代的模糊问题改写成自包含的独立问题。答案层拿到的是改写后的完整问题,完全不感知对话历史。
这样做的好处是:检索和回答质量不受多轮对话长度影响。不管聊了多少轮,答案层看到的永远是一个独立、完整、无歧义的问题。
三种记忆策略
记忆上下文怎么构建?InterX 提供了三种策略:
| 策略 | 机制 | 适用场景 |
|---|---|---|
| 滑动窗口 | 保留最近 N 轮原始对话 | 短对话,上下文简单 |
| 摘要压缩 | LLM 历史摘要 + 最近一轮 | 长对话,节省 token |
| 滑动摘要(默认) | LLM 摘要 + 最近 N 轮 | 兼顾远期背景和近期细节 |
滑动窗口最简单,保留最近 N 轮对话原文。适合短对话场景,上下文一目了然。缺点是对话一长就超出 token 限制,而且远期重要信息会被丢掉。
摘要压缩用 LLM 把历史对话压缩成一段摘要,再拼上最近一轮。节省 token,适合长对话。缺点是摘要可能丢失细节,而且多了一次 LLM 调用。
滑动摘要是默认策略,也是最实用的。它的做法是:LLM 对远期历史生成摘要,同时保留最近 N 轮的原始对话。这样既有远期的背景概览("用户之前问过空调制冷问题"),又有近期的精确细节("上一轮说了滤网两周清洗一次")。
实际使用中,滑动摘要在大多数场景下表现最好。它不需要精确调参(N 值容错范围大),也不会因为摘要质量差而丢失关键信息(因为最近几轮是原始文本)。
图片的"边界转换"模式
聊到图片处理,这里有个工程细节值得说一下。
用户上传图片时,前端通过 API 发送的是 Base64 编码的 Data URL(data:image/png;base64,...)。但下游的检索层和回答层统一使用文件路径——向量嵌入需要读文件,LLM 的多模态输入也需要文件路径或 URL。
如果让每一层都去处理 Base64 和文件路径的转换,耦合就太重了。
InterX 的做法是在 chat 层做一次"边界转换":_parse_images() 把 Base64 解码写入 tempfile.NamedTemporaryFile,下游所有层只看到文件路径。请求处理完毕后,finally 块中 unlink 删除临时文件。
API 层: Base64 Data URL
↓ _parse_images()
chat 层: 临时文件路径(/tmp/xxx.png)
↓ 传递给 retrieval / answer
下游层: 统一文件路径接口
↓ 请求结束
finally: unlink 删除临时文件这就是我说的"边界转换"——在系统的边界处(API 层和内部层之间)做一次格式转换,内部层完全不感知外部的传输格式。finally 块保证临时文件一定会被清理,不会泄露。
这个模式看起来简单,但在实际开发中避免了大量的格式转换代码散落在各个模块里。
统一信封响应格式
前端怎么知道一次请求是成功还是失败?InterX 定义了统一的信封格式:
# 成功
ChatResponseEnvelope(code=0, msg="success", data={
"answer": "...",
"images": ["img_id_1", "img_id_2"],
...
})
# 失败
ErrorEnvelope(code=非0, msg="错误信息")前端只需要判断 code != 0 就知道出错了,不需要猜测各种异常格式。msg 字段给用户友好的错误提示,data 字段在成功时携带完整的回答数据。
这套信封格式贯穿整个 chat 模块的所有接口。不管是正常回答、路由失败、检索超时还是 LLM 异常,前端拿到的都是同一个结构。这种一致性在前端开发中省了很多防御性代码。
查询改写:历史感知的问题理解
查询改写是连接"记忆"和"回答"的桥梁。当用户说"那个按钮在哪"时,改写步骤需要结合历史上下文,把"那个"解析成具体指代的对象。
改写的过程大致是:
- 拿到记忆上下文(根据选择的策略构建)
- 拼上当前用户问题
- LLM 生成改写后的独立问题
改写后的问题是自包含的——不依赖任何历史信息就能被检索和回答系统理解。比如:
- 原始:"那个按钮在哪?" → 改写后:"空调遥控器上的极速制冷按钮在哪里?"
- 原始:"按了没反应怎么办?" → 改写后:"按下极速制冷按钮后空调没有反应该如何处理?"
代词指代、上下文省略、话题延续——这些问题在改写步骤中被统一解决。答案层拿到的是一个干净的、独立的、可以被直接检索的问题。
这也是前面"记忆与回答解耦"设计的实际落地:记忆提供上下文,改写负责理解,回答负责生成。三者各司其职,互不干扰。
多轮对话的完整链路
把上面的设计串起来,一个多轮对话的完整流程是这样的:
用户消息到达 chat 模块
│
├── 1. 加载会话历史
├── 2. 构建记忆上下文(滑动摘要)
├── 3. 解析图片(Base64 → 临时文件)
├── 4. 查询改写(记忆 + 当前问题 → 独立问题)
│
├── 5. 调用 answer 模块
│ ├── 路由判断
│ ├── 层级检索
│ ├── 三层并行回答
│ └── ensemble 融合
│
├── 6. 组装 ChatResponseEnvelope
├── 7. 保存本轮对话到历史
├── 8. 清理临时图片文件
│
└── 9. 返回响应给前端注意第 8 步——临时文件清理。不管前面的流程是成功还是异常,finally 块都会执行 unlink,确保不会留下临时文件垃圾。
小结
多轮对话管理的设计哲学是:让记忆只做记忆该做的事——提供上下文,而不是干扰回答。
通过记忆与回答解耦、三种记忆策略的灵活切换、图片的边界转换模式、统一的信封响应格式、以及历史感知的查询改写,InterX 在多轮对话场景下保持了和单轮一样干净的回答质量。
到这里,InterX 的核心功能层(数据预处理、检索、知识图谱、问答引擎、对话管理)就全部讲完了。后面几篇会聊一些工程层面的话题:LLM 网关的语义缓存、全链路加速策略、以及前端交互设计。