离开 Spring Boot 的第三个月,我终于不再下意识地把 Python 项目当成“没有注解的 Java”来写了。如果你也是做后端出身,现在想往大模型应用方向走,那 FastAPI 和 Pydantic 大概率是你绕不开的第一站——它们一个是 API 层的骨架,一个是数据层的安全网,组合起来就是大模型应用最常用的“对话框后端”形态。
这几个月我接触到的国内大模型应用团队,技术栈高度雷同:模型调用走 OpenAI 兼容 SDK,业务接口用 FastAPI 暴露,所有进出 LLM 的数据结构全交给 Pydantic 把关。Java 转过来的同学,基本上都有 Spring MVC + MyBatis 的底子,看 FastAPI 的第一眼会觉得“这不就是个 Controller 嘛”,但真动手写才发现,两者差距远不止语法层面:Pydantic 的运行时校验、响应模型推导、以及和大模型结构化输出的深度绑定,都是之前接触不到的东西。
这篇文章我不打算给你讲“FastAPI 入门”,而是站在一个 Java 后端的视角,把它和 Spring 生态做映射,拆清楚 FastAPI 的核心定义机制和 Pydantic 的设计思路,再落到一个真实的大模型聊天接口上,看看这些概念怎么串起来用。适合那些刚转过来,Spring 思维还没掰正的人。
1. 先想明白:Java 转大模型应用,为什么要选 FastAPI 而不是继续用 Spring
1.1 大模型应用后端的真实工作是什么
大模型应用后端和传统 CRUD 后端有个显著差别:你的核心逻辑不是从数据库读数据、算业务、再写回去,而是组织 Prompt、调模型 API、解析模型输出。也就是说,真正的“业务代码”是围绕着模型接口做编排,数据库反而退到了次要位置。
这样的形态意味着几个硬性要求:第一,接口要能快速响应模型调用的异步结果,Python 的 asyncio 配合异步 HTTP 客户端处理流式输出非常顺手;第二,进出模型的数据结构经常变,Prompt 加个字段、输出格式调一下,后端不能每次改完还要重新编译部署;第三,团队里 Python 和算法侧的同事需要能直接看懂后端代码,而不是维护一套跨语言约定。
FastAPI 恰好都满足。它的每个接口就是一个 Python 函数,天然能处理 async 调用,配合 Pydantic 做模型输出解析一气呵成。而 Spring 那套体系,你让搞算法的同事去理解@RequestMapping+@RequestBody+ Jackson 注解,学习成本高得多了。
1.2 Java 开发者的三个思维惯性,到了 Python 这边会翻车
先说第一个:思维惯性之一,把“框架约束”当成理所当然。在 Spring 里,Controller、Service、Repository 分层是约定俗成,很多人不假思索就套到 FastAPI 项目上,结果把简单的东西写复杂了。FastAPI 的哲学是“按需组合”,一个脚本文件里写路由、写依赖、写数据模型,都没问题,关键是逻辑清晰。你可以在一个模块里定义所有接口,另起一个模块处理业务,中间用小函数组合,没必要为了分层而分层。
第二个:思维惯性之二,用编译期思维理解类型。Java 里String、Integer在编译期就锁死了,写错了 IDE 直接报错。Python 是运行时执行,类型注解默认只是“注释”,不校验。这正是 Pydantic 存在的意义——它把类型校验挪到运行时,请求进来的时候,如果你的数据不合法,它会当场拒绝并给出明确的错误信息。我见过不少 Java 同事第一周写的代码,def chat(request: ChatRequest)根本没校验,前端传了个畸形 JSON,后面每行代码都崩一下。原因就是他们默认为“类型标注了就会校验”,这在 Java 里没错,在 Python 里必须靠 Pydantic 兜底。
第三个:思维惯性之三,依赖注入认死理。Spring 的@Autowired是容器帮你装配好,你只管用。FastAPI 的Depends()看着像依赖注入,但本质上是「函数组合」——你自己定义一个小函数,FastAPI 在调用路由函数之前先调用它,把结果塞进来。它更像管道,不是 IoC 容器。理解了这个区别,你才不会被“怎么这里有能注入,那里又不能注入”搞糊涂。
1.3 先立个小目标:用最小可运行项目过渡
想快速过渡,我建议你先别碰任何业务,只做一件事:用 FastAPI 写一个POST /echo接口,接收一个 JSON 请求体,里面带name和age,你返回Hello {name}, age {age}。用 Spring 思维试试,再对比 FastAPI 的写法,你会直观感觉到差异——FastAPI 的请求参数在函数签名里面直接声明模型类,没有@RequestBody注解,没有 DTO 转换层,整个链路就是“类型声明即校验”。
2. FastAPI 核心定义:路由、参数与依赖注入,别按 Controller 理解
2.1 路由的写法看起来像装饰器,但语义完全不一样
Spring 里一个接口长这样:
@RestController public class ChatController { @PostMapping("/chat") public ChatResponse chat(@Valid @RequestBody ChatRequest req) { return chatService.chat(req); } }FastAPI 里长这样:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class ChatRequest(BaseModel): message: str class ChatResponse(BaseModel): reply: str @app.post("/chat") async def chat(req: ChatRequest) -> ChatResponse: return ChatResponse(reply=f"你说的是: {req.message}")Java 的@PostMapping是“声明这个方法是接口”,方法参数靠@RequestBody一个个标注。FastAPI 则把整个函数当作接口定义,函数签名即协议:req: ChatRequest表示「请求体是 ChatRequest 类型且做校验」,-> ChatResponse表示「返回值是这个结构(配合后面要说的 response_model 使用)」。
这里最容易被忽略的是:FastAPI 会根据函数签名自动生成 OpenAPI 文档,你启动服务后访问http://127.0.0.1:8000/docs,就能看到接口的 JSON Schema、字段说明、示例请求,还能直接点“Try it out”调试。Spring 想达到同样效果,得上 springdoc-openapi 然后配一堆注解,配置成本不是一个量级。对大模型应用来说,这个自动文档太重要了——你经常要调 Prompt 结构和响应结构,每次改完直接看文档,比反复问前端同学“你请求结构长什么样”高效得多。
2.2 参数绑定:别再分 Query 和 Path 了,全在函数签名里
Spring 把参数来源用注解区分:@PathVariable拿路径参数,@RequestParam拿查询参数,@RequestBody拿请求体。FastAPI 全部靠类型和默认值判断:
- 有默认值,或者直接用
Query(...)/Header(...)/Cookie(...)标注的,是查询参数或请求头; - 没有默认值且声明了类型,同时和路径里的
{参数名}匹配的,是路径参数; - 是一个 Pydantic 模型类,就是整个请求体。
from fastapi import Query, Path, Header @app.get("/users/{user_id}") async def get_user( user_id: int = Path(..., ge=1), verbose: bool = Query(False), x_token: str = Header(...), ): return {"user_id": user_id, "verbose": verbose, "token": x_token}Path(..., ge=1)里的...表示「必填」,ge=1表示大于等于 1,参数校验直接写在函数签名上。这种写法的好处是接口文档里能看到每个参数的约束,调用方一目了然。Java 那边要么用@Validated+ 校验注解,要么自己 if 判断,两条路径都烦。
2.3 依赖注入:FastAPI 的Depends是管道,不是容器
Spring 的依赖注入,本质是 IoC 容器管理 Bean 的生命周期。FastAPI 的Depends简单得多:你定义一个可调用对象(函数或类),FastAPI 在执行路由函数前调用它,拿返回值作为参数。作用其实是「帮你把公共逻辑抽出来」。
大模型应用里最常见的场景是鉴权:每个接口都要校验 API Key,你不想每个接口都写一遍校验逻辑。用 Depends 抽一个函数:
from fastapi import Depends, HTTPException, Header async def verify_token(x_api_key: str = Header(...)): if not valid_key(x_api_key): raise HTTPException(status_code=401, detail="invalid api key") return {"user_id": extract_user(x_api_key)} @app.post("/chat") async def chat(req: ChatRequest, user=Depends(verify_token)): # 这里的 user 是 verify_token 的返回值 ...对比 Spring:@Autowired是“容器帮我拿一个 Bean”,Depends是“我显式调用一个函数拿结果”。后者更透明,你能直接看到依赖关系,不用去翻配置。而且Depends里可以嵌套Depends,比如verify_token内部再用Depends(get_db),组合起来极其灵活,这比 Java 的 AOP 切面轻量多了。
2.4 async 接口:和大模型 API 打交道的基本姿势
大模型接口的响应时间动辄几秒到几十秒,后端如果同步阻塞,一个并发请求就把线程占满了。Spring 处理这种场景要么用 WebFlux,要么丢线程池,心智负担很大。FastAPI 支持async def,底层是 asyncio 事件循环,一个进程能扛大量并发请求。
但要注意一个 Python 新人必踩的坑:如果函数内部用了同步的 HTTP 库(比如 requests),那么即使写成async def,事件循环也会被阻塞。调用大模型 API 请务必用异步客户端,比如httpx.AsyncClient或者对应 SDK 的异步模式。
import httpx @app.post("/chat") async def chat(req: ChatRequest): async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( "https://api.example.com/v1/chat/completions", json={"messages": req.messages}, ) data = resp.json() return ChatResponse(reply=data["choices"][0]["message"]["content"])这里把client.post前面加await,是真正的异步调用——在等待模型返回的几十秒里,事件循环可以处理其他请求。Java 转过来的人最容易忘了await,导致明明用了异步客户端,结果还是同步阻塞。每次写完接口,盯一眼有没有漏 await,这个习惯能救你很多次。
3. Pydantic 模块:从 Lombok 的@Data到运行时数据契约
3.1BaseModel不是 DTO,是一套完整的序列化/校验层
Java 项目里写 DTO 类,一般这么干:
@Data public class ChatRequest { @NotBlank private String message; @Min(0) @Max(2) private Double temperature; }Lombok 帮你生成 getter/setter,Jackson 负责序列化,validation 注解负责校验。这三件事,在 Python 的 Pydantic 里被一个BaseModel全包了:
from pydantic import BaseModel, Field class ChatRequest(BaseModel): message: str = Field(..., min_length=1) temperature: float = Field(0.7, ge=0.0, le=2.0)- 声明即定义:
message: str声明字段类型,Field(...)声明必填,min_length=1约束长度; - 序列化自动完成:外面传 JSON,FastAPI 自动解析成
ChatRequest实例;你返回ChatResponse,自动序列化成 JSON; - 校验自动完成:如果
temperature传了 3.5,请求直接返回 422 错误,并附上具体字段名和错误原因。
Java 的 DTO 只是数据的“载体”,校验是另外接上去的。而 Pydantic 的模型本身就是数据规则,你在任何地方创建它,它就会校验。这种体验很不一样——Java 里数据类和校验是两套系统,Pydantic 把它们焊死在了一起。
3.2Field的常用约束与大模型场景的对应关系
Field提供了一大堆约束参数,很多是专门为大模型应用准备的关键利器:
Field参数 | 作用 | 大模型场景示例 |
|---|---|---|
min_length/max_length | 限制字符串长度 | 限制 Prompt 的 token 数 |
pattern | 正则匹配 | 限定角色只有system/user/assistant |
ge/le/gt/lt | 数字范围 | 限制temperature在 0~2 之间 |
max_items/min_items | 列表长度 | 限制 messages 数组最多传给模型多少条 |
default/default_factory | 默认值 | 未传时的兜底配置 |
alias | 字段别名 | 模型返回中finish_reason映射成finish_reason又偏长,用别名收敛 |
比如定义一个带正则的模型:
class ChatMessage(BaseModel): role: str = Field(..., pattern="^(system|user|assistant)$") content: str = Field(..., min_length=1)role字段如果传入"systemx",Pydantic 会抛一个“字符串不匹配正则”的错误。你可以想象一下,如果不在这里拦,这个畸形 role 传给大模型,可能直接导致模型行为异常,而你把一个 call 请求发出去才发现问题,时间和钱都损失了。
3.3 嵌套模型与容器类型:大模型请求结构的骨架
真实的大模型请求不会只有一个字符串,而是结构化的消息列表。Pydantic 天然支持模型嵌套:
class ChatMessage(BaseModel): role: str content: str class ChatRequest(BaseModel): model: str = "gpt-4o-mini" messages: list[ChatMessage] temperature: float = 0.7 max_tokens: int = Field(512, ge=1, le=4096)这里的list[ChatMessage]意思是:messages必须是数组,且数组里每个元素都是ChatMessage实例。你传一个{"role": "user"}(缺 content),Pydantic 会明确指出第二项缺字段。这种嵌套校验,Java 里要写一堆@Valid+ 递归校验,Pydantic 一个类型标注解决。
Python 3.10+ 的list[ChatMessage]、dict[str, int]这套新语法,本质上和 Java 的泛型有点相似,但 Pydantic 是运行时生效的,不是编译期擦除。这是它和大模型 JSON 交互最舒服的一点——JSON 长什么样,模型定义就长什么样,不需要配置文件、不需要映射器。
3.4response_model:让接口的返回结构也变成文档的一部分
FastAPI 的response_model参数非常有价值——它定义的不只是响应格式,更是接口对外的一个契约。
class ChatResponse(BaseModel): reply: str token_usage: dict[str, int] @app.post("/chat", response_model=ChatResponse) async def chat(req: ChatRequest) -> ChatResponse: ...这里发生了三件事:第一,FastAPI 会按ChatResponse的结构校验你的返回值,多余字段会被过滤掉,缺字段会报错;第二,OpenAPI 文档里的响应结构就是ChatResponse的 JSON Schema,前端可以直接根据文档造数据;第三,Pydantic 会在返回时做一次序列化,保证出去的 JSON 一定符合契约。
Java 里要做到这个程度,得用 MapStruct + OpenAPI 注解 + 一堆手写配置,而 FastAPI 就是声明一个response_model而已。对大模型应用来说,这一点特别重要——模型的输出本来就是自由的 JSON,你用response_model一框,不管模型返回多乱的字段,最终暴露给用户的永远是干净的、结构化的、你完全掌控的响应。
4. 实战组合:用 FastAPI + Pydantic 搭一个聊天接口
4.1 完整代码:一个带校验、带上下文的 LLM 聊天接口
把上面所有概念串起来,写一个可以直接跑的最小聊天接口。先看完整代码:
import httpx from fastapi import FastAPI, Depends from pydantic import BaseModel, Field, field_validator app = FastAPI(title="LLM Chat Demo") # ---------- Pydantic 模型 ---------- class ChatMessage(BaseModel): role: str = Field(..., pattern="^(system|user|assistant)$") content: str = Field(..., min_length=1, max_length=2000) @field_validator("content") @classmethod def check_content(cls, v: str) -> str: # 简单示例:禁止空字符串/纯空白内容 if not v.strip(): raise ValueError("content cannot be blank") return v class ChatRequest(BaseModel): messages: list[ChatMessage] = Field(..., min_length=1, max_length=20) temperature: float = Field(0.7, ge=0.0, le=2.0) max_tokens: int = Field(512, ge=1, le=4096) class ChatResponse(BaseModel): reply: str token_usage: dict[str, int] # ---------- FastAPI 路由 ---------- @app.post("/chat", response_model=ChatResponse) async def chat(req: ChatRequest): async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( "https://api.example.com/v1/chat/completions", json={ "model": "your-model", "messages": [m.model_dump() for m in req.messages], "temperature": req.temperature, "max_tokens": req.max_tokens, }, ) resp.raise_for_status() data = resp.json() return ChatResponse( reply=data["choices"][0]["message"]["content"], token_usage=data.get("usage", {}), )我逐段解释一下。ChatRequest里的messages是list[ChatMessage],意味着外层传进来的数组里每一项都会按ChatMessage校验;model_dump()是 Pydantic v2 里把模型转回 dict 的方法,在 v1 里叫dict(),很多老教程还在用 v1 写法,新手要注意版本差异。
field_validator是 Pydantic v2 的校验器写法,v1 里是@validator("content")。这里check_content在字段校验之后、数据创建之前执行,如果字符串全是空白,就抛 422。你可以想象一下,没有这一步,用户传一个 content 全空格的消息,你的后端辛辛苦苦调一次模型,浪费了时间和钱,只因为开头少了一条校验。
4.2 用 Pydantic 约束大模型输出:结构化输出的思路
上面代码里,data["choices"][0]["message"]["content"]是模型返回的内容,多半是纯文本。但在实际的大模型应用里,我们更希望模型直接返回结构化 JSON,比如让模型判断用户意图并抽取实体:
class IntentResult(BaseModel): intent: str = Field(..., description="用户意图") entities: dict[str, str] = Field(default_factory=dict)然后让模型返回 JSON,再用 Pydantic 解析:
from pydantic import ValidationError raw_text = '{"intent": "book_flight", "entities": {"departure": "北京", "arrival": "上海"}}' try: result = IntentResult.model_validate_json(raw_text) print(result.intent) # book_flight except ValidationError as e: print("模型输出格式不对:", e)这里的model_validate_json是 Pydantic v2 的 JSON 解析方法,把字符串按模型校验并解析成实例。如果模型返回的 JSON 字段错了、类型不对,它会立刻告诉你哪里不对。
很多大模型应用框架(LangChain 系列工具、各类 Agent 库)的“结构化输出”底层做的就是这件事——让模型按你给定的 JSON Schema 返回,然后用 Pydantic 校验解析。所以你把 Pydantic 用熟了,后面看这些框架的源码会非常轻松。
4.3 一个容易忽略的坑:Optional和默认值
Java 转过来很容易犯一个错:把一个可空字段声明成str = None,这在 Pydantic 里其实是错的,因为类型标注是str,默认值是None,Pydantic 会直接在校验时报错“None is not a valid str”。正确的写法是:
from typing import Optional class Config(BaseModel): api_key: Optional[str] = None # 可空 model_name: str = "default" # 有默认值Optional[str]本质是str | None,Pydantic 见到它才允许传空。写模型之前,先把类型和默认值的关系捋清楚:
| 写法 | 字段状态 | 说明 |
|---|---|---|
x: str | 必填 | 不传报错 |
x: str = "a" | 可选,有默认值 | 不传用默认值 |
x: Optional[str] = None | 可选,可空 | 不传为 None,也可显式传 null |
| `x: str | None` | 同 Optional |
如果你把这个坑踩过两三次,就会记住一个原则:凡是可能为 null 的字段,类型标注必须带上None,Pydantic 才认。
5. 从 Java 迁移过来最容易踩的坑,以及我的几个实操心得
5.1 常见问题速查表
我把 Java 转过来最常见的几个问题整理成了一张表,基本覆盖了前几周会遇到的状况:
| 问题 | 症状 | 原因与解法 |
|---|---|---|
| 接口收到参数但没校验 | 前端传了非法 JSON 也能继续执行 | 忘加 Pydantic 模型做参数类型,直接用了dict |
model_dump()报错 | AttributeError | Pydantic v1/v2 混淆。v1 用.dict(),v2 用.model_dump() |
@validator报错 | ImportError 或诡异行为 | 装了 Pydantic v2,但代码按 v1 写,换@field_validator |
| 异步接口却很卡 | 并发请求时响应极慢 | 函数里有同步 IO(如requests),阻塞了事件循环。换异步客户端 |
| 返回的 JSON 多了一堆字段 | 响应里出现模型内部字段 | 忘了声明response_model,或response_model字段与返回字典不一致 |
Optional[str] = None报错 | 提示 None is not valid str | 类型标注和默认值不匹配,用str | None |
Depends里的函数报错 | 500 内部错误 | 忘记在依赖函数上写async def,或参数顺序不对 |
| 422 错误不知道哪里错了 | 前端看到一大段校验失败 JSON | 直接拿exc.errors()看字段明细,定位很快 |
5.2 三个实操心得
心得一:把 Pydantic 模型当成「接口的第一道防线」,而不是「顺手加上去的类」。Java 里很多人先写 Controller 再补 DTO,在 FastAPI 里这个顺序要反过来——先把所有进出接口的数据结构定义成 Pydantic 模型,再写路由函数。模型写清楚了,接口的校验、文档、序列化全都有了,后面写函数就是纯业务逻辑,不夹杂数据处理的脏活。
心得二:不要直接拿 LLM 的原始输出返回给前端,一定过一道response_model。模型输出是不可控的,可能多了字段、少了字段、字段名不对。你用ChatResponse拦截一次,前端拿到的永远是稳定结构,后续想加字段,先改模型,比改前端快得多。这是我从几次线上教训里得出的铁律。
心得三:调试 Pydantic 校验错误,用异常里的.errors()。接口抛 422 时,FastAPI 自带的错误信息不够直观,在代码里捕获ValidationError后打印e.errors(),它能精确告诉你“哪个字段、什么类型、错在哪”。Java 里排查校验报错要翻一大堆日志,这里一行代码搞定。
最后想说的
我在前面几个项目里反复用到的一个思路是:把 “API 请求 → 模型调用 → API 响应” 的每一层边界都用 Pydantic 模型钉死。请求进来有ChatRequest把关,模型返回有IntentResult这类结构化模型兜底,响应用response_model约束。逻辑层的 Python 代码反而不用太精致——只要数据边界不破,里面乱一点都能改得动。
刚开始从 Java 过来,很容易觉得 FastAPI 太“随意”,没有 Spring 的条条框框,心里没底。但用两三个礼拜你会发现,这种“少限制”是它的优势:每个接口就是一个函数,每个数据模型就是一个类,你不再需要为了改一个字段翻三个配置文件。真正的复杂度被 Pydantic 收编成类型约束,路由层变得干干净净。
我目前最常用的组合就是这篇文章里的这一套:async def路由 +Depends做鉴权 + Pydantic 模型做全链路数据结构约束 + 异步 HTTP 客户端调模型。这套东西足够应付绝大多数 LLM 应用后端的场景。再多说一句:如果你装了 Pydantic v2,请把官方文档里关于field_validator、model_validate_json、model_dump这几个方法先读明白,你真正常用的其实就是这几个。拿它们多写几个 Demo,比你背诵整本手册有用得多。