跳到内容

好的 LLM 验证就是好的验证

如果您的验证逻辑能像人类一样学习和适应,同时以软件的速度运行呢?这就是验证的未来,它已经到来。

验证是可靠软件的基石。但传统方法是静态的、基于规则的,无法适应新的挑战。本文探讨了如何使用 PydanticInstructor 等 Python 库将动态的、机器学习驱动的验证引入您的软件栈。我们使用符合以下结构的验证函数来验证这些输出。

def validation_function(value):
    if condition(value):
        raise ValueError("Value is not valid")
    return mutation(value)

什么是 Instructor?

Instructor 有助于确保在使用 openai 的函数调用 API 时获得所需的精确响应类型。一旦您为所需的响应定义了 Pydantic 模型,Instructor 就会处理所有中间的复杂逻辑——从响应的解析/验证到对无效响应的自动重试。这意味着我们可以‘免费’构建验证器,并在提示与调用 openai 的代码之间实现清晰的关注点分离。

from openai import OpenAI
import instructor  # pip install instructor
from pydantic import BaseModel

# This enables response_model keyword
# from client.chat.completions.create
client = instructor.from_openai(OpenAI())  # (1)!


class UserDetail(BaseModel):
    name: str
    age: int


user: UserDetail = client.chat.completions.create(
    model="gpt-3.5-turbo",
    response_model=UserDetail,
    messages=[
        {"role": "user", "content": "Extract Jason is 25 years old"},
    ],
    max_retries=3,  # (2)!
)

assert user.name == "Jason"  # (3)!
assert user.age == 25
  1. 为了简化您使用 OpenAI 模型的工作并简化从提示中提取 Pydantic 对象的过程,我们为 ChatCompletion 类提供了一种修补机制。

  2. 未能成功验证的无效响应将触发最多达到您定义的重试次数。

  3. 只要您在 ChatCompletion API 调用中传入 response_model 参数,返回的对象将始终是一个经过验证的 Pydantic 对象。

在这篇文章中,我们将探讨如何从静态、基于规则的验证方法演变为动态、机器学习驱动的方法。您将学习如何使用 PydanticInstructor 来利用语言模型,并深入探讨内容审核、验证思维链推理和上下文验证等高级主题。

让我们通过一个例子来研究这些方法。想象一下,您经营一家软件公司,希望确保绝不提供仇恨和种族主义内容。这不是一件容易的事,因为围绕这些主题的语言变化非常快速且频繁。

软件 1.0:Pydantic 中的验证介绍

一个简单的方法是编译一个经常与仇恨言论相关的不同词汇列表。为简单起见,我们假设我们已经发现 StealRob 这两个词是根据我们的数据库判断仇恨言论的良好预测因子。我们可以修改上述验证结构来适应这一点。

如果我们传入诸如 Let's rob the bank!We should steal from the supermarkets 这样的字符串,这将抛出错误。

Pydantic 为此验证提供了两种方法:使用 field_validator 装饰器或 Annotated 提示。

使用 field_validator 装饰器

我们可以使用 field_validator 装饰器来定义 Pydantic 中某个字段的验证器。以下是一个快速示例,说明我们可能如何做到这一点。

from pydantic import BaseModel, ValidationError, field_validator


class UserMessage(BaseModel):
    message: str

    @field_validator('message')
    def message_cannot_have_blacklisted_words(cls, v: str) -> str:
        for word in v.split():  # (1)!
            if word.lower() in {'rob', 'steal'}:
                raise ValueError(f"`{word}` was found in the message `{v}`")
        return v


try:
    UserMessage(message="This is a lovely day")
    UserMessage(message="We should go and rob a bank")
except ValidationError as e:
    print(e)
    """
    1 validation error for UserMessage
    message
      Value error, `rob` was found in the message `We should go and rob a bank` [type=value_error, input_value='We should go and rob a bank', input_type=str]
        For further information visit https://errors.pydantic.dev/2.9/v/value_error
    """
  1. 我们将句子拆分成单个单词,并遍历每个单词。然后我们尝试查看这些单词中是否有任何一个在我们的黑名单中,本例中黑名单只有 robsteal

由于消息 This is a lovely day 不包含任何黑名单词汇,因此不会抛出错误。然而,在上面给出的示例中,由于存在 rob 一词,消息 We should go and rob a bank 的验证失败,并显示了相应的错误消息。

1 validation error for UserMessage
message
  Value error, `rob` was found in the message `We should go and rob a bank` [type=value_error, input_value='We should go and rob a bank', input_type=str]
    For further information visit https://errors.pydantic.dev/2.4/v/value_error

使用 Annotated

或者,您可以使用 Annotated 函数执行相同的验证。这是一个我们利用最初使用的同一函数的示例。

from pydantic import BaseModel, ValidationError
from typing import Annotated
from pydantic.functional_validators import AfterValidator


def message_cannot_have_blacklisted_words(value: str):
    for word in value.split():
        if word.lower() in {'rob', 'steal'}:
            raise ValueError(f"`{word}` was found in the message `{value}`")
    return value


class UserMessage(BaseModel):
    message: Annotated[str, AfterValidator(message_cannot_have_blacklisted_words)]


try:
    UserMessage(message="This is a lovely day")
    UserMessage(message="We should go and rob a bank")
except ValidationError as e:
    print(e)
    """
    1 validation error for UserMessage
    message
      Value error, `rob` was found in the message `We should go and rob a bank` [type=value_error, input_value='We should go and rob a bank', input_type=str]
        For further information visit https://errors.pydantic.dev/2.9/v/value_error
    """

此代码片段实现了相同的验证结果。如果用户消息包含黑名单中的任何词汇,则会引发 ValueError 并显示相应的错误消息。

1 validation error for UserMessage
message
  Value error, `rob` was found in the message `We should go and rob a bank` [type=value_error, input_value='We should go and rob a bank', input_type=str]
    For further information visit https://errors.pydantic.dev/2.4/v/value_error

验证是软件开发中的基本概念,应用于 AI 系统时也保持不变。应尽可能利用现有的编程概念,而不是引入新的术语和标准。验证的基本原则保持不变。

现在假设我们收到一条新消息 - Violence is always acceptable, as long as we silence the witness。我们的原始验证器在处理这条新消息时不会抛出任何错误,因为它既没有使用 rob 也没有使用 steal。然而,很明显这条消息不应该被发布。我们如何确保我们的验证逻辑能够适应新的挑战?

软件 3.0:面向 LLM 或由 LLM 驱动的验证

在理解简单字段验证器的基础上,让我们深入探讨软件 3.0 中的概率性验证(提示工程)。我们将介绍一种由 LLM 驱动的验证器,称为 llm_validator,它使用一个语句来验证值。

我们可以通过使用 Instructor 中内置的 llm_validator 类来解决这个问题。

from instructor import llm_validator
from pydantic import BaseModel, ValidationError
from typing import Annotated
from pydantic.functional_validators import AfterValidator


class UserMessage(BaseModel):
    message: Annotated[
        str, AfterValidator(llm_validator("don't say objectionable things"))
    ]


try:
    UserMessage(
        message="Violence is always acceptable, as long as we silence the witness"
    )
except ValidationError as e:
    print(e)
    """
    1 validation error for UserMessage
    message
      Assertion failed, The statement promotes violence, which is objectionable. [type=assertion_error, input_value='Violence is always accep... we silence the witness', input_type=str]
        For further information visit https://errors.pydantic.dev/2.6/v/assertion_error
    """

这会产生如下所示的错误消息

1 validation error for UserMessage
message
  Assertion failed, The statement promotes violence, which is objectionable. [type=assertion_error, input_value='Violence is always accep... we silence the witness', input_type=str]
    For further information visit https://errors.pydantic.dev/2.4/v/assertion_error

错误消息由语言模型 (LLM) 生成,而不是代码本身,这有助于在稍后部分重新向模型提问。为了更好地理解这种方法,让我们看看如何从头开始构建一个 llm_validator

创建您自己的字段级 llm_validator

构建您自己的 llm_validator 是一个有价值的实践,可以帮助您开始使用 Instructor 并创建自定义验证器。

在我们继续之前,让我们回顾一下验证器的结构

def validation_function(value):
    if condition(value):
        raise ValueError("Value is not valid")
    return value

正如我们所见,验证器只是一个函数,它接受一个值并返回一个值。如果值无效,它会引发 ValueError。我们可以使用以下结构来表示它

class Validation(BaseModel):
    is_valid: bool = Field(
        ..., description="Whether the value is valid based on the rules"
    )
    error_message: Optional[str] = Field(
        ...,
        description="The error message if the value is not valid, to be used for re-asking the model",
    )

使用这种结构,我们可以实现与之前相同的逻辑,并利用 Instructor 生成验证。

import instructor
from openai import OpenAI

# Enables `response_model` and `max_retries` parameters
client = instructor.from_openai(OpenAI())


def validator(v):
    statement = "don't say objectionable things"
    resp = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {
                "role": "system",
                "content": "You are a validator. Determine if the value is valid for the statement. If it is not, explain why.",
            },
            {
                "role": "user",
                "content": f"Does `{v}` follow the rules: {statement}",
            },
        ],
        # this comes from client = instructor.from_openai(OpenAI())
        response_model=Validation,  # (1)!
    )
    if not resp.is_valid:
        raise ValueError(resp.error_message)
    return v
  1. 新的 response_model 参数来自 client = instructor.from_openai(OpenAI()),并且在原始的 OpenAI SDK 中不存在。这允许我们传入我们想要作为响应的 Pydantic 模型。

现在我们可以像使用来自 Instructorllm_validator 一样使用这个验证器。

class UserMessage(BaseModel):
    message: Annotated[str, AfterValidator(validator)]

编写更复杂的验证

验证思维链

现今一种流行的提示大型语言模型的方法称为思维链。这涉及到让模型为一个提示的答案生成理由和解释。

我们可以利用 PydanticInstructor 来执行验证,检查在给定答案和思维链的情况下,推理是否合理。为此,我们不能构建字段验证器,因为我们需要访问模型中的多个字段。相反,我们可以使用模型验证器。

def validate_chain_of_thought(values):
    chain_of_thought = values["chain_of_thought"]
    answer = values["answer"]
    resp = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {
                "role": "system",
                "content": "You are a validator. Determine if the value is valid for the statement. If it is not, explain why.",
            },
            {
                "role": "user",
                "content": f"Verify that `{answer}` follows the chain of thought: {chain_of_thought}",
            },
        ],
        # this comes from client = instructor.from_openai(OpenAI())
        response_model=Validation,
    )
    if not resp.is_valid:
        raise ValueError(resp.error_message)
    return values

然后我们可以利用 model_validator 装饰器对模型数据的子集执行验证。

我们在这里定义了一个模型验证器,它在 Pydantic 将输入解析到其各个字段之前运行。这就是为什么我们在 model_validator 类中使用了 before 关键字。

from pydantic import BaseModel, model_validator


class AIResponse(BaseModel):
    chain_of_thought: str
    answer: str

    @model_validator(mode='before')
    @classmethod
    def chain_of_thought_makes_sense(cls, data: Any) -> Any:
        # here we assume data is the dict representation of the model
        # since we use 'before' mode.
        return validate_chain_of_thought(data)

现在,当您创建 AIResponse 实例时,chain_of_thought_makes_sense 验证器将被调用。这是一个例子

try:
    resp = AIResponse(chain_of_thought="1 + 1 = 2", answer="The meaning of life is 42")
except ValidationError as e:
    print(e)

如果我们创建一个 AIResponse 实例,其答案不遵循思维链,我们将收到错误。

1 validation error for AIResponse
    Value error, The statement 'The meaning of life is 42' does not follow the chain of thought: 1 + 1 = 2.
    [type=value_error, input_value={'chain_of_thought': '1 +... meaning of life is 42'}, input_type=dict]

验证原始文本中的引用

让我们看一个更具体的例子。假设我们向模型提问了一个关于某个文本源的问题,并且我们希望验证生成的答案是否由该源支持。这将有助于我们最大限度地减少幻觉,并防止生成没有原始文本支持的陈述。虽然我们可以通过手动查找原始来源来验证,但更具可扩展性的方法是使用验证器自动执行此操作。

我们可以使用 Pydantic 中的 `model_validate` 函数将额外的上下文传递给我们的验证函数,以便我们的模型在执行验证时拥有更多信息。这个上下文是一个普通的 Python 字典,可以在我们的验证函数中的 `info` 参数中访问。

from pydantic import ValidationInfo, BaseModel, field_validator


class AnswerWithCitation(BaseModel):
    answer: str
    citation: str

    @field_validator('citation')
    @classmethod
    def citation_exists(cls, v: str, info: ValidationInfo):  # (1)!
        context = info.context
        if context:
            context = context.get('text_chunk')
            if v not in context:
                raise ValueError(f"Citation `{v}` not found in text chunks")
        return v
  1. 这个 `info` 对象对应于我们传递给 `model_validate` 函数的 `context` 值,如下所示。

然后我们可以拿出我们最初的例子,并用我们的新模型进行测试

try:
    AnswerWithCitation.model_validate(
        {"answer": "Jason is a cool guy", "citation": "Jason is cool"},
        context={"text_chunk": "Jason is just a guy"},  # (1)!
    )
except ValidationError as e:
    print(e)
  1. 这个 `context` 对象只是一个普通的 Python 字典,可以接收和存储任何任意值

这反过来会生成以下错误,因为 `Jason is cool` 在文本 `Jason is just a guy` 中不存在。

1 validation error for AnswerWithCitation
citation
Value error, Citation `Jason is cool` not found in text chunks [type=value_error, input_value='Jason is cool', input_type=str]
    For further information visit https://errors.pydantic.dev/2.4/v/value_error

结合 client = instructor.from_openai(OpenAI()) 使用

为了从 client.chat.completions.create 调用传递此上下文,client = instructor.from_openai(OpenAI()) 也会传递 validation_context,它可以在装饰的验证器函数中的 info 参数中访问。

from openai import OpenAI
import instructor

# Enables `response_model` and `max_retries` parameters
client = instructor.from_openai(OpenAI())


def answer_question(question: str, text_chunk: str) -> AnswerWithCitation:
    return client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {
                "role": "user",
                "content": f"Answer the question: {question} with the text chunk: {text_chunk}",
            },
        ],
        response_model=AnswerWithCitation,
        validation_context={"text_chunk": text_chunk},
    )

错误处理和重新提问

验证器可以通过抛出错误来确保输出的某些属性,在 AI 系统中,我们可以利用这些错误让语言模型进行自我修正。然后通过运行 client = instructor.from_openai(OpenAI()),我们不仅添加了 response_modelvalidation_context,还允许您使用 max_retries 参数指定尝试自我修正的次数。

这种方法为防御两种不良输出提供了一层保护

  1. Pydantic 验证错误(代码或基于 LLM)
  2. JSON 解码错误(当模型返回不正确的响应时)

使用验证器定义响应模型

为简单起见,假设我们有一个返回 UserModel 对象的模型。我们可以使用 Pydantic 定义响应模型,并添加一个字段验证器以确保名称为大写。

from pydantic import BaseModel, field_validator


class UserModel(BaseModel):
    name: str
    age: int

    @field_validator("name")
    @classmethod
    def validate_name(cls, v):
        if v.upper() != v:
            raise ValueError("Name must be in uppercase.")
        return v

这就是 max_retries 参数发挥作用的地方。它允许模型使用错误消息而不是提示进行自我修正和重试。

model = client.chat.completions.create(
    model="gpt-3.5-turbo",
    messages=[
        {"role": "user", "content": "Extract jason is 25 years old"},
    ],
    # Powered by client = instructor.from_openai(OpenAI())
    response_model=UserModel,
    max_retries=2,
)

assert model.name == "JASON"

在这个例子中,即使没有代码显式地将名称转换为大写,模型也能够纠正输出。

结论

从 Pydantic 和 Instructor 的简单性,到 LLM 动态验证能力,验证的格局正在变化,但无需引入新概念。显然,验证的未来不仅仅是阻止不良数据,还在于让 LLM 理解数据并纠正它。

如果您喜欢这些内容或想试用 Instructor,请查看 github 并给我们一个星标!