六维教程

流式输出

大模型生成几百字的回答要几秒甚至十几秒。如果等整段生成完再一次性返回,用户看到的是一段漫长的空白等待。流式输出把生成过程拆成一截一截地推给前端,第一个字几十毫秒就到,后续边生成边追加,体验和打字机一样顺滑。这篇讲清楚 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 处理跨块的不完整行。下一篇讲文本嵌入,把文字转成向量做语义搜索。

上一篇 文本生成入门

下一篇 文本嵌入

上一篇
文本生成入门
下一篇
文本嵌入