在 Laravel 中实现 OpenAI 结构化输出:为生产级 AI 管线强制 JSON Schema 约束
如果你一直把 OpenAI 的 JSON 模式当成"结构化输出"来用,那么你依赖的保证比看上去要弱得多。JSON 模式承诺生成语法合法的 JSON,但并不承诺 status 一定是字符串、confidence 一定是浮点数,或者 items 字段不会在响应中悄悄消失。在生产环境里,这种差异会让你付出代价。我们见过太多凌晨三点管线崩溃的事故,原因只是下游队列处理器需要的某个字段压根没出现,而 JSON 模式对此毫无怨言。
在 Laravel 中使用 OpenAI 的结构化输出——具体来说,就是配置 response_format 的 type: json_schema 和 strict: 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 的输出。
在写一行代码之前,有两个关键认知需要内化:
- 如果模型可以回答,它就一定会遵守你的 schema。这就是给出的保证。
- 如果模型不愿回答(安全拒绝),你会收到一个
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.content 为 null,而 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 中间件进行令牌追踪,这样就能按任务类捕获令牌消耗。结构化输出的令牌数量往往稳定、可预测,因此对令牌花费做异常检测会非常有价值。如果你还想了解 temperature 和 max_tokens 参数如何与受限解码交互,可以参考 Laravel LLM 推理控制指南了解其机制。
使用 Laravel 抽象层
本文涉及的所有内容都是原始实现。对于不想手动管理 schema 定义、响应解析和重试逻辑的团队,Prism PHP 提供了一个统一的 SDK,它在背后封装了 OpenAI、Anthropic 和 Gemini。
了解底层实现是有价值的,原因有二:首先,Prism 的结构化输出支持正是建立在这些基础组件之上,理解抽象层底层的东西,能让你更好地使用它;其次,当抽象层出现问题(在供应商 API 边界、高负载下、遇到非典型 schema 时,它就会出问题),你需要知道从何入手。
决策点很简单:如果是一个涉及多个 AI 供应商的全新项目,或者你的团队不该再为供应商特定的怪癖烦恼,那就用 Prism;如果是需要显式控制请求生命周期、重试行为和令牌遥测的单一供应商集成,那就直接使用底层 API。