持久化与失败处理
从 Redis 未命中回源、MyBatis 查询到并行检索等待与评测落库,说明请求链路的可靠性边界。
完整会话与近期记忆
MySQL 保存完整消息,Redis 保存有 TTL 的近期记忆。读取时先访问 Redis;空列表或读取异常都会走数据库,而不是把缓存当作唯一数据源。
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 恢复对话顺序:
<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 去重,再进行重排:
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);
}
}
这里的 30 秒是逐个等待 future 的上限,不能当作整个接口的严格总超时;当前分支也没有主动取消全部未完成任务。结果缓存为进程内缓存,存在五分钟过期时间;它与 Redis 会话缓存是两种不同用途。
改写失败时回到原查询,重排失败时按已有分数排序。降级响应保留失败原因,避免把知识库不可用伪装成“知识库没有相关内容”。
取消等待与停止底层工作也不是一回事。某个 future 超时返回后,未完成任务仍可能继续占用线程和依赖资源。当前没有为整条请求传播统一 deadline,也未在失败路径统一回收所有检索任务,扩展规模时需要单独评估这些残留工作。
评测结果可追溯
运行先写 RUNNING,再保存逐题结果与汇总,最终标记 COMPLETED。回归比较以上一次完成的运行为基线,分别查看意图 Accuracy / Macro-F1 和回答质量,而不是只比较一项总分。
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、入库检索和回源测试验证关键基础路径,但不能覆盖所有外部模型与并发场景。进一步验证应围绕可重现的失败构造:两次消息写入之间失败、事务提交与内存索引分离、子查询超时后残留、知识更新后缓存旧值,以及校验器异常时的回退标记。
这些是根据当前实现识别出的验证方向,不代表本次文档扩写已经新增或运行对应的后端测试。
