第 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 无法正确渲染。比如收到 **加粗 但还没收到闭合的 ** 时,渲染器不知道是否该加粗。
推荐策略:
- 累积缓冲区:每次收到新 chunk,追加到完整字符串,用 Markdown 库重新渲染整个结果。
- 性能优化:当内容超过一定长度(如 2000 字符)时,只重新渲染最后 N 段,前面的段落冻结为静态 HTML。
- 代码块特殊处理:检测到
```开头但未闭合时,渲染为<pre>但添加"代码块生成中"标识。 - 避免闪烁:用
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]

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。