← 学习路线

流式输出:让慢回答不像卡死

AI 回答要等十几秒,怎样让用户不觉得页面卡死了?流式输出要打通哪几段,才是真的在"流"?

课型:流程·约 20 分钟·4 个术语

先看出事的那一刻

2026 年 7 月 26 日,自动驾驶知识站的 AI 导师第一次接上大模型。六个测试问题跑下来,单轮耗时分别是 20、18、40、14、47、17 秒。一问一答要等半分钟,没有人会等。

于是加上了流式接口:模型写出一点,就推给页面一点。第一版测下来,首字要等 17 秒,整段回答只分成 1 片,一次性到达。接口是流式的,内容却没有流起来。

这节课要回答的是:AI 回答要等十几秒,怎样让用户不觉得页面卡死了?流式输出要打通哪几段,才是真的在"流"?

装备几个词

为什么要懂

AI 替你做了

一句“改成流式输出”,AI 就能写好服务端推送、前端逐段显示,接口看上去一应俱全。

留给你的

确认每一段都真的在流:模型有没有逐字交出来、服务端是否逐段转发、中间的代理有没有攒着、页面是否边收边画。还要实测首字时间。

不懂的代价

名义上是流式,用户照样对着空白等十几秒;你看着代码里的流式接口,以为问题早就解决了。

一条回答要经过的四段

从模型到屏幕,一条回答要经过四段,每一段都可能"攒着":

  1. 模型:一个词一个词地生成。
  2. SDK 与后端:把生成的片段交出来。这一次就卡在这里:SDK 默认要等一条完整的消息生成完,才一次性交出;要打开"增量输出"的选项,它才会一个片段一个片段地交。
  3. 传输:服务端用 SSE 保持连接,一条条推送事件;中间如果还有代理(比如 Cloudflare 上的转发脚本),也得一路不攒着。这一点在这个项目里从没实测过(第 3 课)。
  4. 页面:收到一段,画一段。

任何一段在攒,用户看到的都是同一个样子:等很久,然后一下子全出来。

从 17 秒到 7 秒

改动首字时间分成几片
第一版流式接口17.2 秒1
打开增量输出13.0 秒99
把当前页面的词条直接放进提示词约 7 秒63–91
线上实测8.3 秒(整段 9.0 秒)143

总耗时没有明显变短,变的是等待时有东西可看。

第三行的改进值得多看一眼:打开增量输出之后,首字还要等 13 秒,因为模型每次都先调用一次工具,去查当前页面的词条,这次调用到第 8 秒左右才发出,查完才开口。可这个词条就在读者正在看的页面上,直接放进提示词,模型就不必再去查一遍了。首字时间里,常常藏着一次可以省掉的往返。

深潜等待的这几秒,页面上有什么+

首字要等七八秒,这段时间页面上显示什么,决定了用户会不会走:

  • 发送后,页面显示"思考中"。它原本带着闪动的省略号,样式在后来的一次重构里被删掉了,现在是三个静止的字。
  • 模型调用工具时,提示会换成"正在查词条"之类的具体动作,比一句笼统的"思考中"更让人安心。
  • "停止"按钮只断开了浏览器这一头:服务器上的模型还在继续生成、继续计费,而这一次的额度在开始时就已经扣掉了。

流式输出解决的是"看起来卡死",但等待中的每一个状态,都值得像正常内容一样认真设计。

找茬

下面是 AI 导师从后端到前端的流式输出代码(节选)。现在的版本已经能逐字显示了。找出在异常情况下(回答被截停、用户中途停止、回答写到一半出错)会让用户看到错误信息、或者让错误无声消失的地方。

这段 JS / JSX 里埋了 5 处问题。点击你觉得有问题的行,至少找出 3 处再揭晓。

查看代码与答案(5 处问题)
 1  // server/tutor/service.mjs(导师的消息循环,节选)
 2  for await (const m of query({ prompt: message, options })) {
 3    if (m.type === "stream_event") {
 4      const d = m.event?.delta;
 5      if (d?.type === "text_delta" && d.text) { sawText = true; streamed = true; yield { type: "text", text: d.text }; }
 6    }
 7    if (m.type === "result") {
 8      yield { type: "done", usedToday: quota.usedToday, limit: quota.limit, durationMs: m.duration_ms ?? null, tools: … };
 9  
10  // server/index.mjs(导师接口,节选)
11  return streamSSE(c, async (stream) => {
12    for await (const ev of chat({ learner, page, mode: safeMode, message: message.trim() })) {
13      await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev) });
14  
15  // web/src/components/Tutor.jsx(发送与出错时,节选)
16  setActivity("思考中");
17  setMsgs((m) => [...m.slice(0, -1), { role: "sys", text: `提示:${ev.message}` }]);
18  
19  // web/src/api.js(解析事件流,节选)
20  try { onEvent(JSON.parse(line.slice(5))); } catch { /* 忽略半包 */ }
  • 第 7 行 · 中危 · 被截停的回答,也当成正常完成:模型可能因为超过轮数上限或预算上限被 SDK 截停,这时的结果带着出错的标记。这里不看标记,一律发出"完成",用户看到的是一个戛然而止、却显示正常结束的回答。 该问的话:回答被截停时,用户看到的是什么?结果里的出错标记有人检查吗?
  • 第 11 行 · 中危 · 点了“停止”,服务器上的模型还在跑:页面上的"停止"只断开了浏览器这一头。服务端没有监听连接断开,也没有把中止信号传给模型调用:模型继续生成、继续计费,而这一次的额度在开始时就已经扣掉了。 该问的话:用户点停止之后,服务器上的模型调用会停下来吗?怎么验证?
  • 第 16 行 · 低危 · 等待提示是静止的:"思考中"原本带着闪动的省略号,样式在一次重构里被删掉了,现在是三个静止的字。首字要等七八秒,静止的提示让人分不清是在想,还是卡住了。 该问的话:等待的这几秒里,页面上有什么在动?
  • 第 17 行 · 中危 · 晚到的错误,把写了一半的回答换掉了:出错事件会替换掉最后一条消息。如果回答已经写了一半才出错,这一半内容会被一句"提示:……"整个覆盖,用户连已经看到的部分都没了。 该问的话:回答写到一半出错时,已经显示的内容还在吗?
  • 第 20 行 · 中危 · 注释说忽略半包,实际吞掉的是别的错误:半截的行早就被上面的缓冲逻辑留到下一轮了,走不到这里。这个 catch 真正吞掉的,是处理事件的回调里抛出的异常:页面更新出了错,控制台里一个字都不会留下。 该问的话:这个 catch 实际会接住哪些错误?接住之后有没有记录?

快测

1. 接口已经改成了 SSE 流式输出,可用户还是要等 17 秒,然后整段回答一下子出现。最该查哪里?

查看选项与答案
  • A. SSE 够不够实时,要不要换成 WebSocket——接口本身已经是流式的(SSE),卡的是上游只在最后才交出完整的一条消息。换传输方式,交出来的仍是一整条。
  • B. 上游是不是要等整条消息生成完,才一次性交给后面的环节(正确)——这一次就是 SDK 的增量输出选项没有打开:它默认要等一条完整的消息生成完,才一次性交出。
  • C. 前端是不是等全部内容都到齐,才一次性渲染出来——第一版测下来,接口输出的整段回答只有 1 片,前端没有可以边收边画的东西。打开增量输出之后,才变成 99 片。

流式要每一段都在流,任何一段攒着都不行。

2. 流式输出能让什么变短?

查看选项与答案
  • A. 整段回答的总耗时,因为模型能边想边输出——模型生成整段回答的时间基本不变,流式只是不等写完才交出来。总耗时没有明显变短,变的是等待时有东西可看。
  • B. 每次调用的费用,因为数据是分片传的,更省流量——费用看生成的 token 数,同一段回答生成多少就是多少,分不分片传输都一样。
  • C. 用户盯着空白页、等第一个字出现的那段时间(正确)——第一个字早早出现,用户从那一刻起就开始读了。

流式改变的是等待的感受,不是等待的总长。

3. 首字要等 13 秒,因为模型每次都先调用工具去查词条,而这个词条就在读者正在看的页面上。怎么优化最有效?

查看选项与答案
  • A. 把查词条工具的超时时间调长,别让查询中途被中断——调长超时,不会让它变快,只是等得更久也不报错。
  • B. 让模型只输出更短的回答,这样首字自然就会更快——首字时间取决于第一个字什么时候出现,和回答长短无关。前面那次查词条的往返,照样要等。
  • C. 把页面上的词条内容直接放进提示词,省掉查询(正确)——词条就在读者正在看的页面上,模型不必再去查一遍。这一次,首字从 13 秒降到了 7 秒左右。

先看首字时间花在了哪里,常常能找到一次可以省掉的往返。

判断时刻

AI 导师回答一次要十几秒。上线前你只有半天时间,AI 给了三个方案。

你会选哪一个?

三个选项各自的代价
  • A. 只在页面上加一个转圈的加载动画——考察安抚等待:最快,至少让人知道没卡死。但十几秒盯着一个转圈,很多人还是会走;总时间也一点没变。
  • B. 打开增量输出,服务端逐段推送,页面边收边显示——考察让等待有内容:首字从 17 秒降到 13 秒,之后字是一点点冒出来的,用户从第一个字就开始读。要注意把每一段都打通:SDK、服务端、代理、页面。
  • C. 在上一个方案的基础上,把当前页面的资料直接放进提示词——考察省掉一次往返:首字再降到 7 秒左右:模型不必先去查一遍页面上已有的内容。代价是提示词变长,每次多花一点 token。这次最后就是这么做的。

慢是模型的事,卡是体验的事。先让第一个字尽快出现,再看首字时间里有哪些往返可以省掉。

带走

下次让 AI 做这件事时,问它

  1. 这个 AI 功能的首字时间和总耗时分别是多少?请在线上实测几次。
  2. 从模型到页面,每一段都在逐段传递吗:SDK、服务端、中间的代理、前端?
  3. 用户点“停止”之后,服务器上的模型调用会停下来吗?这一次的额度怎么算?
  4. 回答写到一半出错时,页面怎么显示?已经出来的内容还保留吗?

自己验证

  • 用 curl -N 直接请求流式接口,看数据是一段一段到,还是最后一次性到。
  • 在浏览器开发者工具的“网络”面板里看这个请求的时间分解:等了多久才收到第一个字节。
  • 回答到一半时点“停止”,再去服务器日志里看这次调用有没有提前结束。

慢是模型的事,卡是体验的事:先让第一个字尽快出现。