OpsDesk 调试台:一条变更与故障混合的消息,左侧面板列出意图投票、领域打分和检索命中

IT 服务台多智能体

OpsDesk

面向企业 IT 服务的多智能体平台,识别请求意图、路由领域智能体,并检索知识与校验回答。

  • Java 21
  • Spring AI
  • Vue 3
  • Redis
03 / 05 · DOCUMENTATION

意图识别与路由

三路投票怎么算,领域打分怎么选 Agent,什么情况下升级或降级。

三路投票

意图、领域、紧急度和实体需要分开理解。意图表示用户要做什么,领域表示由谁处理,紧急度决定是否绕过普通路由,实体则保留错误码或工单号等具体线索。例如“VPN 报错 691,工单 #10086”可以同时携带网络故障意图、技术支持领域和两个实体。

IntentRecognizer.recognize 把一条消息交给三个互相独立的识别器,再合并结果:

  1. 大模型:给出意图、置信度和一句理由,返回 JSON。提示词里要求优先返回细粒度意图,比如”VPN 故障”,而不是宽泛的”故障”。
  2. 语义相似:把消息和每类意图的示例句各切成字符 n-gram,算 Jaccard 相似度,取最像的那一类。不需要向量库。
  3. 关键词规则:先匹配细粒度意图的关键词,没命中再匹配宽泛意图的关键词。
opsdesk/src/main/java/com/opsdesk/intent/IntentRecognizer.javaL91-119 @462c00c
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;识别结果按"消息 + 历史"缓存,超过 1000 条时整体清空。

怎么合并

合并发生在 vote 里。每一路的得分是”权重 × 置信度”,累加到它所投的意图上,得分最高的意图胜出:

opsdesk/src/main/java/com/opsdesk/intent/IntentRecognizer.javaL224-259 @462c00c
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);
}
权重 0.7 / 0.2 / 0.1。大模型失败时跳过投票,改用语义或规则的结果;最终得分低于 0.5 则归为 OTHER。

这段代码里有三个值得注意的分支:

  • 大模型不可用(调用失败,或返回的不是结构化 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 判断,不参与投票:

opsdesk/src/main/java/com/opsdesk/intent/IntentRecognizer.javaL324-338 @462c00c
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。

例如“我点开了钓鱼邮件链接”即使没有“立刻”“严重”这些词,也会因为安全事件类别进入 CRITICAL。相反,普通咨询中包含急迫语气,不应自动等同于已发生安全事件。紧急度与意图的独立处理让这种规则可以单独验证。

从意图到 Agent

AgentOrchestrator.run 是编排入口。顺序是:补全意图 → 必要时先澄清 → 路由 → 执行 → 判断是否升级。

opsdesk/src/main/java/com/opsdesk/agent/AgentOrchestrator.javaL48-95 @462c00c
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()
    );
}
低置信度的 OTHER 不硬猜,直接反问用户;多 Agent 命中时走并行分支。

领域打分

路由不是一张”意图 → Agent”的静态表。domainScores 给三个 Agent 各算一个分数,来源有三类:意图所属领域的基础分,消息里领域关键词的命中数,以及抽取到的实体(错误码偏向技术支持,资产编号偏向资产与变更,工单号偏向服务台)。

opsdesk/src/main/java/com/opsdesk/agent/AgentOrchestrator.javaL187-247 @462c00c
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

opsdesk/src/main/java/com/opsdesk/agent/AgentOrchestrator.javaL143-185 @462c00c
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))
    );
}
紧急度和升级意图在打分之前就直接决定了路由;打分后,分数第一的是主 Agent,满足条件的其余 Agent 作为辅助。

规则是:

  • 紧急度为 CRITICAL,或意图是升级、转人工:直接走升级路由,不打分。
  • 否则取分数最高的 Agent 为主 Agent。
  • 其余 Agent 要同时满足”分数不低于 0.45”和”不低于主 Agent 的 55%“才会成为辅助,且服务台不充当辅助。
  • 专属 Agent 不可用时,降级到服务台。

复合问题的处理示例

“发布后出现 500 错误并且网络故障”包含变更与技术故障两组线索。识别阶段给出一个主要意图,路由阶段仍会检查原消息关键词和实体,因此不必把整句话强制压成一个处理领域。

主 Agent 负责最主要的业务方向,满足阈值的辅助 Agent 补充另一领域建议。阈值同时限制绝对分和相对分,避免任何弱命中都扩大成多 Agent 调用。服务台作为兜底,不参与辅助集合,减少泛化回答重复。

紧急升级在领域打分之前执行,回答校验也可以在执行后建议转人工。前者由输入信号触发,后者由输出质量判断触发;它们都最终影响响应的升级标记,但发生在不同阶段。

并行协作

有辅助 Agent 时,各 Agent 用 CompletableFuture 并行处理,结果合并成一条回答,每段标明”主处理”或”辅助处理”:

opsdesk/src/main/java/com/opsdesk/agent/AgentOrchestrator.javaL97-130 @462c00c
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 会退回服务台再处理一次。兜底处理成功后,用户可以收到降级回答:

opsdesk/src/main/java/com/opsdesk/agent/AgentOrchestrator.javaL321-331 @462c00c
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 的筛选条件和升级路由,链路面板里能看到每一步的得分。浏览器没有模型,因此模型投票用规则结果和固定置信度代替,领域回复由模板生成,回答校验也不执行真实模型判断。