Skip to content
签到签到

第 8 章:前端流式 UI

本章解决的问题

前端 AI 面试的核心难点不是调用模型,而是处理流式输出、工具中间状态、取消和重试。这一章给出实现思路。

交互图解:Agent 流式 UI 生命周期 · 查看图解导读

大白话理解

AI 对话 UI 不是一个普通表单:

  • 文本会连续到达。
  • Markdown 会在半截时出现。
  • 模型可能突然要求调工具。
  • 用户可能随时取消。
  • 网络可能中断。

因此,UI 需要状态机,而不是简单的“loading -> success -> error”。

必须掌握的概念

1. 消息状态

一条 assistant 消息至少可能有:

  • pending
  • streaming
  • tool-calling
  • completed
  • error
  • aborted

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.md
  • packages/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。
  • 保留已生成文本。
  • 提供继续或重新生成入口。
  • 服务端也要处理取消和清理。

自测题

  1. assistant 消息有哪些典型状态?
  2. AbortController 在 AI 流式 UI 中的作用是什么?
  3. 为什么每次 token 都更新整个会话列表会很差?
  4. 断线重连时,前端需要关心什么?

本章小结

前端 AI 产品最难的不是发送请求,而是把连续、可中断、可能带工具调用的流建模成稳定 UI 状态。

进一步阅读:

  • packages/client/ui-conversation/src/client/chat/
  • docs/user/guide/index.md