项目地址:github.com/frontzhm/n2...
每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。
先看最终效果:

这是一个基于 RAG 范式 的 Text-to-SQL 智能体:用户用自然语言提问,系统自动召回相关表结构、指标定义和字段取值,生成并执行 SQL,流式返回查询结果。换句话说,不懂 SQL 也没关系,用大白话问就行,系统帮你翻译成 SQL 再查数据库。
下面从零开始,一步步搭出来。
1. 初始化项目
bash
# uv 是 Python 的包管理器,用法和 pnpm 很像(文末有详细介绍)
uv init n2sql-agent
cd n2sql-agent
# 试试能不能跑通
uv run main.py
# 终端输出 "Hello from n2sql-agent!" 说明初始化成功
此时目录结构如下:
csharp
.
├── README.md
├── main.py
├── pyproject.toml
└── uv.lock
2. 添加前端页面
前端代码直接从仓库里拷贝 frontend/ 目录到项目根目录即可:
csharp
frontend/
├── index.html
├── package.json
├── pnpm-lock.yaml
├── vite.config.js
├── public/
│ └── vite.svg
└── src/
├── App.vue # 主页面:聊天对话框 + SSE 进度展示 + 结果表格
├── main.js
├── style.css
└── assets/
└── vue.svg
拷贝完后,安装依赖并启动:
bash
cd frontend
pnpm install
pnpm dev
浏览器打开 http://localhost:5173 就能看到页面了。不过现在发消息不会有响应------后端接口还没启动呢。
3. FastAPI 启动后端接口
回到项目根目录,先安装 FastAPI:
bash
uv add "fastapi[standard]"
# fastapi[standard] 包含 uvicorn 服务器和 fastapi 命令行工具,一步到位
然后把 main.py 改成下面这样------从最简单的 hello 接口开始:
python
from fastapi import FastAPI
app = FastAPI(title="n2sql-agent")
@app.get("/hello")
async def hello():
return {"msg": "Hello FastAPI + uv"}
启动后端:
bash
uv run fastapi dev main.py
# 默认跑在 http://localhost:8000
验证一下:
bash
curl http://127.0.0.1:8000/hello
# 返回 {"msg":"Hello FastAPI + uv"} ------ 接口通了!
4. 添加 SSE 流式查询接口
前端请求的是 POST /api/query,而且是 SSE 流式模式。我们需要在 main.py 里加上这个接口,顺便加上 CORS 跨域支持(前端 5173 端口请求 8000 端口需要它)。
把 main.py 完整替换为:
python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# 创建 FastAPI 应用实例
app = FastAPI(title="n2sql-agent")
# 配置 CORS 中间件,开发阶段全放通
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------- 基础接口 ----------
@app.get("/hello")
async def hello():
"""健康检查 / 测试接口"""
return {"msg": "Hello FastAPI + uv"}
# ---------- SSE 流式接口 ----------
from fastapi.responses import StreamingResponse
import json
import asyncio
async def sse_stream():
"""模拟 NLP-to-SQL 全流程进度推送的 SSE 生成器"""
# 真实流程顺序:
# 抽取关键词 → 召回字段/指标/值 → 合并召回信息 →
# 过滤指标/表 → 添加上下文 → 生成 SQL → 验证 SQL → 执行 SQL
texts = [
{"type": "progress", "step": "抽取关键词", "status": "running"},
{"type": "progress", "step": "抽取关键词", "status": "success"},
{"type": "progress", "step": "召回字段", "status": "running"},
{"type": "progress", "step": "召回指标", "status": "running"},
{"type": "progress", "step": "召回值", "status": "running"},
{"type": "progress", "step": "召回值", "status": "success"},
{"type": "progress", "step": "召回字段", "status": "success"},
{"type": "progress", "step": "召回指标", "status": "success"},
{"type": "progress", "step": "合并召回信息", "status": "running"},
{"type": "progress", "step": "合并召回信息", "status": "success"},
{"type": "progress", "step": "过滤指标", "status": "running"},
{"type": "progress", "step": "过滤表", "status": "running"},
{"type": "progress", "step": "过滤指标", "status": "success"},
{"type": "progress", "step": "过滤表", "status": "success"},
{"type": "progress", "step": "添加额外上下文", "status": "running"},
{"type": "progress", "step": "添加额外上下文", "status": "success"},
{"type": "progress", "step": "生成 SQL", "status": "running"},
{"type": "progress", "step": "生成 SQL", "status": "success"},
{"type": "progress", "step": "验证 SQL", "status": "running"},
{"type": "progress", "step": "验证 SQL", "status": "success"},
{"type": "progress", "step": "执行 SQL", "status": "running"},
{
"type": "result",
"data": [
{"gender": "男", "sales_amount": 135370.5},
{"gender": "女", "sales_amount": 143789.0},
],
},
{"type": "progress", "step": "执行 SQL", "status": "success"},
]
# 逐条推送 SSE 事件,每条间隔 200ms 模拟异步处理延迟
for text in texts:
yield f"data: {json.dumps(text, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.2)
@app.post("/api/query")
async def query():
"""自然语言查询入口,以 SSE 流式返回处理进度和最终结果"""
return StreamingResponse(
sse_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
保存后重启后端,用 curl 试一下 SSE 接口:
bash
curl -N -X POST http://127.0.0.1:8000/api/query \
-H "Content-Type: application/json" \
-d '{"query":"测试"}'
# 一行一行地输出进度和结果 ------ 流式接口跑通了!
5. 前后端联调,看最终效果
确保后端跑在 8000 端口,前端跑在 5173 端口。然后打开浏览器访问 http://localhost:5173,在对话框里输入任意问题(比如"男女性别销售额分别是多少"),点击发送。
你会看到:
- 步骤条依次亮起:抽取关键词 → 召回字段 → ... → 执行 SQL
- 最后弹出一张结果表格
目前数据是写死的 mock 数据,但整个前后端 SSE 流式交互的骨架已经搭好了。
架构小结
css
浏览器 (localhost:5173)
│
│ POST /api/query { query: "..." }
▼
Vite 代理 → FastAPI (localhost:8000)
│
│ SSE 流式推送
├─ data: {"type":"progress","step":"抽取关键词","status":"running"}
├─ data: {"type":"progress","step":"抽取关键词","status":"success"}
├─ ...
└─ data: {"type":"result","data":[...]}
│
▼
前端实时更新步骤条 + 最终表格
- 前端 :一个对话框,用
fetch+ReadableStream消费 SSE 流 - 后端 :一个
POST /api/query接口,用StreamingResponse+async generator逐条推送事件 - 一次请求,持续响应:这就是 SSE 的核心价值
科普名词:UV --- Python 世界的 pnpm
前端从 npm 到 yarn 再到 pnpm,Python 世界也经历了类似的进化:最早用 pip + venv 手动管理,繁琐且容易踩坑;如今有了 uv,体验和 pnpm 高度一致。
uv 和 pnpm 在设计哲学上非常相似------所有项目下载的包统一缓存在机器全局位置,再通过硬链接/符号链接引入项目,避免重复下载、节省磁盘空间。
命令速查对照表
| 场景 | pnpm | uv |
|---|---|---|
| 初始化项目 | pnpm init → package.json |
uv init → pyproject.toml |
| 安装生产依赖 | pnpm add pkg |
uv add pkg |
| 安装开发依赖 | pnpm add pkg -D |
uv add --dev pkg |
| 移除依赖 | pnpm remove pkg |
uv remove pkg |
| 同步全部依赖 | pnpm install |
uv sync |
| 仅安装生产依赖(部署) | pnpm install --prod |
uv sync --no-dev |
| 运行项目命令 | pnpm xxx(需配 scripts) |
uv run xxx |
| 全局安装 CLI 工具 | pnpm add -g pkg |
uv tool install pkg |
| 全局工具列表 | pnpm list -g |
uv tool list |
| 升级全局工具 | pnpm update -g pkg |
uv tool upgrade pkg |
| 删除全局工具 | pnpm remove -g pkg |
uv tool uninstall pkg |
| 锁文件 | pnpm-lock.yaml |
uv.lock |
| 依赖存放位置 | node_modules/ |
.venv/ |
| 全局缓存 | ~/.pnpm-store |
~/.cache/uv |
特别说明 :uv 不必先手动
uv init再uv add------直接在任意项目目录执行uv add,uv 会自动初始化。.venv同样按需自动创建,在第一次add/sync/run时生成。
关键细节
1. 开发依赖 vs 生产依赖
shell
# 生产依赖:程序运行必需,部署时安装
uv add "fastapi[standard]" httpx
# 开发依赖:仅本地开发、测试、格式化,--no-dev 时不安装
uv add --dev ruff pytest mypy
| 写入位置 | pnpm | uv |
|---|---|---|
| 生产依赖 | package.json → dependencies |
pyproject.toml → [project] dependencies |
| 开发依赖 | package.json → devDependencies |
pyproject.toml → [tool.uv] dev-dependencies |
2. 带扩展特性 extras(pkg[extra1,extra2])
[] 表示安装包的同时,附带一组可选子依赖。比如 fastapi[standard] 会额外安装 uvicorn、http 等标准依赖,省去逐个添加的麻烦。书写时必须用引号包裹:
shell
uv add "fastapi[standard]"
uv add "httpx[http2,socks]"
自动写入 pyproject.toml 的效果:
toml
dependencies = [
"fastapi[standard]",
]
pnpm 没有完全对等的概念,类似效果可通过
pnpm add pkg后自行配置来实现。
3. 版本约束
shell
# 固定版本
uv add "uvicorn==0.30.0"
# 兼容版本:>=0.30 且 <0.31
uv add "uvicorn~=0.30.0"
# 最低版本
uv add "uvicorn>=0.28"
4. 全局工具(替代 pipx)
uv tool install 会为每个工具创建独立的隔离环境,全局可用但不污染项目虚拟环境。
shell
uv tool install "fastapi[standard]" # 安装
uv tool upgrade fastapi # 更新
uv tool uninstall fastapi # 删除
uv tool list # 列出全部
典型工作流
shell
# 1. 初始化项目
uv init
# 2. 添加生产依赖
uv add "fastapi[standard]" uvicorn
# 3. 添加开发依赖
uv add --dev ruff pytest
# 4. 移除不需要的依赖
uv remove httpx
uv remove --dev pytest
# 5. 同步依赖(拉取别人代码后 / 服务器部署)
uv sync # 安装全部依赖
uv sync --no-dev # 仅安装生产依赖(部署用)
# 6. 在项目虚拟环境中执行命令(无需手动 source/activate)
uv run fastapi dev main.py
# 7. 全局安装 CLI 工具(安装后可省略 uv run 前缀)
uv tool install "fastapi[standard]"
fastapi dev main.py # 全局可用
科普名词 fastAPI
一句话理解
FastAPI 是一个 Python 后端 Web 框架,用来写接口(API)。如果你写过 Node.js 的 Express,可以把它理解为"带类型校验 + 自动文档 + 异步原生支持的 Python 版 Express",但比 Express 开箱即用的东西多得多。
最小可运行代码(感受一下长什么样)
python
from fastapi import FastAPI
app = FastAPI()
@app.get("/hello")
async def hello():
return {"msg": "Hello FastAPI"}
# 终端运行:fastapi dev main.py
# 浏览器打开 http://localhost:8000/hello 就能看到 {"msg": "Hello FastAPI"}
不需要手动调 res.json(),直接 return 一个字典就行,框架自动帮你转 JSON。
vs Express 核心差异速览
| 场景 | Express | FastAPI |
|---|---|---|
| 创建应用 | const app = express() |
app = FastAPI() |
| 定义路由 | app.get('/path', fn) |
@app.get("/path") 装饰器 |
| 返回 JSON | res.json({...}) 手动调 |
return {...} 自动序列化 |
| 参数校验 | 手动解析 + 手写 if,或用 Zod/Joi | Pydantic 模型声明式校验,框架内置 |
| API 文档 | 需额外装 swagger-jsdoc 等 | /docs (Swagger) 和 /redoc 自动生成 |
| 异步支持 | 需手动 Promise/async | async def 原生 async/await |
| 依赖注入 | 无原生实现 | Depends() 是标志性能力 |
一、基础概念(写接口的第一步)
1. App 实例
python
app = FastAPI(title="我的项目")
整个项目的入口对象,管理路由、中间件、生命周期、全局配置。等价于 Express 的 express()。
2. 路由 ------ 各种姿势接收参数
python
# 路径参数:写在 URL 里
@app.get("/user/{user_id}")
async def get_user(user_id: int):
return {"user_id": user_id}
# 查询参数:跟在 ? 后面,直接声明函数参数即可
@app.get("/search")
async def search(q: str, page: int = 1):
return {"query": q, "page": page}
# POST JSON 请求体:用 Pydantic 模型接收
from pydantic import BaseModel
class CreateUserReq(BaseModel):
name: str
age: int
@app.post("/user")
async def create_user(body: CreateUserReq):
return {"name": body.name, "age": body.age}
新手大坑 :不定义 Pydantic 模型、手动
json.loads()再手写一堆if判空,完全浪费了 FastAPI 的自动校验能力。
3. 路径操作函数
被 @app.get/post 装饰的函数就是接口处理函数。优先写 async def,因为大部分后端工作都是 IO 密集型(数据库查询、调外部 API、SSE 流式推送)。
4. 自动序列化
直接 return 字典或 Pydantic 对象,FastAPI 自动转 JSON,不用像 Express 那样手动 res.json()。
5. 自动 API 文档
启动服务后直接访问:
http://localhost:8000/docs→ Swagger UI(可交互调试)http://localhost:8000/redoc→ 更美观的文档页
零配置,纯靠代码和类型注解自动生成。
二、请求与数据校验(日常最高频)
FastAPI 用 Pydantic 做数据校验,类似于 TypeScript 生态里的 Zod 或 Joi。
python
from pydantic import BaseModel, Field
class QueryReq(BaseModel):
question: str = Field(..., description="用户输入的自然语言问题")
limit: int = Field(10, ge=1, le=100, description="返回条数")
@app.post("/api/query")
async def query(body: QueryReq):
# body.question 和 body.limit 已经被自动校验过了
# 如果前端传了 limit=999,框架直接返回 422 错误,不会进入业务逻辑
return {"question": body.question, "limit": body.limit}
- Body:POST 请求的 JSON 体
- Query :URL 查询参数
?key=value - Path :路径参数
/item/{id} - Header / Cookie:按需声明即可,语法一致
三、三大进阶概念(从能用到写好)
1. 中间件 Middleware ------ 全局拦截
中间件夹在"客户端 ↔ 业务路由"之间,所有请求都会经过它。
python
from fastapi import FastAPI, Request
import time
app = FastAPI()
@app.middleware("http")
async def add_process_time(request: Request, call_next):
start = time.time()
response = await call_next(request) # 放行给下一个中间件/路由
process_time = time.time() - start
response.headers["X-Process-Time"] = str(process_time)
return response
适用场景:请求耗时统计、全局跨域 CORS、统一异常处理、注入 traceId。
2. 依赖注入 Depends ------ FastAPI 标志性能力
Express 没有原生依赖注入,而 FastAPI 把它做成了核心卖点。
python
from fastapi import Depends
# 定义一个可复用的依赖函数
async def get_current_user(token: str):
# 实际项目里查数据库或解析 JWT
return {"user_id": 1, "name": "小明"}
@app.get("/profile")
async def profile(user: dict = Depends(get_current_user)):
# 框架自动调 get_current_user,结果注入到 user
return {"user": user}
@app.get("/orders")
async def orders(user: dict = Depends(get_current_user)):
# 复用同一个校验逻辑,不需要在每个接口里写重复代码
return {"user": user, "orders": [...]}
- 按需引入 :哪个接口需要就加
Depends(xxx),不像中间件全局强制 - 支持嵌套:A 依赖 B,B 依赖 C,框架自动解析
- 适用场景:获取登录用户、获取数据库会话、权限校验
中间件 vs Depends 的区别:中间件在"最外层",所有请求必须经过;Depends 在"路由层",按接口粒度选择性地注入。
3. lifespan 生命周期 ------ 启动时加载、关闭时释放
替代老旧框架的 startup / shutdown 事件,用 Python 的 asynccontextmanager 实现:
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# 服务启动时执行(yield 之前)
print("正在加载模型...")
app.state.model = load_my_model() # 大模型只加载一次
print("模型加载完毕,开始接受请求")
yield
# 服务关闭时执行(yield 之后)
print("正在释放资源...")
app.state.model = None
app = FastAPI(lifespan=lifespan)
适用场景:加载大模型到内存、创建数据库连接池、启动时预热缓存。
四、新手学习路线(照着走,不迷路)
| 阶段 | 学什么 | 目标 |
|---|---|---|
| ① 入门 | 写简单 GET/POST 接口,搞懂路径参数、查询参数、Pydantic 模型 | 能用 5 行代码跑一个接口 |
| ② 拆分 | 学会用 APIRouter 把路由按模块拆分 |
文件长到 200 行时知道怎么切 |
| ③ 进阶 | 学习 Depends 依赖注入,封装登录校验、数据库会话 |
消除接口函数里的重复代码 |
| ④ 架构 | 学习中间件(全局拦截)、lifespan(生命周期管理) | 能搭出一个可维护的项目骨架 |
| ⑤ 实战 | 上手 StreamingResponse + SSE 流式接口 |
实现 AI 对话、进度推送等流式场景 |
| ⑥ 上线 | ContextVar 做链路追踪、Docker 打包、生产启动配置 | 从本地开发到线上部署 |
相关文档链接
科普名词 curl
一句话理解
curl 是一个命令行 HTTP 客户端,作用等价于浏览器地址栏输入 URL 回车,或者 Postman 发请求。后端开发调试接口时,curl 是最快、最轻量的工具------不需要打开任何软件,终端里一行命令就能验证接口通不通。
基本语法
css
curl [选项] <URL>
常用选项速查
| 选项 | 含义 | 记忆技巧 |
|---|---|---|
-X |
指定请求方法(GET/POST/PUT/DELETE) | X = "方法"的叉叉 |
-H |
添加请求头 | H = Header |
-d |
携带请求体数据 | d = data |
-i |
响应中包含 HTTP 头 | i = include |
-v |
显示请求和响应的全部细节 | v = verbose |
-N |
禁用缓冲,流式调试必备 | N = No buffer |
-o |
输出结果保存到文件 | o = output |
\ |
shell 换行符,方便阅读长命令 | 可写成一整行 |
一、基础 GET 请求
bash
# 最简单的 GET
curl http://127.0.0.1:8000/hello
# 带查询参数(⚠️ 地址含 & 必须用引号包裹,否则 shell 会误解)
curl "http://127.0.0.1:8000/hello?name=小明&age=18"
二、调试利器:看请求/响应细节
bash
# -i:响应中包含 HTTP 状态码和 Headers
curl -i http://127.0.0.1:8000/hello
# 输出示例:
# HTTP/1.1 200 OK
# content-type: application/json
# ...
# {"msg": "Hello FastAPI"}
# -v:连请求头、握手过程一起显示(调试神器,出问题先上 -v)
curl -v http://127.0.0.1:8000/hello
调试口诀 :接口访问不通?先
curl -v看看到底发了什么、收到了什么。
三、POST 请求(FastAPI 最常用场景)
发送 JSON 请求体
bash
curl -X POST http://127.0.0.1:8000/api/query \
-H "Content-Type: application/json" \
-d '{"question": "男女性别销售额分别是多少", "limit": 10}'
| 参数 | 作用 |
|---|---|
-H "Content-Type: application/json" |
告诉服务端"我传的是 JSON" |
-d '{"key":"value"}' |
请求体,单引号包裹避免 shell 解析 JSON 里的双引号 |
小知识:写了
-d后 curl 默认就用 POST,可省略-X POST,但建议保留以提高可读性。
发送表单数据(x-www-form-urlencoded)
bash
curl -X POST http://127.0.0.1:8000/login \
-d "username=admin&password=123456"
四、携带认证信息
bash
# Header 中带 Token(最普遍的鉴权方式)
curl http://127.0.0.1:8000/api/private \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
# 携带 Cookie
curl http://127.0.0.1:8000/api/user \
--cookie "sessionId=abc123; uid=1001"
五、流式接口调试(SSE)
bash
# -N 是关键:禁用 curl 的输出缓冲,否则流式数据会憋住不显示
curl -N -X POST http://127.0.0.1:8000/api/query \
-H "Content-Type: application/json" \
-d '{"question": "男女销售额对比"}'
不加 -N 时,curl 会把 SSE 事件攒到缓冲区,等连接结束才一次性输出------看起来就像卡住了。
六、保存响应到文件
bash
# -o:输出到指定文件
curl http://127.0.0.1:8000/hello -o result.json
# -O(大写):按 URL 中的文件名保存
curl -O http://127.0.0.1:8000/download/report.csv
新手常见踩坑
| 坑 | 原因 | 解决 |
|---|---|---|
URL 里的 & 被 shell 当后台运行符号 |
shell 先把 & 解释了,没传给 curl |
整个 URL 用引号包裹 |
| JSON body 里双引号被 shell 吃掉 | shell 解析了双引号 | 最外层用单引号包裹 -d '{...}' |
| SSE 流式接口没反应 | curl 默认有输出缓冲 | 加 -N 参数 |
| POST 请求报 422 | FastAPI 数据校验不通过 | 用 -v 看响应体里的错误详情 |
科普名词 SSE
一句话理解
SSE(Server-Sent Events,服务端推送事件)是一种让服务器"主动、持续"向浏览器推送数据的技术。想象一下:普通 HTTP 请求是你问一句服务器答一句,SSE 是你问一句,服务器一直断断续续回答你,直到事情办完。
SSE vs 其他方案:什么时候用它?
| 方案 | 方向 | 适用场景 |
|---|---|---|
| 普通 HTTP | 客户端 → 服务端 → 客户端(一问一答) | 查数据、提交表单 |
| 轮询 (Polling) | 客户端定时请求 | 不推荐,浪费请求 |
| SSE | 服务端 → 客户端(单向流) | 进度推送、AI 流式回答、实时通知 |
| WebSocket | 双向实时通信 | 聊天、游戏、协同编辑 |
本项目是用户发一个问题(POST),后端逐步完成 NLP→SQL 全流程并用 SSE 推送进度→这恰好是 SSE 最经典的场景:一次请求,持续推送。
SSE 协议格式(核心,必须看懂)
SSE 的数据格式极其简单,就一条规则:
ini
data: 一行 JSON
[空行]
每个事件必须以 data: 开头,以两个换行符 \n\n 结束。看后端 main.py 中的实际输出:
kotlin
data: {"type": "progress", "step": "抽取关键词", "status": "running"}
data: {"type": "progress", "step": "抽取关键词", "status": "success"}
data: {"type": "result", "data": [{"gender": "男", "sales_amount": 135370.5}]}
data: [DONE]
- 每两行是一个"事件块",前端逐个接收
data: [DONE]不是协议要求,是我们约定的结束信号
后端:FastAPI 怎么写 SSE 接口
核心三步:
- 写一个
async def生成器函数,用yield产出一行行data: {json}\n\n - 用
StreamingResponse包裹生成器,设置media_type="text/event-stream" - 头里加缓存控制和长连接标记
以下是你项目 main.py 中的实际代码(简化版):
python
from fastapi.responses import StreamingResponse
import json
import asyncio
async def sse_stream():
"""
异步生成器:逐条产出 SSE 事件。
每 yield 一次,框架自动把这段文本推送给前端。
"""
steps = ["抽取关键词", "召回字段", "生成 SQL", "验证 SQL", "执行 SQL"]
for step in steps:
# 推送 running 状态
yield f"data: {json.dumps({'type': 'progress', 'step': step, 'status': 'running'}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.3)
# 推送 success 状态
yield f"data: {json.dumps({'type': 'progress', 'step': step, 'status': 'success'}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.1)
# 推送最终结果
result = {"type": "result", "data": [{"gender": "男", "sales": 135370.5}]}
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
# 结束信号
yield "data: [DONE]\n\n"
@app.post("/api/query")
async def query():
return StreamingResponse(
sse_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache", # 告诉浏览器别缓存
"Connection": "keep-alive", # 长连接
},
)
关键点说明:
| 要点 | 说明 |
|---|---|
yield f"data: ...\n\n" |
产出一条完整的 SSE 事件;两个 \n 是协议要求的结束符 |
ensure_ascii=False |
确保中文不被转成 \uXXXX,浏览器里直接看到汉字 |
StreamingResponse |
FastAPI 告诉客户端"我要流式传输"的核心包装器 |
async def + await asyncio.sleep() |
模拟耗时操作;真实项目里把 sleep 换成调用 AI 模型、查数据库 |
Cache-Control: no-cache |
必须加,否则浏览器/代理可能缓冲整段再返回,流式效果就没了 |
前端:用原生 fetch 消费 SSE(项目实际代码)
不走 EventSource API(它只支持 GET),而用 fetch + ReadableStream 手动解析,这是处理 POST SSE 的标准做法。
以下是你项目 frontend/src/App.vue 中的实际代码(带注释精讲):
javascript
// 1. 用 POST 发请求,拿到 Response 对象
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q }),
});
if (!response.body) throw new Error("服务器未返回流");
// 2. 从 response.body 获取 ReadableStream 读取器
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = ""; // 缓冲区:TCP 流不是按行到达的,需要拼装
// 3. 循环读流,直到 done
while (true) {
const { value, done } = await reader.read();
if (done) break; // 流结束
// 4. 把收到的字节解码为文本,追加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 5. 按 \n\n 切分出完整的事件块
const events = buffer.split("\n\n");
buffer = events.pop(); // 最后一段可能不完整,留着下次拼
// 6. 逐个处理事件
for (const evt of events) {
const line = evt.trim();
if (!line.startsWith("data:")) continue; // 不是 data 行就跳过
// 7. 解析 JSON
const data = JSON.parse(line.replace(/^data:\s*/, ""));
// 8. 按 type 分类处理
if (data.type === "progress") {
// 更新进度条 / 步骤列表
console.log(`${data.step}: ${data.status}`);
} else if (data.type === "result") {
// 渲染最终的表格
console.table(data.data);
}
}
}
前端关键点拆解:
| 概念 | 说明 |
|---|---|
response.body.getReader() |
浏览器原生流式 API,逐块读取而不是等整个响应完成 |
TextDecoder |
把二进制字节块转成字符串 |
buffer |
必需的缓冲区!TCP 流不保证一个事件正好对应一次 read(),可能半个事件就到达了 |
split("\n\n") + pop() |
用双换行切出完整事件,最后一段不完整的留在 buffer 下次拼 |
{ stream: true } |
告诉解码器"还有更多数据要拼",避免多字节字符(如中文)被截断出错 |
数据流全景图(从前端发问到拿到结果)
arduino
用户输入 → fetch POST → 后端 sse_stream()
│
├─ yield "running" ──→ 前端更新进度条
├─ yield "success" ──→ 前端标记完成
├─ ...
├─ yield "result" ──→ 前端渲染表格
└─ yield "[DONE]" ──→ 前端知道流结束
新手常见踩坑
| 坑 | 原因 | 解决 |
|---|---|---|
| 前端收不到数据,等很久一次性全出来 | 没禁用缓存/缓冲 | 后端加 Cache-Control: no-cache,要用 Nginx 的话还要关 proxy_buffering |
中文 JSON 被转成 \uXXXX |
json.dumps 默认 ensure_ascii=True |
加 ensure_ascii=False |
后端 yield 了但前端没反应 |
CORS 没配 | 后端加 CORSMiddleware,你这个项目已经配了 |
| 前端解析 JSON 报错 | buffer 切分时把一条事件切成两半了 | 用 split("\n\n") + pop() 缓冲区模式 |
| 多字节字符(中文)乱码/截断 | TextDecoder.decode 没传 { stream: true } |
加上 { stream: true } |
EventSource API 不能用 POST |
EventSource 只支持 GET |
用 fetch + ReadableStream 手动解析 |
| 流断了不续传 | SSE 依赖长连接,代理/防火墙可能超时断掉 | 后端定时发心跳(如 data: [PING]\n\n) |