Skip to content

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 定义了统一的信封格式:

python
# 成功
ChatResponseEnvelope(code=0, msg="success", data={
    "answer": "...",
    "images": ["img_id_1", "img_id_2"],
    ...
})

# 失败
ErrorEnvelope(code=非0, msg="错误信息")

前端只需要判断 code != 0 就知道出错了,不需要猜测各种异常格式。msg 字段给用户友好的错误提示,data 字段在成功时携带完整的回答数据。

这套信封格式贯穿整个 chat 模块的所有接口。不管是正常回答、路由失败、检索超时还是 LLM 异常,前端拿到的都是同一个结构。这种一致性在前端开发中省了很多防御性代码。

查询改写:历史感知的问题理解 ​

查询改写是连接"记忆"和"回答"的桥梁。当用户说"那个按钮在哪"时,改写步骤需要结合历史上下文,把"那个"解析成具体指代的对象。

改写的过程大致是:

  1. 拿到记忆上下文(根据选择的策略构建)
  2. 拼上当前用户问题
  3. LLM 生成改写后的独立问题

改写后的问题是自包含的——不依赖任何历史信息就能被检索和回答系统理解。比如:

  • 原始:"那个按钮在哪?" → 改写后:"空调遥控器上的极速制冷按钮在哪里?"
  • 原始:"按了没反应怎么办?" → 改写后:"按下极速制冷按钮后空调没有反应该如何处理?"

代词指代、上下文省略、话题延续——这些问题在改写步骤中被统一解决。答案层拿到的是一个干净的、独立的、可以被直接检索的问题。

这也是前面"记忆与回答解耦"设计的实际落地:记忆提供上下文,改写负责理解,回答负责生成。三者各司其职,互不干扰。

多轮对话的完整链路 ​

把上面的设计串起来,一个多轮对话的完整流程是这样的:

用户消息到达 chat 模块
  │
  ├── 1. 加载会话历史
  ├── 2. 构建记忆上下文(滑动摘要)
  ├── 3. 解析图片(Base64 → 临时文件)
  ├── 4. 查询改写(记忆 + 当前问题 → 独立问题)
  │
  ├── 5. 调用 answer 模块
  │     ├── 路由判断
  │     ├── 层级检索
  │     ├── 三层并行回答
  │     └── ensemble 融合
  │
  ├── 6. 组装 ChatResponseEnvelope
  ├── 7. 保存本轮对话到历史
  ├── 8. 清理临时图片文件
  │
  └── 9. 返回响应给前端

注意第 8 步——临时文件清理。不管前面的流程是成功还是异常,finally 块都会执行 unlink,确保不会留下临时文件垃圾。

小结 ​

多轮对话管理的设计哲学是:让记忆只做记忆该做的事——提供上下文,而不是干扰回答。

通过记忆与回答解耦、三种记忆策略的灵活切换、图片的边界转换模式、统一的信封响应格式、以及历史感知的查询改写,InterX 在多轮对话场景下保持了和单轮一样干净的回答质量。

到这里,InterX 的核心功能层(数据预处理、检索、知识图谱、问答引擎、对话管理)就全部讲完了。后面几篇会聊一些工程层面的话题:LLM 网关的语义缓存、全链路加速策略、以及前端交互设计。