第 6 章:流式输出:像 ChatGPT 一样逐字返回

2 阅读2分钟

第 6 章:流式输出:像 ChatGPT 一样逐字返回

本章目标

这一章把普通响应改造成流式响应。用户不需要等完整答案生成完,而是可以边生成边阅读。

本章效果

下面的截图来自配套 Next.js 项目。回答区域使用 ReadableStream 持续追加模型输出,右侧展示问题结构化分析。

AI Chat 流式输出转存失败,建议直接上传图片文件

为什么需要流式输出

AI 接口常常比普通接口慢。如果用户点击发送后等 10 秒才看到结果,会觉得应用卡住了。

流式输出可以改善等待感:

请求开始 -> 收到第一个 token -> 持续追加文本 -> 完成

服务端流式 API

LangChain 模型通常支持 stream()。在 Next.js API Route 中,我们可以把模型输出转成 ReadableStream

import { createChatModel } from "@/lib/ai/model";

export async function POST(request: Request) {
  const body = await request.json();
  const model = await createChatModel();

  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const modelStream = await model.stream([
          {
            role: "system",
            content: "你是一个简洁可靠的 AI 应用开发助手。"
          },
          ...body.messages
        ]);

        for await (const chunk of modelStream) {
          controller.enqueue(encoder.encode(chunk.text));
        }

        controller.close();
      } catch (error) {
        controller.enqueue(encoder.encode("\n[生成失败,请稍后重试]"));
        controller.close();
      }
    }
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-cache"
    }
  });
}

前端读取流

前端用 response.body.getReader() 读取:

async function readStream(response: Response, onText: (text: string) => void) {
  const reader = response.body?.getReader();
  if (!reader) return;

  const decoder = new TextDecoder();

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    onText(decoder.decode(value));
  }
}

发送消息时先插入一个空 assistant 消息,然后持续追加:

const assistantIndex = nextMessages.length;
setMessages([...nextMessages, { role: "assistant", content: "" }]);

const response = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ messages: nextMessages })
});

await readStream(response, (text) => {
  setMessages((current) => {
    const copy = [...current];
    copy[assistantIndex] = {
      ...copy[assistantIndex],
      content: copy[assistantIndex].content + text
    };
    return copy;
  });
});

取消生成

使用 AbortController

const controller = new AbortController();

await fetch("/api/chat", {
  method: "POST",
  signal: controller.signal,
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ messages })
});

controller.abort();

前端可以提供“停止生成”按钮。生产环境还要考虑服务端是否继续消耗模型请求。

流式输出的 UI 细节

几个体验点很重要:

  • assistant 消息生成中显示光标或 loading
  • 生成时禁用重复发送
  • 支持停止生成
  • 自动滚动到底部,但用户向上翻历史时不要强行抢滚动
  • 错误时保留用户输入,方便重新发送

实战任务

完成:

  • /api/chat 返回 ReadableStream
  • 前端逐块读取文本
  • assistant 消息实时追加
  • loading 和停止生成状态

常见坑

不要用 JSON 包完整个流式响应,普通 JSON 适合一次性返回,不适合 token 级输出。

不要假设每个 chunk 都是完整句子。chunk 可能只是一个字、一个词、甚至空字符串。

不要忘记处理 response.body 为空的情况。

本章小结

流式输出让 AI 应用体验上一个台阶。下一章我们会整理 Prompt,让模型回答更稳定、更符合知识库助手的定位。