简介面向具备前端与Node.js基础的中高级开发者这份资源演示了深度集成DeepSeek大模型并通过WebSocket实现流式聊天交互的完整工程。前端利用WebSocket建立全双工实时通信包含界面组件、样式与图标资源兼顾用户体验与响应速度后端提供构建配置、辅助脚本、依赖清单及说明文档可支撑请求处理与模型接口对接并对API密钥的安全使用加以提示。项目共13个文件压缩包仅30KB轻量易读特别适合快速参考与二次开发代码结构清晰可直接复用。目前已有468人学习下载。借助该包可掌握建立实时通信、对接模型接口并流式返回数据的实现思路同时了解项目目录结构和前后端协作方式对正在打造AI聊天应用或研究流式输出原理的工程师尤其有帮助。1. 为什么 DeepSeek 流式聊天要绕开普通接口一次对话推送的真实困境把 DeepSeek 大模型从一次性问答升级成聊天产品里的实时对话最麻烦的从来不是模型能力而是“生成过程怎么让他看到”。DeepSeek 的 API 本身就支持流式返回但它给的是 SSE 数据流前端网页没法直接拿如果你用 HTTP 轮询去模拟要么每隔几百毫秒打一次接口要么卡在 loading 转圈用户等一段长思考时体验直接崩。WebSocket 在这类场景里是把整条链路串起来的关键服务端保持与 DeepSeek 的长连接收流式分片再通过 WebSocket 把增量实时推给所有前端页面同时还能在中间插状态、插工具调用、插取消指令。这篇就是讲这条链路怎么做通包括服务端接入、前端渲染、心跳重连和后端网关参数适合正在把 DeepSeek 接进自有产品、且不想只做“发一条消息等一个完整 JSON”的工程师。2. 服务端接入用 FastAPI 把 DeepSeek 流式接口转成 WebSocket 推送2.1 协议选型SSE、轮询和 WebSocket 的差别到底在哪我们先说清楚一个问题DeepSeek 的官方 API 在流式模式下返回的是 SSEServer-Sent Events它不是 WebSocket。所以如果你只有一个“前端页面 直接调 DeepSeek”的设想最省事的方案其实是让浏览器端直接发 fetch 请求去读 SSE 流用 ReadableStream 一帧帧解析。那为什么我还要在中间加一层 WebSocket三个原因。第一API Key 不能暴露在浏览器里必须有服务端做转发层既然服务端已经介入了那数据从服务端到前端的这条通道用什么协议就重新可选第二聊天产品通常不是纯文本问答——中间要穿插入库记录、文档引用、工具调用结果、用户权限校验、限流提示等业务消息SSE 是单向的服务端推给前端容易前端想再往回发一条“停止生成”却只能另开一个 HTTP 请求协议上就很别扭第三WebSocket 一条连接可以反复使用不需要每次问话都重建链路在多轮对话场景下从连接开销到代码组织都更接近聊天产品的真实形态。对比项HTTP 轮询SSEWebSocket方向性请求-响应单向服务端到客户端单向全双工双向连接复用每次请求新建长连接只能下行长连接可收发中途插业务消息极不方便只能插下行上下行都能插断线重连无状态天然恢复有 Last-Event-ID 机制需自己实现浏览器兼容全部除 IE 外基本可用全部现代浏览器结论很直接如果你的产品只需要“模型把文字推给用户”SSE 就够了只要出现“用户取消”“工具调用结果回传”“多端同步状态”任何一个诉求WebSocket 就是更合理的底座。这里的实现思路是服务端用官方 SDK 接收 DeepSeek 的 SSE 流解析出增量后再用自有的 WebSocket 协议包装一层推给前端对外部隐藏供应商差异。2.2 最小可跑通的服务端代码AsyncOpenAI 加 WebSocket先看整个链路最核心的一段FastAPI 起一个/ws/chat端点收到前端消息后调用 DeepSeek 的流式接口把每个增量分片转成 WebSocket 消息推回去。我一般会用一个独立的异步客户端而不是直接requests.post去流式读原因在后面的避坑章节里细说。# requirements.txt # fastapi0.110, uvicorn[standard]0.29, openai1.30 import json from typing import List, Dict from fastapi import FastAPI, WebSocket, WebSocketDisconnect from openai import AsyncOpenAI DEEPSEEK_API_KEY sk-你的key DEEPSEEK_BASE_URL https://api.deepseek.com MODEL_NAME deepseek-chat # 长推理场景可换 deepseek-reasoner client AsyncOpenAI( api_keyDEEPSEEK_API_KEY, base_urlDEEPSEEK_BASE_URL, ) app FastAPI() app.websocket(/ws/chat) async def chat_ws(ws: WebSocket): await ws.accept() task None try: while True: payload await ws.receive_json() msg_type payload.get(type) if msg_type chat: history payload.get(messages, []) # 每个 chat 消息对应一个独立的生成任务方便后续取消 task asyncio.create_task(stream_deepseek(ws, history)) elif msg_type ping: await ws.send_json({type: pong}) elif msg_type cancel: if task and not task.done(): task.cancel() await asyncio.gather(task, return_exceptionsTrue) await ws.send_json({type: cancelled}) except WebSocketDisconnect: # 客户端断开时顺手把生成任务取消避免浪费 token if task and not task.done(): task.cancel() finally: await ws.close() async def stream_deepseek(ws: WebSocket, history: List[Dict]): try: stream await client.chat.completions.create( modelMODEL_NAME, messages[ {role: system, content: 你是智能助手回答准确、简洁。}, *history, ], streamTrue, temperature0.7, max_tokens2048, ) async for chunk in stream: if not chunk.choices: # 流式最后一条带 usage 统计的 chunkchoices 为空数组 continue delta chunk.choices[0].delta reasoning getattr(delta, reasoning_content, None) content getattr(delta, content, None) tool_calls getattr(delta, tool_calls, None) if reasoning: await ws.send_json({type: reasoning, content: reasoning}) if content: await ws.send_json({type: text, content: content}) if tool_calls: await ws.send_json({type: tool, content: json.dumps(tool_calls, ensure_asciiFalse)}) await ws.send_json({type: done}) except asyncio.CancelledError: # 主动取消不再向客户端发任何内容 raise except Exception as exc: await ws.send_json({type: error, message: str(exc)})这段代码有几个关键点。一是必须用AsyncOpenAI而不是同步openai.OpenAIFastAPI 的 WebSocket 处理器跑在同一个事件循环里任何同步阻塞都会把整个进程的其它连接全部卡住二是streamTrue一定不能漏漏了之后拿到的就是一个完整响应对象前端就永远等不到增量三是chunk.choices为空数组时要跳过DeepSeek 流式最后会推一条只带usage统计信息的分片不跳过就会索引报错。四是getattr(delta, ...)而不是直接访问属性因为deepseek-chat模型不会返回reasoning_content字段直接取属性会抛异常。参数上temperature0.7是我在对话场景里的常用值偏稳但保留一点多样性max_tokens控制单次回复上限我建议至少给 2048否则长文档总结容易被截断。base_url指向 DeepSeek 官方地址。如果你接入的是deepseek hermes这类第三方微调端点或者公司内部用 vLLM 部署的兼容服务只要它们实现 OpenAI 协议这段代码只需要换base_url和MODEL_NAME两个常量其余逻辑完全复用。2.3 消息协议给前端预埋 reasoning、text、tool 三类增量通道WebSocket 是裸的字节通道发什么、怎么解析完全由你定。我这边的协议原则很简单所有消息都是 JSON事件类型用小写英文字母内容字段统一叫content。前端收到消息后只认type不同type走不同渲染管道。方向type 取值载荷说明前端到服务端chatmessages: 完整会话历史数组前端到服务端ping心跳探测无附加载荷前端到服务端cancel取消当前生成任务服务端到前端reasoning推理过程增量deepseek-reasoner 才有服务端到前端text正式回答增量服务端到前端tool工具调用参数前端可展示“正在调用…”服务端到前端done本轮生成完成服务端到前端error错误信息服务端到前端pong心跳响应服务端到前端cancelled取消成功确认为什么要把reasoning和text分成两条独立通道因为deepseek-reasoner模型的推理过程和最终答案是分开返回的前端如果想要“思考过程折叠展示”的体验必须在协议层就区分否则两段文字混在一起前端很难拆分。tool通道则是给 function calling 场景预留的。如果你是在做智能体编排参考deepseek harness这类套件的思路模型中途发起工具调用时前端需要先收到tool事件展示状态等用户或系统拿到工具结果后再通过chat消息把结果回传这时模型会继续生成后续内容整条链路依然是同一个 WebSocket 连接在跑。2.4 取消与超时用户点“停止”之后后端发生了什么很多第一次做流式聊天的人会犯一个错用户点击“停止生成”前端直接把 WebSocketclose()掉。这样做确实断了消息但服务端那边生成的 Task 并不会自动停止——它会继续向一个已断开的连接里写数据写几次抛异常后才停止这期间 DeepSeek 的 token 费用已经扣掉了。更严重的场景是用户只是不想看当前这轮想换个方式重新问这时候连接不该断断了他连历史都拉不回来。正确做法是像上面代码里那样把每次生成包成一个asyncio.create_task前端发cancel消息时task.cancel()。CancelledError抛出来后stream_deepseek里的async for会中断对 DeepSeek 的连接也被释放真正做到了止损。同时我一般会在收到cancel后回一条cancelled事件让前端把“停止”按钮恢复原状。超时逻辑也值得从一开始就设计进去。DeepSeek 在高峰期的首 token 延迟可能超过 30 秒尤其是deepseek-reasoner做长思考时但服务端不能因此无限等。我常用的做法是给整个生成任务加一个asyncio.wait_for(stream_deepseek(...), timeout180)超过 180 秒强制取消并向客户端发error。这里的时间阈值要看你的业务容忍度写客服系统 120 秒就够做代码生成类的可以放宽到 300 秒。3. 前端侧WebSocket 客户端最小实现与流式文本渲染3.1 一个不需要任何框架的 WebSocket 客户端服务端协议定了前端就好写了。无论你用的是 React、Vue 还是原生 JS核心逻辑都一样建立连接、发送会话历史、按type分派事件。我用原生方式写最简版方便你移植到任意框架。const WS_URL (location.protocol https: ? wss:// : ws://) location.host /ws/chat; let ws null; function connectChat() { ws new WebSocket(WS_URL); ws.onopen () { console.log(ws connected); ws.send(JSON.stringify({ type: chat, messages: getChatHistory() // 从页面上收集历史消息数组 })); }; ws.onmessage (event) { const data JSON.parse(event.data); switch (data.type) { case reasoning: appendIncremental(#thinkingBox, data.content); break; case text: appendIncremental(#answerBox, data.content); break; case tool: showToolStatus(JSON.parse(data.content)); break; case done: finalizeAnswer(); ws.send(JSON.stringify({ type: chat, messages: getChatHistory() })); break; case error: showError(data.message); break; case pong: // 心跳响应留空即可或用于计算 RTT break; default: console.warn(unknown message type, data); } }; ws.onclose () { console.warn(ws closed, will reconnect); scheduleReconnect(); }; }done事件到达后我通常会立即触发下一轮等待把当前问答存入历史数组然后等待用户输入下一条。error事件不能只弹窗应该保留当前已渲染的内容给用户一个“复制已生成部分”的按钮这在实际运营中非常常见——模型生成到一半报错前面的内容还是有价值的。3.2 流式文本渲染增量累积、打字机效果和 Markdown 的边界流式渲染最大的坑是“直接往 DOM 里拼 HTML”。如果你在每次收到增量时做el.innerHTML delta会出现两个问题一是浏览器反复解析整个 HTML 片段长回复时页面越来越卡二是 Markdown 的渲染是整体性的——代码块一定是完整的三反引号包裹才能正确高亮如果增量片段落在代码块中间每次都整块重渲染你会看到代码块高亮一顿一顿地闪烁。我的做法是维护一份“源文本”增量只追加到源文本渲染则用requestAnimationFrame做节流。具体逻辑是每次收到增量先把文本存进sourceText标记一个待渲染状态下一帧再把完整源文本交给 Markdown 渲染器。这样既保证了文本完整又避免了每一条增量都触发一次全量渲染。const sourceText {}; const renderPending {}; function appendIncremental(selector, delta) { sourceText[selector] (sourceText[selector] || ) delta; if (renderPending[selector]) return; // 同一渲染帧内只做一次 renderPending[selector] true; requestAnimationFrame(() { // 注意marked 只是示例生产环境请换成你自己的渲染器并做 XSS 过滤 const html marked.parse(sourceText[selector]); document.querySelector(selector).innerHTML html; scrollToBottom(selector); renderPending[selector] false; }); }关于打字机效果我的建议是不要做。原因很实际DeepSeek 的流式速度已经接近打字机效果前端再做平滑延迟反而显得迟钝。如果产品经理强行要求也一定只能做“首字延迟”即只在第一个字到达时稍微等一下制造悬念后续增量直接渲染不要人为逐字推送。另外增量里可能带有半截 Markdown 标记比如你收到**加粗但还没收到文本**如果每帧都对半个标记做 HTML 转换结果要么被当普通文本显示要么渲染成乱标签。这个无解只能接受——完整源文本一直在内存里等done事件后做最后一次完整渲染把代码块高亮校正过来。3.3 前端心跳防止浏览器和网关悄悄掐掉连接WebSocket 看起来是长连接实际上浏览器和中间网络设备都会对“长时间没有数据活动”的连接动手。尤其移动端Chrome 对后台标签页的定时器有节流机制Safari 对空闲 WebSocket 的处理更激进。所以前端必须在应用层自己做心跳让连接始终保持活动。let heartbeatTimer null; let pongWaitTimer null; function startHeartbeat() { stopHeartbeat(); heartbeatTimer setInterval(() { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping })); // 发完 ping 后 10 秒内没收到 pong判定连接已死主动断开触发重连 pongWaitTimer setTimeout(() { ws.close(); }, 10000); } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) clearInterval(heartbeatTimer); if (pongWaitTimer) clearTimeout(pongWaitTimer); }这里的阈值选择讲究。心跳间隔 30 秒是因为大部分网关的空闲超时设置在 60 秒左右30 秒一ping 留足了余量。pong等待时间设 10 秒是因为正常的网络往返不可能超过这个数除非用户真的断网了。当你发现onmessage里已经很久没收到pong时不要再傻等直接主动close()触发重连逻辑比让连接半死不活地挂着强。轮询getChatHistory()直接全文重发是简单方案。它的弊端也很明显每次把全部历史发给服务端再转发给 DeepSeektoken 消耗随轮数线性增长。进阶做法是服务端按session_id缓存会话重连时前端只发一个sync消息服务端把断线期间丢失的增量补推给前端。这个方案更复杂一般到产品上线后才值得做初期用全量重发完全够用。4. 连接管理与网关配置心跳、断线恢复和 Nginx 落地参数4.1 心跳到底由谁发起客户端推服务端判活服务端不该主动向客户端发 ping原因有两层。第一浏览器在后台标签页会节流 JS 定时器但 WebSocket 的onmessage回调不受节流影响如果服务端每 30 秒发一个 ping浏览器收到的概率很高但客户端想回 pong 时的send()调用却可能因为页面卡顿而延迟造成“连接活着但两端对不上”的误判。第二服务端主动推心跳会让 Nginx 网关的日志里充满心跳帧干扰问题排查。所以我习惯的方案是客户端每 30 秒发一次 ping服务端在收到 ping 时回 pong服务端同时记录每个连接最后一次收到任何消息的时间超过 90 秒没有任何消息包括 ping、chat、cancel就主动close()这个连接释放文件描述符。90 秒这个数字不是随便拍的。普通页面在前台时30 秒一次 ping 一定不会超过 90 秒但用户切到后台标签页时浏览器会降级定时器到 60 秒一次左右所以 90 秒刚好能容忍一次节流后的心跳间隔。如果你把服务端超时设成 60 秒就会误杀一部分正常的后台标签页连接这是我在线上真实踩过的坑。4.2 半开连接检测断网但 onclose 不触发怎么办TCP 连接有一个很阴间的特性客户端拔网线或者 Wi-Fi 断掉时只要不主动发数据浏览器和服务端都感知不到连接已死这个状态叫“半开连接”。你看着 WebSocket 状态还是OPEN实际上数据已经到不了对端了。这时候心跳机制的价值不只是“保活”更是“探活”前端发 ping 后 10 秒内没有 pong说明连接已经半开主动调用ws.close()触发onclose进而走重连逻辑。没有这层探测连接就会一直挂着用户发消息发不出去也没有任何错误提示体验极其难受。服务端也要有对应的兜底。FastAPI 的ws.receive_json()在连接半开时会一直阻塞不会自己抛异常。我的做法是把接收操作包一层超时while True: try: payload await asyncio.wait_for(ws.receive_json(), timeout90) except asyncio.TimeoutError: # 90 秒没有任何消息判定死连接 await ws.close() breakasyncio.wait_for在超时后会取消底层的receiveWebSocketDisconnect不一定抛得出来所以finally里的ws.close()就是最后的清理手段。这里要留意TimeoutError捕获的是整个接收的超时不是心跳超时两者不要混淆。4.3 Nginx 网关配置四个参数决定 WebSocket 能不能真正落地开发环境直连localhost:8000没问题一旦部署到服务器前面必然挂 Nginx 或云厂商的负载均衡。WebSocket 在 Nginx 里的配置比普通 HTTP 多几个关键项少一个都可能让你白折腾半天。location /ws/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 60s; }第一行proxy_http_version 1.1不能省。HTTP/1.0 不支持 Upgrade 头WebSocket 握手直接失败。Upgrade和Connection: upgrade是握手必需的如果前端报WebSocket connection failed且浏览器 Network 面板里看到 400 或 426 状态码优先查这里。proxy_buffering off是我认为最隐蔽的一个坑Nginx 默认会缓冲后端响应把小数据块攒成大数据块再发给客户端对 WebSocket 来说这会让流式增量变成“憋好半天吐一大段”前端打字机效果的体验就毁了。proxy_read_timeout 3600s也要改默认 60 秒意味着如果 DeepSeek 思考时间超过 60 秒而中间没有消息Nginx 会主动断开连接客户端看到的现象是“生成到一半 WebSocket 断开”。如果你用的是云厂商的网关产品对应项通常是“WebSocket 支持开关”“响应缓冲开关”“空闲超时时间”每家叫法不同但原理一致。上线前务必逐项对照检查。5. DeepSeek 流式集成的五个常见踩坑与排查5.1 现象前端半天不收字一收就是一大段第一次联调时最常遇到的情况是代码逻辑都没问题但前端页面一直空白过了十几秒突然一次性冒出整段回答“流式”变成了“式流”。查 Nginx 的access.log会看到后端的响应确实是小包分片发出的那问题就出在 Nginx 的缓冲上。原因proxy_buffering默认开启Nginx 会把后端发来的小块数据攒够一定体积再转发给浏览器WebSocket 场景下这个缓冲直接吞掉了流式的实时性。解决在 WebSocket 的 location 里显式加proxy_buffering off。如果网关不支持关缓冲临时方案是在服务端每次send_json后加一个极短的await asyncio.sleep(0.01)人为把数据块拆得更碎让网关的缓冲阈值更容易触发刷新但这只是治标。确认标准改完后浏览器 Network 面板里 WebSocket 消息应该是密集的小帧而不是稀疏的大帧。5.2 现象生成到一半抛异常报错指向chunk.choices[0]如果你从网上抄了一段 OpenAI 流式解析代码来对接 DeepSeek大概率会在某条数据上翻车。现象是对话进行到末尾时服务端突然抛IndexError: list index out of range或者前端收到内容不完整但没有任何错误提示。原因DeepSeek 的流式返回在最后一条分片里只放usage统计信息choices是一个空数组。很多示例代码是chunk.choices[0].delta这样直接访问的遇到空数组自然就炸了。解决在取delta之前先判断if not chunk.choices: continue。这条看起来是小细节但线上环境一旦触发就是整段生成失败属于最基础的必修课。5.3 现象deepseek-chat 没有推理过程前端却报属性读取异常如果你同时接入了deepseek-chat和deepseek-reasoner或者把代码从 OpenAI 协议切换到deepseek hermes这类第三方端点时很容易遇到AttributeError: ChoiceDelta object has no attribute reasoning_content。原因reasoning_content是 DeepSeek 在deepseek-reasoner模型上特有的字段deepseek-chat不会返回第三方模型更不一定有。直接delta.reasoning_content访问必然崩溃。解决统一用getattr(delta, reasoning_content, None)拿默认值我习惯把deepseek-reasoner的推理过程包在一个独立的渲染区域内折叠展示。另外这条错误不会触发 FastAPI 的 HTTP 异常只会让 WebSocket 连接异常关闭排查时很容易漏。5.4 现象一个用户问长问题整个服务的聊天都卡住了部署到测试环境后你会发现一个用户发起耗时的长思考请求时其他用户的消息也迟迟得不到响应甚至心跳 ping 都回不了。原因最常见的是把同步openai.OpenAI客户端用在了 FastAPI 的事件循环里。同步库的流式读取会阻塞整个事件循环一个慢请求占住线程其他所有连接都在排队。还有一个隐蔽原因async for chunk in stream循环体内如果调用了同步的requests.get()去拉工具结果同样会卡住这个协程所在的事件循环。解决统一用AsyncOpenAI工具调用的外部请求改成await asyncio.to_thread(func, ...)丢到线程池执行。验证方法是做并发测试开 10 个连接同时发消息如果首 token 延迟不随连接数急剧上升说明事件循环没有被阻塞。5.5 现象进程不崩但内存持续上涨几天后被迫重启WebSocket 长连接服务跑一段时间后进程内存像温水煮青蛙一样慢慢涨找不到明显的泄漏点。检查代码后发现history数组一直在往里面追加每轮对话都把全部历史发给 DeepSeek连接多了以后这个数组占的内存非常可观。原因一是会话历史无限增长既占内存又浪费 token二是服务端如果在每个连接上缓存了完整的消息对象连接多了内存自然膨胀。解决给历史列表设一个长度上限。我的习惯是保留 system 消息加上最近 20 轮对话超出部分直接裁剪。估算 token 时按“4 个英文字符约 1 token1 个汉字约 1 token”粗略算超长就截断到 8000 token 以内。这套逻辑应该在服务端做不能依赖前端自觉。6. 进阶验证用测试客户端、压力和日志确认这套方案能上线6.1 用 wscat 快速验证链路是否通开发阶段反复用浏览器调试不方便我一般会直接用wscat做命令行验证。安装一次之后任何环境都能用npm install -g wscat wscat -c ws://localhost:8000/ws/chat连接建立后手动输入一行 JSON{type: chat, messages: [{role: user, content: 你好用一句话介绍你自己}]}正常情况下你应该看到reasoning或text类型的增量消息逐条返回最后一条是done。这一步能排除所有前端问题直接确认服务端到 DeepSeek 的链路是通的。如果这里就卡住去检查 API Key、base_url和模型名如果这里通了但浏览器不通问题大概率出在 Nginx 的 WebSocket 配置上。6.2 并发与首 token 延迟上线前必须量化聊天体验好不好最直接的指标是“用户发完消息到看到第一个字”的延迟。我做上线前验证时会写一个简单脚本开 10 个 WebSocket 并行发送同样的消息分别记录connect_time、send_time和first_token_time然后算平均首 token 延迟。单连接时 500 毫秒到 1 秒都算正常10 连接并发时如果整体延迟呈线性暴涨比如从 1 秒涨到 8 秒说明服务端事件循环被阻塞或者 DeepSeek API 侧限流了。DeepSeek 官方 API 对并发有配额限制具体数值以你的账户为准。遇到429错误码时不要盲目重试先看返回里的Retry-After头按服务端指定的时间等再退避重试。我这里说的“压力测试”不是压垮服务的压力测试而是验证链路在真实并发下的表现测出的数字直接决定你要不要做请求排队和并发池。6.3 日志追踪给每个会话加 trace_id排错不用靠猜等到系统上了生产你会发现最贵的不是 token而是排查问题的时间。因为 WebSocket 是长连接单次故障涉及前端、网关、服务端、DeepSeek 四个环节没有贯穿的日志标识出问题时两头各执一词。所以我在上线前一定会做的一件事给每个会话生成trace_id并从连接建立、收到消息、首 token、流式结束四个节点打点。import time import uuid trace_id str(uuid.uuid4()) trace { trace_id: trace_id, connect_at: time.perf_counter(), first_token_at: None, } async for chunk in stream: if not chunk.choices: continue if trace[first_token_at] is None: trace[first_token_at] time.perf_counter() print(f[trace] {trace_id} first_token_delay{trace[first_token_at] - trace[connect_at]:.3f}s)日志格式不用复杂但必须含有trace_id、事件名、耗时三个字段。前端可以在连接参数里带上session_id服务端把它与trace_id关联。这样线上出问题时让用户提供一条 ID就能从 Nginx 的 access log 到应用日志一路串起来看半小时内定位问题。我最深刻的教训是早期上线时不打点用户反馈“回答很慢”时既不知道是模型慢还是网络慢也不知道是哪段链路慢只能靠猜。这套日志打上之后同类问题的排查时间从小时级降到了分钟级。这算是我在这套方案里最值得说的一个习惯希望帮到你。本文还有配套的精品资源点击获取