ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSE+LangChain流式输出实战:兼顾打字机效果与结构化JSON

SSE+LangChain流式输出实战:兼顾打字机效果与结构化JSON 这两年做大模型应用我最大的感受是“流式”和“结构化”这两个词几乎撑起了生产环境里八成以上的工程量。业务方要的是用户聊天框里那个逐字蹦出来的打字机效果程序要的是能直接喂给数据库的干净 JSON。两个需求撞在一起就成了这个项目的核心用 SSE 把 LangChain 的流式输出推到前端同时保持结构化 JSON 的完整性和可解析性。这条链路我前前后后踩了不少坑包括那个让很多人挠头的stream disconnected before completion: idle timeout waiting for sse以及流式 JSON 半截截断导致的解析崩溃所以这篇把完整方案和数据都梳理出来给正在做同类 AI 应用的读者一个可以直接抄作业的参考。1. 项目概述为什么非要把 SSE、LangChain 和 JSON 绑在一起1.1 从“能对话”到“可集成”的硬需求大模型应用从 Demo 走到生产环境需求会非常明显地分岔。一边是 C 端用户体验聊天界面里的内容必须像打字机一样一行一行蹦出来用户看到第一个字的时间决定了他们愿不愿意继续等下去这种感知差异在实测中比很多人想象的大得多全量返回可能要五六秒流式返回首字往往一两秒内就到了体验完全不在一个量级。另一边是 B 端系统集成对话结果不能只是“看起来像那么回事”的文本它要变成订单参数、体检报告字段、数据库记录这就要求模型输出必须是严格符合 Schema 的 JSON。把这两个需求叠在一起问题就变得棘手了。普通的大模型接口调用返回一个完整字符串好处理但不满足流式诉求用 SSE 流式传输能实时展示但又容易把 JSON 拆得稀碎。所以这个项目的本质就是在“用户体验的流式”和“系统集成的结构化”之间找到一条既能兼顾、又足够工程化的路。LangChain 在其中承担的是模型调用与结构化约束的中间层SSE 是传输管道JSON 是最终交付的标准化载体三者缺一不可。1.2 整体链路与方案选型技术选型上我最终定了 FastAPI LangChain Vue 的组合后端用 FastAPI 的 StreamingResponse 实现 SSE 端点LangChain 负责调用大模型并产出结构化输出前端用浏览器原生 EventSource 接收事件流配合节流逻辑完成打字机效果和增量 JSON 解析。这个组合的好处是没有引入太重的基础设施。SSE 基于普通 HTTP不需要单独部署 WS 服务Nginx 反代也能兼容LangChain 的 with_structured_output 可以直接绑定 Pydantic 模型省去了大量手写 JSON Schema 和校验逻辑的工作量。在实际动手前我把整条链路的边界画得很清楚LangChain 只负责产出 token 流不关心传输SSE 只负责搬运数据不关心数据内容前端负责累积、解析、展示。三个环节通过约定的数据格式衔接我设计的统一事件格式是一个包含type和content字段的小 JSONtype用来区分这是内容片段、元数据还是完成标志content放实际载荷。这样一来前端可以非常清晰地处理不同类型的消息后续要扩展工具调用状态、错误上报这类事件也只需要加 type不需要动协议。2. SSE 流式原理HTTP 上“搭电梯”的推送机制2.1 SSE 与 WebSocket 怎么选很多第一次接触流式方案的人会纠结一件事明明 WebSocket 是双向通信功能更强为什么选 SSE答案在于场景。我们这个项目里数据的流向是单向的服务端把生成结果持续推给客户端客户端几乎不需要往服务端发送实时控制消息。SSE 恰好为这种单向推送做了专门的优化它直接跑在 HTTP 上浏览器原生支持 EventSource 对象自带断线重连机制服务端实现只需要往响应流里写格式化文本就行比 WebSocket 需要维护连接状态、心跳、消息帧协议要简单一个数量级。我还整理过一个选型对比表放在项目文档里给团队参考维度SSEWebSocket通信方向服务端单向推送全双工双向通信传输协议纯 HTTP独立 WS 协议断线重连浏览器原生自动重连需要自己实现服务端复杂度低普通 HTTP 响应即可高需维护长期连接适用场景AI 流式输出、通知推送在线聊天、实时协作、游戏结论很明确做 AI 生成内容的流式输出SSE 是更优解。只有当你还需要客户端频繁发送消息、并且要求低延迟交互时WebSocket 才值得引入。像这个项目客户端只在最开始发一次请求参数后面全是被动接收用 WebSocket 属于杀鸡用牛刀还平白增加运维复杂度。2.2 SSE 协议基础event-stream 的数据格式SSE 的协议格式非常简单简单到很多人第一次看甚至会觉得“就这”。服务端返回的Content-Type是text/event-stream响应体由若干条消息组成每条消息以空行\n\n分隔。一行行来看每条消息可以包含data字段这是最常用的表示实际数据event字段自定义事件类型如果省略则触发默认的onmessageid字段用于断线后重连的游标retry字段告诉浏览器重连间隔毫秒数。这里最重要的是data行的格式。每行data:后面的内容会作为一条数据浏览器接收时会自动拼接多行 data 字段用换行符连接然后触发 message 事件。我用一个最清晰的例子说明data: 第一行数据 data: 第二行数据 event: done data: {status:finished}浏览器端会收到两条消息第一条的event.data是第一行数据\n第二行数据第二条因为是自定义event类型需要用addEventListener(done, handler)来监听。还有一个很多文档里不讲的细节SSE 支持注释行以冒号开头的内容会被浏览器忽略比如: 这是心跳。这个特性特别适合用来保活下文排查超时问题时我会再提。2.3 FastAPI 服务端 SSE 落地理论清楚了落地就简单了。FastAPI 里实现 SSE 端点核心就是StreamingResponse它接收一个异步生成器生成器每次yield一段字符串FastAPI 就把它作为响应体的一部分写出去。配合media_typetext/event-stream响应头的格式就对了。最简单的实现方式大概长这样import asyncio import json from fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() async def event_stream(prompt: str): # 模拟一个逐 chunk 返回的生成过程 for i in range(20): payload { type: content, content: f这是第 {i} 个片段来自基于 {prompt} 的模拟输出。, } yield fdata: {json.dumps(payload, ensure_asciiFalse)}\n\n await asyncio.sleep(0.05) app.get(/stream/{prompt}) async def stream(prompt: str): return StreamingResponse(event_stream(prompt), media_typetext/event-stream)这段代码看起来短但它是整个链路的地基。实际项目中异步生成器里通常是async for chunk in model.astream(...)每一轮迭代把chunk.content包进 JSON payload 再 yield 出去。有一点需要注意json.dumps时务必加ensure_asciiFalse否则后端会默认把中文转成\uXXXX形式的转义序列前端拿到的数据能解析但显示出来是乱码字形非常坑。3. LangChain 结构化输出与流式输出的平衡3.1 结构化输出的三条主流路线LangChain 里让大模型输出 JSON我接触到的方案大致能分为三类。第一类是 Prompt 硬约束加输出解析器就是在系统提示词里把 JSON 结构写死然后用PydanticOutputParser或JsonOutputParser解析模型返回的文本。这条路最灵活任何模型都能用但解析失败率也最高模型一旦夹杂多余解释文字整个解析就崩了。第二类是模型的 JSON Mode 能力比如 OpenAI 的response_format{type: json_object}模型会保证输出合法 JSON但不保证结构符合你的业务 Schema。第三类就是我现在主力使用的 Function Calling 加with_structured_output让模型不仅保证 JSON 合法还通过工具调用的机制把输出绑定到预定义的字段上。三类方案不是互斥的实际项目中常常要组合。比如我遇到过有些国产模型对 Function Calling 支持不完整就退回到 Prompt 约束解析器兜底还有些场景模型需要先根据上下文决定输出哪些字段可选字段Function Calling 也处理得比固定 Schema 好。3.2 with_structured_output 的内部逻辑with_structured_output是我用得最多的结构化输出入口。它的底层逻辑其实不神秘方法接收一个 Pydantic 模型类LangChain 会把这个 Pydantic 类的结构编译成 JSON Schema然后通过函数调用协议传给模型。模型在生成回复的同时会在内部构造一个满足 Schema 约束的 JSON 对象作为工具参数返回LangChain 再负责把这个工具参数反序列化成 Pydantic 实例。用代码呈现就是from pydantic import BaseModel, Field from langchain_openai import ChatOpenAI class Diagnosis(BaseModel): disease_name: str Field(description疾病名称) confidence: float Field(description置信度0到1之间) suggestion: str Field(description健康建议) model ChatOpenAI(modelgpt-4o-mini, temperature0.2) structured_model model.with_structured_output(Diagnosis) result structured_model.invoke(患者持续咳嗽两周夜间加重痰少) print(result.disease_name, result.confidence)这段代码的精髓在Field(description...)。description 不只是给人看的注释LangChain 会把它作为字段说明一并传给模型直接影响模型对字段语义的理解。我做过对比实验同样的模型同样的提问字段描述写得明确时结构化输出的准确率能高出一截。很多人的 Schema 写得极其简陋只写字段名没有说明模型就只能靠猜。3.3 流式输出时的两难增量与完整真正让我投入精力研究的问题出现在把结构化输出和流式输出叠加起来的时候。with_structured_output默认的处理方式是一旦模型返回完整的工具调用参数就一次性解析成 Pydantic 对象。这种模式天然不支持逐 token 增量。如果你在with_structured_output返回的模型链上直接调用.astream()你会发现事件序列很奇怪。LangChain 对结构化模型提供了astream_events接口它会流式地发出事件但其中多数事件的data是累积片段的 JSON 字符串不是干净的增量 token。也就是说模型每生成一个 token事件里的 JSON 字符串就会比上一次长一点你需要维护一个变量不断用最新的完整字符串覆盖旧的。如果图省事直接用astream拿chunk.content很可能拿到的是一整个完整 JSON 字符串打字机效果无从谈起。这里我采用的折中方案是不使用with_structured_output直接流式而是在 Prompt 层要求模型输出纯 JSON 使用JsonOutputParser解析器拿到一个可以逐 chunk 返回文本的流后端只把文本片段转发给前端前端边接收边累积最终自己解析完整 JSON。方案对比一句话总结with_structured_output适合非流式要求高稳定性的场景JsonOutputParser适合流式展示与结构化兼顾的场景。4. 打字机效果与流式 JSON 解析实战4.1 前端 EventSource 的基础用法前端接收 SSE最省事的方式是直接用浏览器内置的EventSource对象。它不需要引入任何第三方依赖实例化之后浏览器自动维护 HTTP 连接断线还会自动重连这几乎是流式推送场景里最优雅的体验。基础用法就是实例化一个 EventSource传入后端 SSE 端点的 URL然后注册回调事件。const es new EventSource(/api/stream/${prompt}) es.onopen () { console.log(SSE 连接已建立) } es.onmessage (event) { const data JSON.parse(event.data) if (data.type content) { accumulator data.content } else if (data.type done) { es.close() } } es.onerror (err) { console.error(SSE 连接异常, err) }onmessage对应的是没有自定义 event 字段的默认消息。如果后端在个别场景使用了event: done这种带类型的事件那就不能用onmessage得改成es.addEventListener(done, (event) {...})。我在最开始接飞书机器人回调的时候有过一个教训后端高兴起来发了个带 custom event 的消息前端onmessage毫无反应排查了半天才发现事件类型不对。4.2 流式 JSON 解析策略累积法、增量法流式 JSON 解析是这个项目里最需要策略的一部分。简单粗暴的JSON.parse(accumulator)会在字符串不完整时抛出异常而流式传输中这种情况每分钟都会发生。我验证过的可靠方案有两种。第一种是累积重试法。维护一个完整文本累加器每当新片段到达就尝试解析完整累加器如果JSON.parse失败就说明 JSON 还没闭合继续等下一个片段。因为JSON.parse失败的代价很低这个策略虽然不优雅但非常实用。它有个小技巧解析失败时不要打印完整报错日志否则控制台会被刷爆只打印一个轻量提示因为半截 JSON 报错是常态不是异常。let rawText function tryParse() { try { return JSON.parse(rawText) } catch (e) { return null } }第二种是括号配平法也是我在输出结构相对固定时优先用的。维护一个栈遇到{或[入栈遇到}或]出栈当栈为空时说明 JSON 顶层结构闭合了这时再做完整解析成功率几乎是百分之百。实际操作中我用一个计数器代替栈遇到左括号加一右括号减一计数从 0 回到 0 时就说明外层闭合。这个方法对字符串里的括号也需要注意字符串内部的括号不该参与计数所以我在判断括号类型前会维护一个inString状态遇到未转义的双引号就切换状态。这两个方法配合起来几乎可以覆盖所有流式 JSON 的解析场景。4.3 打字机效果的正确打开方式很多人以为流式输出天然就是打字机效果其实不然。流式输出只是告诉你“数据到了”浏览器拿到一整段数据后一次性追加到界面用户看到的还是“整段蹦出来”。要做出真正的逐字打印效果必须在前端做节流缓冲。我实现的逻辑分两层。第一层是数据接收层SSE 回调里把收到的每个片段推入一个队列第二层是显示层用一个定时器每隔固定时间我常用 16 到 30 毫秒从队列头部取一个字符追加到 DOM 节点。这个“接收和显示分离”的设计既保证了数据不丢失又控制了显示节奏。核心代码段是这样的const msgQueue [] let displayTimer null let isDisplaying false es.onmessage (event) { const data JSON.parse(event.data) if (data.type content) { msgQueue.push(data.content) if (!isDisplaying) displayLoop() } } function displayLoop() { if (msgQueue.length 0) { const chunk msgQueue.shift() for (const ch of chunk) { // 此处可替换为更精细的按需求逻辑 target.textContent ch } } isDisplaying true displayTimer setTimeout(displayLoop, 30) }这里有个非常微妙的点如果直接把data.content一次性追加到 DOM用户看到的是一个片段一个片段地跳而不是一个字一个字地滚。只有当每个片段内部也逐个字符追加并且用定时器控制节奏才真正接近人类阅读的键入速度。节奏快慢要根据内容长度调整长文本建议 12 到 18 毫秒每个字短文本可以快到 8 毫秒超过 20 毫秒用户会明显觉得输出太慢。4.4 中断、重连与超时处理前端还有一个绕不开的细节生成过程中用户可能会离开页面、网络可能抖动、后端可能因异常中断。SSE 的 EventSource 有自动重连机制但真正的工程问题在于重连之后怎么办。如果没有任何状态记录重新建立连接后浏览器会从头接收数据页面就会出现内容重复。我的处理方案是在服务端支持断点续传的语义。SSE 的id字段此时派上用场服务端维护一个自增序列号每推送一条消息就带上一个id前端把最后收到的id存到localStorage重连时 EventSource 无法直接传自定义 header但可以把 lastEventId 通过 URL 参数传给服务端服务端根据这个参数决定从哪条消息开始推。这个方案不需要额外引入消息队列或数据库只用一个小型的环形缓冲数组就能覆盖大多数场景。另一个极端情况是生成非常慢或者模型推理期间长时间无数据输出。比如一个大表格的推理中间可能要停顿几十秒。这种时候 Nginx 可能会因为响应迟迟没有新数据而掐断连接我后面会专门讲 Nginx 的配置和心跳保活方案。前端也要对长时间无数据的情况设置兜底提示我一般会用一个倒计时逻辑超过 90 秒没有收到任何片段就提示用户“生成仍在进行中请稍候”避免用户误以为页面卡死。5. 完整链路从 FastAPI 后端到 Vue 前端的端到端实现5.1 后端 FastAPI 代码全貌前面拆得太细现在我把端到端的骨架完整串一遍。后端核心模块是流式响应生成器里面调用 LangChain 的异步流式接口每次迭代把模型吐出的增量文本包装成统一事件格式发往前端。同时维护一个序列号用于断点续传图省事时用time.time()的微秒值做 id但同一毫秒多条消息会撞号还是建议用单调自增整数。import asyncio import json from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse from langchain_openai import ChatOpenAI from langchain_core.output_parsers import JsonOutputParser app FastAPI() model ChatOpenAI(modelgpt-4o-mini, temperature0.2) parser JsonOutputParser() async def sse_formatter(prompt: str, start_seq: int): seq start_seq async for chunk in model.astream( f请输出一个JSON对象包含name、summary、tags三个字段。{prompt} ): seq 1 payload {seq: seq, type: content, content: chunk.content} yield fid: {seq}\ndata: {json.dumps(payload, ensure_asciiFalse)}\n\n await asyncio.sleep(0) # 主动让出控制权 # 发送完成事件显式带上event类型 done_payload {seq: seq 1, type: done} yield fevent: done\ndata: {json.dumps(done_payload, ensure_asciiFalse)}\n\n app.get(/api/stream/{prompt}) async def api_stream(prompt: str, last_seq: int 0): # 生产环境注意对prompt做长度与内容校验 return StreamingResponse( sse_formatter(prompt, last_seq), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, # 重要禁止Nginx缓冲 }, )这个实现里有两个隐藏的坑。第一await asyncio.sleep(0)虽然看起来不起眼但它能把控制权交回事件循环避免生成器长时间占用事件循环导致其他请求被饿死第二响应头里的X-Accel-Buffering: no是给 Nginx 看的让它不要对这个响应做缓冲否则 Nginx 可能攒满一段数据才会转发给客户端流式效果会被完全破坏。5.2 前端 Vue 代码全貌前端在 Vue 组件里封装一个可复用的useSSEStream组合式函数把 EventSource 的创建、消息分发、打字机节流、断开重连、组件卸载清理都封装起来。这样聊天页面、报告页面、表单填充页面都能直接复用。我最早的时候在每个页面里各写一套 EventSource 逻辑后来改成一个公共封装减少了大量重复代码。import { ref, onBeforeUnmount } from vue export function useSSEStream(urlBuilder) { const displayText ref() const status ref(idle) let es null let msgQueue [] let rawBuffer let timer null function appendDisplay() { if (msgQueue.length 0) { const chunk msgQueue.shift() for (const ch of chunk) { displayText.value ch } } timer setTimeout(appendDisplay, 16) } function start(prompt) { status.value connecting es new EventSource(urlBuilder(prompt)) es.onopen () { status.value connected } es.onmessage (event) { const data JSON.parse(event.data) if (data.type content) { msgQueue.push(data.content) rawBuffer data.content } else if (data.type done) { status.value done es.close() } } es.onerror () { status.value error } if (!timer) appendDisplay() } function getStructuredResult() { try { return JSON.parse(rawBuffer) } catch (e) { return null } } onBeforeUnmount(() { es?.close() clearTimeout(timer) }) return { displayText, status, start, getStructuredResult } }rawBuffer和displayText是两个独立变量这是有意为之。displayText是给用户看的可以被节流逻辑拆散rawBuffer是给程序用的永远保存完整原始文本。最终用getStructuredResult()拿到完整 JSON。我之前犯过一个错误直接用displayText去做JSON.parse结果因为打字机节流还没放完每次都解析失败。5.3 联调与验证方法端到端联调最容易翻车的地方是环境差异。本地开发时 FastAPI 直接监听 8000 端口EventSource 连接毫无压力一上测试环境前面挂了 Nginx各种代理缓冲、超时问题就全冒出来了。我建议联调开始前先用 curl 验证后端 SSE 原始输出这一步能筛掉一半的假故障。curl -N --max-time 30 http://localhost:8000/api/stream/你好-N参数禁用 curl 自己的缓冲--max-time限制最长等待时间。如果 curl 能看到data: {...}一条条滚出来说明后端链路没问题问题大概率在 Nginx 配置或前端。然后再用浏览器开发者工具看事件流EventSource 的网络请求在 Chrome 里会以event-stream类型呈现点开可以实时查看收到的每条数据。如果浏览器里完全收不到数据而 curl 正常重点排查 CORS 和 Nginx 缓冲。验证 JSON 结构化输出时我习惯在开发环境加一个/api/debug/raw接口直接返回完整的 LangChain 输出原始字符串不经过前端处理方便检查模型是否严格遵守了 JSON 格式。这个接口还能暴露一个常见问题模型在最外层 JSON 前后加了 markdown 代码块标记json ... 这种带噪音的文本直接丢给JSON.parse必挂需要在后端用正则剥掉或者在 Prompt 里明确禁止输出多余字符。6. 常见问题与排查技巧实录6.1 idle timeout 断连问题全排查热词里的stream disconnected before completion: idle timeout waiting for sse几乎是我见过的咨询量最高的 SSE 问题出现频率极高。这个报错的字面意思是连接空闲时间超过阈值被某个中间层强制断开了。断开的元凶通常是 Nginx默认配置下proxy_read_timeout是 60 秒这意味着 60 秒内如果上游没有返回任何新数据Nginx 就会主动切断与后端的长连接。表现到用户身上就是模型回答到一半卡住然后页面出现错误提示甚至整个流式输出彻底中断。需要特别指出的是模型推理过程中的“思考停顿”不是真正的空闲但 Nginx 不管你在服务器里做什么它只看“TCP 连接上有没有数据在传”。推理时间超过 60 秒连接就必须死掉。我总结了一套四级排查方案第一级检查服务端生成器是否真的持续有数据产出排除生成进程本身卡死第二级检查 Nginx 关键配置设置proxy_read_timeout 3600s并把proxy_buffering off第三级在应用层增加 SSE 心跳机制服务端每 15 秒发送一行注释: ping\n\n注释被浏览器忽略但能维持 TCP 连接上的数据流动这一招能从根本上解决绝大部分空闲超时问题第四级如果用了云厂商的负载均衡器还要去控制台检查连接超时设置这些中间层同样会掐断长连接。我自己项目里最常用的是第三级方案在生成器的数据产出间隙主动 yield 心跳注释代价几乎为零效果却最稳定。另外一个不为人知的细节如果用了 Docker 部署宿主机与容器之间的网络层超时也可能触发断连建议在容器健康检查里也预留足够的时间窗口。6.2 JSON 半截字符串解析报错流式场景中JSON.parse报错的概率比你想象的高得多而且不是代码 bug是传输过程必然出现的中间状态。data字段被拆成多个 SSE 事件推送时前端第一次收到{type: cont第二次收到ent, content: 你好}如果每次都尝试解析第一次必然抛异常。这种“必然失败的尝试”需要被包容而不是被当成错误上报到监控系统。所以我的前端策略是解析失败时静默返回null只在解析成功时才做后续处理。{ type: content, content: ... }这种结构里半截 JSON 在前后端都可能出现尤其是在字符边界被多字节中文打断时比如一个汉字被拆成两半前半个 UTF-8 字节序列是无效的。这种情况下累积后重新解析通常能恢复但要注意后端生成器在yield时尽量在字符边界分割避免把一个 unicode 字符的字节序列拆开。更进一步我建议前端做一个小工具函数isJsonComplete(text)通过扫描括号配对来判断是否该尝试解析这比盲目JSON.parse然后 catch 更高效而且能避免恰好 catch 掉一个真正需要上报的逻辑错误。6.3 中文乱码与编码问题另一个高频坑是中文乱码。表面上看SSE 是基于 UTF-8 的不会乱码实际上只要中间某一环处理不当问题就会出现。最常见的场景后端代码里json.dumps(payload)漏了ensure_asciiFalse那么中文会被转成\uXXXX的 ASCII 序列前端JSON.parse后能正确还原中文但如果前端做了日志打印或者字符串长度统计看到的就是一堆\u对排查问题干扰很大。还有一种情况出现在 FastAPI 与前端编码不一致时。FastAPI 默认media_typetext/event-stream的 charset 是 utf-8但如果响应被 Nginx 缓冲后改了 Content-Type前端就可能按错误编码解码。我在 Nginx 配置里强制加上charset utf-8;并定期用浏览器开发者工具检查响应的 Content-Type 响应头确认是text/event-stream; charsetutf-8。这个细节很小但修复成本极低效果立竿见影。6.4 避坑清单与排查速查表下面这张表是我项目收尾时整理给团队的速查表覆盖了实战中最常踩的 8 类问题每个问题的排查优先级都标了顺序。优先级 1 表示最先检查因为往往花费精力最少、命中概率最高问题现象排查方向优先级事件流完全没有推送curl 后端点验证后端存活1数据一次性到达无流式效果查 Nginxproxy_buffering off与X-Accel-Buffering响应头160 秒左右连接断开Nginxproxy_read_timeout配置1推理停顿期间断连服务端增加: ping心跳注释2中文变成\uXXXX检查json.dumps是否设置ensure_asciiFalse2前端收到但解析不到 JSON等待括号配对完成后再尝试JSON.parse2重连后内容重复服务端使用 SSEid字段前端传last_seq参数3事件类型不触发确认后端是否使用event字段前端改用addEventListener3我个人在实际操作中的体会是这条链路的难点从来不在单个环节而在边界。SSE 协议本身简单到半个小时就能摸清LangChain 的接口也足够友好真正要花心思的是每一层衔接处的异常处理——服务端生成器如何保活、Nginx 如何不捣乱、前端如何优雅地等待不完整的 JSON、重连后如何不翻旧账。把这四个边界守住了剩下的就只是业务逻辑的堆砌。最后再分享一个小技巧生产环境里我会在服务端把每个 SSE 事件的耗时和序号记到结构化日志里前端再配合上报收到的最后序号。一旦出现断连事故两边对一下序号就能立刻定位数据丢在哪一环而不需要靠用户截图来猜问题。这套“序号对账”机制是我做这个项目以来性价比最高的一次投入强烈建议你也加上。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进