AI Web 应用前端可观测性:Web Vitals 之外还要看生成链路
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 指南把这些指标分为三层:
- TTFT(Time to First Token):从请求发出到第一个 token 返回的时间。对用户来说,这是从「点击生成」到「看到第一个字」的等待。TTFT 直接决定了用户对响应速度的第一印象。
- ITL(Inter-Token Latency):两个连续 token 之间的时间间隔。这个指标决定了流式输出的「流畅感」。ITL 不稳定,用户会看到文字一阵一阵地蹦出来。
- E2EL(End-to-End Latency):从请求发出到最后一个 token 返回的总时间。这是用户等待生成完成的完整时长。
TPOT(Time Per Output Token)是另一个常用指标,计算方式是 (E2EL - TTFT) / (输出 token 数 - 1),表示平均每个 token 的生成耗时。它剥离了首 token 的 prefill 阶段,更适合衡量生成阶段的效率。
这些指标在后端推理服务上已经有成熟的采集方案,但前端采集是一个容易被忽略的环节。前端记录的 TTFT 包含了网络传输延迟,后端记录的 TTFT 只包含模型推理时间。两组数据对比,才能判断延迟来自网络、排队还是模型本身。
| 指标 | 后端记录的值 | 前端记录的值 | 差值说明 |
|---|---|---|---|
| TTFT | 模型推理首 token 时间 | 请求发出到首 token 渲染 | 网络传输 + 排队 |
| ITL | token 间生成间隔 | 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 响应不应该使用 textContent 或 innerText 来追加内容,因为这些属性会迫使浏览器反复删除和重建子元素。推荐使用 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()
}前后端 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 场景下的典型问题 | 调优方向 |
|---|---|---|
| LCP | AI 组件初始化阻塞首屏渲染 | 延迟加载 AI 组件,优先渲染 LCP 元素 |
| CLS | 流式生成导致布局跳动 | 预留最小高度,固定下方元素位置 |
| INP | 高频 token 渲染阻塞主线程 | requestAnimationFrame 批量渲染 |
| TTFB | AI API 请求排队 | 预连接、请求优先级调整 |
前端可观测性的完整事件体系
把前面的讨论收拢到一起,AI Web 应用的前端可观测性需要覆盖两层指标:
第一层是 Web Vitals,衡量页面本身的基础体验。这一层用 web-vitals 库就可以完成采集。
第二层是生成链路事件,衡量 AI 交互的完整生命周期。这一层需要自己定义和采集。
| 事件类型 | 具体事件 | 关键数据 |
|---|---|---|
| 页面指标 | LCP、CLS、INP、TTFB | value、id、delta |
| 生成开始 | generation.started | traceId、promptHash、model |
| 首 token | generation.first_token | traceId、TTFT |
| 生成完成 | generation.completed | traceId、E2EL、tokenCount |
| 用户取消 | generation.user_cancelled | traceId、已生成 token 数、duration |
| 用户重试 | generation.retried | traceId、retryCount、reason |
| 用户编辑 | generation.edited | traceId、editDistance、section |
| 用户采纳 | generation.accepted | traceId、acceptedRatio |
| 网络错误 | generation.network_error | traceId、errorCode、retryCount |
| 模型错误 | generation.model_error | traceId、errorType、errorMessage |
这些事件需要和后端 trace 通过 traceId 关联,才能做完整的归因分析。
上线前检查清单
以下清单按阶段分组,可以在 AI 功能上线前逐项检查:
阶段一:基础 Web Vitals
- 确认已集成
web-vitals库,采集 LCP、CLS、INP、TTFB - 确认 LCP 元素不被 AI 组件初始化阻塞
- 确认 AI 生成区域有最小高度或空间预留策略,CLS < 0.1
- 确认流式渲染使用 requestAnimationFrame 批量更新,INP < 200ms
阶段二:生成链路事件
- 确认定义了完整的生成事件体系(started → first_token → completed / cancelled / error)
- 确认每个事件携带 traceId、timestamp、sessionId
- 确认 TTFT 和 E2EL 有阈值预警(如 TTFT > 5s 触发告警)
- 确认用户取消和重试事件有独立的 reason 字段
阶段三:前后端关联
- 确认前端请求 header 携带 traceId,后端透传到模型调用
- 确认前端记录的 TTFT 可以和后端记录的模型推理时间对比
- 确认可观测面板能展示完整链路:用户操作 → API → 模型调用 → 流式返回 → 前端渲染
阶段四:用户行为分析
- 确认记录用户的采纳行为(复制、插入、直接使用),用于衡量生成质量
- 确认记录用户的编辑行为(editDistance),用于识别生成质量下降
- 确认取消事件按原因分类(太慢、内容不对、误操作),支持分维度分析
参考资料
- Web Vitals — Google,web.dev/articles/vitals,Google 官方 Web Vitals 文档,本文的 LCP/CLS/INP 定义和采集方法参考此文档。
- Best practices to render streamed LLM responses — Chrome for Developers,developer.chrome.com/docs/ai/render-llm-responses,Chrome 团队关于流式 LLM 响应渲染的性能最佳实践,本文的流式渲染策略参考此指南。
- Key Metrics for LLM Inference — BentoML,bentoml.com/llm/llm-inference-basics/llm-inference-metrics,LLM 推理性能指标定义(TTFT、ITL、E2EL、TPOT),本文的生成链路指标体系参考此文档。
- Understand LLM Latency and Throughput Metrics — Anyscale,docs.anyscale.com/llm/serving/benchmarking/metrics,LLM 延迟和吞吐量的基准测试指标,本文的 TTFT/ITL 对比分析参考此文档。
- Introducing Sentry's Updated AI Agent Monitoring — Sentry Blog,blog.sentry.io/sentrys-updated-agent-monitoring,Sentry 的 AI Agent 监控方案,本文的前后端 trace 关联方案参考此实现。
- Cumulative Layout Shift (CLS) — web.dev,web.dev/articles/cls,Google 关于 CLS 的官方文档,本文的 CLS 调优策略参考此文档。
- Core Web Vitals in 2026: What's Changed and How to Pass — Rivulet IQ,rivuletiq.com/core-web-vitals-2026-whats-changed-and-how-to-pass,2026 年 Core Web Vitals 最新变化,本文的 INP 替代 FID 信息参考此文档。