章节05 / 14
  1. 01先把 AI 应用当成系统,而不是聊天框
  2. 02打通第一个多 Provider 模型请求
  3. 03把 Prompt 写成任务协议
  4. 04结构化输出不是格式化,而是业务门禁
  5. 05把等待时间拆成事件:流式响应实战
  6. 06Tool Calling:模型提出动作,应用执行动作
  7. 07有副作用的工具要先设计刹车
  8. 08RAG 的第一性问题:答案从哪里来
  9. 09让 RAG 回答经得起追问
  10. 10长上下文、会话状态与记忆压缩
  11. 11Agent 工作流要像状态机一样可恢复
  12. 12评测工程:用样例集防止应用退化
  13. 13可观测性与安全:线上问题要能被看见
  14. 14上线不是结束:路由、灰度、回滚和治理复盘
本文目录12
  1. 为什么首字延迟比总时间更决定用户体验
  2. 拆解 Server-Sent Events (SSE) 协议数据流
  3. 用 Vercel AI SDK streamText 构建流式后端接口
  4. 在前端使用 ReadableStream 读取并消费流数据
  5. 拦截取消信号与处理连接中断
  6. 接入 OpenTelemetry 观测流式响应的性能瓶颈
  7. 流式异常边界诊断与防御策略
  8. 实战演练:流式接口的单元验证与验收指南
  9. 1. 验收环境准备
  10. 2. 单元自测步骤
  11. 3. 来源、时效与复核范围说明
  12. 把等待时间拆成事件:流式响应实战:把判断写成可复查证据
05

把等待时间拆成事件:流式响应实战

掌握 Server-Sent Events (SSE) 协议原理与 ReadableStream 标准,通过 Vercel AI SDK streamText 构建高感知的流式 AI 接口,掌握前端消费、流中断拦截与 OpenTelemetry 观测方法。

前置基础
  • 理解 TypeScript 基础语法
  • 了解基础的 HTTP 协议与 Fetch API
  • 完成前序单元中 AI SDK 的基础配置
学习结果
  • 一个基于 Vercel AI SDK streamText 构建的 Node.js 流式响应接口
  • 一个基于原生 ReadableStream 的前端流消费实现
  • 一份覆盖网络抖动、主动取消及模型报错的流式应用异常状态排查表

传统的 Web 应用多采用“请求-等待-响应”的一体化交付模式。但在生成式 AI 场景下,大语言模型生成数百个 Token 往往需要耗费数秒乃至数十秒。如果让用户面对空白屏幕或加载动画长达十几秒,流失率将呈指数级上升。

本实战将带你避开长等待体验陷阱。我们不会缩短模型的物理生成耗时,而是通过**流式响应(Streaming)**技术,将模型的每一次状态变更与输出拆成事件,分批推送到前端。这样可以让用户的“感知等待时间”缩短至首个 Token 生成的微小时间间隔。


为什么首字延迟比总时间更决定用户体验

本节操作锚点:围绕“为什么首字延迟比总时间更决定用户体验”记录步骤、样例、诊断、风险、检查清单和验收结果。

在评估 AI 接口性能时,开发团队往往容易落入“只看总请求时长(Duration)”的陷阱。实际上,决定用户留存和体验舒适度的核心指标是首字延迟(TTFT,Time to First Token)

  • 一体化响应(Non-streaming):用户发出请求 -> 等待模型完全生成 500 字(耗时约 8 秒) -> 界面一次性渲染出所有文本。在这 8 秒内,用户处于认知空白期,极易认为应用已卡死。
  • 流式响应(Streaming):用户发出请求 -> 100 毫秒内接收首个字符 -> 界面逐字滚动展示,直到 8 秒后完整呈现。用户的注意力会被持续的动态输出吸引,认知等待时间几乎降为零。

流式传输由于增加了网络传输的握手和高频 I/O 交互,其“总耗时”甚至可能比一次性响应略慢。然而,它让用户从“等待结果”变成了“观察过程”,这便是感知体验的质变。

在后面的实战中,我们将使用 Vercel AI SDK 和原生 Web API 来构建这一体验,并提供完整的异常处理架构。


拆解 Server-Sent Events (SSE) 协议数据流

本节操作锚点:围绕“拆解ServerSentEventsSSE协议数”记录步骤、样例、诊断、风险、检查清单和验收结果。

大语言模型 API 与我们业务服务器之间、以及业务服务器与前端浏览器之间,通常使用 Server-Sent Events (SSE) 协议进行事件流的单向传递。

根据 MDN Web Docs 的 Server-sent events 规范说明,SSE 是一个基于传统 HTTP 的、长连接的单向推送协议。与 WebSocket 相比,它更轻量,对防火墙友好,且天然支持断线重连。一个典型的 SSE HTTP 响应头需要满足以下条件:

http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

在其传输的数据流中,每一次更新都由一行或多行以 结尾的文本组成,整条消息以 `

` 结束。数据格式通常遵循以下规范:

text
event: message
data: {"text": "创"}

event: message
data: {"text": "意"}

event: message
data: [DONE]

根据 MDN 的 Server-sent events 文档规范,由于客户端在遭遇网络抖动断开连接时会自动尝试发起重连,在开发流式 API 时,我们需要特别注意重连导致的副作用,例如多轮对话中重复扣除 Token 或者产生重复的数据库写入。如果我们在重连处理中没有校验请求标识(Request ID),可能会导致服务器对同一个生成请求启动多次计费计算。


用 Vercel AI SDK streamText 构建流式后端接口

本节操作锚点:围绕“用VercelAISDKstreamText构建”记录步骤、样例、诊断、风险、检查清单和验收结果。

基于 Vercel AI SDK 的 streamText 文档说明,它在底层帮我们封装了针对不同模型提供商(如 OpenAI、Anthropic)底层的流式事件差异,提供了统一的流控制能力。我们可以通过调用它的标准方法,直接获取兼容 W3C ReadableStream 标准的流输出,而无须针对各个模型厂商的非标事件做繁琐的底层适配。

下面我们来编写一个完整的流式响应 API 端点。此代码基于 Node.js 运行时环境,使用 TypeScript 编写:

typescript
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai'; // 确保已安装并配置好环境变量 OPENAI_API_KEY
import http from 'http';

const server = http.createServer(async (req, res) => {
  // 1. 设置跨域和 SSE 必须的 Response Headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*',
  });

  try {
    // 2. 解析请求,并使用 streamText 启动生成流
    const result = await streamText({
      model: openai('gpt-4o-mini'),
      prompt: '请用 150 字左右,深入浅出地解释什么是量子纠缠。',
    });

    // 3. 将 AI SDK 的标准 ReadableStream 管道传输到 HTTP 响应中
    // result.toDataStreamResponse() 会将 token 转化为标准的 event-stream 协议数据
    const dataStream = result.toDataStream();
    const reader = dataStream.getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        break;
      }
      // 将获取的 chunk 写入 HTTP 响应体并立即刷新缓冲区
      res.write(value);
    }
  } catch (error) {
    console.error('模型流式请求发生错误:', error);
    res.write(`event: error\ndata: ${JSON.stringify({ message: 'Internal Server Error' })}\n\n`);
  } finally {
    res.end();
  }
});

server.listen(3000, () => {
  console.log('流式 API 服务已启动: http://localhost:3000');
});

在这个后端服务中,我们手动消费了 result.toDataStream() 返回的 Reader,并直接写入了 Node.js HTTP res 响应。如果是在 Next.js、Nuxt.js 或 Express 中,可以直接返回 result.toDataStreamResponse() 帮助你自动完成 HTTP 报头与流的映射。


在前端使用 ReadableStream 读取并消费流数据

本节操作锚点:围绕“在前端使用ReadableStream读取并消费”记录步骤、样例、诊断、风险、检查清单和验收结果。

根据 MDN Web API 的 ReadableStream 说明,在现代浏览器中,fetch 方法返回的 Response.body 本身就是一个实现了 W3C 规范的 ReadableStream。通过对其进行逐帧读取,可以避免等待整个 JSON 下载完毕才渲染界面的延迟。

下面展示如何在前端利用 TextDecoder 对底层的 UTF-8 字节流(Uint8Array)进行解码,并增量更新 UI:

html
<!DOCTYPE html> 
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>流式交互终端</title>
  <style>
    #output {
      border: 1px solid #ccc;
      padding: 15px;
      width: 500px;
      height: 300px;
      overflow-y: auto;
      white-space: pre-wrap;
      font-family: sans-serif;
      line-height: 1.6;
    }
    button { margin-top: 10px; padding: 8px 15px; cursor: pointer; }
  </style>
</head>
<body>
  <div id="output">等待生成...</div>
  <button id="start-btn">启动生成</button>
  <button id="stop-btn" disabled>停止生成</button>

  <script>
    const outputDiv = document.getElementById('output');
    const startBtn = document.getElementById('start-btn');
    const stopBtn = document.getElementById('stop-btn');
    let abortController = null;

    startBtn.addEventListener('click', async () => {
      outputDiv.textContent = '';
      startBtn.disabled = true;
      stopBtn.disabled = false;

      // 创建信号量,用于主动终止请求
      abortController = new AbortController();

      try {
        const response = await fetch('http://localhost:3000', {
          method: 'POST',
          signal: abortController.signal
        });

        if (!response.ok || !response.body) {
          throw new Error('网络异常或服务器不支持流式响应');
        }

        // 获取底层 Reader 和文本解码器
        const reader = response.body.getReader();
        const decoder = new TextDecoder('utf-8');
        let buffer = '';

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

          // 解码当前数据分片并追加
          const chunk = decoder.decode(value, { stream: true });
          buffer += chunk;
          
          // 这里我们简单将原始流追加显示,生产环境中需根据 SSE 数据格式进行解析
          // 解析规则通常是以 "data: " 开头,"\n\n" 为消息分界
          outputDiv.textContent = buffer;
          outputDiv.scrollTop = outputDiv.scrollHeight;
        }
      } catch (error) {
        if (error.name === 'AbortError') {
          outputDiv.textContent += '\n\n[生成已由用户中止]';
        } else {
          outputDiv.textContent += `\n\n[发生异常]: ${error.message}`;
        }
      } finally {
        startBtn.disabled = false;
        stopBtn.disabled = true;
        abortController = null;
      }
    });

    stopBtn.addEventListener('click', () => {
      if (abortController) {
        abortController.abort();
      }
    });
  </script>
</body>
</html>

在上述代码中,我们通过 response.body.getReader() 直接拿到了底层的 ReadableStream 读取锁,并用 TextDecoder 实时解码二进制数组。这确保了只要网络管道一到达新字符,页面就能立刻完成绘制。


拦截取消信号与处理连接中断

本节操作锚点:围绕“拦截取消信号与处理连接中断”记录步骤、样例、诊断、风险、检查清单和验收结果。

流式交互极大地增强了用户的控制权:用户一旦发现生成的内容不是自己想要的,往往会点击“停止生成”来减少无用等待,这也是产品设计的关键考量。这就要求我们的系统具备响应取消的能力。

如果用户在长文本生成中途关闭了浏览器或点击了“停止生成”,我们必须主动调用前端 ReadableStream 的 reader.cancel() 方法并向后端发送中止信号(如 AbortSignal),因为否则不仅会造成服务器和 API Token 计费的无端浪费,还会导致系统资源无法被及时回收。

在后端,如果使用 Vercel AI SDK 构建应用,我们可以监听 HTTP 请求的 close 事件。一旦底层 TCP 链路断开,应立即停止模型流的输出和对外部 API 的请求:

typescript
req.on('close', () => {
  console.log('客户端连接已断开,正在清理生成上下文与外部请求...');
  // 这里调用你获取的 AbortController.abort() 传递给大模型提供商的客户端
});

接入 OpenTelemetry 观测流式响应的性能瓶颈

本节操作锚点:围绕“接入OpenTelemetry观测流式响应的性能”记录步骤、样例、诊断、风险、检查清单和验收结果。

根据 OpenTelemetry GenAI 语义规范(OpenTelemetry GenAI semantic conventions)指示,针对生成式 AI 系统的可观测性不能局限于传统的 HTTP 请求成功率。在流式传输场景下,必须特别监控以下两个黄金指标:

  1. Time to First Token (TTFT): 测量从发送请求到接收到模型生成的第一个字符的时间段。
  2. Tokens Per Second (吞吐率): 测量整个流输出期间,平均每秒产生的 Token 数量。

通过在流式开始和输出首字时,通过 OpenTelemetry 埋点上报特定属性,可以精准追踪流延迟的罪魁祸首究竟是网络首段阻塞,还是模型推理本身的拥堵:

typescript
// 伪代码示例:在 API 内部手动追踪首字耗时
const startTime = performance.now();
let isFirstTokenHandled = false;

// 在读取流的循环中
const { value, done } = await reader.read();
if (!isFirstTokenHandled && value) {
  const ttft = performance.now() - startTime;
  isFirstTokenHandled = true;
  
  // OpenTelemetry 指标上报,基于 gen_ai 规范命名空间
  activeSpan.setAttribute('gen_ai.response.time_to_first_token', ttft);
  activeSpan.setAttribute('gen_ai.request.model', 'gpt-4o-mini');
}

如果在多轮对话应用中无法容忍偶发性的中间网络抖动,不要使用默认的自动重连机制,而应该在客户端应用层实现细粒度的指数退避重试,除非你能接受重连时请求被当成全新会话从而丢失上下文的风险。


流式异常边界诊断与防御策略

本节操作锚点:围绕“流式异常边界诊断与防御策略”记录步骤、样例、诊断、风险、检查清单和验收结果。

流响应与传统的 JSON API 截然不同。在流式链路中,网络断开或超时发生时,HTTP 状态码往往早在第一个字输出时就已经返回了 200 OK,这导致传统的全局错误拦截器(如 Axios 的 Interceptor)彻底失效。我们必须深入细化流传输中的异常状态表并建立逐个防御。如下表所示:

异常场景表现特征根本原因防御/处理策略
握手期报错发送请求后,还未开始输出,接口报错 500 / 429签名失效、模型提供商速率超限、凭证错误直接降级:通过 HTTP 状态码直接捕获,展示清晰的错误文案或触发自动降级到备用模型。
中途网络断开输出了部分文本后,流无征兆中断,后续再无数据网络信号不稳定,TCP 连接由于代理服务器超时(通常 30s)被掐断指数退避重连 / 断点继续:在应用层捕获 reader.read() 的网络错误,携带已生成的文本上下文尝试重新请求追加。
中途安全过滤流正常返回到一半,突然截断或返回安全提示标记触发了敏感词过滤(Content Filter)或模型内置安全栅栏前端内容标记:解析 SSE 结构中的 finish_reason,如果为 content_filter 则在前端主动渲染友好提示,代替断开空白。
资源/Token耗尽生成中途卡住,解析报错 length 限制请求中设定的 max_tokens 限制,或上下文窗口超出提示续写:捕获 finish_reason: 'length',并在 UI 下方提供“继续生成”的快捷按钮。

如果项目需要在边缘计算环境(如 Cloudflare Workers)部署流式接口,可以使用基于标准 Web API 的 ReadableStream 转换为 SSE 格式,此时应避免直接引入 Node.js 独有的流处理模块(如 stream.Readable),因为边缘运行时对 Node.js 原生 API 的兼容性有限,可能导致部署失败。


实战演练:流式接口的单元验证与验收指南

本节操作锚点:围绕“实战演练:流式接口的单元验证与验收指南”记录步骤、样例、诊断、风险、检查清单和验收结果。

为了检验本单元的学习成效,你需要在本地完整搭建出上述流式响应链条,并通过以下三步完成交付物的验收:

1. 验收环境准备

  • 在项目目录下安装 Vercel AI SDK 依赖:
    bash
    npm install ai @ai-sdk/openai
    
  • 确保本地 .env 配置文件或系统变量中配置了真实的模型凭证:OPENAI_API_KEY(或你所选用的厂商对应 Key)。

2. 单元自测步骤

  1. 启动后端服务:运行编写的 server.ts,确保 localhost:3000 监听无报错。
  2. 前端页面消费验证:在本地起一个静态服务器或直接在浏览器打开静态 HTML,点击 启动生成
  3. 首字响应验证:在浏览器开发者工具(F12)的 Network 面板中选中对应的请求。观察 Response 标签卡,确认数据是逐行(data: ...)增量流入,而不是在请求结束后一次性加载。
  4. 取消动作验证:在文字生成中途,点击 停止生成 按钮。观察后端终端输出,确认是否有 close 监听触发事件日志。同时,确认前端有 [生成已由用户中止] 的标记生成,并且没有后续的 Token 数据积压在网络链路中。

3. 来源、时效与复核范围说明

本单元所述核心原理及接口规范均建立在 2026-05-28 访问的官方标准规范上。以下为未来复核及更新依据:

  • Vercel AI SDK (v3.x):流转换方法依赖于标准 W3C 流,若 AI SDK 后续修改底层转换函数名称,需根据最新的官方文档说明修正 toDataStream() 调用。
  • Server-Sent Events 协议(W3C / MDN):核心 HTTP 头 text/event-stream 格式作为长期标准,不会轻易废弃,但在 HTTP/3 及 HTTP/2 的多路复用网络环境下,注意排查网关对 Buffer 的强制保留策略。
  • OpenTelemetry GenAI 标准:目前语义规范正逐步推进,如果在生成环境中上报,需要以 OpenTelemetry 发布的最新 gen-ai 语义指南为核心更新 Span 属性键名。

把等待时间拆成事件:流式响应实战:把判断写成可复查证据

如果只看最终结果,很容易忽略中间判断;本节补的是这些判断的证据链。本课交付物是 一个流式响应接口、前端消费流程和异常状态表,它要服务于 AI 应用 的下一步,而不是只证明你读过这一节。

如果你现在还没有真实输入,先用一个最小样例完成 把等待时间拆成事件:流式响应实战,因为没有输入就无法判断产物是否可用。 如果本课产物会影响后续交付,应该写明适用条件和不适用条件,否则下一课会把不确定性误当成基础。 如果检查结果只剩一句“看起来可以”,必须补一条反例或失败样例,只有能解释失败原因时,这个判断才站得住。

修复时不要一次改太多变量,先固定输入,再替换一个条件,否则无法知道改动是否有效。围绕 把等待时间拆成事件:流式响应实战 做检查时,至少保留步骤、样例、风险、修复和验收五项。

OpenAI 的《Streaming API responses》说明:支撑响应流、事件处理、前端增量体验、错误边界和完成状态。;这意味着 把等待时间拆成事件:流式响应实战 不能只写经验结论,要把来源变成检查动作。Vercel 的《AI SDK streaming text》提醒:支撑流式文本生成、UI 反馈、取消、超时和流转发。;因此本课方案必须写清边界。OpenTelemetry 的《OpenTelemetry GenAI semantic conventions》提供的证据是:支撑 AI 应用观测、模型请求 span/attribute、token、延迟和生产排障。;所以当前结论按 2026-05-28 的来源状态使用。

复核触发条件要写具体:如果官方 API/SDK、软件工具版本、版权政策、安全合规要求、行业规范、平台发布规则或关键来源页面发生变化,需要重新检查 把等待时间拆成事件:流式响应实战 的步骤、样例、风险和验收清单。

练习验收:把 把等待时间拆成事件:流式响应实战 应用到一个自己的任务,交付一份包含输入、步骤、样例、失败诊断、来源证据和复测结果的记录。缺少其中任何一项,都先标记为 needs_review。