使用 FastAPI 构建生成式 AI 服务——实现类型安全的AI服务

145 阅读20分钟

在本章中,您将学习:

  • 为什么拥有完全类型化的服务至关重要
  • 如何为GenAI服务正确定义和实现类型安全
  • 数据类(dataclasses)和Pydantic数据模型之间的相似性和差异
  • 如何使用Pydantic模型验证GenAI服务中的请求和响应内容
  • 如何使用Pydantic构建自定义字段和数据模型验证器,以防止错误数据通过服务
  • 如何使用Pydantic Settings包加载和验证应用程序环境变量
  • 如何减少与外部系统交互时由于模式变化而带来的不确定性
  • 随着复杂性的增长,如何管理变更以避免引入无意的错误

在处理不断变化的复杂代码库时,尤其是在多个贡献者共同参与的情况下,以及与外部服务(如API或数据库)交互时,您需要遵循最佳实践,例如在构建应用程序时使用类型安全。

本章重点讲解了在构建后端服务和API时,类型安全的重要性。您将学习如何使用Python内置的dataclasses实现类型安全,然后学习Pydantic数据模型,并了解它们的相似性和差异。此外,您将探索如何使用Pydantic数据模型与自定义验证器来防止错误的用户输入或不正确的数据,并学习如何使用Pydantic Settings加载和验证环境变量。最后,您将发现处理外部系统模式变化的策略,并学习如何管理不断发展的代码库中的复杂性,以防止引入bug。

在本章结束时,您将拥有一个完全类型化的GenAI服务,该服务在处理变化、错误的用户输入和不一致的模型响应时,发生bug的几率将大大降低。

要跟随本章的内容,您可以切换到ch04-start分支以获取本章的起始代码。

类型安全简介

编程中的类型指定了可以赋给变量的值以及可以对这些变量执行的操作。

在Python中,常见的类型包括:

  • 整数(Integer)
    表示整数
  • 浮点数(Float)
    表示带有小数部分的数字
  • 字符串(String)
    表示字符序列
  • 布尔值(Boolean)
    表示True或False值

提示
您可以使用typing包导入特殊类型,正如您在第3章的其他代码示例中看到的那样。

类型安全是一种编程实践,确保变量仅被赋值为与其定义类型兼容的值。在Python中,您可以使用类型来检查代码库中变量的使用,特别是当代码库的复杂性和规模不断增长时。类型检查工具(例如mypy)可以利用这些类型来捕获不正确的变量赋值或操作。

静态和动态类型语言

Python是少数几种动态类型语言之一,这意味着类型仅在运行代码时进行检查,而不是提前检查。此外,您不需要显式声明类型,因为在给变量赋值时,类型可以自动推断。然而,一旦您的代码库变得复杂,并且您的服务开始与其他系统(如文件系统、数据库或外部服务)交互时,您将面临几个可靠性问题。

使用类型检查,您可以更快地捕获错误。如果没有类型检查,您必须依赖测试,而测试覆盖不足可能会导致由于错误而导致生产环境崩溃。在最好的情况下,您可能不会意识到影响用户的细微问题和差异;在最坏的情况下,您可能会面临数据损坏的风险,或者在为时已晚时才发现巨大的服务器成本。

静态类型语言强制开发者使用类型,以提高代码的可靠性和执行速度,可能会以开发速度为代价。另一方面,Python中的类型检查并不强制执行,以提供灵活性,因此您可以更快地构建应用程序。但是,Python中的静态类型分析已经随着像mypy这样的工具得到了很大的发展。

你可以通过声明完全类型化的变量和函数来强制执行类型约束,如示例4-1所示。

示例4-1. 在Python中使用类型

from datetime import datetime

def timestamp_to_isostring(date: int) -> str:
    return datetime.fromtimestamp(date).isoformat()

print(timestamp_to_isostring(1736680773))
# 2025-01-12T11:19:52.876758

print(timestamp_to_isostring("27 Jan 2025 14:48:00"))
# 错误: 参数1类型不兼容:"str";
# 期望类型是"int" [arg-type]

代码编辑器和IDE(例如VS Code或JetBrains PyCharm)也可以使用类型检查扩展,如图4-1所示,在您编写代码时,针对类型违规发出警告。

image.png

在复杂的代码库中,很容易丢失对变量、它们的状态以及不断变化的模式的追踪。例如,您可能会忘记timestamp_to_isostring函数接受数字作为输入,而错误地将时间戳作为字符串传递,如图4-1所示。

类型在包维护者或外部API提供者更新代码时也非常有用。类型检查器可以立即发出警告,帮助您在开发过程中处理这些变化。通过这种方式,您可以立即发现潜在错误的源头,而无需运行代码并测试每个端点。因此,类型安全实践可以通过早期检测节省时间,并防止您遇到更难以发现的运行时错误。

最后,您可以进一步设置自动类型检查,在部署管道中防止将破坏性更改推送到生产环境。

一开始,类型安全似乎是一个负担。您必须明确为每个编写的函数指定类型,这可能会在开发初期阶段成为一个麻烦并减缓进度。

一些人为了快速原型开发和减少样板代码,跳过了代码中的类型声明。这种做法更加灵活且易于使用,而且Python足够强大,能够推断简单类型。此外,某些代码模式(例如具有多类型参数的函数)可能非常动态,以至于在实验过程中避免实现严格的类型安全更为方便。然而,当您的服务变得复杂并持续变化时,类型安全将为您节省数小时的开发时间。

好消息是,当您使用数据库时,像Prisma这样的工具可以自动生成一些类型,而当您与外部API交互时,也可以使用客户端生成器。对于外部API,您通常可以找到包含类型提示(即完全类型化的客户端)的官方SDK,指定API使用时的输入和输出的期望类型。如果没有,您可以检查API以创建您自己的完全类型化客户端。我将在本书后面详细介绍Prisma和API客户端生成器。

当您不使用类型时,您会面临各种可能发生的错误,因为其他开发人员可能意外更新了您的服务所交互的数据库表或API模式。在其他情况下,您可能更新了数据库表——例如删除了一个列——并且忘记更新与该表交互的代码。

没有类型,您可能永远不会注意到由于更新而产生的破坏性更改。这可能很难调试,因为未处理的下游错误可能无法明确指出损坏的组件或您自己开发团队中未处理的边缘情况。因此,原本需要一分钟解决的问题,可能会持续半天甚至更长时间。

您始终可以通过广泛的测试来防止一些生产中的灾难。然而,如果您从一开始就使用类型,避免集成和可靠性问题将变得更加容易。

养成良好的编程习惯

如果您过去没有为代码添加类型,现在开始养成为所有变量、函数参数和返回类型添加类型的习惯也永远不晚。

使用类型将使您的代码更具可读性,帮助您尽早发现错误,并在重新访问复杂代码库时节省大量时间,快速理解数据的流动。

实现类型安全

从Python 3.5开始,您可以显式声明变量、函数参数和返回值的类型。允许您声明这些类型的语法称为类型注解。

类型注解

类型注解不会影响应用程序的运行时行为。它们有助于捕获类型错误,尤其是在复杂的大型应用程序中,多个开发人员共同工作时。静态类型检查工具(如mypy、pyright或pyre)结合代码编辑器,可以验证从函数中存储和返回的数据类型是否与预期类型匹配。

在Python应用程序中,类型注解用于:

  • 代码编辑器的自动补全支持

  • 使用像mypy这样的工具进行静态类型检查

  • FastAPI也利用类型提示来:

    • 定义处理程序要求,包括路径和查询参数、请求体、头信息、依赖项等
    • 在需要时转换数据
    • 验证来自传入请求、数据库和外部服务的数据
    • 自动更新生成的OpenAPI规范,从而生成文档页面

您可以通过pip安装loguru:

$ pip install loguru

示例4-2展示了几个类型注解的示例。

示例4-2. 使用类型注解来减少代码变更时出现的未来错误

# utils.py

from typing import Literal, TypeAlias
from loguru import logger
import tiktoken

SupportedModels: TypeAlias = Literal["gpt-3.5", "gpt-4"]
PriceTable: TypeAlias = dict[SupportedModels, float]  
price_table: PriceTable = {"gpt-3.5": 0.0030, "gpt-4": 0.0200} 

def count_tokens(text: str | None) -> int: 
    if text is None:
        logger.warning("Response is None. Assuming 0 tokens used")
        return 0 
    enc = tiktoken.encoding_for_model("gpt-4o")
    return len(enc.encode(text)) 

def calculate_usage_costs(
    prompt: str,
    response: str | None,
    model: SupportedModels,
) -> tuple[float, float, float]: 
    if model not in price_table:
        # 在运行时引发 - 如果有人忽略了类型错误
        raise ValueError(f"Cost calculation is not supported for {model} model.") 
    price = price_table[model] 
    req_costs = price * count_tokens(prompt) / 1000
    res_costs = price * count_tokens(response) / 1000 
    total_costs = req_costs + res_costs
    return req_costs, res_costs, total_costs 

使用Python标准库中的Literal模块。声明字面量gpt-3.5gpt-4并将它们分配给SupportedModel类型别名。PriceTable也是一个简单的类型别名,定义了一个字典,其键仅限于SupportedModel字面量,值的类型为float

TypeAlias标记类型别名,以明确表示它们不是普通的变量赋值。类型通常使用CamelCase命名规范,以区分它们与变量。现在,您可以稍后重新使用PriceTable类型别名。

声明定价表字典并显式地分配PriceTable类型,以限制定价表字典中允许的键和值。

count_tokens函数指定类型,使其接受strNone类型,并始终返回整数。如果有人尝试传入非字符串或None类型,静态检查工具会发出警告。在定义count_tokens时,即使它接收到None并引发错误,代码编辑器和静态检查工具也会发出警告。

即使传入None类型,也返回0,以确保符合函数类型。

使用OpenAI的tiktoken库按gpt-4o模型使用相同的编码对给定文本进行分词。

calculate_usage_costs函数指定类型,使其始终接受文本提示,并为model参数指定预定义的字面量。传递先前声明的PriceTable类型别名的price_table。该函数应返回一个包含三个浮动数的元组。

类型检查器会在传递未预期的模型字面量时发出警告,但您应该始终检查函数输入的正确性,并在运行时引发错误,如果传入了不正确的模型参数。

从定价表中获取正确的价格。如果传入不受支持的模型,则不必担心异常处理,因为此处不可能引发KeyError。如果定价表未更新,函数会在早期引发ValueError。捕获KeyError后,发出警告,提醒定价表需要更新,然后重新引发KeyError,这样可以确保终端打印出详细信息,因为您不能假设价格是正确的。

使用count_tokens函数计算LLM的请求和响应成本。如果由于某种原因LLM没有返回响应(返回None),count_tokens可以处理并假设为零令牌。

按照函数类型返回一个包含三个浮动数的元组。

在复杂的代码库中,猜测传递的数据类型可能会非常具有挑战性,尤其是当您在多个地方进行大量修改时。使用类型化的函数,您可以确保意外的参数不会被传递到尚不支持的函数中。

如示例4-2所示,类型化您的代码有助于在您更新代码时捕获意外的错误。例如,如果您开始使用新的LLM模型,但还不能为新模型计算成本。要支持其他LLM模型的成本计算,您首先应该更新定价表、相关类型和任何异常处理逻辑。完成这些后,您可以相当有信心地知道您的计算逻辑现在扩展到支持新的模型类型。

使用Annotated

在示例4-2中,您可以使用Annotated代替类型别名。Annotated是Python 3.9引入的typing模块的一个特性,类似于类型别名,用于重用类型,但它还允许您为类型定义元数据。

元数据不会影响类型检查工具,但对代码文档、分析和运行时检查非常有用。

自Python 3.9引入以来,您可以像示例4-3所示使用Annotated

示例4-3. 使用Annotated声明带有元数据的自定义类型

from typing import Annotated, Literal

SupportedModels = Annotated[
    Literal["gpt-3.5-turbo", "gpt-4o"], "Supported text models"
]
PriceTableType = Annotated[
    dict[SupportedModels, float], "Supported model pricing table"
]

prices: PriceTableType = {
    "gpt-4o": 0.000638,
    # 错误: 字典条目1的类型不兼容 "Literal['gpt4-o']" [dict-item]
    "gpt4-o": 0.000638,
    # 错误: 字典条目2的类型不兼容 "Literal['gpt-4']" [dict-item]
    "gpt-4": 0.000638,
}

FastAPI文档建议使用Annotated代替类型别名,以提高代码编辑器中的类型检查,增强代码重用性,并在运行时捕获问题。

警告
请记住,Annotated特性需要至少两个参数才能工作。第一个应为传入的类型,其他参数是您希望附加到类型的注释或元数据,如描述、验证规则或其他元数据,如示例4-3所示。

虽然类型本身是有益的,但它并不能解决数据处理和结构化的所有方面。幸运的是,Python的标准库中的数据类(dataclasses)有助于扩展类型系统。

接下来,让我们看看如何利用数据类来改善应用程序中的类型。

数据类(Dataclasses)

数据类是Python 3.7引入的标准库的一部分。如果您需要自定义数据结构,可以使用数据类在应用程序中组织、存储和传输数据。

数据类有助于避免代码“异味”,例如函数参数膨胀问题,当一个函数因需要过多参数而难以使用时。使用数据类可以让您在自定义结构中组织数据,并将其作为单个项传递给需要来自不同位置的数据的函数。

您可以更新示例4-2,利用数据类,如示例4-4所示。

示例4-4. 使用数据类来强制执行类型安全

# utils.py

from dataclasses import dataclass
from typing import Literal, TypeAlias
from utils import count_tokens

SupportedModels: TypeAlias = Literal["gpt-3.5", "gpt-4"]
PriceTable: TypeAlias = dict[SupportedModels, float]
prices: PriceTable = {"gpt-3.5": 0.0030, "gpt-4": 0.0200}

@dataclass 
class Message:
    prompt: str
    response: str | None 
    model: SupportedModels

@dataclass
class MessageCostReport:
    req_costs: float
    res_costs: float
    total_costs: float

# 定义count_tokens函数,如常规
...

def calculate_usage_costs(message: Message) -> MessageCostReport: 
    if message.model not in prices :
        # 在运行时引发 - 如果有人忽略了类型错误
        raise ValueError(
            f"Cost calculation is not supported for {message.model} model."
        )
    price = prices[message.model]
    req_costs = price * count_tokens(message.prompt) / 1000
    res_costs = price * count_tokens(message.response) / 1000
    total_costs = req_costs + res_costs
    return MessageCostReport(
        req_costs=req_costs, res_costs=res_costs, total_costs=total_costs
    )

使用数据类装饰MessageMessageCostReport类,作为用于保存数据的特殊类。

response属性指定类型,可以是strNone。这类似于使用typing模块中的Optional[str]。此新语法在Python 3.10及以后的版本中可用,使用新的联合运算符|

calculate_usage_costs函数的签名更改为使用预定义的数据类。这一变化简化了函数签名。

当您的代码积累了“异味”并变得难以阅读时,您应当利用数据类。

在示例4-4中使用数据类的主要好处是将相关参数分组,以简化函数签名。在其他场景中,您可以使用数据类来:

  • 消除代码重复
  • 减少代码膨胀(大型类或函数)
  • 重构数据块(通常一起使用的变量)
  • 防止不经意的数据变更
  • 促进数据组织
  • 促进封装
  • 强制执行数据验证

它们还可以用于实现许多其他代码改进。

数据类是改善应用程序中数据组织和交换的绝佳工具。然而,在构建API服务时,它们不原生支持几个功能:

  • 自动数据解析
  • 将ISO日期时间格式的字符串解析为日期时间对象

字段验证

  • 在给字段赋值时执行复杂的检查,例如检查字符串是否过长

序列化与反序列化

  • 在JSON和Python数据结构之间转换,尤其是使用不常见类型时

字段过滤

  • 移除未设置或包含None值的对象字段

上述的限制并不会迫使您放弃使用数据类。您应当在需要创建以数据为中心的类,并且尽量减少样板代码时使用数据类,因为它们会自动生成特殊方法、类型注解并支持默认值,从而减少潜在的错误。但是,像Pydantic这样的库支持这些功能,如果您不想实现自己的自定义逻辑(例如,序列化日期时间对象)。

提示
FastAPI也通过Pydantic支持数据类,Pydantic实现了自己的数据类版本,支持上述功能,使您能够迁移大量使用数据类的代码库。

接下来,我们来看看Pydantic以及它为何适合构建GenAI服务。

Pydantic模型

Pydantic是最广泛使用的数据验证库,支持自定义验证器和序列化器。Pydantic的核心逻辑由Python中的类型注解控制,并可以以JSON格式输出数据,便于与其他工具无缝集成。

此外,Pydantic V2中的核心数据验证逻辑已使用Rust重写,以最大化其速度和性能,使其成为Python中最快的数据验证库之一。因此,Pydantic在FastAPI以及Python生态系统中的8,000个其他包(包括Hugging Face、Django和LangChain)中产生了深远的影响。它是经过多次测试的工具包,全球主要科技公司广泛使用,截止本文撰写时,每月下载量达到1.41亿次,是您项目中可以考虑用来替代数据类的合适工具。

Pydantic提供了一个广泛的数据验证和处理工具集,使用其自定义的BaseModel实现。Pydantic模型与数据类有许多相似之处,但在一些细节上有所不同。当您创建Pydantic模型时,一组初始化钩子会被调用,向模型添加数据验证、序列化和JSON模式生成特性,而这些是普通数据类所缺乏的。

FastAPI与Pydantic紧密集成,并在后台利用其丰富的功能集进行数据处理。类型检查器和代码编辑器也可以像处理数据类一样读取Pydantic模型,以执行检查并提供自动补全。

如何使用Pydantic

您可以使用以下命令将Pydantic安装到您的项目中:

$ pip install pydantic

Pydantic的核心实现了BaseModel,这是定义模型的主要方法。模型是继承自BaseModel的类,并使用类型提示定义字段作为注解属性。任何模型都可以作为模式,用来验证您的数据。

除了组织数据,Pydantic模型还允许您指定服务端点的请求和响应要求,并验证来自外部源的不可信数据。您还可以使用Pydantic模型(及其验证器,稍后会讲解)来过滤LLM的输出。

您可以按示例4-5所示创建自己的Pydantic模型。

示例4-5. 创建Pydantic模型

from typing import Literal
from pydantic import BaseModel

class TextModelRequest(BaseModel): 
    model: Literal["gpt-3.5-turbo", "gpt-4o"]
    prompt: str
    temperature: float = 0.0 
  • 定义继承自Pydantic BaseModelTextModelRequest模型。
  • 如果没有提供显式值,设置默认值。例如,如果初始化时没有提供值,则将temperature字段设置为0.0

复合Pydantic模型

使用Pydantic模型,您可以声明数据模式,这些模式定义了服务操作中支持的数据结构。此外,您还可以使用继承来构建复合模型,如示例4-6所示。

示例4-6. 创建复合Pydantic模型

# schemas.py

from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel

class ModelRequest(BaseModel): 
    prompt: str

class ModelResponse(BaseModel): 
    request_id: str
    ip: str | None
    content: str | None
    created_at: datetime = datetime.now()

class TextModelRequest(ModelRequest):
    model: Literal["gpt-3.5-turbo", "gpt-4o"]
    temperature: float = 0.0

class TextModelResponse(ModelResponse):
    tokens: int

ImageSize = Annotated[tuple[int, int], "Width and height of an image in pixels"]

class ImageModelRequest(ModelRequest): 
    model: Literal["tinysd", "sd1.5"]
    output_size: ImageSize
    num_inference_steps: int = 200

class ImageModelResponse(ModelResponse): 
    size: ImageSize
    url: str
  • 定义ModelRequest模型,继承自Pydantic BaseModel
  • 定义ModelResponse模型。如果ip字段没有提供数据,则使用默认值Nonecontent字段可以是字节(对于图像)或字符串(对于文本模型)。
  • 定义TextModelRequestImageModelRequest模型,继承自ModelRequesttemperature字段默认设置为0.0num_inference_steps字段默认设置为200。这两个模型现在都要求提供prompt字符串字段。
  • 定义ImageModelResponseTextModelResponse模型,继承自ModelResponse模型。对于TextModelResponse,提供令牌的计数;对于ImageModelResponse,提供图像大小和远程URL以供下载。

通过示例4-6中定义的模型,您可以为文本和图像生成端点定义所需的模式。

字段约束和验证器

除了支持标准类型外,Pydantic还提供了受约束类型,如EmailStrPositiveIntUUID4AnyHttpUrl等,可以在模型初始化时直接进行数据验证,适用于常见的数据格式。Pydantic类型的完整列表可在官方文档中查看。

注意
某些受约束的类型,如EmailStr,需要安装依赖包才能工作,但对于验证常见的数据格式(如电子邮件)非常有用。

要在Pydantic受约束类型的基础上定义更多自定义和复杂的字段约束,您可以使用Pydantic的Field函数结合Annotated类型,引入验证约束,例如有效的输入范围。

示例4-7. 使用受约束字段

# schemas.py

from datetime import datetime
from typing import Annotated, Literal
from uuid import uuid4
from pydantic import BaseModel, Field, HttpUrl, IPvAnyAddress, PositiveInt

class ModelRequest(BaseModel):
    prompt: Annotated[str, Field(min_length=1, max_length=10000)] 

class ModelResponse(BaseModel):
    request_id: Annotated[str, Field(default_factory=lambda: uuid4().hex)] 
    ip: Annotated[str, IPvAnyAddress] | None 
    content: Annotated[str | None, Field(min_length=0, max_length=10000)] 
    created_at: datetime = datetime.now()

class TextModelRequest(ModelRequest):
    model: Literal["gpt-3.5-turbo", "gpt-4o"]
    temperature: Annotated[float, Field(ge=0.0, le=1.0, default=0.0)] 

class TextModelResponse(ModelResponse):
    tokens: Annotated[int, Field(ge=0)]

ImageSize = Annotated[ 
    tuple[PositiveInt, PositiveInt], "Width and height of an image in pixels"
]

class ImageModelRequest(ModelRequest):
    model: Literal["tinysd", "sd1.5"]
    output_size: ImageSize 
    num_inference_steps: Annotated[int, Field(ge=0, le=2000)] = 200 

class ImageModelResponse(ModelResponse):
    size: ImageSize 
    url: Annotated[str, HttpUrl] | None = None 
  • 将标准类型str替换为FieldAnnotated,并将字符串长度限制为字符范围。
  • 通过传递一个可调用对象给default_factory来生成新的请求UUID。
  • 限制可选的ip字段为任何有效的IPv4或IPv6地址范围。如果客户端的IP无法确定,None也是有效输入。该字段没有默认值,因此如果未提供有效的IP或None,Pydantic将引发ValidationError
  • content字段的长度限制为10,000个字符或字节。
  • temperature字段限制在0.01.0之间,默认值为0.0
  • 重用Annotated约束,将output_size字段限制为正整数,使用PositiveInt受约束类型。ltegte关键字分别表示小于等于和大于等于。
  • num_inference_steps字段限制在02000之间,默认值为200
  • 将可选的url字段限制为任何有效的HTTP或HTTPS URL,要求提供主机名和顶级域(TLD)。

通过示例4-7中定义的模型,您现在可以对传入或传出的数据进行验证,以匹配您定义的数据要求。在这种情况下,FastAPI将利用Pydantic,在请求运行时自动返回错误响应,当数据验证检查失败时,如示例4-8所示。

示例4-8. 数据验证失败时的FastAPI错误响应

$ curl -X 'POST' \
  'http://127.0.0.1:8000/validation/failure' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "prompt": "string",
  "model": "gpt-4o",
  "temperature": 0
}'

{
  "detail": [
    {
      "type": "literal_error",
      "loc": [
        "body",
        "model"
      ],
      "msg": "Input should be 'tinyllama' or 'gemma2b'",
      "input": "gpt-4o",
      "ctx": {
        "expected": "'tinyllama' or 'gemma2b'"
      }
    }
  ]
}

自定义字段和模型验证器

Pydantic的另一个出色的特性是自定义字段验证器,用于执行数据验证检查。示例4-9展示了如何在ImageModelRequest中实现这两种类型的自定义验证器。

示例4-9. 为ImageModelRequest实现自定义字段和模型验证器

# schemas.py

from typing import Annotated, Literal
from pydantic import (
    AfterValidator,
    BaseModel,
    Field,
    PositiveInt,
    validate_call,
)

ImageSize = Annotated[
    tuple[PositiveInt, PositiveInt], "Width and height of an image in pixels"
]
SupportedModels = Annotated[
    Literal["tinysd", "sd1.5"], "Supported Image Generation Models"
]

@validate_call 
def is_square_image(value: ImageSize) -> ImageSize: 
    if value[0] / value[1] != 1:
        raise ValueError("Only square images are supported")
    if value[0] not in [512, 1024]:
        raise ValueError(f"Invalid output size: {value} - expected 512 or 1024")
    return value

@validate_call 
def is_valid_inference_step(
    num_inference_steps: int, model: SupportedModels
) -> int:
    if model == "tinysd" and num_inference_steps > 2000: 
        raise ValueError(
            "TinySD model cannot have more than 2000 inference steps"
        )
    return num_inference_steps

OutputSize = Annotated[ImageSize, AfterValidator(is_square_image)] 
InferenceSteps = Annotated[ 
    int,
    AfterValidator(
        lambda v, values: is_valid_inference_step(v, values["model"])
    ),
]

class ModelRequest(BaseModel):
    prompt: Annotated[str, Field(min_length=1, max_length=4000)]

class ImageModelRequest(ModelRequest):
    model: SupportedModels
    output_size: OutputSize 
    num_inference_steps: InferenceSteps = 200 

除了静态类型检查外,如果错误的参数被传递给is_square_imageis_valid_inference_step函数,则会在运行时引发验证错误。

  • tinysd模型只能生成特定大小的正方形图像。要求非正方形的图像尺寸(即长宽比不等于1)应该引发ValueError
  • 如果用户为tinysd模型要求过多的推理步骤,则引发ValueError

通过为OutputSizeInferenceSteps字段使用注解模式,创建了可重用和更易读的验证器。

  • OutputSize字段验证器附加到output_size字段,以在模型初始化后检查不正确的值。
  • InferenceSteps验证器附加到ImageModelRequest模型,以在模型初始化后对模型字段值进行检查。

通过自定义字段验证器,如示例4-9所示,您现在可以确信,您的图像生成端点将受到保护,避免用户提供的错误配置。

注意
您还可以使用装饰器模式来验证模型字段。可以将特殊方法与模型字段关联,通过使用@field_validator@model_validator装饰器执行条件数据检查。

  • @field_validator访问单个字段的值进行检查,@model_validator装饰器允许执行涉及多个字段的检查。
  • 使用after验证器,您可以在Pydantic完成解析和验证后,执行额外的检查或修改数据。

计算字段

类似于数据类,Pydantic还允许您实现方法来计算由其他字段派生的字段。

您可以使用@computed_field装饰器实现一个计算字段,用于自动计算令牌数量和费用,如示例4-10所示。

示例4-10. 使用计算字段自动计算令牌总数

# schemas.py

from typing import Annotated
from pydantic import computed_field, Field
from utils import count_tokens

...

class TextModelResponse(ModelResponse):
    model: SupportedModels
    price: Annotated[float, Field(ge=0, default=0.01)]
    temperature: Annotated[float, Field(ge=0.0, le=1.0, default=0.0)]

    @property
    @computed_field
    def tokens(self) -> int:
        return count_tokens(self.content)

    @property
    @computed_field
    def cost(self) -> float:
        return self.price * self.tokens

计算字段对于将任何字段计算逻辑封装在Pydantic模型中非常有用,以保持代码的组织性。请记住,计算字段只有在将Pydantic模型转换为字典时(使用.model_dump())或通过序列化时(当FastAPI API处理程序返回响应时)才能访问。

模型导出和序列化

由于Pydantic模型可以序列化为JSON,您在示例4-7中定义的模型也可以转储到(或从)JSON字符串或Python字典中,同时保持任何复合模式,如示例4-11所示。

示例4-11. 导出和序列化TextModelResponse模型

>> response = TextModelResponse(content="FastAPI Generative AI Service", ip=None)
>> response.model_dump(exclude_none=True)
{'content': 'FastAPI Generative AI Service',
 'cost': 0.06,
 'created_at': datetime.datetime(2024, 3, 7, 20, 42, 38, 729410),
 'price': 0.01,
 'request_id': 'a3f18d85dcb442baa887a505ae8d2cd7',
 'tokens': 6}

>> response.model_dump_json(exclude_unset=True)
'{"ip":null,"content":"FastAPI Generative AI Service","tokens":6,"cost":0.06}'

通过这种方式,您可以轻松地将Pydantic模型导出为JSON格式,或者从JSON格式加载数据,同时保留模型中的字段约束和计算字段。这使得Pydantic模型在数据交换和与外部系统集成时非常有用。

将Pydantic模型导出为字典或JSON字符串

当您创建一个Pydantic模型时,您还可以访问与字段相关的类型提示,并可以将模型导出为Python字典或JSON字符串。Pydantic还允许您根据字段的值排除某些字段。例如,您可以使用model.dict(exclude_none=True)来返回一个字典,其中排除了所有设置为None的字段。在导出时排除字段的其他选项包括:

  • exclude_unset
    仅返回在模型创建过程中显式设置的字段。
  • exclude_defaults
    仅返回那些设置为默认值的可选字段。
  • exclude_None
    仅返回那些设置为None的字段。

您可以在导出模型时混合使用这些条件。当模型包含大量字段时,这些选项特别有用,例如在处理过滤器时。例如,如果客户端没有在查询参数中指定某些过滤器,您可以从请求过滤器模型中排除所有未设置的过滤字段。

当将Pydantic模型导出为JSON字符串时,您也可以使用相同的字段排除功能,使用model.json_dump(exclude_unset=True)进行导出。

使用Pydantic解析环境变量

除了BaseModel外,Pydantic还实现了一个用于从文件解析设置和机密的基类。此功能提供在一个可选的Pydantic包pydantic-settings中,您可以将其作为依赖项安装:

$ pip install pydantic-settings

BaseSettings类提供了可选的Pydantic功能,用于从环境变量或机密文件加载设置或配置类。使用此功能,设置值可以在代码中设置或通过环境变量覆盖。

在生产环境中,这非常有用,因为您不希望在代码或容器环境中暴露机密。

当您创建一个继承自BaseSettings的模型时,模型初始化器将尝试使用提供的默认值设置每个字段的值。如果失败,初始化器将从环境变量中读取任何未设置字段的值。

假设有一个dotenv环境文件(ENV):

APP_SECRET=asdlkajdlkajdklaslkldjkasldjkasdjaslk
DATABASE_URL=postgres://sa:password@localhost:5432/cms
CORS_WHITELIST=["https://xyz.azurewebsites.net","http://localhost:3000"]

ENV是一个可以使用Shell脚本语法表示键值对的环境变量文件。

示例4-12展示了如何使用BaseSettings解析环境变量。

示例4-12. 使用Pydantic的BaseSettings解析环境变量

# settings.py

from typing import Annotated
from pydantic import Field, HttpUrl, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict

class AppSettings(BaseSettings): 
    model_config = SettingsConfigDict(
        env_file=".env", env_file_encoding="utf-8" 
    )

    port: Annotated[int, Field(default=8000)]
    app_secret: Annotated[str, Field(min_length=32)]
    pg_dsn: Annotated[
        PostgresDsn,
        Field(
            alias="DATABASE_URL",
            default="postgres://user:pass@localhost:5432/database",
        ),
    ] 
    cors_whitelist_domains: Annotated[
        set[HttpUrl],
        Field(alias="CORS_WHITELIST", default=["http://localhost:3000"]),
    ] 


settings = AppSettings()
print(settings.model_dump()) 
"""
{'port': 8000
 'app_secret': 'asdlkajdlkajdklaslkldjkasldjkasdjaslk',
 'pg_dsn': MultiHostUrl('postgres://sa:password@localhost:5432/cms'),
 'cors_whitelist_domains': {Url('http://localhost:3000/'),
                            Url('https://xyz.azurewebsites.net/')},
}
"""
  • 声明AppSettings继承自pydantic_settings包中的BaseSettings类。
  • 配置AppSettings从项目根目录下的ENV文件读取环境变量,并使用UTF-8编码。默认情况下,使用蛇形命名法的字段名将映射到对应的环境变量名,这些环境变量名是这些字段名的大写版本。例如,app_secret变成APP_SECRET
  • 验证DATABASE_URL环境变量是否具有有效的Postgres连接字符串格式。如果没有提供,则设置默认值。
  • 检查CORS_WHITELIST环境变量是否包含有效的URL列表,其中包括主机名和顶级域名(TLD)。如果未提供,则将默认值设置为包含http://localhost:3000的集合。

我们可以通过打印模型的dump来检查AppSettings类是否正常工作。

注意
使用_env_file参数时,您可以切换环境文件:

test_settings = AppSettings(_env_file="test.env")

在FastAPI中使用数据类或Pydantic模型

尽管数据类仅支持常见类型的序列化(例如,intstrlist等),并且在运行时不会执行字段验证,但FastAPI仍然可以与Pydantic模型和Python的标准数据类一起使用。对于字段验证和额外的功能,您应使用Pydantic模型。示例4-13展示了如何在FastAPI路由处理程序中使用数据类。

示例4-13. 在FastAPI中使用数据类

# schemas.py

from dataclasses import dataclass
from typing import Literal

@dataclass
class TextModelRequest: 
    model: Literal["tinyLlama", "gemma2b"]
    prompt: str
    temperature: float

@dataclass
class TextModelResponse: 
    response: str
    tokens: int

# main.py

from fastapi import Body, FastAPI, HTTPException, status
from models import generate_text, load_text_model
from schemas import TextModelRequest, TextModelResponse
from utils import count_tokens

# load lifespan
...

app = FastAPI(lifespan=lifespan)

@app.post("/generate/text")
def serve_text_to_text_controller(
    body: TextModelRequest = Body(...),
) -> TextModelResponse:  
    if body.model not in ["tinyLlama", "gemma2b"]: 
        raise HTTPException(
            detail=f"Model {body.model} is not supported",
            status_code=status.HTTP_400_BAD_REQUEST,
        )
    output = generate_text(models["text"], body.prompt, body.temperature)
    tokens = count_tokens(body.prompt) + count_tokens(output)
    return TextModelResponse(response=output, tokens=tokens) 
  • 为文本模型的请求和响应模式定义数据类。
  • 将处理程序改为处理POST请求,并将请求体声明为TextModelRequest,响应体声明为TextModelResponse。像mypy这样的静态代码检查器将读取类型注解,并在控制器未返回预期的响应模型时发出警告。
  • 显式检查服务是否支持请求体中提供的model参数。如果不支持,返回一个HTTP 400错误的请求异常响应。
  • FastAPI将普通的数据类转换为Pydantic数据类,以便序列化/反序列化和验证请求和响应数据。

示例4-13中,您通过重构文本模型控制器,利用类型注解使其能够抵御新变化和错误的用户输入。静态类型检查器现在可以帮助您在发生变化时捕获任何与数据相关的问题。此外,FastAPI使用您的类型注解来验证请求和响应,并自动生成OpenAPI文档页面,如图4-2所示。

您现在看到,即使使用普通的数据类,FastAPI在后台也利用Pydantic模型进行数据处理和验证。FastAPI将普通数据类转换为Pydantic风格的数据类,以使用其数据验证功能。这种行为是有意为之的,因为如果您的项目中已有多个数据类类型注解,您仍然可以迁移它们,而无需将它们重写为Pydantic模型以利用数据验证功能。然而,如果您正在启动一个新项目,建议直接使用Pydantic模型来替代Python内建的数据类。

image.png

现在让我们看看如何在FastAPI应用程序中用Pydantic替代数据类。请参见示例4-14**。**

示例4-14. 使用Pydantic建模请求和响应模式

# main.py

from fastapi import Body, FastAPI, HTTPException, Request, status
from models import generate_text
from schemas import TextModelRequest, TextModelResponse 

# load lifespan
...

app = FastAPI(lifespan=lifespan)

@app.post("/generate/text") 
def serve_text_to_text_controller(
    request: Request, body: TextModelRequest = Body(...)
) -> TextModelResponse:
    if body.model not in ["tinyLlama", "gemma2b"]: 
        raise HTTPException(
            detail=f"Model {body.model} is not supported",
            status_code=status.HTTP_400_BAD_REQUEST,
        )
    output = generate_text(models["text"], body.prompt, body.temperature)
    return TextModelResponse(content=output, ip=request.client.host) 
  • 导入Pydantic模型,用于文本模型的请求和响应模式。
  • 将处理程序转换为处理带有请求体的POST请求。然后,声明请求体为TextModelRequest,响应体为TextModelResponse。像mypy这样的静态代码检查器将读取类型注解,并在控制器没有返回预期的响应模型时发出警告。
  • 显式检查服务是否支持请求体中提供的model参数。如果不支持,返回HTTP 400错误的请求异常响应。
  • 按照函数类型返回TextModelResponse Pydantic模型。通过request.client.host访问客户端的IP地址。FastAPI会在后台使用.model_dump()处理模型的序列化工作。由于您还为tokenscost属性实现了计算字段,这些字段将自动包含在API响应中,无需额外工作。

注意
示例4-13所示,如果您使用数据类而不是Pydantic模型,FastAPI会将它们转换为Pydantic数据类,以便序列化/反序列化和验证请求和响应数据。然而,您可能无法利用像字段约束和计算字段等高级特性,数据类无法实现这些功能。

示例4-14所示,Pydantic可以通过帮助进行类型检查、数据验证、序列化、代码编辑器自动补全和计算属性,提供卓越的开发者体验。

FastAPI还可以使用您的Pydantic模型自动生成OpenAPI规范和文档页面,以便您可以无缝地手动测试端点。

启动服务器后,您应该能够看到更新的文档页面,展示新的Pydantic模型和更新的字段约束,如图4-3所示。

image.png

如果您向 /generate/text 端点发送请求,您现在应该能够看到通过 TextModelResponse Pydantic 模型自动填充的响应字段,如 示例4-15** 所示。**

示例4-15. 通过 TextModelResponse Pydantic 模型自动填充响应字段

请求

curl -X 'POST' \
    'http://localhost:8000/generate/text' \
    -H 'accept: application/json' \
    -H 'Content-Type: application/json' \
    -d '{
    "prompt": "What is your name?",
    "model": "tinyllama",
    "temperature": 0.01
}'

http://localhost:8000/generate/text

响应正文

{
    "request_id": "7541204d5c684f429fe43ccf360fЗ3dc",
    "ip": "127.0.0.1",
    "content": "I am not a person. However, I can provide you with information about my name. My name is fastapi bot.",
    "created_at": "2024-03-07T16:06:57.492039",
    "price": 0.01,
    "tokens": 25,
    "cost": 0.25
}

响应头

content-length: 259
content-type: application/json
date: Thu, 07 Mar 2024 16:07:01 GMT
server: uvicorn
x-response-time: 22.9243

在本章中我介绍的Pydantic模型特性仅代表了构建GenAI服务时可用工具的一小部分。您现在应该对如何利用Pydantic注解自己的服务以提高其可靠性和开发者体验更加自信。

总结

在本章中,您学习了为GenAI模型创建完全类型化服务的重要性。您现在了解了如何使用标准类型和受限类型实现类型安全,如何使用Pydantic模型进行数据验证,以及如何在GenAI服务中实现自定义数据验证器。您还发现了验证请求和响应内容的策略,并学习了如何使用Pydantic管理应用设置以防止错误并改善开发体验。总体而言,通过跟随实践示例,您学会了如何实现一个健壮且错误率低的GenAI服务。

下一章将介绍AI工作负载中的异步编程,讨论性能和并行操作。您将了解更多关于I/O绑定任务和CPU绑定任务的内容,并理解FastAPI的后台任务在并发工作流中的角色和局限性。