最近用 FastAPI 做了一个 RAG 流式问答系统支持上传 PDF、多轮对话、打字机效果输出。本文记录从手写 SSE 协议帧到引入 sse-starlette 的全过程包括两个真实踩过的坑依赖版本冲突、断连语义和一个提前预研的问题放到 Nginx 后面会怎样。如果你也在做类似的东西本文能帮你少走弯路。一、为什么需要流式输出普通接口的做法等大模型把答案全部生成完再一次性返回 JSON。问题是用户要白屏等 5~10 秒体验很差。流式输出的目标是 打字机效果—— 大模型吐一个字前端就显示一个字。技术方案对比方案方向复杂度适合场景普通 JSON 响应请求 / 响应低短回答SSEServer-Sent Events服务器 → 客户端单向中等AI 流式输出本文用这个WebSocket双向高实时聊天、协作编辑SSE 本质上就是一个普通 HTTP 长连接服务器可以持续往里面写数据浏览器自动逐条接收。二、SSE 帧到底长什么样SSE 的数据格式非常简单每一帧长这样data: {type:delta,content:中国}注意三个关键点data:是协议规定的前缀不能改后面是一个完整的 JSON 字符串我们自定义的 type、content 都打包在这个 JSON 里最后必须有一个空行\n\n而且这个空行在 JSON 引号外面—— 它是帧与帧之间的分隔符。空行位置写错浏览器永远收不到事件。三、第一版手写 SSE 帧最开始不引入任何第三方库自己拼帧。FastAPI 里核心代码长这样import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel app FastAPI() class AskRequest(BaseModel): question: str def _sse_event(data: dict) - str: 把事件 dict 序列化为 SSE 帧 return fdata: {json.dumps(data, ensure_asciiFalse)}\n\n app.post(/ask/stream) async def ask_stream(req: AskRequest): question req.question.strip() if not question: raise HTTPException(status_code400, detailquestion 不能为空) def generate(): full_answer for event in rag.ask_stream(question): if event[type] delta: full_answer event[content] yield _sse_event({type: delta, content: event[content]}) elif event[type] sources: yield _sse_event({type: sources, sources: event[sources]}) yield _sse_event({type: done}) return StreamingResponse(generate(), media_typetext/event-stream)这版能跑但手写协议有三个坑坑 1\n\n漏一个字符前端就收不到事件。SSE 规定空行才是事件结束少一个 \n浏览器会一直等下一帧。坑 2中文必须写ensure_asciiFalse。不写的话中国 会被序列化成 \u4e2d\u56fd帧体积变大调试时也看不懂。坑 3StreamingResponse要手动设media_typetext/event-stream。不设的话浏览器不知道这是 SSE 流会当作普通 JSON 响应等全部下载完。手写帧的好处是能看清协议本质但生产环境不该自己维护这些细节 —— 容易错还少了心跳、断线重连这些能力。四、引入 sse-starlette结果依赖冲突标准做法是用 sse-starlette 这个库它封装了 EventSourceResponse 和 ServerSentEventpip install sse-starlette然后改造代码from sse_starlette.sse import EventSourceResponse, ServerSentEvent app.post(/ask/stream) async def ask_stream(req: AskRequest): question req.question.strip() if not question: raise HTTPException(status_code400, detailquestion 不能为空) def generate(): full_answer for event in rag.ask_stream(question): if event[type] delta: full_answer event[content] yield ServerSentEvent(data{type: delta, content: event[content]}) elif event[type] sources: yield ServerSentEvent(data{type: sources, sources: event[sources]}) yield ServerSentEvent(data{type: done}) return EventSourceResponse(generate())改动点_sse_event 辅助函数整个删掉ServerSentEvent(data...) 自动帮你做序列化和拼帧StreamingResponse(..., media_type...) 换成 EventSourceResponse(generate())MIME 类型自动设置。踩坑版本冲突新装完启动直接报错fastapi 0.115.0 requires starlette0.39.0,0.37.2, but you have starlette 1.7.0 which is incompatible.原因最新版 sse-starlette 3.x 要求 starlette ≥ 0.49把 starlette 拉到了 1.7.0而我的 FastAPI 0.115.0 锁死 starlette 0.39。两个要求完全不重叠。解决方法是降版本pip install sse-starlette1.8.2pip 会自动把 starlette 降回 0.38.6和 FastAPI 匹配。这个版本的 API 和 3.x 用法完全一样代码不用改。五、断连后发生了什么有个问题我一开始想当然了用户回答到一半关掉浏览器数据库会留下什么我的第一反应是 大模型继续生成完然后存库。因为实际生活里的大模型是这样但是我们目前做出来的demo还不是。真实流程是用户关浏览器 → TCP 连接断开Starlette 检测到客户端断开关闭生成器抛 GeneratorExitfor event in rag.ask_stream(...) 循环被打断循环后面的 db.save_message(...)根本不会执行full_answer 拼到一半就随生成器销毁。也就是说断连后历史表里不会留下半截回答。这其实是好事 —— 历史表里不会出现 用户问了一半、回答了两个字 的脏数据。正常流程请求 → 读历史 → 流式生成 → 存用户问题 → 存完整回答 → done 断连流程请求 → 读历史 → 流式生成一半 → 连接断 → 循环中断 → 什么都不存如果产品要求 哪怕断连也要存完整回答就需要把生成任务放到后台协程里跑让它独立于客户端连接。这个改动大概 40 行代码demo 阶段可以zanshi需要做。六、提前想一下放到 Nginx 后面会遇到什么坑本地跑通后我在想SSE 这种长时间保持连接的接口放到反向代理后面会不会有问题查了一下 Nginx 默认配置果然有个 proxy_read_timeoutproxy_read_timeout 60s;它的意思是Nginx 等后端数据超过 60 秒还没收到就主动断开连接。那问题就来了大模型在 思考检索资料、推理这段时间没有 token 输出SSE 连接上 60 秒没有任何数据流过Nginx 判定超时断开连接前端表现为 卡住然后失败。这也是为什么 EventSourceResponse 自带心跳每隔几秒自动发一个 SSE 注释帧 : ping\n\n即使大模型没产出新 token连接上也一直有数据流过Nginx 就不会误判超时。这也是为什么手写 StreamingResponse 虽然能跑但生产环境更推荐 sse-starlette—— 心跳这种细节库已经帮你处理好了自己写容易漏。EventSourceResponse 自带心跳每隔几秒自动发一个 SSE 注释帧 : ping\n\n即使大模型没产出新 token连接上也一直有数据流过Nginx 就不会误判超时。如果坚持用 StreamingResponse需要自己在生成器里加心跳任务定期往连接里写注释帧。七、上传 PDF 的三道防线顺便记录上传接口的安全设计这部分和流式无关但做 RAG 都要用到ALLOWED_EXTENSIONS {.pdf} MAX_UPLOAD_SIZE 50 * 1024 * 1024 # 50MB app.post(/upload) async def upload_pdf(file: UploadFile File(...)): original_name file.filename or upload.pdf ext pathlib.Path(original_name).suffix.lower() if ext not in ALLOWED_EXTENSIONS: raise HTTPException(status_code400, detail仅支持 PDF 文件) content await file.read() if len(content) MAX_UPLOAD_SIZE: raise HTTPException(status_code413, detail文件超过 50MB 限制) # 防路径穿越存储名用 uuid原文件名只做展示 stored_name f{uuid.uuid4().hex}{ext} pdf_path UPLOAD_DIR / stored_name pdf_path.write_bytes(content) ...三道防线扩展名白名单只允许 PDF大小限制50MB防止超大文件撑爆内存UUID 重命名用户文件名可能是 ../../etc/passwd直接拼路径会写穿目录。用随机名存储原文件名只作为元数据。八、最终项目结构KubeRAG/ ├── app/ │ ├── main.py # FastAPI 路由层 │ ├── rag.py # PDF 切片、向量化、Chroma 检索 │ ├── db.py # SQLite 会话历史 │ └── agent.py # 工具调用 Agent ├── data/ │ ├── uploads/ # PDF 原文 │ └── chat_history.db ├── static/ │ └── index.html # 前端页面 └── requirements.txt完整代码已上传 GitHubBowliceDXY/KubeRAG (github.com)九、面试可能会追问的问题写这篇文章的过程中我自己整理了几个面试官大概率会问的问题供参考SSE 和 WebSocket 怎么选—— 单向推送选 SSE双向交互选 WebSocket\n\n为什么在 JSON 外面—— 它是帧分隔符不是数据内容用户中途关页面历史会存半截吗—— 不会生成器被关闭save_message 不执行Nginx 超时断连怎么解决—— 心跳 ping保持连接活跃sse-starlette 和手写 StreamingResponse 区别—— 封装了序列化、MIME、心跳少写协议细节。总结做流式输出本身不难难的是处理那些教程文章里不太会写的边缘情况依赖版本冲突、断连后发生什么、代理超时怎么办。写这篇文章的初衷不是当教程而是把自己这几天学习过程中踩过的坑完整记录下来。如果其中某一段刚好帮到正在做同样事情的同学那就真的太好了。我也是边学边做文章里的理解不一定全对。如果你发现哪里有问题、或者有更优雅的实现方式欢迎评论区指出来咱们一起讨论。
企业数字化 ERP 产品动态
相关推荐
DSec沙箱平台如何支撑300万Agent环境:轻量隔离与规模化调度 1. 从"一个Agent一个容器"说起:DSec要解决的到底是什么问题如果你最近半年在折腾Agent开发,大概率经历过这样的场景:本地跑一个Agent做测试,开个Docker容器,装依赖、配环境、挂载工具链,一套流程… · 2026/9/26 6:44:27
Spring Boot集成GBase 8s最小demo:驱动配置与事务回滚 简介:这是一份面向Java开发者的Spring Boot集成GBase 8s数据库的入门示例项目,以MyBatis作为持久层框架,逐步演示了从添加依赖、配置数据库连接与数据源、创建Mapper接口到执行增删改查的完整集成过程,适合需要为生产系统适配国产… · 2026/9/26 6:44:27
AI Agent错误处理实战:校验、暂停、回滚与人工接管 把AI Agent放进真实业务环境之后,我会默认一件事:它一定会出错。不是“可能出错”,是“必然出错”。LLM本身是概率系统,每一步决策都带着不确定性,工具返回的数据、外部服务的可用性、上下文窗口的截断,随便… · 2026/9/26 6:44:27
Java开发者转型AI Agent工程师:Spring AI进阶路线与15个实战方向 1. 从Java开发者到AI Agent工程师:这条路到底该怎么走这两年跟不少做Java的朋友聊过,大家普遍有个焦虑:AI这波浪潮来了,Python阵营的人好像天然占优势,写Java的是不是要被落下了?我一开始也有这个担心&… · 2026/9/26 7:18:57
AI测试开发进阶:从大模型到Agent自动生成UI脚本的实战指南 这两年面试测试工程师,我明显感觉到风向变了。前两年大家拼的是谁会写自动化脚本、谁会搭接口测试框架,现在面试官开口就问“有没有用过大模型辅助测试”“能不能让Agent自动生成脚本”。包括智联、BOSS直聘上搜索“AI测试开发”,岗位需求量和… · 2026/9/26 7:18:57
Java开发者转型AI Agent工程师:Spring AI四阶段学习路线与实战指南 1. 从Java开发者到AI Agent工程师:这条路到底该怎么走这两年Java圈子里的焦虑感肉眼可见。以前面试聊的是JVM调优、并发编程、Spring循环依赖,现在面试官冷不丁来一句“你用过Spring AI吗”“Agent和LLM的区别说一下”,很多人当场就卡壳了。我… · 2026/9/26 7:18:57
RHCSA第二次作业实战:LVM扩容、SELinux与防火墙配置 1. RHCSA第二次作业:从命令熟练到系统管理思维的转变RHCSA(Red Hat Certified System Administrator,红帽认证系统管理员)是很多Linux从业者考的第一张认证,它不考背诵、不考选择题,全是上机实操。我拿到“… · 2026/9/26 7:18:57
不用 Spring:手写 MVC,一个 Servlet 如何炼成 Spring MVC 不用 Spring,手写一个 JavaWeb 框架 ④(收官):手写 MVC,一个 Servlet 如何炼成 Spring MVC 📌 系列连载中: ① 手写数据库连接池 → ② 手写 IoC 容器(包扫描 三级缓存)… · 2026/9/26 7:18:57
杭州精工液压伺服阀高频响 工业自动化精密运动控制 现货供应支持定制 随着工业自动化产业的快速升级,精密运动控制已经成为各类高端装备、自动化生产线的核心性能指标,而伺服阀作为液压控制系统中实现高精度动力与动作调节的核心元件,其性能直接决定了整个设备的控制精度、响应速度与运行稳定性。近年来… · 2026/9/26 7:18:44
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46