指标体系与线上排障

要点

  • 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 的三个参数(requestenvctx)统一包装进了 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,排查顺序就应该稳定下来:

  1. query_understanding
  2. memory_retrieval
  3. prompt_assembly
  4. llm_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. 总结

这一篇把“系统整体怎么看”和“线上问题怎么查”连了起来。

核心结论有三个:

  1. 指标不是为了做报表,而是为了尽快发现异常趋势
  2. 排障不是凭经验乱翻日志,而是基于 Trace 按固定顺序逐层定位
  3. 延迟、检索质量、情绪健康度、成本,这四组指标基本覆盖了 AI 伴侣运营的核心视角

再往下一步,就不是“发现问题”了,而是“替换节点时如何降低风险”。下一篇就专门讲这个主题:影子模式、A/B 分桶、渐进发布和自动回滚。