流式输出
大模型生成几百字的回答要几秒甚至十几秒。如果等整段生成完再一次性返回,用户看到的是一段漫长的空白等待。流式输出把生成过程拆成一截一截地推给前端,第一个字几十毫秒就到,后续边生成边追加,体验和打字机一样顺滑。这篇讲清楚 Workers AI 怎么开启流式、数据格式长什么样,以及前端怎么对接。
为什么需要流式
大模型内部是逐个 token 生成的,每生成一个 token 做一次推理。完整回答有几百个 token,串起来就要几秒。非流式做法是全部生成完打包返回,用户全程盯着空白页。
两种模式的体验差距
| 对比项 | 非流式 | 流式 |
|---|---|---|
| 首字延迟 | 等到全部生成完 | 几十毫秒出第一个字 |
| 用户感知 | 长时间空白 | 看到字一个个蹦出来 |
| 中断处理 | 只能等完或超时 | 用户可随时停止 |
| 内存占用 | 整段缓存在内存 | 分块推送,内存占用低 |
| Worker 超时 | 长文本可能触发超时 | 边推边释放,不易超时 |
Cloudflare Workers(Cloudflare 的边缘计算函数服务)有 CPU 时间和请求时长限制,长文本非流式生成容易卡到边界。流式把响应拆成小块持续输出,连接保持活跃,能撑更长的总生成时间。
开启流式
Workers AI 开启流式很简单,调用 env.AI.run 时加一个 stream: true 参数。返回值从一个 JSON 对象变成一个 ReadableStream(可读流),直接塞进 Response 就能流式返回。
export default {
async fetch(request, env) {
const stream = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
messages: [
{ role: "system", content: "你是一个用中文回答问题的助手" },
{ role: "user", content: "用三句话介绍 Cloudflare" },
],
stream: true,
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
},
});
},
};
关键点有两个。stream: true 让模型走流式通道,返回的是 ReadableStream 而不是字符串。响应头必须设成 text/event-stream,浏览器才按 SSE 协议处理。
流式和非流式返回值对比
| 对比项 | 非流式 (stream: false) |
流式 (stream: true) |
|---|---|---|
| 返回类型 | 对象 { response, usage } |
ReadableStream |
| 等待方式 | await 拿到完整结果 | await 拿到流,内容陆续到达 |
| 响应头 | application/json | text/event-stream |
| usage 字段 | 直接在返回对象里 | 不返回,需另外统计 |
注意流式模式下拿不到 usage 字段,因为 token 是逐个生成的,平台不会在流里附带统计。需要统计就自己累加,或者用 AI Gateway(Cloudflare 的 AI 网关服务)记录。
SSE 数据格式
流式输出走的是 SSE(Server-Sent Events,服务器推送事件)协议。每一块数据是一行 data: 开头的文本,以两个换行符分隔。Workers AI 把每个 token 包成一个小 JSON 推过来。
实际推过来的数据长这样
data: {"response":"Cloud"}
data: {"flare"}
data: {" 是"}
data: {"一"}
data: {"家"}
data: [DONE]
格式说明
| 部分 | 含义 |
|---|---|
data: |
SSE 协议固定前缀 |
{"response":"..."} |
一段生成的文本,可能是字、词或短语 |
| 空行(双换行) | 一条消息的结束标记 |
data: [DONE] |
整个生成结束的标记 |
前端解析时按行读,去掉 data: 前缀,把 JSON 里的 response 字段拼起来就是完整文本。遇到 [DONE] 表示结束。
前端 EventSource 对接
浏览器原生支持 SSE,用 EventSource 对象就能接收。它自动处理连接、自动解析 data: 行,用起来比 WebSocket 简单。
<div id="output"></div>
<script>
const eventSource = new EventSource("/ask?question=介绍 Cloudflare");
eventSource.onmessage = (event) => {
if (event.data === "[DONE]") {
eventSource.close();
return;
}
const data = JSON.parse(event.data);
document.getElementById("output").textContent += data.response;
};
eventSource.onerror = () => {
eventSource.close();
};
</script>
EventSource 的限制是只支持 GET 请求。如果要用 POST 传较长的 body,得换成 fetch 配合 ReadableStream 手动解析。
前端 fetch 流式解析
POST 请求传对话历史更实用,这时候用 fetch 读取流式响应。核心是拿到 response.body 这个 ReadableStream,用 reader 逐块读取,按行切分解析。
async function ask(question, history) {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question, history }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullText = "";
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) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") return fullText;
const data = JSON.parse(payload);
fullText += data.response;
// 这里可以做实时渲染
updateUI(fullText);
}
}
return fullText;
}
几个容易踩的坑。
buffer 是必须的。网络分块和 SSE 消息边界不对齐,一个 chunk 可能切断某条消息,必须缓存残余部分拼到下一个 chunk。
decoder.decode 要传 { stream: true },否则遇到多字节字符被切断会乱码。中文字符占 3 个字节,跨 chunk 时这个参数能正确拼接。
lines.pop() 把最后一段可能不完整的行移出处理列表,留到下一轮。这是流式解析的标准做法。
完整的流式问答接口
把 Worker 端和前端串起来。Worker 接收 POST 请求,调用 Workers AI 流式生成,把 ReadableStream 直接作为响应体返回。
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/api/chat" && request.method === "POST") {
const { question, history = [] } = await request.json();
const messages = [
{ role: "system", content: "你是一个用中文回答问题的助手" },
...history,
{ role: "user", content: question },
];
const stream = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
messages,
stream: true,
max_tokens: 1024,
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
"connection": "keep-alive",
},
});
}
return new Response("Not Found", { status: 404 });
},
};
history 是前端传过来的多轮对话历史,格式是 [{role, content}, ...]。展开后拼到 messages 里,模型就能参考上下文继续聊。
小结
流式输出让大模型的响应从干等变成即时可见。Workers AI 加一个 stream: true 就开启,返回 ReadableStream 直接作为 Response 体。数据走 SSE 协议,每块是 data: 开头的 JSON,以 [DONE] 结束。前端用 EventSource 处理 GET 请求,POST 场景用 fetch 读 body 流手动解析,关键是维护 buffer 处理跨块的不完整行。下一篇讲文本嵌入,把文字转成向量做语义搜索。
上一篇 文本生成入门
下一篇 文本嵌入