FastAPI 这几年在 Python 后端圈子里火得不是没道理,尤其是"带参路由"这件事,几乎是把 Flask 时代需要自己手动处理的一大堆脏活全包了。我刚从 Flask 迁到 FastAPI 的那阵子,最大的感受就是:路由还是那个路由,但参数从哪来、怎么校验、文档怎么生成,全都不一样了。这篇就把我带参路由从入门到实战踩过的坑、用顺手的写法、以及项目里真实跑过的代码一次性整理出来,给正在学 FastAPI 或者打算从 Flask 迁过来的朋友一个参考。
1. 从一个常见需求说起:为什么带参路由是 FastAPI 的招牌能力
1.1 先搞清楚"带参路由"到底包含哪几类参数
很多人一听到"带参路由"就以为是 URL 里带个/{id}那种。其实在 FastAPI 里,参数的类型远不止路径参数一种。我通常把它们分成四类:
- 路径参数:写在 URL 路径里的,比如
/users/123,123就是路径参数。 - 查询参数:URL 问号后面的键值对,比如
/users?age=18&city=shanghai,age和city是查询参数。 - 请求体参数:POST/PUT 请求里 JSON body 中的数据,在 FastAPI 中通常用 Pydantic 模型来声明。
- Header / Cookie 参数:从请求头或 Cookie 里取参数,适合放 token、trace_id 这类信息。
这四类参数在 FastAPI 里都有对应的声明方式,且全部走类型提示。这是它和 Flask 最本质的区别。Flask 的request.args.get("xxx")取到的是字符串,你得自己int()转换、自己判断有没有传、自己返回 400。而 FastAPI 直接在函数签名上写类型,校验、转换、报错提示全是框架代劳。这个体验一旦习惯了就回不去。
1.2 同是传参,Flask 与 FastAPI 的体验差距在哪
我最早用 Flask 写接口时,一个常见接口大概长这样:
from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/items/<int:item_id>", methods=["GET"]) def get_item(item_id): q = request.args.get("q", default="", type=str) page = request.args.get("page", default=1, type=int) # 接下来还要自己判断 item_id 是否存在、q 合不合法... return jsonify({"item_id": item_id, "q": q, "page": page})注意几个问题:<int:item_id>这个转换器只处理路径参数,查询参数page的类型转换要靠type=int显式声明;如果传了page=abc,Flask 会静默转成默认值 1,而不是报错。这在生产环境里非常危险——你以为前端传了page=abc是小事,实际它会掩盖掉上游的传参 bug。
换成 FastAPI 是这么写的:
from fastapi import FastAPI app = FastAPI() @app.get("/items/{item_id}") async def get_item(item_id: int, q: str = "", page: int = 1): return {"item_id": item_id, "q": q, "page": page}同样一个接口,区别在于:
item_id声明为int,FastAPI 自动做路径参数的类型校验与转换。page声明为int且有默认值,所以它是可选的;如果传了page=abc,FastAPI 会直接返回 422 校验错误,而不是静默吞掉。- 所有参数在自动生成的
/docs文档里都有说明,前端联调时直接看文档即可,不用追着后端问。
这个"类型即校验"的设计,让带参路由从"手动处理参数"变成了"声明参数意图"。代码量少了,出错率低了,接口文档还白送。
1.3 什么时候值得为快而快:FastAPI 适用场景
FastAPI 不是银弹。我自己的选型经验是这几类场景果断用 FastAPI:
- 前后端分离项目:接口多、参数复杂、需要自动文档。
- 机器学习模型服务:输入输出结构固定,Pydantic 校验能挡住大量脏数据。
- 需要异步能力的服务:FastAPI 原生支持
async,配合 httpx 做上游调用很顺手。
但如果只是一个内部小工具、渲染服务端页面为主的网站,Flask 的生态和上手成本仍然有优势。不是说 FastAPI 不行,而是没必要为了"酷"而引入更高的学习成本。
2. 参数类型拆解:路径参数、查询参数、请求体分别怎么写
2.1 路径参数:/{item_id}里的类型校验
路径参数是带参路由里最基础的一种。声明方式就是在路径字符串里用花括号占位,然后在函数签名里以同名参数接收:
from fastapi import FastAPI app = FastAPI() @app.get("/users/{user_id}") async def get_user(user_id: int): return {"user_id": user_id, "message": f"user {user_id} found"}这里有个细节:如果请求/users/abc,FastAPI 不会把请求打到这个函数里,而是直接返回 422,错误信息会说明user_id无法转换为int。如果请求/users/123,FastAPI 会把字符串"123"转成 Python 的int类型再传给函数。所以函数里直接用user_id做数值运算就很安全,不用再自己防御了。
路径参数本身必须要有值,不能设置默认值。/users/{user_id}不可能在缺省时访问/users/,除非你另外再定义一个不带头参的路由。这跟查询参数完全不同,新手经常搞混。
路径参数还有两个进阶用法值得提一下。第一个是路径转换器,比如{file_path:path}可以匹配包含斜杠的路径:
@app.get("/files/{file_path:path}") async def read_file(file_path: str): return {"path": file_path}这样/files/2024/01/report.pdf这种多级路径也能被完整捕获。第二个是用Path类给路径参数加约束:
from fastapi import Path @app.get("/items/{item_id}") async def read_item(item_id: int = Path(ge=1, le=10000)): return {"item_id": item_id}ge=1, le=10000表示 item_id 必须在 1 到 10000 之间,超出范围同样返回 422。这种参数约束过去要写在函数体里,现在一行声明搞定。
2.2 查询参数:默认值决定必填还是可选
查询参数就是 URL 问号后面的键值对。在 FastAPI 里,函数签名中不是路径参数的那些简单类型参数,默认都会被当成查询参数:
@app.get("/items/") async def list_items(skip: int = 0, limit: int = 10, keyword: str | None = None): return {"skip": skip, "limit": limit, "keyword": keyword}这里的关键规则:
- 有默认值的查询参数是可选的,比如
skip=0、limit=10。 - 没有默认值的查询参数是必填的,比如下面这样:
@app.get("/items/") async def list_items(q: str): return {"q": q}如果请求/items/而不带q参数,FastAPI 直接返回 422,提示q是必需的。
默认值不仅决定可选性,还可以承载业务默认逻辑。比如分页接口,limit默认 10,前端不传也能跑通;但如果你希望某些参数强制前端显式传,就不要给默认值。
正则校验查询参数可以用Query类:
from fastapi import Query @app.get("/items/") async def list_items( q: str | None = Query(default=None, max_length=50, pattern="^[a-zA-Z0-9_]+$"), ): return {"q": q}这种写法在做搜索接口时特别实用——防止用户传一些奇奇怪怪的字符进去,后端不用再写一堆 if 判断。
2.3 请求体参数:Pydantic 模型才是重头戏
真正体现 FastAPI 带参路由优势的,是请求体参数。用 Pydantic 模型声明一个数据结构,FastAPI 会自动完成 JSON 解析、类型校验、嵌套校验:
from pydantic import BaseModel, Field class Item(BaseModel): name: str price: float = Field(gt=0) tags: list[str] = [] @app.post("/items/") async def create_item(item: Item): return {"message": f"item {item.name} created", "price": item.price, "tags": item.tags}请求体传{"name": "鼠标", "price": 99.9},函数收到的item就是一个已经校验过的Item实例,直接item.name、item.price取字段。传{"price": -5}的话,FastAPI 会报price必须大于 0。传一个缺少name的 body,FastAPI 会明确指出来。这种能力在 Flask 里要靠手动解析request.json再加一层序列化库才能实现,FastAPI 第一方就把它做完了。
Pydantic 模型还支持嵌套、枚举、自定义校验器。比如一个订单接口,订单里包含多个商品项:
from pydantic import BaseModel class OrderItem(BaseModel): sku: str quantity: int = Field(ge=1) class Order(BaseModel): order_no: str items: list[OrderItem]前端传{"order_no": "SO001", "items": [{"sku": "A-1", "quantity": 2}]},FastAPI 会一层层校验下去,items里每个元素都会校验quantity >= 1。层级再多也不怕,嵌套校验是自动递归的。
2.4 参数混用与顺序规则:FastAPI 怎么判断参数类型
当路径参数、查询参数、请求体参数混合在同一个接口时,很多人会懵:FastAPI 怎么知道哪个参数对应哪种类型?规则其实很简单——它根据参数的类型和声明位置来判断:
- 参数名和路径中的
{占位符}一致 → 路径参数。 - 参数类型是 Pydantic 模型(继承
BaseModel)→ 请求体参数。 - 其他简单类型(
int、str、bool、list等)→ 查询参数。 - 使用
Query、Path、Header、Body等显式声明 → 按声明走。
看一个综合例子:
from fastapi import Header @app.put("/orders/{order_id}") async def update_order( order_id: int, q: str | None = None, order: Order = None, x_token: str = Header(default=""), ): ...order_id对应路径参数,q是查询参数,order是请求体,x_token从 Header 里取。这些参数在函数签名里没有先后顺序限制,FastAPI 的类型推导完全能处理。唯一要注意的是 Python 语法本身:带默认值的参数要在不带默认值的参数之后。比如order: Order = None这种写法,Python 3.10+ 建议写order: Order | None = None,否则类型检查器会提示问题。
新手最容易迷路的点在于:如果一个 Pydantic 模型参数给了默认值None,FastAPI 就把它变成可选的请求体了。这在接口设计上其实是好事——比如"部分更新"接口,前端只传要改的字段,后端用model_dump(exclude_unset=True)只取传进来的字段做更新。
3. 从零开始搭一个带参路由的 FastAPI 项目骨架
3.1 安装和启动最小步骤
带参路由本身不复杂,但项目一跑起来,环境和启动方式就有讲究了。先看最小安装:
pip install fastapi uvicorn[standard]fastapi是框架本体,uvicorn是 ASGI 服务器。uvicorn[standard]会多装一些依赖,包括uvloop、httptools、websockets等,性能和 WebSocket 支持都会更好。如果只装裸uvicorn,长连接场景下性能会差一些。
启动命令:
uvicorn main:app --reload --port 8000main:app表示从main.py文件导入app实例。--reload开启热重载,改代码自动重启,开发必备。注意--reload只适合开发环境,生产环境要关掉,否则会白白增加资源消耗。
3.2 项目目录怎么分才不后悔
我见过不少 FastAPI 项目把所有路由写在一个main.py里,几百行下来直接没法维护。带参路由这种场景,参数校验、业务逻辑、外部服务调用得分开,否则改一个参数就要在文件里翻半天。
这是我目前在用的一个参考结构:
fastapi_project/ ├── app/ │ ├── main.py # FastAPI 实例、路由注册、全局异常处理 │ ├── core/ │ │ ├── config.py # 配置项(环境变量、常量) │ │ └── logger.py # 日志配置 │ ├── routers/ │ │ ├── items.py # 按业务域拆分路由 │ │ ├── users.py │ │ └── ollama.py # 调用外部模型服务的路由 │ ├── schemas/ │ │ ├── item.py # Pydantic 请求/响应模型 │ │ └── user.py │ ├── services/ │ │ ├── item_service.py # 业务逻辑层 │ │ └── llm_service.py # 封装外部 API 调用 │ └── models/ # ORM 模型(如果用 SQLAlchemy) ├── tests/ │ ├── test_items.py │ └── test_users.py ├── requirements.txt └── README.md关键思路是路由只做参数接收和响应输出,业务逻辑下沉到 services。这样带参路由的校验逻辑集中在 schemas,业务规则在 services,路由文件保持简洁。项目大了以后,多人协作不会互相踩到对方的代码。
3.3 一个完整的带参路由示例代码
下面这个例子把路径参数、查询参数、请求体参数、Header 参数全用上了,同时覆盖了增删改查的常见形态:
# app/routers/items.py from fastapi import APIRouter, HTTPException, Header, Query from pydantic import BaseModel, Field router = APIRouter(prefix="/items", tags=["items"]) class ItemCreate(BaseModel): name: str = Field(min_length=1, max_length=50) price: float = Field(gt=0) tags: list[str] = [] class ItemUpdate(BaseModel): name: str | None = None price: float | None = Field(default=None, gt=0) tags: list[str] | None = None @router.get("/") async def list_items( keyword: str | None = Query(default=None, max_length=50), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), ): # 实际项目里这里调 service 层 return {"keyword": keyword, "page": page, "page_size": page_size} @router.get("/{item_id}") async def get_item(item_id: int, x_trace_id: str | None = Header(default=None)): if item_id <= 0: raise HTTPException(status_code=400, detail="item_id must be positive") return {"item_id": item_id, "trace_id": x_trace_id} @router.post("/") async def create_item(item: ItemCreate): return {"created": item.model_dump()} @router.patch("/{item_id}") async def patch_item(item_id: int, item: ItemUpdate): updates = item.model_dump(exclude_unset=True) return {"item_id": item_id, "updates": updates}最后在main.py里注册:
# app/main.py from fastapi import FastAPI from app.routers import items app = FastAPI(title="FastAPI Demo") app.include_router(items.router)这里有个细节值得多说一句:APIRouter(prefix="/items")让子路由统一挂/items前缀。这样get_item的路径只要写/{item_id}就行,最终访问的 URL 是/items/{item_id}。等路由文件多起来,这个前缀机制能省很多事,改前缀只动一处。
3.4 自动文档 /docs 怎么看,怎么利用
FastAPI 自带的自动文档是它的杀手锏。启动项目后访问http://localhost:8000/docs,你会看到所有带参路由的参数说明、请求体示例、返回结构,而且可以直接在页面上"Try it out"发请求测试。
我带团队时有个习惯:接口写完后,先看 /docs 里参数和校验描述是否和产品需求一致。因为 /docs 完全由代码里的类型注释、Field 描述生成,如果文档里显示的约束不对,说明代码声明本身就有问题。比如字段的最大长度没写、必填参数没体现,不等联调就会发现。
/docs还能导出 OpenAPI 的 JSON——很多团队用它生成前端 TypeScript 类型、Mock 服务,甚至接口测试用例。前端的接口层代码可以直接由 OpenAPI 生成,省掉大量手写重复劳动。
4. 运行与调试中踩过的坑
4.1 uvicorn 日志丢失问题的排错链路
说到 uvicorn,就不得不提一个几乎每个人都会遇到的现象:改了代码以后,终端上不打印访问日志,或者启动时日志信息特别少。很多人第一反应是"日志丢了",其实往往是日志配置和运行参数的问题。
我的排查链路是这样的,按顺序检查:
确认 uvicorn 启动参数:
--log-level info是否设置。如果用了--log-level warning,访问日志自然不会出现,因为访问日志的级别是 info。推荐开发时用uvicorn main:app --reload --log-level info --access-log。确认是否被自定义 logging 配置覆盖:FastAPI 项目里如果自己
logging.basicConfig或dictConfig配置了 root logger,可能会把 uvicorn 的 logger 也接管,导致格式变化甚至静默。这种情况我建议在core/logger.py里显式保留 uvicorn 的 access logger:
import logging logging.basicConfig(level=logging.INFO) # 确保 uvicorn 的访问日志不会被关掉 uvicorn_access = logging.getLogger("uvicorn.access") uvicorn_access.setLevel(logging.INFO)如果日志完全没有输出:检查是否在
main.py顶层被某些库调用了logging.disable(logging.CRITICAL)。这种事我遇到过一次,某个第三方库为了"安静"把全局日志禁了,排查了很久。Windows 下 Ctrl+C 崩溃或无日志:这是 uvicorn 在 Windows 的已知问题,往往和 signal handler 有关。一个实用的方案是改用
--workers 1且不带--reload,跑完测试就停。如果做桌面应用嵌入,直接用uvicorn.run(app, log_config=None)配合自定义日志会更可控。
4.2 类型校验报错读不到点上?那是 Pydantic 的提示没看习惯
FastAPI 返回 422 错误时,响应体长这样:
{ "detail": [ { "type": "missing", "loc": ["body", "name"], "msg": "Field required", "input": {"price": 9.9} }, { "type": "greater_than", "loc": ["body", "price"], "msg": "Input should be greater than 0", "input": -5 } ] }新手觉得这个报错难读,其实恰恰相反,loc明确指向了参数位置(body 下的 name、body 下的 price),msg说明了规则。你要做的第一件事就是看loc和type。loc告诉你"哪里错了",type告诉你"错在哪类规则"。项目里如果前端调接口返回 422,直接把这两个字段发给后端就够了,比截图大字报强多了。
还有一种常见情况:请求体的某个嵌套字段错了,loc会显示嵌套路径,比如["body", "items", 0, "quantity"],意思是 items 列表的第 0 个元素里的 quantity 字段有问题。定位非常精准。
4.3 路由顺序冲突:/users/me与/users/{user_id}的故事
路径参数和静态路径撞车是带参路由的老大难问题。看这段代码:
@app.get("/users/me") async def get_me(): return {"user": "me"} @app.get("/users/{user_id}") async def get_user(user_id: int): return {"user_id": user_id}如果把/users/{user_id}写在前面,那么请求/users/me时,FastAPI 会先尝试匹配{user_id},由于me不是合法的 int,会返回 422,而不会自动跳到/users/me。规则是按路由声明顺序匹配,先匹配到谁就用谁。
所以,静态路径要放在动态路径之前声明。另外注意一个细节:如果user_id声明为str,那/users/me就会被{user_id}吃掉,/users/me那层永远匹配不到。这也是为什么我建议路径参数尽量用int或带约束的类型——它能把"类型不合规"的请求挡在外面,让静态路径有机会被命中。
4.4 Windows 打包 FastAPI 的注意事项
FastAPI 项目在 Windows 下用 PyInstaller 打包成 exe,是个高频需求,尤其是做本地工具、桌面端内嵌服务时。我踩过的坑主要是这两个:
第一个坑是 PyInstaller 默认收集不到 uvicorn 的子模块。uvicorn 内部通过动态导入加载 logger、protocols 等组件,直接pyinstaller main.py打包出来的 exe 启动时会报模块找不到。解决方案是在 spec 文件里加上:
hiddenimports=[ "uvicorn.logging", "uvicorn.loops", "uvicorn.loops.auto", "uvicorn.protocols", "uvicorn.protocols.http", "uvicorn.protocols.http.auto", "uvicorn.protocols.websockets", "uvicorn.protocols.websockets.auto", "uvicorn.lifespan", "uvicorn.lifespan.on", ]第二个坑是打包后的 exe 路径问题。如果项目里用相对路径读配置文件,打包后当前工作目录可能和你预期的不一样。稳妥做法是通过sys.executable或/path/to/your/exe所在的目录来定位资源文件。
4.5 Pydantic v1/v2 迁移的坑
Pydantic 升级是 FastAPI 项目里一个隐蔽的雷。FastAPI 从某个版本开始默认使用 Pydantic v2,v2 的 API 和 v1 有不兼容的地方:
- v1 的
.dict()在 v2 里改成了.model_dump()。 - v1 的
class Config: orm_mode = True在 v2 里变成了model_config = ConfigDict(from_attributes=True)。 - v2 的校验错误信息格式变化很大,字段名从
_schema变成type。
如果你 clone 老项目,第一件事就是确认pydantic的版本。我遇到过直接把老代码跑在 v2 环境,结果validate和parse_obj全报错的情况。最快的检查方法:
pip show pydantic看到Version: 2.x.x就按 v2 的写法来,别硬套网上 v1 的旧教程。
5. 实战扩展:在带参路由中接通 Ollama 本地模型
5.1 设计思路:路由只做参数校验,业务逻辑下沉
既然热词里有"fastapi调用ollama",我就把这个真实场景展开讲讲。很多人拿到 FastAPI 后第一件事就是把模型调用直接糊进路由函数里,几十行代码堆在async def里面。这样不是不行,但项目一复杂就难受了。我的做法是:路由层只负责接收参数和返回响应,模型调用放 service 层。
这样的好处有三个:
- 带参路由的校验逻辑和模型调用的实现可以独立修改。
- 测试时可以只测 service 层,不需要起 HTTP 服务。
- 后续换模型服务(比如从 Ollama 换成其他推理服务)时,只改 service,路由不动。
5.2 写一个/api/generate带参接口
先定义一个请求模型:
# app/schemas/llm.py from pydantic import BaseModel, Field class GenerateRequest(BaseModel): model: str = Field(default="llama3", examples=["llama3", "qwen2.5"]) prompt: str = Field(min_length=1, max_length=4096) temperature: float = Field(default=0.7, ge=0.0, le=2.0) stream: bool = False然后一个路由接收它:
# app/routers/ollama.py import httpx from fastapi import APIRouter, HTTPException from app.schemas.llm import GenerateRequest router = APIRouter(prefix="/api", tags=["ollama"]) OLLAMA_BASE_URL = "http://localhost:11434" @router.post("/generate") async def generate(payload: GenerateRequest): if payload.stream: return await _stream_generate(payload) async with httpx.AsyncClient(timeout=120) as client: resp = await client.post( f"{OLLAMA_BASE_URL}/api/generate", json=payload.model_dump(exclude={"stream"}), ) if resp.status_code != 200: raise HTTPException(status_code=502, detail=f"ollama error: {resp.text}") return resp.json()这个接口的带参路由价值在哪?在于前端只需要关心model、prompt、temperature、stream四个参数,非法输入在进路由时就被拦住了。prompt超过 4096 个字符返回 422,temperature传 3.0 返回 422。你不用在自己的代码里写if len(prompt) > 4096这类判断。
5.3 流式响应的实现细节
Ollama 的/api/generate支持流式返回,每行一个 JSON。FastAPI 可以用StreamingResponse把它包装成 SSE 或 NDJSON 流:
from fastapi.responses import StreamingResponse import json async def _stream_generate(payload: GenerateRequest): async def event_stream(): async with httpx.AsyncClient(timeout=None) as client: data = payload.model_dump(exclude={"stream"}) async with client.stream("POST", f"{OLLAMA_BASE_URL}/api/generate", json=data) as resp: if resp.status_code != 200: yield f"data: {json.dumps({'error': resp.text})}\n\n" return async for line in resp.aiter_lines(): if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue yield f"data: {json.dumps(obj)}\n\n" return StreamingResponse( event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, )这里有个很容易忽略的细节:X-Accel-Buffering: no这个响应头。如果你在 nginx 反向代理后面跑 FastAPI,nginx 默认会缓冲 SSE 流,导致前端迟迟收不到第一帧,体验极差。加上这个头就是告诉代理别缓冲。这个坑我第一次对接时找了好几个小时,最后发现是缓冲问题。
另一个细节是timeout=None。流式请求耗时可能很长,默认的 httpx 超时(5 秒)会中途断掉。生成场景下必须把超时关掉,或者设置为一个足够大的值。
5.4 性能与异常处理
FastAPI 接 Ollama 这类本地模型服务,性能瓶颈通常不在 FastAPI 本身,而在模型推理。设计带参路由时,有几个点值得提前想好:
- 超时控制:给非流式接口设置合理的上游超时(比如 120 秒),防止模型卡死导致整个请求挂在那。
- 任务队列:如果模型推理时间长、并发一多就排队,建议引入任务队列把接口改成异步提交-轮询结果模式,而不是所有请求都同步等模型算完。
- 错误透传:Ollama 返回的错误信息要原样传给前端,不要自己写"model error"这种笼统的话。前端要根据具体错误做提示,比如模型不存在、显存不足等等。
我实际跑下来,FastAPI 这一层本身的延迟在毫秒级,整个耗时基本都在 Ollama 的推理上。所以带参路由设计得再花哨,都不如把超时和错误处理做好来得实在。
最后分享一个小技巧
带参路由调得再熟,也会遇到"参数声明和实际业务校验不一致"的时候。我的习惯是先用 Pydantic 的 Field 把所有硬约束(长度、范围、必填)写在模型里,再在 service 层写业务校验(比如该用户是否存在、该商品是否可售),两层校验各司其职,互不混淆。这样 422 处理的是格式问题,业务错误走自定义异常,前端判断起来非常清晰。
FastAPI 的带参路由确实把开发体验提升了一大截,把参数校验从"手写 if"变成了"声明类型"。如果你正从 Flask 迁过来,建议把路由声明和 Pydantic 模型当重点来学,这两个点吃透了,项目结构怎么搭都会有底。