我一直想找一个框架,能把类型约束从工具参数一路管到模型输出。LLM 的输出不可靠,这个没人怀疑。我的假设是:如果在输出这一端加一道 Pydantic 校验,越界的值能不能当场拦住,而不是等它流进下游数据库才炸。这个假设对不对?我把 pydantic-ai 装了一遍来验证。

pip 卡了十二分钟,uv 几秒装完

先装。第一遍我用 pip。python3 -m venv venv 建好环境,pip install pydantic-ai 一跑,卡了十二分钟没装完。日志停在 Attempting to resume incomplete download (524 kB/2.0 MB, attempt 3)。这句是本机当场吐的。它反复重试,一次都没成。

我当时判断是网络对 PyPI 的下载不稳。这个判断对不对,我用第二个工具验了一下。换 uv,uv pip install --python venv/bin/python pydantic-ai,几秒钟装完。装出来的是 pydantic_ai 2.67.0。这个版本号是我用 import pydantic_ai 打出来的,不是猜的。

pip 和 uv 差这么多,不是网络突然好了,是 uv 的下载和缓存机制不一样。教训只有一句。装 Python 包,能上 uv 就别用 pip 干等。这个结论是我用十二分钟加一条断流报错换来的。

test 模型不用 key

它有个内置的 test 模型,不用 API key 就能跑。这对我这种只想验证类型逻辑的人太重要了,省掉一笔调用费。官方文档管它叫 TestModel,说它不是真模型,只是一段程序化的 Python 代码,按你注册工具的 JSON schema 去生成能通过校验的数据。我写的第一段代码是这样:

from pydantic import BaseModel, Field
from pydantic_ai import Agent

class Sentiment(BaseModel):
    label: str
    score: float = Field(ge=-1, le=1)

agent = Agent('test', output_type=Sentiment)
result = agent.run_sync('这句话是正面还是负面?')
print(result.output)

跑出来的输出是 label='a' score=-1.0。这里有两个信息。test 模型确实不需要 key。score 被压进了负一到正一的区间,正好落在边界负一点零。Field(ge=-1, le=1) 这条约束在输出端是生效的,不是摆设。

label 是 'a',这个值让我愣了一下。我以为它会填 positive 或者 negative 这种像样的词。结果它只填了个字符 'a'。回头看文档才明白,test 模型不在乎语义,它只保证填出来的值能过 JSON schema 校验。这个预期和实际的落差,正好说明它测的是类型,不是内容。

工具和依赖注入

输出约束只是其中一端。真正把类型贯穿起来的是工具和依赖注入。我又写了一段:

from dataclasses import dataclass
from pydantic_ai import Agent, RunContext

@dataclass
class Deps:
    db: dict

class Verdict(BaseModel):
    action: str
    confidence: float = Field(ge=0, le=1)

agent = Agent('test', deps_type=Deps, output_type=Verdict)

@agent.tool
def lookup_order(ctx: RunContext[Deps], order_id: str) -> str:
    # 按 order_id 查订单状态
    return ctx.deps.db.get(order_id, 'not_found')

agent.run_sync 带上 deps=Deps(db={'A-1': 'shipped'}) 之后,输出类型是 Verdict,值长这样:action='a' confidence=0.0。工具 lookup_order 收到 RunContext[Deps],ctx.deps.db 里那条记录被正确读到。@agent.tool 的函数签名和 docstring 会自动变成工具 schema,参数在进函数之前先被校验一遍。

这一点跟直接塞一个全局变量进去的写法很不一样。依赖是显式传进来的。测试时想换一个 fake db,就换一个 Deps 实例,不用改函数本身。我写惯了全局单例,看到这种显式注入,第一反应是啰嗦,用起来才发现好处在测试那一侧。

越界的值会被拦

最能说明问题的,是我故意让模型返回一个越界的值。我用 FunctionModel 塞了一个 confidence=2.0,超出了 Field(le=1):

def bad_model(messages, info):
    return ModelResponse(parts=[TextPart('{"action":"ship","confidence":2.0}')])

agent2 = Agent(FunctionModel(bad_model), output_type=Verdict)
agent2.run_sync('随便')

这一步没跑过去。报错是 UnexpectedModelBehavior Exceeded maximum output retries (1)。confidence=2.0 超了 le=1。pydantic-ai 没让它静默通过。它把这次输出当异常,重试一次之后还是非法,就抛出来。默认重试一次这个数,是从报错文本里读的,不是我编的。

这个行为正好回答了我开头的问题。越界的值确实会被拦在输出这一层,不会流到下游。FunctionModel 是个纯函数,我让它返回什么它就返回什么,没有模型的随机性,所以这个拦截是确定性的,谁跑都一样。这是这篇里我最想验证的一点,它过了。

代价和分界

类型约束不是免费的。第一,output_type 走结构化输出,模型要按 schema 生成,token 比纯文本多。第二,schema 太复杂会反过来害你。字段嵌套太深、约束太严,模型填错一次就触发重试,重试一次还错就抛异常,这个异常你得上层接住。第三,test 模型生成的是 label='a' 这种无意义数据,只能测流程能不能跑通,测不出模型答得好不好。后者要接真实模型再上 evals,这是另一条线。还有一件事是我从报错里读出来的。output retries 这个重试次数是可以调的,默认是 1。模型一次填不对就该立刻失败,把它调成 0 也行。反过来模型偶尔抽风,填错一次第二次能填对,那保留 1 甚至调到 2 更稳。这个默认值是从报错文本和官方文档里拼出来的,不是拍脑袋。

我的判断是:output_type 和 Field 约束适合守输出边界,不适合替代评测。前者拦格式和范围,后者量内容质量,两件事别混。

给个分界。你的 agent 输出要直接写库、写接口、被别的服务消费,那 output_type 这道校验是刚需,越界的值早拦早好。如果输出只是给人看的一段文本,或者你还没想清楚输出长什么样,硬套一个 BaseModel 反而添乱。类型约束省下的坑,主要在系统边界这一层,不在闲聊这一层。