AB
AiBoss
Tutorials

instructor 教程:用 Pydantic 模型约束 LLM 结构化输出

Tutorials

instructor 教程:用 Pydantic 模型约束 LLM 结构化输出

调用大模型时,即使明确要求返回 JSON,也常遇到代码块包裹、字段名漂移、数字字段塞进自然语言等问题。instructor 把「解析输出」变成「声明数据结构」:用 Pydantic 模型定义想要的类型,由库负责生成 schema、校验结果并在失败时自动重试。本文从安装、模型定义、客户端包装讲到批量抽取与自定义校验器。

调用大语言模型时,即使提示词里明确写了「请用 JSON 返回」,实际拿到的结果仍然经常出问题:被 markdown 代码块包了一层、字段名被悄悄翻译成了另一种语言、本该是数字的字段里塞进了「偏高」这样的自然语言。于是代码里堆满了 json.loads 加 KeyError 的防御逻辑,模型每升一次版本,行为可能又变一次。

instructor 要解决的就是这个循环。它不要求你写「解析 LLM 输出」的代码,而是让你只声明「我想要的数据结构长什么样」。你定义一个 Pydantic 模型,把它作为 response_model 传给调用,拿回来的直接就是模型实例,而不是字典。字段约束、嵌套校验、失败重试都由库在背后处理。本文按实际使用顺序,从环境准备讲到批量抽取和自定义校验器。

准备工作

环境与依赖

instructor 是 Python 库,依赖 Pydantic v2。建议在虚拟环境里安装,不要装到全局环境:

python -m venv .venv
source .venv/bin/activate
pip install "instructor>=1.13.0" anthropic

Windows 下激活虚拟环境的命令是 .venv\Scripts\activate。上面同时安装了 anthropic,因为本文以 Anthropic 客户端为例;如果你用的是其他厂商的客户端,把对应的 SDK 换成它即可,instructor 对多家客户端都提供了包装函数。

版本号、支持的客户端列表、各模式的可用性都会随发布变化,安装前请以官网当前信息为准。

API 密钥

调用模型需要对应厂商的 API 密钥。以 Anthropic 为例,密钥通常通过环境变量提供:

export ANTHROPIC_API_KEY="你的密钥"

不要把密钥硬编码进源码或提交到版本库。密钥的申请方式、可用地区、计费方式请以厂商官网当前信息为准。

需要先想清楚的一件事

在动手写代码之前,先确定你要抽取或生成的数据结构。instructor 的核心思路是「类型定义即提示词」:你在字段上写的 description 会被自动嵌入到发给模型的提示中,字段的类型和取值范围会变成模型的约束条件。所以模型设计得越清楚,输出越稳定。这一步值得多花几分钟。

操作步骤

第一步:用 Pydantic 模型声明想要的类型

先定义一个描述任务的模型:

from pydantic import BaseModel, Field

class TaskItem(BaseModel):
    title: str = Field(description="任务标题(30 字以内)")
    priority: int = Field(ge=1, le=5, description="优先级 1〜5")
    due_date: str | None = Field(default=None, description="截止日期,YYYY-MM-DD 格式")

这里有三个要点:

  • 类型即约束。priority 声明为 int,模型返回字符串数字时会被转换,返回「偏高」这类无法转换的内容时会触发校验失败。
  • Field 的 description 会进入提示词。写「优先级 1〜5」比写「优先级」更能约束取值范围,写「YYYY-MM-DD 格式」能显著减少日期格式漂移。
  • 可选字段用默认值表达。due_date 声明为 str | None 并给 None 默认值,模型在信息不足时可以留空,而不是编一个日期出来。

除了 ge / le,Pydantic 还提供 gt、lt、min_length、max_length、pattern 等约束,都可以直接写在 Field 里。

第二步:包装 LLM 客户端

用 from_anthropic 把已有的客户端包一层:

import anthropic
import instructor

client = instructor.from_anthropic(anthropic.Anthropic())

包装之后,客户端多出了结构化输出的能力,其余调用方式保持不变。如果你已经有一份现成的 anthropic.Anthropic() 调用代码,只需要改这一行初始化。

第三步:传入 response_model 发起调用

task: TaskItem = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=512,
    messages=[
        {"role": "user", "content": "帮我安排明天之前完成财报审阅的计划"}
    ],
    response_model=TaskItem,
)

print(task.title)      # 财报审阅
print(task.priority)   # 3,是 int 而不是字符串
print(task.due_date)   # 2026-06-05

返回值不是字典,而是 TaskItem 的实例。这意味着 IDE 的补全能用,类型检查器能静态验证,字段拼错会在开发阶段就暴露,而不是等到线上跑出 KeyError。

注意 response_model 是新增参数,model、max_tokens、messages 这些仍然是原客户端的参数,含义不变。

第四步:理解自动重试

如果模型返回了违反约束的值,比如 priority=7,instructor 不会直接把异常抛给你,而是把校验错误信息追加到提示里重新请求:

task: TaskItem = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=512,
    messages=[{"role": "user", "content": "..."}],
    response_model=TaskItem,
    max_retries=3,
)

重试时追加给模型的内容大致是这样的:

Validation Error: priority must be between 1 and 5, got 7. Please correct the output and try again.

也就是说,原本需要手写的「捕获校验异常 → 拼接错误说明 → 重新请求」这套循环,由库代劳了。max_retries 控制重试次数上限,超过之后才会把异常抛出。这个参数值得按业务容忍度设置:抽取类任务可以给 2〜3 次,实时交互场景给太多会明显拉长响应时间。

第五步:用自定义校验器强制业务规则

类型约束只能表达「是整数」「在 1 到 5 之间」这类规则。更复杂的业务逻辑用 @field_validator 表达:

from pydantic import BaseModel, Field, field_validator
from datetime import datetime

class TaskItem(BaseModel):
    title: str
    priority: int = Field(ge=1, le=5)
    due_date: str | None = None

    @field_validator("due_date")
    @classmethod
    def must_be_future(cls, v: str | None) -> str | None:
        if v is None:
            return v
        d = datetime.strptime(v, "%Y-%m-%d").date()
        if d <= datetime.today().date():
            raise ValueError("due_date 请指定未来的日期")
        return v

校验失败时抛出的消息会原样成为反馈给模型的内容。也就是说,你写什么语言的错误消息,模型就会在什么语言的语境下重试——用中文写错误消息,重试提示也是中文的。这一点在实现上是个很实用的副作用。

需要留意的是,校验器里抛出的异常消息会进入提示词,所以不要在里面写入密钥、内部路径、用户隐私等敏感信息。

第六步:一次抽取多条记录

把单个模型包进一个列表模型,就能一次抽取多条:

from typing import List

class TaskList(BaseModel):
    tasks: List[TaskItem] = Field(description="抽取出的任务列表")

result: TaskList = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": "本周作业:周一前写完会议纪要、周四提交报销、周五交报告"
    }],
    response_model=TaskList,
)

for task in result.tasks:
    print(f"[优先级 {task.priority}] {task.title} — {task.due_date}")

列表里的每一项都会走同一套校验和重试逻辑。抽取多条时 max_tokens 要相应放大,否则输出会被截断,导致 JSON 不完整而触发重试。

第七步:显式指定模式(可选)

以 Anthropic 为例,instructor 在底层是把 Pydantic 的 schema 作为 tool_use 传给客户端的,利用 stop_reason: "tool_use" 来确保输出符合 schema。这比单纯依赖 JSON 模式更可靠。

默认情况下用的就是工具调用模式。如果需要在代码里写明,可以这样:

client = instructor.from_anthropic(
    anthropic.Anthropic(),
    mode=instructor.Mode.ANTHROPIC_TOOLS,
)

不同客户端、不同厂商支持的模式名称和可用性不一样,具体有哪些模式、各自适用什么场景,请以官网当前信息为准。

一个完整示例

下面把前面的内容串成一个可以直接运行的脚本,从一段自然语言里抽取出任务列表,并强制截止日期必须是未来:

import anthropic
import instructor
from datetime import datetime
from typing import List
from pydantic import BaseModel, Field, field_validator


class TaskItem(BaseModel):
    title: str = Field(description="任务标题(30 字以内)")
    priority: int = Field(ge=1, le=5, description="优先级 1〜5")
    due_date: str | None = Field(
        default=None, description="截止日期,YYYY-MM-DD 格式"
    )

    @field_validator("due_date")
    @classmethod
    def must_be_future(cls, v: str | None) -> str | None:
        if v is None:
            return v
        d = datetime.strptime(v, "%Y-%m-%d").date()
        if d <= datetime.today().date():
            raise ValueError("due_date 请指定未来的日期")
        return v


class TaskList(BaseModel):
    tasks: List[TaskItem] = Field(description="抽取出的任务列表")


client = instructor.from_anthropic(anthropic.Anthropic())

result: TaskList = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": "本周作业:周一前写完会议纪要、周四提交报销、周五交报告",
    }],
    response_model=TaskList,
    max_retries=3,
)

for task in result.tasks:
    print(f"[优先级 {task.priority}] {task.title} — {task.due_date}")

运行前确认 ANTHROPIC_API_KEY 已经设置好。model 参数请替换成你账号当前可用的模型名,模型名和可用性会随时间变化,以官网当前信息为准。

如果某条任务的日期被模型填成了过去的时间,校验器会抛出中文错误消息,instructor 会把它带回给模型重新生成,直到通过或达到 max_retries 上限。

注意事项

为什么只用 JSON 模式不够

厂商提供的 JSON 模式或工具调用确实有用,但在实际项目里会碰到三个限制:

  • 没有类型层面的保证。{"age": "二十岁"} 是合法 JSON,但下游代码会崩。
  • 嵌套校验要手写。十层深的字段结构,每次都用 dict["key"] 一层层取,既啰嗦又容易漏。
  • 重试逻辑要自己实现。每个项目都写一遍「校验失败就重新请求」,是明显的重复劳动。

instructor 结合 Pydantic v2 把这三件事一起解决:类型保证来自模型定义,嵌套校验由 Pydantic 递归完成,重试由库自动执行。

把 LLM 输出当作不可信数据源

这是使用时最需要建立的心态。模型输出和外部 API 的响应没有本质区别,都必须在系统边界上做校验。instructor 的价值在于,这份校验同时充当了给模型的指令书——你写的约束越明确,模型越容易一次通过。

常见坑

  • max_tokens 太小。输出被截断会导致 JSON 不完整,进而触发重试,看起来像是模型不听话,实际是长度不够。抽取多条记录时尤其要注意。
  • description 写得含糊。字段说明是提示词的一部分,写「日期」和写「截止日期,YYYY-MM-DD 格式」的效果差别很大。
  • 校验器里放敏感信息。错误消息会进入提示词并可能被记录,不要写入密钥或隐私数据。
  • max_retries 设得过高。每次重试都是一次完整的模型调用,既增加延迟也增加费用。建议配合超时和降级逻辑使用。
  • 把校验器当业务逻辑层。校验器适合表达「这个字段必须满足什么条件」,复杂的业务规则仍应放在应用层。
  • 忽略可选字段。信息不足时给模型留一个 None 的出口,比逼它编造内容更安全。

关于版本与费用

instructor 的版本迭代较快,支持的客户端、模式名称、参数默认值都可能变化。本文示例中的版本号、模型名、参数取值仅作演示,实际使用前请以官网当前信息为准。API 调用的计费方式、配额限制、可用地区同样以厂商官网当前信息为准。

设计建议

把 Pydantic 模型当作接口契约来设计:字段名用业务语言,description 写清楚格式和取值范围,能用类型约束表达的就不要留给校验器。模型定义得越细致,输出越稳定,上层代码也越简单。一旦习惯了这种开发方式,再回去和 json.loads 与 KeyError 缠斗就很难接受了。