流式对话接口怎么选:SSE 与 WebSocket 的原理、实现和工程边界

在一次性接口中,客户端发送请求,服务端生成完整结果后返回响应。这种模式适合查询、提交表单和读取固定资源,但不适合逐字输出、日志订阅、任务进度展示等场景:服务端可能需要较长时间才能得到最终结果,而用户更关心"已经生成了什么"。

工程上通常有三种做法:客户端轮询任务状态、服务端使用 SSE(Server-Sent Events)推送事件,或者使用 WebSocket 建立双向长连接。轮询实现简单,却会引入额外请求和状态延迟;SSE 更贴近"服务端持续推送";WebSocket 则提供了完整的双向消息通道。

协议选择不能只看"是否实时"。还应结合以下条件:

  • 服务端是否需要主动向客户端发送数据;
  • 客户端是否需要在连接建立后频繁发送消息;
  • 部署链路是否包含反向代理、网关或负载均衡;
  • 是否需要断线恢复、连接数控制和细粒度鉴权;
  • 一条连接的生命周期是几秒,还是需要长期保持。

本文以 Python 为例,演示两种协议的最小实现。示例中的模型或业务处理用模拟函数代替,实际接入时应将其替换为真实的任务执行逻辑。

二、SSE 与 WebSocket 的工作原理

1. SSE:基于 HTTP 的单向事件流

SSE 建立在普通 HTTP 响应之上。客户端发起一个 GET 请求,服务端不立即结束响应,而是持续写入符合事件流格式的数据。响应头通常包含 Content-Type: text/event-stream,每个事件以空行结束。

最基本的消息形式如下:

vbnet 复制代码
event: token
id: 42
data: {"text":"你好"}

客户端浏览器可以通过 EventSource 接收默认消息,或监听指定的 event 类型。SSE 的核心特点是:连接方向上,客户端主要负责建立和关闭连接,业务数据由服务端向客户端推送。浏览器原生支持自动重连,但服务端仍需设计事件编号和重复消费处理。

2. WebSocket:从 HTTP 升级到双向帧传输

WebSocket 通常先通过 HTTP 请求发起握手。握手成功后,连接升级为 WebSocket,后续消息不再遵循普通 HTTP 请求---响应模式,而是通过帧在同一条连接上双向传输。

它适合聊天、协同编辑、实时控制和需要客户端持续上行的场景。代价是连接管理更复杂:服务端要处理心跳、连接上限、广播、异常断开以及部署环境对升级请求的支持。

两者并不存在绝对的性能优劣。对于"提交一次任务,然后接收持续输出"的接口,SSE 往往更简单;对于双方都要持续发送业务消息的场景,WebSocket 更自然。这里的判断依赖业务消息方向和基础设施配置,不能仅凭协议名称下结论。

三、用 SSE 实现流式输出

下面使用 Flask 展示服务端。先安装依赖:

复制代码
python -m pip install flask

服务端将文本拆成若干片段,以事件形式逐次返回。time.sleep 只是模拟耗时操作,不代表实际生成速度。

python 复制代码
import json
import time
from flask import Flask, Response, request

app = Flask(__name__)

def generate_events(prompt):
    parts = ["已收到请求。", "正在处理:", prompt, "。"]
    for number, text in enumerate(parts, start=1):
        payload = {"text": text, "finished": False}
        yield f"id: {number}\n"
        yield "event: token\n"
        yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
        time.sleep(0.4)

    yield "event: done\n"
    yield f"data: {json.dumps({'finished': True})}\n\n"

@app.get("/stream")
def stream():
    prompt = request.args.get("prompt", "")
    if not prompt or len(prompt) > 2000:
        return {"error": "prompt is required and must be no longer than 2000 characters"}, 400

    response = Response(generate_events(prompt), mimetype="text/event-stream")
    response.headers["Cache-Control"] = "no-cache"
    response.headers["X-Accel-Buffering"] = "no"
    return response

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8000, threaded=True)

启动后,可以在浏览器控制台测试:

javascript 复制代码
const source = new EventSource(
  "/stream?prompt=" + encodeURIComponent("流式接口")
);

source.addEventListener("token", (event) => {
  const data = JSON.parse(event.data);
  console.log(data.text);
});

source.addEventListener("done", () => {
  console.log("完成");
  source.close();
});

source.onerror = () => {
  console.warn("连接异常,浏览器可能会尝试重连");
};

EventSource 会自动重连,但这不等于业务一定不会重复。服务端应使用 id 标识事件,并根据请求头中的 Last-Event-ID 决定是否能够从某个位置继续。若事件不可重放,应在协议中明确"断线后重新执行"或"断线后只能重新开始",不能假设客户端一定只收到一次。

四、用 WebSocket 实现双向通信

安装一个常见的异步 WebSocket 框架:

复制代码
python -m pip install fastapi uvicorn websockets

示例中,客户端可以先发送问题,服务端再逐条回传结果;连接期间客户端还可以发送 cancel 取消处理。

python 复制代码
import asyncio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

async def produce_answer(text, websocket):
    for part in ["已收到:", text, ",处理完成。"]:
        await websocket.send_json({"type": "token", "text": part})
        await asyncio.sleep(0.4)
    await websocket.send_json({"type": "done"})

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    task = None
    try:
        while True:
            message = await websocket.receive_json()
            message_type = message.get("type")

            if message_type == "question":
                text = str(message.get("text", ""))
                if not text or len(text) > 2000:
                    await websocket.send_json({"type": "error", "message": "invalid text"})
                    continue
                if task and not task.done():
                    await websocket.send_json({"type": "error", "message": "busy"})
                    continue
                task = asyncio.create_task(produce_answer(text, websocket))

            elif message_type == "cancel" and task:
                task.cancel()
                await websocket.send_json({"type": "cancelled"})

            else:
                await websocket.send_json({"type": "error", "message": "unknown message type"})
    except WebSocketDisconnect:
        if task and not task.done():
            task.cancel()

启动命令如下:

lua 复制代码
uvicorn app:app --host 127.0.0.1 --port 8000

浏览器客户端可以这样发送消息:

ini 复制代码
const socket = new WebSocket("ws://127.0.0.1:8000/ws");
socket.onopen = () => {
  socket.send(JSON.stringify({type: "question", text: "实时通信"}));
};
socket.onmessage = (event) => {
  const message = JSON.parse(event.data);
  console.log(message);
};

实际系统中不应仅依靠关闭连接来取消后端任务。任务执行器需要可取消,或至少能够检查取消标记;否则客户端断开后,服务端仍可能继续占用模型、线程或外部资源。

五、生产环境必须补齐的工程细节

1. 代理缓冲和超时

SSE 需要尽快把小片段发送到客户端。如果反向代理为了提高吞吐而缓存响应,用户可能直到缓冲区积累后才看到内容。部署时应确认代理允许流式响应,并关闭相应位置的缓冲;X-Accel-Buffering: no 只对支持该响应头的代理有意义,不能替代完整的代理配置。

长连接还会受到网关空闲超时影响。可以周期性发送注释心跳,例如 : heartbeat\n\n,但心跳只能维持连接活跃,不能解决后端任务永久阻塞的问题。

2. 鉴权与跨域

SSE 的原生 EventSource 对自定义请求头支持有限,跨域时还要考虑凭据、Cookie 的 SameSite 属性和服务端 CORS 配置。WebSocket 也不是"连接成功就可信":服务端应校验来源、会话或短期令牌,并限制消息大小、连接时长和每个用户的并发数。

不要把长期有效的密钥写在浏览器代码中。若需要鉴权,可由后端签发短期会话标识;服务端配置中的敏感值应从环境变量读取,例如:

ini 复制代码
export STREAM_AUTH_SECRET="replace-with-runtime-secret"

应用程序通过 os.environ["STREAM_AUTH_SECRET"] 读取,并在启动时检查变量是否存在。示例值只是占位符,不能直接用于生产环境。

3. 错误协议要统一

建议至少定义 tokendoneerrorcancelledheartbeat 等事件或消息类型。错误信息应包含稳定的错误码,例如 INVALID_INPUTUPSTREAM_TIMEOUT,而不是把内部堆栈直接返回给客户端。

对于已经输出部分内容后发生的错误,客户端不能简单地把已有文本当成完整结果。可以在 error 消息中携带 request_id 和是否可重试的标志,并由界面区分"部分结果"和"最终结果"。

4. 连接与任务要分离

长连接只是传输通道,不应承担全部任务状态。更稳妥的设计是:请求进入后生成 request_id,任务状态保存于可恢复的存储或任务系统,SSE/WebSocket 负责订阅输出。这样即使连接断开,也能查询任务状态或重新订阅。

如果使用多进程或多实例部署,单机内存中的连接表无法天然实现跨实例广播。此时需要粘性会话、共享消息中间件或专门的连接层,具体方案取决于部署拓扑和一致性要求。

六、常见问题

SSE 能替代 WebSocket 吗?

不能一概而论。若业务是客户端发起一次请求、服务端连续返回事件,SSE 通常足够;若客户端也要持续发送控制消息、光标位置或实时操作,WebSocket 更合适。也可以采用普通 HTTP 提交任务,再通过 SSE 订阅结果,以减少连接内的协议复杂度。

为什么服务端已经 yield,浏览器却一次性收到?

常见原因包括代理缓冲、响应压缩缓冲、应用服务器没有及时刷新,或事件格式缺少结尾空行。应逐层检查响应头、代理配置和客户端读取方式。若链路经过多个网关,任一层都可能改变实际行为。

自动重连会不会重复生成?

会有这种可能。自动重连只负责重新建立传输连接,不保证业务请求幂等。应为请求设置业务标识,服务端保存事件游标或任务状态;无法恢复时,要明确提示客户端重新执行,而不是静默拼接两次结果。

SSE 和 WebSocket 是否都需要心跳?

长连接通常都需要考虑活跃检测,但形式不同。SSE 可以发送注释事件,WebSocket 常用 Ping/Pong 或应用层心跳。心跳间隔要小于关键代理的空闲超时,同时避免在大量连接上造成不必要的负载;具体间隔应以实际网络设备配置为准。

能否把所有异常都交给客户端重试?

不建议。输入错误、权限错误和参数冲突通常不应重试;网络断开、暂时性上游超时在满足幂等条件时才可能重试。重试还应设置次数、退避时间和总时限,避免故障时形成请求风暴。

七、总结

SSE 是建立在 HTTP 之上的服务端事件流,适合单向持续输出,浏览器接入成本较低;WebSocket 通过协议升级提供双向通信,适合交互频繁的实时系统。真正的工程难点并不在于写出一个循环发送消息的示例,而在于处理代理缓冲、断线恢复、鉴权、心跳、取消、幂等和资源回收。

选择协议时,先画清消息方向和任务生命周期,再确认部署链路的超时与升级能力。无论使用哪一种协议,都应定义稳定的消息类型、错误码和请求标识,并把业务任务状态与传输连接分离。这样即使连接中断,系统也能解释发生了什么,并在允许的条件下继续工作。

相关推荐
QXWZ_IA2 小时前
如何防止第三方施工对油气管道的破坏?千寻智慧桩+北斗定位预警方案
人工智能·科技·智能硬件
天天进步20152 小时前
Pixelle-Video 源码解析 #14:直连 API 媒体模型:OpenAI、Wan、Kling、Seedance 如何接入?
人工智能·媒体
向成科技2 小时前
金融政务终端如何实现算力全覆盖?
人工智能·金融·政务·设备·一体机·主板·智能终端
a16252704632 小时前
环保与安全双重约束下全球煤炭供需收缩及其价格上行研究
大数据·人工智能
用户73499134716532 小时前
你的GNN可能只是一个昂贵的MLP:表格数据中的“因果边界“问题
人工智能
小码哥哥2 小时前
企业资料管理的「第三次浪潮」:当网盘遇上知识库,文件开始“会思考“
大数据·人工智能
auto_go2 小时前
大模型实战指南(15)——从单兵到生态:Agent 开发全景回顾与前沿展望
人工智能
克里斯蒂亚诺更新2 小时前
学习PyTorch
人工智能·pytorch·学习