LLM 流式输出完全指南:从一次"前端等了 20 秒白屏"看懂 SSE 流式响应

2024 年我给产品做 AI 对话助手,第一版很朴素:等大模型整段答案生成完再一次性返回。同事试用时盯着空白对话框干等了二十秒,扭头问我是不是卡死了。我想到该做流式,以为就是把后端 return 改成 yield、前端循环读一下。结果改完问题一个接一个:套了 Nginx 流式完全没生效,还是白屏然后整段蹦出;前端偶发 JSON.parse 报错本地复现不出;模型生成到一半报限流错前端直接僵死;用户关了页面后端还在继续烧 token。本文把 LLM 流式输出从头梳理。为什么必须流式:它不让回答变快,但把首字延迟 TTFT 从十几秒压到一秒内,用持续反馈取代漫长白屏。SSE 是什么:服务端单向推送协议,本质是一个一直不结束的 HTTP 响应,每条消息以 data 开头、空行结尾,data 内容要用 JSON 包一层防 token 含换行破坏分帧。服务端:大模型传 stream=True 配合 FastAPI 的 StreamingResponse,拿到一片 token 就立刻 yield。客户端:别用原生 EventSource——它只能 GET、带不了请求体和鉴权头,要用 fetch 加 ReadableStream 自己读。最隐蔽的坑:read 拿到的数据块边界由网络决定,根本不等于一条 SSE 消息,必须用缓冲区拼接碎片再按空行切出完整消息,否则线上偶发解析崩溃。三个工程坑:流中途出错时 200 早已发出无法用状态码表达,要在流内部发 error 消息;客户端用 AbortController 取消、服务端用 is_disconnected 感知断开止损;Nginx 默认缓冲响应会让流式彻底失效要 proxy_buffering off。核心一句:流式不是把 return 改成 yield 的小改动,而是一次通信模型的切换——它把有头有尾的包裹换成了边界要你自己重建的管道。

2024 年我给一个产品做 AI 对话助手,第一版很朴素:前端发问题,后端调大模型,等模型把答案整段生成完,再一次性返回给前端渲染。功能跑通那天我挺满意——直到把它给同事试用。一个同事问了个稍微复杂点的问题,然后我看着他盯着那个空白的对话框,一秒、两秒、五秒、十秒……整整二十秒,页面上什么都没有,他扭头问我"是不是卡死了"。问题很清楚:大模型生成一段长回答要十几二十秒,而我让用户对着白屏干等了这十几二十秒。我马上想到该用"流式输出"——像 ChatGPT 那样,一个字一个字往外蹦。我以为这事很简单:把后端的 return 改成 yield,前端循环读一下就行。结果改完之后,问题一个接一个地冒出来:部署到线上,套了一层 Nginx,流式完全没生效,还是白屏二十秒然后整段蹦出来;好不容易绕过 Nginx,前端又开始偶发 JSON.parse 报错,本地怎么都复现不出来;有一次大模型生成到一半报了限流错误,前端就那么僵住了,既不报错也不结束;还有用户中途关掉了对话页,我却发现后端还在继续调用大模型、继续烧 token。盯着这些问题我才反应过来:我把"流式"想得太简单了。流式输出不是"把 return 换成 yield"这么一个小改动——它把"一次完整的响应"彻底变成了"一条持续的数据流",而一条流的错误处理、缓冲、边界、取消,和一次普通的请求-响应完全是两套规则。那次之后我才认真把 LLM 流式输出这套东西从头搞明白。这篇文章就把它梳理一遍:为什么必须流式、SSE 是什么、服务端和客户端分别怎么写、数据为什么会"切断",以及把流式对话真正做稳要避开的那些坑。

问题背景

先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:AI 对话助手用"等模型生成完再整段返回"的方式,用户提问后要对着白屏干等十几二十秒。改成流式后,又接连出现:套了 Nginx 流式失效、前端偶发 JSON.parse 报错、模型中途出错前端僵死、用户关页面后端仍在烧 token。

我当时的错误认知:"流式输出就是把后端的 return 改成 yield,前端循环读一下就行,是个小改动。"

真相:流式把"一次完整的响应"变成了"一条持续的数据流"。一旦流开始——HTTP 状态码已经发出、响应头已经定死——它就进入了一套全新的规则:错误不能再用状态码表达、数据块的边界由网络决定而不是由你决定、中间代理的缓冲会让流形同虚设、连接的生命周期需要你主动管理。"把 return 改成 yield"只是这套规则的第一行代码,真正的工程量全在后面。

要把流式对话做稳,需要几块认知:

  • 为什么 LLM 应用几乎必须做流式,它解决的核心指标是什么;
  • SSE(Server-Sent Events)是什么,它的报文长什么样;
  • 服务端怎么把大模型的输出边生成边吐出去;
  • 客户端为什么不能用原生 EventSource,该怎么读这条流;
  • 数据块为什么会被"切断",中途出错、客户端取消、Nginx 缓冲这些坑怎么处理。

一、为什么必须流式:首字延迟与那块"白屏"

先想清楚流式到底解决什么问题,后面所有的复杂度才值得付出。

大模型生成文字是逐个 token 往外蹦的——它本来就是一个字一个字算出来的,生成一段三百字的回答,内部要算十几秒。问题在于:非流式的做法,是在服务端把这十几秒全部攒着,等最后一个字算完,才把整段话一次性发给前端。于是用户感知到的等待时间,就是这完整的十几二十秒,而且这期间屏幕上什么都没有

这里有一个关键指标叫 TTFT(Time To First Token,首字延迟)——从用户提问到屏幕上出现第一个字的时间。非流式方案的 TTFT,等于整段话的总生成时间;而流式方案的 TTFT,只是模型算出第一个 token 的时间,通常一秒之内。总生成时间没变,但用户的感受天差地别:文字像打字一样持续往外流,他从第一秒就知道"它在干活、它在回答我",而不是对着一块白屏怀疑系统卡死了。

下面这段就是那个让同事干等二十秒的"反面教材"——它的问题不在于慢,而在于它把所有的慢都攒成了一坨砸给用户:

from fastapi import FastAPI
from openai import OpenAI

app = FastAPI()
client = OpenAI()


@app.post("/chat-blocking")
def chat_blocking(prompt: str):
    # 反面教材:等大模型把整段话全部生成完,才一次性返回。
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    # 这一行要等到模型吐完最后一个字才会执行 ——
    # 模型生成 300 字要十几秒,用户就对着白屏干等十几秒。
    return {"answer": resp.choices[0].message.content}

所以对一个 LLM 应用来说,流式几乎不是"要不要做"的选择题,而是"必须做"的及格线。它不会让回答变快一毫秒,但它把"漫长的等待"换成了"持续的反馈"——这就是它全部的价值,也是值得我们为它付出后面那一堆工程复杂度的原因。

二、SSE 是什么:服务端单向推送的简单协议

要做流式,得先选一个"能让服务端持续往客户端送数据"的通道。常见的有三种:轮询太笨;WebSocket 是全双工长连接,功能强但偏重;而 LLM 流式输出的场景其实很单纯——只需要服务端单向地、持续地把 token 推给客户端,客户端不需要在这条流上反向说话。对这种"单向推送",有一个比 WebSocket 轻得多的标准方案:SSE(Server-Sent Events,服务器发送事件)

SSE 的本质朴素得让人意外:它就是一个一直不结束的 HTTP 响应。普通 HTTP 响应是"发完响应体就关闭";而 SSE 响应把 Content-Type 声明成 text/event-stream,然后一直把连接开着,服务端有新数据就往这个还没关闭的响应体里继续写。客户端则一直读着这个永远没读完的响应。

它的报文格式也极简单:响应体是一行行文本,每一条消息以 data: 开头,以一个空行(连续两个换行 \n\n)作为这条消息的结束。下面是一次真实 SSE 响应的样子:

# 一次 SSE 响应:HTTP 头部声明 text/event-stream,响应体是连续不断的事件流
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {"delta": "检"}

data: {"delta": "索增"}

data: {"delta": "强生成"}

data: [DONE]

注意两个细节。其一,每条 data: 之间隔着一个空行,这个空行就是消息边界,后面客户端解析全靠它。其二,这里的 data: 内容我都包了一层 JSON({"delta": "检"}),而不是直接写裸文本——这是个有意的选择:大模型吐出的 token 里随时可能带换行符(比如它在写代码),而换行符在 SSE 里是有特殊含义的,裸文本直接塞进去会破坏分帧。用 JSON 包一层,换行就被转义成 \n 字面量,安全了。最后用一个约定好的 [DONE] 标记告诉客户端"流结束了"。

三、服务端:把大模型的输出边生成边吐出去

选定 SSE,服务端要做的事就清晰了:调用大模型时开启流式,每从模型那里拿到一小段 token,就立刻按 SSE 格式包成一帧、吐给客户端,而不是攒着。

这里依赖两个东西。一是大模型 API 的流式模式——调用时传 stream=True,返回的就不再是一个完整结果,而是一个可以逐块迭代的流。二是 Web 框架的流式响应能力——以 FastAPI 为例,它提供 StreamingResponse,接收一个生成器,生成器 yield 一点,它就往连接里发一点,不等生成器跑完。两者一对接,token 就能从模型一路"流"到客户端:

import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI

app = FastAPI()
client = AsyncOpenAI()


async def llm_stream(prompt: str):
    """异步生成器:每从大模型拿到一小段 token,就立刻 yield 一帧 SSE。"""
    stream = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        stream=True,                       # 关键:开启大模型的流式模式
    )
    async for chunk in stream:
        delta = chunk.choices[0].delta.content
        if delta:
            # 用 JSON 包一层:token 里可能含换行,裸塞进 data: 会破坏 SSE 分帧
            yield f"data: {json.dumps({'delta': delta})}\n\n"
    yield "data: [DONE]\n\n"                # 约定一个结束标记


@app.post("/chat")
async def chat(prompt: str):
    # StreamingResponse:不等生成器跑完,它 yield 一帧就发一帧
    return StreamingResponse(llm_stream(prompt),
                             media_type="text/event-stream")

这段代码的精髓全在 async for 那个循环里:它每一轮拿到的 delta 只是答案的一小片(可能就一两个字),拿到就立刻 yield 出去,绝不等下一片。StreamingResponse 收到这一帧就立刻发往客户端。于是模型算出第一个字的那一刻,这个字就已经在去往用户屏幕的路上了——TTFT 被压到了一秒以内。服务端的逻辑到这儿其实已经成型,看起来也不复杂。但真正的麻烦,在客户端那一侧,以及这条流途经的每一个中间环节。

四、客户端:为什么不能用原生 EventSource

说到在浏览器里接收 SSE,很多人第一反应是浏览器原生的 EventSource 对象——它就是为 SSE 而生的,几行代码就能用。但在 LLM 对话这个场景里,它有一个绕不过去的硬伤

EventSource 只能发起 GET 请求,无法携带请求体,也无法自定义请求头。可 LLM 对话的输入是什么?是一段可能很长的 prompt,往往还附带多轮对话历史——这种数据只能塞进 POST 的请求体,不可能塞进 URL。况且接口通常还要带 Authorization 头做鉴权,EventSource 也加不了。所以它从一开始就不适配:

// 反面教材:浏览器原生 EventSource 看似为 SSE 而生,却有个硬伤 ——
// 它只能发 GET 请求,无法携带请求体、无法自定义请求头。
const es = new EventSource("/chat?prompt=hello");
es.onmessage = (e) => console.log(e.data);

// 问题:LLM 对话的 prompt 往往很长、还带多轮历史,只能塞进 URL query,
// 既有长度上限,又把对话内容暴露在了 URL 里(会被日志、缓存记下来)。
// 而且它带不了 Authorization 头。
// 结论:真实的 LLM 流式接口,客户端要用 fetch + ReadableStream 自己读。

正确的做法是用 fetchfetch 能 POST、能带请求体和请求头,而它返回的响应对象有一个 body 属性,是一个 ReadableStream——一个"可以一边到达一边读"的流。我们拿到它的 reader,循环调用 read(),数据来一块就读一块:

async function streamChat(prompt) {
  const resp = await fetch("/chat", {
    method: "POST",                       // fetch 可以 POST,能带请求体和头部
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
  });

  // resp.body 是一个 ReadableStream,可以一边到达一边读
  const reader = resp.body.getReader();
  const decoder = new TextDecoder();      // 把二进制字节解码成文本

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;                      // 流读完了,连接关闭
    const text = decoder.decode(value, { stream: true });
    console.log("收到一段:", text);        // 注意:这一段不一定是完整的 SSE 消息
  }
}

这段代码能跑,流式效果也出来了。但请记住最后那行注释里的警告——read() 每次拿到的 text,不一定是一条完整的 SSE 消息。这正是下一节那个"偶发 JSON.parse 报错"的根源。

五、最隐蔽的坑:数据块会被"切断"

这是整个流式输出里最反直觉、也最容易栽的一个坑。开头那个"本地复现不出来的偶发 JSON.parse 报错",根子就在这里。

问题的本质是:服务端"发送"的边界,和客户端"收到"的边界,根本不是一回事。服务端 yield 了一帧 data: {"delta": "强生成"}\n\n,你以为客户端的一次 read() 就会完整地收到这一帧。但不是——数据在网络上是字节流,它怎么被切成一块块送达,由 TCP、由网络状况、由中间设备决定,跟你的消息边界毫无关系。一次 read() 可能拿到半条消息,也可能一次拿到两条半。本地测试网络又快又稳,一帧基本就是一次 read(),所以怎么测都正常;一到真实网络,消息被拦腰切开,客户端拿着半截 data: {"delta": "强生JSON.parse,当场报错。

解法是固定的:不要假设"一次 read 等于一条消息",而要用一个缓冲区。每次 read() 到的碎片都先拼进缓冲区,然后按 SSE 的消息分隔符——空行 \n\n——去切;切出来的、\n\n 之前的部分才是完整消息,而最后那段不完整的尾巴留在缓冲区里,等下一次 read() 的数据来接上:

// 关键:reader.read() 拿到的每一块,边界由 TCP、网络决定,
// 完全可能把一条 "data: ...\n\n" 从中间切断,或一次塞来好几条。
// 必须用缓冲区把碎片拼起来,再按空行 \n\n 切出完整消息。
async function streamChat(prompt, onDelta, onError) {
  const resp = await fetch("/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
  });
  const reader = resp.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";                        // 跨多次 read 累积的缓冲区

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

    // 按 SSE 消息分隔符 \n\n 切;最后一段可能不完整,留回 buffer
    const parts = buffer.split("\n\n");
    buffer = parts.pop();                 // 不完整的尾巴留到下一轮再拼

    for (const part of parts) {
      if (!part.startsWith("data: ")) continue;
      const data = part.slice(6);         // 去掉 "data: " 前缀
      if (data === "[DONE]") return;      // 收到结束标记,正常结束
      const msg = JSON.parse(data);       // 此刻才是完整消息,解析安全
      if (msg.error) onError(msg.error);
      else onDelta(msg.delta);            // 把这一小片追加渲染到界面
    }
  }
}

这段 buffer 的处理是流式客户端的标准骨架,一个字都不能省。它的核心思想就一句话:网络给你的是字节流,消息边界要你自己重建。把这一步漏掉,你的流式对话会在 Demo 里完美无瑕,然后在真实用户的网络里随机崩溃。

六、工程坑:流中途出错、客户端取消、Nginx 缓冲

骨架搭好了,但要把流式对话真正放进生产,还有几个绕不开的坑。每一个都对应着开头那场事故里的一个症状。

坑 1:流中途出错,无法再用状态码表达。这是流式和普通请求最本质的区别。普通请求出错,你返回个 500,客户端一看状态码就知道。但流式响应,HTTP 状态码 200 在第一帧发出时就已经定死了——此后大模型若中途报错(限流、超长、内容审核),你根本没法把 200 改回 500。唯一的办法,是在流内部发一条约定好的 error 消息,让客户端识别它。开头那个"模型出错前端僵死",就是因为服务端 error 后直接断流、什么都没发,客户端傻等:

async def llm_stream(prompt: str):
    try:
        stream = await client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            stream=True,
        )
        async for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                yield f"data: {json.dumps({'delta': delta})}\n\n"
        yield "data: [DONE]\n\n"
    except Exception as e:
        # 关键:此刻 200 状态码早已发出,无法再用 HTTP 状态表达错误。
        # 只能在流内部发一条约定好的 error 消息,客户端据此识别并展示。
        yield f"data: {json.dumps({'error': str(e)})}\n\n"

坑 2:客户端取消,服务端要能感知。用户看了两句不想看了,关掉对话、切走页面——如果客户端不主动中止请求,这条 HTTP 连接还挂着,服务端会继续调用大模型、继续烧 token,纯属浪费钱。客户端侧,要用 AbortController 在用户取消时中止 fetch:

// 客户端取消:用户点"停止生成"、或切走页面时主动中止请求,
// 否则服务端会继续调用大模型、继续烧 token。
const controller = new AbortController();

async function streamChat(prompt, onDelta, onError) {
  const resp = await fetch("/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
    signal: controller.signal,            // 把取消信号挂到这个请求上
  });
  // ……读取流、拼缓冲区的逻辑同第五节……
}

// 用户点了"停止生成",或对话组件卸载时调用:
function stopGenerating() {
  controller.abort();                     // 中止 fetch,底层连接随之断开
}

而服务端,要在每吐一帧之前检查客户端是否还在。FastAPI 的 Request 提供了 is_disconnected(),一旦发现客户端走了,立刻停止生成,把对大模型的调用也省下来:

from fastapi import Request

@app.post("/chat")
async def chat(request: Request, prompt: str):
    async def guarded_stream():
        async for frame in llm_stream(prompt):
            # 每吐一帧前先检查客户端是否已经断开
            if await request.is_disconnected():
                # 客户端走了,立刻停止 —— 不再消耗大模型的调用与 token
                break
            yield frame
    return StreamingResponse(guarded_stream(),
                             media_type="text/event-stream")

坑 3:Nginx 等反向代理的缓冲,会让流式彻底失效。这是开头"套了 Nginx 流式没生效"的真凶。Nginx 做反向代理时,默认会缓冲后端的响应:它会等后端把整个响应攒完,再一次性发给浏览器。这个行为对普通接口是优化,但对 SSE 是灾难——它把你辛辛苦苦做的流式,又变回了"白屏 + 整段一次性出现"。必须显式关掉代理缓冲:

# Nginx 反向代理:默认会"缓冲"后端响应 —— 等后端把整个响应攒完
# 再一次性发给浏览器,这会让流式输出彻底失效(又变回"白屏 + 一次性出现")。
location /chat {
    proxy_pass http://127.0.0.1:8000;
    proxy_buffering off;          # 关掉代理缓冲:后端吐一点就转发一点
    proxy_cache off;              # 关掉缓存
    proxy_read_timeout 300s;      # 流式响应耗时长,把读超时调大
    chunked_transfer_encoding on;
}
# 另一种办法:让后端在响应里加一个 X-Accel-Buffering: no 头,
# Nginx 见到它就会对这个响应单独关闭缓冲。

除了 Nginx,这一路上的 CDN、网关、甚至某些浏览器的压缩中间层,都可能有类似的缓冲行为。一个排查口诀:如果你确认服务端在持续 yield、客户端却总是"憋一会儿然后一大坨蹦出来",那基本就是中间某一层在缓冲。下面这张图,把一条流从用户提问到渲染完成的完整链路串起来:

关键概念速查

概念 / 手段 说明
TTFT 首字延迟 从提问到屏幕出现第一个字的时间,流式把它从十几秒压到一秒内
流式输出 把"一次完整响应"变成"一条持续数据流",边生成边发
SSE 服务端单向推送协议,本质是一个一直不结束的 HTTP 响应
text/event-stream SSE 响应的 Content-Type,声明这是事件流
data: 与空行 每条 SSE 消息以 data: 开头,以空行 \n\n 作为边界
StreamingResponse FastAPI 的流式响应,生成器 yield 一帧就发一帧
EventSource 浏览器原生 SSE 接收器,只能 GET、带不了请求体,不适配 LLM
fetch + ReadableStream 客户端正确方案:能 POST,逐块读 resp.body
缓冲区拼接 read() 边界由网络决定,须缓冲碎片再按 \n\n 切完整消息
proxy_buffering off 关闭 Nginx 代理缓冲,否则流式会被攒成整段

避坑清单

  1. LLM 应用几乎必须做流式;它不让回答变快,但把"漫长等待"换成"持续反馈",TTFT 从十几秒压到一秒内。
  2. 流式把一次完整响应变成一条持续数据流,它的错误处理、边界、缓冲、取消是另一套规则。
  3. SSE 用 text/event-stream,每条消息以 data: 开头、以空行 \n\n 结束;data 内容用 JSON 包一层,防 token 含换行破坏分帧。
  4. 服务端调大模型传 stream=True,配合 FastAPI 的 StreamingResponse,拿到一片 token 就立刻 yield。
  5. 客户端别用原生 EventSource,它只能 GET、带不了请求体和鉴权头;要用 fetch + ReadableStream。
  6. read() 拿到的块边界由网络决定,绝不等于一条 SSE 消息;必须用缓冲区拼接、按 \n\n 切出完整消息再解析。
  7. "本地正常、线上偶发 JSON.parse 报错"几乎一定是漏了缓冲区拼接,半条消息被拿去解析了。
  8. 流中途出错时 200 状态码早已发出,无法用状态码表达;要在流内部发一条约定的 error 消息让客户端识别。
  9. 客户端用 AbortController 在用户取消时中止 fetch;服务端用 is_disconnected() 感知断开,及时停止烧 token。
  10. Nginx 等代理默认缓冲响应会让流式失效,要 proxy_buffering off 或加 X-Accel-Buffering: no 头。

总结

回头看那个让同事对着白屏干等二十秒的第一版,以及后面接二连三的线上问题,最该记住的不是某一段缓冲区代码,而是我动手前那个想当然的判断——"流式输出就是把 return 改成 yield"。这句话错在它把流式当成了一个局部的小改动,而它其实是一次通信模型的切换。普通的请求-响应,是一个有头有尾、状态码确定、边界清晰的"包裹";而流是一条持续的、没有终点保证的、边界要你自己重建的"管道"。你用对待包裹的思路去对待管道,它就会在你看不见的地方——真实网络、中间代理、异常时刻——一个接一个地漏。

所以做 LLM 流式输出,真正的工程量根本不在"怎么开始流"。stream=Trueyield 谁都会写,Demo 里它也确实能一个字一个字往外蹦。真正的工程量在流开始之后:数据块被网络切断了你怎么把它拼回来、模型中途报错了你怎么在已经发出 200 的流里告诉客户端、用户中途走了你怎么感知并止损、中间的 Nginx 偷偷缓冲了你怎么发现。这篇文章的几节,其实就是顺着一条流的生命周期展开的:先想清楚为什么要流式、SSE 是什么;再看服务端怎么吐、客户端怎么读;然后是那个最隐蔽的"数据被切断"的坑;最后是出错、取消、缓冲这几个把它真正做稳的工程细节。

你会发现,这套思路和我们处理任何"流"的经验都是相通的。读一个大文件,你不会假设一次 read 就读完;处理一个消息队列,你不会假设每条消息严丝合缝;做断点续传,你必须自己记录边界。流式输出只是把这种"流的思维"搬到了 LLM 应用里——网络只负责把字节送到,至于这些字节哪里是一条消息的开头、哪里是结尾、中间出了错怎么办,从来都是应用层自己的责任。大模型让"生成一段漂亮的文字"变得几乎免费,但把这段文字稳稳地、一个字一个字地、在各种异常下都不出岔子地送到用户眼前,依然是一件需要老老实实做工程的事。

最后想说,流式做没做扎实,差距永远不会在 Demo 里暴露——Demo 的网络又快又稳、不套代理、用户也不会中途退出,怎么写都丝滑。它只在真实用户的弱网、真实的反向代理、真实的模型限流、真实的"看两眼就关掉"面前才显形。那时候它会一次性给你三张账单:线上偶发的解析崩溃(漏了缓冲拼接)、出错时的界面僵死(没在流里发 error)、以及一笔笔为已经离开的用户白烧的 token(没做取消感知)。所以别等用户报"它怎么又卡住了",在你写下第一行 stream=True 的时候就该想清楚:这条流被切断了我接得住吗?它中途出错了我说得出口吗?用户走了我停得下来吗?这三个问题都有答案,你的流式对话才不只是 Demo 里那一段漂亮的打字机效果,而是一个能在真实世界里稳稳托住每一个字的系统。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Redis 缓存三大杀手完全指南:从一次"缓存挂了数据库被打垮"看懂穿透、击穿、雪崩

2026-5-21 18:36:13

技术教程

分布式锁完全指南:从一次"两个人买走同一件库存"看懂 Redis 锁的所有坑

2026-5-21 18:46:52

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索