评估驱动的系统设计:从原型到生产

本教程面向ML/AI工程师,以收据解析为例,展示如何将评估作为核心流程,从原型迭代至生产级系统。内容涵盖问题定义、数据收集、V0系统构建、评估设计、业务指标映射、系统改进及部署监控。强调在有限数据下,通过评估驱动决策,平衡成本与性能,并提供了完整的代码示例和实操步骤。

技术背景

在构建LLM应用时,开发者常面临数据稀缺、需求模糊和性能评估主观等问题。传统开发依赖直觉和猜测,难以客观衡量系统质量。评估驱动的开发方法通过定义量化指标,将工程严谨性引入AI系统构建,使团队能基于数据做出决策,优化成本与性能。本教程以收据解析为例,展示如何从少量标注数据出发,逐步构建并优化一个生产级系统,解决实际业务问题。

核心原理

核心原理是将评估作为开发流程的中心,通过定义评分器(grader)来量化系统输出质量。评分器类型包括字符串检查、文本相似度和模型评分,可针对结构化输出的特定字段或整体输出进行评判。评估数据包含输入、真实输出和预测输出,通过模板变量注入评分器。系统性能通过评估分数反映,并映射到业务指标(如成本),以指导改进优先级。

架构分析

系统采用两步流水线:第一步使用视觉语言模型(如o4-mini)从收据图像提取结构化信息(ReceiptDetails),第二步使用语言模型基于提取的信息做出审计决策(AuditDecision)。两个步骤均通过OpenAI Responses API调用,使用Pydantic模型定义输出结构。评估框架使用OpenAI Evals,支持自定义数据源和评分器,可灵活集成到开发流程中。

完整实战教程

概述

本指南的目的

本指南提供了一份实用的端到端教程,介绍如何有效地使用评估(evals)作为核心流程,创建一个生产级的自主系统,以替代劳动密集型的人工工作流程。它直接源于处理项目的协作经验,在这些项目中,用户可能没有从完美的标注数据或对问题的完全理解开始——这两个问题大多数教程都避而不谈,但在实践中几乎总是严峻的挑战。

将评估作为核心流程可以避免“戳一下希望一下”的猜测和对准确性的主观判断,而是要求工程严谨性。这意味着我们可以就成本权衡和投资做出原则性的决策。

目标读者

本指南面向ML/AI工程师和解决方案架构师,他们正在寻找超越入门教程的实用指导。本笔记本完全可执行,并尽可能模块化组织,以支持直接在您自己的应用程序中使用代码示例。

引导性叙事:从小种子到生产系统

我们将遵循一个现实的故事线:用自动化系统替换用于验证费用的手动收据分析服务。

* 从小处着手: 从一小部分标注数据(零售收据)开始。许多企业没有良好的真实数据集合。

* 增量构建: 开发一个最小可行系统,并建立初始评估。

* 业务对齐: 在业务KPI和美元影响的背景下评估评估性能,并针对努力方向,避免在低影响改进上工作。

* 评估驱动的迭代: 通过使用评估分数来推动模型改进,然后使用更好的模型在更多数据上扩展评估并识别更多改进领域,从而迭代改进。

如何使用本指南

本指南以评估为中心,贯穿构建LLM应用的生命周期。

  1. 如果您主要对提出的想法感兴趣,请阅读文本并浏览代码。
  2. 如果您是因为其他正在进行的项目而来,可以直接跳到相关部分,深入研究那里的代码,复制并根据您的需求进行调整。
  3. 如果您想真正理解这一切是如何运作的,请下载此笔记本,并在阅读时运行单元格;编辑代码以进行自己的更改,测试您的假设,并确保您真正理解所有部分是如何协同工作的。
注意:如果您的OpenAI组织有零数据保留(ZDR)政策,Evals仍然可用,但会保留数据以维护应用程序状态。

用例:收据解析

为了精简本指南,我们将使用一个小型假设问题,该问题仍然足够复杂,值得进行详细和多方面的评估。特别是,我们将专注于如何在有限数据量的情况下解决问题,因此我们使用的数据集非常小。

问题定义

在本指南中,我们假设从一个用于审核和归档收据的工作流程开始。虽然一般来说,这个问题已经有很多成熟的解决方案,但它与其他没有那么多先前工作的问题类似;此外,即使存在良好的企业解决方案,通常仍然存在需要人工时间的“最后一英里”问题。

在我们的案例中,我们假设有一个管道,其中:

* 人们上传收据照片

* 会计团队审核每张收据,以分类并批准或审计费用

根据与会计团队的访谈,他们基于以下因素做出决定:

  1. 商家
  2. 地理位置
  3. 费用金额
  4. 购买的商品或服务
  5. 手写笔记或注释

我们的系统应能处理大多数收据而无需人工干预,但会将低置信度的决策升级给人工QA。我们将专注于降低会计流程的总成本,这取决于:

  1. 之前/当前系统每张收据的运行成本
  2. 新系统发送给QA的收据数量
  3. 系统每张收据的运行成本,加上任何固定成本
  4. 错误对业务的影响,无论是被踢出审核的收据还是漏掉的错误
  5. 开发和集成系统的工程成本

数据集概述

收据图像来自CC by 4.0许可的收据手写检测计算机视觉项目数据集,由Roboflow发布。我们添加了自己的标签和叙述性描述,以便用少量示例讲述一个故事。

项目生命周期

并非每个项目都会以相同的方式进行,但项目通常有一些重要的共同组成部分。

Project Lifecycle实线箭头表示主要的进展或步骤,而虚线表示问题理解的持续性质——对客户领域的更多了解将影响流程的每一步。下面我们将详细考察几个这样的迭代优化循环。

并非每个项目都会以相同的方式进行,但项目通常有一些共同的重要组成部分。

1. 理解问题

通常,启动工程流程的决定是由了解业务影响但不需要知道流程细节的领导层做出的。在我们的例子中,我们正在构建一个旨在取代非AI工作流程的系统。从某种意义上说,这是理想的:我们有一组领域专家,*即当前正在执行该任务的人*,我们可以采访他们以了解任务细节,并依靠他们来帮助开发适当的评估。

这一步不会在我们开始构建系统之前结束;我们的初步评估总是对问题空间的不完整理解,随着我们接近解决方案,我们将继续完善我们的理解。

2. 收集示例(收集数据)

在现实世界的项目中,很少能在开始时拥有所有必要的数据来实现令人满意的解决方案,更不用说建立信心了。

在我们的案例中,我们假设我们有相当数量的系统*输入*样本,以收据图像的形式,但开始时没有任何完全标注的数据。我们发现,在自动化现有流程时,这种情况并不少见。我们将逐步与领域专家合作,逐步扩展我们的测试和训练集,并逐步使我们的评估更加全面。

3. 构建端到端的V0系统

我们希望尽快构建系统的骨架。我们不需要一个性能良好的系统——我们只需要一个接受正确输入并提供正确类型输出的系统。通常,这几乎就像在提示中描述任务、添加输入并使用单个模型(通常具有结构化输出)进行初步尽力尝试一样简单。

4. 标注数据并构建初始评估

我们发现,在没有既定真实数据的情况下,使用早期版本的系统生成“草稿”真实数据并不罕见,这些数据可以由领域专家进行标注或纠正。

一旦我们构建了端到端系统,我们就可以开始处理我们拥有的输入以生成合理的输出。我们将把这些输出发送给我们的领域专家进行评分和纠正。我们将利用这些纠正以及关于专家如何做出决策的对话来设计进一步的评估,并将专业知识嵌入系统中。

5. 将评估映射到业务指标

在我们开始纠正每个错误之前,我们需要确保我们有效地投入时间。在这个阶段,最关键的任务是审查我们的评估,并了解它们如何与我们的关键目标相关联。

6. 逐步改进系统和评估

在确定了哪些工作最值得投入之后,我们就可以开始对系统进行迭代改进了。评估作为客观指南,帮助我们了解何时系统已经足够好,并确保我们避免或识别性能回退。

7. 整合QA流程与持续改进

评估不仅仅用于开发。对生产服务的全部或部分进行监控,会随着时间推移浮现出更多有用的测试和训练样本,从而识别出错误的假设或发现覆盖不足的领域。这也是确保模型在初始开发过程完成后的长期内持续表现良好的唯一途径。

V0 系统构建

在实践中,我们可能会构建一个通过 REST API 运行的系统,可能带有某种 Web 前端,能够访问一组组件和资源。为了本教程的目的,我们将其简化为两个函数:extract_receipt_details 和 evaluate_receipt_for_audit,它们共同决定如何处理给定的收据。

像这样将流程分解为步骤有利有弊;如果流程由小的独立步骤组成,则更容易检查和开发。但您可能会逐渐丢失信息,实际上让您的代理玩“传话游戏”。在本笔记本中,我们分解了步骤,并且不让审计员看到实际收据,因为这对于我们想要讨论的评估更具启发性。

我们从第一步开始,即字面数据提取。这是*中间*数据:它是人们会隐式检查的信息,但通常不会被记录。因此,我们通常没有标记数据可供使用。

%pip install --upgrade openai pydantic python-dotenv rich persist-cache -qqq
%load_ext dotenv
%dotenv

# Place your API key in a file called .env
# OPENAI_API_KEY=sk-...

结构化输出模型

将有意义的信息捕获到结构化输出中。

from pydantic import BaseModel


class Location(BaseModel):
    city: str | None
    state: str | None
    zipcode: str | None


class LineItem(BaseModel):
    description: str | None
    product_code: str | None
    category: str | None
    item_price: str | None
    sale_price: str | None
    quantity: str | None
    total: str | None


class ReceiptDetails(BaseModel):
    merchant: str | None
    location: Location
    time: str | None
    items: list[LineItem]
    subtotal: str | None
    tax: str | None
    total: str | None
    handwritten_notes: list[str]
*注意*:通常我们会使用 decimal.Decimal 对象来表示上述数字,并使用 datetime.datetime 对象来表示 time 字段,但这两者在反序列化时效果不佳。为了本教程的目的,我们将使用字符串,但在实际应用中,你可能需要另一层转换来确保输出得到正确验证。

基本信息提取

让我们构建 extract_receipt_details 函数。

通常,对于可能有效的初步尝试,我们只需将目前收集到的可用文档提供给 ChatGPT,并要求它生成一个提示词。在拥有基准来评估自己之前,不值得在提示工程上花费太多时间!这是 o4-mini 根据上述问题描述生成的提示词。

BASIC_PROMPT = """
Given an image of a retail receipt, extract all relevant information and format it as a structured response.

# Task Description

Carefully examine the receipt image and identify the following key information:

1. Merchant name and any relevant store identification
2. Location information (city, state, ZIP code)
3. Date and time of purchase
4. All purchased items with their:
   * Item description/name
   * Item code/SKU (if present)
   * Category (infer from context if not explicit)
   * Regular price per item (if available)
   * Sale price per item (if discounted)
   * Quantity purchased
   * Total price for the line item
5. Financial summary:
   * Subtotal before tax
   * Tax amount
   * Final total
6. Any handwritten notes or annotations on the receipt (list each separately)

## Important Guidelines

* If information is unclear or missing, return null for that field
* Format dates as ISO format (YYYY-MM-DDTHH:MM:SS)
* Format all monetary values as decimal numbers
* Distinguish between printed text and handwritten notes
* Be precise with amounts and totals
* For ambiguous items, use your best judgment based on context

Your response should be structured and complete, capturing all available information
from the receipt.
"""
import base64
import mimetypes
from pathlib import Path

from openai import AsyncOpenAI

client = AsyncOpenAI()


async def extract_receipt_details(
    image_path: str, model: str = "o4-mini"
) -> ReceiptDetails:
    """Extract structured details from a receipt image."""
    # Determine image type for data URI.
    mime_type, _ = mimetypes.guess_type(image_path)

    # Read and base64 encode the image.
    b64_image = base64.b64encode(Path(image_path).read_bytes()).decode("utf-8")
    image_data_url = f"data:{mime_type};base64,{b64_image}"

    response = await client.responses.parse(
        model=model,
        input=[
            {
                "role": "user",
                "content": [
                    {"type": "input_text", "text": BASIC_PROMPT},
                    {"type": "input_image", "image_url": image_data_url},
                ],
            }
        ],
        text_format=ReceiptDetails,
    )

    return response.output_parsed

测试一张收据

让我们仅评估一张收据,并手动审查它,看看一个智能模型配合简单提示词能表现如何。

from rich import print

receipt_image_dir = Path("data/test")
ground_truth_dir = Path("data/ground_truth")

example_receipt = Path(
    "data/train/Supplies_20240322_220858_Raven_Scan_3_jpeg.rf.50852940734939c8838819d7795e1756.jpg"
)
result = await extract_receipt_details(example_receipt)

如果我们重新运行它,会得到不同的答案,但通常大部分内容是正确的,只有少数错误。这里有一个具体的例子:

walmart_receipt = ReceiptDetails(
    merchant="Walmart",
    location=Location(city="Vista", state="CA", zipcode="92083"),
    time="2023-06-30T16:40:45",
    items=[
        LineItem(
            description="SPRAY 90",
            product_code="001920056201",
            category=None,
            item_price=None,
            sale_price=None,
            quantity="2",
            total="28.28",
        ),
        LineItem(
            description="LINT ROLLER 70",
            product_code="007098200355",
            category=None,
            item_price=None,
            sale_price=None,
            quantity="1",
            total="6.67",
        ),
        LineItem(
            description="SCRUBBER",
            product_code="003444193232",
            category=None,
            item_price=None,
            sale_price=None,
            quantity="2",
            total="12.70",
        ),
        LineItem(
            description="FLOUR SACK 10",
            product_code="003444194263",
            category=None,
            item_price=None,
            sale_price=None,
            quantity="1",
            total="0.77",
        ),
    ],
    subtotal="50.77",
    tax="4.19",
    total="54.96",
    handwritten_notes=[],
)

模型正确提取了很多内容,但错误地重命名了一些行项目——实际上是不正确的。更重要的是,它弄错了一些价格,并且决定不对任何行项目进行分类。

没关系,我们并不期望此时能得到完美的答案!相反,我们的目标是构建一个可以进行评估的基础系统。然后,当我们开始迭代时,我们不会靠“感觉”来让某些东西*看起来*更好——我们是在工程化一个可靠的解决方案。但首先,我们将添加一个动作决策来完成我们的草稿系统。

行动决策

接下来,我们需要闭环,基于收据做出实际决策。这看起来非常相似,因此我们将直接展示代码,不做过多解释。

通常,我们会从能力最强的模型开始——目前是 o3——进行首次尝试,然后在正确性得到确认后,尝试不同的模型,分析它们对业务影响的权衡,并考虑是否可以通过迭代来弥补。客户可能愿意为了更低的延迟或成本而接受一定的准确性损失,或者改变架构以同时满足成本、延迟和准确性目标可能更有效。我们稍后将讨论如何明确且客观地进行这些权衡。

对于本教程,o3 可能过于强大。我们将在首次尝试中使用 o4-mini,这样我们可以获得一些推理错误,用来演示当这些错误出现时如何解决。

接下来,我们需要闭环,基于收据做出实际决策。这看起来非常相似,因此我们将直接展示代码,不做过多解释。

from pydantic import BaseModel, Field

audit_prompt = """
Evaluate this receipt data to determine if it need to be audited based on the following
criteria:

1. NOT_TRAVEL_RELATED:
   - IMPORTANT: For this criterion, travel-related expenses include but are not limited
   to: gas, hotel, airfare, or car rental.
   - If the receipt IS for a travel-related expense, set this to FALSE.
   - If the receipt is NOT for a travel-related expense (like office supplies), set this
   to TRUE.
   - In other words, if the receipt shows FUEL/GAS, this would be FALSE because gas IS
   travel-related.

2. AMOUNT_OVER_LIMIT: The total amount exceeds $50

3. MATH_ERROR: The math for computing the total doesn't add up (line items don't sum to
   total)

4. HANDWRITTEN_X: There is an "X" in the handwritten notes

For each criterion, determine if it is violated (true) or not (false). Provide your
reasoning for each decision, and make a final determination on whether the receipt needs
auditing. A receipt needs auditing if ANY of the criteria are violated.

Return a structured response with your evaluation.
"""


class AuditDecision(BaseModel):
    not_travel_related: bool = Field(
        description="True if the receipt is not travel-related"
    )
    amount_over_limit: bool = Field(description="True if the total amount exceeds $50")
    math_error: bool = Field(description="True if there are math errors in the receipt")
    handwritten_x: bool = Field(
        description="True if there is an 'X' in the handwritten notes"
    )
    reasoning: str = Field(description="Explanation for the audit decision")
    needs_audit: bool = Field(
        description="Final determination if receipt needs auditing"
    )


async def evaluate_receipt_for_audit(
    receipt_details: ReceiptDetails, model: str = "o4-mini"
) -> AuditDecision:
    """Determine if a receipt needs to be audited based on defined criteria."""
    # Convert receipt details to JSON for the prompt
    receipt_json = receipt_details.model_dump_json(indent=2)

    response = await client.responses.parse(
        model=model,
        input=[
            {
                "role": "user",
                "content": [
                    {"type": "input_text", "text": audit_prompt},
                    {"type": "input_text", "text": f"Receipt details:\n{receipt_json}"},
                ],
            }
        ],
        text_format=AuditDecision,
    )

    return response.output_parsed

整体流程示意图展示了两次 LLM 调用:

Process Flowchart如果我们用这个模型运行上面的例子,会得到如下结果——同样,这里我们使用一个示例结果。当你运行代码时,可能会得到略有不同的结果。

audit_decision = await evaluate_receipt_for_audit(result)
print(audit_decision)
audit_decision = AuditDecision(
    not_travel_related=True,
    amount_over_limit=True,
    math_error=False,
    handwritten_x=False,
    reasoning="""
    The receipt from Walmart is for office supplies, which are not travel-related, thus NOT_TRAVEL_RELATED is TRUE.
    The total amount of the receipt is $54.96, which exceeds the limit of $50, making AMOUNT_OVER_LIMIT TRUE.
    The subtotal ($50.77) plus tax ($4.19) correctly sums to the total ($54.96), so there is no MATH_ERROR.
    There are no handwritten notes, so HANDWRITTEN_X is FALSE.
    Since two criteria (amount over limit and travel-related) are violated, the receipt
    needs auditing.
    """,
    needs_audit=True,
)

这个例子说明了为什么我们关心端到端评估,以及为什么我们不能孤立地使用它们。在这里,初始提取存在OCR错误,并将价格转发给审计员,这些价格加起来不等于总额,但审计员未能检测到这一点,并断言没有数学错误。然而,遗漏这一点并不会改变审计决定,因为它确实发现了收据需要审计的其他两个原因。

因此,AuditDecision在事实上是不正确的,但我们关心的决定是正确的。这给了我们改进的空间,但也引导我们为在哪里以及何时应用我们的工程努力做出明智的选择。

话虽如此,让我们构建一些评估吧!

初始评估

一旦我们有了一个功能最简的系统,就应该处理更多输入,并让领域专家帮助开发真实数据。领域专家执行专家任务可能没有太多时间投入到我们的项目中,因此我们希望高效起步,先追求广度而非深度。

如果你的数据*不*需要领域专业知识,那么你应该使用标注解决方案(例如 Label Studio),并尝试在政策、预算和数据可用性限制下尽可能多地标注数据。在这种情况下,我们将假设数据标注是一种稀缺资源;我们每周只能依赖少量标注,但这些标注人员还有其他工作职责,他们的时间和帮助意愿可能有限。与这些专家一起标注示例有助于更高效地选择未来的示例。

由于我们有一个两步链,我们将收集类型为 [FilePath, ReceiptDetails, AuditDecision] 的元组。通常,做法是取未标注的样本,通过我们的模型运行,然后让专家纠正输出。就本笔记本而言,我们已经对 data/test 中的所有收据图像完成了这一过程。

其他考虑

不过,事情远不止如此,因为当评估多步骤过程时,了解端到端性能以及每个单独步骤的性能(*以先前步骤的输出为条件*)都很重要。

在这种情况下,我们需要评估:

  1. 给定输入图像,我们提取所需信息的效果如何?
  2. 给定收据信息,我们的审计决策的判断有多好?
  3. 给定输入图像,我们做出最终审计决策的成功率如何?

第2点和第3点的措辞差异是因为如果我们给审计员提供不正确的数据,我们预期它会得出不正确的结论。我们*想要*的是确信审计员基于可用证据做出正确的决策,即使这些证据具有误导性。如果我们不关注这种情况,我们最终可能会训练审计员忽略其输入,导致整体性能下降。

评分器

评估的核心组件是

评分器。我们最终的评估将使用18个评分器,但我们只使用三种类型,而且它们在概念上都非常直接。

以下是我们字符串检查评分器、文本相似度评分器以及模型评分器的示例。

example_graders = [
    {
        "name": "Total Amount Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.total }}",
        "reference": "{{ item.correct_receipt_details.total }}",
    },
    {
        "name": "Merchant Name Accuracy",
        "type": "text_similarity",
        "input": "{{ item.predicted_receipt_details.merchant }}",
        "reference": "{{ item.correct_receipt_details.merchant }}",
        "pass_threshold": 0.8,
        "evaluation_metric": "bleu",
    },
]

# A model grader needs a prompt to instruct it in what it should be scoring.
missed_items_grader_prompt = """
Your task is to evaluate the correctness of a receipt extraction model.

The following items are the actual (correct) line items from a specific receipt.

{{ item.correct_receipt_details.items }}

The following items are the line items extracted by the model.

{{ item.predicted_receipt_details.items }}

Score 0 if the sample evaluation missed any items from the receipt; otherwise score 1.

The line items are permitted to have small differences or extraction mistakes, but each
item from the actual receipt must be present in some form in the model's output. Only
evaluate whether there are MISSED items; ignore other mistakes or extra items.
"""

example_graders.append(
    {
        "name": "Missed Line Items",
        "type": "score_model",
        "model": "o4-mini",
        "input": [{"role": "system", "content": missed_items_grader_prompt}],
        "range": [0, 1],
        "pass_threshold": 1,
    }
)

每个评分器都会评估预测输出的某一部分。这可能是一个非常狭窄的检查,针对结构化输出中的特定字段,或者是一个更全面的检查,对输出进行整体评判。有些评分器可以在没有上下文的情况下工作,并孤立地评估输出(例如,一个LLM评判器评估一段文字是否粗鲁或不恰当)。其他评分器可以根据输入和输出进行评估,而我们在这里使用的评分器则依赖于输出和真实(正确)输出进行比较。

使用Evals最直接的方式是提供一个提示和一个模型,让评估在输入上运行并自行生成输出。另一种有用的方法使用先前记录的响应或完成作为输出的来源。虽然这并不那么简单,但最灵活的做法是提供一个包含我们希望它使用的所有内容的条目——这允许我们将“预测”函数设为任意系统,而不是将其限制为单个模型调用。这就是我们在下面的示例中使用它的方式;下面显示的EvaluationRecord将用于填充{{ }}模板变量。

关于模型选择的说明:
选择正确的模型至关重要。虽然更快、更便宜的模型在生产中通常更可取,但开发工作流程受益于优先使用可用的最强大模型。在本指南中,我们对系统任务和基于LLM的评分都使用o4-mini——虽然o3更强大,但我们的经验表明,输出质量的差异相对于成本的显著增加来说是适中的。在实践中,每位工程师每天在评估上花费10美元以上是典型的,但扩展到每位工程师每天100美元以上可能不可持续。
尽管如此,定期使用更先进的模型(如o3)进行基准测试是有价值的。如果您观察到显著的改进,请考虑将其纳入您的评估数据的代表性子集。模型之间的差异可以揭示重要的边缘情况,并指导系统改进。
import asyncio


class EvaluationRecord(BaseModel):
    """Holds both the correct (ground truth) and predicted audit decisions."""

    receipt_image_path: str
    correct_receipt_details: ReceiptDetails
    predicted_receipt_details: ReceiptDetails
    correct_audit_decision: AuditDecision
    predicted_audit_decision: AuditDecision


async def create_evaluation_record(image_path: Path, model: str) -> EvaluationRecord:
    """Create a ground truth record for a receipt image."""
    extraction_path = ground_truth_dir / "extraction" / f"{image_path.stem}.json"
    correct_details = ReceiptDetails.model_validate_json(extraction_path.read_text())
    predicted_details = await extract_receipt_details(image_path, model)

    audit_path = ground_truth_dir / "audit_results" / f"{image_path.stem}.json"
    correct_audit = AuditDecision.model_validate_json(audit_path.read_text())
    predicted_audit = await evaluate_receipt_for_audit(predicted_details, model)

    return EvaluationRecord(
        receipt_image_path=image_path.name,
        correct_receipt_details=correct_details,
        predicted_receipt_details=predicted_details,
        correct_audit_decision=correct_audit,
        predicted_audit_decision=predicted_audit,
    )


async def create_dataset_content(
    receipt_image_dir: Path, model: str = "o4-mini"
) -> list[dict]:
    # Assemble paired samples of ground truth data and predicted results. You could
    # instead upload this data as a file and pass a file id when you run the eval.
    tasks = [
        create_evaluation_record(image_path, model)
        for image_path in receipt_image_dir.glob("*.jpg")
    ]
    return [{"item": record.model_dump()} for record in await asyncio.gather(*tasks)]


file_content = await create_dataset_content(receipt_image_dir)

一旦我们有了评分器和数据,创建并运行我们的评估就非常简单了:

from persist_cache import cache


# We're caching the output so that if we re-run this cell we don't create a new eval.
@cache
async def create_eval(name: str, graders: list[dict]):
    eval_cfg = await client.evals.create(
        name=name,
        data_source_config={
            "type": "custom",
            "item_schema": EvaluationRecord.model_json_schema(),
            "include_sample_schema": False,  # Don't generate new completions.
        },
        testing_criteria=graders,
    )
    print(f"Created new eval: {eval_cfg.id}")
    return eval_cfg


initial_eval = await create_eval(
    "Initial Receipt Processing Evaluation", example_graders
)

# Run the eval.
eval_run = await client.evals.runs.create(
    name="initial-receipt-processing-run",
    eval_id=initial_eval.id,
    data_source={
        "type": "jsonl",
        "source": {"type": "file_content", "content": file_content},
    },
)
print(f"Evaluation run created: {eval_run.id}")
print(f"View results at: {eval_run.report_url}")

运行该评估后,您将能够在界面中查看它,并应看到类似下面的内容。

(注意,如果您有零数据保留协议,OpenAI 不会存储这些数据,因此该界面中将无法查看。)

类似:

Summary UI您可以深入数据标签页查看单个示例:

Details UI

将评估与业务指标关联

评估能告诉你哪些方面可以改进,并帮助追踪长期进展和回退。但上述三个评估只是度量——我们需要赋予它们存在的理由。

首先,我们需要为收据处理的最后阶段添加评估,以便开始看到审计决策的结果。其次,也是最重要的,我们需要一个*业务相关性模型*。

业务模型

要弄清楚新系统在不同性能水平下能带来多少成本和收益,几乎从来都不容易。人们常常会避免给事情加上数字,因为他们知道其中有多少不确定性,不想做出让自己难堪的猜测。没关系;我们只需做出最佳猜测,如果以后获得更多信息,我们可以完善模型。

对于本教程,我们将创建一个简单的成本结构:

这给了我们两个基线比较:

此外,人工驱动流程额外花费20万美元。

我们期望我们的服务通过降低运行成本来节省资金(如果使用上述提示词配合o4-mini,大约每张收据1美分),但我们在审计和漏审上是省钱还是亏钱,取决于我们系统的性能。这可能值得写成一个简单的函数——下面是一个包含上述因素但忽略细节且不考虑开发、维护和服务成本的版本。

def calculate_costs(fp_rate: float, fn_rate: float, per_receipt_cost: float):
    audit_cost = 2
    missed_audit_cost = 30
    receipt_count = 1e6
    audit_fraction = 0.05

    needs_audit_count = receipt_count * audit_fraction
    no_needs_audit_count = receipt_count - needs_audit_count

    missed_audits = needs_audit_count * fn_rate
    total_audits = needs_audit_count * (1 - fn_rate) + no_needs_audit_count * fp_rate

    audit_cost = total_audits * audit_cost
    missed_audit_cost = missed_audits * missed_audit_cost
    processing_cost = receipt_count * per_receipt_cost

    return audit_cost + missed_audit_cost + processing_cost


perfect_system_cost = calculate_costs(0, 0, 0)
current_system_cost = calculate_costs(0.02, 0.03, 0.20)

print(f"Current system cost: ${current_system_cost:,.0f}")

回到评估

上述模型的意义在于,它让我们能够为原本只是一个数字的评估赋予实际含义。例如,当我们运行上述系统时,商家名称的错误率高达85%。但深入分析后,发现大多数错误是大小写问题或“Shell Gasoline”与“Shell Oil #2144”之间的差异——这些问题在进一步追踪后,似乎不会影响我们的审计决策或改变我们的基本成本。

另一方面,我们似乎有大约一半的时间未能捕捉到收据上手写的“X”,而当收据上有“X”但被遗漏时,大约有一半的情况会导致本应被审计的收据未被审计。这些情况在我们的数据集中占比过高,但如果它们只占收据的1%,那么50%的失败率每年将让我们损失75,000美元。

类似地,由于OCR错误,我们经常因为数学计算不符而审计收据,这种情况高达20%。这可能让我们损失近400,000美元!

现在,我们可以添加更多的评分器,并从审计决策的准确性出发,反向确定我们应该重点关注哪些问题。

以下是其余的评分器以及我们使用初始未优化提示词得到的结果。请注意,此时我们的表现相当糟糕!在我们的20个样本(8个正例,12个负例)中,我们有两个假阴性和两个假阳性。如果将其推广到整个业务,我们将因漏掉的审计损失375,000美元,因不必要的审计损失475,000美元。

simple_extraction_graders = [
    {
        "name": "Merchant Name Accuracy",
        "type": "text_similarity",
        "input": "{{ item.predicted_receipt_details.merchant }}",
        "reference": "{{ item.correct_receipt_details.merchant }}",
        "pass_threshold": 0.8,
        "evaluation_metric": "bleu",
    },
    {
        "name": "Location City Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.location.city }}",
        "reference": "{{ item.correct_receipt_details.location.city }}",
    },
    {
        "name": "Location State Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.location.state }}",
        "reference": "{{ item.correct_receipt_details.location.state }}",
    },
    {
        "name": "Location Zipcode Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.location.zipcode }}",
        "reference": "{{ item.correct_receipt_details.location.zipcode }}",
    },
    {
        "name": "Time Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.time }}",
        "reference": "{{ item.correct_receipt_details.time }}",
    },
    {
        "name": "Subtotal Amount Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.subtotal }}",
        "reference": "{{ item.correct_receipt_details.subtotal }}",
    },
    {
        "name": "Tax Amount Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.tax }}",
        "reference": "{{ item.correct_receipt_details.tax }}",
    },
    {
        "name": "Total Amount Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_receipt_details.total }}",
        "reference": "{{ item.correct_receipt_details.total }}",
    },
    {
        "name": "Handwritten Notes Accuracy",
        "type": "text_similarity",
        "input": "{{ item.predicted_receipt_details.handwritten_notes }}",
        "reference": "{{ item.correct_receipt_details.handwritten_notes }}",
        "pass_threshold": 0.8,
        "evaluation_metric": "fuzzy_match",
    },
]

item_extraction_base = """
Your task is to evaluate the correctness of a receipt extraction model.

The following items are the actual (correct) line items from a specific receipt.

{{ item.correct_receipt_details.items }}

The following items are the line items extracted by the model.

{{ item.predicted_receipt_details.items }}
"""

missed_items_instructions = """
Score 0 if the sample evaluation missed any items from the receipt; otherwise score 1.

The line items are permitted to have small differences or extraction mistakes, but each
item from the actual receipt must be present in some form in the model's output. Only
evaluate whether there are MISSED items; ignore other mistakes or extra items.
"""

extra_items_instructions = """
Score 0 if the sample evaluation extracted any extra items from the receipt; otherwise
score 1.

The line items are permitted to have small differences or extraction mistakes, but each
item from the actual receipt must be present in some form in the model's output. Only
evaluate whether there are EXTRA items; ignore other mistakes or missed items.
"""

item_mistakes_instructions = """
Score 0 to 10 based on the number and severity of mistakes in the line items.

A score of 10 means that the two lists are perfectly identical.

Remove 1 point for each minor mistake (typos, capitalization, category name
differences), and up to 3 points for significant mistakes (incorrect quantity, price, or
total, or categories that are not at all similar).
"""

item_extraction_graders = [
    {
        "name": "Missed Line Items",
        "type": "score_model",
        "model": "o4-mini",
        "input": [
            {
                "role": "system",
                "content": item_extraction_base + missed_items_instructions,
            }
        ],
        "range": [0, 1],
        "pass_threshold": 1,
    },
    {
        "name": "Extra Line Items",
        "type": "score_model",
        "model": "o4-mini",
        "input": [
            {
                "role": "system",
                "content": item_extraction_base + extra_items_instructions,
            }
        ],
        "range": [0, 1],
        "pass_threshold": 1,
    },
    {
        "name": "Item Mistakes",
        "type": "score_model",
        "model": "o4-mini",
        "input": [
            {
                "role": "system",
                "content": item_extraction_base + item_mistakes_instructions,
            }
        ],
        "range": [0, 10],
        "pass_threshold": 8,
    },
]


simple_audit_graders = [
    {
        "name": "Not Travel Related Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_audit_decision.not_travel_related }}",
        "reference": "{{ item.correct_audit_decision.not_travel_related }}",
    },
    {
        "name": "Amount Over Limit Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_audit_decision.amount_over_limit }}",
        "reference": "{{ item.correct_audit_decision.amount_over_limit }}",
    },
    {
        "name": "Math Error Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_audit_decision.math_error }}",
        "reference": "{{ item.correct_audit_decision.math_error }}",
    },
    {
        "name": "Handwritten X Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_audit_decision.handwritten_x }}",
        "reference": "{{ item.correct_audit_decision.handwritten_x }}",
    },
    {
        "name": "Needs Audit Accuracy",
        "type": "string_check",
        "operation": "eq",
        "input": "{{ item.predicted_audit_decision.needs_audit }}",
        "reference": "{{ item.correct_audit_decision.needs_audit }}",
    },
]


reasoning_eval_prompt = """
Your task is to evaluate the quality of *reasoning* for audit decisions on receipts.
Here are the rules for audit decisions:

Expenses should be audited if they violate any of the following criteria:
1. Expenses must be travel-related
2. Expenses must not exceed $50
3. All math should be correct; the line items plus tax should equal the total
4. There must not be an "X" in the handwritten notes

If ANY of those criteria are violated, the expense should be audited.

Here is the input to the grader:
{{ item.predicted_receipt_details }}

Below is the output of an authoritative grader making a decision about whether or not to
audit an expense. This is a correct reference decision.

GROUND TRUTH:
{{ item.correct_audit_decision }}


Here is the output of the model we are evaluating:

MODEL GENERATED:
{{ item.predicted_audit_decision }}


Evaluate:
1. For each of the 4 criteria, did the model correctly score it as TRUE or FALSE?
2. Based on the model's *scoring* of the criteria (regardless if it scored it
   correctly), did the model reason appropriately about the criteria (i.e. did it
   understand and apply the prompt correctly)?
3. Is the model's reasoning logically sound, sufficient, and comprehensible?
4. Is the model's reasoning concise, without extraneous details?
5. Is the final decision to audit or not audit correct?

Grade the model with the following rubric:
- (1) point for each of the 4 criteria that the model scored correctly
- (3) points for each aspect of the model's reasoning that is meets the criteria
- (3) points for the model's final decision to audit or not audit

The total score is the sum of the points, and should be between 0 and 10 inclusive.
"""


model_judgement_graders = [
    {
        "name": "Audit Reasoning Quality",
        "type": "score_model",
        "model": "o4-mini",
        "input": [{"role": "system", "content": reasoning_eval_prompt}],
        "range": [0, 10],
        "pass_threshold": 8,
    },
]

full_eval = await create_eval(
    "Full Receipt Processing Evaluation",
    simple_extraction_graders
    + item_extraction_graders
    + simple_audit_graders
    + model_judgement_graders,
)

eval_run = await client.evals.runs.create(
    name="complete-receipt-processing-run",
    eval_id=full_eval.id,
    data_source={
        "type": "jsonl",
        "source": {"type": "file_content", "content": file_content},
    },
)

eval_run.report_url

Large Summary UI

启动飞轮

有了商业模式,我们就有了地图,知道什么值得做,什么不值得做。我们最初的评估是一个路标,让我们知道我们正朝着正确的方向前进;但最终我们需要更多的路标。在流程的这个阶段,我们通常会有很多不同的事情可以做,其中有几个相互关联的循环,在一个循环上的改进将为另一个循环上的改进打开更多空间。

Development Flywheel1. 我们的评估能指出需要改进的地方,我们可以立即利用它们来指导模型选择、提示工程、工具使用和微调策略。

  1. 一旦系统在评估中表现良好,我们的工作并未结束。那时正是*改进评估*的时候。我们将处理更多数据,交给领域专家审查,并将修正反馈到构建更好、更全面的评估中。

这个循环可能会持续一段时间。我们可以通过识别“有趣”数据的有效前沿来加速这一过程。有几种技术可以实现这一点,但一个简单的方法是重新运行模型对输入进行预测,以优先标记那些答案不一致的输入。当使用不同的底层模型时,这种方法尤其有效,而且通常即使使用不太智能的模型也会受益(如果笨模型与聪明模型一致,那么这可能不是一个难题)。

一旦我们似乎达到了性能收益递减的点,我们可以继续使用相同的技术来优化模型成本;如果我们有一个表现相当好的系统,那么微调或某种形式的模型蒸馏可能会让我们从更小、更便宜、更快的模型中获得类似的性能。

系统改进

有了评估体系,并理解了它们与业务指标的联系后,我们终于可以着手改进系统的输出。

上文提到,商户名称的错误率高达85%,远超其他评估项。这看起来相当糟糕,或许只需稍加努力就能大幅改善,但让我们先从业务指标的终点出发,逆向追踪导致错误决策的问题。

这样做时,我们发现商户名称的错误与最终审计决策完全无关,也没有证据表明它们对决策有任何影响。基于我们的业务模式,实际上并不需要改进它——换句话说,*并非所有评估都重要*。相反,我们可以专门检查那些做出错误审计决策的案例。这样的案例只有两个(共20个)。仔细检查后,我们观察到这两个案例的问题都出在流水线的第二阶段,即基于无问题的提取结果做出了错误决策。事实上,这两个案例都源于对差旅相关费用的推理错误。

第一个案例中,购买的是汽车配件店的雪刷。这有点边缘化,但我们的领域专家认为这是合理的差旅费用(因为司机可能需要它来清理挡风玻璃)。这似乎表明,更详细地解释决策过程并提供类似示例可以纠正错误。

第二个案例中,购买的是家装店的工具。这些工具与正常驾驶无关,因此这张收据应被审计为“非差旅相关费用”。在这个案例中,我们的模型*正确*地将其识别为非差旅相关费用,但随后对该事实的推理不正确,显然误解了not_travel_related为true应意味着needs_audit为true。这同样似乎是一个通过更清晰的指令和几个示例就能解决的问题。

将其与成本模型联系起来,我们注意到有1个假阴性和1个假阳性,以及7个真阳性和11个真阴性。将这些数据外推到生产环境中的频率,这将使我们的总成本每年增加63,000美元。

让我们修改提示词并重新运行评估,看看效果如何。我们将在指令中提供更多指导,以具体示例(如机油,与雪刷不同,但需要相同的推理)的形式,并包含从训练集(data/train)中选取的三个示例作为少样本指导。

first_ai_system_cost = calculate_costs(
    fp_rate=1 / 12, fn_rate=1 / 8, per_receipt_cost=0.01
)

print(f"First version of our system, estimated cost: ${first_ai_system_cost:,.0f}")
nursery_receipt_details = ReceiptDetails(
    merchant="WESTERN SIERRA NURSERY",
    location=Location(city="Oakhurst", state="CA", zipcode="93644"),
    time="2024-09-27T12:33:38",
    items=[
        LineItem(
            description="Plantskydd Repellent RTU 1 Liter",
            product_code=None,
            category="Garden/Pest Control",
            item_price="24.99",
            sale_price=None,
            quantity="1",
            total="24.99",
        )
    ],
    subtotal="24.99",
    tax="1.94",
    total="26.93",
    handwritten_notes=[],
)

nursery_audit_decision = AuditDecision(
    not_travel_related=True,
    amount_over_limit=False,
    math_error=False,
    handwritten_x=False,
    reasoning="""
    1. The merchant is a plant nursery and the item purchased an insecticide, so this
       purchase is not travel-related (criterion 1 violated).
    2. The total is $26.93, under $50, so criterion 2 is not violated.
    3. The line items (1 * $24.99 + $1.94 tax) sum to $26.93, so criterion 3 is not
       violated.
    4. There are no handwritten notes or 'X's, so criterion 4 is not violated.
    Since NOT_TRAVEL_RELATED is true, the receipt must be audited.
    """,
    needs_audit=True,
)

flying_j_details = ReceiptDetails(
    merchant="Flying J #616",
    location=Location(city="Frazier Park", state="CA", zipcode=None),
    time="2024-10-01T13:23:00",
    items=[
        LineItem(
            description="Unleaded",
            product_code=None,
            category="Fuel",
            item_price="4.459",
            sale_price=None,
            quantity="11.076",
            total="49.39",
        )
    ],
    subtotal="49.39",
    tax=None,
    total="49.39",
    handwritten_notes=["yos -> home sequoia", "236660"],
)
flying_j_audit_decision = AuditDecision(
    not_travel_related=False,
    amount_over_limit=False,
    math_error=False,
    handwritten_x=False,
    reasoning="""
    1. The only item purchased is Unleaded gasoline, which is travel-related so
       NOT_TRAVEL_RELATED is false.
    2. The total is $49.39, which is under $50, so AMOUNT_OVER_LIMIT is false.
    3. The line items ($4.459 * 11.076 = $49.387884) sum to the total of $49.39, so
       MATH_ERROR is false.
    4. There is no "X" in the handwritten notes, so HANDWRITTEN_X is false.
    Since none of the criteria are violated, the receipt does not need auditing.
    """,
    needs_audit=False,
)

engine_oil_details = ReceiptDetails(
    merchant="O'Reilly Auto Parts",
    location=Location(city="Sylmar", state="CA", zipcode="91342"),
    time="2024-04-26T8:43:11",
    items=[
        LineItem(
            description="VAL 5W-20",
            product_code=None,
            category="Auto",
            item_price="12.28",
            sale_price=None,
            quantity="1",
            total="12.28",
        )
    ],
    subtotal="12.28",
    tax="1.07",
    total="13.35",
    handwritten_notes=["vista -> yos"],
)
engine_oil_audit_decision = AuditDecision(
    not_travel_related=False,
    amount_over_limit=False,
    math_error=False,
    handwritten_x=False,
    reasoning="""
    1. The only item purchased is engine oil, which might be required for a vehicle
       while traveling, so NOT_TRAVEL_RELATED is false.
    2. The total is $13.35, which is under $50, so AMOUNT_OVER_LIMIT is false.
    3. The line items ($12.28 + $1.07 tax) sum to the total of $13.35, so
       MATH_ERROR is false.
    4. There is no "X" in the handwritten notes, so HANDWRITTEN_X is false.
    None of the criteria are violated so the receipt does not need to be audited.
    """,
    needs_audit=False,
)

examples = [
    {"input": nursery_receipt_details, "output": nursery_audit_decision},
    {"input": flying_j_details, "output": flying_j_audit_decision},
    {"input": engine_oil_details, "output": engine_oil_audit_decision},
]

# Format the examples as JSON, with each example wrapped in XML tags.
example_format = """
<example>
    <input>
        {input}
    </input>
    <output>
        {output}
    </output>
</example>
"""

examples_string = ""
for example in examples:
    example_input = example["input"].model_dump_json()
    correct_output = example["output"].model_dump_json()
    examples_string += example_format.format(input=example_input, output=correct_output)

audit_prompt = f"""
Evaluate this receipt data to determine if it need to be audited based on the following
criteria:

1. NOT_TRAVEL_RELATED:
   - IMPORTANT: For this criterion, travel-related expenses include but are not limited
   to: gas, hotel, airfare, or car rental.
   - If the receipt IS for a travel-related expense, set this to FALSE.
   - If the receipt is NOT for a travel-related expense (like office supplies), set this
   to TRUE.
   - In other words, if the receipt shows FUEL/GAS, this would be FALSE because gas IS
   travel-related.
   - Travel-related expenses include anything that could be reasonably required for
   business-related travel activities. For instance, an employee using a personal
   vehicle might need to change their oil; if the receipt is for an oil change or the
   purchase of oil from an auto parts store, this would be acceptable and counts as a
   travel-related expense.

2. AMOUNT_OVER_LIMIT: The total amount exceeds $50

3. MATH_ERROR: The math for computing the total doesn't add up (line items don't sum to
   total)
   - Add up the price and quantity of each line item to get the subtotal
   - Add tax to the subtotal to get the total
   - If the total doesn't match the amount on the receipt, this is a math error
   - If the total is off by no more than $0.01, this is NOT a math error

4. HANDWRITTEN_X: There is an "X" in the handwritten notes

For each criterion, determine if it is violated (true) or not (false). Provide your
reasoning for each decision, and make a final determination on whether the receipt needs
auditing. A receipt needs auditing if ANY of the criteria are violated.

Note that violation of a criterion means that it is `true`. If any of the above four
values are `true`, then the receipt needs auditing (`needs_audit` should be `true`: it
functions as a boolean OR over all four criteria).

If the receipt contains non-travel expenses, then NOT_TRAVEL_RELATED should be `true`
and therefore NEEDS_AUDIT must also be set to `true`. IF THE RECEIPT LISTS ITEMS THAT
ARE NOT TRAVEL-RELATED, THEN IT MUST BE AUDITED. Here are some example inputs to
demonstrate how you should act:

<examples>
{examples_string}
</examples>

Return a structured response with your evaluation.
"""

我们对上述提示所做的修改是:

  1. 在关于差旅费用的第1项下,我们添加了一个项目符号
- Travel-related expenses include anything that could be reasonably required for
business-related travel activities. For instance, an employee using a personal
vehicle might need to change their oil; if the receipt is for an oil change or the
purchase of oil from an auto parts store, this would be acceptable and counts as a
travel-related expense.
  1. 我们增加了关于如何评估数学错误的更具指导性的建议。
  2. 具体来说,我们添加了以下要点:

- Add up the price and quantity of each line item to get the subtotal
- Add tax to the subtotal to get the total
- If the total doesn't match the amount on the receipt, this is a math error
- If the total is off by no more than $0.01, this is NOT a math error

这实际上与我们提到的问题无关,但却是我们注意到的审计模型推理中的另一个缺陷。

  1. 我们添加了非常明确的指导(实际上我们需要强调并反复强调),说明非旅行相关费用也应进行审计。
Note that violation of a criterion means that it is `true`. If any of the above four
values are `true`, then the receipt needs auditing (`needs_audit` should be `true`: it
functions as a boolean OR over all four criteria).

If the receipt contains non-travel expenses, then NOT_TRAVEL_RELATED should be `true`
and therefore NEEDS_AUDIT must also be set to `true`. IF THE RECEIPT LISTS ITEMS THAT
ARE NOT TRAVEL-RELATED, THEN IT MUST BE AUDITED.
  1. 我们添加了三个示例,即用 XML 标签包裹的 JSON 输入/输出对。

根据修订后的提示词,我们将重新生成数据以进行评估,并重新运行相同的评估来比较结果:

file_content = await create_dataset_content(receipt_image_dir)

eval_run = await client.evals.runs.create(
    name="updated-receipt-processing-run",
    eval_id=full_eval.id,
    data_source={
        "type": "jsonl",
        "source": {"type": "file_content", "content": file_content},
    },
)

eval_run.report_url

当我们再次运行评估时,实际上仍然有两个审计决策是错误的。深入分析我们出错的示例后,发现我们完全修复了之前识别的问题,但我们的示例改进了推理步骤,却导致另外两个问题浮出水面。具体来说:

  1. 有一张收据需要审计,仅仅是因为提取过程中出现错误,手写的“X”没有被识别出来。审计模型推理正确,但基于的是错误的数据。
  2. 有一张收据在提取时,0.35美元的借记费用没有显示出来,因此审计模型识别出了一个数学错误。这几乎可以肯定是因为我们提供了更详细的指令和清晰的示例,表明它需要实际加总所有行项目才能判断是否存在数学错误。这再次证明了审计模型的行为是正确的,并表明我们需要修正提取模型。

这很好,我们将继续在发现问题的过程中进行迭代。这就是改进的循环!

模型选择

在开始一个项目时,我们通常从最强大的模型之一开始,例如 o4-mini,以建立性能基线。一旦我们确信模型能够解决任务,下一步就是探索更小、更快或更具成本效益的替代方案。

优化推理成本和延迟至关重要,尤其是在生产环境或面向客户的系统中,这些因素会显著影响整体费用和用户体验。例如,从 o4-mini 切换到 gpt-4.1-mini 可以将推理成本降低近三分之二——这是一个深思熟虑的模型选择带来显著节省的例子。

在下一节中,我们将使用 gpt-4.1-mini 重新运行评估,用于提取和审计步骤,看看更高效的模型表现如何。

file_content = await create_dataset_content(receipt_image_dir, model="gpt-4.1-mini")

eval_run = await client.evals.runs.create(
    name="receipt-processing-run-gpt-4-1-mini",
    eval_id=full_eval.id,
    data_source={
        "type": "jsonl",
        "source": {"type": "file_content", "content": file_content},
    },
)

eval_run.report_url

结果相当令人鼓舞。看起来提取准确率完全没有受到影响。我们看到一个回归(又是雪扫帚),但我们的审计决策正确率比提示词修改前提高了一倍。

Eval Variations这有力地证明了我们可以切换到更便宜的模型,但可能需要更多的提示工程、微调或某种形式的模型蒸馏。然而请注意,根据我们当前的模型,这已经可以为我们节省资金。我们还不太相信这一点,因为我们的样本量还不够大——实际的假阴性率将高于我们在这里看到的0。

system_cost_4_1_mini = calculate_costs(
    fp_rate=1 / 12, fn_rate=0, per_receipt_cost=0.003
)

print(f"Cost using gpt-4.1-mini: ${system_cost_4_1_mini:,.0f}")

进一步改进

本手册侧重于评估的理念与实践,而非全面的模型改进技术。为了提升或维持模型性能(尤其是在转向更小、更快或更便宜的模型时),请按顺序考虑以下步骤——从顶部开始,仅在必要时继续向下。例如,在诉诸微调之前,始终先优化你的提示词;在较弱的提示词上进行微调可能会锁定不良性能,即使你之后改进了提示词也无济于事。

Model Improvement Waterfall1. 模型选择: 尝试更智能的模型,或增加其推理预算。

  1. 提示调优: 澄清指令并提供非常明确的规则。
  2. 示例与上下文: 添加少量或大量示例,或为问题提供更多上下文。RAG 适用于此,并可用于动态选择相似示例。
  3. 工具使用: 提供工具来解决特定问题,包括访问外部 API、查询数据库的能力,或使模型能够自行解答问题。
  4. 辅助模型: 添加模型来执行有限的子任务,进行监督并提供防护措施,或使用专家混合,并聚合多个子模型的解决方案。
  5. 微调: 使用带标签的训练数据进行监督微调,使用评估器进行强化微调,或使用不同输出进行直接偏好优化。

以上选项都是最大化性能的工具。一旦你试图优化性价比,通常你已经完成了上述所有步骤,并且可能不需要重复大多数步骤,但你仍然可以微调较小的模型,或使用最佳模型来训练较小的模型(模型蒸馏)。

OpenAI Evals 的一个真正出色的特点是,你可以使用相同的评估器进行强化微调,以极其样本高效的方式产生更好的模型性能。需要注意的是,确保使用独立的训练数据,并且在 RFT 期间不要泄露你的评估数据集。

部署与后期开发

构建和部署LLM应用只是开始——真正的价值来自于持续改进。一旦系统上线,优先考虑持续监控:记录追踪、跟踪输出,并使用智能采样技术主动抽取真实用户交互以供人工审查。

生产数据是您发展评估和训练数据集最真实的来源。定期从实际用例中收集和整理新样本,以识别差距、边缘情况和改进的新机会。

在实践中,利用这些数据进行快速迭代。自动化定期微调管道,在最近的高质量样本上重新训练模型,并在评估中优于现有模型时自动部署新版本。捕获用户的更正和反馈,并系统地将这些见解反馈到提示或再训练过程中——尤其是当它们突出持续存在的问题时。

通过将这些反馈循环嵌入到后期开发工作流中,您确保LLM应用持续适应、保持稳健,并随着用户需求的变化与用户需求保持紧密一致。

贡献者

本手册是 OpenAI 与 Fractional 的合作成果。

常见问题

常见错误包括:1. 提示词不明确导致模型推理错误,如对差旅费用的误判;2. 提取错误(如OCR错误)影响下游决策;3. 评估指标与业务目标脱节,导致优化方向错误。排查方法:使用评估报告定位失败样本,分析错误类型,针对性地修改提示词或增加示例。生产环境优化:监控模型性能,定期用新数据重新评估,考虑模型蒸馏或微调以降低成本。

企业应用扩展

进一步改进可遵循模型改进瀑布:先尝试更智能的模型,再优化提示词,添加示例或上下文,使用工具,引入辅助模型,最后进行微调。强化微调(RFT)可利用评估器提升性能。部署后应持续监控,收集生产数据,定期更新评估和训练集,实现持续改进。

总结

本教程面向ML/AI工程师,以收据解析为例,展示如何将评估作为核心流程,从原型迭代至生产级系统。内容涵盖问题定义、数据收集、V0系统构建、评估设计、业务指标映射、系统改进及部署监控。强调在有限数据下,通过评估驱动决策,平衡成本与性能,并提供了完整的代码示例和实操步骤。

内容来源与验证

继续系统学习

站内还有更多完整实战教程

加入会员,解锁全部教程、完整代码与持续更新内容。

加入会员
查看全部模型评估与微调教程