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

IT 服务台多智能体

OpsDesk

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

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

检索与监控回写

不依赖外部向量库的混合检索,以及监控如何把在线表现折算成惩罚分。

混合检索

服务台资料里既有自然语言描述,也有必须精确匹配的标识。“远程访问失败”与“VPN 连不上”可能描述同一件事,而错误码 691、具体软件名和资产编号又不能只依靠宽泛相似度处理。两路打分尝试兼顾词项的重要性与共享词片段。

知识库检索由两路得分加权相加:BM25 按词项频率与文档长度打分,哈希向量提供固定维度的词项重叠近似。两路各自归一化到 0~1,再按配置的权重融合,默认 BM25 占 0.65,向量占 0.35。

opsdesk/src/main/java/com/opsdesk/knowledge/KnowledgeBaseService.javaL126-151 @462c00c
public List<SearchResult> search(String query, int topK) {
    if (query == null || query.isBlank()) {
        return List.of();
    }
    Map<String, Double> bm25 = bm25Scores(query);
    Map<String, Double> vector = vectorScores(query);
    Map<String, Double> fused = new HashMap<>();
    documents.forEach(doc -> {
        double score = properties.getRag().getBm25Weight() * bm25.getOrDefault(doc.id(), 0.0)
                + properties.getRag().getVectorWeight() * vector.getOrDefault(doc.id(), 0.0);
        fused.put(doc.id(), score);
    });
    return documents.stream()
            .sorted(Comparator.comparingDouble((KnowledgeDocument d) -> fused.getOrDefault(d.id(), 0.0)).reversed())
            .limit(topK)
            .map(doc -> new SearchResult(
                    doc.id(),
                    doc.title(),
                    doc.content(),
                    round(fused.getOrDefault(doc.id(), 0.0)),
                    doc.chunkIndex(),
                    doc.metadata()
            ))
            .filter(result -> result.score() > 0.0)
            .toList();
}
融合发生在内存里:遍历全部文档,按加权分排序,取前 topK,分数为 0 的结果丢弃。

不用向量库的”向量”

这里的向量是 256 维的哈希向量。每个分词结果按哈希值落到某一维上,哈希的奇偶决定加一还是减一,查询和文档用同样的方法算出向量,再比余弦相似度:

opsdesk/src/main/java/com/opsdesk/knowledge/KnowledgeBaseService.javaL278-287 @462c00c
private double[] embed(String text) {
    double[] vector = new double[256];
    Set<String> grams = new HashSet<>(tokenize(text));
    for (String gram : grams) {
        int hash = gram.hashCode();
        int idx = Math.floorMod(hash, vector.length);
        vector[idx] += (hash & 1) == 0 ? 1.0 : -1.0;
    }
    return vector;
}

它不理解语义,只是把共享的词片段折叠到固定维度上做近似,所以和真正的 embedding 模型不是一个量级。它的价值是零依赖:知识库规模不大、要求能离线跑通整条链路时,不用额外部署向量库。后续可以把 embed 换成真实的 embedding 服务,融合逻辑不用改。

opsdesk/src/main/java/com/opsdesk/knowledge/KnowledgeBaseService.javaL249-257 @462c00c
private Map<String, Double> normalize(Map<String, Double> scores) {
    double max = scores.values().stream().mapToDouble(Double::doubleValue).max().orElse(0.0);
    if (max <= 0.0) {
        return scores;
    }
    Map<String, Double> normalized = new HashMap<>();
    scores.forEach((id, score) -> normalized.put(id, score / max));
    return normalized;
}
两路得分量纲不同,所以都按各自的最大值归一化,再加权。

从文档到检索片段

知识入库先将资料拆为片段,再保存文档主体、切片与标题等元数据。默认使用递归切分器,配置的片段规模为 500、重叠为 80;切分器异常时走简单文本切分。启动时把持久化片段加载为内存记录,并生成对应哈希向量。

BM25 的词项频率、文档频率和长度归一化帮助区分常见词与相对稀有的业务词。哈希向量把词片段压入固定维度,余弦相似度提供另一种重叠近似;不同词发生哈希碰撞仍可能增加噪声,没有真正获得语言模型式的语义理解。

两路各按本次候选中的最大分归一化,因此融合分是当前查询下的相对排序依据。不同问题的最高分不能直接横向比较,更不能把某个固定分值理解成统一的回答可信度。

检索之前和之后

/search 与 /chat 里的检索不是直接调用 search。外面还有一层:查询先被改写成多个子查询并行召回,按片段 ID 去重,再由大模型重排。这一层由 tool 包的工具管理器负责,同时加上缓存、等待上限、三态熔断和降级结果。熔断打开时返回降级结果,不会拖垮整条对话链路。

只有业务意图才会触发这一整套检索。问候和闲聊直接跳过。

flowchart TD
  Q[原始问题] --> X[保留原问题并改写]
  X --> P[并行子查询召回]
  P --> D[按片段 ID 去重]
  D --> R[模型重排或分数回退]
  R --> C[知识上下文]
  F[超时、异常或熔断] --> G[带原因的降级片段]
  G --> C

原问题始终保留,避免改写漏掉专有名词;各子查询先扩大召回,再按片段 ID 合并。相同片段被多个查询命中只保留一次,当前实现不会因为重复命中而自动提高它的融合权重。模型重排返回的是候选索引顺序,解析失败时使用已有分数排序。

熔断与缓存的边界

工具缓存按查询与 topK 保存结果,五分钟内可以复用。知识新增不会在这段管理器实现中主动清空所有旧查询缓存,因此刚录入资料后相同查询可能短暂读到旧结果;多实例各自持有缓存,也没有统一失效广播。

熔断器达到失败阈值后进入 OPEN,恢复等待后进入 HALF_OPEN,成功再回到 CLOSED。当前半开状态没有限制为只允许单个探测请求;此外底层检索会把异常包装成降级片段,外层不一定将其统计为失败。因此熔断状态只能解释已被当前实现计入的失败,不能当成所有依赖异常的完整计数。

降级片段有固定标识和错误元数据,含义是“未能完成检索”。如果只看结果列表非空,或响应里的知识命中布尔值,可能将这类片段误当作真实文档命中;分析时需要查看片段来源与 fallback 标记。

监控与惩罚分

PerformanceMonitor.collect 每 10 秒运行一次,从编排器取各 Agent 的统计,做两件事:超过阈值就告警,同时算出每个 Agent 的惩罚分写回去。

opsdesk/src/main/java/com/opsdesk/monitor/PerformanceMonitor.javaL68-100 @462c00c
public void collect() {
    monitorCollectionTimer.record(() -> {
        monitorCollections.increment();
        Map<String, Object> agentStats = orchestrator.stats();
        Map<String, Double> penalties = new HashMap<>();
        double[] minSuccessRate = {1.0};
        agentStats.forEach((key, value) -> {
            if (value instanceof Map<?, ?> map) {
                double sr = asDouble(map.get("success_rate"), 1.0);
                double avg = asDouble(map.get("avg_ms"), 0.0);
                if (sr < minSuccessRate[0]) {
                    minSuccessRate[0] = sr;
                    agentSuccessRate = sr;
                }
                if (sr < properties.getMonitor().getSuccessRateThreshold()) {
                    addAlert("agent_success_rate:" + key, sr, properties.getMonitor().getSuccessRateThreshold());
                }
                if (avg > properties.getMonitor().getLatencyMsThreshold()) {
                    addAlert("agent_avg_ms:" + key, avg, properties.getMonitor().getLatencyMsThreshold());
                }
                penalties.put(key, routingPenalty(sr, avg));
            }
        });
        agentSuccessRate = minSuccessRate[0];
        orchestrator.updateRoutingPenalties(penalties);
        if (!penalties.isEmpty()) {
            addSuggestion("路由权重已根据在线表现调整", "检查 /monitor 中低成功率或高延迟 Agent,必要时优化 prompt 或增加实例。", 7);
        }
        if (++collectCount % METRIC_PERSIST_EVERY_COLLECTIONS == 0) {
            persistAgentMetrics(agentStats);
        }
    });
}
采样、告警、回写在同一个定时任务里完成;每隔若干次采样才落一次库。

HTTP 请求指标、工具调用统计和 Agent 处理统计是不同层次。一次 HTTP 请求可能包含意图判断、多个检索子查询、多个 Agent 和校验调用;某一层的成功率良好,不保证整个用户请求成功或延迟可接受。

惩罚分的算法很直接:成功率低于 90% 时按差距加罚,平均延迟超过 3 秒时再按超出部分加罚,总惩罚不超过 0.9:

opsdesk/src/main/java/com/opsdesk/monitor/PerformanceMonitor.javaL130-139 @462c00c
private double routingPenalty(double successRate, double avgMs) {
    double penalty = 0.0;
    if (successRate < 0.90) {
        penalty += Math.min(0.5, (0.90 - successRate) * 2);
    }
    if (avgMs > 3000) {
        penalty += Math.min(0.4, (avgMs - 3000) / 10000);
    }
    return Math.min(0.9, penalty);
}

惩罚分用在哪

惩罚分被写进每个 Agent 实例的统计里,参与它的路由得分:

opsdesk/src/main/java/com/opsdesk/agent/AgentStats.javaL34-38 @462c00c
public double routingScore() {
    double latencyScore = 1.0 / (1.0 + avgLatencyMs() / 1000.0);
    double base = successRate() * 0.7 + latencyScore * 0.3;
    return base * Math.max(0.0, 1.0 - monitorPenalty);
}
路由得分 = (成功率 × 0.7 + 延迟分 × 0.3) × (1 − 惩罚分)。

编排器在同一类型的多个实例里,选路由得分最高的一个:

opsdesk/src/main/java/com/opsdesk/agent/AgentOrchestrator.javaL333-336 @462c00c
private Optional<BaseAgent> bestAgent(AgentType agentType) {
    return pool.getOrDefault(agentType, List.of()).stream()
            .max(Comparator.comparingDouble(a -> a.stats().routingScore()));
}

要说清楚的是它的作用范围:这个得分只用来在同一类型的实例之间挑选,不参与前面”领域打分”那一步,也就是它不会让路由从技术支持改投服务台。而现在每类 Agent 只部署一个实例(见 AgentConfig),所以惩罚分暂时不会改变任何一次路由结果,只会出现在 /monitor 的 monitor_penalty 和 routing_score 字段里。机制本身是完整的:给某一类增加实例后,它就开始起作用。

如何判断一次调优是否有效

检索调优先看正确片段是否进入候选,再看重排是否把它放进最终 topK,最后检查回答是否实际使用了资料。召回不足、重排丢失和回答忽略上下文分别对应不同问题,不能只用最终回答是否“看起来不错”来定位。

监控调优则应固定同一领域、请求类型和统计窗口。降低平均延迟可能只是更多请求走了降级;提高工具成功率也可能来自异常被包装。指标需要与降级标记、路由变化及逐条评测结果一起解释。