基于 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_content 都 map 整个数组,消息多了不卡吗?实测 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)要不要带上去? 我的选择是不带。半截回答容易让模型产生困惑,还不如当它没存在过。不过这个也看场景,如果你的产品需要"继续生成"功能,那这条消息就得保留。
错误处理和重试
流式请求的错误分两类:
- 请求阶段失败 (网络不通、429 限流)------
connecting→error - 流中途断了 (连接超时、服务端异常)------
streaming→error
第二种更烦,因为已经有部分内容了。我的处理方式是:保留已接收的内容,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)?目前这个组件是独立的还好,如果后面要加"对话列表切换"、"历史记录持久化"这些功能,状态管理迟早得搬出去。但过早抽象是万恶之源,先这样吧。