指标体系与线上排障
要点
- Trace 擅长回答:「这一次请求为什么出问题
- 延迟是用户最直接能感知到的质量指标
- 指标不需要保存完整现场,它只需要在请求过程中抽取几个关键数字
- 假设用户反馈:“我告诉过 AI 我喜欢吃辣,但它推荐了一家日料店
- 症状:用户确定自己说过,但 AI 完全不记得
内容
1. Trace 解决单次排查,Metrics 解决整体诊断
Trace 擅长回答:「这一次请求为什么出问题?」
但如果你想回答下面这些问题,光有 Trace 不够:
- 这周整体延迟是不是变慢了
- 新模型上线之后,记忆命中率有没有下降
- 情绪系统是不是突然变得过于敏感
- Token 成本为什么这个月异常上升
这些问题需要 Metrics,也就是指标体系
最简单的理解方式是:
- Trace 看单次现场
- Metrics 看系统趋势
这两者必须一起存在,才能真正支撑 AI 系统的运营。
2. 四类核心指标
2.1 延迟指标
延迟是用户最直接能感知到的质量指标。这里有两个术语需要先说明:
- TTFB(Time To First Byte,首字节时间):从请求发出到收到第一个字节响应的耗时。在流式回复场景中,它代表用户按下发送后等待多久才看到 AI 开始"打字"
- P50 / P95 / P99(百分位数):P50 表示 50% 的请求比这个值快(即中位数),P95 表示 95% 的请求比这个值快。P95 和 P99 越高,说明"尾部慢请求"越严重——大部分人还行,但有少数人体验很差
| 指标 | 计算方式 | 关注点 |
|---|---|---|
| TTFB P50 / P95 / P99 | 从请求到首个 token 发出 | P95 是否稳定 |
| 各节点耗时分布 | 从 Trace 摘要聚合 | 谁是瓶颈节点 |
| LLM 首 token 延迟 | 节点开始到首次 yield | 区分管线慢还是模型慢 |
如果用户说“最近回复变慢了”,第一眼就应该先看这组指标。
2.2 检索质量指标
对 AI 伴侣来说,记忆系统是否有效,决定了“它到底像不像真的记得你”。
| 指标 | 计算方式 | 关注点 |
|---|---|---|
| 记忆命中率 | 检索到至少 1 条相关记忆的请求占比 | 低了说明召回有问题 |
| 平均召回条数 | 每次检索到的有效记忆数量 | 太少信息不足,太多噪声过大 |
| Top-1 相似度分布 | 每次检索第一条的得分分布 | 判断 embedding 质量 |
2.3 情绪系统指标
情绪状态机的问题,很少会以报错形式暴露,更多是“感觉不对”。这类问题尤其依赖指标。
| 指标 | 计算方式 | 关注点 |
|---|---|---|
| 情绪分类分布 | 各情绪状态占比 | 是否长期偏向某一类 |
| 状态切换频率 | 每会话切换次数 | 是否过度敏感 |
| 亲密度增长曲线 | 按天聚合平均亲密度 | 是否异常跳变 |
2.4 成本指标
AI 系统天然带着成本属性,成本不是财务问题,而是架构问题。
| 指标 | 计算方式 | 关注点 |
|---|---|---|
| Token 消耗量 | 输入加输出 token 总数 | 决定 API 账单 |
| 单次对话成本 | token 数乘模型单价 | 是否突破预算 |
| 安全拦截率 | 被安全过滤拦截的请求比例 | 过高可能误杀,过低可能漏检 |
3. 指标采集实现:每次请求只提取关键数值
指标不需要保存完整现场,它只需要在请求过程中抽取几个关键数字。
采集时,指标分为两种基本类型:
- 计数器(Counter):统计"发生了多少次",比如请求总数、Token 消耗量。每次只做累加
- 直方图(Histogram):记录"每次的值是多少",比如延迟、相似度分数。保留每个数值,事后可以计算平均值和百分位数
// metrics.ts
// 以分钟为粒度生成时间 key,如 "2026-03-13T10:05"
function getMinuteKey(): string {
return new Date().toISOString().slice(0, 16)
}
class MetricsCollector {
private counters: Map<string, number> = new Map()
private histograms: Map<string, number[]> = new Map()
// 累加计数器:比如每来一个请求 increment('requests')
increment(name: string, value: number = 1) {
this.counters.set(name, (this.counters.get(name) ?? 0) + value)
}
// 记录一个观测值:比如每次请求 observe('latency.total', 1350)
observe(name: string, value: number) {
const arr = this.histograms.get(name) ?? []
arr.push(value)
this.histograms.set(name, arr)
}
// 将本次请求收集的指标写入 KV
async flush(env: { KV: KVNamespace }) {
const minute = getMinuteKey()
for (const [name, value] of this.counters) {
const key = `metrics:counter:${name}:${minute}`
const existing = Number(await env.KV.get(key) ?? '0')
await env.KV.put(key, String(existing + value), { expirationTtl: 86400 })
}
for (const [name, values] of this.histograms) {
const key = `metrics:histogram:${name}:${minute}`
const existing = JSON.parse(await env.KV.get(key) ?? '[]') as number[]
await env.KV.put(key, JSON.stringify([...existing, ...values]), {
expirationTtl: 86400
})
}
}
}在管线里接入时,一般就是边执行边上报。下面的代码展示了在各节点执行完毕后如何收集指标(这些变量来自 trace.span() 的返回值或管线中间结果):
// pipeline-metrics.ts
const metrics = new MetricsCollector()
// 延迟指标:记录每个节点的耗时(单位 ms)
metrics.observe('latency.safety_check', safetyCheckDuration)
metrics.observe('latency.memory_retrieval', memoryRetrievalDuration)
metrics.observe('latency.llm_generation', llmDuration)
metrics.observe('latency.total', totalDuration)
// 检索质量指标
metrics.increment('retrieval.total_requests')
if (memories.length > 0) metrics.increment('retrieval.hit_requests')
metrics.observe('retrieval.count', memories.length)
metrics.observe('retrieval.top_score', memories[0]?.score ?? 0)
// 成本指标
metrics.increment('tokens.input', inputTokenCount)
metrics.increment('tokens.output', outputTokenCount)
// 情绪分布指标:每种情绪各自计数
metrics.increment(`emotion.${currentEmotion}`)
// 在 Hono 框架中,c 是请求上下文对象
// c.executionCtx 对应 Workers 原生的 ExecutionContext(上一篇用的 ctx)
// c.env 对应 Workers 原生的 env(绑定的 D1、KV 等资源)
c.executionCtx.waitUntil(metrics.flush(c.env))NOTE
关于 c.executionCtx:上一篇用的是 Workers 原生写法 ctx.waitUntil(),这里用的是 Hono 框架的写法。两者功能完全相同,只是访问方式不同——Hono 把 Workers 的三个参数(request、env、ctx)统一包装进了 c 对象里。
这里依然延续上一篇的原则:指标采集必须足够轻,不影响主路径。
与上一篇 storeSummary 一样,flush() 的 KV 先读后写在高并发下存在竞态问题(两个请求同时读到旧值,各自累加后写回会丢失其中一个)。低流量场景可以接受;高并发场景需要改用 Durable Objects 做原子计数。
4. 线上排查:从“回复不对”到定位根因
假设用户反馈:“我告诉过 AI 我喜欢吃辣,但它推荐了一家日料店。”
真正高效的排查流程应该是固定的,而不是每次靠经验临场发挥。
4.1 第一步:先找到这条消息对应的 Trace
// query.ts
const traces = await env.DB.prepare(
`SELECT trace_id, total_duration, has_error, has_degraded, created_at
FROM traces
WHERE user_id = ? AND created_at BETWEEN ? AND ?
ORDER BY created_at DESC`
).bind(userId, startTime, endTime).all()这一步不是“查日志”,而是把问题精确落到某一次请求上。
4.2 第二步:按固定顺序检查关键节点
// inspect.ts
const spans = await env.DB.prepare(
`SELECT name, duration, status, input, output, metadata
FROM spans WHERE trace_id = ? ORDER BY start_time`
).bind(traceId).all()一旦进入 Trace,排查顺序就应该稳定下来:
query_understandingmemory_retrievalprompt_assemblyllm_generation
为什么是这个顺序?因为它正好对应“理解问题 → 找上下文 → 组织输入 → 生成输出”的因果链。
4.3 第三步:把现象映射回根因层
| 排查结果 | 根因 | 修复方向 |
|---|---|---|
| 查询理解没识别偏好通道 | 查询理解层 | 调整 Prompt 或规则 |
| 检索结果为空 | 记忆检索层 | 检查写入、Embedding、Top-K |
| 检索到了但 Prompt 没带上 | Prompt 组装层 | 调整优先级和 Token 预算 |
| Prompt 正确但输出仍忽略 | LLM 生成层 | 补 few-shot、改约束或换模型 |
这也是可观测性的真正价值:把“感觉像是哪里不对”变成“明确知道哪一层不对”。
5. 三种最常见的故障模式
5.1 记忆“丢失”
症状:用户确定自己说过,但 AI 完全不记得。
排查路径:
- 先看当初说这件事时的
memory_write - 再看本次请求的
memory_retrieval - 最后确认是否在
prompt_assembly被截断
5.2 情绪“跳变”
症状:AI 前一条还很亲近,后一条突然变冷。
排查路径:
- 对比相邻两条 Trace 的
emotion_read - 看
emotion_update的输入是什么 - 判断是用户输入触发,还是衰减/规则造成
5.3 延迟突增
症状:某段时间内用户普遍反馈“变慢了”。
排查路径:
- 先看
latency.total的 P95 曲线 - 再看每个节点的耗时分布
- 最后判断是模型侧变慢,还是检索侧、网络侧、数据库侧变慢
常见例子也很典型:
latency.llm_generation突增,说明模型提供商可能变慢latency.memory_retrieval持续上涨,说明向量库或检索策略需要优化
6. 总结
这一篇把“系统整体怎么看”和“线上问题怎么查”连了起来。
核心结论有三个:
- 指标不是为了做报表,而是为了尽快发现异常趋势
- 排障不是凭经验乱翻日志,而是基于 Trace 按固定顺序逐层定位
- 延迟、检索质量、情绪健康度、成本,这四组指标基本覆盖了 AI 伴侣运营的核心视角
再往下一步,就不是“发现问题”了,而是“替换节点时如何降低风险”。下一篇就专门讲这个主题:影子模式、A/B 分桶、渐进发布和自动回滚。