创建时间: 2026-06-06最后更新: 2026-06-06

概述

如何实现逐字输出?这个问题面试也经常被问到

AI Assistant 的回复其实已经通过接口返回了,但在前端看起来还是像整段内容一次性出现。对于聊天产品来说,这种反馈会显得有点生硬,用户更熟悉的是一句话正在被慢慢写出来的感觉。这种效果是怎么实现的呢?

我们具体的实现没有只押在后端 streaming 上,而是把方案拆成了两层。后端尽量输出真正的文本流,让模型内容尽早到达浏览器;前端再增加一层 typewriter 显示状态,即使某些环境把响应缓冲成一整段,页面上仍然可以按照本地节奏逐字展示。

后端可以用上模型接口的 streaming 能力,但模型服务、本地开发服务、Worker、代理层和浏览器都有可能影响实际 chunk 到达的粒度。前端多做一层显示控制之后,视觉体验就不会完全依赖链路里每一层都按预期流式透传。

问题背景

最开始的 API 使用了 LangChain 的 model.stream(),然后把每个 chunk 写入 text/plain 响应:

code.ts
1
const stream = await model.stream(messages)
2
3
for await (const chunk of stream) {
4
controller.enqueue(encoder.encode(content))
5
}

前端这边使用的是 AI SDK 的 TextStreamChatTransport

code.ts
1
const transport = useMemo(
2
() => new TextStreamChatTransport<UIMessage>({
3
api: `${getWebClientEnv().NEXT_PUBLIC_API_BASE_URL}/rpc/chat/inbox`,
4
body: {
5
conversation,
6
},
7
}),
8
[conversation],
9
)

从代码上看,这条链路已经是在做流式输出了。但实际跑起来以后,AI 回复仍然可能一下子显示出来。这里我们可以把原因梳理得更完整一点。

模型服务端返回的 chunk 粒度不一定很细,有时它本身就会攒一小段再吐出来。LangChain 这一层也可能不会按前端期待的小 token 粒度透出。再往外看,本地开发服务、Worker、代理或者浏览器都有可能缓冲响应,导致前端比较晚才拿到数据。即使网络层确实收到了流,AI SDK 写入 messages 的时候,也可能一次性更新一段较长的文本。

所以,后端已经 stream,并不等于用户一定能看到视觉逐字输出。这两个概念要分开看:一个是网络传输层的流式,一个是界面展示层的逐字。

后端处理 SSE

后端这次改成直接调用 DeepSeek/OpenAI 兼容的 /chat/completions 接口,并开启 stream: true

上游接口返回的是 SSE,每一行大致是这样的格式:

code.ts
1
data: {"choices":[{"delta":{"content":"你"}}]}
2
data: {"choices":[{"delta":{"content":"好"}}]}
3
data: [DONE]

我们要做的事情并不复杂:读出每个 delta.content,然后立刻写入自己的 ReadableStream。这样前端拿到的就不是完整 JSON,也不是 SSE 原始数据,而是更适合 TextStreamChatTransport 消费的纯文本流。

先看请求 DeepSeek 的部分:

code.ts
01
const upstream = await fetch(`${baseURL.replace(/\/$/, "")}/chat/completions`, {
02
method: "POST",
03
headers: {
04
authorization: `Bearer ${env.DEEPSEEK_API_KEY}`,
05
"content-type": "application/json",
06
},
07
body: JSON.stringify({
08
model: env.DEEPSEEK_MODEL ?? "deepseek-chat",
09
messages,
10
stream: true,
11
}),
12
signal: c.req.raw.signal,
13
})

拿到上游响应以后,再把 SSE 转成纯文本流:

code.ts
01
const textStream = new ReadableStream<Uint8Array>({
02
async start(controller) {
03
const encoder = new TextEncoder()
04
const decoder = new TextDecoder()
05
const reader = upstream.body?.getReader()
06
let buffer = ""
07
let closed = false
08
09
if (!reader) {
10
controller.close()
11
return
12
}
13
14
try {
15
while (true) {
16
const { done, value } = await reader.read()
17
18
if (done || closed) {
19
break
20
}
21
22
buffer += decoder.decode(value, { stream: true })
23
24
const lines = buffer.split("\n")
25
buffer = lines.pop() ?? ""
26
27
for (const line of lines) {
28
const trimmed = line.trim()
29
30
if (!trimmed.startsWith("data:")) {
31
continue
32
}
33
34
const data = trimmed.slice(5).trim()
35
36
if (!data) {
37
continue
38
}
39
40
if (data === "[DONE]") {
41
closed = true
42
controller.close()
43
break
44
}
45
46
const parsed = JSON.parse(data) as {
47
choices?: Array<{ delta?: { content?: unknown } }>
48
}
49
const content = parsed.choices?.[0]?.delta?.content
50
51
if (typeof content === "string" && content) {
52
controller.enqueue(encoder.encode(content))
53
}
54
}
55
56
if (closed) {
57
break
58
}
59
}
60
61
if (!closed) {
62
controller.close()
63
}
64
} catch (error) {
65
controller.error(error)
66
}
67
},
68
})

这里有两个细节值得注意。第一,buffer 不能省,因为上游返回的二进制块不一定刚好按行切开;我们需要把最后一截不完整的行留下来,等下一次读取后再继续解析。第二,遇到 [DONE] 后要主动关闭 controller,这样前端能明确知道这次回复已经结束。

响应头也要配合一下,尽量告诉中间层不要缓存这段流:

code.ts
1
return new Response(textStream, {
2
headers: {
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 的节奏慢慢显示。

1. 逐字输出配置

code.ts
1
const TYPEWRITER_INTERVAL_MS = 18
2
const TYPEWRITER_CHARS_PER_STEP = 1

这里设置成每 18ms 显示 1 个字符。中文阅读里,这个速度会比较接近正在回复的观感。如果后面发现长文本显得太慢,也可以再把步长调大。

2. 字符处理

这里不要直接用 text.slice(0, length)。普通字符串切片在大多数中文场景里能跑,但遇到 emoji 或一些 Unicode 字符时,可能会把一个完整字符切坏。

所以我们用 Array.from 先按字符拆开,再做长度计算和切片:

code.ts
1
function getTextLength(text: string) {
2
return Array.from(text).length
3
}
4
5
function sliceText(text: string, length: number) {
6
return Array.from(text).slice(0, length).join("")
7
}

3. 提取 assistant 文本

这里要处理的是模型生成出来的 assistant 回复。我们可以从 messages 里提取每条 assistant 消息的完整文本,再交给后面的显示状态一点点推进。

code.ts
01
const [visibleAssistantTextById, setVisibleAssistantTextById] =
02
useState<Record<string, string>>({})
03
04
const assistantFullTextById = useMemo(() => {
05
const textById: Record<string, string> = {}
06
07
for (const message of messages) {
08
if (message.role !== "assistant") {
09
continue
10
}
11
12
const text = getMessageText(message)
13
14
if (text) {
15
textById[message.id] = text
16
}
17
}
18
19
return textById
20
}, [assistantTextSignature, messages])

这里会得到一个以消息 id 为 key 的完整文本映射。后面的显示状态,就是围绕这份完整文本一点点推进。

4. 初始化新消息

当新的 assistant 消息出现时,我们不马上把完整内容放进可见文本里,而是先展示第一个字符。这样就算 AI SDK 已经一次性给了完整内容,用户看到的也仍然是逐字开始。

code.ts
01
useEffect(() => {
02
setVisibleAssistantTextById((current) => {
03
const next: Record<string, string> = {}
04
let changed = false
05
06
for (const [id, fullText] of Object.entries(assistantFullTextById)) {
07
const visibleText = current[id]
08
09
if (visibleText === undefined || !fullText.startsWith(visibleText)) {
10
next[id] = sliceText(fullText, TYPEWRITER_CHARS_PER_STEP)
11
changed = true
12
continue
13
}
14
15
next[id] = visibleText
16
}
17
18
if (Object.keys(current).length !== Object.keys(next).length) {
19
changed = true
20
}
21
22
return changed ? next : current
23
})
24
}, [assistantFullTextById])

这里的 !fullText.startsWith(visibleText) 是一个兜底判断。比如某条消息内容被替换、重试或者重新生成,旧的可见文本已经不是新完整文本的前缀,就需要从头重新开始显示。

5. 推进可见字符

只要还有没有展示完的字符,就用 setTimeout 往前推进一格。

code.ts
01
const hasTypewriterWork = Object.entries(assistantFullTextById).some(([id, fullText]) => {
02
const visibleText = visibleAssistantTextById[id] ?? ""
03
04
return getTextLength(visibleText) < getTextLength(fullText)
05
})
06
07
useEffect(() => {
08
if (!hasTypewriterWork) {
09
return
10
}
11
12
const timer = window.setTimeout(() => {
13
setVisibleAssistantTextById((current) => {
14
let changed = false
15
const next = { ...current }
16
17
for (const [id, fullText] of Object.entries(assistantFullTextById)) {
18
const visibleText = current[id] ?? ""
19
const visibleLength = getTextLength(visibleText)
20
const fullLength = getTextLength(fullText)
21
22
if (visibleLength >= fullLength) {
23
continue
24
}
25
26
next[id] = sliceText(fullText, visibleLength + TYPEWRITER_CHARS_PER_STEP)
27
changed = true
28
}
29
30
return changed ? next : current
31
})
32
}, TYPEWRITER_INTERVAL_MS)
33
34
return () => window.clearTimeout(timer)
35
}, [assistantFullTextById, hasTypewriterWork, visibleAssistantTextById])

这段逻辑的关键是让完整文本和可见文本分开。完整文本负责记录真实结果,可见文本只负责页面展示。两者分开以后,渲染体验就有了可以调节的空间。

6. 渲染可见文本

渲染消息时,用户消息照常展示完整内容;AI 新回复则优先展示 visibleAssistantTextById 里的可见文本。

code.ts
01
const visibleMessageText =
02
!isUser
03
? visibleAssistantTextById[message.id] ?? sliceText(messageText, TYPEWRITER_CHARS_PER_STEP)
04
: messageText
05
06
return (
07
<MessageResponse>
08
{visibleMessageText}
09
</MessageResponse>
10
)

这一步接上以后,即使 messageText 已经是完整回复,UI 也不会立刻把整段内容摆出来,而是按照本地节奏慢慢显示。

Loading 配合

在模型还没有开始输出正文之前,页面需要有一个普通的 loading 气泡。否则用户点完发送以后,中间会有一小段空白等待,体验上会像是按钮没有反应。

code.ts
01
function TypingBubble() {
02
return (
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,但还没有实际文本,也继续显示。

code.ts
1
const shouldShowTypingBubble =
2
status === "submitted" ||
3
(status === "streaming" && latestMessage?.role !== "assistant") ||
4
(status === "streaming" && latestMessage?.role === "assistant" && !latestAssistantText)

另外,AI SDK 有时会先插入一条空的 assistant 消息。如果这条空消息也渲染出来,页面上就会同时出现 loading 气泡和一条空的 AI Assistant。所以渲染消息时要把空 assistant 过滤掉:

code.ts
1
if (!isUser && !messageText.trim()) {
2
return null
3
}

方案收益

这套方案的收益主要体现在稳定性上。后端仍然是真实的流式输出,能早到达的 token 或文本片段会尽早写给前端;前端也不会被动等待链路中每一层都完美透传,而是自己控制最终展示出来的节奏。

它还有一个很实际的好处:不会依赖模型服务一定按 token 返回。哪怕某次返回的 chunk 比较大,或者某个代理层把响应攒了一下,用户看到的仍然是逐字展开的回复。配合 Array.from 之后,中文、emoji 这类 Unicode 字符也不容易被切坏。

loading 气泡和真实回复之间也做了过滤,不会重复出现两条 AI Assistant。这些细节单独看都不大,但合在一起,聊天区会自然很多。

总结

Web 端 AI 逐字输出,不能只看接口有没有 stream。更稳妥的做法,是把后端传输和前端显示拆开处理。

后端负责尽早把模型 token 或文本片段写出来,前端负责把最终可见内容按照合适的节奏展示出来。这样即使链路中某一层发生缓冲,用户看到的仍然是符合聊天产品预期的逐字输出体验。