Skip to main content

一次知识问答在后端经历了哪八个阶段

开篇引言

假设你在一家电商公司做开发,公司上了一套智能客服助手,接入了 3C 数码、家电、服装等多个品类的商品知识库,还对接了订单系统、物流系统等业务接口。某天,一个用户在对话框里输入了一句话:

iPhone 16 Pro 的退货政策是什么?

然后点了发送。

几秒后,答案像打字机一样一个字一个字蹦出来,不仅引用了退货政策文档,还能实时查到这个用户的订单状态。看起来很丝滑,但这背后到底发生了什么?

从用户按下回车到最后一个 Token 推送完毕,后端至少要做这些事:加载这个用户之前聊了什么、把问题改写成更适合检索的形式、判断应该去哪个知识库找答案、从向量数据库捞文档、把文档和问题拼成 Prompt、调大模型生成答案、再一个 Token 一个 Token 通过 SSE 推给前端……

这些环节按什么顺序跑?哪些可以跳过?如果用户只是打个招呼说了句你好,还需要去知识库检索吗?如果检索什么都没找到,要不要硬着头皮让模型编一个答案?

这就是本篇要回答的问题。

在 Ragent 项目中,整个问答流程由一个叫 StreamChatPipeline 的类编排,它把上面这些事情拆成了八个阶段,按固定顺序依次执行,中间设置了三个短路点——满足特定条件时提前结束,不用走完全部八步。

本篇是 AI 知识问答系列的第 1 篇,预计共 18 篇。它是整个系列的全景地图,不深入任何一个环节的代码细节,目标只有一个:看完之后,你脑子里有一张完整的地图,后续每篇文章都能在这张地图上找到自己的位置。

请求进入 Pipeline 之前

用户的问题从浏览器出发,到达 StreamChatPipeline 之前,还要经过两层防护和一次初始化。这三步不属于八个阶段,但它们决定了请求能不能进入 Pipeline。

1. 幂等提交拦截

Controller 层的 chat() 方法上标注了 @IdempotentSubmit 注解,基于用户 ID 加分布式锁。用户快速连点两次发送按钮,第二次请求会被直接拦截,返回“当前会话处理中,请稍后再发起新的对话”。

当然,大家可以根据实际要求拦截,这里仅是其中一种拦截策略。

这是一个纯工程手段,和 RAG 本身没有关系,但在生产环境中非常必要——没有它,同一个用户的两次请求会同时进入 Pipeline,导致记忆写入冲突、资源浪费。

2. 队列式并发限流

Service 层的 streamChat() 方法标注了 @ChatRateLimit,由 ChatRateLimitAspect 拦截,内部委托给 ChatQueueLimiter 处理。

打个比方,大模型推理就像餐厅的后厨,灶台数量有限。ChatQueueLimiter 用 Redis 信号量控制同时有多少个请求可以在后厨炒菜(并发坑位),超出的请求排队等待。如果等太久超过了最大等待时间,直接返回排队超时,不让用户干等。

队列式并发限流是 Ragent 的一个核心工程设计,后面会专门展开讲。

3. 全链路 Trace 初始化

请求拿到并发坑位后,在进入 Pipeline 之前,AOP 切面会初始化全链路追踪上下文——生成 traceIdtaskId,通过 RagTraceContext(基于 TransmittableThreadLocal,支持跨线程池传递)贯穿后续所有阶段。

每个阶段的耗时、输入输出都会记录到 Trace,出了问题可以精确定位卡在哪一步。这是生产环境排查性能瓶颈的基础设施。

八个阶段全景图

整个链路从 HTTP 请求到流式响应,完整路径长这样:

HTTP GET /rag/v3/chat
└── RAGChatController.chat()
@IdempotentSubmit(防重复提交)
└── RAGChatServiceImpl.streamChat()
@ChatRateLimitChatRateLimitAspect
└── ChatQueueLimiter.enqueue()(排队等信号量)
└── invokeWithTrace()(初始化全链路 Trace
└── StreamChatPipeline.execute(ctx)
阶段 1:loadMemory — 加载会话记忆
阶段 2:rewriteQuery — 查询改写与子问题拆分
阶段 3:resolveIntents — 意图识别
阶段 4:handleGuidance — 歧义引导 [短路点 #1]
阶段 5:handleSystemOnly — 系统直答 [短路点 #2]
阶段 6:retrieve — 多通道检索(KB + MCP
阶段 7:handleEmptyRetrieval — 空结果兜底 [短路点 #3]
阶段 8:streamRagResponse — Prompt 组装 + 流式生成

下面这张 PlantUML 活动图,把 Pipeline 前的防护和 Pipeline 内的八个阶段、三个短路分支都画了出来:

而这张地图在代码中的体现,就是 StreamChatPipeline.execute() 方法,只有 20 行,却是整个问答系统的骨架:

public void execute(StreamChatContext ctx) {
loadMemory(ctx); // 阶段 1
rewriteQuery(ctx); // 阶段 2
resolveIntents(ctx); // 阶段 3

if (handleGuidance(ctx)) { // 阶段 4 — 短路点 #1
return;
}
if (handleSystemOnly(ctx)) { // 阶段 5 — 短路点 #2
return;
}

RetrievalContext retrievalCtx = retrieve(ctx); // 阶段 6
if (handleEmptyRetrieval(ctx, retrievalCtx)) { // 阶段 7 — 短路点 #3
return;
}

streamRagResponse(ctx, retrievalCtx); // 阶段 8
}

每个阶段是一个私有方法,通过 boolean 返回值实现短路控制——返回 true 表示当前阶段已经处理完毕,Pipeline 直接 return,不再往下走。

这种设计的好处是一目了然:你看这 20 行代码,就知道一次问答走了哪些步骤、在哪里可能提前结束。后续每篇文章要讲的内容,都对应这里面的某一个方法调用。

阶段 1:加载会话记忆

用一句话概括:把用户当前消息追加到记忆存储,然后加载完整的对话历史。

为什么这是第一步?因为后续的查询改写和意图识别都需要对话上下文。比如用户上一轮问了 iPhone 16 Pro 的价格,这一轮追问“那它能退货吗”——没有对话历史,改写模块根本不知道“它”指的是什么。

这个阶段做完后,ctx.history 被填充为一个 List<ChatMessage>,包含了这个会话的完整对话记录(当然,实际生产中会有滑动窗口或摘要压缩来控制长度,不会无限膨胀)。

阶段 2:查询改写与子问题拆分

用一句话概括:把用户的原始问题改写成更适合检索的形式,同时把复合问题拆成多个子问题。

这个阶段有两个输出:

  • 改写后的完整问题rewrittenQuestion):消除代词引用、补全上下文信息。比如用户说“那它的保修期呢”,结合对话历史改写为“iPhone 16 Pro 的保修期是多少”。
  • 子问题列表subQuestions):如果用户问了一个复合问题,会被拆成多个独立的子问题。比如“iPhone 和 AirPods 的退货政策分别是什么”,拆成“iPhone 的退货政策是什么”和“AirPods 的退货政策是什么”两个子问题。

为什么放在这个位置?往前看,它需要阶段 1 的对话历史才能正确消解代词;往后看,阶段 3 的意图识别需要针对每个子问题分别做分类。

阶段 3:意图识别

用一句话概括:对每个子问题并行做意图分类,判断应该去哪个知识库、调哪个工具、还是系统直接回答。

Ragent 的意图体系是一棵树,每个叶子节点代表一个具体的意图,有三种类型:

意图类型含义举例
KB知识库检索去 3C 数码知识库查退货政策
MCP工具调用调订单系统接口查物流状态
SYSTEM系统直答用户说你好,直接让模型回答

每个子问题会命中若干意图节点,每个节点带一个分数。但命中太多也不行——意图越多,后续检索和调用的开销越大。所以这里有一个封顶算法:保证每个子问题至少保留最高分意图,然后按全局分数排序分配剩余配额,在多样性和性能之间取平衡。

意图树的结构设计、Prompt 打分方案、算法是系统中最复杂的部分之一,第 5~9 篇用 5 篇的篇幅详细展开。

阶段 4:歧义引导 [短路点 #1]

用一句话概括:检测多个意图之间是否存在歧义,如果有就反问用户,让用户自己选。

回到开头的例子。如果用户问的不是“iPhone 16 Pro 的退货政策”,而是只说了一句“退货政策是什么”——3C 数码的知识库和家电的知识库都高分命中了。系统不确定用户问的是哪个品类,猜错了不如不猜。

这时候 handleGuidance 会检测到歧义,通过 SSE 直接推送一段引导文本给前端,类似于“请问您想了解的是 3C 数码还是家电的退货政策?”让用户点选之后,带着明确的选择重新进入 Pipeline。

注意,这个阶段触发短路时,不调用大模型,直接推送引导选项就返回了。这是第一个短路点。

阶段 5:系统直答 [短路点 #2]

用一句话概括:如果所有意图都是 SYSTEM 类型,跳过检索,直接用系统 Prompt 调 LLM 回答。

什么情况下会触发?用户说“你好”、“你能做什么”、“谢谢”这类不需要查任何知识库也不需要调任何工具的问题。阶段 3 把它们全部归类为 SYSTEM 意图,到了阶段 5 检查发现——所有子问题的所有意图节点都是 SYSTEM 类型,那就不需要走检索了。

跳过阶段 6 和 7,直接拿意图节点上配置的 promptTemplate(如果有的话,没有就用默认系统 Prompt)调 LLM 流式生成回答。用户打个招呼,没必要去向量数据库里搜一圈,白白浪费时间和资源。

这是第二个短路点。和阶段 4 不同的是,这里虽然跳过了检索,但还是调用了大模型生成回答。

阶段 6:多通道检索

用一句话概括:KB 意图走向量检索(多通道并行 + 去重 + 精排),MCP 意图走工具调用,两条路同时跑。

到了这一步,说明用户的问题确实需要检索或调用工具才能回答。retrievalEngine.retrieve() 内部会根据意图类型分两条路径:

  • KB 意图:走 MultiChannelRetrievalEngine,对每个命中的知识库并行发起向量检索,检索结果经过去重、精排(Reranker)等后处理,最终筛选出最相关的几条文档片段。
  • MCP 意图:走 MCPToolExecutor 链,调用外部系统的 API 获取实时数据(比如查订单系统的物流状态)。

这两条路是并行执行的,最终合并到一个 RetrievalContext 对象里,包含 kbContext(知识库检索结果)和 mcpContext(工具调用结果)。

阶段 7:空结果兜底 [短路点 #3]

用一句话概括:如果知识库和工具都没有返回任何结果,直接告诉用户没找到,而不是让模型自由发挥。

retrievalCtx.isEmpty() 检查 kbContextmcpContext 是否都为空。如果是,推送一条固定消息“未检索到与问题相关的文档内容。”然后 return

为什么要这样做?如果把空的上下文喂给大模型,模型没有参考资料可用,大概率会基于自己的预训练知识编造一个答案。在电商客服场景下,编造的退货政策或价格信息比没有答案更危险——用户可能信以为真。与其让模型凭空编造,不如坦诚告知没找到。

这是第三个短路点。

阶段 8:Prompt 组装与流式生成

解锁付费内容,👉 戳