基于 Claude Streaming API 的多轮对话组件设计:状态机与流式渲染那些事

基于 Claude Streaming API 的多轮对话组件设计:状态机与流式渲染那些事

上个月接了个需求:做一个内部 AI 助手,接 Claude API,要求多轮对话、流式输出、支持 Markdown 渲染。听起来不难对吧?fetch + ReadableStream + markdown-it,三件套一搭就完事了。

实际写下来,光是"用户连续发消息时上一条流还没断怎么办"这个问题,就改了三版。

这篇聊聊我在这个组件里踩过的坑,以及最后沉淀下来的一套状态机 + 流式渲染方案。

先看最核心的矛盾

Claude 的 Streaming API 基于 SSE(Server-Sent Events),服务端一个 token 一个 token 往外吐。前端要做的事情看似简单------接一段拼一段,渲染出来。

但多轮对话场景下,问题全挤在一起了:

  • 用户发了第二条消息,第一条的流还在跑
  • 网络断了,重连后状态怎么恢复
  • 流式拼接的半截 Markdown 怎么渲染(比如一个 ``````````` 只来了一半)
  • 用户手动点了"停止生成",清理工作谁来做

这些问题的根源是一样的:对话中每条消息的生命周期管理太粗糙 。大部分人(包括一开始的我)用一个 loading 布尔值就想搞定所有状态,后面必然翻车。

对话消息的状态机

一条 AI 回复消息,从创建到结束,实际上会经历这么几个阶段:

go 复制代码
idle → connecting → streaming → completed
                 ↘ error
       ↗ (retry)

用 TypeScript 描一下:

ts 复制代码
type MessageStatus =
  | 'idle'        // 占位,还没开始请求
  | 'connecting'  // 请求已发出,等第一个 chunk
  | 'streaming'   // 正在接收 token
  | 'completed'   // 流正常结束
  | 'error'       // 出错了
  | 'aborted'     // 用户手动停止

interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  status: MessageStatus
  abortController?: AbortController // 用来取消请求
}

关键在于:每条消息自己管自己的状态 ,而不是用一个全局的 isLoading 去控制整个对话。

这样做的好处立竿见影------用户连续发了两条消息,第一条的流可以正常走完(或者被 abort),第二条的流独立开始,互不干扰。

状态流转的实现

我用了一个 useReducer 来管理消息列表,比 useState + 一堆 setter 干净很多:

ts 复制代码
type Action =
  | { type: 'add_message'; message: Message }
  | { type: 'append_content'; id: string; chunk: string }
  | { type: 'set_status'; id: string; status: MessageStatus }

function chatReducer(state: Message[], action: Action): Message[] {
  switch (action.type) {
    case 'add_message':
      return [...state, action.message]

    case 'append_content':
      // 只改目标消息的 content,其他不动
      return state.map(msg =>
        msg.id === action.id
          ? { ...msg, content: msg.content + action.chunk }
          : msg
      )

    case 'set_status':
      return state.map(msg =>
        msg.id === action.id
          ? { ...msg, status: action.status }
          : msg
      )
  }
}

有人会说:每次 append_contentmap 整个数组,消息多了不卡吗?实测 200 条消息以内完全无感。真到了几百条的规模,上虚拟列表就行,瓶颈不在这。

流式请求这层

Claude 的 Messages API 开启 streaming 后,返回的是 SSE 格式。用 fetch 配合 ReadableStream 读就行,不需要引 EventSource 库:

ts 复制代码
async function streamChat(
  messages: { role: string; content: string }[],
  onChunk: (text: string) => void,
  onDone: () => void,
  onError: (err: Error) => void,
  signal?: AbortSignal
) {
  const resp = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 4096,
      stream: true,
      messages,
    }),
    signal, // 传入 AbortSignal,支持外部取消
  })

  const reader = resp.body!.getReader()
  const decoder = new TextDecoder()
  let buffer = '' // SSE 可能一次给半行,需要缓冲

  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: ')) continue
      const data = line.slice(6)
      if (data === '[DONE]') { onDone(); return }

      const event = JSON.parse(data)
      // content_block_delta 里才是真正的文本增量
      if (event.type === 'content_block_delta') {
        onChunk(event.delta?.text ?? '')
      }
    }
  }
  onDone()
}

这里有个细节:decoder.decode(value, { stream: true }) 这个 stream: true 不能丢。不加的话,多字节 UTF-8 字符(比如中文)被截断在两个 chunk 之间会乱码。之前排查过一次,用户输入中文偶现乱码,最后就是这一行的事。

"停止生成"的实现

有了 AbortController,停止生成就很直白:

ts 复制代码
function handleStop(messageId: string) {
  const msg = messages.find(m => m.id === messageId)
  msg?.abortController?.abort()
  dispatch({ type: 'set_status', id: messageId, status: 'aborted' })
}

fetch 收到 abort 信号后会抛 AbortError,在 onError 里判断一下 err.name === 'AbortError' 就行,不用当真错误处理。

流式 Markdown 渲染------真正头疼的部分

流式场景下渲染 Markdown 的难点在于:你拿到的永远是一份"没写完的文档"

比如 Claude 正在输出一段代码块:

r 复制代码
第一次:`\`\``ts\nconst a
第二次:`\`\``ts\nconst a = 1\nconsol
第三次:`\`\``ts\nconst a = 1\nconsole.log(a)\n`\`\``

前两次的 Markdown 都是不合法的------代码块没闭合。直接丢给 markdown-it 或者 react-markdown,渲染结果要么吞掉这段,要么整段当纯文本。

方案一:暴力补全(我最后用的)

思路很简单:在渲染前,检测未闭合的语法块,临时补上闭合标记。

ts 复制代码
function patchIncompleteMarkdown(raw: string): string {
  // 数代码块的栅栏数量,奇数说明没闭合
  const fenceCount = (raw.match(/^```/gm) || []).length
  let patched = raw
  if (fenceCount % 2 !== 0) {
    patched += '\n```'
  }

  // 粗体 / 斜体同理
  const boldCount = (raw.match(/\*\*/g) || []).length
  if (boldCount % 2 !== 0) {
    patched += '**'
  }

  return patched
}

粗暴但好用。唯一的问题是处理不了嵌套很深的复杂情况(比如列表里套代码块里套粗体),不过实际对话场景很少遇到。

方案二:增量解析

用一个 Markdown AST parser 做增量解析,每次只处理新增的 token。理论上性能最好,但实现成本太高了,而且主流 Markdown 库都不支持增量模式。除非你在做类似 Cursor 那种编辑器级别的产品,否则没必要。

渲染频率控制

Claude 吐 token 的速度很快,有时候一秒能来几十个 chunk。如果每个 chunk 都触发一次 setState → 重新渲染 → 重新解析 Markdown,页面会卡到没法看。

我的做法是用 requestAnimationFrame 做批处理:

ts 复制代码
function useThrottledContent(rawContent: string) {
  const [rendered, setRendered] = useState('')
  const bufferRef = useRef(rawContent)
  const rafRef = useRef<number>()

  useEffect(() => {
    bufferRef.current = rawContent

    if (!rafRef.current) {
      rafRef.current = requestAnimationFrame(() => {
        // 一帧只渲染一次,把积攒的内容一次性更新
        const patched = patchIncompleteMarkdown(bufferRef.current)
        setRendered(patched)
        rafRef.current = undefined
      })
    }

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current)
    }
  }, [rawContent])

  return rendered
}

这样不管一帧内来了多少个 chunk,Markdown 解析和 DOM 更新都只跑一次。实测从"肉眼可见的卡顿"变成了"丝滑",没什么代价。

还有一点:react-markdown 每次渲染都会重建整个组件树。消息少的时候无所谓,消息多了(尤其是长代码块),切换到 dangerouslySetInnerHTML + markdown-it 预编译会快不少。当然要做好 XSS 过滤,DOMPurify 跑一遍。

多轮对话的上下文组装

Claude API 要求每次请求带上完整的对话历史。这里有个取舍:到底传多少轮?

全传?token 数一多,响应变慢,费用也上去了。截断?截多了上下文丢失,AI 答非所问。

我的做法是滑动窗口 + token 估算

ts 复制代码
function buildContext(messages: Message[], maxTokens = 8000) {
  const result: { role: string; content: string }[] = []
  let tokenCount = 0

  // 从最新的消息往前取,直到快超限
  for (let i = messages.length - 1; i >= 0; i--) {
    const msg = messages[i]
    if (msg.status === 'aborted') continue // 被中断的消息不要带上去

    // 粗估:1 个中文字 ≈ 2 token,1 个英文单词 ≈ 1.3 token
    const estimated = msg.content.length * 1.5
    if (tokenCount + estimated > maxTokens) break

    tokenCount += estimated
    result.unshift({ role: msg.role, content: msg.content })
  }

  return result
}

token 估算不需要很精确,粗算留个余量就行。真要精确可以用 Claude 的 token counting API,但那又多一次网络请求,没必要。

有个容易忽略的点:被用户中断的消息(aborted)要不要带上去? 我的选择是不带。半截回答容易让模型产生困惑,还不如当它没存在过。不过这个也看场景,如果你的产品需要"继续生成"功能,那这条消息就得保留。

错误处理和重试

流式请求的错误分两类:

  1. 请求阶段失败 (网络不通、429 限流)------connectingerror
  2. 流中途断了 (连接超时、服务端异常)------streamingerror

第二种更烦,因为已经有部分内容了。我的处理方式是:保留已接收的内容,UI 上显示"生成中断,点击重试"。重试时不清空已有内容,而是从断点继续------虽然 Claude API 本身不支持断点续传,但可以把已有内容拼到 prompt 里让它接着说。

ts 复制代码
function handleRetry(messageId: string) {
  const msg = messages.find(m => m.id === messageId)
  if (!msg) return

  // 把已有的半截内容作为 assistant 消息的一部分传上去
  // 加一句提示让 Claude 接着往下写
  const context = buildContext(messages)
  context.push({
    role: 'assistant',
    content: msg.content, // 已有的部分
  })
  context.push({
    role: 'user',
    content: '请从断点处继续,不要重复已有内容。',
  })

  dispatch({ type: 'set_status', id: messageId, status: 'connecting' })
  // 重新发起流式请求,onChunk 里追加内容...
}

说实话这个方案不算完美,Claude 有时候还是会重复一部分内容。但比起重新生成整条回复,用户体验好很多。

组件结构

最后聊聊组件怎么拆。我的结构大概长这样:

arduino 复制代码
ChatContainer
├── MessageList          // 虚拟列表(消息多的时候)
│   ├── UserMessage      // 纯文本,没啥花头
│   └── AssistantMessage // 流式渲染 + 状态指示
│       ├── MarkdownRenderer  // Markdown → HTML
│       └── StatusIndicator   // connecting / streaming / error 各种状态的 UI
├── InputArea            // 输入框 + 发送按钮
└── StopButton           // 全局的"停止生成"

AssistantMessage 是最重的组件,也是唯一需要关心性能的地方。React.memo 加上,再把 MarkdownRenderer 单独 memo 一层(依赖 rendered 字符串而不是 rawContent),基本就够了。

有个小坑:MessageList 在流式输出的时候需要自动滚动到底部。但如果用户手动往上翻了,就不应该自动滚。这个逻辑看着简单,写起来一堆边界 case,我最后的判断条件是:

ts 复制代码
// 用户是否"基本在底部":距离底部不超过 80px 就算
const isNearBottom =
  container.scrollHeight - container.scrollTop - container.clientHeight < 80

距离取 80px 而不是 0,是因为流式渲染每帧都在改变 scrollHeight,严格等于 0 的话会一直在"该不该滚"之间反复横跳。

聊到这

整个方案的核心就两件事:给每条消息一个清晰的状态机流式渲染做好补全和节流

状态机把并发、取消、重试这些脏活都收拢到了状态流转里,不至于在各种 if-else 里迷失。流式渲染那边,暴力补全 + rAF 批处理的组合虽然不优雅,但在生产环境里足够稳。

有一个我还在纠结的点:消息列表的状态到底该放在组件里(useReducer),还是抽到外面(zustand / jotai)?目前这个组件是独立的还好,如果后面要加"对话列表切换"、"历史记录持久化"这些功能,状态管理迟早得搬出去。但过早抽象是万恶之源,先这样吧。

相关推荐
juejin_cn3 小时前
[转][译] 从零开始构建 OpenClaw — 第六部分(持久化记忆)
javascript
juejin_cn3 小时前
[转][译] 从零开始构建 OpenClaw — 第七部分(子智能体系统)
javascript
an317425 小时前
解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南
前端·javascript·vue.js
Lee川7 小时前
🚀《JavaScript 灵魂深处:从 V8 引擎的“双轨并行”看执行上下文的演进之路》
javascript·面试
比特鹰7 小时前
手把手带你用Flutter手搓人生K线
前端·javascript·flutter
大雨还洅下7 小时前
前端JS: 数组扁平化
javascript
奔跑路上的Me7 小时前
前端导出 Word/Excel/PDF 文件
前端·javascript
bluceli7 小时前
JavaScript异步编程深度解析:从回调到Async Await的演进之路
前端·javascript
SuperEugene7 小时前
路由与布局骨架篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用
前端·javascript·vue.js