AI Web 应用前端可观测性:Web Vitals 之外还要看生成链路

0阅读11分钟

AI Web 应用的可观测盲区

我在 review 一个 AI 写作助手的性能报告时,发现一个现象:页面的 Core Web Vitals 全绿——LCP 1.8 秒、CLS 0.04、INP 85 毫秒——但用户反馈里「生成很慢」和「生成的东西不对」占了将近一半。Web Vitals 在告诉我们的事情和用户的实际体验之间,存在一个不小的缝隙。

这个缝隙的根源是:Web Vitals 衡量的是页面的加载和渲染性能,而 AI 交互的核心等待不在页面渲染,而在生成链路。用户感知到的「慢」,可能是模型推理排队、网络传输、流式输出的首 token 延迟,或者生成内容质量不达标导致的反复重试。这些信息在 LCP、CLS 和 INP 里都看不到。

这篇文章想拆的问题是:AI Web 应用的前端可观测性,在 Web Vitals 之外还需要哪些维度的指标和事件,才能让团队在用户说「慢」或「不对」的时候,有数据可以定位。

Web Vitals 能做什么、不能做什么

Core Web Vitals 是 Google 定义的一组页面体验指标,2026 年的三个核心指标是 LCP(Largest Contentful Paint)、INP(Interaction to Next Paint)和 CLS(Cumulative Layout Shift)。它们分别从加载速度、交互响应和视觉稳定性三个维度衡量页面的基础体验。对于 AI Web 应用,这些指标依然重要——AI 功能通常嵌入在页面中,页面本身的基础体验如果差,AI 功能再好也会被用户放弃。

但 Web Vitals 的设计目标是衡量「页面」的性能,不是「交互链路」的性能。当一个用户在 AI 写作助手里点击「生成大纲」按钮时,他等待的不是页面渲染,而是模型的推理结果。这段等待时间不在 LCP 里,也不在 INP 里。INP 衡量的是用户交互到下一次绘制的时间,但 AI 生成的等待不是主线程被阻塞,而是前端在等后端的流式响应。

Google Chrome 团队在 2025 年发布的《Best practices to render streamed LLM responses》专门讨论了这个问题:流式 LLM 响应的前端渲染需要特别关注 DOM 更新策略和布局稳定性,否则流式 token 的不断插入会导致频繁的布局重计算。这说明即使 AI 功能已经加载完毕,流式渲染过程本身也可能影响 CLS 和 INP。

把两类指标的边界理清,后面的讨论会更有方向:

维度Web Vitals 覆盖Web Vitals 不覆盖
页面加载LCP、FCP、TTFB
交互响应INP模型推理等待时间
视觉稳定CLS流式输出的逐步布局变化
生成链路TTFT、ITL、E2EL
用户行为取消、重试、编辑、采纳
错误归因JS 异常模型输出质量、流中断

生成链路的三个关键延迟指标

LLM 推理的性能在工程上已经有一套相对成熟的指标体系。BentoML 的 LLM Inference Metrics 文档和 Anyscale 的 benchmarking 指南把这些指标分为三层:

  1. TTFT(Time to First Token):从请求发出到第一个 token 返回的时间。对用户来说,这是从「点击生成」到「看到第一个字」的等待。TTFT 直接决定了用户对响应速度的第一印象。
  2. ITL(Inter-Token Latency):两个连续 token 之间的时间间隔。这个指标决定了流式输出的「流畅感」。ITL 不稳定,用户会看到文字一阵一阵地蹦出来。
  3. E2EL(End-to-End Latency):从请求发出到最后一个 token 返回的总时间。这是用户等待生成完成的完整时长。

TPOT(Time Per Output Token)是另一个常用指标,计算方式是 (E2EL - TTFT) / (输出 token 数 - 1),表示平均每个 token 的生成耗时。它剥离了首 token 的 prefill 阶段,更适合衡量生成阶段的效率。

这些指标在后端推理服务上已经有成熟的采集方案,但前端采集是一个容易被忽略的环节。前端记录的 TTFT 包含了网络传输延迟,后端记录的 TTFT 只包含模型推理时间。两组数据对比,才能判断延迟来自网络、排队还是模型本身。

指标后端记录的值前端记录的值差值说明
TTFT模型推理首 token 时间请求发出到首 token 渲染网络传输 + 排队
ITLtoken 间生成间隔token 间渲染间隔网络抖动 + 渲染阻塞
E2EL模型推理总时间请求发出到生成完成网络传输 + 排队

案例一:流式输出导致 CLS 暴涨

场景:一个 AI 客服系统,模型返回的内容通过 SSE(Server-Sent Events)流式渲染到对话气泡里。产品需求是「像 ChatGPT 一样一个字一个字出来」。

翻车:上线后 CLS 从 0.05 涨到 0.35,远超 Google 建议的 0.1 阈值。原因是每次新 token 插入 DOM 时,对话气泡的高度发生变化,下方的「快捷回复」按钮被反复推下。更麻烦的是,当生成的内容包含 Markdown 代码块时,代码块渲染会触发更大的布局偏移。

修复:给对话气泡区域预留固定高度(或最小高度),让流式内容在固定空间内展开。对 Markdown 渲染,使用流式 Markdown 解析器而不是每次全量重新解析,避免已渲染部分的反复重排。

// ❌ 坏的做法:每次收到 token 都全量重设 innerHTML
function handleToken(token: string) {
  accumulated += token
  // 每次全量解析 + 重设 innerHTML,导致整个 DOM 子树重建
  // 浏览器需要重新解析已渲染的内容,触发大量布局计算
  chatBubble.innerHTML = markdownToHtml(accumulated)
}
 
// ✅ 好的做法:使用流式解析器,只追加新内容
function handleToken(token: string) {
  accumulated += token
  // 流式解析器只处理新增的 token,输出新的 DOM 节点
  // 已有内容不会被重新解析,减少布局抖动
  const fragment = streamingParser.write(token)
  if (fragment) {
    chatBubble.append(fragment)
  }
}
/* ❌ 坏的做法:对话气泡没有预设高度 */
.chat-bubble {
  /* 高度随内容自动增长 */
  /* 每个 token 到来都会改变高度,推动下方元素 */
}
 
/* ✅ 好的做法:预留最小高度,减少布局偏移 */
.chat-bubble {
  min-height: 120px;
  /* 为流式内容预留空间 */
  /* 内容在固定区域内展开,下方元素不会被反复推动 */
}

Chrome 团队的指南明确指出,流式 LLM 响应不应该使用 textContentinnerText 来追加内容,因为这些属性会迫使浏览器反复删除和重建子元素。推荐使用 append()insertAdjacentText('beforeend', ...)appendChild(createTextNode(...)) 来追加文本,让浏览器只处理新增的部分。

案例二:TTFT 很长但 Web Vitals 全绿

场景:一个 AI 代码审查工具,用户提交代码后等待模型生成审查意见。后端模型推理本身需要 8-12 秒(涉及长上下文处理),但前端没有任何中间状态反馈。

翻车:用户反馈「点了按钮之后完全没反应,以为卡死了」。LCP 显示页面加载正常,INP 显示按钮点击响应正常。但用户感知到的是「应用没有在工作」。问题在于前端只关注了页面性能,没有处理生成链路的等待体验。

修复:在点击生成按钮后立即切换到「生成中」状态,显示进度指示器。同时记录从点击到首 token 的 TTFT,如果 TTFT 超过阈值(比如 5 秒),触发一条可观测性事件上报。

// ❌ 坏的做法:点击后只发请求,没有状态反馈
async function handleGenerate() {
  const response = await fetch('/api/review', {
    method: 'POST',
    body: JSON.stringify({ code: selectedCode }),
  })
  // 请求可能等待 8-12 秒,期间页面没有任何变化
  // 用户不知道是卡了还是在工作
  const result = await response.json()
  displayResult(result)
}
 
// ✅ 好的做法:立即切换状态 + 记录 TTFT + 超时预警
async function handleGenerate() {
  setGenerating(true) // 立即切换到「生成中」状态
  showProgressIndicator()
 
  const startTime = performance.now()
  let firstTokenReceived = false
 
  try {
    const response = await fetch('/api/review', {
      method: 'POST',
      body: JSON.stringify({ code: selectedCode }),
    })
 
    const reader = response.body!.getReader()
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
 
      if (!firstTokenReceived) {
        firstTokenReceived = true
        const ttft = performance.now() - startTime
        // 上报 TTFT 指标
        reportMetric('ai.ttft', ttft)
        // 如果 TTFT 超过阈值,上报预警事件
        if (ttft > 5000) {
          reportWarning('ai.ttft.slow', { ttft, model: 'code-review' })
        }
        hideProgressIndicator() // 首 token 到达后隐藏进度指示器
      }
 
      appendToken(new TextDecoder().decode(value))
    }
  } catch (error) {
    reportError('ai.generation.failed', error)
    showErrorState()
  } finally {
    setGenerating(false)
  }
}

这个案例的核心问题是:Web Vitals 告诉你页面是否正常,但生成链路的指标才能告诉你用户的任务是否在推进。

案例三:用户取消生成后无法归因

场景:一个 AI 写作平台,用户可以中途取消正在生成的内容。产品团队想知道用户为什么取消——是生成太慢、内容不对、还是交互设计有问题。

翻车:前端只记录了「生成失败」的事件,把用户取消也归类为失败。后端只记录模型调用是否成功。两边数据对不上,无法判断取消的原因。团队只能猜。

修复:在前端定义一套完整的生成事件体系,区分正常完成、用户取消、网络错误、模型错误等不同状态。每个事件都携带 traceId,可以和后端的模型调用 trace 关联。

// ❌ 坏的做法:只区分成功和失败
enum GenerationStatus {
  Success = 'success',
  Failed = 'failed',
}
 
function handleGenerationEnd(status: GenerationStatus) {
  // 用户取消和模型错误被混在一起,无法归因
  report('generation.end', { status })
}
 
// ✅ 好的做法:细粒度事件 + traceId 关联
enum GenerationEvent {
  Started = 'generation.started',
  FirstToken = 'generation.first_token',
  Completed = 'generation.completed',
  UserCancelled = 'generation.user_cancelled',
  Retried = 'generation.retried',
  NetworkError = 'generation.network_error',
  ModelError = 'generation.model_error',
}
 
function reportGenerationEvent(event: GenerationEvent, meta: {
  traceId: string
  tokenCount?: number
  duration?: number
  reason?: string
}) {
  // 每个事件都携带 traceId,可以和后端 trace 关联
  // 团队可以区分「用户因为太慢取消」和「用户因为内容不对取消」
  analytics.track(event, {
    ...meta,
    timestamp: Date.now(),
    sessionId: getSessionId(),
  })
}
// 用户取消时的处理
function handleUserCancel(traceId: string, tokenCount: number, duration: number) {
  reportGenerationEvent(GenerationEvent.UserCancelled, {
    traceId,
    tokenCount,
    duration,
    reason: duration < 3000 ? 'too_fast_cancel' : 'content_not_expected',
  })
  // 同时通知后端停止生成
  abortController.abort()
}
流程图画布 · 115%
Mermaid 流程图加载中...

前后端 trace 关联

前端事件只有和后端模型调用 trace 关联起来,才能做有效的归因。用户说「生成很慢」时,团队需要知道这个延迟发生在哪里:是前端到后端的网络传输、后端的请求排队、RAG 检索、还是模型推理本身。

Sentry 在 2025 年 6 月更新的 AI Agent Monitoring 提供了一个参考实现:它把前端的用户交互、后端的 API 调用和 LLM 调用放在同一个分布式 trace 里展示。工程团队可以在一条 trace 里看到「用户点击 → API 请求 → OpenAI 调用 → token 流式返回 → 前端渲染」的完整链路。

这种关联的关键是 traceId 的传递。前端发起请求时在 header 里携带 traceId,后端在调用模型时透传这个 traceId,模型返回的结果也关联到同一个 traceId。这样,前端记录的 TTFT 和后端记录的首 token 时间就可以对比,差值就是网络和排队延迟。

延迟环节前端能看到的后端能看到的对比结论
网络传输请求发出 → 后端收到前端 TTFB - 后端收到时间
请求排队后端收到 → 开始处理后端排队 span
RAG 检索开始处理 → 检索完成后端检索 span
模型推理开始推理 → 首 token后端 TTFT
流式传输首 token 返回 → 首 token 渲染末 token 发出 → 前端收到网络 + 渲染延迟

Web Vitals 在 AI 场景下的调优

虽然 Web Vitals 不能覆盖生成链路,但 AI 页面的流式渲染过程同样会影响 CLS 和 INP。以下是几个针对性的调优策略:

CLS 方面:AI 生成区域应该预留空间。如果无法预估生成内容的长度,至少给一个最小高度,避免生成区从零开始增长。生成区下方的元素(如操作按钮、推荐内容)应该有固定的锚定位置,不会因为上方内容的增长而被推移。

INP 方面:流式 token 渲染需要控制 DOM 更新频率。如果每收到一个 token 就触发一次 DOM 更新和 React re-render,高频的 token 流会让主线程忙于渲染,无法及时响应用户的交互(如点击「停止生成」)。可以使用节流或 requestAnimationFrame 来批量处理 token 渲染。

// ❌ 坏的做法:每收到一个 token 就触发状态更新
function handleToken(token: string) {
  // 每个 token 都触发一次 setState,导致 React 频繁 re-render
  // 高频 token 流会让主线程被渲染占满
  setContent(prev => prev + token)
}
 
// ✅ 好的做法:使用 requestAnimationFrame 批量渲染
const tokenBuffer: string[] = []
let rafId: number | null = null
 
function handleToken(token: string) {
  tokenBuffer.push(token)
  if (!rafId) {
    rafId = requestAnimationFrame(() => {
      // 一次性把缓冲区里的 token 全部追加
      setContent(prev => prev + tokenBuffer.join(''))
      tokenBuffer.length = 0
      rafId = null
    })
  }
}

LCP 方面:AI 页面的 LCP 元素通常是首屏的交互区域(如输入框或对话列表)。确保 LCP 元素不被 AI 生成的加载状态阻塞——输入框应该在页面加载后立即可见和可交互,而不是等 AI 功能初始化完成。

指标AI 场景下的典型问题调优方向
LCPAI 组件初始化阻塞首屏渲染延迟加载 AI 组件,优先渲染 LCP 元素
CLS流式生成导致布局跳动预留最小高度,固定下方元素位置
INP高频 token 渲染阻塞主线程requestAnimationFrame 批量渲染
TTFBAI API 请求排队预连接、请求优先级调整

前端可观测性的完整事件体系

把前面的讨论收拢到一起,AI Web 应用的前端可观测性需要覆盖两层指标:

第一层是 Web Vitals,衡量页面本身的基础体验。这一层用 web-vitals 库就可以完成采集。

第二层是生成链路事件,衡量 AI 交互的完整生命周期。这一层需要自己定义和采集。

事件类型具体事件关键数据
页面指标LCP、CLS、INP、TTFBvalue、id、delta
生成开始generation.startedtraceId、promptHash、model
首 tokengeneration.first_tokentraceId、TTFT
生成完成generation.completedtraceId、E2EL、tokenCount
用户取消generation.user_cancelledtraceId、已生成 token 数、duration
用户重试generation.retriedtraceId、retryCount、reason
用户编辑generation.editedtraceId、editDistance、section
用户采纳generation.acceptedtraceId、acceptedRatio
网络错误generation.network_errortraceId、errorCode、retryCount
模型错误generation.model_errortraceId、errorType、errorMessage

这些事件需要和后端 trace 通过 traceId 关联,才能做完整的归因分析。

上线前检查清单

以下清单按阶段分组,可以在 AI 功能上线前逐项检查:

阶段一:基础 Web Vitals

  1. 确认已集成 web-vitals 库,采集 LCP、CLS、INP、TTFB
  2. 确认 LCP 元素不被 AI 组件初始化阻塞
  3. 确认 AI 生成区域有最小高度或空间预留策略,CLS < 0.1
  4. 确认流式渲染使用 requestAnimationFrame 批量更新,INP < 200ms

阶段二:生成链路事件

  1. 确认定义了完整的生成事件体系(started → first_token → completed / cancelled / error)
  2. 确认每个事件携带 traceId、timestamp、sessionId
  3. 确认 TTFT 和 E2EL 有阈值预警(如 TTFT > 5s 触发告警)
  4. 确认用户取消和重试事件有独立的 reason 字段

阶段三:前后端关联

  1. 确认前端请求 header 携带 traceId,后端透传到模型调用
  2. 确认前端记录的 TTFT 可以和后端记录的模型推理时间对比
  3. 确认可观测面板能展示完整链路:用户操作 → API → 模型调用 → 流式返回 → 前端渲染

阶段四:用户行为分析

  1. 确认记录用户的采纳行为(复制、插入、直接使用),用于衡量生成质量
  2. 确认记录用户的编辑行为(editDistance),用于识别生成质量下降
  3. 确认取消事件按原因分类(太慢、内容不对、误操作),支持分维度分析

参考资料

  1. Web Vitals — Googleweb.dev/articles/vitals,Google 官方 Web Vitals 文档,本文的 LCP/CLS/INP 定义和采集方法参考此文档。
  2. Best practices to render streamed LLM responses — Chrome for Developersdeveloper.chrome.com/docs/ai/render-llm-responses,Chrome 团队关于流式 LLM 响应渲染的性能最佳实践,本文的流式渲染策略参考此指南。
  3. Key Metrics for LLM Inference — BentoMLbentoml.com/llm/llm-inference-basics/llm-inference-metrics,LLM 推理性能指标定义(TTFT、ITL、E2EL、TPOT),本文的生成链路指标体系参考此文档。
  4. Understand LLM Latency and Throughput Metrics — Anyscaledocs.anyscale.com/llm/serving/benchmarking/metrics,LLM 延迟和吞吐量的基准测试指标,本文的 TTFT/ITL 对比分析参考此文档。
  5. Introducing Sentry's Updated AI Agent Monitoring — Sentry Blogblog.sentry.io/sentrys-updated-agent-monitoring,Sentry 的 AI Agent 监控方案,本文的前后端 trace 关联方案参考此实现。
  6. Cumulative Layout Shift (CLS) — web.devweb.dev/articles/cls,Google 关于 CLS 的官方文档,本文的 CLS 调优策略参考此文档。
  7. Core Web Vitals in 2026: What's Changed and How to Pass — Rivulet IQrivuletiq.com/core-web-vitals-2026-whats-changed-and-how-to-pass,2026 年 Core Web Vitals 最新变化,本文的 INP 替代 FID 信息参考此文档。

评论 0

0 / 1000