先看出事的那一刻
2026 年 7 月 26 日,自动驾驶知识站的 AI 导师第一次接上大模型。六个测试问题跑下来,单轮耗时分别是 20、18、40、14、47、17 秒。一问一答要等半分钟,没有人会等。
于是加上了流式接口:模型写出一点,就推给页面一点。第一版测下来,首字要等 17 秒,整段回答只分成 1 片,一次性到达。接口是流式的,内容却没有流起来。
这节课要回答的是:AI 回答要等十几秒,怎样让用户不觉得页面卡死了?流式输出要打通哪几段,才是真的在"流"?
装备几个词
为什么要懂
AI 替你做了
一句“改成流式输出”,AI 就能写好服务端推送、前端逐段显示,接口看上去一应俱全。
留给你的
确认每一段都真的在流:模型有没有逐字交出来、服务端是否逐段转发、中间的代理有没有攒着、页面是否边收边画。还要实测首字时间。
不懂的代价
名义上是流式,用户照样对着空白等十几秒;你看着代码里的流式接口,以为问题早就解决了。
一条回答要经过的四段
从模型到屏幕,一条回答要经过四段,每一段都可能"攒着":
- 模型:一个词一个词地生成。
- SDK 与后端:把生成的片段交出来。这一次就卡在这里:SDK 默认要等一条完整的消息生成完,才一次性交出;要打开"增量输出"的选项,它才会一个片段一个片段地交。
- 传输:服务端用 SSE 保持连接,一条条推送事件;中间如果还有代理(比如 Cloudflare 上的转发脚本),也得一路不攒着。这一点在这个项目里从没实测过(第 3 课)。
- 页面:收到一段,画一段。
任何一段在攒,用户看到的都是同一个样子:等很久,然后一下子全出来。
从 17 秒到 7 秒
| 改动 | 首字时间 | 分成几片 |
|---|---|---|
| 第一版流式接口 | 17.2 秒 | 1 |
| 打开增量输出 | 13.0 秒 | 99 |
| 把当前页面的词条直接放进提示词 | 约 7 秒 | 63–91 |
| 线上实测 | 8.3 秒(整段 9.0 秒) | 143 |
总耗时没有明显变短,变的是等待时有东西可看。
第三行的改进值得多看一眼:打开增量输出之后,首字还要等 13 秒,因为模型每次都先调用一次工具,去查当前页面的词条,这次调用到第 8 秒左右才发出,查完才开口。可这个词条就在读者正在看的页面上,直接放进提示词,模型就不必再去查一遍了。首字时间里,常常藏着一次可以省掉的往返。
深潜等待的这几秒,页面上有什么+
首字要等七八秒,这段时间页面上显示什么,决定了用户会不会走:
- 发送后,页面显示"思考中"。它原本带着闪动的省略号,样式在后来的一次重构里被删掉了,现在是三个静止的字。
- 模型调用工具时,提示会换成"正在查词条"之类的具体动作,比一句笼统的"思考中"更让人安心。
- "停止"按钮只断开了浏览器这一头:服务器上的模型还在继续生成、继续计费,而这一次的额度在开始时就已经扣掉了。
流式输出解决的是"看起来卡死",但等待中的每一个状态,都值得像正常内容一样认真设计。
找茬
下面是 AI 导师从后端到前端的流式输出代码(节选)。现在的版本已经能逐字显示了。找出在异常情况下(回答被截停、用户中途停止、回答写到一半出错)会让用户看到错误信息、或者让错误无声消失的地方。
这段 JS / JSX 里埋了 5 处问题。点击你觉得有问题的行,至少找出 3 处再揭晓。
·第 7 行中危
被截停的回答,也当成正常完成
模型可能因为超过轮数上限或预算上限被 SDK 截停,这时的结果带着出错的标记。这里不看标记,一律发出"完成",用户看到的是一个戛然而止、却显示正常结束的回答。
该问的话:回答被截停时,用户看到的是什么?结果里的出错标记有人检查吗?
·第 11 行中危
点了“停止”,服务器上的模型还在跑
页面上的"停止"只断开了浏览器这一头。服务端没有监听连接断开,也没有把中止信号传给模型调用:模型继续生成、继续计费,而这一次的额度在开始时就已经扣掉了。
该问的话:用户点停止之后,服务器上的模型调用会停下来吗?怎么验证?
·第 16 行低危
等待提示是静止的
"思考中"原本带着闪动的省略号,样式在一次重构里被删掉了,现在是三个静止的字。首字要等七八秒,静止的提示让人分不清是在想,还是卡住了。
该问的话:等待的这几秒里,页面上有什么在动?
·第 17 行中危
晚到的错误,把写了一半的回答换掉了
出错事件会替换掉最后一条消息。如果回答已经写了一半才出错,这一半内容会被一句"提示:……"整个覆盖,用户连已经看到的部分都没了。
该问的话:回答写到一半出错时,已经显示的内容还在吗?
·第 20 行中危
注释说忽略半包,实际吞掉的是别的错误
半截的行早就被上面的缓冲逻辑留到下一轮了,走不到这里。这个 catch 真正吞掉的,是处理事件的回调里抛出的异常:页面更新出了错,控制台里一个字都不会留下。
该问的话:这个 catch 实际会接住哪些错误?接住之后有没有记录?
查看代码与答案(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 秒,然后整段回答一下子出现。最该查哪里?
你可能是这么想的:接口本身已经是流式的(SSE),卡的是上游只在最后才交出完整的一条消息。换传输方式,交出来的仍是一整条。
对了。这一次就是 SDK 的增量输出选项没有打开:它默认要等一条完整的消息生成完,才一次性交出。
你可能是这么想的:第一版测下来,接口输出的整段回答只有 1 片,前端没有可以边收边画的东西。打开增量输出之后,才变成 99 片。
流式要每一段都在流,任何一段攒着都不行。
查看选项与答案
- A. SSE 够不够实时,要不要换成 WebSocket——接口本身已经是流式的(SSE),卡的是上游只在最后才交出完整的一条消息。换传输方式,交出来的仍是一整条。
- B. 上游是不是要等整条消息生成完,才一次性交给后面的环节(正确)——这一次就是 SDK 的增量输出选项没有打开:它默认要等一条完整的消息生成完,才一次性交出。
- C. 前端是不是等全部内容都到齐,才一次性渲染出来——第一版测下来,接口输出的整段回答只有 1 片,前端没有可以边收边画的东西。打开增量输出之后,才变成 99 片。
流式要每一段都在流,任何一段攒着都不行。
2. 流式输出能让什么变短?
你可能是这么想的:模型生成整段回答的时间基本不变,流式只是不等写完才交出来。总耗时没有明显变短,变的是等待时有东西可看。
你可能是这么想的:费用看生成的 token 数,同一段回答生成多少就是多少,分不分片传输都一样。
对了。第一个字早早出现,用户从那一刻起就开始读了。
流式改变的是等待的感受,不是等待的总长。
查看选项与答案
- A. 整段回答的总耗时,因为模型能边想边输出——模型生成整段回答的时间基本不变,流式只是不等写完才交出来。总耗时没有明显变短,变的是等待时有东西可看。
- B. 每次调用的费用,因为数据是分片传的,更省流量——费用看生成的 token 数,同一段回答生成多少就是多少,分不分片传输都一样。
- C. 用户盯着空白页、等第一个字出现的那段时间(正确)——第一个字早早出现,用户从那一刻起就开始读了。
流式改变的是等待的感受,不是等待的总长。
3. 首字要等 13 秒,因为模型每次都先调用工具去查词条,而这个词条就在读者正在看的页面上。怎么优化最有效?
你可能是这么想的:调长超时,不会让它变快,只是等得更久也不报错。
你可能是这么想的:首字时间取决于第一个字什么时候出现,和回答长短无关。前面那次查词条的往返,照样要等。
对了。词条就在读者正在看的页面上,模型不必再去查一遍。这一次,首字从 13 秒降到了 7 秒左右。
先看首字时间花在了哪里,常常能找到一次可以省掉的往返。
查看选项与答案
- A. 把查词条工具的超时时间调长,别让查询中途被中断——调长超时,不会让它变快,只是等得更久也不报错。
- B. 让模型只输出更短的回答,这样首字自然就会更快——首字时间取决于第一个字什么时候出现,和回答长短无关。前面那次查词条的往返,照样要等。
- C. 把页面上的词条内容直接放进提示词,省掉查询(正确)——词条就在读者正在看的页面上,模型不必再去查一遍。这一次,首字从 13 秒降到了 7 秒左右。
先看首字时间花在了哪里,常常能找到一次可以省掉的往返。
判断时刻
AI 导师回答一次要十几秒。上线前你只有半天时间,AI 给了三个方案。
你会选哪一个?
考察:安抚等待
最快,至少让人知道没卡死。但十几秒盯着一个转圈,很多人还是会走;总时间也一点没变。
考察:让等待有内容
首字从 17 秒降到 13 秒,之后字是一点点冒出来的,用户从第一个字就开始读。要注意把每一段都打通:SDK、服务端、代理、页面。
考察:省掉一次往返
首字再降到 7 秒左右:模型不必先去查一遍页面上已有的内容。代价是提示词变长,每次多花一点 token。这次最后就是这么做的。
慢是模型的事,卡是体验的事。先让第一个字尽快出现,再看首字时间里有哪些往返可以省掉。
三个选项各自的代价
- A. 只在页面上加一个转圈的加载动画——考察安抚等待:最快,至少让人知道没卡死。但十几秒盯着一个转圈,很多人还是会走;总时间也一点没变。
- B. 打开增量输出,服务端逐段推送,页面边收边显示——考察让等待有内容:首字从 17 秒降到 13 秒,之后字是一点点冒出来的,用户从第一个字就开始读。要注意把每一段都打通:SDK、服务端、代理、页面。
- C. 在上一个方案的基础上,把当前页面的资料直接放进提示词——考察省掉一次往返:首字再降到 7 秒左右:模型不必先去查一遍页面上已有的内容。代价是提示词变长,每次多花一点 token。这次最后就是这么做的。
慢是模型的事,卡是体验的事。先让第一个字尽快出现,再看首字时间里有哪些往返可以省掉。
带走
下次让 AI 做这件事时,问它
- 这个 AI 功能的首字时间和总耗时分别是多少?请在线上实测几次。
- 从模型到页面,每一段都在逐段传递吗:SDK、服务端、中间的代理、前端?
- 用户点“停止”之后,服务器上的模型调用会停下来吗?这一次的额度怎么算?
- 回答写到一半出错时,页面怎么显示?已经出来的内容还保留吗?
自己验证
- 用
curl -N直接请求流式接口,看数据是一段一段到,还是最后一次性到。 - 在浏览器开发者工具的“网络”面板里看这个请求的时间分解:等了多久才收到第一个字节。
- 回答到一半时点“停止”,再去服务器日志里看这次调用有没有提前结束。
慢是模型的事,卡是体验的事:先让第一个字尽快出现。
