当前位置:首页 > 技术 > 正文内容

在 Laravel 中实现 OpenAI 结构化输出:为生产级 AI 管线强制 JSON Schema 约束

访客 技术 2026年5月4日 22:20 13

如果你一直把 OpenAI 的 JSON 模式当成"结构化输出"来用,那么你依赖的保证比看上去要弱得多。JSON 模式承诺生成语法合法的 JSON,但并不承诺 status 一定是字符串、confidence 一定是浮点数,或者 items 字段不会在响应中悄悄消失。在生产环境里,这种差异会让你付出代价。我们见过太多凌晨三点管线崩溃的事故,原因只是下游队列处理器需要的某个字段压根没出现,而 JSON 模式对此毫无怨言。

在 Laravel 中使用 OpenAI 的结构化输出——具体来说,就是配置 response_formattype: json_schemastrict: true——可以填补这一缺陷。系统会在生成层面通过受限解码强制进行模式合规检查:模型根本不可能生成任何违反你 schema 的 token。本文将介绍如何在 Laravel 中正确接入这套机制,揭示真正会遇到的失败模式(它们和你想的不太一样),并说明结构化输出如何融入智能体管线——在这种管线中,下游步骤依赖的是带类型的、可预测的数据载荷。

本文专注于 OpenAI 的实现。Gemini 通过 generationConfig.responseSchema 提供同样的概念,而 Claude 则通过工具定义来接近这一目标。JSON Schema 的结构在这三者之间基本上是通用的,差异在于实现层:每个供应商如何接收 schema、各自的拒绝机制、严格模式的约束以及错误的表现形式。这些差异值得单独成文。如果你想先了解跨供应商的整体情况,可以参考 Laravel AI 集成架构指南,其中在架构层面进行了对比。

为何 JSON 模式已经不够用了

下面的对比表值得保留。这些差异在纸面上很细微,但在高负载下是灾难性的。

特性 json_object 模式 json_schema 严格模式
保证生成合法 JSON
保证所有必需 key 都存在
保证类型正确
强制枚举值检查
强制 additionalProperties: false
暴露 refusal 字段
遵守 JSON Schema 规范子集

JSON 模式只保证语法正确,不保证 schema 遵守。即使你在提示词中标记为"必须"的字段,也可能从响应中消失。OpenAI 自己的文档现在已将 json_object 模式视为旧版。在生产环境中,对于数据抽取和智能体工作流,带有 json_schema 的严格模式已经成为新的默认选择。

如果你的 Laravel 应用要基于 AI 生成的 JSON 进行决策(比如路由任务、更新记录、触发下游智能体),你就需要更强的保证。

OpenAI 结构化输出在底层如何运作

理解其机制很重要,因为它既能体现其能力,也能说明其限制。

OpenAI 在生成令牌之前,会使用上下文无关文法引擎来屏蔽所有不合规的令牌,模型根本无法生成不符合规则的响应。这不是提示词层面的约束,再多的指令遵循或微调也起不到受限解码的作用。schema 被直接编译进了生成过程中。

安全策略依然会生效。模型会遵守现有的安全规则,并可能拒绝不安全的请求。此时,它会在响应中返回一个 refusal 字符串,让你能够以编程方式检测到模型生成了拒绝响应,而非符合 schema 的输出。

在写一行代码之前,有两个关键认知需要内化:

  1. 如果模型可以回答,它就一定会遵守你的 schema。这就是给出的保证。
  2. 如果模型不愿回答(安全拒绝),你会收到一个 refusal 字段而非 content。你必须显式地处理这两种分支。

在 Laravel 中定义 JSON Schema

将 schema 定义分散在各个服务类中是一种维护灾难。最好把它们放在一个专门的配置文件中:这个 Laravel 原生的地方适合存放静态的、环境感知的定义,而且在使用时无需实例化任何类。

<?php

// config/ai-schemas.php

return [

    'review_analysis' => [
        'name'   => 'review_analysis',
        'strict' => true,
        'schema' => [
            'type'                 => 'object',
            'additionalProperties' => false,
            'required'             => ['sentiment', 'confidence', 'summary', 'flags', 'escalate'],
            'properties'           => [
                'sentiment'  => [
                    'type' => 'string',
                    'enum' => ['positive', 'negative', 'neutral', 'mixed'],
                ],
                'confidence' => [
                    'type' => 'number',
                ],
                'summary'    => [
                    'type' => 'string',
                ],
                'flags'      => [
                    'type'  => 'array',
                    'items' => ['type' => 'string'],
                ],
                'escalate'   => [
                    'type' => ['boolean', 'null'],
                ],
            ],
        ],
    ],

];

在服务中通过一个配置调用即可拉取所需 schema:

'response_format' => [
    'type'        => 'json_schema',
    'json_schema' => config('ai-schemas.review_analysis'),
],

配置文件方法是可发布的,可以按环境覆盖,并且随代码库进行版本控制。当 schema 发生变化时,只需改动一个文件,无需实例化任何类,也无需导入任何命名空间。

必须了解的严格模式约束

OpenAI 的严格模式是在生成层面通过受限解码强制执行的,而不是在提示词层面。这个区别至关重要,因为它意味着这些约束是硬性的,而非参考性的。如果你的 schema 违反了这些规则,API 会直接拒绝请求。因此,部署前请务必熟悉这些规则。

schema 中每个对象的 additionalProperties 都必须设为 false,并且 properties 中列举的所有字段都必须出现在 required 中。如果你的 schema 违反了其中任何一条规则,API 会立刻拒绝请求。你不会得到一个降级响应,只会收到一个 HTTP 错误。

以下 JSON Schema 关键字不被支持,会导致 API 拒绝请求:

  • default
  • 字符串的 minLength / maxLength
  • 数字的 minimum / maximum
  • 正则约束 pattern
  • 条件 schema if / then / else

要适应这些约束,而不是绕开它们。后面会介绍在 Laravel 中进行的 API 后验证,这部分用来处理 OpenAI 不强制执行的领域规则。

边缘情况警示:JSON Schema 的 $defs 在严格模式下是支持的,可用于可复用的子 schema。但如果你引用的 $def 没有在同一 schema 对象中声明,部分 SDK 版本下 API 可能会静默拒绝请求,而不返回有用的错误。部署前始终应当在本地校验复杂的 schema。写一个单元测试,对 schema 定义调用 json_validate(),这是一种低成本的保险。

如何处理可选字段

这是开发者容易踩坑的地方。你不能简单地将一个字段从 required 中省略,因为严格模式要求每个属性都必须是必需的。惯用的解决办法是让类型可为空:

'escalate' => [
    'type' => ['boolean', 'null'],
],

现在,模型可以为 escalate 返回 null,你的 PHP 代码可以干净地处理这一点。不要试图通过完全移除可选字段来规避此问题,那样会失去一个完整且可预测的数据契约。

携带严格 Schema 执行 API 调用

将 API 调用封装在一个专门的服务中是生产环境非做不可的事情。你需要一个统一的地方来实现重试逻辑、记录令牌消耗,并处理响应契约。

<?php

namespace App\AI\Services;

use App\Exceptions\AI\ModelRefusalException;
use App\Exceptions\AI\TruncatedResponseException;
use Illuminate\Support\Facades\Log;
use OpenAI\Laravel\Facades\OpenAI;
use OpenAI\Exceptions\TransporterException;
use OpenAI\Exceptions\ErrorException;

class ReviewAnalysisService
{
    private const MODEL = 'gpt-4o';
    private const MAX_RETRIES = 3;
    private const RETRY_DELAY_MS = 500;

    public function analyse(string $reviewText): array
    {
        $attempt = 0;

        while ($attempt < self::MAX_RETRIES) {
            $attempt++;

            try {
                $response = OpenAI::chat()->create([
                    'model'           => self::MODEL,
                    'messages'        => [
                        [
                            'role'    => 'system',
                            'content' => 'You are a review analysis assistant. Analyse the provided customer review and respond according to the schema.',
                        ],
                        [
                            'role'    => 'user',
                            'content' => $reviewText,
                        ],
                    ],
                    'response_format' => [
                        'type'        => 'json_schema',
                        'json_schema' => config('ai-schemas.review_analysis'),
                    ],
                    'max_tokens' => 1024,
                ]);

                return $this->parseResponse($response);

            } catch (ErrorException $e) {
                // 速率限制:429,服务器错误:5xx
                if ($e->getCode() === 429 || $e->getCode() >= 500) {
                    if ($attempt < self::MAX_RETRIES) {
                        usleep(self::RETRY_DELAY_MS * 1000 * $attempt);
                        continue;
                    }
                }

                Log::error('OpenAI structured output error', [
                    'message' => $e->getMessage(),
                    'code'    => $e->getCode(),
                ]);

                throw $e;

            } catch (TransporterException $e) {
                // 网络层面的错误,进行重试
                if ($attempt < self::MAX_RETRIES) {
                    usleep(self::RETRY_DELAY_MS * 1000 * $attempt);
                    continue;
                }
                throw $e;
            }
        }

        throw new \RuntimeException('Max retries exceeded for structured output request.');
    }

    private function parseResponse(mixed $response): array
    {
        $choice = $response->choices[0];

        // 在检查拒绝之前先处理截断
        if ($choice->finishReason === 'length') {
            throw new TruncatedResponseException(
                'Structured output response was truncated. Increase max_tokens or simplify the schema.'
            );
        }

        $message = $choice->message;

        if (!empty($message->refusal)) {
            throw new ModelRefusalException($message->refusal);
        }

        return json_decode($message->content, true, 512, JSON_THROW_ON_ERROR);
    }
}

通过服务提供者将此服务注册到服务容器中,然后在需要的地方进行注入。尽可能避免在控制器里直接使用 Facade。

你必须处理的两类失败模式

Schema 约束完全消除了"畸形 JSON"这类失败,但仍有两种失败模式会出现在严格模式下,并且两者都需要显式处理。

拒绝响应

当模型因安全策略而拒绝回答时,message.contentnull,而 message.refusal 包含拒绝原因。不要将其视为一个通用异常,它是一种第一公民级别的响应状态。请单独记录相关日志,因为拒绝情况的突然增多通常意味着存在提示注入的尝试,或者输入数据的质量发生了变化。

<?php

namespace App\Exceptions\AI;

class ModelRefusalException extends \RuntimeException
{
    public function __construct(string $refusalReason)
    {
        parent::__construct("Model refused the request: {$refusalReason}");
    }
}

在实际的生产环境中,我们曾将这些信息路由到一个单独的监控渠道。在一个数据抽取任务中出现拒绝,几乎总是因为上游数据载荷有问题,或者是遇到了一个值得人工复核的安全策略边界。

截断响应

finish_reason: 'length' 表示在尚未完成 schema 输出前,模型已经耗尽了输出令牌。在严格模式下,被截断的响应一定是无效 JSON。因为受限解码器无法生成部分符合 schema 的输出,所以只要令牌预算卡断了输出,结果就无法使用。解决方法几乎总是增加 max_tokens,或者降低 schema 的复杂度。

生产陷阱:结构化输出中的截断无法在应用层面恢复。你无法修复一个写到一半的 JSON 对象。始终要为 max_tokens 设置足够大的值,并在监控数据中记录响应结束原因。在结构化输出调用中出现 length 原因的结束,是一个配置缺陷,而非边界情况。

超越 Schema 的 Laravel 验证

严格模式保证了结构上的合规,但不保证语义上的正确性。例如,confidence 值为 999.5 是完全合法的 JSON、符合 schema,但在你的业务逻辑中完全是错的。

使用一个带有验证功能的 Laravel 数据传输对象(DTO)可以干净地填补这一缺口:

<?php

namespace App\AI\DTOs;

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;

readonly class ReviewAnalysisResult
{
    public function __construct(
        public string $sentiment,
        public float  $confidence,
        public string $summary,
        public array  $flags,
        public ?bool  $escalate,
    ) {}

    public static function fromArray(array $data): self
    {
        $validator = Validator::make($data, [
            'sentiment'  => ['required', 'string', 'in:positive,negative,neutral,mixed'],
            'confidence' => ['required', 'numeric', 'between:0,1'],
            'summary'    => ['required', 'string', 'min:10', 'max:1000'],
            'flags'      => ['required', 'array'],
            'flags.*'    => ['string'],
            'escalate'   => ['nullable', 'boolean'],
        ]);

        if ($validator->fails()) {
            throw new ValidationException($validator);
        }

        return new self(
            sentiment:  $data['sentiment'],
            confidence: (float) $data['confidence'],
            summary:    $data['summary'],
            flags:      $data['flags'],
            escalate:   $data['escalate'] ?? null,
        );
    }
}

这遵循了我们在 Laravel OpenAI 集成指南中记录的相同验证模式。Schema 强制结构,DTO 强制领域规则。这两层都不可或缺。

架构师笔记:严格 Schema 加 DTO 验证的组合为你带来了一样前所未有的东西:一个你可以进行类型提示的 AI 响应。下游代码在接收 ReviewAnalysisResult 时,其对形状的信任程度可以像对待应用中的任何其他值对象一样。这就是让你免于写下六层深度的防御式 null 检查的方法。

将结构化输出融入智能体管线

到这里,真正的价值开始显现。在智能体工作流中,一个 AI 调用的输出会触发下一步处理。如果没有 schema 保证,你需要在每次交接时编写大量防御性代码。有了严格模式,交接就变成了一个有类型约束的契约。

<?php

namespace App\Jobs;

use App\AI\DTOs\ReviewAnalysisResult;
use App\AI\Services\ReviewAnalysisService;
use App\Exceptions\AI\ModelRefusalException;
use App\Exceptions\AI\TruncatedResponseException;
use App\Models\Review;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;

class AnalyseReviewJob implements ShouldQueue
{
    use InteractsWithQueue, Queueable;

    public int $tries = 1; // 重试逻辑在服务内部处理

    public function __construct(
        private readonly int $reviewId
    ) {}

    public function handle(ReviewAnalysisService $service): void
    {
        $review = Review::findOrFail($this->reviewId);

        try {
            $raw    = $service->analyse($review->body);
            $result = ReviewAnalysisResult::fromArray($raw);

            $review->update([
                'sentiment'     => $result->sentiment,
                'ai_confidence' => $result->confidence,
                'ai_summary'    => $result->summary,
                'flags'         => $result->flags,
            ]);

            // 根据语义条件控制下游步骤
            if ($result->escalate === true) {
                dispatch(new EscalateReviewJob($this->reviewId));
            }

        } catch (ModelRefusalException $e) {
            Log::warning('Review analysis refused by model', [
                'review_id' => $this->reviewId,
                'reason'    => $e->getMessage(),
            ]);

            $review->update(['ai_status' => 'refused']);

        } catch (TruncatedResponseException $e) {
            Log::error('Review analysis truncated', [
                'review_id' => $this->reviewId,
            ]);

            $this->fail($e);
        }
    }
}

请注意我们没有在做什么:我们不会在每个下游操作前写 isset($result['escalate'])。DTO 保证了这个键一定存在并且类型正确。智能体的分支逻辑之所以干净,是因为数据契约是干净的。这正是我们的生成式 AI 架构契约和治理指南所说的:将 AI 输出视为带类型的系统接口,而不是原始文本。

对于那些有多个智能体相互交接的更重管线,可以将这种方法与针对 LLM 幻觉的 schema 验证配对使用。结构化输出消除了结构性的幻觉,但语义偏移(比如,一个技术上有效但高到不合理的 confidence 值)仍然需要应用层面的一层断言。

在基础设施方面,每个结构化输出的任务都应该通过 Laravel AI 中间件进行令牌追踪,这样就能按任务类捕获令牌消耗。结构化输出的令牌数量往往稳定、可预测,因此对令牌花费做异常检测会非常有价值。如果你还想了解 temperaturemax_tokens 参数如何与受限解码交互,可以参考 Laravel LLM 推理控制指南了解其机制。

使用 Laravel 抽象层

本文涉及的所有内容都是原始实现。对于不想手动管理 schema 定义、响应解析和重试逻辑的团队,Prism PHP 提供了一个统一的 SDK,它在背后封装了 OpenAI、Anthropic 和 Gemini。

了解底层实现是有价值的,原因有二:首先,Prism 的结构化输出支持正是建立在这些基础组件之上,理解抽象层底层的东西,能让你更好地使用它;其次,当抽象层出现问题(在供应商 API 边界、高负载下、遇到非典型 schema 时,它就会出问题),你需要知道从何入手。

决策点很简单:如果是一个涉及多个 AI 供应商的全新项目,或者你的团队不该再为供应商特定的怪癖烦恼,那就用 Prism;如果是需要显式控制请求生命周期、重试行为和令牌遥测的单一供应商集成,那就直接使用底层 API。

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。