凌晨 2:47,某电商团队的 AI 选品功能开始批量出错。告警日志里满是 null------不是程序崩了,是 LLM 返回了一个合法的 JSON,字段都在,但值全是空字符串。
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: -9999、status: "PENDING_APPROVAL"(不在允许的枚举里)、confidence: 1.7(应该是 0-1 的浮点数)。
结构化输出(Structured Outputs)功能 能解决前两类,但对第三类无能为力。
二、第一层:格式守护
格式守护的目标是:拿到 LLM 的原始文本,无论模型输出了什么,都给我一个可以 parse 的 JSON 字符串。
2.1 Fence 剥离
最常见的格式污染是模型在 JSON 外面加了 markdown fence:
json
```json
{"key": "value"}
或者更坑的,fence 前后还有说明文字:
当然,这是您需要的 JSON 数据:
json
{"key": "value"}
python
剥离逻辑:
```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 库覆盖了大多数情况:
bash
pip install json-repair
python
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:
python
# 漏逗号
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 } 结构:
typescript
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 的错误路径是精确的:
typescript
// 如果 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:
python
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 Outputs | Zod/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 → 重试」自动化了:
python
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 格式化成自然语言,附在下一次请求里:
vbnet
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 语义失效的例子
json
{
"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() 可以做异步校验:
typescript
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'):
python
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:忽略有问题的字段,只使用验证通过的字段。适合「推荐列表」类场景:
python
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:语义失效时返回预设的安全默认值。适合「不能返回错误数据」的场景:
typescript
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 类:
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 对象:
python
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 校验只能在流结束后做:
typescript
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)