跳转至

第 8 章:前端实现:状态、流式渲染与体验指标

第 8 章封面

前端不是把页面画出来,而是把体验写成一个可预测系统。用户必须知道下一步是什么,失败了怎么回来,以及系统到底在干什么。[17]

AI 产品的前端和传统 Web 应用有一个根本区别:响应时间从毫秒级变成了秒级甚至分钟级,输出从确定性的 JSON 变成了流式的、可能出错的自然语言。你的用户会盯着一个可能需要 10 秒才开始吐字的界面,中途可能断流,输出可能带幻觉,长度不可预测。传统的 loading spinner + 成功/失败二态远远不够。

本章解决三个问题:如何管理复杂状态、如何处理流式渲染、如何用指标裁决体验。

你将收获什么

  • 一套完整的状态矩阵:覆盖 AI 产品特有的所有状态,不止空/加载/成功/失败。
  • 一套流式渲染方案:从 SSE 连接管理到增量 Markdown 渲染,含取消、断流重连、内存回收。
  • 一套 AI 交互三件套:进度(可预期)、证据(可追溯)、反馈(可学习)。
  • 一份体验指标最小集:把"好用"变成可观察的门槛。[6][17]

8.1 状态矩阵:AI 产品需要更多状态

传统 Web 页面通常有 4 种状态:空、加载中、成功、失败。AI 产品至少需要 8 种,因为"加载中"和"失败"各自有多种子状态。

完整状态矩阵

状态 触发条件 用户看到什么 必须提供的操作 前端实现要点
空态 (Empty) 首次进入 / 无历史 引导语 + 3–5 个示例 Prompt 点击示例直接发送 示例需定期更新,避免过时
等待中 (Pending) 已发送、服务端未响应 "正在连接…" + 取消按钮 取消 超过 5 秒未收到首字节,自动提示"服务繁忙"
流式生成中 (Streaming) 收到首字节 逐字/逐块渲染 + 实时光标 停止生成 增量渲染,不可全量替换 DOM
生成完成 (Done) 收到 [DONE] / 流关闭 完整结果 + 引用 + 操作栏 复制 / 保存 / 导出 / 重新生成 渲染最终 Markdown,添加引用标记
用户中止 (Aborted) 用户点击"停止" 已生成部分 + "已停止" 标记 继续生成 / 重新生成 / 编辑 保留已接收内容,清理连接
可恢复错误 (Recoverable) 网络中断 / 429 限流 / 超时 错误描述 + 恢复建议 重试 / 修改后重发 指数退避重试,保留用户输入
不可恢复错误 (Fatal) 401 未授权 / 500 内部错误 明确错误 + 下一步指引 重新登录 / 联系支持 区分客户端错误和服务端错误
降级态 (Degraded) 置信度低 / 检索无结果 结果 + 醒目的"置信度低"警告 核验 / 补充资料 / 切换模型 与正常完成态视觉区分(如黄色边框)

关键原则:每种状态之间的转换路径必须是确定的。if/else 嵌套拼凑的状态管理会在第三次需求变更时崩溃。用状态机。

页面级状态矩阵(示例)

把上面的通用状态应用到具体页面:

页面 空态 等待中 流式生成中 完成 中止 可恢复错误 不可恢复错误 降级态
AI 对话 引导 Prompt "连接中" 逐字渲染 + 思维链折叠 结果 + 引用 + 反馈 保留已生成 重试按钮 重新登录 "置信度低"标签
文档分析 上传入口 "解析中" 逐段输出摘要 摘要 + 关键信息卡片 保留已解析 重新上传 格式不支持 "部分页面无法解析"
代码生成 需求描述输入框 "理解需求中" 逐行代码 + 语法高亮 完整代码 + 运行按钮 保留已生成 重试 任务过于复杂 "生成结果未验证"
批量任务 任务列表空态 排队中 (N/M) 实时进度条 结果表格 + 导出 暂停队列 单条重试 配额耗尽 部分任务失败

8.2 流式渲染:AI 前端的核心难题

大模型推理通常通过 Server-Sent Events (SSE) 流式返回 token。这带来了传统前端从未遇到的挑战。

8.2.1 SSE 连接管理

class StreamManager {
  private controller: AbortController | null = null;
  private buffer = '';

  async start(url: string, body: object, onChunk: (text: string) => void) {
    // 每次新请求前清理上一次的连接
    this.abort();
    this.controller = new AbortController();
    this.buffer = '';

    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      signal: this.controller.signal,
    });

    if (!response.ok) throw new StreamError(response.status);
    if (!response.body) throw new StreamError(0, 'No response body');

    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        this.buffer += chunk;

        // 解析 SSE 格式:按 data: 行分割
        const lines = this.buffer.split('\n');
        this.buffer = lines.pop() || ''; // 最后一行可能不完整,留到下次

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') return;
            onChunk(data);
          }
        }
      }
    } finally {
      reader.releaseLock();
    }
  }

  abort() {
    this.controller?.abort();
    this.controller = null;
  }
}

8.2.2 五个必须处理的边界场景

场景 表现 处理方式
用户取消 点击"停止生成" 调用 AbortController.abort(),保留已收到的内容,状态转为 Aborted
网络断流 流中途静默断开 设置心跳超时(如 15 秒无新数据),自动提示"连接中断",提供重试
页面导航 用户在生成中切换页面 在路由守卫中调用 abort(),防止内存泄漏和幽灵更新
并发请求 用户快速连发多条消息 前一条未完成时,先 abort 再发新请求,或排队处理
超大输出 模型输出数千行 虚拟滚动 (Virtual Scroll),只渲染可视区域的 DOM 节点

8.2.3 增量 Markdown 渲染

流式输出的内容通常是 Markdown 格式。难点在于:不完整的 Markdown 无法正确渲染。比如收到 **加粗 但还没收到闭合的 ** 时,渲染器不知道是否该加粗。

推荐策略:

  1. 累积缓冲区:每次收到新 chunk,追加到完整字符串,用 Markdown 库重新渲染整个结果。
  2. 性能优化:当内容超过一定长度(如 2000 字符)时,只重新渲染最后 N 段,前面的段落冻结为静态 HTML。
  3. 代码块特殊处理:检测到 ``` 开头但未闭合时,渲染为 <pre> 但添加"代码块生成中"标识。
  4. 避免闪烁:用 requestAnimationFrame 批量更新 DOM,不要每个 token 都触发渲染。
// 增量渲染的节流策略
let renderPending = false;
let fullContent = '';

function onChunk(chunk: string) {
  fullContent += chunk;

  if (!renderPending) {
    renderPending = true;
    requestAnimationFrame(() => {
      renderMarkdown(fullContent);
      renderPending = false;
    });
  }
}

8.3 错误处理:把报错变成行动指南

前端最常见的浪费,就是把技术堆栈直接扔给用户。错误提示必须是可行动的

推荐三段式结构: 1. 发生了什么(人话,非技术术语)。 2. 为什么(可选,简要解释)。 3. 怎么做(必须有,提供按钮或链接)。

AI 场景特有错误处理表

错误类别 HTTP 状态码 用户看到的提示 恢复操作 是否自动重试
限流 429 "请求太快,请稍后再试" 倒计时后自动重试 是,指数退避
超时 408 / 504 "生成耗时较长,已暂停" 继续 / 重试 / 查看已生成 是,最多 2 次
认证失效 401 "登录已过期,请重新登录" 跳转登录页
配额耗尽 402 / 403 "本月额度已用完" 升级套餐 / 等待重置
模型过载 503 "服务繁忙,正在排队" 等待 / 换模型 是,退避
内容违规 451 "内容不符合使用规范" 修改输入 / 查看规范
检索无结果 200 (空) "未找到相关信息,请补充资料" 上传资料 / 扩大范围
置信度低 200 (低分) "结果可能不准确,请核验" 重新生成 / 人工编辑
前端异常 N/A "页面遇到问题,请刷新" 刷新页面

错误映射实现:

const ERROR_MAP: Record<number, { message: string; action: string; autoRetry: boolean }> = {
  429: { message: '请求太快,请稍后再试', action: 'countdown_retry', autoRetry: true },
  401: { message: '登录已过期', action: 'redirect_login', autoRetry: false },
  402: { message: '本月额度已用完', action: 'show_upgrade', autoRetry: false },
  503: { message: '服务繁忙,正在排队', action: 'backoff_retry', autoRetry: true },
};

function handleError(status: number, traceId: string) {
  const info = ERROR_MAP[status] || { message: '服务暂时不可用', action: 'retry', autoRetry: false };
  reportError({ status, traceId, timestamp: Date.now() }); // 上报
  return info;
}

8.4 AI 交互三件套:进度、证据、反馈

AI 的核心是不确定性,前端的任务是把不确定性可视化。[6]

图 8-1:AI 输出区 UI 结构示意

1. 进度 (Progress)

阶段 持续时间范围 用户看到什么 超时阈值
连接中 0–2 秒 脉冲动画 5 秒未连接 → 提示"服务繁忙"
思考中 0–5 秒 "正在分析问题…" 10 秒无首字节 → 提示"排队中"
检索中 0–3 秒 "正在查找相关资料…" + 来源数量 8 秒 → 提示"检索范围较大"
生成中 1–60 秒 逐字渲染 + 光标闪烁 15 秒无新内容 → "可能卡住了"

超时阈值的来源: 以上数字基于 Nielsen Norman Group 的响应时间研究——0.1 秒感觉即时,1 秒保持思路连贯,10 秒是注意力极限。AI 场景因用户预期不同,阈值放宽 2–3 倍,但仍需兜底。

必须提供"停止/取消"按钮。 这不是锦上添花,是基本权利。用户在等待 30 秒后发现问题问错了,必须能立刻停止。

2. 证据 (Evidence)

所有事实性陈述必须带引用标记 [n],点击可展开来源摘要和链接。

interface Evidence {
  id: string;
  source_title: string;
  url?: string;
  snippet: string;           // 命中的原文片段
  relevance_score: number;   // 0-1,用于排序和置信度提示
}

实现要点: - 引用标记在流式渲染中延迟渲染:等整段文本完成后再插入引用链接,避免中途插入导致布局跳动。 - 当所有引用的 relevance_score 都低于阈值(如 0.5)时,在结果顶部显示"置信度低"警告。 - 引用面板支持"查看原文",帮助用户判断模型是否在胡编。

3. 反馈 (Feedback)

  • 一键反馈:点赞/点踩,不要求文字输入。
  • 点踩后追问:展开 3–5 个选项("不准确"/"不相关"/"不完整"/"有害内容"/"其他"),数据回流到失败样本库。[18]
  • 隐式反馈:记录用户是否复制、保存、导出了结果——这比显式反馈更真实。

8.5 状态机实现

别用一堆 isLoading, isError, isRetrying 的布尔值来拼凑逻辑。AI 输出区本质是一个状态机。

type Phase = 'idle' | 'pending' | 'streaming' | 'done' | 'aborted' | 'error_recoverable' | 'error_fatal' | 'degraded';

interface OutputState {
  phase: Phase;
  content: string;            // 流式累积
  evidences: Evidence[];
  trace_id: string;           // 必传,用于关联后端日志
  error?: { code: number; message: string; action: string };
  timing: {
    request_ms: number;       // 请求发出时间
    first_token_ms?: number;  // 首字节到达(TTFT)
    end_ms?: number;          // 生成完成
  };
}

// 合法的状态转换
const TRANSITIONS: Record<Phase, Phase[]> = {
  idle:               ['pending'],
  pending:            ['streaming', 'error_recoverable', 'error_fatal'],
  streaming:          ['done', 'aborted', 'error_recoverable', 'degraded'],
  done:               ['idle', 'pending'],          // 重新提问
  aborted:            ['idle', 'pending'],           // 继续或重新生成
  error_recoverable:  ['pending', 'idle'],           // 重试或放弃
  error_fatal:        ['idle'],                      // 只能回到初始
  degraded:           ['idle', 'pending'],            // 接受或重新生成
};

function transition(current: Phase, next: Phase): Phase {
  if (!TRANSITIONS[current]?.includes(next)) {
    console.error(`Invalid transition: ${current}${next}`);
    return current; // 拒绝非法转换
  }
  return next;
}

TTFT(Time to First Token) 是 AI 产品最重要的前端体验指标。它衡量从用户按下发送到屏幕出现第一个字的时间。一般目标:TTFT < 2 秒(简单问题)、< 5 秒(复杂推理)。超过 10 秒用户会认为系统死了。


8.6 体验指标:最小裁决集

不要搞一堆虚荣指标(如 PV/UV)。在 0→1 阶段,只关注能裁决系统优化方向的指标。

最小事件埋点表

事件 Key 触发时机 业务含义 对应指标
task_start 用户点击发送/回车 意图开始 (分母)
first_token 第一个字符渲染上屏 响应速度 效率指标:TTFT
stream_complete 流结束 生成耗时 效率指标:总生成时间
user_abort 用户点击停止 体验不耐受 守门指标:中止率
evidence_click 用户点击引用来源 信任建立/核验 质量指标:证据采纳率
feedback_negative 用户点踩 结果不可用 质量指标:负反馈率
task_complete 用户复制/导出/保存 结果被采纳 北极星指标:任务闭环率
client_error 前端捕获到异常 系统故障 守门指标:客户端错误率

指标裁决规则

指标 健康区间 黄灯(需关注) 红灯(必须行动)
TTFT P95 < 3 秒 3–8 秒 > 8 秒
中止率 < 5% 5–15% > 15%
负反馈率 < 10% 10–20% > 20%
任务闭环率 > 60% 40–60% < 40%
客户端错误率 < 0.5% 0.5–2% > 2%

阈值来源:TTFT 基于 Google RAIL 模型(100ms/1s/10s 分界)在 AI 场景下的放宽;闭环率和反馈率基于 B2B SaaS 产品的行业基准(Mixpanel 2024 Benchmark);中止率 15% 是经验值——超过此线说明系统响应速度或结果质量有系统性问题。


8.7 常见陷阱与修复

# 现象 根因 修复
1 用户盯着屏幕发呆,不知道是否死机 流式输出卡顿时无视觉反馈 15 秒无新内容 → 显示"正在组织语言…"脉冲动画
2 报错全是 "Unknown Error" 前端直接透传 HTTP 状态码 在 API 层拦截,映射为用户可理解的提示(见 8.3 错误表)
3 改了一个埋点,闭环率暴跌 埋点口径变更导致前后不可比 改动前后必须同口径回归,用 A/B 分流而非全量替换
4 切换页面后控制台报错 流式连接未在路由变更时关闭 路由守卫中调用 streamManager.abort(),清理所有订阅
5 长对话越来越卡 DOM 节点无限增长,未做虚拟化 超过 50 条消息时启用虚拟滚动,只渲染可视区域
6 Markdown 渲染闪烁 每个 token 都触发完整重渲染 requestAnimationFrame 节流,冻结已完成段落

8.8 交付物清单

提交代码前,请确认你交付了以下内容:

  • [ ] 状态矩阵文档:覆盖每个关键页面的 8 种状态 + 转换路径。[17]
  • [ ] 流式渲染组件:实现了 SSE 连接管理、取消、断流超时、增量 Markdown 渲染。
  • [ ] 错误映射层:HTTP 状态码 → 用户可理解的提示 + 恢复操作。
  • [ ] 输出区三件套:进度(含取消)、证据(含引用展开)、反馈(含点踩追问)。
  • [ ] 埋点定义:包含上述最小事件集 + TTFT 采集。
  • [ ] 失败样本库接入:前端能捕获 trace_id 并上报用户反馈。[18]
  • [ ] 路由守卫:页面切换时自动关闭流式连接,无内存泄漏。

下一章

前端把体验做成了可预测的闭环,下一章我们进入后端:如何构建一个默认幂等、审计完备、错误语义清晰的后端系统。 见:09-backend.md

参考

[6] 05-validation.md [17] 17-deployment.md [18] 18-evaluation.md 详见本书统一参考文献列表:references.md