简介:深度集成DeepSeek大模型的WebSocket流式聊天实现,是一份面向初、中级前端及全栈开发者的实战资源,帮助理解如何从零搭建基于大模型的实时聊天应用,同时掌握前后端协作的基本思路。资源包共13个文件,大小约30KB,压缩包内包含React组件(jsx/css/svg)、vite与package配置、Python后端脚本及README说明文档,项目结构清晰,前后端分离,便于按模块学习和快速部署。目前已有468人学习,热度稳定,适合作为课程设计、毕业设计或项目起步的参考。通过学习这份资源,可以掌握WebSocket双向通信的具体写法、DeepSeek接口的调用与鉴权方法、流式消息在前后端的传递处理,以及如何利用配置文件完成快速启动。代码量精简且注释明确,能有效缩短开发者对整套流程的熟悉周期,也为后续扩展多轮对话、上下文记忆等功能留出了清晰路径。
1. 聊天应用从“转圈等待”到“逐字返回”,为什么说 WebSocket 不是可选项而是必经之路
把 DeepSeek 接进聊天框,第一版大多用普通 HTTP 请求,点发送后卡在原地等 DeepSeek 把整段话生成完再一并返回。模型生成 500 字要 3 到 5 秒,用户盯着“发送中”的转圈,体验几乎等于把聊天产品做成了“提交表单”。要解决这个问题,最常见的做法是上 SSE(Server-Sent Events),让后端把 DeepSeek 的增量结果当成事件流推给前端。可一旦进入多轮会话、多端同时在线、对话中途点“停止生成”,SSE 的短板就出来了:它只能服务端单向推送,无法让客户端在生成过程中通过同一条连接发指令,断线后也没有统一的恢复原语,前端只能自己反复用 HTTP 轮询补齐数据。于是就有了本标题这套方案:深度集成 DeepSeek 大模型的 WebSocket 流式聊天实现,核心不是“把 API 接到 WebSocket 上”,而是把 DeepSeek 的 HTTP SSE 协议翻译成一套可双向通信、可心跳保活、可中断、可重连的实时消息链路。这适合那些聊天只是起点,后面还要做 Copilot 问答、长任务进度推送、多人协作面板的开发者。下面从协议设计、最小实现、参数边界、踩坑经验一步步拆开讲。
2. 先定协议再写代码:DeepSeek 的流式接口与 WebSocket 转发模型
2.1 DeepSeek 的流式接口长什么样:SSE 事件流和 delta 增量含义
DeepSeek 的 API 兼容 OpenAI 协议,所以它的流式接口本质是一个 HTTP POST 请求,请求体里把stream置为true,返回的Content-Type是text/event-stream。客户端会收到一串以data:开头的行,每行是一个 JSON 片段,直到收到data: [DONE]表示整个生成过程结束。
一段典型的 DeepSeek SSE 数据流是下面这个样子:
data: {"id":"chatcmpl-xxxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} data: {"id":"chatcmpl-xxxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]} data: {"id":"chatcmpl-xxxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":",今天"},"finish_reason":null}]} data: [DONE]这里需要理解的关键点是delta字段。它与普通补全不同,不给你完整句子,只给相对于上一条消息的新增片段。首个 chunk 的 delta 里通常带role,后续只带content。finish_reason在最后的有效 chunk 里是stop(正常结束)或length(因 max_tokens 截断)。
提示:如果用了 DeepSeek 的函数调用或工具调用能力,delta 里会出现
tool_calls字段,而不是content,这部分消息要单独路由,否则后端简单拼接 content 会把 tool_calls 丢掉。
2.2 为什么不用 SSE 直连浏览器:单向链路、断线无恢复、无法双向中断
有人会问,后端开个代理接口,前端用EventSource去接 SSE,不也能实现打字机效果吗?为什么非要用 WebSocket 包一层?
这个问题的答案取决于你的产品形态。如果只是“打开页面,问一句,看它慢慢打字”,SSE 确实够用。但一旦下面的需求出现哪怕一个,SSE 就会开始翻车:
第一,中断控制。想“停止生成”,SSE 没有标准的方式让服务端去取消上游请求,只能前端断开连接,后端再去取消。但前端断开之后,后端不一定能第一时间感知,API 请求可能还在继续烧你的 token。WebSocket 是双向的,前端可以发一条{"type":"cancel"}消息,后端收到后中断上游请求,我能把这次生成的 token 消耗及时掐断。
第二,断线恢复。SSE 断线后 EventSource 会自动重连,但会用 HTTP 重新发一次完整请求,这就产生了重复的上下文,DeepSeek 收到后会重新从零生成,之前的输出全部作废。WebSocket 可以在连接里携带session_id和消息序号,后端基于这个会话决定是续推还是重建,语义清楚得多。
第三,多端同步。聊天一旦上多端,比如用户在 PC 发起问答、在手机上继续看进度,SSE 推送的对象是“一个 HTTP 响应”,没有可寻址的通道概念。WebSocket 天然是“一条持久连接”,可以做多端路由和消息回放。
这也是本标题里“深度集成”四个字的含义:不是简单转发字节流,而是让 DeepSeek 的上游流式会话和你自己的 WebSocket 消息协议形成一一对应的生命周期关系。
2.3 转发模型:一条 WebSocket 对应一次或多次上游会话
常见的落地模型有两种。
第一种叫“一对一模型”,一条 WebSocket 连接对应当前正在进行的会话。用户打开聊天页就建立连接,每次发消息都通过这条连接上行,完成一轮后保持连接空闲,等下一轮。这种模型实现最简单,心跳也简单,缺点是同一个页面开了多个聊天窗口时,需要为每个窗口建一条连接。
第二种叫“多路复用模型”,一条 WebSocket 对应一个用户,消息体里带session_id区分不同会话。这种适合做侧边栏多会话入口,但需要自己做消息分发和背压控制,复杂度会上来。
我一般建议,第一版先做一对一模型,把协议里的session_id字段先留着并规范好格式,比如proj_{task_id}_{user_id},后续想升级多路复用不需要改协议,只需要改后端的路由 key。
下面给出后端和前端的消息格式定义。WebSocket 上行消息:
{ "type": "chat", "session_id": "proj_10001_88123", "messages": [{"role": "user", "content": "讲一下快速排序"}, {"role": "assistant", "content": "快速排序是..."}, {"role": "user", "content": "那归并排序呢?"}] }WebSocket 下行消息:
{ "type": "delta", "session_id": "proj_10001_88123", "delta": ",归并", "finish": false }type字段我建议至少预留四种:chat(上行发起请求)、cancel(上行取消)、delta(下行增量)、done(下行结束)。心跳单独走ping/pong,不要混进业务消息。
3. 最小可跑通实现:FastAPI + WebSocket 把 DeepSeek 消息推到浏览器
3.1 用 FastAPI 写后端:接收 WebSocket 上行并把 DeepSeek SSE 转推出去
这里我用 FastAPI,原因有二:它对 WebSocket 支持是标准功能,不需要像 Django 那样额外引入 Channels 并配置独立的 ASGI 路由机制;其次它的异步接口可以直接搭配 OpenAI Python SDK 的异步流式调用,代码写起来是一套 async 栈,心智负担小。如果你已经在用 Django,也不是不能做,走 Django Channels 的AsyncWebsocketConsumer思路一样,只是配置项更多。
后端核心代码,我将 DeepSeek 的流式调用封装成一个生成器:
import json import httpx DEEPSEEK_API_KEY = "sk-xxxxxxxx" DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" async def stream_deepseek(messages: list, max_tokens: int = 1024): # 1. 组 DeepSeek 的请求体,stream 必须为 True payload = { "model": "deepseek-chat", "messages": messages, "stream": True, "max_tokens": max_tokens, "temperature": 0.7 } headers = { "Authorization": f"Bearer {DEEPSEEK_API_KEY}", "Content-Type": "application/json" } # 2. 发起流式请求,用 httpx 的异步流式读取 async with httpx.AsyncClient(timeout=60.0) as client: async with client.stream("POST", f"{DEEPSEEK_BASE_URL}/chat/completions", json=payload, headers=headers) as resp: if resp.status_code != 200: body = await resp.aread() raise RuntimeError(f"DeepSeek API error: {resp.status_code} {body[:200]}") # 3. 逐行解析 SSE async for line in resp.aiter_lines(): line = line.strip() if not line.startswith("data:"): continue data_str = line[5:].strip() if data_str == "[DONE]": break try: chunk = json.loads(data_str) except json.JSONDecodeError: continue choices = chunk.get("choices", []) if not choices: continue choice = choices[0] delta = choice.get("delta", {}) content = delta.get("content") if content: yield content这段代码有几个关键点。超时我设成 60 秒,指的是“读不到新数据的最长空闲时间”,适合长句生成场景;如果你有很长的思考链输出,可能需要调成 120 秒。逐行解析选用aiter_lines而不是读整段,是为了避免等待完整响应体,DeepSeek 的 SSE 是一行一个事件,逐行读天然对齐。遇到 HTTP 非 200 时我把响应体前 200 字节带进异常信息,这是排障时最省时间的做法——裸看状态码往往会漏掉“余额不足”和“上下文字数超限”这类 400 级错误的具体原因。
然后是 WebSocket 端点的处理逻辑:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect app = FastAPI() @app.websocket("/ws/chat") async def chat_socket(websocket: WebSocket): await websocket.accept() session_id = None try: while True: # 1. 等前端下行消息 raw = await websocket.receive_text() msg = json.loads(raw) if msg["type"] == "ping": await websocket.send_text(json.dumps({"type": "pong"})) continue if msg["type"] == "cancel": # 取消逻辑会在 3.3 节展开 continue if msg["type"] == "chat": session_id = msg.get("session_id") # 2. 把 messages 透传给 DeepSeek async for delta_content in stream_deepseek(msg["messages"]): await websocket.send_text(json.dumps({ "type": "delta", "session_id": session_id, "delta": delta_content })) # 3. 结束标志 await websocket.send_text(json.dumps({ "type": "done", "session_id": session_id })) except WebSocketDisconnect: # 前端断线,执行清理 pass在这段代码里,FastAPI 的receive_text()会阻塞等待下一条消息,也就是说一轮对话结束后连接是保持不关闭的,直到下一次type=chat。这里有个隐藏问题:stream_deepseek是占用当前协程的上游请求,如果在生成过程中收到cancel,这个 while 循环根本不会走到msg["type"] == "cancel"那行判断,因为async for在等 DeepSeek 的下一块数据。这个问题我在 3.3 节统一处理。
3.2 前端如何收流:浏览器端 WebSocket 与增量渲染
前端我用原生 JavaScript 写最小示例,不引入框架,方便看核心逻辑。把这段代码放进一个 HTML 文件里就能跑。
const ws = new WebSocket("ws://localhost:8000/ws/chat"); let outputBox = document.getElementById("output"); let isGenerating = false; // 1. 心跳:每 30 秒发一次 ping,服务端回 pong 即视为存活 const heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); } }, 30000); ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === "delta") { outputBox.textContent += data.delta; } else if (data.type === "done") { isGenerating = false; } else if (data.type === "pong") { console.log("heartbeat ok"); } }; function sendMessage() { if (isGenerating) return; // 防重入 isGenerating = true; ws.send(JSON.stringify({ type: "chat", session_id: "proj_10001_88123", messages: [{ role: "user", content: document.getElementById("input").value }] })); } function cancelGenerate() { ws.send(JSON.stringify({ type: "cancel", session_id: "proj_10001_88123" })); }前端的核心是不要碰message事件里的旧数据逻辑,每次 delta 都是增量,直接做textContent +=追加即可。心跳间隔 30 秒是一个经验值,多数浏览器和服务端对空闲 WebSocket 的清理阈值在 60 秒左右,30 秒发一次足够保活,又不会太频繁占用资源。如果服务端通过负载均衡暴露,比如 Nginx 代理 WebSocket,心跳间隔要小于 Nginx 的proxy_read_timeout配置值,否则连接会被 Nginx 先掐掉。
3.3 cancel 的正确实现:用 asyncio.Task 包住上游请求才能真取消
前面代码里的type=cancel是无效分支,因为生成过程中事件循环被stream_deepseek占用。正确的做法是把一次生成任务包装成独立 Task,主循环只负责接收消息和状态管理:
import asyncio @app.websocket("/ws/chat") async def chat_socket(websocket: WebSocket): await websocket.accept() current_task = None try: while True: raw = await websocket.receive_text() msg = json.loads(raw) if msg["type"] == "chat": messages = msg["messages"] session_id = msg.get("session_id") # 1. 为新消息创建生成任务 current_task = asyncio.create_task( stream_forward(websocket, session_id, messages) ) elif msg["type"] == "cancel": # 2. 取消生成任务 if current_task and not current_task.done(): current_task.cancel() await websocket.send_text(json.dumps({ "type": "done", "session_id": session_id, "cancelled": True })) except WebSocketDisconnect: if current_task and not current_task.done(): current_task.cancel()asyncio.create_task把耗时的生成任务放到了后台调度,主协程继续receive_text()监听用户行为。取消时调用task.cancel(),会在生成器内部抛出CancelledError,从而触发httpx.AsyncClient的清理逻辑中断上游 HTTP 请求。这是“积压 token 止损”的关键,否则取消只是前端不做渲染,后端仍然会把整段生成完。
要注意的细节是stream_forward里要监听asyncio.CancelledError,因为如果直接让异常冒泡,WebSocket 连接可能被连带关闭:
async def stream_forward(websocket: WebSocket, session_id: str, messages: list): try: async for delta_content in stream_deepseek(messages): await websocket.send_text(json.dumps({ "type": "delta", "session_id": session_id, "delta": delta_content })) await websocket.send_text(json.dumps({"type": "done", "session_id": session_id})) except asyncio.CancelledError: # 这里发一条 cancel ack,告知前端生成已取消 await websocket.send_text(json.dumps({"type": "cancelled", "session_id": session_id})) raise我踩过一次印象深刻的翻车:只调用了current_task.cancel(),但没有在CancelledError里捕获异常,结果 FastAPI 日志里刷了一堆Task exception was never retrieved,还伴随内存缓慢增长——因为已经发送的 delta 消息不会回滚,但任务对象没有被正确回收。所以except asyncio.CancelledError后记得把异常重新抛出去,也不要吞掉它。
4. 参数与协议细节:temperature、max_tokens、stream 与上下文管理的真实边界
4.1 stream=true 时,max_tokens 直接影响用户看到的“断句质量”
不少人在调 DeepSeek 接口时,把max_tokens当成一个安全阀——设大,担心超时;设小,担心回答被截断。但流式场景里max_tokens的实际效果不只是截断,它还决定了用户在流式输出过程中能等待的“最长路径”。
举个例子,当max_tokens=256时,DeepSeek 的每个 chunk 可能输出 1 到 3 个字,但整个生成过程在 256 个 token 后就终止,长问题的回答经常说到一半戛然而止,用户没有等待过程,看到的是“逻辑断层”。而max_tokens=2048时,虽然总耗时会变长,但前端增量渲染的气质完全不一样——回答有了铺垫、论证和收尾,这会让用户觉得“这个模型比另一个能数数”。
从技术上说,DeepSeek 生成结束有两种情况。正常到达语义尾点时finish_reason=stop;被max_tokens截断时finish_reason=length。这两种情况前端都要处理,我的做法是后端在done消息里带上finish_reason字段,这样前端可以在回答尾部渲染一个“已截断”的小提示,而不是让用户误以为模型说完了。
{ "type": "done", "session_id": "proj_10001_88123", "finish_reason": "length", "truncated": true }这是深度集成里很少被提及却是真实体验分水岭的细节:流式聊天的体验瓶颈不在首字延迟,而在结束边界的可解释性。
4.2 temperature 在流式调用里的实时表现:不是越高越好
temperature在 DeepSeek 的 OpenAI 兼容接口里取值范围是 0 到 2,默认通常是 1。需要说明的是,DeepSeek 的deepseek-reasoner模型对temperature的处理建议是设置为 0 或接近 0,因为它内部有一套思维链生成逻辑,过高的温度会让推理过程散掉;而deepseek-chat模型则可以在 0.5 到 0.8 之间调,兼顾稳定性和多样性。
我一般在聊天场景用 0.6 到 0.7,在代码生成场景用 0.2 到 0.3。原因是代码生成对“确定性格式”极其敏感,温度太高很容易引入不闭合的花括号或残缺的变量名,这在流式渲染过程中特别扎眼——因为用户会逐字看到这些错误逐渐成形,无法像普通接口那样一次返回时忽略局部瑕疵。
还有一点容易忽略:temperature不是唯一控制随机性的参数,top_p也和它相关。OpenAI 兼容接口里建议不要同时修改这两个参数中的两个,保持一个为默认值,否则会互相干扰。如果你确实需要调节,优先动temperature就好。
4.3 messages 序列长度与上下文管理的四个策略
WebSocket 长连接下,messages 数组会随着对话轮数不断膨胀。DeepSeek 的上下文窗口有上限,比如deepseek-chat是 64K 上下文窗口,deepseek-reasoner也是 64K,但问题在于:把全部历史塞进 messages,先不说 token 费用,单是请求体变大,首字延迟就会明显上涨。
我的上下文管理策略分四步:先设硬上限,再分段压缩,然后配滑动窗口,最后做会话合并。
第一步,硬上限:messages 里总 token 数不得超过模型窗口的 70%。计算方式可以用 DeepSeek 官方提供的 tokenizer,也可以在服务端用tiktoken的近似估算。超过就触发裁剪。
第二步,分段压缩:不要把整段历史都丢掉,而是把早期对话压缩成一句话摘要附加到系统提示里。举例说明:用户前 20 轮都在聊“帮我改简历”,第 21 轮问“那我项目经历里要不要写小程序开发”,这时历史里的具体问答细节可能不重要,但“用户正在改简历”这个主题是有用的。我习惯于把“主题摘要”做成 system prompt 的一个字段,在流式请求时和最新 messages 一起发给 DeepSeek。
第三步,滑动窗口:messages 保留最后 N 轮原样内容,比如最近 6 轮不压缩,这能保证模型对近期对话有精确记忆。
第四步,会话合并:如果单轮里用户一次性粘贴了一大段代码或文档,可以把这段内容提取出来放到单独的上下文槽,而不是塞进 messages 序列,这样既减少 token 重复计费,又避免请求体被撑大。
4.4 函数调用与 tool_calls:DeepSeek 流式里容易漏接的一个分支
热词里有一条“deepseek messages tool calls need immediate results”值得展开,这其实是 DeepSeek API 的一个行为特征:当你开了tools参数,模型可能在流式过程中返回tool_calls,而不是content。如果你的后端只转发了delta.content,前端会看到输出突然停止,但finish_reason是tool_calls。
我在生产环境第一次遇到时也是手足无措——模型看起来“说完了”,但没有任何显示。后来排查发现 delta 里有tool_calls的增量片段:
{ "id": "chatcmpl-xxxx", "object": "chat.completion.chunk", "choices": [ { "index": 0, "delta": { "tool_calls": [ {"index": 0, "id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"上海\"}"}} ] }, "finish_reason": null } ] }这里tool_calls不像content那样一次给全,arguments也是增量片段风格的字符串拼接。也就是说你不能直接取delta.tool_calls[0]["function"]["arguments"]当作最终 JSON,而要在后端按index聚合积累,等finish_reason=tool_calls时拼接成完整 JSON 字符串,再解析后执行工具。
深度集成场景里,我一般把 tool_calls 的处理放在后端,不让前端感知工具调用的细节:后端收到工具调用后去执行工具,然后把工具结果封装成一条tool角色消息追加到messages序列,自动发起下一轮 DeepSeek 请求。这个“两段式”请求对前端是透明的,前端只看到一次 WebSocket 会话里出现了两段 delta 输出,中间有短暂停顿——如果你不处理这个停顿体验,用户会以为模型卡住了。
建议的简版处理策略是:如果工具执行时间预计超过 1 秒,先向前端推一条{"type": "status", "status": "tool_running", "tool_name": "get_weather"}的状态消息,前端可以在界面上渲染一个“正在查天气…”的小标识,避免黑匣子感。
5. 避坑:WebSocket 断线重连、心跳失效、tool_calls 不返回和 Django Channels 兼容性
5.1 断线重连后消息丢失,WebSocket 没有官方重发机制
现象:手机从 Wi-Fi 切到 4G 时,WebSocket 连接断开。等信号恢复,浏览器自动重连成功,但刚才发送的那条消息在 DeepSeek 侧已经生成完了,生成结果存留在后端无处投递,用户在界面上看不到这轮回答。
原因:WebSocket 协议本身不保证消息送达,连接断开后,后端的WebSocketDisconnect异常只会触发清理逻辑,不会把未投递消息保存。前端重连建立的是新连接,没有 session 绑定,旧会话的数据就永远丢了。
解决:把会话状态外置到 Redis 或内存缓存。后端在连接断开时,如果当前会话有未发完的 delta,把这些消息按 session_id 存储,标记为pending。前端重连成功后发一条{"type": "resume", "session_id": "proj_10001_88123"},后端检查到pending状态,把缓存里的消息按序补推过去。
这里要注意的是:重连恢复只适合“生成结束后的消息回放”,不适合“生成过程中的实时续推”。因为 DeepSeek 上游请求可能已经被后端取消了,无法续接生成位置。所以实际情况是残局恢复——要么重新发起生成,要么提示用户“当前回答已中断,请重发”,二选一,不要假装能无缝续推。
5.2 心跳 30 秒发一次,Nginx 仍然 60 秒掐断连接
现象:WebSocket 连接建立后稳定运行 1 分钟,然后前端onclose触发,服务端日志显示连接正常关闭,但没有任何报错。
原因:Nginx 默认proxy_read_timeout是 60 秒,也就是说 60 秒内后端如果没有任何数据推给浏览器,Nginx 会主动关闭连接。你 30 秒发一次ping,假设 ping 消息从浏览器发到后端,后端返回pong经过 Nginx 转发,这个事件是“后端到浏览器”的数据流,应该能刷新 Nginx 的计时器。但问题在于,如果你把ping/pong放在业务层,没有实际发送真正字节流到 socket 层面,某些 Nginx 配置会过滤掉未激活的事件。更常见的原因是你的 WebSocket 心跳走了浏览器到后端的方向,但 Nginx 的proxy_read_timeout监控的是“从后端到浏览器”的读取超时,两条方向计时不同。
解决:Nginx 的location配置里显式调大超时时间:
location /ws/ { proxy_pass http://backend_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout改成 3600 秒后,心跳压力就不大了。真正的教训是:心跳不是万能的,反向代理的超时参数才是最终裁决者。上线前一定要查一下代理层的超时配置,不要只看应用层流量。
5.3 DeepSeek 已返回 tool_calls 但前端无响应,杀进程也救不回
现象:请求里带了tools参数,模型判断需要调用工具,后端把 SSE 流读取完毕,但整个输出框是空的,没有任何content字段被渲染,且finish_reason是tool_calls。
原因:后端只处理了delta.content,忽略了delta.tool_calls分支。DeepSeek 在工具调用场景下,生成内容的载体从content切换成了tool_calls,两者不会同时出现。你转发逻辑里没有 tool_calls 分支,所有 tool 调用都被静默丢弃。
解决:在后端 stream 转发逻辑里,判断 delta 里是否有tool_calls字段。如果有,聚合index字段,按function.arguments拼接。等finish_reason=tool_calls时,把这个 JSON 解析出函数名和参数,执行对应函数,把结果作为下一条messages的一部分再发起一次流式请求。示例判断逻辑:
if delta.get("tool_calls"): for tool_call in delta["tool_calls"]: idx = tool_call["index"] # 按 index 聚合,拼接 arguments 增量 tool_calls_buffer[idx]["arguments"] += tool_call["function"]["arguments"] continue这个坑的隐蔽性在于,DeepSeek 不会抛错,它只是“礼貌地”不产生content。如果后端日志只打了 sent delta 的字数,你根本看不出来丢了什么。调试方法是用 WebSocket Test Client 直接连api.deepseek.com的 SSE 流,在测试客户端里展开原始数据流看delta.tool_calls是否出现,把问题定位到“上游有数据、自己解析丢了”这层。
5.4 Django Channels 里跑同步调用,消息全部串在同一个事件循环
现象:项目是 Django,集成了 Channels,WebSocket 可以接通,但第一个用户发起 DeepSeek 请求后,第二个用户的请求一直不返回,直到第一个完成才开始处理。
原因:Channels 的AsyncWebsocketConsumer是基于 ASGI 事件循环的,而 OpenAI SDK 的同步请求是耗时阻塞操作。如果你在receive()里直接调用client.chat.completions.create(stream=True)并同步迭代,整个事件循环就被这一个请求占住了,其他连接全部排队。
解决有两种路线。一是彻底异步化,参考本文第 3 章的 httpx 异步流式写法,在async_to_sync之外保持全 async 栈。二是把它塞进线程池,用asyncio.to_thread包裹同步调用,让事件循环不被阻塞。
我个人的建议是:新项目直接上 FastAPI,别在 Django Channels 里绕。Django Channels 适合以 Django ORM 为中心、天然就有消息队列的业务,比如管理后台的实时通知推送;但如果你需要高频次的双向对话、中断恢复、多路复用,FastAPI + WebSocket 的路径要直得多。已经用了 Django 的老项目,就老老实实把 DeepSeek 流式调用放到 Celery 任务或独立微服务里,让 Channels 只负责把结果转发到浏览器,把同步阻塞从 WebSocket 处理路径上彻底拿掉。
5.5 asyncio.create_task 任务泄漏:连接断开后遗留任务仍在跑
现象:部署上线后,日志里频繁出现Task was destroyed but it is pending警告,同时后端到 DeepSeek 的 API 调用量比前端触发量高出一截。
原因:前端直接关闭页面时,WebSocketDisconnect异常抛出,主循环退出,但当前正运行的current_task没有被 cancel。这个任务会一直跑完 DeepSeek 的完整生成流程,白白烧 token。
解决:在WebSocketDisconnect的 except 块里,不仅 cancel 任务,还要用await asyncio.gather(current_task, return_exceptions=True)确保任务真正结束再退出协程。更稳妥的方案是做超时控制:任务创建时用asyncio.wait_for(task, timeout=120)包一层,给所有生成任务一个绝对时限,避免极端情况下任务悬挂。
注意:
task.cancel()只是给任务发取消信号,不代表任务立即停止。如果任务内部的 httpx 请求处于读阻塞,取消信号要等下一次 I/O 返回才能生效,所以 cancel 后必须配合await等待回收,不能直接下一条逻辑。
6. 用 WebSocket Test Client 做压测与断线验证,把 React 实时面板接进流式链路
流式聊天上线前的验证,不是“看前端能不能打字”这么简单,核心是验证三件事:首字延迟、断线恢复、取消响应。这三件事我都用 WebSocket Test Client 这类工具跑,最常用的一个是命令行版websocat,另一个是 Chrome 的在线 WebSocket 调试工具。
测试首字延迟,我在websocat里模拟前端发同样的type=chat请求,手动掐秒表计算从发送到第一个 delta 返回的时间间隔。如果首字延迟超过 1.5 秒,我会先怀疑 messages 太长——把最近 6 轮之外的历史都压缩成摘要,而不是全量发送。测试断线恢复,我会在生成过程中直接断开 TCP 连接,观察后端是否在WebSocketDisconnect后正确 cancel 上游请求,是否在 Redis 里留下了 pending 记录;再重连发type=resume,看消息是否回放。这个链路如果不测,上线后就只能指望用户运气好。
React 侧的实时面板接入,本质是把 WebSocket 事件映射成前端状态。我用一个useChatSocket自定义 Hook 封装连接管理,监听delta更新消息内容,监听status显示工具运行状态,监听cancelled重置生成标志位。关键点是 useEffect 的清理函数里要关闭 WebSocket,否则 React 18 严格模式在开发环境下会建立两条连接,前后端消息互相错乱,这是一类很容易被误判为“DeepSeek 并发冲突”的假故障。
// React Hook 的最小骨架 import { useEffect, useRef, useState } from "react"; export function useChatSocket(url) { const [content, setContent] = useState(""); const [isGenerating, setIsGenerating] = useState(false); const wsRef = useRef(null); useEffect(() => { const ws = new WebSocket(url); wsRef.current = ws; ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === "delta") { setContent((prev) => prev + data.delta); } else if (data.type === "cancelled") { setIsGenerating(false); } }; return () => { ws.close(); wsRef.current = null; }; }, [url]); const send = (messages) => { setContent(""); setIsGenerating(true); wsRef.current.send(JSON.stringify({ type: "chat", messages })); }; const cancel = () => { wsRef.current.send(JSON.stringify({ type: "cancel" })); }; return { content, isGenerating, send, cancel }; }把流式聊天的状态机刻意收敛成content与isGenerating两个变量,是我自己在多个项目里磨合出来的习惯。表面看它很简单,但它能避免一个常见的复杂度陷阱:一旦在前端给每条流式消息建独立状态,就会出现“点了停止但某条还在飘字”“历史消息和新增消息混在同一个视图里”诸如此类的状态同步翻车。让后端协议替你承载状态——每轮开始清空,delta 增量累加,cancelled 封口——前端就只管渲染,不要重复造一套会话状态流程。
这些年调聊天类集成的经验,给我最深的一个教训是:流式链路里的大部分故障都不是“DeepSeek 答错了”,而是协议层的静默失败——连接断了没人知道、tool_calls 字段被丢了没人察觉、任务取消了没回收。把命门抓在主连接生命周期和消息字段完整性的验证上,胜过追着单个请求猜来猜去。先把 WebSocket Test Client 这套验证流程跑成习惯,再往上堆功能和视觉,希望帮到你。
本文还有配套的精品资源,点击获取