欢迎光临
我们一直在努力

LLM 输出校验网关:用 JSON Schema 和重试策略构建可靠的 AI 响应管道

LLM 输出校验网关:用 JSON Schema 和重试策略构建可靠的 AI 响应管道

一、AI 输出的不可靠性:当大模型返回了"差不多"但"不够精确"的数据

Prompt 工程师经常遇到这样的情况:你需要 LLM 输出一个结构化的 JSON,包含 name、age、email 三个字段。Prompt 里写得很清楚:"请以 JSON 格式输出,包含 name (string)、age (number)、email (string) 三个字段。"

LLM 返回的结果可能是:

{"name": "张三", "age": "28岁", "email": "zhangsan@email.com"}

age 是字符串 "28岁" 而不是数字。这个差别对于后面的代码是致命的——age + 1 的期望结果是 29,实际结果是 "28岁1"。

单独调整 Prompt 可以解决这个 case,但明天换一个模型版本,或者用户换了一种问法,age 可能又变成 "二十八"。靠 Prompt 调优来保证输出格式的一致性,就像在流沙上建房子——你永远不能确定下一脚踩在实地上。

解决方案是在 LLM 输出后插入一层校验网关。这一层不关心 LLM 怎么生成的内容,只关心输出的格式是否符合预期。

graph LR
A[用户输入] –> B[LLM 调用]
B –> C[原始输出]
C –> D{JSON Schema 校验}
D –>|通过| E[返回给下游]
D –>|失败, 重试次数 < N| F[重试: 反馈错误信息]
F –> B
D –>|失败, 重试次数 >= N| G[降级: 返回默认值或错误]
G –> H[记录异常日志]

style D fill:#ffd43b,color:#000
style G fill:#ff6b6b,color:#fff
style E fill:#51cf66,color:#fff

本文将实现一个完整的 LLM 输出校验网关,覆盖 JSON Schema 校验、自动重试和降级策略。

二、校验网关的架构设计:三层防护

校验网关的核心职责是保证 LLM 输出符合预期格式。它应该作为 LLM 调用链中的独立环节存在,而不是混在业务代码中。

Layer 1 — 格式解析:尝试将 LLM 输出解析为目标格式(JSON)。LLM 的输出可能被包裹在 markdown 代码块(```json … ```)中,这一层先提取纯净的 JSON 字符串。

Layer 2 — Schema 校验:使用 JSON Schema 验证 JSON 的结构、类型和约束。不符合的直接拒绝。

Layer 3 — 重试与降级:校验失败时,将错误信息反馈给 LLM,要求重新生成。重试超过阈值后,触发降级策略。

降级策略有三种选择:返回默认值(对于非关键字段)、抛出错误(对于关键字段)、人工介入(对于金融/医疗等高风险场景)。

三、完整的校验网关实现

import Ajv, { JSONSchemaType } from 'ajv';
import OpenAI from 'openai';

const ajv = new Ajv({ allErrors: true });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// 定义输出 Schema
interface UserProfile {
name: string;
age: number;
email: string;
}

const userProfileSchema: JSONSchemaType<UserProfile> = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
age: { type: 'number', minimum: 0, maximum: 150 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'age', 'email'],
additionalProperties: false,
};

// 校验网关配置
interface ValidationConfig {
schema: JSONSchemaType<any>;
maxRetries: number;
defaultValue?: any;
onFallback?: 'error' | 'default' | 'log';
}

class LLMValidationGateway {
private configs: Map<string, ValidationConfig> = new Map();

register(type: string, config: ValidationConfig) {
this.configs.set(type, config);
}

async callWithValidation(
prompt: string,
outputType: string,
): Promise<any> {
const config = this.configs.get(outputType);
if (!config) {
throw new Error(`Unknown output type: ${outputType}`);
}

let lastError = '';
let lastResponse = '';

for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
const context = attempt === 0
? prompt
: this.buildRetryPrompt(prompt, lastResponse, lastError);

const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: context }],
temperature: attempt === 0 ? 0.3 : 0, // 重试时降低温度
response_format: { type: 'json_object' },
});

const raw = response.choices[0].message.content || '';
lastResponse = raw;

// Layer 1: 提取 JSON
const json = this.extractJSON(raw);

if (!json) {
lastError = '输出不是有效的 JSON 格式';
continue;
}

// Layer 2: Schema 校验
const validate = ajv.compile(config.schema);
const valid = validate(json);

if (valid) {
return json; // 成功
}

const errors = validate.errors || [];
lastError = errors
.map(e => `${e.instancePath}: ${e.message}`)
.join('; ');
}

// Layer 3: 降级
return this.handleFallback(config, lastError, lastResponse);
}

// 从 LLM 输出中提取纯净的 JSON
private extractJSON(raw: string): any {
// 尝试直接解析
try {
return JSON.parse(raw);
} catch {}

// 尝试提取 markdown 代码块中的 JSON
const match = raw.match(/```(?:json)?\\s*([\\s\\S]*?)```/);
if (match) {
try {
return JSON.parse(match[1].trim());
} catch {}
}

// 尝试提取第一个完整的 JSON 对象
const objectMatch = raw.match(/\\{[\\s\\S]*\\}/);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch {}
}

return null;
}

// 构建重试 Prompt:将错误信息反馈给 LLM
private buildRetryPrompt(
originalPrompt: string,
failedResponse: string,
errorMessage: string,
): string {
return [
originalPrompt,
'',
'—',
`你上一次的输出不符合格式要求。错误如下:${errorMessage}`,
`你的上一次输出:${failedResponse.substring(0, 200)}`,
'请修正后重新输出,确保格式完全符合要求。',
].join('\\n');
}

// 降级处理
private handleFallback(
config: ValidationConfig,
error: string,
lastResponse: string,
): any {
console.error(`LLM validation fallback: ${error}`);

if (config.onFallback === 'default' && config.defaultValue !== undefined) {
return config.defaultValue;
}

throw new Error(`校验失败(${config.maxRetries + 1} 次尝试后):${error}。最后一次输出:${lastResponse.substring(0, 200)}`);
}
}

// 使用示例
async function main() {
const gateway = new LLMValidationGateway();
gateway.register('userProfile', {
schema: userProfileSchema,
maxRetries: 2,
defaultValue: { name: '未知', age: 0, email: 'unknown@unknown.com' },
onFallback: 'default',
});

try {
const result = await gateway.callWithValidation(
'请提取以下文本中的用户信息:我是张三,今年 28 岁,邮箱 zhangsan@email.com',
'userProfile',
);
console.log('提取结果:', result);
// { name: '张三', age: 28, email: 'zhangsan@email.com' }
} catch (err) {
console.error('提取失败:', err);
}
}

四、性能与成本的平衡

额外延迟:校验本身是毫秒级的(JSON 解析 + Schema 校验)。但如果触发重试,会额外增加一次 LLM 调用,通常耗时 1-5 秒。这是校验网关的主要性能成本。

Token 消耗:重试时,Prompt 包含原始 Prompt + 错误反馈 + 上一次的失败输出。这会额外消耗 200-500 Token。建议重试上限设为 2 次,总成本增加不超过 50%。

监控指标:建议对校验网关监控以下指标:

  • 首次校验通过率(期望 > 85%)
  • 重试后通过率(期望 > 95%)
  • 降级触发次数(期望 < 1%)

如果首次校验通过率持续 < 70%,说明 Prompt 设计有问题,应该优化 Prompt 而非依赖重试。

不适用场景:

  • 实时对话场景(延迟要求 < 200ms)——重试的延迟不可接受
  • 允许模糊输出的场景(如聊天机器人)——过度约束会损害回答的自然度
  • 输出格式极其复杂(嵌套层级 > 5)——JSON Schema 的表达能力有限

五、总结

LLM 输出校验网关用 JSON Schema + 自动重试保证了 AI 输出的格式可靠性。三层防护(解析 → 校验 → 降级)让下游系统不再需要为 LLM 的"随机行为"做防御性编程。

落地路径:先把项目中所有依赖 LLM 输出的地方列出来,为每个定义 JSON Schema;再实现一个通用的校验网关(如本文代码),统一替换原有的"信任 LLM 输出"的逻辑;最后通过监控指标(首次通过率、重试率)持续调优 Prompt。

少即是多——不要在每个调用 LLM 的地方做防御,在管道中统一做一次就够了。

赞(0)
未经允许不得转载:171主机测评 » LLM 输出校验网关:用 JSON Schema 和重试策略构建可靠的 AI 响应管道
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址