如何实现逐字输出?这个问题面试也经常被问到
AI Assistant 的回复其实已经通过接口返回了,但在前端看起来还是像整段内容一次性出现。对于聊天产品来说,这种反馈会显得有点生硬,用户更熟悉的是一句话正在被慢慢写出来的感觉。这种效果是怎么实现的呢?
我们具体的实现没有只押在后端 streaming 上,而是把方案拆成了两层。后端尽量输出真正的文本流,让模型内容尽早到达浏览器;前端再增加一层 typewriter 显示状态,即使某些环境把响应缓冲成一整段,页面上仍然可以按照本地节奏逐字展示。
后端可以用上模型接口的 streaming 能力,但模型服务、本地开发服务、Worker、代理层和浏览器都有可能影响实际 chunk 到达的粒度。前端多做一层显示控制之后,视觉体验就不会完全依赖链路里每一层都按预期流式透传。
最开始的 API 使用了 LangChain 的 model.stream(),然后把每个 chunk 写入 text/plain 响应:
1const stream = await model.stream(messages)23for await (const chunk of stream) {4controller.enqueue(encoder.encode(content))5}
前端这边使用的是 AI SDK 的 TextStreamChatTransport:
1const transport = useMemo(2() => new TextStreamChatTransport<UIMessage>({3api: `${getWebClientEnv().NEXT_PUBLIC_API_BASE_URL}/rpc/chat/inbox`,4body: {5conversation,6},7}),8[conversation],9)
从代码上看,这条链路已经是在做流式输出了。但实际跑起来以后,AI 回复仍然可能一下子显示出来。这里我们可以把原因梳理得更完整一点。
模型服务端返回的 chunk 粒度不一定很细,有时它本身就会攒一小段再吐出来。LangChain 这一层也可能不会按前端期待的小 token 粒度透出。再往外看,本地开发服务、Worker、代理或者浏览器都有可能缓冲响应,导致前端比较晚才拿到数据。即使网络层确实收到了流,AI SDK 写入 messages 的时候,也可能一次性更新一段较长的文本。
所以,后端已经 stream,并不等于用户一定能看到视觉逐字输出。这两个概念要分开看:一个是网络传输层的流式,一个是界面展示层的逐字。
后端这次改成直接调用 DeepSeek/OpenAI 兼容的 /chat/completions 接口,并开启 stream: true。
上游接口返回的是 SSE,每一行大致是这样的格式:
1data: {"choices":[{"delta":{"content":"你"}}]}2data: {"choices":[{"delta":{"content":"好"}}]}3data: [DONE]
我们要做的事情并不复杂:读出每个 delta.content,然后立刻写入自己的 ReadableStream。这样前端拿到的就不是完整 JSON,也不是 SSE 原始数据,而是更适合 TextStreamChatTransport 消费的纯文本流。
先看请求 DeepSeek 的部分:
01const upstream = await fetch(`${baseURL.replace(/\/$/, "")}/chat/completions`, {02method: "POST",03headers: {04authorization: `Bearer ${env.DEEPSEEK_API_KEY}`,05"content-type": "application/json",06},07body: JSON.stringify({08model: env.DEEPSEEK_MODEL ?? "deepseek-chat",09messages,10stream: true,11}),12signal: c.req.raw.signal,13})
拿到上游响应以后,再把 SSE 转成纯文本流:
01const textStream = new ReadableStream<Uint8Array>({02async start(controller) {03const encoder = new TextEncoder()04const decoder = new TextDecoder()05const reader = upstream.body?.getReader()06let buffer = ""07let closed = false0809if (!reader) {10controller.close()11return12}1314try {15while (true) {16const { done, value } = await reader.read()1718if (done || closed) {19break20}2122buffer += decoder.decode(value, { stream: true })2324const lines = buffer.split("\n")25buffer = lines.pop() ?? ""2627for (const line of lines) {28const trimmed = line.trim()2930if (!trimmed.startsWith("data:")) {31continue32}3334const data = trimmed.slice(5).trim()3536if (!data) {37continue38}3940if (data === "[DONE]") {41closed = true42controller.close()43break44}4546const parsed = JSON.parse(data) as {47choices?: Array<{ delta?: { content?: unknown } }>48}49const content = parsed.choices?.[0]?.delta?.content5051if (typeof content === "string" && content) {52controller.enqueue(encoder.encode(content))53}54}5556if (closed) {57break58}59}6061if (!closed) {62controller.close()63}64} catch (error) {65controller.error(error)66}67},68})
这里有两个细节值得注意。第一,buffer 不能省,因为上游返回的二进制块不一定刚好按行切开;我们需要把最后一截不完整的行留下来,等下一次读取后再继续解析。第二,遇到 [DONE] 后要主动关闭 controller,这样前端能明确知道这次回复已经结束。
响应头也要配合一下,尽量告诉中间层不要缓存这段流:
1return new Response(textStream, {2headers: {3"content-type": "text/plain; charset=utf-8",4"cache-control": "no-cache",5"x-accel-buffering": "no",6},7})
做到这里,后端的目标就比较清晰了:尽可能早地把模型文本片段写出去,让前端有机会更快开始展示。
只做后端流式仍然不够保险,所以前端又补了一层显示状态。
真实消息仍然保存在 AI SDK 的 messages 中,这部分不要破坏。我们额外维护一个 visibleAssistantTextById,渲染时不直接展示完整的 assistant 文本,而是展示一个逐步增长的 visibleMessageText。这样即使 messages 里某条回复一下子变成了完整内容,页面也会按照 typewriter 的节奏慢慢显示。
1const TYPEWRITER_INTERVAL_MS = 182const TYPEWRITER_CHARS_PER_STEP = 1
这里设置成每 18ms 显示 1 个字符。中文阅读里,这个速度会比较接近正在回复的观感。如果后面发现长文本显得太慢,也可以再把步长调大。
这里不要直接用 text.slice(0, length)。普通字符串切片在大多数中文场景里能跑,但遇到 emoji 或一些 Unicode 字符时,可能会把一个完整字符切坏。
所以我们用 Array.from 先按字符拆开,再做长度计算和切片:
1function getTextLength(text: string) {2return Array.from(text).length3}45function sliceText(text: string, length: number) {6return Array.from(text).slice(0, length).join("")7}
这里要处理的是模型生成出来的 assistant 回复。我们可以从 messages 里提取每条 assistant 消息的完整文本,再交给后面的显示状态一点点推进。
01const [visibleAssistantTextById, setVisibleAssistantTextById] =02useState<Record<string, string>>({})0304const assistantFullTextById = useMemo(() => {05const textById: Record<string, string> = {}0607for (const message of messages) {08if (message.role !== "assistant") {09continue10}1112const text = getMessageText(message)1314if (text) {15textById[message.id] = text16}17}1819return textById20}, [assistantTextSignature, messages])
这里会得到一个以消息 id 为 key 的完整文本映射。后面的显示状态,就是围绕这份完整文本一点点推进。
当新的 assistant 消息出现时,我们不马上把完整内容放进可见文本里,而是先展示第一个字符。这样就算 AI SDK 已经一次性给了完整内容,用户看到的也仍然是逐字开始。
01useEffect(() => {02setVisibleAssistantTextById((current) => {03const next: Record<string, string> = {}04let changed = false0506for (const [id, fullText] of Object.entries(assistantFullTextById)) {07const visibleText = current[id]0809if (visibleText === undefined || !fullText.startsWith(visibleText)) {10next[id] = sliceText(fullText, TYPEWRITER_CHARS_PER_STEP)11changed = true12continue13}1415next[id] = visibleText16}1718if (Object.keys(current).length !== Object.keys(next).length) {19changed = true20}2122return changed ? next : current23})24}, [assistantFullTextById])
这里的 !fullText.startsWith(visibleText) 是一个兜底判断。比如某条消息内容被替换、重试或者重新生成,旧的可见文本已经不是新完整文本的前缀,就需要从头重新开始显示。
只要还有没有展示完的字符,就用 setTimeout 往前推进一格。
01const hasTypewriterWork = Object.entries(assistantFullTextById).some(([id, fullText]) => {02const visibleText = visibleAssistantTextById[id] ?? ""0304return getTextLength(visibleText) < getTextLength(fullText)05})0607useEffect(() => {08if (!hasTypewriterWork) {09return10}1112const timer = window.setTimeout(() => {13setVisibleAssistantTextById((current) => {14let changed = false15const next = { ...current }1617for (const [id, fullText] of Object.entries(assistantFullTextById)) {18const visibleText = current[id] ?? ""19const visibleLength = getTextLength(visibleText)20const fullLength = getTextLength(fullText)2122if (visibleLength >= fullLength) {23continue24}2526next[id] = sliceText(fullText, visibleLength + TYPEWRITER_CHARS_PER_STEP)27changed = true28}2930return changed ? next : current31})32}, TYPEWRITER_INTERVAL_MS)3334return () => window.clearTimeout(timer)35}, [assistantFullTextById, hasTypewriterWork, visibleAssistantTextById])
这段逻辑的关键是让完整文本和可见文本分开。完整文本负责记录真实结果,可见文本只负责页面展示。两者分开以后,渲染体验就有了可以调节的空间。
渲染消息时,用户消息照常展示完整内容;AI 新回复则优先展示 visibleAssistantTextById 里的可见文本。
01const visibleMessageText =02!isUser03? visibleAssistantTextById[message.id] ?? sliceText(messageText, TYPEWRITER_CHARS_PER_STEP)04: messageText0506return (07<MessageResponse>08{visibleMessageText}09</MessageResponse>10)
这一步接上以后,即使 messageText 已经是完整回复,UI 也不会立刻把整段内容摆出来,而是按照本地节奏慢慢显示。
在模型还没有开始输出正文之前,页面需要有一个普通的 loading 气泡。否则用户点完发送以后,中间会有一小段空白等待,体验上会像是按钮没有反应。
01function TypingBubble() {02return (03<div className="flex w-full items-start gap-3">04<span className="mt-6 flex size-9 shrink-0 items-center justify-center rounded-full border border-violet-200 bg-violet-50 text-violet-700">05<Bot className="size-4" />06</span>07<div className="flex min-w-0 max-w-[min(34rem,82%)] flex-col gap-1.5">08<div className="flex items-center gap-2 text-xs font-medium text-violet-700">09<Sparkles className="size-3.5" />10<span>AI Assistant</span>11</div>12<div className="rounded-xl rounded-tl-sm border border-slate-200 bg-white px-4 py-3 text-sm leading-6 text-slate-800">13<div className="flex items-center gap-2.5">14<span className="text-slate-500">正在回复</span>15<div className="flex items-center gap-1">16<span className="size-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.2s]" />17<span className="size-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.1s]" />18<span className="size-1.5 animate-bounce rounded-full bg-slate-400" />19</div>20</div>21</div>22</div>23</div>24)25}
显示条件可以这样判断:请求已经提交时显示;正在 streaming 但最新消息还不是 assistant 时显示;如果最新消息已经是 assistant,但还没有实际文本,也继续显示。
1const shouldShowTypingBubble =2status === "submitted" ||3(status === "streaming" && latestMessage?.role !== "assistant") ||4(status === "streaming" && latestMessage?.role === "assistant" && !latestAssistantText)
另外,AI SDK 有时会先插入一条空的 assistant 消息。如果这条空消息也渲染出来,页面上就会同时出现 loading 气泡和一条空的 AI Assistant。所以渲染消息时要把空 assistant 过滤掉:
1if (!isUser && !messageText.trim()) {2return null3}
这套方案的收益主要体现在稳定性上。后端仍然是真实的流式输出,能早到达的 token 或文本片段会尽早写给前端;前端也不会被动等待链路中每一层都完美透传,而是自己控制最终展示出来的节奏。
它还有一个很实际的好处:不会依赖模型服务一定按 token 返回。哪怕某次返回的 chunk 比较大,或者某个代理层把响应攒了一下,用户看到的仍然是逐字展开的回复。配合 Array.from 之后,中文、emoji 这类 Unicode 字符也不容易被切坏。
loading 气泡和真实回复之间也做了过滤,不会重复出现两条 AI Assistant。这些细节单独看都不大,但合在一起,聊天区会自然很多。
Web 端 AI 逐字输出,不能只看接口有没有 stream。更稳妥的做法,是把后端传输和前端显示拆开处理。
后端负责尽早把模型 token 或文本片段写出来,前端负责把最终可见内容按照合适的节奏展示出来。这样即使链路中某一层发生缓冲,用户看到的仍然是符合聊天产品预期的逐字输出体验。