第 8 章:前端流式 UI
本章解决的问题
前端 AI 面试的核心难点不是调用模型,而是处理流式输出、工具中间状态、取消和重试。这一章给出实现思路。
交互图解:Agent 流式 UI 生命周期 · 查看图解导读
大白话理解
AI 对话 UI 不是一个普通表单:
- 文本会连续到达。
- Markdown 会在半截时出现。
- 模型可能突然要求调工具。
- 用户可能随时取消。
- 网络可能中断。
因此,UI 需要状态机,而不是简单的“loading -> success -> error”。
必须掌握的概念
1. 消息状态
一条 assistant 消息至少可能有:
pendingstreamingtool-callingcompletederroraborted
2. 增量渲染
每次收到 chunk 后更新局部文本,不要让整个会话重新渲染。
3. 流式 Markdown
Markdown 在未完成时可能不合法。前端可以:
- 使用增量 Markdown 解析。
- 或把未完成块临时按纯文本显示。
- 或按块解析,而不是每次全文解析。
4. 取消与重试
取消使用 AbortController。取消后要决定:
- 是否保留已生成文本。
- 是否标记为 aborted。
- 是否允许继续或重新生成。
5. 断线重连
断线后需要:
- 记录游标或最后事件。
- 重连后恢复会话状态。
- 避免重复提交消息。
DeepSeek Harness 对应实现
DeepSeek Harness 的前端把业务状态放在 React 之外的 object layer。
关键思路:
- Session 持有事件窗口和流式累积。
ConversationNodeAssembler把事件变成 UI 节点。Notifier合批通知。- React 通过
useSyncExternalStore订阅快照。
这样 token 高频到达时,React 不会整棵树抖动。
进一步阅读:
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.mdpackages/client/runtime/src/client/sessions/
代码示例
使用 AbortController 取消请求
tsx
import { useState } from 'react'
function ChatBox() {
const [text, setText] = useState('')
const [status, setStatus] = useState<'idle' | 'streaming' | 'aborted'>('idle')
async function send(prompt: string) {
const controller = new AbortController()
setStatus('streaming')
setText('')
try {
const response = await fetch('/api/chat', {
method: 'POST',
signal: controller.signal,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt }),
})
if (!response.body) return
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
setText(current => current + decoder.decode(value, { stream: true }))
}
setStatus('idle')
} catch (error) {
if (controller.signal.aborted) {
setStatus('aborted')
}
}
return controller
}
return <pre>{text}</pre>
}消息状态模型
ts
type AssistantMessageState =
| { kind: 'pending' }
| { kind: 'streaming'; text: string }
| { kind: 'tool-calling'; toolName: string }
| { kind: 'completed'; text: string }
| { kind: 'aborted'; text: string }
| { kind: 'error'; message: string }使用 discriminated union 能让 UI 更明确地处理每种状态。
面试怎么问
问:流式输出如何避免整棵树频繁重渲染?
回答要点:
- 把流式状态隔离到独立对象或组件。
- 使用 selector 或外部 store。
- 只更新当前 assistant 消息。
- 对通知做合批,例如 microtask 或 animation frame。
问:流式输出时如何处理 Markdown?
回答要点:
- 半截 Markdown 不能直接当作完整文档解析。
- 可以按文本先显示,完整块再渲染。
- 或者使用增量 AST。
- 工具调用代码块需要特殊展示。
问:用户取消生成后应该怎么处理?
回答要点:
- 使用
AbortController取消请求。 - 标记当前消息为 aborted。
- 保留已生成文本。
- 提供继续或重新生成入口。
- 服务端也要处理取消和清理。
自测题
- assistant 消息有哪些典型状态?
AbortController在 AI 流式 UI 中的作用是什么?- 为什么每次 token 都更新整个会话列表会很差?
- 断线重连时,前端需要关心什么?
本章小结
前端 AI 产品最难的不是发送请求,而是把连续、可中断、可能带工具调用的流建模成稳定 UI 状态。
进一步阅读:
packages/client/ui-conversation/src/client/chat/docs/user/guide/index.md