生产AI系统的代码重构:从快速原型到可维护代码
生产AI系统的代码重构:从快速原型到可维护代码
一、真实故事:那段"没人敢动"的代码
2025年4月,某金融科技公司召开了一次特殊的代码评审会议。
会议室里,技术总监赵磊打开了一个叫ChatService.java的文件。
这个文件有3278行。
没有人开口说话。因为大家都知道这意味着什么。
这段代码,最初是6个月前,为了给董事会做演示,由两名工程师在一个周末赶出来的。演示效果很好,董事会当场拍板:这就是我们要做的AI产品,三个月后上线。
于是,这个"用来演示的原型",在没有经过任何重构的情况下,直接成为了核心生产系统。
6个月后,这个系统每天处理50,000+次AI对话,支撑着公司4个核心业务线。
它的现状:
- 没有单元测试(0%覆盖率)
- 提示词硬编码在Java字符串里
ChatService.java里包含:提示词管理、会话管理、模型路由、费用统计、缓存逻辑、审计日志、限流、降级……所有逻辑- 数据库操作直接在Service里写JDBC
- 改任何一行代码,都可能触发不知道在哪里的Bug
赵磊看着这个文件,说了一句话:
"所有人都知道这不对,但没有人敢动它。这才是最大的问题。"
接下来的4个月,他们团队进行了一次系统性的AI代码重构。这篇文章,记录了这次重构的每一个决策和技术细节。
二、AI应用代码的10种常见坏味道
在正式开始重构之前,赵磊团队对代码做了完整的"坏味道"扫描。以下是他们找到的10种AI专属代码坏味道(附真实代码示例):
坏味道1:提示词字符串硬编码
// 坏代码 - 提示词散落在业务逻辑中
public String analyzeRisk(String content) {
String response = chatClient.call(
"你是一个金融风险分析专家,具有15年经验。" +
"请分析以下内容的风险等级(低风险/中风险/高风险):\n" +
content +
"\n请返回JSON格式:{\"level\": \"...\", \"reason\": \"...\"}"
);
return response;
}问题: 提示词版本无法追踪,改提示词就是改代码,测试困难。
坏味道2:神对象ChatService(God Object)
// 坏代码 - 一个Service包打天下
@Service
public class ChatService {
// 会话管理(本该是SessionService)
private Map<String, List<Message>> sessionHistory = new HashMap<>();
// 模型配置(本该是ModelConfigService)
@Value("${openai.model:gpt-4}")
private String model;
// 费用统计(本该是CostTrackingService)
private AtomicLong totalTokensUsed = new AtomicLong(0);
// 限流逻辑(本该是RateLimiterService)
private RateLimiter rateLimiter = RateLimiter.create(10.0);
// 缓存(本该用Spring Cache注解)
private Map<String, String> responseCache = new HashMap<>();
// 还有2000行各种混杂的逻辑...
}坏味道3:重复的RAG代码
// 坏代码 - 同样的RAG逻辑在4个地方复制
public String searchProduct(String query) {
// 同样的Embedding+搜索逻辑,第3次出现
float[] embedding = embeddingModel.embed(query);
List<Document> docs = vectorStore.similaritySearch(embedding, 5);
String context = docs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n"));
return chatClient.call("基于以下信息回答:" + context + "\n问题:" + query);
}
public String searchPolicy(String query) {
// 完全一样的代码,第4次出现
float[] embedding = embeddingModel.embed(query); // 复制粘贴
// ...
}坏味道4:if-else路由炸弹
// 坏代码 - AI模型/提示词选择全靠if-else
public String generateContent(String type, String input) {
if (type.equals("news")) {
return chatClient.call("写一篇新闻:" + input);
} else if (type.equals("blog")) {
return chatClient.call("写一篇博客:" + input);
} else if (type.equals("report")) {
return chatClient.call("写一份报告:" + input);
} else if (type.equals("email")) {
return chatClient.call("写一封邮件:" + input);
} else if (type.equals("summary")) {
return chatClient.call("写一个摘要:" + input);
}
// 每增加一种类型,就要改这里
throw new IllegalArgumentException("未知类型: " + type);
}坏味道5:吞异常的AI调用
// 坏代码 - 异常被吞掉,调用方完全不知道失败了
public String chat(String message) {
try {
return chatClient.call(message);
} catch (Exception e) {
e.printStackTrace(); // 就这
return ""; // 返回空字符串,调用方以为成功了
}
}坏味道6:阻塞的同步调用链
// 坏代码 - 多个AI调用顺序执行,浪费时间
public ReportResult generateReport(String data) {
// 这三个AI调用完全独立,却顺序执行
String summary = chatClient.call("总结:" + data); // 2s
String risk = chatClient.call("风险分析:" + data); // 2s
String suggestion = chatClient.call("建议:" + data); // 2s
// 合计等待 6s,实际可以并行只需 2s
return new ReportResult(summary, risk, suggestion);
}坏味道7:无限制的对话历史
// 坏代码 - 对话历史无限增长,终将耗尽token限制
public void addToHistory(String sessionId, Message message) {
sessionHistory.computeIfAbsent(sessionId, k -> new ArrayList<>())
.add(message);
// 没有任何长度限制,对话越来越长
// 几百轮后:超出token限制 → 异常 → 所有调用失败
}坏味道8:调试日志泄露敏感信息
// 坏代码 - 日志中包含用户数据
public String processLoan(String userId, String financialData) {
log.debug("处理贷款申请: userId={}, data={}", userId, financialData);
// financialData可能包含:收入证明、征信报告...
String result = chatClient.call("评估贷款:" + financialData);
log.debug("AI结果: {}", result); // 结果可能包含个人财务分析
return result;
}坏味道9:魔法数字/字符串
// 坏代码 - 数字和字符串完全没有语义
if (tokenCount > 4096) { // 4096是什么?为什么是这个数?
truncateHistory(session, 10); // 10又是什么?
}
if (score > 0.75) { // 0.75从哪来的?
return "MATCH";
}坏味道10:测试代码混入生产
// 坏代码 - 测试数据和Mock混入生产代码
public String getModelResponse(String input) {
if ("test".equals(System.getenv("ENV"))) {
return "这是一个测试响应"; // 测试用Mock
}
// ... 真实调用
}三、提示词硬编码重构:抽取到配置中心
3.1 重构目标
将提示词从Java代码中完全分离,实现:
- 提示词版本管理
- 运行时热更新(不重启服务修改提示词)
- A/B测试不同版本提示词
- 完整的变更审计
3.2 提示词管理方案设计
3.3 提示词数据库设计
CREATE TABLE ai_prompt_templates (
id BIGSERIAL PRIMARY KEY,
template_key VARCHAR(200) NOT NULL, -- 唯一标识符,如: risk.analysis.v2
name VARCHAR(200) NOT NULL,
description TEXT,
template_text TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT false,
model_hint VARCHAR(100), -- 建议使用的模型
max_tokens INTEGER,
temperature DECIMAL(3,2),
variables JSONB, -- 模板变量说明
created_by VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (template_key, version)
);
-- 同一key只有一个激活版本
CREATE UNIQUE INDEX idx_active_template
ON ai_prompt_templates (template_key)
WHERE is_active = true;
-- 示例数据
INSERT INTO ai_prompt_templates (template_key, name, template_text, is_active) VALUES
('risk.analysis', '风险分析提示词',
'你是一个拥有15年经验的金融风险分析专家。\n\n请对以下内容进行专业的风险评估:\n{content}\n\n要求返回JSON格式:\n{"level": "低风险|中风险|高风险", "score": 0-100, "reasons": ["原因1", "原因2"], "suggestions": ["建议1"]}',
true);3.4 重构后的提示词管理Service
// 重构前(坏代码)
public String analyzeRisk(String content) {
return chatClient.call(
"你是一个金融风险分析专家,具有15年经验。请分析以下内容的风险等级:\n" + content
);
}
// ═══════════════════════════════════════
// 重构后(好代码)
// ═══════════════════════════════════════
@Service
@Slf4j
public class PromptTemplateService {
private final PromptTemplateRepository templateRepository;
// 本地缓存(避免每次都查数据库)
private final LoadingCache<String, PromptTemplate> templateCache;
public PromptTemplateService(PromptTemplateRepository templateRepository) {
this.templateRepository = templateRepository;
// Guava LoadingCache:1000个条目,写入后30分钟过期
this.templateCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()
.build(new CacheLoader<>() {
@Override
public PromptTemplate load(String key) {
return templateRepository.findActiveByKey(key)
.orElseThrow(() -> new PromptNotFoundException(key));
}
});
}
/**
* 获取提示词模板
*/
public PromptTemplate getTemplate(String key) {
try {
return templateCache.get(key);
} catch (ExecutionException e) {
throw new PromptNotFoundException("提示词不存在: " + key, e);
}
}
/**
* 渲染提示词(替换变量)
*/
public String render(String key, Map<String, Object> variables) {
PromptTemplate template = getTemplate(key);
String rendered = template.getTemplateText();
for (Map.Entry<String, Object> entry : variables.entrySet()) {
rendered = rendered.replace("{" + entry.getKey() + "}",
String.valueOf(entry.getValue()));
}
// 检查未替换的变量
if (rendered.contains("{") && rendered.contains("}")) {
log.warn("提示词[{}]存在未替换的变量: {}", key, rendered);
}
return rendered;
}
/**
* 热更新:接收配置变更事件,清除缓存
*/
@EventListener(PromptTemplateUpdatedEvent.class)
public void onTemplateUpdated(PromptTemplateUpdatedEvent event) {
templateCache.invalidate(event.getTemplateKey());
log.info("提示词缓存已失效: {}", event.getTemplateKey());
}
}
// 重构后的业务代码(干净!)
@Service
public class RiskAnalysisService {
private final ChatClient chatClient;
private final PromptTemplateService promptTemplateService;
private final BeanOutputConverter<RiskAnalysisResult> outputConverter;
public RiskAnalysisResult analyzeRisk(String content) {
// 从配置中心加载提示词
String prompt = promptTemplateService.render(
"risk.analysis",
Map.of("content", content)
);
String response = chatClient.call(prompt);
return outputConverter.convert(response);
}
}四、神对象(God Object)拆分:ChatService过大时的分解方法
4.1 职责分析
4.2 拆分后的服务架构
ChatService(只做编排,<200行)
├── ConversationService // 会话生命周期管理
├── ModelRoutingService // 模型选择和路由
├── PromptTemplateService // 提示词管理(已实现)
├── CostTrackingService // 费用和Token统计
├── ConversationHistoryService // 对话历史管理
└── AiFallbackService // 降级和容错4.3 编排层重构
// 重构前(3278行的怪兽)
// 重构后的ChatService - 只负责编排(<200行)
@Service
@Slf4j
public class ChatService {
private final ConversationService conversationService;
private final ModelRoutingService modelRoutingService;
private final PromptTemplateService promptTemplateService;
private final CostTrackingService costTrackingService;
private final ConversationHistoryService historyService;
private final AiFallbackService fallbackService;
private final AuditLogService auditLogService;
/**
* 核心对话方法 - 现在只有60行,清晰可读
*/
public ChatResult chat(ChatRequest request) {
// 1. 获取或创建会话
Conversation conversation = conversationService.getOrCreate(
request.getSessionId(), request.getUserId()
);
// 2. 路由到合适的模型
ModelConfig modelConfig = modelRoutingService.route(
request.getUserId(),
request.getMessageType(),
conversation.getTokensUsed()
);
// 3. 加载提示词
String systemPrompt = promptTemplateService.render(
request.getPromptKey(),
request.getPromptVariables()
);
// 4. 加载对话历史
List<Message> history = historyService.getHistory(
conversation.getId(),
modelConfig.getMaxHistoryTokens()
);
// 5. 调用AI(带降级)
AiResponse aiResponse = fallbackService.callWithFallback(
() -> callAi(modelConfig, systemPrompt, history, request.getUserMessage()),
() -> fallbackService.getDefaultResponse(request.getMessageType())
);
// 6. 更新历史和统计
historyService.addExchange(conversation.getId(),
request.getUserMessage(), aiResponse.getContent());
costTrackingService.recordUsage(request.getUserId(),
modelConfig.getModelId(), aiResponse.getTokensUsed());
// 7. 审计日志
auditLogService.logAiInteraction(request, aiResponse);
return ChatResult.from(aiResponse);
}
}4.4 对话历史管理Service重构
// 从ChatService中提取的ConversationHistoryService
@Service
@Slf4j
public class ConversationHistoryService {
// 关键常量:有了名字,不再是魔法数字
private static final int DEFAULT_MAX_HISTORY_TURNS = 20; // 最大历史轮次
private static final int DEFAULT_MAX_HISTORY_TOKENS = 4000; // 最大历史Token数
private static final int ESTIMATED_TOKENS_PER_CHAR = 2; // 中文估算系数
private final ConversationHistoryRepository historyRepository;
/**
* 获取对话历史(自动截断,避免超出Token限制)
*/
public List<Message> getHistory(Long conversationId, Integer maxTokens) {
int limit = maxTokens != null ? maxTokens : DEFAULT_MAX_HISTORY_TOKENS;
List<ConversationHistoryEntity> rawHistory = historyRepository
.findByConversationIdOrderByCreatedAtDesc(
conversationId,
PageRequest.of(0, DEFAULT_MAX_HISTORY_TURNS * 2) // 最多取40条(20轮)
);
// 从新到旧截取,直到达到Token限制
List<Message> messages = new ArrayList<>();
int tokenCount = 0;
for (ConversationHistoryEntity entity : rawHistory) {
int entityTokens = estimateTokens(entity.getContent());
if (tokenCount + entityTokens > limit) break;
messages.add(0, entity.toMessage()); // 插入到头部(保持时间顺序)
tokenCount += entityTokens;
}
log.debug("会话[{}]加载历史: {}条消息, 约{}tokens",
conversationId, messages.size(), tokenCount);
return messages;
}
/**
* 估算Token数(简单估算,生产建议用tiktoken)
* 中文:1字符 ≈ 2 tokens
* 英文:1字符 ≈ 0.25 tokens
*/
private int estimateTokens(String text) {
if (text == null) return 0;
long chineseChars = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
long otherChars = text.length() - chineseChars;
return (int)(chineseChars * 2 + otherChars * 0.25);
}
/**
* 添加对话记录
*/
@Transactional
public void addExchange(Long conversationId,
String userMessage,
String aiResponse) {
// 添加用户消息
ConversationHistoryEntity userEntity = ConversationHistoryEntity.builder()
.conversationId(conversationId)
.role("user")
.content(userMessage)
.tokenCount(estimateTokens(userMessage))
.createdAt(Instant.now())
.build();
// 添加AI回复
ConversationHistoryEntity aiEntity = ConversationHistoryEntity.builder()
.conversationId(conversationId)
.role("assistant")
.content(aiResponse)
.tokenCount(estimateTokens(aiResponse))
.createdAt(Instant.now().plusMillis(1)) // +1ms确保顺序
.build();
historyRepository.saveAll(List.of(userEntity, aiEntity));
// 异步清理过旧的历史(保留最近100轮)
cleanupOldHistoryAsync(conversationId);
}
@Async
public void cleanupOldHistoryAsync(Long conversationId) {
long totalCount = historyRepository.countByConversationId(conversationId);
int maxRecords = DEFAULT_MAX_HISTORY_TURNS * 2 * 3; // 保留3倍的正常量
if (totalCount > maxRecords) {
historyRepository.deleteOldRecords(
conversationId,
totalCount - maxRecords
);
log.debug("会话[{}]历史清理: 删除{}条旧记录",
conversationId, totalCount - maxRecords);
}
}
}五、重复的RAG逻辑提取:公共组件设计
5.1 提取公共RAG组件
// 重构前:4处重复的RAG代码
// 重构后:统一的RAG Pipeline
@Component
@Slf4j
public class RagPipeline {
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
private final ChatClient chatClient;
private final PromptTemplateService promptTemplateService;
/**
* 通用RAG执行方法
*/
public RagResult execute(RagRequest request) {
long startTime = System.currentTimeMillis();
// 1. Embedding查询
String query = request.getQuery();
float[] queryEmbedding = embeddingModel.embed(query);
// 2. 向量检索
SearchRequest searchRequest = SearchRequest.query(query)
.withTopK(request.getTopK() != null ? request.getTopK() : 5)
.withSimilarityThreshold(
request.getSimilarityThreshold() != null
? request.getSimilarityThreshold() : 0.70
);
if (request.getFilterExpression() != null) {
searchRequest = searchRequest.withFilterExpression(
request.getFilterExpression()
);
}
List<Document> relevantDocs = vectorStore.similaritySearch(searchRequest);
// 3. 构建上下文
String context = buildContext(relevantDocs, request.getContextTemplate());
// 4. 加载提示词模板
String promptKey = request.getPromptKey() != null
? request.getPromptKey() : "rag.default";
String prompt = promptTemplateService.render(promptKey, Map.of(
"context", context,
"query", query
));
// 5. 调用LLM
String response = chatClient.call(prompt);
long duration = System.currentTimeMillis() - startTime;
log.debug("RAG执行完成: query={}, docs={}, duration={}ms",
query, relevantDocs.size(), duration);
return RagResult.builder()
.answer(response)
.sourceDocuments(relevantDocs)
.context(context)
.durationMs(duration)
.build();
}
private String buildContext(List<Document> docs, String template) {
if (docs.isEmpty()) {
return "(没有找到相关参考信息)";
}
return IntStream.range(0, docs.size())
.mapToObj(i -> {
Document doc = docs.get(i);
return String.format("[参考%d] %s\n来源: %s",
i + 1, doc.getContent(),
doc.getMetadata().getOrDefault("source", "未知"));
})
.collect(Collectors.joining("\n\n"));
}
}
// 重构后,各业务Service只需要几行代码
@Service
public class ProductSearchService {
private final RagPipeline ragPipeline;
public String searchProduct(String query) {
RagResult result = ragPipeline.execute(RagRequest.builder()
.query(query)
.promptKey("product.search")
.filterExpression("type == 'PRODUCT'")
.topK(5)
.build());
return result.getAnswer();
}
}@Service
public class PolicyQaService {
private final RagPipeline ragPipeline;
public String queryPolicy(String question) {
RagResult result = ragPipeline.execute(RagRequest.builder()
.query(question)
.promptKey("policy.qa")
.filterExpression("type == 'POLICY'")
.topK(3)
.similarityThreshold(0.80)
.build());
return result.getAnswer();
}
}六、条件逻辑重构:策略模式替换if-else链
6.1 识别需要重构的if-else
// 重构前:6种内容类型的if-else炸弹(还在不断增加)
public ContentGenerationResult generate(String type, ContentRequest request) {
String response;
if (type.equals("news")) {
response = chatClient.call("写一篇新闻报道:" + request.getTopic());
} else if (type.equals("blog")) {
response = chatClient.call("写一篇博客文章,要求SEO友好:" + request.getTopic());
} else if (type.equals("report")) {
response = chatClient.call("写一份专业报告:" + request.getTopic() +
"\n篇幅:" + request.getWordCount() + "字");
} else if (type.equals("email")) {
response = chatClient.call("写一封商务邮件:" + request.getTopic() +
"\n收件人:" + request.getRecipient());
} else if (type.equals("summary")) {
response = chatClient.call("对以下内容写摘要:" + request.getContent());
} else if (type.equals("translation")) {
response = chatClient.call("将以下内容翻译成" + request.getTargetLanguage() +
":" + request.getContent());
} else {
throw new IllegalArgumentException("未知类型: " + type);
}
return new ContentGenerationResult(type, response);
}6.2 策略模式重构
// 策略接口
public interface ContentGenerationStrategy {
String getType();
ContentGenerationResult generate(ContentRequest request,
PromptTemplateService promptService,
ChatClient chatClient);
}
// 新闻策略
@Component
@Slf4j
public class NewsGenerationStrategy implements ContentGenerationStrategy {
@Override
public String getType() { return "news"; }
@Override
public ContentGenerationResult generate(ContentRequest request,
PromptTemplateService promptService,
ChatClient chatClient) {
String prompt = promptService.render("content.news", Map.of(
"topic", request.getTopic(),
"length", request.getWordCount() != null ? request.getWordCount() : 800
));
String response = chatClient.call(prompt);
return ContentGenerationResult.builder()
.type(getType())
.content(response)
.build();
}
}
// 博客策略
@Component
public class BlogGenerationStrategy implements ContentGenerationStrategy {
@Override
public String getType() { return "blog"; }
@Override
public ContentGenerationResult generate(ContentRequest request,
PromptTemplateService promptService,
ChatClient chatClient) {
String prompt = promptService.render("content.blog", Map.of(
"topic", request.getTopic(),
"keywords", request.getKeywords() != null ?
String.join(",", request.getKeywords()) : ""
));
return ContentGenerationResult.builder()
.type(getType())
.content(chatClient.call(prompt))
.build();
}
}
// 策略注册器(替代if-else)
@Service
@Slf4j
public class ContentGenerationService {
// Spring自动注入所有ContentGenerationStrategy实现
private final Map<String, ContentGenerationStrategy> strategyMap;
private final PromptTemplateService promptTemplateService;
private final ChatClient chatClient;
public ContentGenerationService(
List<ContentGenerationStrategy> strategies,
PromptTemplateService promptTemplateService,
ChatClient chatClient) {
// 将策略列表转换为Map,key是type
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(
ContentGenerationStrategy::getType,
Function.identity()
));
this.promptTemplateService = promptTemplateService;
this.chatClient = chatClient;
log.info("已注册内容生成策略: {}", strategyMap.keySet());
}
/**
* 重构后:不再有if-else,新增类型只需加新Strategy Bean
*/
public ContentGenerationResult generate(String type, ContentRequest request) {
ContentGenerationStrategy strategy = strategyMap.get(type);
if (strategy == null) {
throw new UnsupportedContentTypeException(
"不支持的内容类型: " + type +
",已支持: " + strategyMap.keySet()
);
}
return strategy.generate(request, promptTemplateService, chatClient);
}
public Set<String> getSupportedTypes() {
return strategyMap.keySet();
}
}七、异常处理统一化:从散落的try-catch到全局处理
7.1 AI专属异常体系
// 异常层级设计
public class AiException extends RuntimeException {
private final String errorCode;
private final boolean retryable;
// 构造器...
}
// 具体子类
public class AiRateLimitException extends AiException {
private final Instant retryAfter; // 何时可以重试
public AiRateLimitException(Instant retryAfter) {
super("AI_RATE_LIMIT", "API调用频率超限", true);
this.retryAfter = retryAfter;
}
}
public class AiTokenLimitException extends AiException {
private final int tokenCount;
private final int maxTokens;
public AiTokenLimitException(int tokenCount, int maxTokens) {
super("AI_TOKEN_LIMIT",
String.format("Token超限: %d/%d", tokenCount, maxTokens), false);
this.tokenCount = tokenCount;
this.maxTokens = maxTokens;
}
}
public class AiServiceUnavailableException extends AiException {
public AiServiceUnavailableException(String provider) {
super("AI_SERVICE_UNAVAILABLE", "AI服务暂不可用: " + provider, true);
}
}
public class PromptNotFoundException extends AiException {
public PromptNotFoundException(String key) {
super("PROMPT_NOT_FOUND", "提示词不存在: " + key, false);
}
}7.2 全局异常处理器
@RestControllerAdvice
@Slf4j
public class AiExceptionHandler {
private final AlertNotificationService alertService;
/**
* AI限流异常处理
*/
@ExceptionHandler(AiRateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public ErrorResponse handleRateLimit(AiRateLimitException ex,
HttpServletRequest request) {
log.warn("AI API限流: path={}", request.getRequestURI());
return ErrorResponse.builder()
.errorCode(ex.getErrorCode())
.message("当前请求量过大,请稍后重试")
.retryAfter(ex.getRetryAfter())
.requestId(getRequestId(request))
.build();
}
/**
* AI服务不可用
*/
@ExceptionHandler(AiServiceUnavailableException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ErrorResponse handleServiceUnavailable(AiServiceUnavailableException ex,
HttpServletRequest request) {
log.error("AI服务不可用: {}", ex.getMessage());
// 触发告警
alertService.sendAlert("AI服务不可用", ex.getMessage());
return ErrorResponse.builder()
.errorCode(ex.getErrorCode())
.message("AI服务暂时不可用,请稍后重试")
.requestId(getRequestId(request))
.build();
}
/**
* 提示词不存在
*/
@ExceptionHandler(PromptNotFoundException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handlePromptNotFound(PromptNotFoundException ex) {
log.error("提示词未找到: {}", ex.getMessage());
// 这是配置问题,需要告警
alertService.sendAlert("提示词配置缺失", ex.getMessage());
return ErrorResponse.builder()
.errorCode(ex.getErrorCode())
.message("服务配置错误,已通知技术团队")
.build();
}
/**
* 兜底处理
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneral(Exception ex, HttpServletRequest request) {
log.error("未预期异常: {}", ex.getMessage(), ex);
return ErrorResponse.builder()
.errorCode("INTERNAL_ERROR")
.message("服务内部错误,请稍后重试")
.requestId(getRequestId(request))
.build();
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-ID");
}
}八、测试补写策略:为遗留AI代码写测试
8.1 AI代码测试的特殊挑战
- LLM输出不确定(相同输入,不同输出)
- 测试成本高(调用真实LLM API)
- 延迟高(不适合放在CI中)
8.2 分层测试策略
@ExtendWith(MockitoExtension.class)
class ChatServiceTest {
@Mock
private ChatClient chatClient;
@Mock
private PromptTemplateService promptTemplateService;
@Mock
private ConversationHistoryService historyService;
@InjectMocks
private ChatService chatService;
/**
* 测试AI调用失败时的降级逻辑
* 不依赖真实LLM
*/
@Test
void whenAiFailsShouldReturnFallback() {
// Arrange
when(chatClient.call(anyString()))
.thenThrow(new AiServiceUnavailableException("GPT-4"));
when(promptTemplateService.render(any(), any()))
.thenReturn("Mock prompt");
when(historyService.getHistory(any(), any()))
.thenReturn(Collections.emptyList());
// Act
ChatResult result = chatService.chat(ChatRequest.builder()
.userId("user-123")
.sessionId("session-456")
.userMessage("你好")
.promptKey("general.chat")
.build());
// Assert
assertThat(result.getContent()).isNotBlank();
assertThat(result.isFallback()).isTrue();
}
/**
* 测试Token限制保护
*/
@Test
void shouldTruncateHistoryWhenTooLong() {
// 构造超长历史(超过maxTokens限制)
List<Message> longHistory = generateMessages(100); // 100条消息
when(historyService.getHistory(any(), any())).thenReturn(longHistory);
when(chatClient.call(anyString())).thenReturn("AI响应");
when(promptTemplateService.render(any(), any())).thenReturn("提示词");
// 验证即使历史很长,调用也能成功
assertThatNoException().isThrownBy(() ->
chatService.chat(ChatRequest.builder()
.userId("user-123")
.sessionId("session-456")
.userMessage("测试")
.promptKey("general.chat")
.build())
);
}
}
/**
* 提示词渲染单元测试(不需要LLM)
*/
class PromptTemplateServiceTest {
private PromptTemplateService service;
@Test
void shouldReplaceVariables() {
// Arrange
PromptTemplate template = new PromptTemplate();
template.setTemplateText("你好,{name}!你的账户余额是{balance}元。");
when(templateRepository.findActiveByKey("test.greeting"))
.thenReturn(Optional.of(template));
// Act
String rendered = service.render("test.greeting", Map.of(
"name", "张三",
"balance", "1000.00"
));
// Assert
assertThat(rendered).isEqualTo("你好,张三!你的账户余额是1000.00元。");
}
@Test
void shouldWarnOnUnreplacedVariables() {
// 测试变量未全部替换时的行为
// ...
}
}九、渐进式重构:不停机重构的安全策略
9.1 Strangler Fig模式
9.2 Feature Flag实现
@Service
@Slf4j
public class ChatServiceRouter {
private final OldChatService oldChatService;
private final NewChatService newChatService;
private final FeatureFlagService featureFlagService;
private final MetricsService metricsService;
/**
* 渐进式迁移路由
* 通过Feature Flag控制新旧代码的流量比例
*/
public ChatResult chat(ChatRequest request) {
boolean useNewService = featureFlagService.isEnabled(
"new-chat-service",
request.getUserId()
);
long startTime = System.currentTimeMillis();
String serviceVersion = useNewService ? "new" : "old";
try {
ChatResult result = useNewService
? newChatService.chat(request)
: oldChatService.chat(request);
// 记录指标(用于对比验证)
metricsService.recordChatResult(serviceVersion,
System.currentTimeMillis() - startTime, true);
return result;
} catch (Exception e) {
log.error("[{}服务]调用失败: {}", serviceVersion, e.getMessage());
metricsService.recordChatResult(serviceVersion,
System.currentTimeMillis() - startTime, false);
// 新服务失败时,自动降级到旧服务
if (useNewService) {
log.warn("新服务失败,降级到旧服务");
return oldChatService.chat(request);
}
throw e;
}
}
}
// Feature Flag配置(数据库驱动)
@Service
public class FeatureFlagService {
private final RedisTemplate<String, Object> redisTemplate;
/**
* 按用户ID的百分比灰度
* 可以通过管理界面实时调整百分比,无需重启
*/
public boolean isEnabled(String flagName, String userId) {
FeatureFlag flag = getFlag(flagName);
if (!flag.isEnabled()) return false;
// 按用户ID哈希,确保同一用户始终在同一组
int hash = Math.abs(userId.hashCode() % 100);
return hash < flag.getRolloutPercentage();
}
}9.3 迁移时间表
Week 1: 新服务上线,0%流量(仅内部测试)
Week 2: 5%流量(技术团队内部用户)
Week 3: 20%流量(监控指标正常)
Week 4: 50%流量(并行运行,对比数据)
Week 5: 80%流量(确认无问题)
Week 6: 100%流量,旧服务作为降级保留
Week 8: 下线旧服务,完成迁移十、重构效果评估:代码质量指标前后对比
10.1 静态代码质量对比
| 指标 | 重构前 | 重构后 | 改善幅度 |
|---|---|---|---|
| 最大类行数 | 3278行 | 198行 | -94% |
| 单元测试覆盖率 | 0% | 76% | +76% |
| SonarQube代码坏味道 | 247个 | 18个 | -93% |
| 圈复杂度(最大) | 89 | 11 | -88% |
| 重复代码率 | 34% | 4% | -88% |
| 技术债(SonarQube估算) | 184天 | 12天 | -93% |
10.2 运行时指标对比
| 指标 | 重构前 | 重构后 | 原因 |
|---|---|---|---|
| 提示词变更上线时间 | 4小时(发版) | 5分钟(热更新) | 配置中心 |
| AI服务降级时间 | 30分钟(人工) | 自动(秒级) | 降级策略 |
| Bug修复平均时间 | 8小时 | 2小时 | 代码可读性提升 |
| 新功能开发时间 | 5天 | 2天 | 职责清晰 |
| 线上事故次数(月) | 4.2次 | 1.1次 | 异常处理规范化 |
10.3 使用SonarQube持续监控代码质量
# sonar-project.properties
sonar.projectKey=ai-service
sonar.projectName=AI服务
# 质量门禁(新代码必须满足)
sonar.qualitygate.wait=true
# 以下指标必须达标,否则CI失败
# - 新代码覆盖率 >= 80%
# - 新代码重复率 < 5%
# - 新代码安全热点 = 0
# - 新代码严重Bug = 0FAQ
Q1:旧代码没有测试,重构从哪里开始?
黄金法则:先写测试,再重构。
步骤:
- 用集成测试覆盖关键业务路径(API级别的测试)
- 只要API行为不变,重构就是安全的
- 边重构边补写单元测试(针对抽取出来的新Service)
Q2:重构期间业务还在快速迭代,怎么协调?
这是最难的部分。赵磊团队的做法:
- 成立专项重构小组(2人),不参与新功能开发
- 业务功能开发在旧代码上进行(保持旧系统稳定)
- 重构完成某个模块后,新功能才在新代码上开发
Q3:如何说服老板投入时间做重构?
用数据说话:
- 计算每次线上事故的成本(人力+业务损失)
- 统计因代码难读导致的功能延期时间
- 估算技术债的累积速度(没人动的代码会越来越难动)
赵磊的团队算出:每月线上事故成本约15万元,重构后降到4万元。4个月重构成本(2人工资)约20万,5个月收回成本。
Q4:神对象拆分后,测试跑得更慢了(需要Mock更多依赖)?
这是一个真实的权衡。解决方案:
- 使用
@TestConfiguration提供轻量化的测试配置 - 对高频测试的Service,提供测试专用的Fake实现(比Mock更稳定)
总结
赵磊团队用4个月完成了从"没人敢动"到"敢动且可以快速动"的转变。
重构的核心原则:
- 提示词外化:代码里不应该有任何提示词字符串
- 单一职责:每个Service只做一件事,200行以内
- 消除重复:RAG/Embedding等公共逻辑抽取为组件
- 策略替代if-else:开闭原则,新增不修改
- 异常体系化:AI专属异常层级 + 全局处理
- 渐进式迁移:Strangler Fig + Feature Flag,安全灰度
最重要的一点:重构不是一次性工程,而是持续的习惯。
当你在每次开发新功能前,能够习惯性地问一句"这段代码六个月后还能维护吗"——那才是真正的工程师成熟度。
