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

📅 2026/7/8 0:28:24 👁️ 阅读次数 📝 编程学习
LLM 输出校验网关:用 JSON Schema 和重试策略构建可靠的 AI 响应管道

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

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

Prompt 工程师经常遇到这样的情况:你需要 LLM 输出一个结构化的 JSON,包含nameageemail三个字段。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 的地方做防御,在管道中统一做一次就够了。