AI 应用的线上问题很少是"服务挂了"这种显式故障。更多是三件事同时发生:用户说"它今天答得很怪"、成本账单比上周高了一截、某个模型供应商的响应慢了一截。这三件事在传统监控面板上都不报警——延迟没超阈值,错误率是零,CPU 很闲。
观测体系要补的正是这块:把一次生成链路的输入、版本、耗时分段、输出与成本都留下来,并且能重跑。
1. 与传统服务观测的差异
| 对比项 | 传统服务 | AI 应用 |
|---|---|---|
| 结果 | 确定,可断言 | 不确定,同一输入多次不同 |
| 成本 | 与请求数近似线性 | 与 token 数相关,方差极大 |
| 依赖 | 内部组件为主 | 外部模型 API、向量库、工具服务 |
| 失败形态 | 抛异常、返回错误码 | 200 但答非所问、JSON 解析崩、静默降级 |
| 主指标 | QPS、P99、错误率 | 上面这些 + token/请求、拒答率、解析失败率 |
最关键的一条:**AI 应用的"错误"经常以成功响应的形式返回。**因此日志里必须有结果状态字段,而不是靠 HTTP 状态码判断。
2. 必须记的六类字段
| 字段 | 示例 | 不记会怎样 |
|---|---|---|
| 请求 id | req-9f2c…(贯穿日志、追踪、回放) |
无法把一次对话的全部记录串起来 |
| 模型与版本 | 供应商 + 模型名 + 快照版本 | 换了模型导致退化时无法归因 |
| 提示版本 | prompt-answer-v12 |
分不清是模型变坏还是提示改坏 |
| token 用量 | 输入 / 输出 / 缓存命中,分开记 | 成本骤增查不到来源 |
| 耗时分段 | 排队、上游、首 token、生成 | 只能说"慢了",说不清慢在哪一段 |
| 结果状态 | OK / TRUNCATED / PARSE_FAIL / REFUSED / DEGRADED |
错误率恒为 0,问题全隐形 |
public record AiCallLog(
String requestId, String tenantId, String conversationId,
String provider, String model, String modelSnapshot,
String promptVersion, String toolSetVersion,
int inputTokens, int outputTokens, int cachedTokens,
long queueMs, long upstreamMs, long firstTokenMs, long generateMs,
CallStatus status, String degradeReason) { }
这条 record 是整篇的骨架:请求 id 是主键,版本三元组(模型、提示、工具)用于归因,耗时四段用于定位,状态用于统计质量。
3. 耗时分段:慢在哪一段
| 阶段 | 含义 | 异常时先怀疑 |
|---|---|---|
| 排队 | 等本地线程/连接池 | 池太小、并发上限设得死 |
| 上游 | 建立连接 + 首包 | 网络、供应商限流 |
| 首 token | 到第一个 token 的时间 | 输入过长、提示过大、冷启动 |
| 生成 | 首 token 到结束 | 输出过长、流式被反压 |
只记总耗时是没用的:总耗时从 2s 涨到 6s,可能只是输出长度从 300 token 涨到 900 token,而首 token 时间完全没变——那是业务变化,不是性能退化。
public AiCallLog call(ChatRequest req) {
long t0 = System.nanoTime();
PooledConnection conn = pool.borrow(); // 排队
long t1 = System.nanoTime();
StreamResponse resp = upstream.open(conn, req); // 上游
long t2 = System.nanoTime();
String first = resp.firstToken(); // 首 token
long t3 = System.nanoTime();
String rest = resp.drain(); // 生成
long t4 = System.nanoTime();
return logBuilder(req)
.queueMs(ms(t0, t1)).upstreamMs(ms(t1, t2))
.firstTokenMs(ms(t2, t3)).generateMs(ms(t3, t4))
.outputTokens(count(rest)).build();
}
**耗时字段要打点在同一层。**常见错误是请求入口记一次、SDK 回调里再记一次,两边口径不同,加起来对不上总耗时。
4. 追踪:把模型调用做成 span
模型调用天然是一次外部依赖调用,应该是一个 span,而不是日志里的一行文本。用 Micrometer Observation 或 OpenTelemetry 时,span 上必须带三类属性:模型与版本、提示版本、token 用量。
@Component
public class ModelObservation {
private final ObservationRegistry registry;
public String observe(String model, String promptVersion, Supplier<String> call) {
return Observation.createNotStarted("ai.chat", registry)
.lowCardinalityKeyValue("ai.provider", providerOf(model))
.lowCardinalityKeyValue("ai.model", model)
.highCardinalityKeyValue("ai.prompt.version", promptVersion)
.observe(call);
}
}
要点:ai.model 是低基数维度(可做聚合),ai.prompt.version 基数会随发布增长,放高基数;请求 id 不要当 span 属性,它属于 trace id 的层级。这样"某次发布后首 token 变慢"才能在追踪系统里按提示版本切片出来。
5. 回放:同输入重跑,看差异
回放是排障效率最高的一环:拿到请求 id,取回当次的完整输入(原始问题、检索到的文档片段、生效提示版本),用当前配置重跑一遍,对比输出。
public ReplayDiff replay(String requestId) {
RecordedCall original = callStore.find(requestId);
ReplayResult now = chatService.call(original.toRequest()); // 当前版本
return ReplayDiff.between(original, now);
// 输出: 文本差异、token 差异、状态变化、耗时变化
}
三个纪律:
- 重跑默认不写业务副作用(不扣费、不落用户会话、不触发工具的真实写操作);
- 重跑结果要和原记录并排存,不能覆盖原记录;
- 回放要能指定"用哪个版本重跑",否则你只能证明"现在和历史不一样",证明不了"是哪个变更导致的"。
6. 采样策略:全量记不起,那就按风险记
全量记录提示与输出的存储成本会迅速超过模型调用本身。可行的是分层采样:
| 通道 | 采样率 | 保留期 | 用途 |
|---|---|---|---|
| 元数据(id、版本、token、耗时、状态) | 全量 | 较长 | 成本、延迟、质量趋势 |
| 完整输入输出 | 低比例随机 | 短 | 抽样分析 |
| 错误与解析失败 | 全量 | 较长 | 排障主依据 |
| 慢请求(超 P99 或指定阈值) | 全量 | 较长 | 性能定位 |
| 拒答与降级 | 全量 | 较长 | 策略调整依据 |
原则:**元数据全量、内容按风险采样。**随机采样率再低,只要"错误必留",排障就不会缺料。
7. 质量监控:三个比率
| 指标 | 定义 | 异常含义 |
|---|---|---|
| 拒答率 | 状态为 REFUSED 的占比 |
升高→提示过严或检索缺失;降低→约束失效 |
| 解析失败率 | 期望 JSON 却解析失败 | 提示约束失效、模型换了、温度太高 |
| 用户追问率 | 同一会话内短时间连续追问 | 答案没解决问题,最接近真实满意度 |
前两个是规则可算的,第三个要在会话维度聚合。追问率高但拒答率正常,通常意味着检索召回不够——答了,但没答到点上。
8. 告警:三类就够
- 成本骤增:
token 用量 / 请求数或单位时间总 token 超基线一定幅度; - 延迟劣化:
firstTokenMs的 P95 环比上升,且持续若干分钟; - 错误率:
PARSE_FAIL与DEGRADED占比超阈值。
# 告警规则要点:用比率而非绝对值,用环比而非固定阈值
- alert: AiFirstTokenLatencyRegressed
expr: histogram_quantile(0.95, sum by (le) (rate(ai_first_token_ms_bucket[10m])))
> 1.3 * avg_over_time(ai_first_token_p95_baseline[7d])
for: 15m
- alert: AiOutputTokensPerRequestSpiked
expr: sum(rate(ai_output_tokens_total[30m])) / sum(rate(ai_calls_total[30m]))
> 1.5 * avg_over_time(ai_tokens_per_call_baseline[7d])
for: 20m
阈值留给你自己的基线,别抄别人的数字。告警要挂在"比率与版本"上:如果只是总 token 涨了但每请求 token 没变,那是流量涨了,属于容量问题不是质量问题。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 用户投诉答错,日志里查不到那次请求 | 没有贯穿的请求 id,或只记了摘要 | 请求 id 从网关生成并注入 MDC,全链路透传 |
| 成本涨了但调用量没涨 | 只记总量,没记输入/输出/缓存分开 | 三类 token 分开计数,按模型与提示版本打标签 |
| 延迟劣化查不出原因 | 只记总耗时 | 补四段打点:排队、上游、首 token、生成 |
| 换模型后质量下降无法归因 | 日志没记模型快照版本 | 模型名 + 快照 + 提示版本三元组一起记 |
| 日志库被撑爆 | 每条都打全量提示与输出 | 元数据全量、内容按风险采样,长文本进对象存储 |
| 追踪系统里模型调用是一个大黑块 | 没有独立 span | 模型调用单独开 span,带版本属性 |
| 回放结果和线上不一致 | 回放没固定随机性、没固定同一版本 | 回放时 temperature=0 并显式指定版本 |
10. 边界:什么不该塞进观测
| 场景 | 更适合的手段 |
|---|---|
| 判断答案对不对 | 离线评测集与门禁,不是日志 |
| 归因某次发布的影响 | 评测基线对比 + 版本标签 |
| 用户是否满意 | 会话行为指标与访谈 |
| 供应链成本谈判 | 账单系统,日志只做核对 |
| 安全审计与合规留痕 | 专门的审计库,与观测库分离 |
一句话:日志回答"发生了什么",追踪回答"慢在哪",回放回答"换个版本会怎样",评测回答"到底对不对"。
11. 自测
- AI 应用的错误率为什么经常显示为 0,实际却在出错?
- 六类必记字段里,哪三类构成"版本三元组",用来归因退化?
- 总耗时上涨但首 token 时间不变,通常说明什么?
- 为什么请求 id 不适合作为 span 的高基数属性?
- 采样策略里"错误必留"这一条解决了什么问题?
- 回放时不做业务副作用隔离,会带来什么后果?