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

IT 服务台多智能体

OpsDesk

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

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

持久化与失败处理

从 Redis 未命中回源、MyBatis 查询到并行检索等待与评测落库,说明请求链路的可靠性边界。

完整会话与近期记忆

MySQL 保存完整消息,Redis 保存有 TTL 的近期记忆。读取时先访问 Redis;空列表或读取异常都会走数据库,而不是把缓存当作唯一数据源。

opsdesk/src/main/java/com/opsdesk/memory/MemoryManager.javaL135-150 @462c00c
public List<ConversationMessage> getWorkingMemory(String userId, String conversationId) {
    try {
        String key = wmKey(userId, conversationId);
        List<String> raw = redisTemplate.opsForList().range(key, 0, properties.getMemory().getWorkingMax() - 1);
        if (raw != null && !raw.isEmpty()) {
            List<ConversationMessage> messages = new ArrayList<>();
            for (int i = raw.size() - 1; i >= 0; i--) {
                messages.add(objectMapper.readValue(raw.get(i), ConversationMessage.class));
            }
            return messages;
        }
    } catch (Exception ex) {
        log.warn("Redis read failed, falling back to MySQL: {}", ex.getMessage());
    }
    return loadRecentMessagesFromDatabase(userId, conversationId);
}
缓存异常被记录后继续回源;数据库自身不可用仍会影响请求,不能理解为任意存储故障都能透明处理。

数据库查询同时按用户与会话过滤,倒序取最近 N 条,再由 Java 恢复对话顺序:

opsdesk/src/main/resources/mapper/MessageMapper.xmlL5-12 @462c00c
<select id="selectRecentByConversation" resultType="com.opsdesk.persistence.entity.MessageEntity">
    SELECT id, conversation_id, user_id, role, content, created_at
    FROM message
    WHERE conversation_id = #{conversationId}
      AND user_id = #{userId}
    ORDER BY id DESC
    LIMIT #{limit}
</select>

回源成功后尝试回填 Redis 并续设 TTL,回填失败不丢弃已经读出的消息。长对话压缩、用户画像和情景摘要是另外的处理环节;它们不能替代全量消息持久化。

写入顺序与故障影响

addMessage 先写数据库消息,再尝试向 Redis 推入近期记录并设置 TTL。Redis 更新异常被捕获,因此这条路径可以保留数据库记录;数据库写入自身失败则没有同样的透明兜底。

控制器分别调用用户消息与助手消息的写入方法,没有一个包住整个模型调用与两次写入的数据库事务。前一次写入成功、后一次失败时,可能留下不完整轮次,不能把“有持久化”解释成整轮对话绝对原子。

情况当前处理使用时的含义
Redis 没有近期消息查询 MySQL 最近消息并尝试回填缓存清空后仍有对话依据
Redis 读取或解析异常记录原因后回源该分支容忍缓存故障
Redis 回填失败返回已经读到的数据库消息不因回填失败丢弃查询结果
MySQL 查询或写入失败仍可能中断请求缓存不能代替完整持久化
画像提炼失败记录失败并保留已有画像不应把本轮画像视为已更新

压缩把更早消息总结为情景记忆,并保留近期逐字内容。摘要生成失败时可能退回消息数量说明,这种占位信息有助于维持流程,却不具备原摘要的业务信息量。

Schema 与查询边界

Flyway 迁移定义会话、消息、用户画像、知识文档与片段、评测案例与运行、Agent 指标等表。单表 CRUD 使用 MyBatis-Plus,关联、批量和统计查询使用 XML。

知识文档与片段不是同一条记录:文档用于管理原始资料,切片用于检索。评测也区分一次运行的汇总与逐题结果,才能追溯整体分数变化的原因。

知识新增在数据库事务中写入文档和切片,随后更新进程内索引。内存集合不参加 MySQL 回滚:后续数据库提交失败或同批资料写入中断时,内存与数据库仍可能产生差异。当前结构在启动时从库加载,尚不是具备事务提交后发布和跨节点同步的索引服务。

近期消息查询按用户与会话同时限定,是当前数据访问的隔离条件;它不等于已经建立外部身份认证。调用方传入的用户标识和真正可信的登录身份,仍属于不同层次。

并行检索与等待上限

KnowledgeToolManager.searchWithRewrite 保留原问题并加入改写子查询,使用 CompletableFuture 并行召回,按片段 ID 去重,再进行重排:

opsdesk/src/main/java/com/opsdesk/tool/KnowledgeToolManager.javaL38-72 @462c00c
public ToolResult<List<SearchResult>> searchWithRewrite(String query, int topK) {
    validate(query, topK);
    if (!breaker.allow()) {
        return new ToolResult<>(true, fallback(query, "工具熔断中,请稍后重试"), "knowledge_search", "circuit open", false, 0, false);
    }
    Instant start = Instant.now();
    try {
        List<String> queries = rewriteQuery(query);
        Map<String, SearchResult> merged = new LinkedHashMap<>();
        int recallK = Math.max(topK, 5);
        List<CompletableFuture<ToolResult<List<SearchResult>>>> futures = queries.stream()
                .map(subQuery -> CompletableFuture.supplyAsync(() -> search(subQuery, recallK)))
                .toList();
        for (CompletableFuture<ToolResult<List<SearchResult>>> future : futures) {
            for (SearchResult result : future.get(30, TimeUnit.SECONDS).data()) {
                merged.putIfAbsent(result.id(), result);
            }
        }
        List<SearchResult> reranked = rerank(query, new ArrayList<>(merged.values()), topK);
        stats.record(true, Duration.between(start, Instant.now()).toMillis());
        breaker.recordSuccess();
        return new ToolResult<>(true, reranked, "knowledge_search", null, false,
                Duration.between(start, Instant.now()).toMillis(), true);
    } catch (TimeoutException ex) {
        stats.record(false, Duration.between(start, Instant.now()).toMillis());
        breaker.recordFailure();
        return new ToolResult<>(true, fallback(query, "执行超时"), "knowledge_search", "timeout", false,
                Duration.between(start, Instant.now()).toMillis(), false);
    } catch (Exception ex) {
        stats.record(false, Duration.between(start, Instant.now()).toMillis());
        breaker.recordFailure();
        return new ToolResult<>(true, fallback(query, ex.getMessage()), "knowledge_search", ex.getMessage(), false,
                Duration.between(start, Instant.now()).toMillis(), false);
    }
}
每个 future.get 的等待上限为 30 秒;超时、异常和熔断打开时返回带原因的降级结果。

这里的 30 秒是逐个等待 future 的上限,不能当作整个接口的严格总超时;当前分支也没有主动取消全部未完成任务。结果缓存为进程内缓存,存在五分钟过期时间;它与 Redis 会话缓存是两种不同用途。

改写失败时回到原查询,重排失败时按已有分数排序。降级响应保留失败原因,避免把知识库不可用伪装成“知识库没有相关内容”。

取消等待与停止底层工作也不是一回事。某个 future 超时返回后,未完成任务仍可能继续占用线程和依赖资源。当前没有为整条请求传播统一 deadline,也未在失败路径统一回收所有检索任务,扩展规模时需要单独评估这些残留工作。

评测结果可追溯

运行先写 RUNNING,再保存逐题结果与汇总,最终标记 COMPLETED。回归比较以上一次完成的运行为基线,分别查看意图 Accuracy / Macro-F1 和回答质量,而不是只比较一项总分。

opsdesk/src/main/java/com/opsdesk/evaluation/EvalPersistenceService.javaL92-102 @462c00c
public void saveResults(String runId, List<Map<String, Object>> results) {
    for (Map<String, Object> result : results) {
        EvalResultEntity entity = new EvalResultEntity();
        entity.setRunId(runId);
        entity.setTestId(String.valueOf(result.get("test_id")));
        entity.setPassed(Boolean.TRUE.equals(result.get("passed")) ? 1 : 0);
        entity.setScoresJson(toJson(result.get("scores")));
        entity.setMetadataJson(toJson(result.get("metadata")));
        resultMapper.insert(entity);
    }
}
每题的通过状态、分数与元数据单独保存,支持定位回退案例。

现有后端测试包含 XML 查询、知识入库检索和 Redis 未命中回源。本站演示复刻规则、检索与评测交互,没有 MySQL、Redis 或模型服务,刷新后本地会话和新增知识会清空;真实持久化与故障处理应以这些后端路径验证。

指标与回归如何解释

意图 Accuracy 是预测正确的样本比例;Macro-F1 先计算各类 F1 再取平均,能暴露常见类别表现良好但少数类别退步的情况。当前计算包含真实标签与预测标签的并集,因此误预测出一个额外类别也会影响宏平均。

回答质量由模型裁判给出相关性、准确性、完整性和有用性,并保留 judge_failed 与错误信息。裁判失败、无模型降级和真实模型评分不能混在一起解释;模型裁判分数也不是人工验收的替代品。

历史比较只有在样本、模型和配置相近时才有意义。新增难题导致整体分数下降,与同一批案例被改坏,是两种不同的变化。逐题记录中的输入、期望、预测和回答帮助找到实际退步,不能只看运行汇总的一条百分比。

从现有测试推导后续重点

已有 XML、入库检索和回源测试验证关键基础路径,但不能覆盖所有外部模型与并发场景。进一步验证应围绕可重现的失败构造:两次消息写入之间失败、事务提交与内存索引分离、子查询超时后残留、知识更新后缓存旧值,以及校验器异常时的回退标记。

这些是根据当前实现识别出的验证方向,不代表本次文档扩写已经新增或运行对应的后端测试。