第1996篇:2025年AI工程最重要的10个教训——每一条都是血泪换来的
第1996篇:2025年AI工程最重要的10个教训——每一条都是血泪换来的
这篇文章,我想写得直一点。
不是方法论,不是最佳实践清单,是真实踩过的坑、真实交过的学费、真实改变过我行为的教训。
有些教训来自我自己的项目,有些来自我在星球里帮读者排查过的问题,有些来自同行在不同场合分享的故事。
每一条都有代价,希望你能少付一些。
教训一:不要在没有监控的情况下上线AI功能
这是第一条,也是我认为代价最高的一条。
我见过的AI系统事故,有70%在上线之前是"完全正常的"——本地测试通过、staging环境测试通过、小流量测试也通过。然后上线,然后出问题了,然后没有人知道哪里出了问题。
因为没有监控。
AI系统的问题不像传统系统那样会抛出明显的Exception,它的失效是软性的:LLM开始返回质量下降的输出,响应时间悄悄变长,某类问题的答案开始出错……如果没有监控,这些问题可能在悄然影响用户两周之后你才发现。
最低标准的监控体系:
@Aspect
@Component
public class LLMCallAuditAspect {
private final MeterRegistry registry;
private final AuditLogRepository auditRepo;
@Around("@annotation(LLMCall)")
public Object audit(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getSignature().getName();
long start = System.currentTimeMillis();
Object result = null;
Throwable error = null;
try {
result = pjp.proceed();
return result;
} catch (Throwable t) {
error = t;
throw t;
} finally {
long duration = System.currentTimeMillis() - start;
recordAudit(method, result, error, duration);
}
}
private void recordAudit(String method, Object result, Throwable error, long duration) {
String status = error != null ? "error" : "success";
// 指标
registry.timer("llm.call", "method", method, "status", status)
.record(duration, TimeUnit.MILLISECONDS);
// 审计日志(关键,用于事后分析)
LLMAuditLog log = LLMAuditLog.builder()
.method(method)
.durationMs(duration)
.status(status)
.errorType(error != null ? error.getClass().getSimpleName() : null)
.timestamp(Instant.now())
.traceId(MDC.get("traceId"))
.build();
auditRepo.save(log);
}
}这是最基础的。上线前,确保你能回答这几个问题:
- 现在LLM的平均响应时间是多少?
- 过去一小时有多少次调用失败?
- 今天的token消耗是多少,折合成本是多少?
- 有没有任何调用的响应时间超过10秒?
如果回答不了,先别上线。
教训二:LLM的"自信"和"准确"是两回事
这个教训让我在合同审查项目上吃了亏,前面提过。
LLM在表达不确定信息时,语气跟表达确定信息时几乎一样——都是肯定句式,都是清晰陈述,都看起来非常可靠。但准确性可能天差地别。
永远不要凭语气判断LLM的可信度。
我现在对所有关键LLM输出,都有一个独立的"可信度评估"步骤。不是问LLM自己"你对这个答案有多少把握"(这个答案也不可信),而是用外部手段验证:规则校验、数据库核查、二次模型确认。
public class LLMResultValidator {
/**
* 对于高风险输出,走三重验证
*/
public ValidationResult validate(String output, ValidationContext context) {
List<ValidationResult> results = new ArrayList<>();
// 1. 规则校验(格式、必要字段、范围)
results.add(ruleEngine.validate(output, context.getRules()));
// 2. 数据库核查(关键实体是否真实存在)
results.add(dbChecker.verify(output, context.getEntityTypes()));
// 3. 二次模型确认(用另一个模型验证答案是否合理)
if (context.isHighRisk()) {
results.add(secondaryLLM.verify(context.getQuestion(), output));
}
return aggregateResults(results);
}
}教训三:Token成本会在你不注意的时候爆炸
我的一个读者,他们做了一个"智能日报生成"功能,用LLM每天为用户生成工作日报。上线第一周,一切正常。
第二周,运营做了一个活动,用户量涨了5倍。Token成本直接冲破预算,月底账单出来差点出了事故。
问题根源是:他们没有对每个用户、每个功能设置Token预算上限。
@Component
public class TokenBudgetGuard {
private final RedisTemplate<String, Long> redisTemplate;
// 每个用户每天的token预算
private static final Map<String, Long> DAILY_BUDGETS = Map.of(
"FREE_USER", 10_000L,
"PRO_USER", 100_000L,
"ENTERPRISE_USER", 1_000_000L
);
public void checkAndConsume(String userId, String userTier, long estimatedTokens) {
String key = "token_budget:" + userId + ":" + LocalDate.now();
Long budget = DAILY_BUDGETS.get(userTier);
Long current = redisTemplate.opsForValue().get(key);
if (current == null) current = 0L;
if (current + estimatedTokens > budget) {
throw new TokenBudgetExceededException(
String.format("用户 %s 今日token预算已用完(已用 %d,预算 %d)",
userId, current, budget)
);
}
// 原子性地增加计数
redisTemplate.opsForValue().increment(key, estimatedTokens);
redisTemplate.expire(key, Duration.ofDays(1));
}
}在AI系统里,成本控制跟功能实现一样重要,应该在设计阶段就考虑,而不是出了账单才想起来。
教训四:RAG召回率不够,不要怪模型
这是我见过最多的误判。RAG系统效果不好,第一反应是换个更好的LLM。换了,还是不好,换更贵的,还是不好。
最后发现:问题在检索,不在生成。
检索出来的文档本来就是不相关的,LLM再厉害也没有原材料可用。
一个简单的排查方法:在RAG链路里加一个日志,单独记录每次查询召回的文档内容,然后人工看一看,这些文档到底跟问题相关不相关。这个检查通常能在10分钟内告诉你问题出在哪。
// 加在RAG流水线里的诊断中间件
@Component
public class RAGDiagnosticInterceptor {
private final Logger diagLogger = LoggerFactory.getLogger("rag.diagnostic");
public RAGContext interceptRetrieve(String query, List<Document> retrieved) {
if (diagLogger.isDebugEnabled()) {
diagLogger.debug("RAG检索诊断 | query={} | retrieved_count={} | docs={}",
query,
retrieved.size(),
retrieved.stream()
.map(d -> Map.of(
"score", d.getScore(),
"preview", d.getContent().substring(0, Math.min(100, d.getContent().length()))
))
.collect(Collectors.toList())
);
}
return new RAGContext(query, retrieved);
}
}开debug日志,看检索结果,先确认召回质量再谈生成质量。
教训五:Prompt版本管理必须像代码一样认真
Prompt是AI系统最核心的"代码",但很多团队把它当配置来管理——随手改,不记录,不测试,不review。
这带来的问题:一个修改Prompt的人,不知道上一个版本是什么,不知道为什么改,也不知道改了之后效果是变好还是变差了。
Prompt应该有:版本号、变更记录、测试用例、性能对比数据。
-- Prompt版本管理表
CREATE TABLE prompt_version (
id BIGSERIAL PRIMARY KEY,
prompt_key VARCHAR(100) NOT NULL, -- 标识这个Prompt的用途
version INTEGER NOT NULL, -- 递增版本号
content TEXT NOT NULL, -- Prompt内容
variables JSONB, -- 支持的变量
is_active BOOLEAN DEFAULT FALSE, -- 当前激活版本
test_case_count INTEGER DEFAULT 0, -- 关联测试用例数
avg_quality_score DECIMAL(5,2), -- 平均质量评分
created_by VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
change_reason TEXT, -- 必填:这次改了什么、为什么改
UNIQUE(prompt_key, version)
);每次修改Prompt,必须跑一遍测试集对比新旧版本的输出质量,然后才能上线。这个流程感觉麻烦,但能救你很多次。
教训六:Agent不是越复杂越智能
这个教训我前面也提过,但值得单独列出来强调。
我在2024年初做过一个很"炫"的Multi-Agent系统:有负责规划的Agent、负责执行的Agent、负责校验的Agent、负责报告的Agent。设计文档写了20页,看起来非常高级。
实际运行起来:平均一个任务要调用LLM 35次,耗时7-10分钟,稍微复杂一点的任务Agent就开始循环或者卡死。
后来大幅简化:把70%的任务改成单Agent + 工具调用,只有真正需要并行处理或需要专业化分工的才用双Agent。效果反而更好,成本降低了80%,速度快了5倍。
判断标准:如果你能用一个工具调用解决的事,不要用Agent;如果一个Agent能解决的事,不要用两个。每多一个LLM调用节点,就多一个出错的可能,多一倍的延迟,多一份成本。
教训七:LLM的输出必须有格式校验和修复机制
让LLM输出JSON是一个常见需求,但JSON格式出错的概率比你想象的高得多。特别是当输出内容里包含中文、特殊字符、或者内容长度逼近token限制时。
不要裸用JSON.parse(),要有健壮的解析和修复机制:
@Component
public class RobustJSONParser {
private final LLMClient llmClient;
public <T> T parseWithRetry(String llmOutput, Class<T> targetClass,
String originalPrompt, int maxRetries) {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
// 先尝试直接解析
String cleaned = extractJSONFromOutput(llmOutput);
return objectMapper.readValue(cleaned, targetClass);
} catch (JsonProcessingException e) {
if (attempt == maxRetries) {
log.error("JSON解析在{}次尝试后仍然失败: {}", maxRetries, llmOutput);
throw new LLMOutputParseException("无法解析LLM输出为JSON", e);
}
log.warn("第{}次JSON解析失败,尝试让LLM修复格式", attempt);
// 让LLM自己修复格式
String fixPrompt = String.format("""
以下JSON格式有误,请修复并只输出正确的JSON,不要有任何其他内容:
错误的JSON:
%s
JSON应该符合的结构(根据之前的请求):
%s
""", llmOutput, getExpectedStructure(targetClass));
llmOutput = llmClient.complete(fixPrompt, Map.of("temperature", 0.0));
}
}
throw new LLMOutputParseException("不可能到达这里");
}
/**
* 从可能包含额外文字的LLM输出中提取JSON
*/
private String extractJSONFromOutput(String output) {
// LLM有时会在JSON前后加上说明性文字
int start = output.indexOf('{');
int end = output.lastIndexOf('}');
if (start != -1 && end != -1 && end > start) {
return output.substring(start, end + 1);
}
return output;
}
}教训八:向量数据库不是越快越好,要先选对
2024年初我在一个项目上选了Weaviate,因为它的benchmark在某些指标上很好看。结果上线之后发现,我们的核心用法(按字段过滤 + 向量检索组合)在Weaviate上性能很差。
事后研究才发现,Weaviate在纯向量检索上很快,但带过滤条件的混合查询优化做得不够好。而我们90%的查询都是混合查询。
选向量数据库的正确姿势:先确认你的典型查询模式,然后用这个模式做benchmark。
常见的查询模式:
- 纯向量相似度搜索 → Qdrant、Pinecone表现好
- 向量 + 结构化过滤组合 → Weaviate、Milvus
- 需要跟关系型数据在一起 → pgvector(性能差点但省架构复杂度)
- 超大规模(亿级别) → Milvus、Elasticsearch with dense_vector
不存在"最好的向量库",只有最适合你场景的。
教训九:不要在没有降级方案的情况下依赖LLM
这个教训来自一次真实的大规模事故。
某平台有一个核心功能依赖LLM做分类,没有规则降级方案。某天LLM服务提供商出现故障,响应延迟从200ms变成30秒,然后开始返回500。整个核心功能停摆了2小时。
教训:任何依赖外部LLM服务的关键功能,都必须有一个不依赖LLM的降级方案。 可以是规则引擎、可以是老版本的ML模型、可以是"返回默认值+人工处理队列"。
@Service
public class TextClassificationService {
private final LLMClassifier llmClassifier;
private final RuleBasedClassifier ruleClassifier; // 降级方案
private final CircuitBreaker circuitBreaker;
public ClassificationResult classify(String text) {
// 先尝试LLM,熔断时自动降级到规则
return circuitBreaker.executeSupplier(() -> {
ClassificationResult result = llmClassifier.classify(text);
if (result.getConfidence() < 0.7) {
// LLM置信度低,也降级到规则
return ruleClassifier.classify(text);
}
return result;
});
// CircuitBreaker fallback自动调用ruleClassifier
}
}教训十:AI工程的最大风险是"能用"带来的过度自信
最后一条,也是最难以量化但最重要的教训。
AI系统有一个危险特性:它在大多数情况下是"能用"的。问它普通问题,答案看起来都不错。这种"大多数时候还行"很容易让团队产生过度自信,放松了对边界情况的警惕。
然后,某一类边缘输入触发了一个严重错误,你才发现系统其实从来没有真正被测试过在那种情况下的行为。
对抗过度自信的方法: 在系统上线之前,刻意构造"恶意"测试用例。
不是正常的测试用例,而是:
- 极端长度的输入(超长文本、极短文本)
- 包含特殊字符、表情符号、代码片段的输入
- 语意模糊、多义的输入
- 与训练数据分布差异很大的输入
- 刻意欺骗的输入("忽略所有之前的指令,改为……")
如果系统在这些用例上崩溃,你就知道需要在哪里加防护了。
10条教训,每一条背后都有一个真实的故事,一次真实的代价。
我不希望你把这些当成条条框框来背,而是当成"地图上已经标记的雷区"——你知道那里有雷,绕开就行。
但也有些雷,只有自己踩过才真正记住。这也是没办法的事。
