Pydantic AI 结构化输出与评估 · coles.codes

Reddit r/LocalLLaMA 工具

摘要

关于使用 Pydantic AI 和 Pydantic Evals 确保 LLM 结构化输出的详细指南,涵盖结构验证、内容正确性和开放式判断。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/14 10:21

# Pydantic AI 结构化输出与 Bedrock 上的评估 来源:https://coles.codes/posts/pydantic-evals 当大语言模型首次在生产环境中出问题时,通常并非戏剧性的。它返回了有效的 JSON,你的解析器很高兴,然后下游三个服务突然崩溃,因为 `passengers` 返回的是字符串 `"two"` 而不是整数 `2`。没有抛出异常,没有记录错误——形状正确但含义错误,而你花了一个下午去弄清楚到底是哪一类问题。 那个下午我经历过不止一次,这大致就是为什么我现在认为分阶段信任模型是值得的。首先锁定返回内容的形状,然后判断形状内的内容是否合格,最后找到一种判断开放式部分的方法——那些无法通过精确匹配断言覆盖的部分。 Pydantic AI (https://ai.pydantic.dev/) 和 Pydantic Evals (https://ai.pydantic.dev/evals/) 覆盖了整个范围,而且许多组件在过去几个月内已经发布或成熟,因此值得从头到尾走一遍。 `` 1. 形状 2. 内容 3. 判断 结构化输出 ──► Pydantic Evals ──► LLM 作为裁判 (约束解码) 用例 + 校准过的 评估器 与自有标签对比 ─────────── ────────────── ───────────── 形状是否正确? 内容是否正确? 开放式文本是否合格? `` ## 为什么结构化输出优于原始 JSON (https://coles.codes/posts/pydantic-evals#why-structured-outputs-beat-raw-json) 从模型中获取结构化数据过去意味着几种 hacky 方式,而且所有方式都有漏洞。 第一种是“提示并祈祷”。你要求 JSON,得到的却是包裹在 Markdown 围栏中的看似有效的 JSON,或者前面带着一句欢快的前言,或者末尾有个逗号导致解析器崩溃。于是你写解析步骤、重试循环、一个去除围栏的小函数——最终你拥有了大量胶水代码,其唯一使命就是为模型道歉。 JSON 模式是次优选择。它保证输出句法上有效的 JSON,这固然更好,但门槛很低。JSON 模式无法阻止模型返回错误的字段、错误的类型,或者当你请求字符串时返回数字。你仍需验证,仍会遇到失败,只不过问题被推到了下游一步。 然后是工具调用技巧:你定义一个函数,其参数是你的模式,然后让模型“调用”它。这可行,但存在语义错配——你把工具调用管道扭曲成数据提取任务,并且为原本不是工具的东西承担了工具调用的开销。 结构化输出在解码器层面解决了这个问题——这正是应该解决的地方。提供商根据你的模式编译一个文法,限制令牌生成过程,从而保证输出能够解析为模式,而不仅仅是“通常能”。这消除了重试循环、去围栏的胶水代码,以及整个类别的 bug——例如下游系统因返回 `null` 而非 `0` 的字段而崩溃。你要求一个形状,就能得到它。 ## Pydantic AI 如何适配 (https://coles.codes/posts/pydantic-evals#how-pydantic-ai-fits-in) 你把一个 `BaseModel` 作为 `output_type` 传递给它,它派生 JSON 模式,驱动提供商的结构化输出模式,并将响应直接验证回类型化对象。你获得解码器级别的保证,以及一个真正的 Python 对象:类型安全、IDE 自动补全、还有你自己的字段验证器作为第二道防线。模型提供正确的形状,Pydantic 提供正确的对象,两者之间的接缝几乎消失。 如果你曾经手写 `try/except json.loads` 并配上重试计数器,这种差距怎么强调都不过分——样板代码不是减少,而是彻底消失。以下是“以前”的版本(我写过不止一次): `` raw = call_model(prompt) try: booking = Booking(**json.loads(strip_fences(raw))) except (json.JSONDecodeError, ValidationError): raw = call_model(prompt + "\n\nReturn ONLY valid JSON.") # 再次友好请求 booking = Booking(**json.loads(strip_fences(raw))) # 并祈祷 `` 而“以后”的版本: `` agent = Agent("bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0", output_type=Booking) booking = agent.run_sync(prompt).output # 已经是类型化的 Booking,有保证 `` 所有胶水代码消失了,保证被移到了解码器层面——它本应所在之处。 如果你像我一样是 AWS 原生用户,那么值得了解的最新动态是:这不再是 Bedrock 上的客户端技巧。 AWS 于 2026 年 2 月 4 日在 Amazon Bedrock (https://aws.amazon.com/about-aws/whats-new/2026/02/structured-outputs-available-amazon-bedrock) 上原生推出了结构化输出,其机制正如我上面所述:用于模式合规的约束解码 (https://aws.amazon.com/blogs/machine-learning/structured-outputs-on-amazon-bedrock-schema-compliant-ai-responses),无需提示工程或额外的检查。有两种接入方式:为响应格式定义 JSON 模式,或者使用严格的工具定义,使模型的工具调用与你的规范匹配。该功能面向 Anthropic 的 Claude 4.5 模型以及一组开源模型通用可用,支持 Converse、ConverseStream、InvokeModel 和 InvokeModelWithResponseStream API (https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html),并于 2026 年 4 月 1 日覆盖 GovCloud (US) (https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-bedrock-structured-outputs-govcloud)。 在此之前,Converse API 让你不得不使用函数调用作为变通方案——到处都存在同样的语义错配。现在,保证来自 Bedrock 本身。对于运行在 Bedrock 上的 Pydantic AI 技术栈,约束解码在服务端进行,Pydantic AI 只负责验证返回的结果,中间没有任何伪造。 ## 有效的形状不等于好的内容 (https://coles.codes/posts/pydantic-evals#a-valid-shape-doesnt-mean-good-content) 模式保证告诉你响应是格式良好的。但它对内容是否正确、相关或真实一无所知。模型可以交给你一个精美的类型化对象,里面装满了自信满满的胡说八道,而你的验证器会毫不犹豫地放行——因为每个字段都是你要求的那个类型。 这就是评估(evals)要填补的空白,而 Pydantic Evals 正是为此设计的框架。 ## 三个核心抽象 (https://coles.codes/posts/pydantic-evals#the-three-core-abstractions) 该框架基于三个抽象,你大约五分钟就能记住。 **Case** 是一个测试:一个输入、一个可选的预期输出、以及一些元数据。可以把它看作测试表中的一行。**Dataset** 是一组放在一起运行的 Case。**Evaluator** 是检查输出并判断通过/失败,或者返回分数的东西。 你编写一个封装 agent 的 `task` 函数,调用 `dataset.evaluate(task)`,它会运行每个 Case,应用评估器,并返回包含通过率和分数的报告。如果你以前写过参数化的 pytest,这些都不会让你困惑——这种熟悉感是它最好的地方。 ## 免费获得的评估器 (https://coles.codes/posts/pydantic-evals#the-evaluators-you-get-for-free) 有几个评估器内置在框架中,它们覆盖的范围比你可能预期的更广: - `EqualsExpected`、`Equals`、`Contains` 用于精确匹配和子串匹配,适用于答案确实确定的情况。 - `IsInstance` 检查输出是否是正确的 Pydantic 模型或类型,因此可以直接插入已经验证形状的技术栈。 - `MaxDuration` 提供延迟预算。一个正确答案如果花了九秒钟,在生产中仍然是失败。 - `LLMJudge` 接受一个评分标准,并用一个 LLM 对输出评分——要么二值通过/失败,要么 0 到 1 的分数,并可切换裁判模型。 自定义评估器只是一个返回 bool、int/float 或字符串的类。bool 是断言,数字是分数,字符串是标签。这就是整个契约,足以构建内置评估器未覆盖的任何东西。 合在一起,一个小型测试套件读起来正如你所期望的: `` from pydantic_evals import Case, Dataset from pydantic_evals.evaluators import IsInstance, LLMJudge dataset = Dataset( cases=[ Case( name="refund_within_window", inputs="bought this yesterday, I want my money back", ), ], evaluators=[ IsInstance(type_name="SupportReply"), LLMJudge(rubric="Reply approves the refund and stays civil about it"), ], ) report = dataset.evaluate_sync(handle_ticket) report.print(include_input=True, include_output=True) `` 形状检查和判断检查并列存在——这正是关键所在。一个断言响应是正确的东西,另一个询问它是否合格。 ## 处理非确定性 (https://coles.codes/posts/pydantic-evals#handling-non-determinism) 两次运行相同的提示可能会得到两个不同的答案,即使在温度为零的情况下——因为推理栈并非批次不变的 (https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/),而且你无法控制托管 API 上的批次大小。单次通过/失败几乎说明不了什么。 Pydantic Evals 通过 `repeat` 参数处理这个问题。设置 `repeat=5`,每个 Case 运行五次,你会得到一个通过率而不是一个不可靠的单一结果。这一个标志就是用于区分你可以信任的测试套件和某个周二莫名变红(且无人能重建原因)的测试套件的关键。此外,还提供了基于 Tenacity 的重试处理来处理临时故障,这样速率限制不会导致整个运行失败。 有意选择你的通过率阈值。你几乎从不想要 100%。你想要一个反映用户能容忍多少变化以及失败实际上有多糟糕的数字。这既是产品决策也是技术决策,值得比简单地追求整数的思考更多。 ## LLM 作为裁判 (https://coles.codes/posts/pydantic-evals#llm-as-judge) `LLMJudge` 是这里变得有用的部分,因为大多数真实输出无法通过精确匹配来评分。摘要是否忠实于来源?语气是否恰当?它是否回答了提出的问题?这些需要判断,而第二个模型可以提供这种判断。这就是 LLM-as-judge 模式,也是结构化输出和基本评估之后的自然步骤。 一个 LLM 裁判在你还未验证它与人类一致之前是毫无价值的。首选二值通过/失败,而不是 1 到 5 的评分——因为没有人能说清 3 分和 4 分的区别,而且你的评审员也不会就这一点达成一致。使用一个强大的裁判模型,最好与你要评分的模型来自不同的系列,这样你就不仅仅是在衡量模型对自己输出的偏爱。并在信任它产生的任何数字之前,对照你自己的标签进行校准 (https://hamel.dev/blog/posts/llm-judge/)。如果裁判和人类一半时间不一致,那么分数几乎无法传达关于质量的信息。 使用得当,它是对结构化输出无法触及的响应部分的质量关卡。使用懒散,它只是演戏,框架无法区分差异。它提供了机制,校准取决于你。我在构建 lgtmaybe (https://coles.codes/posts/building-lgtmaybe/) 时走了很多弯路——当时一个未校准的评审员产生了自信但没人能信任的结论。 ## 对追踪进行断言 (https://coles.codes/posts/pydantic-evals#asserting-on-the-trace) 安装了 Logfire (https://logfire.pydantic.dev/docs/) 插件后,Pydantic Evals 会在任务运行时捕获 OpenTelemetry spans。然后 `HasMatchingSpan` 让你可以对追踪本身进行断言:调用了哪些工具、按什么顺序、使用什么参数。不仅仅是最终答案是否正确,还包括 agent 是否走了一条合理的路径来得到它。 `` Case 输入 │ ▼ task 函数 ──► Agent 运行 ─┬──► 工具调用: search ──┐ ├──► 工具调用: fetch ──┤ └──► 最终输出 │ │ │ ┌────────────────┘ │ ▼ ▼ 输出评估器 span 评估器 IsInstance, LLMJudge HasMatchingSpan │ │ └─────────────────┬─────────────────┘ ▼ EvaluationReport `` 对于任何 agent 化的东西——MCP 工具调用或多步检索——仅输出评估会不断向你撒谎。一个 agent 可能通过完全破坏的轨迹偶然得到正确答案,而你直到遇到一个运气耗尽的输入时才会知道。Span 评估捕获了这类失败,我会将它添加到任何我真正要发布的 agent 中。 ## 它不做什么 (https://coles.codes/posts/pydantic-evals#what-it-doesnt-do) 它是一个回归测试框架,而不是一个平台,有必要指出它的不足,以免你寻找不存在的东西。 它没有内置的指标库,没有 G-Eval,没有开箱即用的类似 RAGAS 的忠实度评分。没有红队测试。没有内置的 pass@k、置信区间或统计显著性测试。裁判偏差缓解是手动的。文档诚实地指出 LLM 裁判存在长度和风格偏差,缓解措施由你自己实施:温度为零、多个裁判、以及对照人工标签进行验证。 我对此没有任何不满——它是代码优先、类型安全、Pydantic 原生、后端无关的,而且库本身是免费的。Logfire 是可选的付费后端,如果你需要追踪落地的位置。如果你需要更广泛的指标库或生产仪表板,可以与 DeepEval (https://deepeval.com/) 或 Langfuse (https://langfuse.com/) 搭配使用,而不是期望 Pydantic Evals 成为那些东西。它只做一件事:将随机输出转化为一个你可以用来控制合并的数字,并且它不会把一个平台拖进你的仓库。 ## 这留给你什么 (https://coles.codes/posts/pydantic-evals#where-this-leaves-you) 因此,这幅图景是连贯的。结构化输出——现在在 Bedrock 上是原生的——保证了形状。Pydantic AI 将那个形状转化为你可以实际工作的类型化对象。Evals 告诉你形状内的内容是否合格。而 LLM 作为裁判,在你校准之后,对精确匹配永远无法企及的开放式部分进行评分。 如果你已经在 Pydantic AI 上做好了验证,这些都不是重写。它是在同一条道路上的接下来的几步,而且大多数步骤每步只需要几行代码。

相似文章

解密 AI Agent 的评测方法

Anthropic Engineering

Anthropic 发布了一份指南,介绍如何为 AI Agent 设计严谨的自动化评测方案,重点解决了多轮交互和状态修改带来的复杂性挑战。