模型切换成功,应用却因 JSON 契约崩溃
Laravel 应用在主模型限流后成功切换备用模型,但备用响应更换了 JSON 字段名和枚举值,导致业务解析失败。案例揭示了接口恢复可用与输出契约保持兼容之间的差距。
AI 深度解读
主模型触发限流,备用模型成功接管,请求返回 200,应用却因为缺少字段崩了。作者用一个 Laravel AI 故障案例说明:服务商能接替请求,不代表它能接替应用依赖的数据契约。
这篇解读值得关注的,是如何把「模型回答了」变成「业务可以使用这个回答」:用结构化输出约束字段,用领域类型限制取值,再让每个备用服务商独立接受契约测试。这套边界划分也适用于日常 AI 接口开发。
备用模型回答成功,应用为什么还会崩
原文的故障过程只有一秒:09:42:11,主服务商返回 429 rate_limit;09:42:12,备用服务商返回 200 success;同一秒,应用报出 Missing key: recommended_action。
提示词原本要求三个字段:risk、recommended_action、reason。风险值限定为 low|medium|high,动作限定为 approve|review|reject。
备用模型却给出了 risk_level、action、explanation,还把 review 写成了 manual_review。人能理解它在建议人工审核,程序却无法按约定取字段。即使把字段名映射回来,动作值依然超出了业务允许的范围。
有效 JSON、字段正确、业务取值合法,是三道不同的检查。
Laravel AI SDK 允许给 provider 配置有序列表,例如先用 Lab::OpenAI,再用 Lab::Anthropic。主服务商限流或不可用时,SDK 可以尝试下一家,但它解决的是请求能否完成。
不同服务商的模型行为、工具支持、配置项和结构化输出实现仍有差异。作者提到 SDK 更新记录中出现过服务商专属的结构化输出修复,但没有提供具体版本,不能据此断言某个版本已经解决全部兼容问题。
提示词里的 JSON 示例,为什么还不够
作者的第一步调整,是让 Agent 实现 HasStructuredOutput,把输出结构写进定义。提示词负责解释任务,Schema 负责声明字段。
下面这段代码解决的是三个必需字段的结构约束:
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
class RefundDecision implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string
{
return <<<'PROMPT'
Evaluate the refund request.
Return a risk level, a recommended action,
and a short explanation based only on the
supplied account and policy information.
PROMPT;
}
public function schema(JsonSchema $schema): array
{
return [
'risk' => $schema->string()->required(),
'recommended_action' => $schema->string()->required(),
'reason' => $schema->string()->required(),
];
}
}
要看清每一行的 string()->required():它要求字段存在且类型为字符串,示例没有声明枚举范围。因此,recommended_action 即使返回 approve_now,也不能仅凭这层定义判断它符合业务要求。
调用 prompt() 后,代码可以通过 $response['recommended_action'] 读取结果。原文使用的请求是 Evaluate refund request RF-1084.。读取方便了,不代表可以直接拿这个字符串执行退款。
第二步是把响应转换为应用自己的领域类型。这里的领域类型,就是用程序明确规定退款结果允许出现哪些值:
enum RefundRisk: string
{
case Low = 'low';
case Medium = 'medium';
case High = 'high';
}
enum RefundAction: string
{
case Approve = 'approve';
case Review = 'review';
case Reject = 'reject';
}
final readonly class RefundResult
{
public function __construct(
public RefundRisk $risk,
public RefundAction $action,
public string $reason,
) {}
public static function fromAiResponse($response): self
{
return new self(
risk: RefundRisk::from($response['risk']),
action: RefundAction::from(
$response['recommended_action']
),
reason: $response['reason'],
);
}
}
关键是两处 ::from():manual_review、approve_now 或空字符串都无法转换为对应枚举,错误会在进入退款逻辑前暴露。reason 仍然只是字符串,这段代码没有验证解释是否充分,也没有验证决策是否符合政策。
备用服务商必须单独通过什么测试
只让正常请求一直走主服务商,测不到备用模型的实际表现。作者建议逐个指定 Lab::OpenAI 和 Lab::Anthropic,让每一家都独立完成相同任务。
原文的测试输入是:账户注册七天,政策要求审核。响应先经过 RefundResult::fromAiResponse(),再检查风险是否为 RefundRisk,动作是否严格等于 RefundAction::Review。
这里既有结构检查,也有最低限度的业务检查。不过,风险断言只检查类型,没有验证具体风险等级;一个案例也无法覆盖完整的退款政策。
这些测试会真实调用服务商,应与快速单元测试分开,在 staging、切换模型之前和升级 SDK 之后运行。还要测试校验失败的出口:重试一次、送人工审核,或者停止流程,都需要事先确定。
校验拒绝了响应之后,系统仍然需要一个明确的处理分支。
否则,开发者可能为了让流程继续,在异常处理中随手补一个默认动作,把前面建立的边界重新绕开。
我的判断
作者把可用性和业务契约拆开处理,这个判断成立。相比只强化提示词措辞,Schema 加领域枚举让约束更明确,错误也更容易定位。
但原文提供的是故障叙述、代码示例和测试建议,没有给出测试运行结果、失败率或服务商之间的对照数据。因此,不能把它当成这套实现已经证明可靠的实验报告。
它还有一个边界:契约合法不等于决策正确。模型完全可能返回合法的 approve,解释也像模像样,实际却违反退款政策。枚举能拦住陌生动作,拦不住选错动作。
相比自动把 manual_review 改成 review,严格拒绝更容易暴露兼容问题。如果团队确实需要适配层,也应明确映射规则并单独测试,不能让另一个模型猜字段含义。
另外,逐家测试回答质量,还不能替代故障切换测试。主服务商返回 429 后是否真的切到备用、备用也失败时如何结束,仍需验证。原文没有展示这一部分的测试实现。
能拿来做什么
- 给现有 AI 接口梳理三层约束:字段和类型、允许的业务取值、转换失败后的处理方式。在 Node 服务中也可以沿用这个顺序,先校验响应,再创建业务对象。
- 为每个备用服务商安排独立的契约测试,至少覆盖一个有明确预期动作的案例,并在切换模型和升级 SDK 时运行。
- 检查前端消费 AI 结果的入口。涉及审核状态或操作按钮时,应由服务端输出校验后的业务状态,避免页面直接根据模型生成的任意字符串决定行为。
- 单独演练非法字段、非法枚举和全部服务商失败的情况,把重试一次、人工审核或停止流程落实成可检查的分支。