意图识别与路由
三路投票怎么算,领域打分怎么选 Agent,什么情况下升级或降级。
三路投票
意图、领域、紧急度和实体需要分开理解。意图表示用户要做什么,领域表示由谁处理,紧急度决定是否绕过普通路由,实体则保留错误码或工单号等具体线索。例如“VPN 报错 691,工单 #10086”可以同时携带网络故障意图、技术支持领域和两个实体。
IntentRecognizer.recognize 把一条消息交给三个互相独立的识别器,再合并结果:
- 大模型:给出意图、置信度和一句理由,返回 JSON。提示词里要求优先返回细粒度意图,比如”VPN 故障”,而不是宽泛的”故障”。
- 语义相似:把消息和每类意图的示例句各切成字符 n-gram,算 Jaccard 相似度,取最像的那一类。不需要向量库。
- 关键词规则:先匹配细粒度意图的关键词,没命中再匹配宽泛意图的关键词。
public IntentResult recognize(String message, List<Map<String, String>> history) {
String key = cacheKey(message, history);
IntentResult cached = cache.get(key);
if (cached != null) {
return cached;
}
Instant start = Instant.now();
Map<String, Object> llm = llmRecognize(message, history);
Map<String, Object> semantic = semanticRecognize(message);
Map<String, Object> pattern = patternRecognize(message);
VoteResult vote = vote(llm, semantic, pattern);
IntentCategory intent = vote.intent();
UrgencyLevel urgency = urgency(message, intent);
IntentResult result = new IntentResult(
intent,
vote.confidence(),
urgency,
intentGroup(intent),
extractEntities(message),
String.valueOf(llm.getOrDefault("reasoning", "")),
Duration.between(start, Instant.now()).toMillis(),
vote.sourceScores()
);
if (cache.size() > 1000) {
cache.clear();
}
cache.put(key, result);
return result;
}
怎么合并
合并发生在 vote 里。每一路的得分是”权重 × 置信度”,累加到它所投的意图上,得分最高的意图胜出:
private VoteResult vote(Map<String, Object> llm, Map<String, Object> semantic, Map<String, Object> pattern) {
Map<String, Double> sourceScores = new LinkedHashMap<>();
sourceScores.put("llm", confidence(llm));
sourceScores.put("embedding", confidence(semantic));
sourceScores.put("pattern", confidence(pattern));
if (Boolean.TRUE.equals(llm.get("failed"))) {
if (semantic.get("intent") != IntentCategory.OTHER && confidence(semantic) > 0) {
return new VoteResult((IntentCategory) semantic.get("intent"), confidence(semantic), sourceScores);
}
if (pattern.get("intent") != IntentCategory.OTHER && confidence(pattern) > 0) {
return new VoteResult((IntentCategory) pattern.get("intent"), confidence(pattern), sourceScores);
}
return new VoteResult(IntentCategory.OTHER, 0.0, sourceScores);
}
Map<IntentCategory, Double> scores = new EnumMap<>(IntentCategory.class);
addScore(scores, llm, 0.70);
addScore(scores, semantic, 0.20);
addScore(scores, pattern, 0.10);
Map.Entry<IntentCategory, Double> best = scores.entrySet().stream()
.max(Map.Entry.comparingByValue())
.orElse(Map.entry(IntentCategory.OTHER, 0.0));
IntentCategory patternIntent = (IntentCategory) pattern.getOrDefault("intent", IntentCategory.OTHER);
double patternConfidence = confidence(pattern);
if (GENERIC_INTENTS.contains(best.getKey())
&& SPECIFIC_INTENTS.contains(patternIntent)
&& patternConfidence >= 0.5
&& best.getValue() < 0.8) {
sourceScores.put("refined_by_pattern", patternConfidence);
return new VoteResult(patternIntent, Math.max(best.getValue(), patternConfidence), sourceScores);
}
if (best.getValue() < CONFIDENCE_THRESHOLD) {
return new VoteResult(IntentCategory.OTHER, best.getValue(), sourceScores);
}
return new VoteResult(best.getKey(), best.getValue(), sourceScores);
}
这段代码里有三个值得注意的分支:
- 大模型不可用(调用失败,或返回的不是结构化 JSON):不再加权,先看语义相似,再看规则,都没有就是
OTHER。服务台不会因为模型挂了而完全不能分类。 - 细粒度优先:如果投票结果是宽泛意图(比如”故障”),而规则命中了细粒度意图(比如 VPN),规则的置信度不低于 0.5,且投票得分低于 0.8,就用规则的细粒度意图替换。这样”VPN 连不上”不会停留在笼统的”故障”上。投票结果里会多一项
refined_by_pattern,说明发生过替换。 - 低置信度:得分低于 0.5 的一律当作
OTHER,后面由编排器去反问用户。
每一路的原始得分都会随结果返回(source_scores),演示里的”意图投票”条就是它。
得分怎样解释
假设三路都支持同一个意图,置信度分别为 0.9、0.6、0.8,其加权得分为 0.7 × 0.9 + 0.2 × 0.6 + 0.1 × 0.8 = 0.83。如果三路分歧,分数会分散到不同标签上,再比较各标签总分。这是计算示例,不是项目评测结果。
加权分是路由规则使用的分数,没有经过概率校准,不能把 0.83 直接解读为“有 83% 的概率识别正确”。模型失败后的备用路径也不是把剩余两路重新归一化投票,而是按源码的回退顺序选择。
识别缓存把消息与历史一起纳入键,避免相同文本在不同对话上下文中无条件复用结果。超过阈值整体清空是一种简单的容量控制,不是 LRU,也不提供分布式缓存一致性。
紧急度
紧急度由独立的 urgency 判断,不参与投票:
private UrgencyLevel urgency(String message, IntentCategory intent) {
String msg = normalize(message);
if (msg.contains("紧急") || msg.contains("urgent") || msg.contains("p0") || msg.contains("全公司")
|| intent == IntentCategory.SECURITY_INCIDENT) {
return UrgencyLevel.CRITICAL;
}
if (msg.contains("今天") || msg.contains("马上") || msg.contains("尽快") || msg.contains("影响业务")
|| intent == IntentCategory.ESCALATION || intent == IntentCategory.HUMAN_HANDOFF) {
return UrgencyLevel.HIGH;
}
if (intent == IntentCategory.COMPLAINT) {
return UrgencyLevel.MEDIUM;
}
return UrgencyLevel.LOW;
}
例如“我点开了钓鱼邮件链接”即使没有“立刻”“严重”这些词,也会因为安全事件类别进入 CRITICAL。相反,普通咨询中包含急迫语气,不应自动等同于已发生安全事件。紧急度与意图的独立处理让这种规则可以单独验证。
从意图到 Agent
AgentOrchestrator.run 是编排入口。顺序是:补全意图 → 必要时先澄清 → 路由 → 执行 → 判断是否升级。
public OrchestratorResult run(AgentRequest request) {
Instant start = Instant.now();
AgentRequest req = request;
if (req.intent() == null) {
IntentResult intentResult = intentRecognizer.recognize(req.message(), req.history());
req = req.withIntent(intentResult);
}
if (needsClarification(req)) {
return new OrchestratorResult(
req.requestId(),
"我还不能确定您要处理的是哪类问题。请补充一下是故障报障、权限/软件申请、资产或变更,还是使用咨询?",
AgentType.SERVICE_DESK,
req.intent(),
false,
Duration.between(start, Instant.now()).toMillis(),
List.of(AgentType.SERVICE_DESK),
AgentType.SERVICE_DESK,
List.of(),
"低置信度 OTHER 意图,先澄清用户需求",
req.intentConfidence()
);
}
RoutingDecision decision = routeDecision(req);
if (decision.multiAgent()) {
return runParallel(req, decision);
}
AgentResponse response = execute(req, decision.primaryAgent());
boolean escalated = response.escalate()
|| req.urgency() == UrgencyLevel.CRITICAL
|| req.intent() == IntentCategory.ESCALATION
|| req.intent() == IntentCategory.HUMAN_HANDOFF;
return new OrchestratorResult(
req.requestId(),
response.content(),
response.agentType(),
req.intent(),
escalated,
Duration.between(start, Instant.now()).toMillis(),
List.of(response.agentType()),
decision.primaryAgent(),
List.of(),
decision.reason(),
decision.confidence()
);
}
领域打分
路由不是一张”意图 → Agent”的静态表。domainScores 给三个 Agent 各算一个分数,来源有三类:意图所属领域的基础分,消息里领域关键词的命中数,以及抽取到的实体(错误码偏向技术支持,资产编号偏向资产与变更,工单号偏向服务台)。
private Map<AgentType, Double> domainScores(AgentRequest req) {
String msg = req.message() == null ? "" : req.message().toLowerCase(Locale.ROOT);
Map<AgentType, Double> scores = new EnumMap<>(AgentType.class);
scores.put(AgentType.SERVICE_DESK, 0.1);
scores.put(AgentType.TECH_SUPPORT, 0.0);
scores.put(AgentType.ASSET_CHANGE, 0.0);
if (List.of(
IntentCategory.QUERY,
IntentCategory.REQUEST,
IntentCategory.ACCESS_REQUEST,
IntentCategory.PASSWORD_RESET,
IntentCategory.SOFTWARE_INSTALL,
IntentCategory.COMPLAINT,
IntentCategory.GREETING,
IntentCategory.FEEDBACK,
IntentCategory.OTHER
).contains(req.intent())) {
scores.merge(AgentType.SERVICE_DESK, 0.55, Double::sum);
}
if (List.of(
IntentCategory.INCIDENT,
IntentCategory.NETWORK_VPN,
IntentCategory.SECURITY_INCIDENT,
IntentCategory.HARDWARE_FAULT
).contains(req.intent())) {
scores.merge(AgentType.TECH_SUPPORT, 0.75, Double::sum);
}
if (List.of(
IntentCategory.CHANGE,
IntentCategory.CHANGE_REQUEST,
IntentCategory.ASSET,
IntentCategory.ASSET_QUERY
).contains(req.intent())) {
scores.merge(AgentType.ASSET_CHANGE, 0.75, Double::sum);
}
long techHits = countHits(msg, "故障", "报错", "打不开", "无法使用", "断网", "vpn", "网络", "内网", "蓝屏", "死机", "崩溃", "500", "超时");
long assetHits = countHits(msg, "变更", "发布", "上线", "扩容", "资产", "设备", "许可", "领用", "报废", "盘点");
long deskHits = countHits(msg, "申请", "权限", "密码", "安装", "咨询", "怎么", "如何", "工单");
scores.merge(AgentType.TECH_SUPPORT, Math.min(0.45, techHits * 0.18), Double::sum);
scores.merge(AgentType.ASSET_CHANGE, Math.min(0.45, assetHits * 0.18), Double::sum);
scores.merge(AgentType.SERVICE_DESK, Math.min(0.35, deskHits * 0.12), Double::sum);
Map<String, List<String>> entities = req.entities() == null ? Map.of() : req.entities();
if (!entities.getOrDefault("error_code", List.of()).isEmpty()) {
scores.merge(AgentType.TECH_SUPPORT, 0.2, Double::sum);
}
if (!entities.getOrDefault("asset", List.of()).isEmpty()) {
scores.merge(AgentType.ASSET_CHANGE, 0.15, Double::sum);
}
if (!entities.getOrDefault("ticket_id", List.of()).isEmpty()) {
scores.merge(AgentType.SERVICE_DESK, 0.1, Double::sum);
}
scores.replaceAll((agentType, score) -> round(score));
return scores;
}
选主 Agent 与辅助 Agent
private RoutingDecision routeDecision(AgentRequest req) {
if (req.urgency() == UrgencyLevel.CRITICAL) {
return new RoutingDecision(AgentType.ESCALATION, List.of(), "紧急度为 CRITICAL,触发升级路由", 1.0);
}
if (req.intent() == IntentCategory.ESCALATION || req.intent() == IntentCategory.HUMAN_HANDOFF) {
String intent = req.intent() == null ? "unknown" : req.intent().name().toLowerCase(Locale.ROOT);
return new RoutingDecision(
AgentType.ESCALATION,
List.of(),
"意图为 " + intent + ",触发升级路由",
Math.max(req.intentConfidence(), 0.8)
);
}
Map<AgentType, Double> scores = domainScores(req);
Map<AgentType, Double> availableScores = new EnumMap<>(AgentType.class);
scores.forEach((agentType, score) -> {
if (agentType == AgentType.SERVICE_DESK || pool.containsKey(agentType)) {
availableScores.put(agentType, score);
}
});
if (availableScores.isEmpty()) {
return new RoutingDecision(AgentType.SERVICE_DESK, List.of(), "无可用专属 Agent,降级到 ServiceDeskAgent", 0.1);
}
List<Map.Entry<AgentType, Double>> ordered = availableScores.entrySet().stream()
.sorted(Map.Entry.<AgentType, Double>comparingByValue().reversed())
.toList();
AgentType primary = ordered.getFirst().getKey();
double primaryScore = ordered.getFirst().getValue();
List<AgentType> supportingAgents = ordered.stream()
.skip(1)
.filter(entry -> entry.getKey() != AgentType.SERVICE_DESK)
.filter(entry -> entry.getValue() >= 0.45 && entry.getValue() >= primaryScore * 0.55)
.map(Map.Entry::getKey)
.toList();
return new RoutingDecision(
primary,
supportingAgents,
routingReason(req, availableScores, primary, supportingAgents),
round(Math.min(primaryScore, 1.0))
);
}
规则是:
- 紧急度为 CRITICAL,或意图是升级、转人工:直接走升级路由,不打分。
- 否则取分数最高的 Agent 为主 Agent。
- 其余 Agent 要同时满足”分数不低于 0.45”和”不低于主 Agent 的 55%“才会成为辅助,且服务台不充当辅助。
- 专属 Agent 不可用时,降级到服务台。
复合问题的处理示例
“发布后出现 500 错误并且网络故障”包含变更与技术故障两组线索。识别阶段给出一个主要意图,路由阶段仍会检查原消息关键词和实体,因此不必把整句话强制压成一个处理领域。
主 Agent 负责最主要的业务方向,满足阈值的辅助 Agent 补充另一领域建议。阈值同时限制绝对分和相对分,避免任何弱命中都扩大成多 Agent 调用。服务台作为兜底,不参与辅助集合,减少泛化回答重复。
紧急升级在领域打分之前执行,回答校验也可以在执行后建议转人工。前者由输入信号触发,后者由输出质量判断触发;它们都最终影响响应的升级标记,但发生在不同阶段。
并行协作
有辅助 Agent 时,各 Agent 用 CompletableFuture 并行处理,结果合并成一条回答,每段标明”主处理”或”辅助处理”:
private OrchestratorResult runParallel(AgentRequest req, RoutingDecision decision) {
Instant start = Instant.now();
List<AgentType> targets = decision.agentTypes();
List<CompletableFuture<AgentResponse>> futures = targets.stream()
.map(type -> CompletableFuture.supplyAsync(() -> execute(req, type)))
.toList();
List<AgentResponse> responses = futures.stream().map(CompletableFuture::join).toList();
List<String> parts = new ArrayList<>();
for (AgentResponse response : responses) {
if (response.success()) {
String role = response.agentType() == decision.primaryAgent() ? "主处理" : "辅助处理";
parts.add("[" + response.agentType().name().toLowerCase(Locale.ROOT) + " - " + role + "]\n" + response.content());
}
}
String content = parts.isEmpty() ? "抱歉,所有 Agent 均处理失败。" : String.join("\n\n", parts);
boolean escalate = responses.stream().anyMatch(AgentResponse::escalate);
List<AgentType> agentTypes = responses.stream()
.filter(AgentResponse::success)
.map(AgentResponse::agentType)
.toList();
return new OrchestratorResult(
req.requestId(),
content,
decision.primaryAgent(),
req.intent(),
escalate,
Duration.between(start, Instant.now()).toMillis(),
agentTypes.isEmpty() ? targets : agentTypes,
decision.primaryAgent(),
decision.supportingAgents(),
decision.reason(),
decision.confidence()
);
}
并行减少的是独立处理分支的等待叠加,并不会让每个模型调用本身变快。合并结果保留主辅标签,方便看出哪一段来自哪个处理者;当前不是多个 Agent 轮流讨论并达成共识的协议,合并也不等同于消除了相互矛盾的建议。
失败降级
单个 Agent 执行失败时,execute 会退回服务台再处理一次。兜底处理成功后,用户可以收到降级回答:
private AgentResponse execute(AgentRequest req, AgentType agentType) {
BaseAgent agent = bestAgent(agentType).orElseGet(() -> bestAgent(AgentType.SERVICE_DESK).orElse(null));
if (agent == null) {
return new AgentResponse(AgentType.SERVICE_DESK, "服务暂时不可用,请稍后重试。", false, 0.0, 0, false);
}
AgentResponse response = agent.handle(req);
if (!response.success() && agentType != AgentType.SERVICE_DESK) {
return bestAgent(AgentType.SERVICE_DESK).map(a -> a.handle(req)).orElse(response);
}
return response;
}
降级路径仍依赖服务台处理本身能够完成,因此不能承诺任意依赖故障都只返回正常答案。排查时应同时看原始 Agent、实际执行者和升级标记,避免将“服务台兜底成功”计作专属 Agent 处理成功。
回答校验的实际作用
AnswerVerifier 将问题、回答和完整上下文交给模型,读取 pass、grounded 和 need_escalation。控制器将校验结果作为响应字段,并把需要升级的判断合并进 escalated;当前不会因为 pass=false 自动重新生成答案或逐句删除回答。
校验器异常时采用宽松回退:pass 为 true,grounded 根据上下文是否非空判断,转人工根据回答文本中的提示判断。这让接口仍可返回,但非空上下文并不能证明回答逐句有据。因此这些标记应结合校验路径解释,不能当成严格事实保证。
演示里复刻了什么
演示的模拟后端把这里的规则用 JavaScript 重写了一遍:关键词规则、字符 n-gram 相似度、投票与细粒度优先、紧急度、领域打分、主辅 Agent 的筛选条件和升级路由,链路面板里能看到每一步的得分。浏览器没有模型,因此模型投票用规则结果和固定置信度代替,领域回复由模板生成,回答校验也不执行真实模型判断。
