普通响应等生成结束后返回整段答案。流式响应在生成过程中分段发送内容,客户端收到一段就展示一段。

两种方式的区别可以通过一个本地服务观察:让同一份文本按相同节奏产生,分别一次返回和分段返回,比较首段内容与完整答案到达的时间。

生成与交付

模型以 Token 为单位生成文本,Token 是分词器编码后的文本单位。响应接口负责把生成结果交给客户端。

普通接口收集生成结果,结束后返回完整消息。例如,服务端返回 {"text": "完整答案"},客户端解析 JSON 后展示正文。

流式接口先发送响应头,在生成过程中向响应正文写入数据。客户端读取文本增量,追加到界面。两种处理过程如下:

普通响应:产生片段1 → 产生片段2 → 产生片段3 → 返回完整结果
流式响应:产生片段1并发送 → 产生片段2并发送 → 产生片段3并发送 → 发送完成标记

这些片段沿同一条 HTTP 响应发送,不需要为每个片段重新请求。“一次返回”描述应用何时取得完整结果,不描述网络包的数量;普通响应在网络传输中也有分包。

本例固定文本,将交付方式作为对照变量。真实模型调用受采样、模型版本等因素影响,两次请求的答案不保证逐字相同。

本地对照实验

程序每隔 0.5 秒产生一个片段,共三段:

片段1:服务端分段发送内容。
片段2:客户端接收并展示片段。
片段3:收到结束事件后,标记回答完成。

/normal 收齐三段内容后返回 JSON,/stream 每取得一个片段就发送一条事件。两条路径共用 generate(),拼接后的正文一致。

实验使用 Python 3.10 及以上和 curl,不调用模型 API。服务只监听本机,代码保存为 response_demo.py:

import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 
 
PARTS = (
    "服务端分段发送内容。",
    "客户端接收并展示片段。",
    "收到结束事件后,标记回答完成。",
)
 
 
def generate():
    for part in PARTS:
        time.sleep(0.5)
        yield part
 
 
class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
 
    def send_event(self, event, data):
        payload = json.dumps(data, ensure_ascii=False)
        message = f"event: {event}\ndata: {payload}\n\n"
        self.wfile.write(message.encode("utf-8"))
        self.wfile.flush()
 
    def do_GET(self):
        if self.path == "/normal":
            text = "".join(generate())
            body = json.dumps({"text": text}, ensure_ascii=False).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif self.path == "/stream":
            self.close_connection = True
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream; charset=utf-8")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "close")
            self.end_headers()
            try:
                for part in generate():
                    self.send_event("delta", {"text": part})
                self.send_event("done", {})
            except (BrokenPipeError, ConnectionResetError):
                pass
        else:
            self.send_error(404)
 
 
if __name__ == "__main__":
    with ThreadingHTTPServer(("127.0.0.1", 8000), Handler) as server:
        print("Listening on http://127.0.0.1:8000", flush=True)
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            pass

在文件所在目录运行:

python3 response_demo.py

服务输出 Listening on http://127.0.0.1:8000 后,另开终端请求普通接口:

curl -sS -N http://127.0.0.1:8000/normal

等待约 1.5 秒,得到完整结果:

{ "text": "服务端分段发送内容。客户端接收并展示片段。收到结束事件后,标记回答完成。" }

再请求流式接口:

curl -sS -N http://127.0.0.1:8000/stream

前三条事件每隔约 0.5 秒到达,结束事件在第三条之后发出:

event: delta
data: {"text": "服务端分段发送内容。"}
 
event: delta
data: {"text": "客户端接收并展示片段。"}
 
event: delta
data: {"text": "收到结束事件后,标记回答完成。"}
 
event: done
data: {}
 

流式正文采用 SSE(Server-Sent Events)格式:event 表示事件类型,data 携带数据,空行分隔事件。代码将新增文本命名为 delta,完成事件命名为 done,并用 JSON 编码数据。这些业务约定由本例定义,SSE 不规定事件必须叫什么,也不要求数据采用 JSON。格式见 SSE 标准。

本例按句子分段。模型接口的文本增量由服务决定,不要求与 Token、词或句子的边界对应。

普通路径中的 join(generate()) 等待三段内容产生后返回,流式路径在循环里写入、刷新。这里决定了发送时机。HTTP 响应的结束方式也不同:普通路径用 Content-Length 标明正文长度,流式路径在完成事件之后关闭连接。相关行为见 Python HTTP 服务文档和 HTTP/1.1 正文长度规则。

-N 关闭 curl 的输出缓冲,收到的数据直接打印,参数含义见 curl 文档。若流式内容在结尾成批出现,检查服务端是否逐段写入、客户端是否逐段读取;经过代理时,检查代理的响应缓冲配置。

演示结束后,在服务终端按 Ctrl+C 停止。若 8000 端口已占用,修改脚本中的监听端口,并同步修改请求地址。

首段等待与完整耗时

三次等待各占 0.5 秒,对应的时间如下:

方式收到第一段可展示内容收到完整答案
普通响应约 1.5 秒约 1.5 秒
流式响应约 0.5 秒约 1.5 秒

时间来自程序设定,不代表模型性能。运行结果还包含连接、调度和传输开销。流式将第一段内容的交付提前了约一秒,三段内容的产生时间没有改变。

首段正文到达和完整响应结束,应分开计时。本例的流式响应先发送响应头,等待 0.5 秒后发送正文。HTTP 首字节时间(TTFB)计算发出请求到收到首字节的耗时,与首段正文到达时间不同。测量首段正文时,应排除不含正文的角色、状态等元数据事件。

实际应用中,检索、工具执行或模型推理会占用正文生成前的时间。排查首段延迟时,分别记录这些步骤的耗时。应用服务取得上游片段后,需要向浏览器转发;如果先收齐上游结果再返回,浏览器等待的就是完整结果。

片段组装与完成状态

客户端按事件更新回答:收到 delta,追加其中的 text;收到 done,标记完成。如果连接在第二条事件后断开,保留已收到的内容,并将回答标记为中断。

网络读取不以 SSE 事件为边界。一条事件可能跨多次读取,一次读取也可能包含多条事件。客户端先增量解码字节,保留尚未收齐的文本,按空行取出完整事件,再解析其中的 JSON。

文本片段用于生成中的展示。JSON、工具调用参数和入库数据,需要在组装完成并通过校验后交给业务处理。

普通接口在发送响应前确定 HTTP 状态码。流式接口发出 HTTP 200 后,后续错误通过流内事件通知;连接异常断开且没有完成标记时,客户端按中断处理。

本例的 done 表示固定文本已发送完。接入模型服务时,按服务协议处理停止原因和最终用量。例如,DeepSeek 在 [DONE] 前的最后一个内容块返回整次调用的 usage,约定见 接口文档。费用按实际输入、输出、工具用量和计价规则核算,不按界面已显示的文字核算。

用户点击停止后,应用需要把取消传到后端任务和上游模型调用。关闭客户端连接与取消生成任务是两个操作,后者由应用的取消逻辑负责。

响应方式的选择

长文本回答适合边生成边阅读。使用流式,需要增加事件解析、片段组装、完成判断和取消处理,界面区分“生成中”“已完成”“中断”等状态。

短分类和少量字段没有多少内容可提前展示。需要整份 JSON 校验后入库的任务,要等数据收齐;普通响应省去了片段组装环节。

离线批处理没有用户等待中间内容,可以按完整结果组织流程。交互式任务则测量首段等待和完整耗时,判断提前展示片段带来的收益是否值得增加这套处理逻辑。