一、引言
在RAG系统的实际运行中,外部服务依赖是不可避免的脆弱点:LLM API可能超时,Embedding服务可能出现故障,向量数据库可能响应延迟,网络连接可能中断。基于我们在测试环境收集的数据,观察到LLM API约有3-5%的调用失败率,Embedding服务有约1-2%的超时率,这意味着完全依赖外部服务的系统在生产环境中可能经常出现不可用的情况。更重要的是,即使是最简单的系统,也可能遇到用户上传恶意文件、数据库连接异常、文件系统满等各种预料之外的问题。
工程化实践的核心目标是在各种异常情况下保证系统的基本可用性,提供良好的用户体验,并确保数据一致性和安全性。这不仅需要在架构设计时就考虑容错能力,更需要在每个实现环节都加入防御性代码。
这些工程化实践的实现,需要在功能完善性和代码复杂度之间找到平衡。过多的防御性代码会增加系统的复杂性,太少的保护又会让系统在生产环境中变得脆弱。StoryVerse采用了分层防御的策略:在入口处进行严格验证,在处理环节加入容错机制,在数据存储层确保一致性,在状态展示层提供友好反馈。
二、降级设计
2.1 降级策略设计原则
降级设计不是简单地"出错时显示个提示",而是需要系统性的思考。StoryVerse的降级策略设计遵循两个核心原则:一是感知降级,通过source_type字段记录数据来源,为后期分析和优化提供依据;二是渐进式降级,在LLM服务不可用时提供基础但可用的替代方案,而非完全阻断功能。
private static final String SOURCE_TYPE_LLM_SUMMARY = "llm_summary";
private static final String SOURCE_TYPE_LOCAL_FALLBACK = "local_fallback";
数据来源标记是一个重要设计,它有几个关键用途。首先,它让我们能够统计兜底方案的使用频率,评估LLM服务的稳定性,为资源扩容或服务切换提供数据支持。其次,它让我们能够分析兜底内容的质量,如果发现很多降级数据,可以针对性地优化提示词或改进LLM调用策略。最后,在未来可能的人工审核流程中,这些标记可以帮助优先审核兜底生成的内容。
2.2 世界观总结降级实现
世界观总结采用二级降级策略:优先使用LLM生成高质量总结,当LLM调用失败或返回空内容时,降级至本地兜底方案。本地方案通过提取前两段剧情进行截断拼接,生成基础但可用的总结,确保系统功能不中断:
private static final int MAX_WORLD_SUMMARY_CHARS = 220;
private String buildWorldSummaryWithFallback(String novelTitle, String fullText, List<NovelKnowledgeClient.PlotChunk> plotChunks) {
try {
// 优先使用LLM
return novelKnowledgeClient.buildWorldSummary(novelTitle, fullText);
} catch (RuntimeException ex) {
log.warn("小说 {} 的世界观总结降级到本地兜底: {}", novelTitle, safeMessage(ex));
// 降级到本地方案
return buildLocalWorldSummary(novelTitle, plotChunks);
}
}
private String buildLocalWorldSummary(String novelTitle, List<NovelKnowledgeClient.PlotChunk> plotChunks) {
StringBuilder summary = new StringBuilder("《").append(novelTitle).append("》的本地兜底世界观总结。");
if (!plotChunks.isEmpty()) {
summary.append(" 当前主要内容:").append(truncateText(plotChunks.get(0).text(), MAX_WORLD_SUMMARY_CHARS));
}
if (plotChunks.size() > 1) {
summary.append(" 后续补充:").append(truncateText(plotChunks.get(1).text(), MAX_WORLD_SUMMARY_CHARS));
}
return summary.toString();
}
private String truncateText(String text, int maxChars) {
if (text == null || text.isBlank()) {
return "";
}
String normalized = text.replaceAll("\\s+", " ").trim();
if (normalized.length() <= maxChars) {
return normalized;
}
return normalized.substring(0, maxChars) + "...";
}
这个实现有几个精心设计的细节。首先,异常捕获使用了RuntimeException,这捕获了所有可能的异常类型,包括NovelKnowledgeClient内部可能抛出的业务异常和各种系统异常。其次,降级时记录了warn级别日志,这让我们能够监控兜底方案的使用情况,而不仅仅是错误情况。最后,truncateText方法在截断文本时还对空白字符进行了规范化,确保生成的摘要文本整洁。
本地兜底方案虽然简单,但很有效。它选择了小说最前面的两个剧情块,通常包含最重要的开场信息,这为后续的角色扮演功能提供了最基本的上下文。虽然这种简单拼接不能替代LLM生成的总结,但它确保了系统在外部服务故障时仍然可用,用户仍然可以进行基本的小说角色对话。
2.3 角色识别降级实现
角色识别同样采用二级策略:LLM识别失败或返回空列表时,系统创建默认"主要人物"角色,其摘要基于第一段剧情生成。该策略虽在质量上有所妥协,但保证了后续角色扮演功能的可用性:
private static final int MAX_CHARACTER_SUMMARY_CHARS = 160;
private CharacterProfileBatch buildCharacterProfilesWithFallback(String novelTitle, String fullText, List<NovelKnowledgeClient.PlotChunk> plotChunks) {
try {
List<NovelKnowledgeClient.CharacterProfileResult> characters =
novelKnowledgeClient.buildCharacterProfiles(novelTitle, fullText, plotChunks);
if (!characters.isEmpty()) {
return new CharacterProfileBatch(characters, SOURCE_TYPE_LLM_SUMMARY);
}
log.warn("小说 {} 没有生成角色画像,使用本地兜底方案", novelTitle);
} catch (RuntimeException ex) {
log.warn("小说 {} 的角色画像降级到本地兜底: {}", novelTitle, safeMessage(ex));
}
// 降级到本地方案
return new CharacterProfileBatch(buildLocalCharacterProfiles(fullText, plotChunks), SOURCE_TYPE_LOCAL_FALLBACK);
}
private List<NovelKnowledgeClient.CharacterProfileResult> buildLocalCharacterProfiles(String fullText, List<NovelKnowledgeClient.PlotChunk> plotChunks) {
List<NovelKnowledgeClient.CharacterProfileResult> results = new ArrayList<>();
String summary;
if (!plotChunks.isEmpty()) {
summary = "本地兜底角色画像:根据第一段剧情整理," +
truncateText(plotChunks.get(0).text(), MAX_CHARACTER_SUMMARY_CHARS);
} else {
summary = "本地兜底角色画像:根据上传文本整理," +
truncateText(fullText, MAX_CHARACTER_SUMMARY_CHARS);
}
results.add(new NovelKnowledgeClient.CharacterProfileResult("主要人物", summary));
return results;
}
private record CharacterProfileBatch(List<NovelKnowledgeClient.CharacterProfileResult> characters, String sourceType) {}
角色识别降级的设计有两个值得注意的点。首先,降级触发条件不仅是异常情况,还包括LLM返回空列表的情况,这确保了即使LLM正常响应但没有识别出角色时,系统也能正常工作。其次,使用记录类(record)封装结果,这是一个很好的数据封装实践,它使返回值包含了角色列表和来源类型两个信息,保持了方法的简洁性。
兜底角色命名为"主要人物"是一个精心的选择。这个名称既符合小说的常见设定,又不会误导用户认为系统识别出了具体角色。同时,在角色描述中明确标记这是"本地兜底角色画像",让了解系统的用户能够知道内容来源,避免对内容质量产生过高期望。
三、事务管理与一致性保障
3.1 文件操作与数据库事务
文件上传流程中,文件保存与数据库写入构成一个分布式事务。系统采用"先文件后数据库"的策略,在数据库操作失败时通过deleteQuietly方法清理已保存的文件,避免垃圾数据积累。这种补偿式事务设计在单机部署场景下能有效保证一致性:
@Transactional
public NovelUploadResponse uploadNovel(NovelUploadRequest request) {
// ... 前置处理 ...
Path storedPath = storeNovelFile(relativeFileUri, fileBytes);
try {
if (novelMapper.findById(novelRecord.getId()) == null) {
novelMapper.insert(novelRecord);
} else {
novelMapper.update(novelRecord);
}
uploadRecordMapper.insert(uploadRecord);
} catch (RuntimeException ex) {
// 异常时清理已保存的文件
deleteQuietly(storedPath);
throw ex;
}
// ... 后续处理 ...
}
private void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 忽略删除异常,避免二次错误
}
}
事务策略选择是这个实现的关键。为什么是"先文件后数据库"而不是"先数据库后文件"?因为文件系统没有事务支持,如果先写数据库后写文件,当文件写入失败时,我们需要回滚数据库,而回滚逻辑在有外键约束或复杂关联时会变得复杂。反过来,如果先写文件后写数据库,当数据库操作失败时,我们只需要删除已保存的文件即可,这个逻辑要简单得多。
deleteQuietly方法的实现也体现了防御性编程的思想。它不抛出任何异常,而是默默忽略删除失败。这是因为当我们已经处于异常处理流程中时,删除失败只是一个次要问题,不应该阻止主异常的传播。同时,方法对null路径进行了检查,避免了空指针异常。
值得注意的是,这种补偿式事务设计在单机环境下是足够的,但在分布式环境中可能不够完善。如果文件保存成功但数据库事务提交失败,我们成功删除了文件,系统处于一致状态。但如果在数据库事务提交后,应用突然崩溃,文件无法被清理,会留下垃圾数据。在实际系统中,可以通过后台定期清理任务来处理这种边缘情况。
3.2 数据库事务边界与并发控制
角色画像持久化通过@Transactional注解保证原子性:多个角色画像的插入要么全部成功,要么全部回滚。同时,character_profile表的唯一索引uq_character_profile_novel_name与MyBatis的upsert操作配合,实现了"存在则更新,不存在则插入"的语义,避免了竞态条件问题:
@Transactional
protected void persistCharacterProfiles(UUID novelId, List<NovelKnowledgeClient.CharacterProfileResult> profiles, String sourceType) {
for (NovelKnowledgeClient.CharacterProfileResult profile : profiles) {
CharacterProfileRecord record = new CharacterProfileRecord();
record.setId(UUID.randomUUID());
record.setNovelId(novelId);
record.setCharacterName(profile.characterName());
record.setSummaryText(profile.summaryText());
record.setSourceType(sourceType);
characterProfileMapper.upsert(record); // 使用upsert而非insert
}
}
对应的MyBatis Mapper XML实现:
<insert id="upsert">
INSERT INTO character_profile (id, novel_id, character_name, summary_text, source_type)
VALUES (#{id}, #{novelId}, #{characterName}, #{summaryText}, #{sourceType})
ON DUPLICATE KEY UPDATE
summary_text = VALUES(summary_text),
source_type = VALUES(source_type),
updated_at = CURRENT_TIMESTAMP
</insert>
事务边界的选择很重要。将整个循环放在单个事务中,确保了所有角色画像要么全部插入成功,要么全部回滚,保持了数据一致性。但在极端情况下,如果小说包含数百个角色,这个事务可能会比较大。在实际系统中,可以考虑分批处理,但在StoryVerse的场景下,单本小说的角色数量通常不会太多,这种设计是合理的。
Upsert操作与唯一索引的组合是处理并发写入的经典做法。即使两个请求同时尝试为同一小说创建同一个角色,由于唯一索引的存在,第二个请求不会插入新记录,而是会更新现有记录。这种设计避免了先查询后插入的竞态条件问题,在高并发场景下表现更稳定。
在更新逻辑中,我们只更新了summary_text和source_type字段,保留了原始记录的id和created_at,这是一个合理的设计。这确保了记录的标识符保持稳定,即使多次上传同一小说,角色记录的ID也不会变化。同时,更新updated_at字段让我们能够追踪记录的最后修改时间。
3.3 流程中的状态追踪
在处理流程的每个关键节点,系统都会更新upload_record表的状态,确保流程的可观测性:
@Transactional
protected void updateUpload(UUID uploadRecordId, String status, String stage, Integer chunkCount, Integer characterCount, String worldPointId, String errorMessage) {
uploadRecordMapper.updateStatus(uploadRecordId, status, stage, chunkCount, characterCount, worldPointId, errorMessage);
}
状态追踪不仅是为了向用户展示进度,它在故障恢复中也起着重要作用。如果处理流程在某个阶段崩溃,系统重启后可以通过upload_record表的状态知道处理到了哪一步,从而决定是重试还是需要用户重新上传。在更完善的系统中,甚至可以实现断点续传功能,从中断的地方继续处理,而不用重新开始。
每个状态更新使用独立事务的设计也有其考虑。这样可以确保即使后续步骤失败,之前的进度也能被记录下来。如果所有状态更新都在同一个大事务中,那么当某个步骤失败时,所有状态更新都会被回滚,我们就无法知道处理实际进行到了哪一步。
四、异常处理与可观测性
4.1 异常层次设计
系统定义了专用的BusinessException,用于表示业务逻辑异常,与系统级异常区分开:
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
异常分层设计是一个常见但重要的实践。通过区分业务异常和系统异常,我们可以在全局异常处理器中采用不同的处理策略。业务异常通常可以直接展示给用户,因为它们表达的是业务规则(如"文件不能为空"),而系统异常应该被记录详细日志,向用户展示一个通用的错误消息(如"系统出现错误,请稍后重试")。
虽然当前代码中没有显式的全局异常处理器,但这种异常层次设计为后续实现提供了基础。在典型的Spring Boot应用中,可以通过@RestControllerAdvice来实现全局异常处理,统一所有API的错误响应格式,确保无论何处发生异常,客户端收到的都是一致的错误响应结构。
4.2 安全错误信息处理
safeMessage方法确保不会将敏感信息(如API密钥、内部路径)暴露给用户。内部详细错误信息仅记录在日志中,用于问题诊断;面向用户的错误信息经过滤处理,确保安全性的同时提供足够的反馈:
private String safeMessage(Throwable throwable) {
if (throwable == null || throwable.getMessage() == null || throwable.getMessage().isBlank()) {
return "unknown error";
}
return throwable.getMessage().trim();
}
在流程失败时,系统同时记录详细日志和更新用户可见的状态:
} catch (RuntimeException ex) {
log.error("处理上传 {} 失败", uploadRecordId, ex);
updateUpload(uploadRecordId, "failed", requireUploadRecord(uploadRecordId).getStage(),
0, 0, null, safeMessage(ex));
novelMapper.updateStatus(novel.getId(), "draft");
throw ex;
}
日志记录有几个关键点。首先,使用error级别记录完整异常(包括堆栈跟踪),这对于诊断问题至关重要。其次,在日志消息中包含uploadRecordId,这样可以通过日志中的ID快速关联到数据库中的记录,查看更多上下文信息。最后,异常被重新抛出,而不是被吞掉,这确保了上层逻辑或监控系统能够感知到故障。
safeMessage方法虽然当前实现很简单,但框架已经搭好。在更完善的实现中,我们可以进一步过滤异常消息中的敏感信息,如数据库连接字符串、文件系统路径、API密钥等。甚至可以设计一个错误消息转换机制,将内部错误代码映射为更友好的用户消息。
值得注意的是,失败时我们保留了当前的stage信息,这样用户可以知道系统是在哪个步骤失败的,这对于问题报告很有帮助。同时,我们将小说状态回退为draft,允许用户重新发起处理流程,而不会让小说卡在"处理中"状态。
4.3 输入验证与防护
系统在入口处进行了完整的输入验证,及早发现和处理非法输入:
private void validateUploadRequest(NovelUploadRequest request) {
if (request.getFile() == null || request.getFile().isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
if (!StringUtils.hasText(request.getTitle())) {
throw new BusinessException("小说标题不能为空");
}
}
输入验证是防御性编程的第一道防线,它有三个主要目的。首先,它可以快速失败,在进行任何昂贵操作(如文件保存、LLM调用)之前就发现问题,节省资源。其次,它提供清晰的错误消息,让用户知道如何修正输入。最后,它防范了恶意输入,虽然在这个简单的验证中主要是检查必填项,但在更复杂的场景中,还可以检查文件大小、文件类型等。
这个验证方法被设计为私有方法,在uploadNovel方法的开始处被调用。这种"先验证后处理"的模式是一个良好的实践,它确保所有后续代码都可以假设输入是有效的,简化了错误处理逻辑。
当前的验证逻辑相对简单,在实际生产系统中,通常需要更全面的验证。例如,限制上传文件的大小,检查文件类型(确保是文本文件而非可执行文件),验证标题的长度,过滤可能的XSS攻击向量等。这些验证可以根据安全需求逐步添加。
五、安全防护措施
5.1 路径遍历防护
存储路径安全通过双重检查确保:首先对文件名进行安全过滤,删除危险字符;其次在路径解析后进行归一化检查,确保最终路径在根目录下,有效防范路径遍历攻击:
private Path storeNovelFile(String relativeFileUri, byte[] content) {
Path root = Path.of(storageProperties.getNovelDir()).toAbsolutePath().normalize();
Path targetPath = root.resolve(relativeFileUri).normalize();
// 关键检查:确保最终路径在根目录下
if (!targetPath.startsWith(root)) {
throw new BusinessException("非法的存储路径");
}
try {
Files.createDirectories(targetPath.getParent());
Files.write(targetPath, content);
return targetPath;
} catch (IOException ex) {
throw new BusinessException("保存上传文件失败");
}
}
路径遍历攻击(Path Traversal)是Web应用中常见的安全漏洞,攻击者通过构造包含../的文件名,尝试访问应用程序根目录之外的文件。如果没有适当防护,攻击者可能会覆盖系统文件、读取敏感数据或上传恶意文件到可执行位置。
这里的防御措施有几层。首先,在路径构建前,我们已经通过sanitizeFileName方法过滤了文件名中的危险字符,这是第一道防线。其次,在构建完整路径后,我们对根路径和目标路径都进行了归一化(normalize),这解析了所有.和..组件。最后,也是最关键的,我们检查归一化后的目标路径是否以归一化后的根路径开头,这确保了文件不会被存储到根目录之外。
toAbsolutePath的使用也很重要,它确保根路径和目标路径都是绝对路径,避免了相对路径带来的歧义。这种双重归一化加前缀检查的方法,是防范路径遍历攻击的标准做法。
5.2 文件名安全处理
文件名处理采用白名单策略,仅保留字母、数字、下划线、短横线和点,其他字符均被替换为下划线。该策略有效防范了各种基于文件名的攻击向量:
private String sanitizeFileName(String originalFilename) {
String fileName = StringUtils.hasText(originalFilename) ? originalFilename : "novel.txt";
// 白名单策略
String sanitized = fileName.replaceAll("[^a-zA-Z0-9._-]", "_");
return sanitized.isBlank() ? "novel.txt" : "novel.txt";
}
等等,这里发现一个bug!原代码最后无论处理结果如何,都返回了"novel.txt",这显然是个错误。让我修正这个问题:
private String sanitizeFileName(String originalFilename) {
String fileName = StringUtils.hasText(originalFilename) ? originalFilename : "novel.txt";
// 白名单策略
String sanitized = fileName.replaceAll("[^a-zA-Z0-9._-]", "_");
return StringUtils.hasText(sanitized) ? sanitized : "novel.txt";
}
白名单策略通常比黑名单策略更安全,因为它只允许确知安全的字符,而不是尝试猜测所有可能的危险字符。在文件安全领域,这种方法特别有效,因为危险的文件名可能包含各种特殊字符,如路径分隔符、命令注入字符、Null字符等。
除了过滤字符,在实际生产系统中,通常还会考虑其他安全措施。例如,使用系统生成的文件名而不是用户上传的文件名(我们的代码中已经通过使用UUID路径部分地做到了这一点),检查文件内容以确保其与扩展名匹配(如文件声称是txt但实际是exe),限制文件名长度以防止各种缓冲区溢出攻击等。
5.3 文件完整性校验
通过计算和存储文件的SHA-256哈希值,系统可以校验文件完整性,防范文件篡改:
private String sha256Hex(byte[] content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content));
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not supported", ex);
}
}
文件哈希虽然当前主要用于标识文件,但它有很多潜在的扩展用途。在未来,我们可以用它来做重复文件检测:如果用户上传一个哈希已存在的文件,我们可以直接使用已有的知识,而不用重复处理,节省计算资源。哈希也可以用于文件完整性校验,在读取文件时重新计算哈希,确保文件在存储后没有被意外修改或恶意篡改。
值得注意的是异常处理方式。NoSuchAlgorithmException被包装在IllegalStateException中,这是一个合适的选择,因为SHA-256算法在所有符合规范的JVM中都是必需的,如果它不可用,那就是一个严重的系统错误,而不是一个可恢复的业务异常。
六、总结
StoryVerse的工程化实践围绕"为失败而设计"的理念展开,通过降级保证可用性,通过事务管理保证一致性,通过异常处理保证友好性,通过安全措施保证安全性。这四个方面共同构成了系统稳定运行的基石,虽然它们不如AI功能那样引人注目,但在生产环境中,它们恰恰是最重要的部分。
降级设计让系统在外部服务故障时依然提供可用功能,感知降级机制为后续优化提供了数据支持。事务管理确保了文件操作和数据库操作的一致性,补偿式事务设计在单机环境下足够有效。异常处理不仅提供了友好的用户反馈,还确保了可观测性,帮助开发人员快速诊断问题。安全防护措施从多个层面防范了常见的安全威胁,特别是路径遍历防护的多层防御设计,值得借鉴。
三篇技术博客系统地介绍了小说知识库系统的架构设计、技术实现和工程化实践,从高层架构到具体代码,完整地展示了一个RAG系统的设计与实现过程。希望这些内容能够为类似系统的开发提供参考和借鉴,帮助开发人员少走弯路,构建更稳定、更安全的系统。