☰
SSE + LangChain 结构化输出实战:从协议原理到生产级流式接口
2026/10/15 11:16:07 网站建设 项目流程

1. SSE 这个看似简单的协议,坑全在细节里

做 AI 应用绕不开流式输出。不管是让大模型一个字一个字往外蹦的打字机效果,还是边生成边返回结构化 JSON,底层几乎都离不开 SSE(Server-Sent Events,服务端推送事件)。我最早接触 SSE 时觉得特别简单,无非就是后端把响应头改成text/event-stream,然后一直往连接里写数据嘛,能有什么坑。真等项目跑起来才发现,那些"看着正常但偶尔卡住"、"浏览器收到数据但连接无端断开"的诡异问题,全和 SSE 协议本身的细节有关。

SSE 和 WebSocket 最大的区别在于它是单向的:只能服务端推给客户端。但正是这个"单向"特性,让它天然适配大模型对话这种场景——用户发完请求之后,剩下的就是等模型吐字,根本不需要持续往服务端发消息。相比之下,如果只是为了"从后端拿流式文本"就用 WebSocket,等于开着卡车去买菜,消息帧格式、心跳机制、双向通道全都要自己维护,复杂度直接高一个量级。我在实际项目里给 LangChain 接流式输出时首选 SSE,原因就是它可以直接复用标准 HTTP 层,配合 Nginx 或云厂商的负载均衡都不用额外做协议转换。

理解 SSE 的关键是搞清楚它的报文格式。一个 SSE 响应长什么样?简单说就是若干行文本,以data:开头,用空行分割不同的事件。服务端可以往里写id:用于断点续传,写event:标记事件类型,写retry:告诉浏览器重连间隔,最常用的就是data和event两个字段。很多新手会犯一个错误:用 JSON 序列化一个完整的对象丢给data:,然后前端JSON.parse去解析。这种做法在小规模 demo 里没问题,但一旦消息体很大,或者中间被代理切断,整个 JSON 就废了——因为 SSE 不保证一条data一定能在一次网络包中完整送达。

所以正规做法是什么?大模型场景下通常是一行一个数据块。比如 OpenAI 的流式接口返回的是data: {"choices":[{"delta":{"content":"你"}}]},每条data就是一个独立的 JSON,前端收到一行就解析一行。我做 LangChain 封装时也遵循了这个惯例,后端把模型输出的文本按 token 追加写入流,每写一个片段就 flush 一次,让前端能实时渲染,同时每个片段之间用空行分隔,保证浏览器能正确识别消息边界。

另外必须提一下 EventSource 和 fetch 流式读取的差异。EventSource 是浏览器内置的 SSE 客户端,用起来极其简单,但它只能发 GET 请求,没法自定义请求头。这意味着你没法用它带 Authorization 头(除非走 cookie),也没法发 POST。LangChain 的很多接口消息体很大,甚至要传 system prompt 和几百轮历史对话,GET 请求的 URL 长度根本扛不住。我之前做过一个 Vue 项目,最初用 EventSource 接 agent 的流式响应,后来需要把用户多轮上下文也塞进请求里,只能全部改成 fetch + StreamReader 手动读流。前者是"开箱即用但限制多",后者是"啥都能干但要手写解析逻辑",两者没有绝对优劣,取决于你的鉴权方式和请求体复杂度。

还有一个小细节:SSE 协议规定浏览器在连接异常断开后会自动重连,重连时会带上 Last-Event-ID 头。如果你的后端实现了断点续传逻辑,这个头就是救命的;如果没实现,就要在服务端显式关闭连接或者忽略重连请求,否则会出现奇怪的"重复消息"。我之前踩过一次坑,服务端在一次任务结束后忘了主动断开连接,浏览器等了一会儿自动重连,导致用户看到了两次完整的回答。这个问题排查了半小时,后来在代码里明确了"任务结束必须close()连接"的约定才彻底解决。

2. LangChain 结构化输出:别再让模型凭空吐 JSON 了

大部分人入门 LangChain 时玩的是最简单的链式问答:往里丢 prompt,得到一段自然语言回复。但在真实生产环境里,很少有大模型直接输出文本给用户就完事的场景。更多时候,你需要的是让模型返回一个 JSON,里面包含字段、类型、甚至嵌套结构,供下游系统直接消费。比如我做一个"AI 自动生成销售周报"的功能,需要模型返回{"title": "...", "summary": "...", "metrics": {"revenue": 10000}}这种结构,而不是一段讲了一堆废话的自由文本。这就引出了 LangChain 结构化输出的核心价值:把"模型自由发挥"变成"模型按契约返回"。

LangChain 的结构化输出有几种典型方案,我按使用频率排个序。

第一种是with_structured_output(),这是目前最新也是最推荐的方式。它允许你传入一个 Pydantic 模型或 JSON Schema,LangChain 会根据你的定义自动选择走 tool calling 还是 JSON mode。比如定义好class WeeklyReport(BaseModel)之后,直接chain.with_structured_output(WeeklyReport)就完事。返回的对象就是你定义的 Pydantic 实例,字段校验、类型转换、嵌套结构全都由框架兜底。这种方式适合大部分"我知道我要什么结构"的场景。

第二种是PydanticOutputParser,也就是在 prompt 里告诉模型"你是一个 JSON 输出器,必须严格按 schema 来",然后模型输出的文本再由 parser 做一次解析。这种方式的问题是模型不一定每次都乖乖听话,输出里偶尔会混入 markdown 代码块标记、注释文字甚至换行导致的格式错乱。一旦模型抽风,parser 直接抛OutputParserException,你必须做兜底。

第三种是StrOutputParser,严格来说它不做结构化,只负责把模型输出转成字符串。但如果你搭配 LangChain 的JsonOutputParser用,反而能实现一种非常实用的流式 JSON 解析方案,后面我会详细讲。

我的经验是:能用with_structured_output就不要手写 prompt 约束。原因很简单——工具的可靠性永远高于模型的自觉性。模型即使被 prompt 教育了一百遍"你只能输出 JSON",在输出长度很长时还是可能出现中途截断、引号转义错乱之类的问题。而with_structured_output走的是 tool calling 或 OpenAI 的response_format参数,从协议层面强制 JSON 格式,可靠性完全是另一个级别。

不过这里有个容易被忽略的问题:with_structured_output并不是所有模型都支持。本地跑的 Ollama 模型、某些开源模型走 tool calling 的链路时,LangChain 底层需要做一些适配,如果不支持,就会回退到 prompt-based 方案。我实际测试过用qwen2.5:14b这类本地模型配合with_structured_output,稳定性只能说还行,偶尔还会出现格式逃逸的情况。真要在生产环境用,我的建议是优先选支持 function calling 的商业模型 API,或者提前做好格式逃逸的兜底逻辑。

再补一个真实经验:结构化输出的字段命名也很有讲究。Pydantic 模型里最好用英文短命名,含义靠注释表达,而不是用中文命名或超长驼峰命名。因为大模型的 token 感知和输出稳定性会受字段名长度影响,字段名越短越清晰,模型输出时越少出错。比如revenue: float就比the_total_revenue_amount_of_the_current_period: float稳定得多。这一点我和同事实测对比过,确实是后者更容易出格式错误。

3. FastAPI 后端实操:把 LangChain 流式输出封装成 SSE 接口

后端部分决定了整个流式体验的地基。我用的是 FastAPI + LangChain 的组合,FastAPI 天然支持StreamingResponse,配合text/event-stream的 media type,就可以把一个异步生成器直接暴露成标准 SSE 接口。核心思路是:LangChain 的.astream()方法逐块产出输出,后端把每个块包装成 SSE 的data:行,实时写进响应体。

先看一个最简单的实现。假设你有一个chain,它是 LangChain 的 LCEL 表达式链,直接调用:

from fastapi import FastAPI from fastapi.responses import StreamingResponse app = FastAPI() async def event_generator(question: str): async for chunk in chain.astream({"question": question}): if chunk: # 把每个片段包装成 SSE 的 data 行,注意结尾要跟两个换行 yield f"data: {json.dumps({'type': 'token', 'content': chunk}, ensure_ascii=False)}\n\n" @app.post("/chat/stream") async def chat_stream(question: str): return StreamingResponse( event_generator(question), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", # 禁用 Nginx 缓冲,否则流式会被吞 }, )

这个基础版能跑,但实际项目里远远不够。你需要考虑几个问题。

第一个是心跳保活。很多生产环境在网关层配置了 idle timeout,比如云负载均衡默认 60 秒没有数据就断开连接。大模型生成过程有时候会"卡顿",尤其是思考复杂问题时可能几秒内没有新 token 产出。如果这几秒恰好触发空闲超时,浏览器就会收到一个连接被重置的错误,表现为"对话中途突然停住"。网上那个典型的报错stream disconnected before completion: idle timeout waiting for sse就是这么来的。解决办法很简单——周期性发送注释行。SSE 规范允许data之外的行可以被客户端忽略,最常见的做法是每 15 秒发一行: keep-alive注释。注意冒号开头的行是注释,浏览器不会触发任何事件,但能保持连接活跃。

import asyncio async def event_generator(question: str): try: async for chunk in chain.astream({"question": question}): if chunk: yield f"data: {json.dumps({'type': 'token', 'content': chunk}, ensure_ascii=False)}\n\n" finally: # 任务结束必须断开连接,避免浏览器自动重连造成重复输出 yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"

第二个是事件类型的设计。我通常约定三类事件:token表示文本片段,tool表示 agent 正在调用工具,done表示整个流结束。前端根据event字段区分处理,打字机只渲染token类型,tool类型可以用来展示"正在调用搜索工具"这类过程信息。如果你用 EventSource 监听,则天然支持event:字段映射到不同回调;如果用 fetch 流式读取,就得自己做 JSON 解析后分发。

第三个是"封装 sse 流式接口调用逻辑"这个热词背后的问题——当你不止一个接口需要流式输出时,重复写生成器逻辑会让人崩溃。我现在的做法是抽一个通用函数:

def sse_wrapper(agen): """把任意异步生成器包装成标准 SSE 数据流""" async def wrapper(): try: async for item in agen: if isinstance(item, dict): yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n" else: yield f"data: {json.dumps({'type': 'token', 'content': str(item)}, ensure_ascii=False)}\n\n" except asyncio.CancelledError: # 客户端断开时,要主动停止内部生成器,避免泄漏 raise finally: yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n" return wrapper()

这个封装的好处是业务代码只需要返回一个async for的生成器,上层统一处理 SSE 格式、心跳和结束事件,各个接口就不用各写各的流式逻辑了。很多网上流传的"封装 SSE 流式接口调用逻辑,完成流式消息解析与"的帖子,实战里都是在做类似这件事。

再补充一个关于 FastAPI 的细节:StreamingResponse中最好不要使用同步生成器(非 async 的 yield 函数),因为那样会阻塞事件循环。尤其当你还需要同时处理多个用户的流式请求时,同步生成器会把整个服务拖垮。老老实实写成async def生成器,内部所有耗时操作都走异步。

4. 前端打通:打字机效果与流式 JSON 的增量解析

后端把流式数据准备好了,前端要做的两件事:一是渲染打字机效果,二是从流里实时提取 JSON 结构。这两件事其实是可以同时做的——文本边增加,解析器边在后台尝试还原 JSON 结构。

先说打字机效果。如果用了 EventSource,事件监听写起来非常方便:

const es = new EventSource("/api/chat?question=xxx"); es.addEventListener("token", (event) => { const data = JSON.parse(event.data); currentText += data.content; document.getElementById("output").textContent = currentText; }); es.addEventListener("done", () => es.close());

但前面说了,EventSource 只能 GET,很多场景下有 POST 需求。那就得用 fetch + ReadableStream。核心代码长这样:

async function fetchStream(payload) { const resp = await fetch("/api/chat/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const reader = resp.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按 SSE 的空行分割完整事件 const events = buffer.split("\n\n"); buffer = events.pop(); // 最后一段可能不完整,保留到 buffer for (const evt of events) { const lines = evt.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const data = JSON.parse(line.slice(6)); handleSSEData(data); } } } } }

注意decoder.decode(value, { stream: true })这个参数,它在处理多字节 UTF-8 字符跨网络包边界时必不可少。忽略它的话,中文可能在流中间出现乱码,尤其是模型输出一个很长的句子时,一个字可能被拆在两个 chunk 里,你会在界面上看到一闪而过的乱码然后恢复。这个问题我第一次做时踩过,前端排查了半天,最后才发现是解码器没配stream: true。

打字机效果渲染本身有两个优化点。第一,不要直接操作 DOM 拼接字符串,用虚拟 DOM 或框架的状态管理会更顺滑。比如 Vue 里直接改ref的值,React 里用setState批量更新,都能自动触发重渲染。第二,如果文本很长,整个渲染会越来越卡,可以做一个小优化:针对纯文本场景,用innerHTML拼接可能比textContent慢,能用textContent就尽量不要碰innerHTML,还能顺便防 XSS。

再说流式 JSON 解析。这是"完成流式消息解析"里最有意思的部分。直接JSON.parse整个流式 JSON 是行不通的——流还没结束,JSON 永远是不完整的。有一种思路是把每个 token 追加进一个缓存字符串,每收到一个 token 就尝试JSON.parse一次,成功就代表当前已解析出完整的结构,失败就忽略继续等。这种"每次都尝试,直到成功"的贪心策略在实践里非常好用,尤其对 LangChain 的JsonOutputParser输出来说,因为模型输出的 JSON 是按 key 逐步生成的,结构是渐进式完整的。

let rawJson = ""; function handleToken(content) { rawJson += content; try { const obj = JSON.parse(rawJson); // 解析成功,更新界面状态 renderPartialResult(obj); } catch (e) { // 不完整,忽略 } }

这个方法有个需要注意的地方:如果 JSON 中间包含转义字符串(比如{"text": "he said \"hi\""}),在引号转义符还没写完时JSON.parse会一直报错,但这没关系,等后续 token 补全后自然会解析成功。唯一要注意的是不要把rawJson缓存得无上限,防止某些异常情况下内存暴涨,可以在接近预期长度时做一次截断保护。

对于 LangChain 的结构化输出,还有一个更省心的思路:用JsonOutputParser配astream(),让 LangChain 自己把增量 JSON 整理好再传出来。去年我给一个基于 DeerFlow 的智能体做二次开发时,就是让 agent 流式输出 markdown 正文的同时,在结尾专门输出一段 JSON 结构化字段,前端用增量解析的方式边抖出文本边实时更新"标题""关键词"这类元信息,体验比等全部跑完再一次性渲染好得多。

5. 连接断开、超时与 JSON 解析失败的排查实录

这部分是我最想分享的,几乎每个问题都在生产环境真实遇到过。先说 SSE 连接断开。报错信息五花八门,最常见的是stream disconnected before completion: idle timeout waiting for sse,这个报错的根源通常是链路中某一段有空闲超时机制,常见起因有三个:网关配置、服务端长时间不产出数据、客户端主动断开。排查步骤我建议按顺序来:先看服务端日志有没有正常产出数据,如果有而前端断了,大概率是中间代理层的问题;如果服务端本身就长时间没产出,那是模型的问题或配置问题;如果两者都正常,就要看是不是客户端超时了。

我用一个表格总结排查思路:

症状可能原因解决方向
流开始后几十秒无数据,然后断开代理/网关空闲超时增加心跳注释行,缩短心跳间隔
接口后面包了 Nginx 后流式失效Nginx 缓冲了响应加X-Accel-Buffering: no头或调proxy_buffering off
浏览器 EventSource 自动重连服务端未在结束时关闭连接流结束时显式发送done事件并close()
部分移动网络下流式频繁断开运营商/基站 NAT 空闲超时心跳频率提高到 10 秒左右

再说 Nginx 这个老大难。默认 Nginx 会缓冲上游响应,尤其当proxy_buffering on时,SSE 流式输出会被聚合成大块再一次性交给浏览器,输出一点展示一点的效果直接没了。一定要在 location 里加proxy_buffering off;或者让后端响应头带上X-Accel-Buffering: no。这两个办法我都用过,实测X-Accel-Buffering: no更灵活,因为可以在后端单接口控制,不用动 Nginx 全局配置。

JSON 解析失败是另一个高频问题。我遇到过几种典型情况:

第一种是模型在 JSON 前后加了多余文字,比如"好的,以下是您需要的 JSON:{...}"。这种情况PydanticOutputParser通常能处理一部分,但有额外的文字时仍然会解析失败。我的兜底方案是正则优先提取{...}大括号区间,再JSON.parse,再交给 Pydantic 验证。

第二种是 JSON 里出现了非法控制字符或者未转义引号。多数原因是模型自己生成了格式不严格的文本。如果用了with_structured_output还好,tool calling 的输出基本不会出这类问题;如果是 prompt 约束型号,就真的只能靠兜底了。我的个人心得是:不依赖模型自觉,在做完 parse 之后加一层 Pydantic 校验,任何字段变形、类型错误都会被捕捉并触发重试逻辑。

第三种是流式输出时,模型输出了两个连续 JSON 对象(可能因为上下文重复导致)。如果你用一次JSON.parse解析,只会拿到第一个对象,第二个会丢失。做法是把原字符串做一次"从第一个左花括号到最后一个右花括号"的切片,再解析。这种问题在长上下文的对话里偶尔会出现,排查起来最费时间,因为你根本想不到模型会输出冗余内容。

最后聊聊我在调试 SSE 时的独门技巧——用 curl 直连看原始响应。前端经过各种库和框架层层封装后,问题定位很困难。直接curl -N http://localhost:8000/api/chat/stream -d '{"question":"你好"}' -H "Content-Type: application/json",就能看到 SSE 原始报文长什么样。如果是生产环境,还可以先透传到本地在本地 curl,能快速区分是前端解析问题还是后端输出问题。这个习惯帮我节省了不少排查时间。

6. 结构化输出落地时,一定要想清楚的几个取舍

流式输出和结构化输出在需求明确时都很简单,但两者叠加在一起,取舍就变得复杂了。你可以选择"边流式边增量解析",也可以选择"先生成完整 JSON 整个返回给前端",两者的体验差异巨大。

我的建议是:交互场景(聊天、问答、报告起草)优先用流式 + 增量解析,因为用户需要的是"看到东西在动"的反馈感,哪怕 JSON 结构还没完整,可以先渲染标题、列表、核心段落。但如果是内部系统调用、RPA 流程、数据批处理这类不关注实时体验的场景,就没必要上 SSE 了。你完全可以等 LangChain 输出完整 JSON 对象,后端起一个普通 HTTP 接口返回,前端拿到就是一个干净的 JSON。强行引入流式反而增加故障点。

关于 LangChain Agent 场景再多说两句。如果你用 agent-inbox 这类交互式 agent 组件,会发现 agent 可能不光输出文本,还会调用工具。工具调用过程中的中间状态非常多,SSE 的事件类型设计就必须更细致。我建议至少区分token(模型文本)、tool(工具调用信息)、status(状态变化)、done(结束)四类事件。前端拿到tool事件后,可以渲染一个"正在调用搜索工具..."的占位提示,等拿到后续token再切换回打字机效果。这种细分事件的设计,是 agent 类应用体验好坏的分水岭。

还有一点必须强调:大模型生成的内容天然带有不确定性,所以在设计 SSE 协议时,尽量让客户端对服务端发送的各种事件保持"容错"。比如前端代码里对未知事件类型不要抛异常,统一走默认处理分支;对 JSON 解析失败不要直接白屏,而是显示"解析异常,正在重试"。我见过好几次因为一个异常的 SSE 事件导致整个页面崩溃的情况,都是因为前端做得太脆弱,没有做好防御性编程。

7. 一个值得借鉴的完整落地框架

把前面这些串起来,一个完整的"SSE + LangChain 结构化输出"落地框架大概是这样的:

后端:

  1. 定义 Pydantic 模型约束结构化输出字段。
  2. 用chain.astream()流式产出,配合with_structured_output或其他结构化输出方案。
  3. FastAPI 写一个通用 SSE 包装函数,统一处理数据格式、心跳、结束事件。
  4. 流式接口返回StreamingResponse,设置正确的响应头和超时策略。
  5. 在网关、Nginx 层关闭缓冲,保证端到端流式。

前端:

  1. 用 fetch + ReadableStream 处理 POST 流式请求(除非你只需要 GET)。
  2. 实现增量 JSON 解析,边收 token 边尝试JSON.parse。
  3. 按事件类型分发到 UI 渲染函数,打字机效果只处理文本类型事件。
  4. 面对未知事件、解析失败、连接断开等情况做好容错。

调用方:

  1. 约定好事件协议和数据结构,文档写清楚。
  2. 对"流式但非结构化"和"结构化但非流式"两种简化模式做降级兼容。

这个框架我后来在多个项目里复用,包括基于 DeerFlow 智能体二次开发的对话系统、客服知识库问答、以及让 AI 生成结构化日报的场景。每次只需要替换内部的具体业务逻辑,SSE 的管道和事件协议基本不动。

如果你只是做一个个人项目,性能和防御性要求没这么高,直接抄第一节的简易版也够用。但如果你想把这个做成一个复用度高的底层模块,我强烈建议把 SSE 包装函数、心跳机制、事件协议、前端解析器这四部分抽成公用代码,一次投入,长期受益。

最后的最后,分享一个很小的实操细节:SSE 是文本协议,所以后端 yield 出去的数据一定要是字符串,千万别直接 yield 一个 dict 给StreamingResponse,FastAPI 会尝试做 JSON 序列化,但 SSE 格式就乱了。我早期就因为这个浪费过不少时间,看着像是格式问题,其实是类型没转换。写代码的时候多加一个json.dumps顺手得很,但忘了就是一片混乱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询