三个子问题命中了八个意图,该保留哪几个
开篇引言
上一篇讲了单次 LLM 调用怎么给叶子节点打分——Prompt 模板的两步判断流程、模型回复确定性、JSON 解析的五层容错。一次调用进去一个问题,出来一个按分数排好序的 List<NodeScore>,整个链路很清晰。
但实际场景不会这么简单。回忆一下第 4 篇查询改写——用户发了一句“我的订单到哪了,另外退货政策是什么?”,查询改写会把它拆成两个子问题,每个子问题独立走意图分类。如果用户的问题更复杂一些,拆出三个子问题也很正常。
这就带来了几个绕不开的问题:
- 三个子问题,要一个接一个串行调 LLM,还是同时并行调?
- 每个子问题最多返回 3 个意图,三个子问题就是最多 9 个,下游检索能承受吗?
- 如果简单地按分数从高到低截前 3 个,会不会出现某个子问题完全被忽略的情况?
用一个具体场景来感受这个问题。假设用户在电商客服界面发了一句长问题:
我的订单到哪了?另外之前买的 AirPods 能不能退,物流费怎么算?
查询改写把它拆成三个子问题。每个子问题各自命中了 2~3 个意图,加起来八个。系统总共只允许 3 个意图进入下游检索——该怎么选?
这篇文章要回答的就是这个问题。IntentResolver 作为意图识别阶段的编排者,负责把子问题分发出去并行跑,把结果收回来做总量封顶,保证既不丢子问题,又不让意图数量爆炸。
并行调度:子问题怎么同时跑
1. resolve() 方法全景
IntentResolver 的入口方法 resolve() 做三件事:取子问题、并行分类、总量封顶。
@RagTraceNode(name = "intent-resolve", type = "INTENT")
public List<SubQuestionIntent> resolve(RewriteResult rewriteResult) {
List<String> subQuestions = CollUtil.isNotEmpty(rewriteResult.subQuestions())
? rewriteResult.subQuestions()
: List.of(rewriteResult.rewrittenQuestion());
List<CompletableFuture<SubQuestionIntent>> tasks = subQuestions.stream()
.map(q -> CompletableFuture.supplyAsync(
() -> {
try {
return new SubQuestionIntent(q, classifyIntents(q));
} catch (Exception e) {
log.error("子问题意图分类失败,降级为空意图,question:{}", q, e);
return new SubQuestionIntent(q, List.of());
}
},
intentClassifyExecutor
))
.toList();
List<SubQuestionIntent> subIntents = tasks.stream()
.map(CompletableFuture::join)
.toList();
return capTotalIntents(subIntents);
}
逐段拆一下。
取子问题列表。 RewriteResult 来自第 4 篇查询改写,里面有 rewrittenQuestion(整体改写后的问题)和 subQuestions(拆分后的子问题列表)。如果有子问题列表就用子问题,没有(简单问题没拆)就把 rewrittenQuestion 当作唯一的子问题。一行代码,兜底逻辑到位。
并行提交。 每个子问题用 CompletableFuture.supplyAsync 提交到 intentClassifyExecutor 专用线程池。三个子问题三个 future,互不干扰。
等待汇总。 .join() 等所有 future 完成,拿到每个子问题的意图分类结果,打包成 List<SubQuestionIntent>。
总量封顶。 最后调 capTotalIntents() 做全局约束——总意图数不能超过 3 个。这是本篇的重头戏,后面单独展开。
在往下看之前,先认识一下两个数据模型:
// 子问题 + 它命中的意图列表
public record SubQuestionIntent(String subQuestion, List<NodeScore> nodeScores) {
}
// 封顶算法的中间结构:意图 + 它来自第几个子问题
public record IntentCandidate(int subQuestionIndex, NodeScore nodeScore) {
}
SubQuestionIntent 很直观——一个子问题文本,加上它命中的意图列表。IntentCandidate(意图候选)是封顶算法内部用的中间结构,给每个意图打上来自第几个子问题的标记,方便后面追踪归属。
2. 为什么一定要并行
2.1 串行的延迟账
假设每次意图分类的 LLM 调用耗时 800ms 左右(Prompt 几千 token,模型做相对复杂的分类判断):
| 调度方式 | 三个子问题总延迟 | 说明 |
|---|---|---|
| 串行 | ~2400ms | 三次调用依次执行,延迟累加 |
| 并行 | ~800ms | 三个同时跑,取最慢那个 |
用户感知的首字延迟是整个 pipeline 的累加——会话记忆加载 + 查询改写 + 意图识别 + 检索 + LLM 生成。意图识别这一环如果串行,光这里就多出 1.6 秒,用户体验明显变差。并行跑只取最慢那个子问题的耗时,基本等于一次 LLM 调用的时间。
2.2 为什么用专用线程池
@Qualifier("intentClassifyThreadPoolExecutor")
private final Executor intentClassifyExecutor;
意图分类用的是专用线程池 intentClassifyThreadPoolExecutor,不和检索、记忆加载等异步任务混用。
为什么要隔离?意图分类是网络 IO 密集型任务(调用外部 LLM 服务),和检索(调用向量数据库)、记忆加载的 IO 特征不同。如果所有异步任务共用一个线程池,检索慢了(比如向量数据库偶尔抖动)可能把线程池占满,导致意图分类也卡住——明明子问题 LLM 调用已经返回了,拿不到线程执行后续逻辑。
线程池的核心参数:核心线程数等于 CPU 核心数,最大线程数是 CPU 核心数的两倍,队列用 SynchronousQueue(直接交接,不排队),拒绝策略是 CallerRunsPolicy(池子满了就让调用者线程自己跑)。这个配置思路是:子问题数量通常是 2~4 个,不需要很大的池子,但拒绝策略要兜底——哪怕池子满了也不能丢任务,降级到调用者线程同步执行就行。
3. 异常处理:lambda 里面的 try-catch
resolve() 里有一个容易被忽略的设计细节——try-catch 的位置。
.map(q -> CompletableFuture.supplyAsync(
() -> {
try {
return new SubQuestionIntent(q, classifyIntents(q));
} catch (Exception e) {
log.error("子问题意图分类失败,降级为空意图,question:{}", q, e);
return new SubQuestionIntent(q, List.of());
}
},
intentClassifyExecutor
))
try-catch 包在 lambda 内部,而不是在 .join() 外面。这个位置的选择很有讲究。
如果 try-catch 放在外面(比如对 .join() 的结果做异常处理),单个子问题一旦抛异常,对应的 CompletableFuture 就会进入 completeExceptionally 状态。调 .join() 时会抛出 CompletionException,三个子问题里一个失败就可能中断整个流程。
放在里面就不一样了。不管内部怎么炸——网络超时、LLM 服务熔断、线程池拒绝——catch 里都会返回一个带空意图列表的 SubQuestionIntent(q, List.of())。CompletableFuture 始终正常完成,不会影响其他子问题的结果。
这里还有一个和第 6 篇的衔接点。classifyTargets() 内部已经有全局 try-catch 兜底(JSON 解析失败时返回空列表),这里再包一层是双重保险。classifyTargets() 兜的是 JSON 解析阶段的异常,resolve() 的 lambda 兜的是更外层的异常——比如 LLM 服务直接挂了、HTTP 连接超时、线程中断等,第一层根本走不到的场景。