一次知识问答在后端经历了哪八个阶段
开篇引言
假设你在一家电商公司做开发,公司上了一套智能客服助手,接入了 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 切面会初始化全链路追踪上下文——生成 traceId 和 taskId,通过 RagTraceContext(基于 TransmittableThreadLocal,支持跨线程池传递)贯穿后续所有阶段。
每个阶段的耗时、输入输出都会记录到 Trace,出了问题可以精确定位卡在哪一步。这是生产环境排查性能瓶颈的基础设施。
八个阶段全景图
整个链路从 HTTP 请求到流式响应,完整路径长这样:
HTTP GET /rag/v3/chat
└── RAGChatController.chat()
@IdempotentSubmit(防重复提交)
└── RAGChatServiceImpl.streamChat()
@ChatRateLimit → ChatRateLimitAspect
└── 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() 检查 kbContext 和 mcpContext 是否都为空。如果是,推送一条固定消息“未检索到与问题相关的文档内容。”然后 return。
为什么要这样做?如果把空的上下文喂给大模型,模型没有参考资料可用,大概率会基于自己的预训练知识编造一个答案。在电商客服场景下,编造的退货政策或价格信息比没有答案更危险——用户可能信以为真。与其让模型凭空编造,不如坦诚告知没找到。
这是第三个短路点。