AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. 普通请求的问题

我们之前写的所有接口,都遵循同一个模式:前端发请求 → 后端处理 → 后端把结果一次性返回。

这个模式在大多数场景下没问题。查一个用户信息,后端几十毫秒就查完了,一次性返回一个 JSON,前端拿到直接渲染。

但有些场景不适用。最典型的就是 AI 大模型。

你在 ChatGPT 上输入一个问题,回答不是"啪"一下全部出现的,而是一个字一个字地往外蹦,像打字一样。这背后的原因是:大模型生成文本是逐 token 进行的(token 大致可以理解为一个字或词),一段 500 字的回复可能要花 5-10 秒才能全部生成完。

如果等全部生成完再一次性返回,用户就得盯着空白页面干等好几秒,体验很差。更好的做法是:生成一个 token 就发一个 token,让用户立刻看到内容逐步出现。

这就是"流式响应"——服务端不是攒够了所有数据再发,而是边生成边发,数据像水流一样持续地从服务端流向客户端。

除了 AI 对话,还有一些场景也需要流式响应:

  • 大文件下载(不可能把几百 MB 的文件全部加载到内存再发)
  • 实时数据推送(股票行情、日志监控、通知提醒)
  • 长时间运行的任务汇报进度

2. Hono 的三种流式 API

Hono 提供了三种流式响应方式,适用于不同场景:

API用途Content-Type
streamText文本流(逐段发送文本)text/plain
stream二进制流(文件下载、转发)自定义
streamSSEServer-Sent Events(结构化事件流)text/event-stream

我们从最简单的 streamText 开始,逐步讲到最常用的 streamSSE

3. streamText:最简单的文本流

streamText 用于逐段发送纯文本。先看后端怎么写:

src/index.ts
01
import { Hono } from 'hono'
02
import { streamText } from 'hono/streaming'
03
04
const app = new Hono()
05
06
app.get('/stream', (c) => {
07
return streamText(c, async (stream) => {
08
for (const chunk of ['Hello', ' ', 'World', '!']) {
09
await stream.write(chunk)
10
await stream.sleep(100)
11
}
12
})
13
})
14
15
export default app

跟普通接口用 return c.json(...) 一次性返回不同,这里用 streamText 开启了一个流式响应。回调函数里的 stream 对象就是你的"出水口":

  • stream.write('Hello') —— 立刻把 'Hello' 发给客户端,不等后面的内容
  • stream.sleep(100) —— 等 100 毫秒再继续(模拟逐步生成的效果)

所以客户端收到的数据是分四次到达的:先收到 Hello,100ms 后收到一个空格,再 100ms 后收到 World,最后收到 !

前端怎么消费流

普通接口用 await res.json() 就能拿到数据,但流式响应不行——因为数据是分批到达的,没办法一次性解析。

前端需要用 ReadableStream 来逐块读取。你可以把它想象成一个水龙头:你不知道总共有多少水,只能一直接,直到水龙头关了。

client.ts
01
async function consumeStream() {
02
const response = await fetch('/stream')
03
04
// 1. 从 response.body 获取一个 reader(读取器)
05
const reader = response.body!.getReader()
06
07
// 2. TextDecoder 用于把二进制数据转成字符串
08
const decoder = new TextDecoder()
09
10
// 3. 循环读取,每次拿到一块数据
11
while (true) {
12
const { done, value } = await reader.read()
13
14
// done 为 true 表示流结束了(水龙头关了)
15
if (done) break
16
17
// value 是 Uint8Array(二进制),需要解码成字符串
18
const text = decoder.decode(value, { stream: true })
19
20
// 把拿到的文本追加到页面上
21
document.getElementById('output')!.textContent += text
22
}
23
}

这个模式第一次见会觉得陌生,但核心就三步:拿 reader → 循环 read → 处理每一块数据。后面的 SSE 也是类似的套路。

4. stream:二进制流

stream 是更底层的 API,适合文件下载、大文件转发等场景。

src/index.ts
1
import { stream } from 'hono/streaming'
2
3
app.get('/download', (c) => {
4
return stream(c, async (stream) => {
5
// 从远程拉取大文件,直接转发给客户端
6
const res = await fetch('https://example.com/large-file')
7
await stream.pipe(res.body!)
8
})
9
})

stream.pipe() 把一个 ReadableStream 的数据直接转发到响应流。文件不需要先全部下载到内存,边收边发,内存占用很低。

你也可以手动设置响应头:

src/index.ts
1
app.get('/download', (c) => {
2
c.header('Content-Type', 'application/octet-stream')
3
c.header('Content-Disposition', 'attachment; filename="data.zip"')
4
5
return stream(c, async (stream) => {
6
const res = await fetch('https://example.com/data.zip')
7
await stream.pipe(res.body!)
8
})
9
})

5. SSE:Server-Sent Events

前面的 streamText 能逐段发送文本,但它只是"一坨一坨"地发数据,客户端收到的就是原始文本片段,没有任何结构。

实际应用中,你经常需要区分不同类型的消息。比如一个 AI 聊天接口,流式返回的内容可能包括:正文文本、思考过程、工具调用结果、结束信号。前端需要知道每一块数据"是什么类型的",才能分别处理。

SSE(Server-Sent Events)就是为这种场景设计的。它是 HTTP 协议原生支持的结构化事件流

SSE 的数据长什么样

SSE 不是什么新协议,它就是普通的 HTTP 响应,只不过 Content-Type 设为 text/event-stream,响应体按照特定的文本格式组织。

每一条消息(事件)长这样:

SSE
01
event: message
02
id: 1
03
data: 你好
04
05
event: message
06
id: 2
07
data: 世界
08
09
event: done
10
id: 3
11
data: [DONE]

格式规则很简单:

  • 每个字段占一行,格式是 字段名: 值
  • data 是消息内容(必填)
  • event 是事件类型(可选,前端用它区分不同类型的消息)
  • id 是消息编号(可选,用于断线重连)
  • 消息之间用一个空行分隔

这就是 SSE 的全部协议。没有二进制编码,没有握手过程,就是纯文本。

SSE vs WebSocket

你可能听说过 WebSocket,它也能做服务端推送。两者的区别:

SSEWebSocket
方向单向(服务端 → 客户端)双向
协议普通 HTTP独立的 ws:// 协议
自动重连浏览器原生支持需要自己实现
复杂度非常简单相对复杂
适用场景AI 流式输出、通知推送、实时数据展示聊天室、协同编辑、游戏

如果你只需要服务端往客户端推数据(比如 AI 回答),SSE 就够了,简单得多。AI 大模型的流式接口(OpenAI、Claude 等)几乎全部用的 SSE 格式。

后端发送 SSE

Hono 用 streamSSE 发送 SSE 事件:

src/index.ts
01
import { streamSSE } from 'hono/streaming'
02
03
app.get('/sse', (c) => {
04
return streamSSE(c, async (stream) => {
05
let id = 0
06
while (true) {
07
await stream.writeSSE({
08
data: JSON.stringify({ time: Date.now() }),
09
event: 'tick',
10
id: String(id++),
11
})
12
await stream.sleep(1000)
13
}
14
})
15
})

writeSSE 会自动把你传入的对象格式化成 SSE 文本格式。上面每次调用实际发出去的内容就是:

code.ts
1
event: tick
2
id: 0
3
data: {"time":1714000000000}
4

前端消费 SSE

浏览器原生提供了 EventSource API,专门用来接收 SSE:

client.ts
01
// 创建 SSE 连接(和 fetch 不同,EventSource 会自动保持连接)
02
const source = new EventSource('/sse')
03
04
// 监听 'tick' 类型的事件(对应后端 writeSSE 里的 event 字段)
05
source.addEventListener('tick', (e) => {
06
const data = JSON.parse(e.data)
07
console.log('服务端时间:', data.time)
08
})
09
10
// 连接建立时触发
11
source.addEventListener('open', () => {
12
console.log('SSE 连接已建立')
13
})
14
15
// 连接出错时触发(浏览器会自动尝试重连,不需要你处理)
16
source.addEventListener('error', (e) => {
17
console.log('SSE 连接出错,浏览器将自动重连')
18
})
19
20
// 不想接收了,手动关闭
21
// source.close()

EventSource 的好处:

  • 自动重连:网络断了浏览器会自动重连,不用你写重试逻辑
  • 事件分类:后端发 event: tick,前端就用 addEventListener('tick', ...) 接收,不同类型的消息互不干扰

EventSource 有一个限制:只支持 GET 请求。你不能在创建连接的时候发送请求体(body)。这对于 AI 聊天场景是个问题——你需要用 POST 把用户的消息发给后端。解决办法是用 fetch 手动解析 SSE 格式,下面会讲到。

6. 实战:对接 AI 大模型

最常见的流式场景:前端发消息,后端调大模型 API,把回答逐字转发给前端。

整个数据流是这样的:

code.ts
1
前端 --POST 消息--> 你的 Hono 服务 --POST 消息--> OpenAI/Claude API
2
前端 <--SSE 转发-- 你的 Hono 服务 <--SSE 响应-- OpenAI/Claude API

你的 Hono 服务在中间起"转发"作用:收到大模型返回的 SSE 流,解析出文本内容,再用 streamSSE 转发给前端。

后端:转发大模型的 SSE 流

src/index.ts
01
import { Hono } from 'hono'
02
import { streamSSE } from 'hono/streaming'
03
04
const app = new Hono<{ Bindings: { OPENAI_API_KEY: string } }>()
05
06
app.post('/chat', async (c) => {
07
const { messages } = await c.req.json()
08
09
// 调用 OpenAI,开启 stream 模式
10
const response = await fetch(
11
'https://api.openai.com/v1/chat/completions',
12
{
13
method: 'POST',
14
headers: {
15
'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
16
'Content-Type': 'application/json',
17
},
18
body: JSON.stringify({
19
model: 'gpt-4o',
20
messages,
21
stream: true, // 关键:告诉 OpenAI 用流式返回
22
}),
23
}
24
)
25
26
// OpenAI 返回的也是 SSE 格式的流
27
// 我们需要逐行读取,提取文本内容,再转发给前端
28
return streamSSE(c, async (stream) => {
29
const reader = response.body!.getReader()
30
const decoder = new TextDecoder()
31
let buffer = ''
32
let id = 0
33
34
while (true) {
35
const { done, value } = await reader.read()
36
if (done) break
37
38
// 把二进制数据解码成文本,追加到 buffer
39
buffer += decoder.decode(value, { stream: true })
40
41
// 按换行符拆分成多行
42
const lines = buffer.split('\n')
43
44
// 最后一行可能不完整(数据还没到齐),留到下一轮处理
45
buffer = lines.pop() || ''
46
47
for (const line of lines) {
48
// OpenAI 的 SSE 每行以 'data: ' 开头
49
if (!line.startsWith('data: ')) continue
50
51
const data = line.slice(6) // 去掉 'data: ' 前缀
52
if (data === '[DONE]') return // 结束信号
53
54
// 解析 JSON,提取生成的文本片段
55
const parsed = JSON.parse(data)
56
const content = parsed.choices[0]?.delta?.content
57
if (content) {
58
// 转发给前端
59
await stream.writeSSE({
60
data: content,
61
event: 'message',
62
id: String(id++),
63
})
64
}
65
}
66
}
67
})
68
})

这段代码看起来比较长,核心逻辑其实就两步:

  1. reader.read() 循环读取 OpenAI 返回的 SSE 流
  2. 从每行 data: ... 中提取文本,用 writeSSE 转发给前端

中间那个 buffer 的处理是为了应对一个现实问题:网络传输的数据不一定是按行完整到达的,一次 read() 拿到的可能是半行数据,所以需要一个缓冲区来拼接。

前端:消费聊天流

因为要用 POST 发送用户消息,不能用 EventSource(它只支持 GET),所以用 fetch + reader 手动解析 SSE:

client.ts
01
async function chat(messages: Array<{ role: string; content: string }>) {
02
const response = await fetch('/chat', {
03
method: 'POST',
04
headers: { 'Content-Type': 'application/json' },
05
body: JSON.stringify({ messages }),
06
})
07
08
// 和前面 streamText 的消费方式一样:拿 reader,循环读
09
const reader = response.body!.getReader()
10
const decoder = new TextDecoder()
11
let result = ''
12
13
while (true) {
14
const { done, value } = await reader.read()
15
if (done) break
16
17
const text = decoder.decode(value, { stream: true })
18
19
// 手动解析 SSE 格式:找到 'data: ' 开头的行,提取内容
20
const lines = text.split('\n')
21
for (const line of lines) {
22
if (!line.startsWith('data: ')) continue
23
const content = line.slice(6)
24
result += content
25
// 更新页面上的文字,实现"打字机"效果
26
document.getElementById('chat')!.textContent = result
27
}
28
}
29
}

前端的消费模式和第 3 节 streamText 几乎一样,只是多了一步 SSE 格式的解析(找 data: 开头的行)。

7. 三种流的选择

简单总结:

  • 只是逐段发文本,用 streamText,前端用 fetch + reader 消费
  • 转发文件或二进制数据,用 stream + pipe
  • 需要事件类型、自动重连、或对接 AI 模型,用 streamSSE

对于 AI 应用,streamSSE 是最常用的。它和大模型 API 的 SSE 格式天然匹配,前端消费也最方便。

下一篇我们讲项目结构与环境管理,看看一个正式的 Hono 项目该怎么组织代码和管理环境变量。