首页 / 资讯中心 / 文章详情

LangChain实战:SSE流式输出、OutputParser与ToolCall全链路整合

LangChain实战:SSE流式输出、OutputParser与ToolCall全链路整合 ★ FEATURED ARTICLE
先把人话放在前面你搭过LangChain应用大概率会遇到这么个场景——模型输出是一串一路蹦出来的token你要在网页上显示出打字机效果同时后端还要从这些字里抽出结构化数据去做后续逻辑然后可能还得让模型调用一个函数才能回答。SSE、OutputParser、ToolCall这三件事放在一起干很多新手会卡到怀疑人生。我这次把从流式接口到结构化输出的完整链路拆开从FastAPI侧怎么发SSE流到LangChain三大Parser的差别和踩坑点再到ToolCall的实战姿势一条线讲完。这个内容适合两类读者一类是正在做Chatbot、知识库问答想给前端接流式效果的另一类是开始做Agent、想让模型真正操作工具而不是光吐文字的。看完之后你能直接抄走一套可运行的方案也知道遇到“stream disconnected before completion”这类报错时问题到底出在哪一层。1. 为什么要凑齐 SSE、OutputParser 和 ToolCall 三件套1.1 需求还原从打字机到“让 AI 下地干活”很多项目的演进路线是固定的:第一个版本只要模型回复文字,第二个版本要求前端打字机效果,第三个版本开始要求抽结构化字段,比如从用户提问里识别出意图、提取实体、生成标签,第四个版本就要让模型调用搜索引擎、数据库、内部API了。这四条需求,恰好对应SSE、OutputParser和ToolCall三个技术点。先说SSE(Server-Sent Events),它是浏览器原生支持的服务端推送协议,服务端可以持续向客户端推送消息。做LLM应用时,模型是流式吐字的,你不可能等全部生成完再一次性返回给前端——用户等不了,体验也太差。用SSE把token一个接一个推给浏览器,前端一边收一边渲染,这就是打字机效果的本质。值得强调的是,SSE是单向的,服务端主动推,客户端被动收,这正好匹配LLM调用场景:请求一次,持续推送回复。再说OutputParser,它的核心作用是约束和解析模型的输出。大模型吐出来的是自然语言文本,不是JSON,不是字段,不是对象。后端程序要拿用户问题里的“城市、日期、预算”去调API怎么办?必须有一个Parser把这些文本按照约定好的结构拆出来。LangChain里的OutputParser就是干这个的:把裸文本变成Python对象,顺便校验格式。最后是ToolCall,也就是工具调用。模型在回答你之前,可以先决定要调哪个函数、传什么参数,拿到工具执行结果后再组织语言回复。这和OutputParser是两种思路:OutputParser是“从文本里解析参数”,ToolCall是“让模型原生输出参数对象”。后者可靠得多,但依赖模型能力,也需要你做Agent循环。1.2 三个关键选型判断第一,流式协议选SSE而不是WebSocket。原因很简单:LLM接口是典型的“一请求一响应,但响应分片很多”,不需要客户端频繁上行消息。WebSocket是全双工,功能强但维护成本高,浏览器断线重连还要自己写;SSE天生支持自动重连,协议简单,服务端实现更是几行代码的事。凡是做Chatbot流式输出,我优先SSE。第二,结构化输出在Parser和ToolCall之间怎么选。我的经验是分阶段:如果项目只有文本问答,用OutputParser足够;如果要做Agent,直接上ToolCall。早期项目想省钱,用PydanticOutputParser让模型吐JSON,效果也不差,但模型一换、prompt一改,解析就可能翻车。ToolCall是模型厂商在训练阶段就优化过的能力,参数以JSON对象形式直接给到代码,不需要你从自然语言里“大海捞针”。第三,后端框架用FastAPI的StreamingResponse。FastAPI天然支持async生成器,配合LangChain的astream方法非常顺滑。Node后端也有类似方案,但Python生态里LangChain的支持最完整,尤其当你需要同时处理流式SSE、工具调用、Agent循环时,FastAPI是当前最优解。2. 开发环境与基础链路搭建2.1 项目基础结构与环境配置动手之前,先把环境理清楚。我的项目里最常用的一套组合是:Python 3.11 FastAPI uvicorn[standard] langchain langchain-openai openai pydantic v2安装命令走pip即可:pip install fastapi uvicorn[standard] langchain langchain-openai openai pydantic这里要提醒一个版本坑:langchain-openai是独立包,别直接pip install langchain就以为啥都有了。在2024年之后的LangChain版本里,langchain-openai负责OpenAI系模型接入,langchain本体不再捆绑具体模型实现。项目文件结构我习惯这样组织:app/ ├── main.py # FastAPI入口注册路由 ├── schemas.py # Pydantic模型请求体/响应体/结构化输出 ├── tools.py # 自定义工具函数 ├── parser_demo.py # OutputParser相关 ├── agent_demo.py # ToolCall Agent循环 └── sse_chunk.py # SSE流式相关逻辑2.2 先打通最基础的SSE流式接口网络上有大量LangChain入门教程停留在“离线调用模型”阶段,真正要接前端,第一步是让模型输出流起来。下面这段是我实测可用的最小FastAPI接口:# main.py import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from langchain_openai import ChatOpenAI app FastAPI() llm ChatOpenAI( modelgpt-4o, temperature0, api_keysk-xxx ) app.post(/chat/stream) async def chat_stream(prompt: str): async def gen(): async for chunk in llm.astream(prompt): # 每条SSE数据必须是 data: 内容\n\n 格式 yield fdata: {json.dumps({content: chunk.content}, ensure_asciiFalse)}\n\n # 结束标记前端靠它判断流结束 yield data: [DONE]\n\n return StreamingResponse(gen(), media_typetext/event-stream)这个接口干了几件事:接收用户prompt,调用LangChain的astream方法,把每个chunk包成SSE格式吐出去,最后发一个[DONE]标记。客户端只要按SSE协议解析,就能看到逐个蹦出来的文字。有一个细节容易翻车:yield的字符串必须以\n\n结尾。SSE协议规定,消息之间用空行分隔,data:是数据行的前缀。很多新手写流式接口时只发了data:行没加空行,前端EventSource就永远不触发onmessage。这不是玄学,是协议要求。另外,StreamingResponse的media_type必须设成text/event-stream。写成application/json的话,浏览器EventSource会直接报错。响应头一般还需要关闭缓存,可以加一个headers{Cache-Control: no-cache}。2.3 前端怎么处理SSE流你搜“vue python sse”,大概率会看到一堆人在问:为什么用EventSource收不到数据?答案多半是后端格式不对,或者EventSource本身不支持POST。EventSource有个硬限制:只支持GET请求。如果你的LangChain后端希望接收POST请求(带复杂JSON参数),前端就得用fetch手动读取流。我用Vue 3写过一个SSE流解析函数,核心逻辑是把响应体当成ReadableStream,按SSE协议的换行规则拆分:// sse.ts export async function readSSEStream( url: string, body: object, onMessage: (event: MessageEventLike) void ) { const res await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body), }) const reader res.body!.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { value, done } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) // SSE消息以空行分隔 const parts buffer.split(\n\n) buffer parts.pop()! // 保留最后不完整的一段 for (const part of parts) { const lines part.split(\n) for (const line of lines) { if (line.startsWith(data:)) { const data line.slice(5).trim() if (data [DONE]) return onMessage({ data }) } } } } }这个函数每次拿到一小段字节流,先拼进buffer,再按\n\n切分成完整消息,最后提取data:前缀后面的内容。前端坚持用Content-Type: application/json发POST,后端可以正常写Pydantic请求体模型,不用为EventSource将就GET。前端接口一旦通,你就会发现tokens是一小段一小段蹦出来的。但注意,这只是“流式输出”的最表层,真正的复杂度在于:模型可能边吐字边要调用工具,这部分我们把战线拉到第5节。3. LangChain 三大 OutputParser 实战与避坑3.1 为什么模型输出必须经过 Parser 这道闸门模型输出是给人看的话,不是给代码看的数据。你要让后端流程自动读取用户问题里的“城市、日期、预算目标”,唯一可行的方式是:在Prompt里告诉模型“你必须返回JSON,字段是city、date、budget”,然后用一个Parser把模型返回的JSON文本解析成Python对象。OutputParser在LangChain里的运行机制是:先把格式指令注入Prompt,再把模型输出解析成目标结构。也就是一个塞进去、一个掏出来。塞进去靠get_format_instructions(),掏出来靠parse(),如果掏出来发现格式对不上,还能用OutputFixingParser做错误修正。3.2 PydanticOutputParser:最硬核最严格,但也是最容易踩坑的那个PydanticOutputParser是三大Parser里我个人用得最多、也坑得最多的一个。它让你定义一个Pydantic模型,然后模型输出必须严格匹配这个结构。以下是Pydantic v2版本的代码:from langchain.output_parsers import PydanticOutputParser from langchain_core.pydantic_v1 import BaseModel, Field # 注意版本 class TravelPlan(BaseModel): city: str Field(description用户想要旅行的城市) budget: float Field(description预算金额数字) days: int Field(description旅行天数) must_visit: list[str] Field(description必去景点列表) parser PydanticOutputParser(pydantic_objectTravelPlan) format_instructions parser.get_format_instructions()然后把它拼进Prompt:from langchain_core.prompts import PromptTemplate prompt PromptTemplate.from_template( 根据用户需求提取旅行计划。\n {format_instructions}\n 用户需求{query} ) chain prompt | llm | parser result chain.invoke({query: 我想去成都玩三天预算五千必须去熊猫基地和宽窄巷子}) print(result) # TravelPlan(...)看到这里你会觉得很美好,但实战中这个Parser有四个大坑:第一个是Pydantic版本混乱。LangChain旧代码普遍是from langchain import PydanticOutputParser,新版本移到langchain_core.output_parsers。如果你想少踩坑,建议统一用langchain_core, 并且在Pydantic v2项目里注意from langchain_core.pydantic_v1 import BaseModel这个导入路径——LangChain兼容层用的是pydantic v1方言,Field描述写法不一样。第二个是**JSON被包裹**。一些模型,尤其是开源模型,会把JSON输出在json 代码块里。parser.parse()遇到代码块会死,报错类似于“Expecting value: line 1 column 1 (char 0)”。我见过太多人卡在这里。最省事的办法是在Template里加一条“直接输出JSON,不要代码块标记”。第三个坑是转义字符。模型输出的JSON里如果嵌套引号或换行符,Python的json.loads有可能直接炸。PydanticOutputParser对格式的宽容度不高,一旦炸了,整条链就断了,不会自动重试。第四个坑是字段名不匹配。模型的习惯性输出跟你的字段定义对不上,比如定义了must_visit,模型写成must_visits,解析直接失败。遇到这些情况,我的建议是给Parser加保险——OutputFixingParser。它能在解析失败时把错误信息和原文本塞给LLM重新修正一遍:from langchain.output_parsers import OutputFixingParser fixing_parser OutputFixingParser.from_llm( parserparser, llmChatOpenAI(modelgpt-4o) )代价是多一次模型调用,但换来的是稳定性。对生产环境来说,这往往是值得的。3.3 StructuredOutputParser:轻量级 KV 提取,入门首选如果你不需要嵌套对象,只想提取几个简单的键值对,StructuredOutputParser比Pydantic轻得多。它的原理是给你一组ResponseSchema,然后让模型按固定格式输出,解析时用简单的key-value拆分:from langchain.output_parsers import StructuredOutputParser, ResponseSchema response_schemas [ ResponseSchema(nameemotion, description用户情绪positive/negative/neutral), ResponseSchema(namekeyword, description用户提问中的核心关键词), ] parser StructuredOutputParser.from_response_schemas(response_schemas) format_instructions parser.get_format_instructions() prompt PromptTemplate.from_template( 分析用户情绪和关键词。\n{format_instructions}\n用户说{query} ) chain prompt | llm | parser result chain.invoke({query: 你们的客服也太差劲了一天都没回我消息}) print(result) # {emotion: negative, keyword: 客服 回复慢}StructuredOutputParser的卖点是简单直接,适合做意图分类、情绪判断、标签提取这种“字段不多、值不嵌套”的场景。它内部其实是一种朴素的行解析,不依赖JSON的完整性,容错性反而比PydanticOutputParser好一些。但它有明显的天花板:不支持嵌套结构,不支持数组对象。你的输出需要两层以上结构,就别用它了,老老实实上Pydantic。3.4 JSONOutputParser:我只是想让输出更好解析如果你已经习惯了直接llm.invoke()然后json.loads,那JSONOutputParser就是给这个习惯加一层薄薄的保障。它做的事情很简单:把大模型的文本输出解析成JSON。from langchain.output_parsers import JSONOutputParser parser JSONOutputParser() prompt PromptTemplate.from_template( 输出JSON城市、日期、预算\n用户说{query}\n ) chain prompt | llm | parser result chain.invoke({query: 本周想去杭州预算两千内}) print(result) # {city: 杭州, date: 本周, budget: 2000}和PydanticOutputParser的区别在于,它不绑定Pydantic模型,拿到的是普通字典。好处是灵活,坏处是没有校验。字段缺失、类型错误它都不管。我个人更建议在LangChain里用JSONOutputParser配合Pydantic模型做二次校验,而不是裸用。它比其他Parser好的地方在于,自动去除文本中的markdown代码块标记(fence)。不过也有限制:如果模型输出里同时有描述文字和JSON,它也会尝试从文本里找JSON块并解析,这种情况下容易把描述里的JSON误当成结果。3.5 三大 Parser 选型速查表一个表格可以直接帮你在项目里做决策:Parser适用场景优势劣势推荐度PydanticOutputParser复杂嵌套结构、字段较多、需要强校验类型安全、字段强约束、可靠性高Prompt格式要求严格、对部分弱模型易翻车极推荐StructuredOutputParser简单KV提取、意图分类、标签识别轻量、速度快、对模型格式要求较低不支持嵌套复杂结构入门推荐JSONOutputParser快速拿JSON、结构不固定的场景简单直接、兼容性最好无校验、字段缺失不提示看场景用选购逻辑不是越复杂越好。我的原则是:能被JSON承载就用JSONOutputParser,结构稳定且需要校验就上Pydantic,简单打标业务用StructuredOutputParser。别一上来就Pydantic,模型格式要求会让你在prompt调优上耗费大量时间。4. ToolCall 方案实战从“解析”到“执行”的质变4.1 OutputParser 的极限,就是 ToolCall 的起点OutputParser的本质是“从自然语言文本里扣参数”。这在很多场景下够用,但有个致命问题:如果模型输出的文本里参数不完整,或者被对话里的其他内容干扰,你得到的就是一堆残缺JSON。而且你每用一次OutputParser,都要在Prompt里写一堆“你必须按以下JSON格式输出”的指令,模型不想听的时候你真拿它没办法。ToolCall彻底改变了游戏规则。它的工作原理是:模型在内部生成响应时,就有一个专门的机制决定“要不要调用某个工具、参数是什么”。你可以把工具列表注册给模型,模型在生成时会先输出一个结构化的“调用计划”,里面包含工具名和参数JSON对象,这不会混在普通文本里。加上了这层原生结构,参数解析的成功率比OutputParser高一个量级。类比一下:OutputParser像你让人把地址写在纸上再手动录入系统,ToolCall像是人直接通过表单控件选择地址提交,后端拿到就是标准字段。4.2 bind_tools 实战LangChain 里 ToolCall 的标准姿势LangChain里做ToolCall,核心API就两个:tool装饰器绑定函数,bind_tools绑定工具列表。看下面完整的代码:from langchain_core.tools import tool from langchain_openai import ChatOpenAI tool def get_weather(city: str, date: str) - str: 获取某个城市某天的天气预报 # 这里可以接真实天气API return f{city}在{date}的天气是晴天气温22-28℃ llm ChatOpenAI(modelgpt-4o, temperature0) llm_with_tools llm.bind_tools([get_weather]) response llm_with_tools.invoke(北京明天天气怎么样) print(response.tool_calls)关键点在于:模型返回的AIMessage对象里带了一个tool_calls列表。每个元素大概是这个样子:[ { name: get_weather, args: {city: 北京, date: 明天}, id: call_abcd1234, type: tool_call, } ]你看,参数是结构化的,不需要任何Parser去抠。这就是ToolCall和OutputParser最核心的差异。4.3 工具执行循环做一个最简单的 Agent 大脑拿到了tool_calls,还只是半程。你需要把每个工具调用执行一遍,再把结果放回模型,让模型基于结果生成最终回复。有的模型一次只会调一个工具,有的会调多个,复杂场景下可能调完一个后又想调另一个——这就是Agent的递归循环。一个最小的Agent循环长这样:from langchain_core.messages import HumanMessage, AIMessage, ToolMessage def run_agent(query: str, max_iterations: int 5): messages [HumanMessage(contentquery)] for i in range(max_iterations): response llm_with_tools.invoke(messages) messages.append(response) # 没有工具调用直接结束 if not response.tool_calls: return response.content # 遍历所有工具调用 for tool_call in response.tool_calls: selected_tool {get_weather: get_weather}[tool_call[name]] tool_output selected_tool.invoke(tool_call[args]) messages.append(ToolMessage( contenttool_output, tool_call_idtool_call[id] )) # 下一轮循环模型会看到工具结果后继续推理 return 已达到最大迭代次数任务未完成这个循环之所以能work,靠的是ToolMessage.tool_call_id和原始tool_call.id一一对应。模型看到每个工具调用的结果后会重新组织语言。如果你没把工具结果以ToolMessage放回对话,模型就不知道刚才的工具调用成功了还是失败了。做多工具、多步骤时,这个循环就是“langchain deep agents”的最小雏形。你可以加检索工具、数据库工具、自定义API工具,每多一个工具,模型就能多干一件事。所谓“让AI下地干活”,本质就是把一批工具交到模型手里。4.4 SSE 场景下 ToolCall 的流式边界问题这里有个很多人没想过的问题:流式输出时,tool_calls是分片(chunk)到达的。你如果用astream逐个chunk拿数据,每个chunk里的tool_call_chunks字段往往是残缺的,必须累加合并才能得到完整的JSON参数。LangChain在新版本里提供了AIMessageChunk.tool_call_chunks属性,你需要在循环里手动合并。简化思路如下:tool_call_chunks {} async for chunk in llm_with_tools.astream(messages): if chunk.tool_call_chunks: for tc_chunk in chunk.tool_call_chunks: index tc_chunk[index] if index not in tool_call_chunks: tool_call_chunks[index] { name: , args: , id: tc_chunk[id] or , } tool_call_chunks[index][name] tc_chunk[name] or tool_call_chunks[index][args] tc_chunk[args] or # 最后集中合并成完整参数 import json for idx, acc in tool_call_chunks.items(): acc[args] json.loads(acc[args])这个代码的意思是:args字段是流式拼出来的JSON字符串,先累加,等调用结束后再json.loads。注意tc_chunk[id]只在第一次出现时有值,后续都是空,所以要用or保住初始值。经验之谈:如果项目里Agent工具调用频率很高,同时又要流式输出最终回复,我建议的架构是——工具调用阶段不等流式,直接一次性拿完整tool_calls执行;只有最终文本回复阶段才开启流式。这样能极大减少复杂度和出错概率。等模型把工具结果都消化完、开始生成人话时,再astream给前端,体验上没区别,代码却简单十倍。5. 整合实战FastAPI LangChain ToolCall 的 SSE 智能体接口5.1 整体数据流怎么设计把前四节的内容拼起来,是一个典型的“FastAPI LangChain ToolCall SSE”智能体服务。数据流是这样的:Vue前端发起POST请求,带用户消息FastAPI接口接收消息,创建一个消息列表LangChain模型判断是否要调用工具,如果要调用,后端执行工具并把结果放回消息列表模型把最终回复以token流的方式通过SSE推给前端前端解析data行,更新UI聊天区域这里有个关键设计:工具调用的过程不要以SSE流形式暴露给前端——除非你要展示“Agent正在调用工具”的状态。更好的做法是:工具阶段在请求内同步完成,然后只把最终回复结果流式返回。想展示中间过程,就用自定义SSE事件推送event: tool_status。5.2 后端核心代码Agent 和 SSE 二合一下面这段代码是我项目里实际在用的结构,做了简化。核心思路是用第4节的ReAct循环处理工具调用,完成后用astream返回最终回复:# agent_demo.py import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from langchain_core.messages import HumanMessage, ToolMessage from langchain_openai import ChatOpenAI from langchain_core.tools import tool app FastAPI() tool def search_flight(from_city: str, to_city: str, date: str): 查询航班信息 return f{from_city}到{to_city}{date}航班号CA123410:00起飞 llm ChatOpenAI(modelgpt-4o, temperature0) tools [search_flight] llm_with_tools llm.bind_tools(tools) app.post(/agent/stream) async def agent_stream(query: str): async def generate(): messages [HumanMessage(contentquery)] # 第一步执行工具调用循环同步拿到最终消息 for _ in range(5): response llm_with_tools.invoke(messages) messages.append(response) if not response.tool_calls: break for tc in response.tool_calls: tool_output {search_flight: search_flight}[tc[name]].invoke(tc[args]) messages.append(ToolMessage(contenttool_output, tool_call_idtc[id])) yield fevent: tool_status\ndata: {json.dumps({tool: tc[name], status: done})}\n\n # 第二步最终回复走流式 async for chunk in llm_with_tools.astream(messages): if chunk.content: yield fdata: {json.dumps({content: chunk.content}, ensure_asciiFalse)}\n\n yield data: [DONE]\n\n return StreamingResponse(generate(), media_typetext/event-stream)注意第4节的Agent循环我用了同步invoke,原因就是上面说的策略:工具调用阶段不求流式,稳定优先。只有最终输出阶段才走astream。实际项目里这个选择帮我少排查了无数个“SSE断流”问题。5.3 前端事件处理文本流和工具状态分道扬镳后端发了两种SSE事件:event: tool_status和默认的data:消息。前端读流时不能只解析data:,还要把event:行的类型记下来。我给出一个适配第2节readSSEStream的思路:function handleSSEMessage(raw: string) { const lines raw.split(\n) let eventType message let data for (const line of lines) { if (line.startsWith(event:)) { eventType line.slice(6).trim() } else if (line.startsWith(data:)) { data line.slice(5).trim() } } if (data [DONE]) { // 结束 } else if (eventType tool_status) { // 更新UI显示正在查询航班状态 } else { // 追加普通文本 } }事件类型和文本内容分开处理之后,Vue端玩出花就很方便了:文本走打字机,工具状态走一个独立的loading区,用户能直观看到“AI正在查航班”而不是干等着。这是我认为SSE智能体UI体验最有价值的一点。5.4 二次开发时可以怎么扩展如果你看到的不是“从零搭建”,而是“基于现有智能体框架二次开发”(比如某些开源框架的二次开发),底层思路是一致的:把SSE事件格式规范化,把工具注册表抽出来,把Agent循环中的每一步事件都外放。很多框架本身就暴露了SSE的data:通道,你要做的就是对齐事件字段。此外,生产中建议加一个人类审批环节——不是所有工具调用都应该自动执行。LangChain有个agent-inbox机制专门做“工具调用前先等用户确认”的场景。简单做法是:当tool_calls里出现敏感工具(比如发邮件、删数据),先停住,往SSE推event: approval_required,用户点了同意再继续循环。这一层能避免很多事故。6. 常见问题与排查记录6.1 必坑stream disconnected before completion: idle timeout waiting for sse先说结论:这个报错八成不是LangChain的问题,也不是你代码的问题,而是连接空闲超时把连接给断了。它字面意思是“在等待SSE时,连接空闲超时”,翻译成白话就是:服务端或者中间代理等了你一会儿,发现这条连接迟迟没有新数据,就主动掐了。三个常见元凶:第一个是Nginx超时配置。这是最常见的。如果你用Nginx反向代理FastAPI,默认的proxy_read_timeout是60秒。SSE连接会一直挂着,如果60秒内没有任何流式数据,连接就被Nginx断掉了。解决办法:location / { proxy_pass http://127.0.0.1:8000; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_http_version 1.1; }proxy_buffering off特别关键。Nginx默认会缓冲后端响应,SSE是长连接流式响应,缓冲会导致前端很久才收到一批数据,甚至直接表现为不显示。关掉缓冲,数据才能一条条流过去。第二个是模型思考时间过长。模型推理本身需要几秒,在这几秒内,服务端还没开始吐token,连接处于空闲状态。前端或者Nginx等不到数据,超时就断了。解决办法:提高超时时间,并且在SSE流上做心跳。所谓心跳,就是定期发送一个注释行,比如:yield : ping\n\nSSE协议规定注释行会被前端忽略,但它能让连接产生数据流动,防止被当作空闲连接掐断。我在长时间Agent任务里会用定时器每15秒发一次ping。第三个是本地测试时操作系统/客户端超时。比如你用Python的requests包访问SSE接口,requests默认没有流式支持,响应不及时读走,底层socket也可能被系统回收。做测试建议用httpx的stream方式,或直接用curl -N,不要用普通requests.get()一把梭。6.2 前端收不到数据,但后端明明在跑这个现象主要集中在两种原因。第一种是前端用了EventSource,但后端接口是POST,EventSource不支持POST直接无反应。这时候换成fetchReadableStream,参考第2.3节的封装。第二种是后端返回的数据不是标准SSE格式,最常见的是忘了\n\n,或者把data:写成了data:加一个奇怪空格。注意协议要求data:后面加一个空格,数据才是“北京天气预报……”。我见过有人写data:{content:...}没空格,前端解析会拿到半截字符串。6.3 流式输出 PydanticOutputParser 直接崩这是我最想吐槽的LangChain坑之一:有人会天真地想,既然输出是流式的,那就把每个chunk喂给PydanticOutputParser,让前端实时拿到结构化解析结果。但PydanticOutputParser需要的是完整JSON,你喂给它半个JSON,它只会一直报错。Parser的输入必须是一个完整消息,不是流的中间片段。如果真要做部分解析(Partial Parser),LangChain有JsonOutputParser分支和部分解析的机制,可以每次尝试解析累积的文本,返回一个部分结果。但实践中我发现这纯属自找麻烦:解析逻辑要兼容“今天解出city,明天解出budget”,状态管理复杂度爆炸。我强烈建议:流式展示给用户的文本用chunk.content,结构化解析另开一路,等待最终完整消息再Parser。两者分开,逻辑清晰十倍。6.4 ToolCall 执行时报错参数里有意外字段模型调工具时很“自信”,可能会传你函数签名里根本没有的参数,比如{city: 北京, unit: celsius},而你定义的get_weather只接受city和date。这时候工具调用就直接报TypeError。解决办法有二:一是在工具函数里加**kwargs兜底忽略多余字段;二是在模型调用前约束工具schema。大部分情况下我选后者——给tool函数写清楚参数描述,模型进化的重点就是学会“别乱传”。描述的准确度直接影响参数可靠性,这个投资很值。6.5 常见问题速查表问题现象根因解决方案idle timeout流到一半断了Nginx/客户端超时关缓冲、加超时、发心跳前端无输出Console无报错EventSource不支持POST用fetchReadableStreamParser崩JSON解析异常模型输出被包裹或格式错误加OutputFixingParser或修改提示ToolCall多传参数TypeError模型参数幻觉规范工具描述、加兜底参数最后分享一个我自己的经验其实踩过这么多坑之后,我现在的项目约定已经非常简单:如果目标只是输出文本,SSE 普通chat模型就够;如果要结构化字段,优先让模型直接吐JSON再用Pydantic校验,别在Prompt格式上反复试错;一旦引入工具调用,就别再用OutputParser去解析工具参数了,老老实实走bind_tools Agent循环,并且永远记住——工具阶段别开流式、文本阶段才开流式。另外,如果你正在做的项目是从某个Agent框架二次开发,建议看一眼它的SSE事件协议定义。通常你会看到data:里包着JSON,event:里标着类型。你只要用自己的前端组件去对齐这些事件,就能把整个框架的能力接进来。把事件规范和工具注册表设计好,这个系统才真的能“下地干活”。
阅读完成 · 觉得有帮助?
咨询建站