编程

0 阅读12分钟

凌晨 2:47,某电商团队的 AI 选品功能开始批量出错。告警日志里满是 null——不是程序崩了,是 LLM 返回了一个合法的 JSON,字段都在,但值全是空字符串。

{
  "product_id": "",
  "category": "",
  "score": null,
  "reason": ""
}

工程师花了 40 分钟定位:那天模型供应商推了一次静默更新,新版本在某些 prompt 下倾向于输出「结构合法但内容为空」的响应。代码里只有 JSON.parse(),没有任何后续校验。一个空字符串穿过了整条链路,写进了数据库,推给了用户。

这个故事说明了一件事:LLM 输出的可靠性,不只是「JSON 格式对不对」,而是「输出能不能被业务信任」。

这两件事之间,差了整整三层防线。

一、LLM 输出为什么会出错

把 LLM 输出失效分成三类,每类有不同的成因和发生频率:

格式失效:JSON 根本 parse 不了。常见原因是模型在 JSON 前后加了 markdown fence(```json ```),或者在截断时切断了嵌套结构,或者多了一个不该有的注释。

在没有强制结构约束的情况下,这类失效的概率大约是 2-3%(主流大模型约 1-3%(各模型有差异))。听起来不高,但如果每天跑 10 万次请求,意味着每天有 2000-3000 次硬失败。

Schema 失效:JSON 格式合法,但字段不对。required 字段缺失、数字字段返回字符串、枚举字段返回了不在 schema 里的值。

这类失效容易被忽视,因为 JSON.parse() 成功了,程序没有抛异常,但业务逻辑已经拿到了错误数据。复杂嵌套 schema 下,字段缺失率可以高达 5-15%。

语义失效:格式和 schema 都对,但值不符合业务约束。比如 amount: -9999status: "PENDING_APPROVAL"(不在允许的枚举里)、confidence: 1.7(应该是 0-1 的浮点数)。

结构化输出(Structured Outputs)功能 能解决前两类,但对第三类无能为力。


二、第一层:格式守护

格式守护的目标是:拿到 LLM 的原始文本,无论模型输出了什么,都给我一个可以 parse 的 JSON 字符串。

2.1 Fence 剥离

最常见的格式污染是模型在 JSON 外面加了 markdown fence:

```json
{"key": "value"}

或者更坑的,fence 前后还有说明文字:

当然,这是您需要的 JSON 数据:

{"key": "value"}

剥离逻辑:

```python
import re

def strip_json_fence(text: str) -> str:
    """剥离 markdown fence,提取 JSON 内容"""
    text = text.strip()
    
    # 优先匹配 ```json ... ``` 模式
    m = re.search(r'```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```', text, re.DOTALL)
    if m:
        return m.group(1).strip()
    
    # 如果没有 fence,尝试直接找 JSON 起始位置
    # 模型有时会在 JSON 前面加说明文字
    json_start = text.find('{')
    if json_start == -1:
        json_start = text.find('[')
    if json_start != -1:
        # 从最后一个 } 或 ] 截断
        json_end = max(text.rfind('}'), text.rfind(']'))
        if json_end > json_start:
            return text[json_start:json_end + 1]
    
    return text

2.2 json-repair 自动修复

剥完 fence 之后,JSON 可能还不合法:漏逗号、多余逗号、单引号、截断的字符串。

手写这些 case 的 parser 很累,json_repair 库覆盖了大多数情况:

pip install json-repair
from json_repair import repair_json

def safe_parse_json(text: str) -> dict | None:
    """格式守护:fence剥离 → 修复 → parse"""
    cleaned = strip_json_fence(text)
    try:
        import json
        return json.loads(cleaned)
    except json.JSONDecodeError:
        # 尝试自动修复
        repaired = repair_json(cleaned)
        try:
            return json.loads(repaired)
        except json.JSONDecodeError:
            return None

json_repair 能处理的典型 case:

# 漏逗号
repair_json('{"a": 1 "b": 2}')  # → '{"a": 1, "b": 2}'

# 截断的字符串
repair_json('{"name": "Joh')  # → '{"name": "Joh"}'

# 单引号
repair_json("{'key': 'value'}")  # → '{"key": "value"}'

真实生产中,这一层能把格式失效率从 2-3% 降到 <0.1%。


三、第二层:Schema 校验

JSON 格式合法之后,下一步是校验结构是否符合预期。这里重点讲两个工具:TypeScript 侧的 Zod 和 Python 侧的 Pydantic v2。

3.1 TypeScript:Zod safeParse

Zod 的 safeParse 不抛异常,返回 { success, data, error } 结构:

import { z } from 'zod';

// 定义 schema
const ProductSchema = z.object({
  product_id: z.string().min(1),
  category: z.enum(['electronics', 'clothing', 'food']),
  score: z.number().min(0).max(1),
  reason: z.string().min(10),
});

type Product = z.infer<typeof ProductSchema>;

function validateLLMOutput(raw: unknown): Product | null {
  const result = ProductSchema.safeParse(raw);
  
  if (result.success) {
    return result.data;
  }
  
  // 提取错误路径,用于后续 feedback prompt
  const errors = result.error.issues.map(issue => ({
    path: issue.path.join('.'),
    code: issue.code,
    message: issue.message,
  }));
  
  console.error('Schema validation failed:', errors);
  return null;
}

Zod 的错误路径是精确的:

// 如果 score 是字符串 "0.8" 而不是数字,错误会是:
// { path: 'score', code: 'invalid_type', message: 'Expected number, received string' }

这个错误路径后面会用来构造 feedback prompt,让模型知道哪里出错了。

3.2 Python:Pydantic v2

Pydantic v2 的 model_validate 支持类型 coercion(strict=False 时),可以把 "0.8" 自动转成 0.8

from pydantic import BaseModel, Field, field_validator
from typing import Literal
from enum import Enum

class Category(str, Enum):
    electronics = "electronics"
    clothing = "clothing"
    food = "food"

class ProductOutput(BaseModel):
    product_id: str = Field(min_length=1)
    category: Category
    score: float = Field(ge=0.0, le=1.0)
    reason: str = Field(min_length=10)

def validate_product(data: dict) -> ProductOutput | None:
    try:
        return ProductOutput.model_validate(data)
    except Exception as e:
        # ValidationError 包含详细的字段路径
        errors = e.errors() if hasattr(e, 'errors') else str(e)
        print(f"Validation failed: {errors}")
        return None

3.3 Structured Outputs vs 手工校验

很多人以为用了 Structured Outputs 就不需要校验了。对比一下:

维度Structured OutputsZod/Pydantic 手工校验
JSON 格式保证✓ 100%需要 L1 修复
Schema 结构保证✓ 100%~99%(+retry)
语义约束(范围/枚举/业务规则)✓ 自定义
模型限制特定大模型系列任意模型
schema 复杂度限制递归深度 ≤5 层无限制
额外 token 消耗极低retry 时约 1.3x

结论:如果你使用支持 Structured Outputs 的模型且 schema 简单,Structured Outputs 能解决格式和结构问题,但语义校验还是要自己做。如果多模型混用,手工校验更通用。

3.4 instructor:retry with feedback

instructor 库把「校验 → 失败 → 构造 feedback → 重试」自动化了:

import instructor
from openai import OpenAI  # 此处以某兼容 OpenAI 接口的 SDK 为例
from pydantic import BaseModel, Field

# 以兼容 OpenAI 协议的 SDK 为例
client = instructor.from_openai(OpenAI())

class ProductOutput(BaseModel):
    product_id: str = Field(min_length=1, description="商品唯一ID,非空字符串")
    category: str = Field(description="商品类目: electronics/clothing/food 之一")
    score: float = Field(ge=0.0, le=1.0, description="推荐得分,0到1之间")
    reason: str = Field(min_length=10, description="推荐理由,至少10字")

# instructor 自动处理 retry with validation error feedback
result = client.chat.completions.create(
    model="deepseek-chat"  # 替换为你使用的模型,
    max_retries=3,  # 最多重试3次
    response_model=ProductOutput,
    messages=[
        {"role": "user", "content": "为用户推荐一款电子产品,返回 JSON"}
    ]
)

instructor 失败时会把 Pydantic 的 ValidationError 格式化成自然语言,附在下一次请求里:

Your previous response had validation errors:
- score: Input should be greater than or equal to 0 (got -1)
- reason: String should have at least 10 characters (got 3)

Please fix these issues and return a valid response.

根据 instructor 文档的 benchmark:

  • 首次成功率约 85%(复杂 schema)
  • 三次重试后成功率约 99%
  • 平均 token 消耗约 1.3x(大多数情况首次就对了)

四、第三层:语义校验

Schema 校验通过后,还有一类错误是「字段值在 schema 层面合法,但在业务层面非法」。

4.1 语义失效的例子

{
  "product_id": "prod_12345",   // ✓ 格式对
  "category": "electronics",   // ✓ 枚举内
  "score": 0.95,                // ✓ 0-1之间
  "reason": "this is a reason" // ✓ 超过10字
}

但如果 prod_12345 在数据库里不存在呢?如果 score: 0.95 但商品库存为 0、不应该被推荐呢?

这类约束不是 Zod/Pydantic 能表达的——它们需要访问外部状态。

4.2 自定义语义 validator

Zod 的 .refine().superRefine() 可以做异步校验:

import { z } from 'zod';

const ProductSchema = z.object({
  product_id: z.string().min(1),
  category: z.enum(['electronics', 'clothing', 'food']),
  score: z.number().min(0).max(1),
  reason: z.string().min(10),
}).superRefine(async (data, ctx) => {
  // 语义校验:商品是否存在
  const product = await db.products.findById(data.product_id);
  if (!product) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['product_id'],
      message: `Product ${data.product_id} does not exist in database`,
    });
  }
  
  // 语义校验:库存是否充足
  if (product && product.stock === 0) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['product_id'],
      message: `Product ${data.product_id} is out of stock`,
    });
  }
});

Pydantic 侧用 @model_validator(mode='after')

from pydantic import BaseModel, model_validator

class ProductOutput(BaseModel):
    product_id: str
    category: str
    score: float
    reason: str
    
    @model_validator(mode='after')
    def validate_product_exists(self) -> 'ProductOutput':
        # 语义校验(同步示例)
        product = product_db.get(self.product_id)
        if product is None:
            raise ValueError(f"Product {self.product_id} not found")
        if product.stock == 0:
            raise ValueError(f"Product {self.product_id} is out of stock")
        return self

4.3 语义失效的降级策略

语义失效比 schema 失效更难 retry——因为 LLM 可能根本就不知道哪些商品 ID 存在。

两种降级策略:

Partial Accept:忽略有问题的字段,只使用验证通过的字段。适合「推荐列表」类场景:

from typing import Optional

class ProductOutputPartial(BaseModel):
    product_id: str
    category: str
    score: Optional[float] = None   # 允许缺失
    reason: Optional[str] = None    # 允许缺失

def validate_with_partial(data: dict) -> dict:
    """如果完整校验失败,尝试 partial accept"""
    try:
        return ProductOutput.model_validate(data).model_dump()
    except ValidationError:
        # 降级到 partial schema
        partial = ProductOutputPartial.model_validate(data, strict=False)
        return {k: v for k, v in partial.model_dump().items() if v is not None}

Fallback Default:语义失效时返回预设的安全默认值。适合「不能返回错误数据」的场景:

async function getProductRecommendation(userId: string): Promise<Product> {
  const raw = await callLLM(userId);
  const parsed = safeParse(raw);
  const schemaResult = ProductSchema.safeParse(parsed);
  
  if (!schemaResult.success) {
    return FALLBACK_PRODUCT; // 返回安全默认值
  }
  
  const semanticResult = await validateSemantic(schemaResult.data);
  if (!semanticResult.valid) {
    return FALLBACK_PRODUCT;
  }
  
  return schemaResult.data;
}

const FALLBACK_PRODUCT: Product = {
  product_id: 'default_bestseller',
  category: 'electronics',
  score: 0.5,
  reason: '系统推荐热销商品',
};

五、三层防线整合:生产级 ValidatedLLMClient

把三层防线整合成一个可复用的 TypeScript 类:

import { z } from 'zod';
import OpenAI from 'openai'  // 使用兼容 OpenAI 协议的 SDK;

interface ValidationResult<T> {
  data: T | null;
  success: boolean;
  attempts: number;
  errors: string[];
  fallback: boolean;
}

class ValidatedLLMClient {
  private client: OpenAI  // 兼容 OpenAI 协议的客户端;
  private maxRetries: number;
  
  constructor(apiKey: string, maxRetries = 3) {
    this.client = new OpenAI({ apiKey })  // 可替换为 DeepSeek、Qwen 等兼容客户端;
    this.maxRetries = maxRetries;
  }
  
  async generate<T>(
    prompt: string,
    schema: z.ZodSchema<T>,
    options: {
      model?: string;
      systemPrompt?: string;
      semanticValidator?: (data: T) => Promise<{ valid: boolean; error?: string }>;
      fallback?: T;
    } = {}
  ): Promise<ValidationResult<T>> {
    const { model = 'deepseek-chat', systemPrompt, semanticValidator, fallback } = options;
    const errors: string[] = [];
    let currentPrompt = prompt;
    
    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      // 1. 调用 LLM
      const response = await this.client.chat.completions.create({
        model,
        messages: [
          ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
          { role: 'user' as const, content: currentPrompt },
        ],
        response_format: { type: 'json_object' },
      });
      
      const rawText = response.choices[0].message.content ?? '';
      
      // Layer 1: 格式守护
      const parsed = this.safeParseJSON(rawText);
      if (!parsed) {
        const err = `Attempt ${attempt}: JSON parse failed`;
        errors.push(err);
        currentPrompt = `${prompt}\n\nPrevious error: Invalid JSON output. Return ONLY a valid JSON object.`;
        continue;
      }
      
      // Layer 2: Schema 校验
      const schemaResult = schema.safeParse(parsed);
      if (!schemaResult.success) {
        const schemaErrors = schemaResult.error.issues.map(
          i => `${i.path.join('.')}: ${i.message}`
        );
        const err = `Attempt ${attempt}: Schema validation failed: ${schemaErrors.join(', ')}`;
        errors.push(err);
        currentPrompt = `${prompt}\n\nPrevious response had errors:\n${schemaErrors.join('\n')}\nPlease fix these fields.`;
        continue;
      }
      
      // Layer 3: 语义校验(可选)
      if (semanticValidator) {
        const semanticResult = await semanticValidator(schemaResult.data);
        if (!semanticResult.valid) {
          const err = `Attempt ${attempt}: Semantic validation failed: ${semanticResult.error}`;
          errors.push(err);
          if (attempt === this.maxRetries) {
            // 语义失效时返回 fallback 或 null
            return { data: fallback ?? null, success: false, attempts, errors, fallback: true };
          }
          currentPrompt = `${prompt}\n\nSemantic error: ${semanticResult.error}. Please choose different values.`;
          continue;
        }
      }
      
      return { data: schemaResult.data, success: true, attempts: attempt, errors, fallback: false };
    }
    
    // 全部重试失败
    return { data: fallback ?? null, success: false, attempts: this.maxRetries, errors, fallback: !!fallback };
  }
  
  private safeParseJSON(text: string): unknown | null {
    try {
      // 去除 fence
      const cleaned = text
        .replace(/^```(?:json)?\s*/m, '')
        .replace(/\s*```\s*$/m, '')
        .trim();
      return JSON.parse(cleaned);
    } catch {
      // 尝试找 JSON 起始位置
      const start = text.indexOf('{');
      const end = text.lastIndexOf('}');
      if (start !== -1 && end > start) {
        try {
          return JSON.parse(text.slice(start, end + 1));
        } catch {
          return null;
        }
      }
      return null;
    }
  }
}

// 使用示例
const llmClient = new ValidatedLLMClient(process.env.OPENAI_API_KEY!);

const ProductSchema = z.object({
  product_id: z.string().min(1),
  category: z.enum(['electronics', 'clothing', 'food']),
  score: z.number().min(0).max(1),
  reason: z.string().min(10),
});

const result = await llmClient.generate(
  '推荐一款电子产品,返回 JSON',
  ProductSchema,
  {
    semanticValidator: async (data) => {
      const exists = await productDb.exists(data.product_id);
      return exists
        ? { valid: true }
        : { valid: false, error: `Product ${data.product_id} not found` };
    },
    fallback: { product_id: 'bestseller_001', category: 'electronics', score: 0.5, reason: '热销商品推荐' },
  }
);

if (result.success) {
  console.log('Product:', result.data);
} else {
  console.log('Used fallback after', result.attempts, 'attempts:', result.errors);
}

这个类做了什么:

  • 三层防线依次执行,任意一层失败都进入 retry
  • retry 时把错误信息嵌入 prompt,让模型知道哪里出错
  • 全部重试失败时返回 fallback 而不是抛异常
  • 记录每次尝试的错误,方便后续分析

六、性能与成本权衡

做验证会不会让系统更慢、更贵?

延迟 overhead:三层防线的本地计算(JSON parse + Zod/Pydantic 校验 + 同步语义检查)在大多数情况下 <5ms,相比 LLM 调用本身的 300-2000ms,可以忽略不计。异步语义校验(数据库查询)的延迟取决于数据库性能,通常 1-20ms。

retry 的 token 成本:根据 instructor 的数据,使用 Pydantic 校验 + retry with feedback,约 90% 的请求首次即通过,平均 token 消耗约 1.3x。

但是,这 1.3x 的成本该怎么看?

假设不做验证:

  • 格式失败率 2%:每 100 次请求 2 次硬失败,需要重试
  • Schema 失败率 5%:5 次静默错误进入业务逻辑,可能导致数据污染

数据污染一旦写入数据库,修复成本远超 30% 的额外 token 消耗。

结论:三层防线是一次性的架构投入,长期收益远超成本。


七、流式场景的特殊处理

如果你用的是流式输出(SSE),情况会复杂一点:JSON 是一块一块来的,没法等到完整再 parse。

7.1 instructor 的 Partial 模式

instructor 支持 Partial[Model],在流式场景中随着 token 的到来逐步构建 partial 对象:

import instructor
from pydantic import BaseModel
from typing import Iterable

# 以兼容 OpenAI 协议的 SDK 为例
client = instructor.from_openai(OpenAI())

class ProductOutput(BaseModel):
    product_id: str
    category: str
    score: float
    reason: str

# 流式输出,逐步构建 partial 对象
for partial_product in client.chat.completions.create_partial(
    model="deepseek-chat"  # 替换为你使用的模型,
    response_model=ProductOutput,
    messages=[{"role": "user", "content": "推荐电子产品"}],
    stream=True,
):
    # partial_product 是部分填充的 ProductOutput
    # 已到达的字段有值,未到达的字段为 None
    if partial_product.product_id:
        print(f"Product ID so far: {partial_product.product_id}")

7.2 流式 JSON 的 Schema 校验时机

流式场景里,完整 Schema 校验只能在流结束后做:

async function* streamWithValidation<T>(
  prompt: string,
  schema: z.ZodSchema<T>
): AsyncGenerator<{ partial: string; complete?: T }> {
  let accumulated = '';
  
  const stream = await openai.chat.completions.create({
    model: 'deepseek-chat'  // 替换为你使用的模型,
    messages: [{ role: 'user', content: prompt }],
    stream: true,
    response_format: { type: 'json_object' },
  });
  
  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta?.content ?? '';
    accumulated += delta;
    yield { partial: accumulated }; // 实时把 partial 文本给前端
  }
  
  // 流结束后做完整校验
  const parsed = JSON.parse(accumulated);
  const result = schema.safeParse(parsed);
  if (result.success) {
    yield { partial: accumulated, complete: result.data };
  } else {
    // 流完成但 schema 校验失败:触发重试
    throw new ValidationError(result.error.issues);
  }
}

八、生产落地 Checklist

把三层防线整理成可执行的 checklist:

Layer 1:格式守护

  • 所有 LLM 响应经过 fence 剥离(去除 ```json ```
  • 使用 json_repair 或类似库处理轻微格式错误
  • JSON.parse 的 try/catch 有明确的失败处理,不是 silent catch

Layer 2:Schema 校验

  • 每个 LLM 输出对应一个 Zod/Pydantic schema
  • 使用 safeParse 而不是 parse(不让未捕获的异常进入业务逻辑)
  • 校验失败时提取 error.issues 路径,写入日志
  • 需要多模型支持时,Schema 校验比 Structured Outputs 更通用

Layer 3:语义校验

  • 识别哪些字段有「schema 合法但业务非法」的可能性
  • 对这些字段写自定义 validator(数据库查询、范围约束等)
  • 确定语义失败的降级策略:partial accept 还是 fallback default
  • 语义失败的日志里要记录「哪个字段违反了哪个业务规则」

Retry 策略

  • 格式/schema 失败时,把错误信息构造进下一次 prompt(不要原样重试)
  • 语义失败时,判断是否值得重试(LLM 可能不知道正确答案)
  • 重试次数上限(建议 3 次),超过上限返回 fallback 而不是死循环
  • 重试次数和错误类型写入 metrics(用于后续优化)

凌晨 2:47 的那次告警,根本原因不是 LLM 的问题——是工程没有在 LLM 和业务逻辑之间搭一道门。

三层防线做的事情,就是把这道门从「粗糙的 JSON.parse」换成「有防线、有降级、有 metrics 的验证器」。

从 demo 到生产,这是必须要过的一关。


参考资料:Zod 文档(zod.dev)、Pydantic v2 文档、instructor 库文档、结构化输出(Structured Outputs)相关文档、json-repair 库(mangiucugna/json_repair)