如何生成AI结果
AI 结果的展示方式直接影响用户体验。一次流畅的流式输出、一个设计合理的加载状态、一套完善的 Markdown 渲染方案,共同决定了用户是「感到惊艳」还是「反复等待」。本文系统拆解 AI 结果从生成到展示的完整链路,覆盖设计原则、流式传输、加载状态、结果格式化和错误处理。
一、结果展示设计原则
AI 产品与传统工具的核心差异在于:结果是「生成」出来的,而非「查询」得到的。生成需要时间,而这段等待时间的体验,完全取决于展示设计。
1.1 即时反馈:消除空白等待
用户对「无反馈等待」的容忍阈值大约是 1 秒。超过这个时间没有视觉变化,用户会怀疑系统卡死。AI 生成通常需要 2-30 秒,因此必须做到:
- 首 Token 时间(TTFT)可视化:从用户提交到第一个字符出现,控制在 500ms 以内,并通过加载动画填补这段空白。
- 操作确认:用户点击「生成」后,按钮状态立即变化(loading spinner / 禁用),确认请求已被接收。
- 进度感知:即使无法精确估算剩余时间,也要通过打字机光标、骨架屏脉冲等方式传递「系统正在工作」的信号。
1.2 渐进展示:让内容自己说话
流式输出的本质是渐进展示(Progressive Disclosure)。不要等全部生成完毕再一次性呈现——这会让用户白白等待 5-30 秒,且失去「观察 AI 思考」的信任感。
渐进展示的三个层次:
| 层次 | 说明 | 适用场景 |
|---|---|---|
| 逐字展示 | 每个 Token 到达即渲染 | 聊天对话、短文本生成 |
| 逐段展示 | 按段落/区块缓冲后渲染 | 长文章、结构化报告 |
| 分层展示 | 先出框架(标题、列表骨架),再填充内容 | 复杂文档、代码生成 |
1.3 可读性优先
AI 输出通常包含混合内容:正文、代码、表格、公式、引用。可读性设计的核心是:
- 排版呼吸感:段落间距、行高、代码块与正文的视觉区分。
- 语法高亮:代码块必须有正确的语言和语法着色。
- 结构清晰:标题层级、列表缩进、引用样式都要一致。
二、流式输出实现
流式输出是 AI 结果展示的底层技术基础。目前主流有三种传输方案。
2.1 Server-Sent Events(SSE)
SSE 是目前最广泛使用的 AI 流式传输协议。它基于标准 HTTP,单向推送,天然适配「服务器 → 客户端」的 Token 流场景。
后端实现要点(Node.js):
// Hono 框架示例
app.post('/api/chat', async (c) => {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
for await (const chunk of llm.stream(prompt)) {
const data = `data: ${JSON.stringify({ content: chunk.text })}\n\n`
controller.enqueue(encoder.encode(data))
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // 防止 Nginx 缓冲
}
})
})前端消费(React + Fetch API):
async function consumeStream(prompt: string, onToken: (text: string) => void) {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
const parsed = JSON.parse(line.slice(6))
onToken(parsed.content)
}
}
}
}2.2 WebSocket
WebSocket 支持双向通信,适合需要「边生成边交互」的场景——比如用户可以在 AI 生成过程中发送新消息或修改参数。
优势:低延迟、双向通信、支持多路复用。 劣势:需要维护连接状态、断线重连逻辑更复杂、代理和防火墙兼容性不如 SSE。
2.3 方案对比
| 维度 | SSE | WebSocket | 长轮询 |
|---|---|---|---|
| 协议 | HTTP | WS | HTTP |
| 方向 | 单向(服务器→客户端) | 双向 | 请求-响应 |
| 延迟 | 低(200-500ms TTFT) | 极低 | 高(每轮需新请求) |
| 断线重连 | 浏览器自动重连 | 需手动实现 | 无需(短连接) |
| 实现复杂度 | 低 | 中 | 中 |
| 代理兼容 | 好(标准 HTTP) | 一般(需 WS 支持) | 好 |
| 适用场景 | 单轮生成 | 多轮交互、协同编辑 | 兼容性兜底 |
2.4 打字机效果的细节打磨
真正的「打字机效果」不是简单地把每个 Token 拼接到字符串末尾。需要注意:
- 光标闪烁:流式输出过程中显示闪烁光标(CSS
animation: blink 1s step-end infinite),结束后隐藏。 - 自动滚动:内容增长时自动滚动到底部,但用户手动滚动后暂停自动滚动,避免干扰阅读。
- Token 缓冲:高频率 Token 到达时(如每 10ms 一个),用
requestAnimationFrame批量更新 DOM,避免每 Token 一次渲染导致性能问题。 - 中断保留:用户点击「停止生成」时,保留已生成的内容,而不是清空。
三、加载状态设计
加载状态是用户在「首 Token 到达前」唯一的视觉反馈,设计质量直接决定感知等待时长。
3.1 加载状态方案对比
| 方案 | 实现方式 | 感知等待 | 适用场景 | 注意事项 |
|---|---|---|---|---|
| 骨架屏 | 占位灰块模拟最终布局 | 短 | 结构化内容(卡片、列表) | 需与实际布局匹配 |
| 打字指示器 | 三点跳动动画 | 中 | 聊天场景 | 轻量、不打扰 |
| 进度条 | 确定/不确定进度 | 短 | 可预估时长的任务 | 不确定进度条避免虚假预期 |
| 文案提示 | 「正在思考...」「正在分析...」 | 中 | 所有场景的兜底 | 文案要匹配任务语义 |
| 分步指示 | 步骤 1/2/3 可视化 | 短 | 多阶段任务(分析→生成→优化) | 每步完成时更新状态 |
3.2 等待时长的心理学
研究表明,「有解释的等待」比「无解释的等待」感知时长短 30-40%。因此:
- 告诉用户在做什么:「正在分析你的需求...」比「加载中...」更好。
- 给出大致预期:「通常需要 5-10 秒」比什么都不说要好。
- 阶段性反馈:如果生成过程有多个步骤,每完成一步都更新状态文案。
3.3 骨架屏设计要点
骨架屏(Skeleton Screen)的关键不是「放几个灰色矩形」,而是:
- 布局匹配:骨架屏的尺寸和位置要与最终内容一致,减少内容加载后的视觉跳动(Layout Shift)。
- 脉冲动画:柔和的亮度脉冲传递「正在加载」信号,避免用户误以为页面卡死。
- 渐进替换:内容到达时逐块替换骨架,而非整页切换。
四、结果格式化
AI 模型的输出通常是 Markdown 格式,但原始 Markdown 不能直接呈现给用户。结果格式化的任务是将 Markdown 转换为可读、美观、安全的富文本。
4.1 Markdown 渲染方案对比
| 方案 | 库 | 特点 | 性能 | 安全性 |
|---|---|---|---|---|
| AST 渲染 | react-markdown + remark-gfm | React 组件化、插件生态丰富 | 中(需优化) | 需配合 sanitize |
| HTML 直出 | marked + dangerouslySetInnerHTML | 性能高、实现简单 | 高 | 必须 sanitize |
| 流式专用 | FluidMarkdown | 专为流式场景设计,支持增量解析 | 高 | 内置安全处理 |
| 自定义解析器 | markdown-it + 自定义插件 | 灵活度最高 | 高 | 可控 |
4.2 流式 Markdown 的核心挑战
流式输出中的 Markdown 渲染不是「等内容完整再解析」,而是「边接收边渲染」。这带来三个关键问题:
问题一:未闭合的语法标记
Token 逐个到达时,经常遇到 **加粗文字 没有 ** 闭合的情况。解决方案是在渲染前预处理,自动补全未闭合的标记:
function repairIncompleteMarkdown(content: string): string {
let repaired = content
// 补全未闭合的代码块
const fenceCount = (repaired.match(/```/g) || []).length
if (fenceCount % 2 !== 0) repaired += '\n```'
// 补全未闭合的粗体
const boldCount = (repaired.match(/\*\*/g) || []).length
if (boldCount % 2 !== 0) repaired += '**'
// 补全未闭合的行内代码
const inlineCount = (repaired.match(/`/g) || []).length
if (inlineCount % 2 !== 0) repaired += '`'
return repaired
}问题二:频繁重解析的性能开销
每个 Token 到达都触发完整 Markdown 解析,在长回复(数千字)时会造成明显卡顿。优化策略:
- 分块渲染:将内容按块级边界(段落、标题、代码块)拆分,每个块独立渲染。只有最后一个「正在写入」的块需要重新解析。
React.memo+ 自定义比较器:已完成的块跳过重新渲染。startTransition:将 Token 到达触发的更新标记为低优先级,避免阻塞用户交互。
问题三:代码块的语法高亮
代码块在流式输出中尤其棘手——在闭合 ``` 到达之前,无法确定代码语言,也无法安全地应用语法高亮。常用策略:
| 策略 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 延迟高亮 | 流式过程中用等宽纯文本显示,闭合后统一高亮 | 简单可靠 | 闭合前后有视觉跳变 |
| 渐进高亮 | 每个 Token 到达后重新执行高亮,加 debounce | 体验连续 | 性能开销大 |
| 语言推断 | 检测代码块开头的语言标识,未闭合前按该语言高亮 | 折中方案 | 无语言标识时回退纯文本 |
推荐使用 Shiki 作为语法高亮引擎——它是 VSCode 的底层高亮器,支持 100+ 语言,主题兼容性好。配合 rehype-highlight 插件可以直接集成到 Markdown 渲染管线中。
4.3 安全性:AI 输出是不可信输入
AI 生成的 Markdown 本质上是不可信内容——用户可以通过 Prompt 注入引导模型输出包含 <script> 标签或 javascript: 链接的恶意内容。渲染器是安全边界,必须做到:
- 使用
rehype-sanitize或等效方案,白名单放行安全标签(p、a、code、img等)。 - 剥离
<script>、onerror事件处理器、javascript:协议 URL。 - 对链接添加
rel="noopener noreferrer"和target="_blank"。
五、错误状态处理
AI 生成过程中可能出现的错误类型和处理策略:
| 错误类型 | 触发场景 | 处理策略 |
|---|---|---|
| 网络中断 | 用户网络断开 | 保留已生成内容,显示「连接中断」提示,提供「重试」按钮 |
| 服务端超时 | 生成时间超过网关限制 | 自动重试 1 次,失败后保留部分内容并提示 |
| 模型限流 | 触发 rate limit | 指数退避重试,显示「当前繁忙,稍后重试」 |
| 内容安全 | 触发内容审核策略 | 显示审核提示,不保留被拦截内容 |
| Token 上限 | 达到模型最大输出长度 | 保留已生成内容,提示「已达长度上限」并提供「继续生成」 |
| 解析错误 | SSE 数据格式异常 | 记录日志,尝试恢复流,失败则保留已解析内容 |
核心原则:永远不要丢弃已生成的内容。 即使生成过程中发生错误,用户已经看到的部分应该保留在界面上,并给出明确的错误说明和可执行的下一步操作。
六、AI 结果生成与展示流程
七、实战案例
案例一:AI 写作助手的流式输出优化
背景:某 AI 写作工具最初采用「等待完整响应后渲染」的方案,用户平均等待 8-15 秒才能看到结果,跳出率高达 45%。
优化措施:
- 切换到 SSE 流式输出:首 Token 时间从 8 秒降至 400ms,用户在请求发出后不到半秒即可看到文字开始出现。
- 分块 Markdown 渲染:将内容按段落拆分,使用
React.memo避免重复解析已完成的段落。长文档(3000+ 字)的渲染帧率从 15fps 提升到 55fps。 - 骨架屏 → 流式过渡:在等待首 Token 的 400ms 内显示与正文布局一致的骨架屏,首 Token 到达后平滑过渡为流式文本。
- 代码块延迟高亮:流式过程中用纯等宽文本显示代码,闭合后调用 Shiki 一次性高亮,消除了代码块的闪烁问题。
效果:用户跳出率从 45% 降至 18%,平均会话时长提升 60%。
案例二:企业级 AI 分析平台的渐进展示
背景:一个面向金融分析师的 AI 平台,生成包含图表、数据表格、文字分析的复杂报告,完整生成需要 20-30 秒。
优化措施:
- 分层展示策略:先输出报告大纲(标题 + 章节骨架),再逐段填充分析内容。用户在 2 秒内即可看到报告结构。
- 分步进度指示:将生成过程拆分为「数据收集 → 趋势分析 → 图表生成 → 报告撰写」四个步骤,每步完成时更新进度条。
- 表格渐进渲染:数据表格逐行到达,先显示已有行,新行到达时平滑插入。
- 停止与续写:用户可在任何时刻停止生成,已生成的部分保留。停止后提供「从断点继续」选项。
效果:用户满意度评分从 3.2 提升至 4.5(满分 5 分),「等待焦虑」相关反馈减少 70%。
八、无障碍设计
AI 结果展示不能忽略无障碍访问(Accessibility)。流式输出对屏幕阅读器用户尤其具有挑战性——每个 Token 到达都可能触发朗读,导致用户听到的是断断续续的单词流。
关键要求:
aria-live="polite":让屏幕阅读器在新内容到达时「等当前朗读完毕再播报」,避免打断。aria-busy="true":流式输出期间标记为忙碌状态,阅读器暂停播报直到生成完成。aria-atomic="false":只播报新增内容,不重复朗读整个回复。- 批量播报:将 Token 按 2-3 秒间隔分批推送给阅读器,避免逐字朗读。
- 不抢占焦点:流式响应开始时不要将焦点从输入框移走。
- 键盘可达:所有操作按钮(停止、重试、复制)必须可通过键盘访问。
九、实施检查清单
在上线 AI 结果展示功能前,逐项确认:
- 首 Token 时间(TTFT)控制在 500ms 以内
- 加载状态已设计并实现(骨架屏 / 打字指示器 / 进度提示)
- 流式传输使用 SSE 或 WebSocket,且处理了断线重连
- Markdown 渲染支持流式场景,已处理未闭合语法标记
- 代码块有语法高亮,且流式过程中不会闪烁
- 已通过
rehype-sanitize或等效方案处理 XSS 安全 - 用户可随时停止生成,已生成内容被保留
- 错误状态有明确提示和可执行的下一步操作
- 自动滚动行为合理:默认跟随,用户滚动后暂停
- 无障碍属性已配置:
aria-live、aria-busy、aria-atomic - 所有操作按钮支持键盘访问
- 长回复(1000+ Token)的渲染性能已验证,无卡顿
- 移动端适配:触摸滚动流畅、布局不错乱
- 已测试弱网环境下的降级表现
十、参考资料
- The Complete Guide to Streaming LLM Responses in Web Applications — SSE 到实时 UI 的完整实现指南
- How To Build a Performant AI Markdown Renderer — 流式 Markdown 渲染的性能优化方案
- Streaming — AI UX Pattern — AI 流式输出的 UX 设计模式
- antgroup/FluidMarkdown — 面向移动端的流式 Markdown 渲染引擎
- Claude API Streaming (SSE) in Practice — Claude API 流式输出的实践指南
- The Streamable-UI Pattern: Turn Chat Into a Live React Dashboard — 可流式 UI 模式:将聊天变为实时 React 仪表盘
- Ephibbs/flowtoken — 流式 LLM 输出的动画与样式库
- 前端流式输出 3 种实现 — Fetch API、SSE、WebSocket 三种前端实现方式对比