创建日期:2026-09-20 | 最近更新:2026-09-20 全部代码完整贴出(含注释,不做「……」省略);运行输出为本机真实执行结果(Mock 模式,无需 API Key)。源码与日志留档在
source/agent-engineering/。 前置:Agent 工程化总览。
结构化输出五层防护链:从 3% 崩溃到零格式异常
做 LLM 应用遇到的第一个工程化问题,几乎都是它:模型返回的 JSON 是坏的。
线上实测的经验值:裸用
JSON.parse()解析模型输出,失败率约 3%~5%。失败原因五花八门——截断、单引号、尾逗号、Markdown 包裹、字段类型错、枚举越界、必填缺失。这些错误直接导致崩溃,用户看到白屏。这篇把防护做成五层阶梯,每层都给完整实现,并用真实输出说明「上一层为什么不够」。
一、先定义「坏」有哪些种(这决定了分层)
要分层,先得把错误分类。这是全套设计的起点:
export const UserExtractionSchema = z.object({
name: z.string().min(1).describe("用户姓名"),
age: z.number().int().min(0).max(150).describe("用户年龄"),
email: z.string().email().nullable().optional().describe("邮箱地址,没有则为 null 或缺省"),
tags: z
.array(z.enum(["VIP", "new", "inactive", "churned"]))
.min(1)
.describe("用户标签"),
confidence: z.number().min(0).max(1).describe("提取置信度"),
});
export type UserExtraction = z.infer<typeof UserExtractionSchema>;
这个 schema 是故意设计得足够复杂的:必填字段、枚举、数值范围、可空字段都有,这样才能暴露模型输出的各类问题。
配套的故障样本库——模拟线上真实会遇到的坏输出:
export const MOCK_RESPONSES = {
// 完全合法的 JSON - L0 成功场景
validJson: JSON.stringify({
name: "张三",
age: 28,
email: "zhangsan@example.com",
tags: ["VIP", "new"],
confidence: 0.95,
}),
// 缺少引号和尾部括号的坏 JSON - L0 直接 crash
brokenJson: "{name: '张三', age: 28, tags: ['VIP'], confidence: 0.9",
// 截断的 JSON - 展示 jsonrepair 的修复能力
truncatedJson:
'{"name":"张三","age":28,"email":"zhangsan@example.com","tags":["VIP","new"],"confidence":0.9',
// 被 Markdown 代码块包裹的 JSON - L0/L1/L2 都会解析失败
extraTextJson:
'```json\n{"name":"张三","age":28,"tags":["VIP"],"confidence":0.95}\n```',
// 类型错误的 JSON(age 是字符串)- L1 能解析但 L2 zod 校验失败
wrongTypeJson: JSON.stringify({
name: "李四",
age: "二十八",
tags: ["new"],
confidence: 0.8,
}),
// 枚举值错误 - tags 包含不存在的枚举值
wrongEnumJson: JSON.stringify({
name: "王五",
age: 35,
tags: ["super-vip"],
confidence: 0.7,
}),
// 缺少必填字段(没有 tags)
missingFieldJson: JSON.stringify({
name: "赵六",
age: 42,
confidence: 0.6,
}),
} as const;
export type MockScenario = keyof typeof MOCK_RESPONSES;
七种坏法可以归成两类,这个划分贯穿全文:
| 类别 | 例子 | 谁能修 |
|---|---|---|
| 格式错误(解析就失败) | 截断、单引号、尾逗号、Markdown 包裹 | jsonrepair ✅ |
| 语义错误(能解析,但内容不对) | 类型错、枚举越界、必填缺失 | 只能重试 ❌ jsonrepair 修不了 |
二、L0:裸调用 + JSON.parse()
import { MOCK_RESPONSES, type UserExtraction } from "./schemas.js";
export interface RawParseOptions {
mockScenario?: keyof typeof MOCK_RESPONSES;
useRealLLM?: boolean;
}
function isMockMode(options?: RawParseOptions): boolean {
if (options?.useRealLLM) return false;
return (
process.env.USE_MOCK === "true" ||
process.env.MOCK_MODE === "true" ||
!process.env.OPENAI_API_KEY
);
}
async function callLLM(
prompt: string,
options?: RawParseOptions,
): Promise<string> {
if (isMockMode(options)) {
await new Promise((r) => setTimeout(r, 50 + Math.random() * 100));
const scenario = options?.mockScenario ?? "validJson";
return MOCK_RESPONSES[scenario];
}
// 真实 LLM 调用
const { getLLM } = await import("@offerai/shared");
const llm = getLLM();
const response = await llm.invoke(prompt);
return response.content.toString();
}
// 注意看这个函数——没有 try-catch,没有任何校验
export async function rawParseLLMOutput(
prompt: string,
options?: RawParseOptions,
): Promise<UserExtraction> {
const raw = await callLLM(prompt, options);
// 直接 JSON.parse,出错就 crash
const parsed = JSON.parse(raw);
// 这里用 as 强制类型断言——这只是 TypeScript 编译时的谎言
// 运行时 parsed 可能是任何东西,TS 类型检查在这里完全没用
return parsed as UserExtraction;
}
真实运行输出:
【L0】裸调用 + JSON.parse()
✅ 成功: {
"name": "张三",
"age": 28,
"email": "zhangsan@example.com",
"tags": ["VIP", "new"],
"confidence": 0.95
}
❌ 坏 JSON 直接崩溃: Expected property name or '}' in JSON at position 1 (line 1 column 2)
两个必须点破的地方:
as UserExtraction是编译时的谎言。TypeScript 类型在编译后完全消失,运行时parsed可以是任何东西——parsed.age完全可能是字符串"二十八",而类型系统不会拦你。- 只靠 try-catch 不够。catch 到了你也不知道怎么恢复——用户看到的仍然是一个 500。
三、L1:JSON Mode + try-catch
import { ChatOpenAI } from "@langchain/openai";
import { MOCK_RESPONSES, UserExtractionSchema, type UserExtraction, resolveModelName } from "./schemas.js";
export type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; stage: "json_mode" | "parse" };
export interface JsonModeOptions {
mockScenario?: keyof typeof MOCK_RESPONSES;
useRealLLM?: boolean;
model?: string;
}
function isMockMode(options?: JsonModeOptions): boolean {
if (options?.useRealLLM) return false;
return (
process.env.USE_MOCK === "true" ||
process.env.MOCK_MODE === "true" ||
!process.env.OPENAI_API_KEY
);
}
async function callLLMWithJsonMode(
prompt: string,
options?: JsonModeOptions,
): Promise<string> {
if (isMockMode(options)) {
await new Promise((r) => setTimeout(r, 50 + Math.random() * 100));
const scenario = options?.mockScenario ?? "validJson";
return MOCK_RESPONSES[scenario];
}
// 关键参数——response_format 强制 JSON 输出
const llm = new ChatOpenAI({
modelName: resolveModelName(options?.model),
modelKwargs: { response_format: { type: "json_object" } },
});
// 必须在 prompt 中提醒模型输出 JSON,否则 JSON Mode 可能不生效
const jsonPrompt = `${prompt}\n\n请以 JSON 格式输出结果。`;
const response = await llm.invoke(jsonPrompt);
return response.content.toString();
}
export async function jsonModeParse(
prompt: string,
options?: JsonModeOptions,
): Promise<ParseResult<UserExtraction>> {
try {
const raw = await callLLMWithJsonMode(prompt, options);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (parseErr) {
return {
ok: false,
error: `JSON 解析失败: ${(parseErr as Error).message}\n原始输出: ${raw.slice(0, 200)}`,
stage: "parse",
};
}
// 这里仍然是强制类型断言!
// 虽然 JSON 合法了,但内容是否符合 schema 完全没检查
return { ok: true, data: parsed as UserExtraction };
} catch (err) {
return {
ok: false,
error: `LLM 调用失败: ${(err as Error).message}`,
stage: "json_mode",
};
}
}
真实运行输出:
【L1】JSON Mode + try-catch
✅ 合法 JSON: 成功
⚠️ 类型错误 JSON (L1检测不到): 返回了错误数据: age=二十八
看第二行——这就是 L1 的边界:它把「抛异常」变成了「返回错误对象」(可控了),但**age 拿到的是字符串 "二十八",L1 完全检测不到**,还会当成成功返回给业务。
两个容易踩的点:
- JSON Mode 要求 prompt 里出现 "json" 关键词,否则可能不生效(所以代码里加了「请以 JSON 格式输出结果」);
response_format是通过modelKwargs传的,不同 provider 的兼容层支持度不一。
四、L2:Zod 运行时校验
import { ZodError } from "zod";
import { ChatOpenAI } from "@langchain/openai";
import { MOCK_RESPONSES, UserExtractionSchema, type UserExtraction, resolveModelName } from "./schemas.js";
import type { ParseResult } from "./l1-json-mode.js";
export interface ZodValidateOptions {
mockScenario?: keyof typeof MOCK_RESPONSES;
useRealLLM?: boolean;
model?: string;
}
function isMockMode(options?: ZodValidateOptions): boolean {
if (options?.useRealLLM) return false;
return (
process.env.USE_MOCK === "true" ||
process.env.MOCK_MODE === "true" ||
!process.env.OPENAI_API_KEY
);
}
async function callLLM(
prompt: string,
options?: ZodValidateOptions,
): Promise<string> {
if (isMockMode(options)) {
await new Promise((r) => setTimeout(r, 50 + Math.random() * 100));
const scenario = options?.mockScenario ?? "validJson";
return MOCK_RESPONSES[scenario];
}
const llm = new ChatOpenAI({
modelName: resolveModelName(options?.model),
modelKwargs: { response_format: { type: "json_object" } },
});
const jsonPrompt = `${prompt}\n\n请严格按照以下 JSON Schema 输出:\n${JSON.stringify(UserExtractionSchema.shape, null, 2)}\n\n请以 JSON 格式输出结果。`;
const response = await llm.invoke(jsonPrompt);
return response.content.toString();
}
function formatZodErrors(error: ZodError): string {
const issues = error.issues.map((issue) => {
const path = issue.path.join(".") || "(root)";
return ` - ${path}: ${issue.message} (${issue.code})`;
});
return `Schema 校验失败:\n${issues.join("\n")}`;
}
export async function zodValidatedParse(
prompt: string,
options?: ZodValidateOptions,
): Promise<ParseResult<UserExtraction>> {
try {
const raw = await callLLM(prompt, options);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (parseErr) {
return {
ok: false,
error: `JSON 解析失败: ${(parseErr as Error).message}`,
stage: "parse",
};
}
// 这是关键区别——用 safeParse 而不是 parse
// safeParse 不抛异常,返回 { success: true, data } 或 { success: false, error }
// 可以优雅地处理校验失败,获取详细的字段级错误信息
const validationResult = UserExtractionSchema.safeParse(parsed);
if (!validationResult.success) {
return {
ok: false,
error: formatZodErrors(validationResult.error),
stage: "parse",
};
}
// 这里 validationResult.data 是真正类型安全的!
// 不是强制类型断言,而是 Zod 校验通过后才返回
return { ok: true, data: validationResult.data };
} catch (err) {
return {
ok: false,
error: `LLM 调用失败: ${(err as Error).message}`,
stage: "json_mode",
};
}
}
真实运行输出:
【L2】Zod Schema 运行时校验
✅ 合法数据: 校验通过
❌ 类型错误被捕获: 校验失败: Schema 校验失败:
❌ 缺失字段被捕获: 校验失败: 缺少必填字段
这一层的本质:把「类型安全」从编译时搬到了运行时。as UserExtraction 和 safeParse 的区别,就是「我说它是」和「它真的是」。
两个设计细节值得学:
- 用
safeParse而不是parse:parse抛异常(要靠 try-catch),safeParse返回判别联合(discriminated union),能拿字段级错误信息; - 把 schema 一起塞进 prompt(
JSON.stringify(UserExtractionSchema.shape)):让模型知道要输出什么结构,从源头降低出错率。
为什么不用 class-validator:Zod 一行
z.infer同时拿到编译时类型 + 运行时校验,单一数据源;class-validator 要装饰器 + 类,两份定义容易不同步。详见 zod 系列。
⚠️ 一个真实踩过的坑:.optional() 会被 provider 拒绝
schemas.ts 的 email 字段上留着这段注释,值得单独拎出来:
// ⚠️ 必须同时带 .nullable(),只写 .optional() 会被 withStructuredOutput 拒绝:
// Zod field at `#/definitions/extract/properties/email` uses `.optional()`
// without `.nullable()` which is not supported by the API.
email: z.string().email().nullable().optional().describe("邮箱地址,没有则为 null 或缺省"),
三种写法的实测差异:
| 写法 | provider | 省略 email | email: null |
|---|---|---|---|
.optional() | ❌ 被拒 | 可过 | 不可过 |
.nullable() | ✅ 可用 | 被拒(变必填) | 可过 |
.nullable().optional() | ✅ 可用 | 可过 | 可过 |
根因:OpenAI 风格的 strict 结构化输出要求「没有值」用 null 表达,而不是把字段从 required 里删掉。
五、L3:withStructuredOutput + jsonrepair 自动修复
import { jsonrepair } from "jsonrepair";
import { ChatOpenAI } from "@langchain/openai";
import { MOCK_RESPONSES, UserExtractionSchema, type UserExtraction, resolveModelName } from "./schemas.js";
import type { ParseResult } from "./l1-json-mode.js";
export interface StructuredOutputOptions {
mockScenario?: keyof typeof MOCK_RESPONSES;
useRealLLM?: boolean;
model?: string;
}
function isMockMode(options?: StructuredOutputOptions): boolean {
if (options?.useRealLLM) return false;
return (
process.env.USE_MOCK === "true" ||
process.env.MOCK_MODE === "true" ||
!process.env.OPENAI_API_KEY
);
}
async function mockCall(options?: StructuredOutputOptions): Promise<string> {
await new Promise((r) => setTimeout(r, 50 + Math.random() * 100));
const scenario = options?.mockScenario ?? "validJson";
return MOCK_RESPONSES[scenario];
}
function stripMarkdownWrappers(text: string): string {
// 移除 Markdown 代码块包裹
let cleaned = text.trim();
if (cleaned.startsWith("```")) {
const firstNewline = cleaned.indexOf("\n");
if (firstNewline !== -1) {
cleaned = cleaned.slice(firstNewline + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.slice(0, -3);
}
}
return cleaned.trim();
}
export async function structuredOutputWithRepair(
prompt: string,
options?: StructuredOutputOptions,
): Promise<ParseResult<UserExtraction>> {
try {
if (isMockMode(options)) {
// Mock 模式:走完整的修复流程,让学员看到效果
const raw = await mockCall(options);
return repairAndValidate(raw);
}
// 第一层:尝试 withStructuredOutput
try {
const llm = new ChatOpenAI({
modelName: resolveModelName(options?.model),
});
// LangChain.js v1 直接接受 Zod schema
// 内部自动:1) 转 JSON Schema 注入 prompt 2) 绑定 tool calling 3) 解析输出
const structuredLlm = llm.withStructuredOutput(UserExtractionSchema);
const result = await structuredLlm.invoke(prompt);
return { ok: true, data: result };
} catch (structuredErr) {
console.log(
`[L3] withStructuredOutput 失败,尝试 jsonrepair 兜底: ${(structuredErr as Error).message}`,
);
// 第二层兜底:手动获取字符串 → 清理 → jsonrepair → JSON.parse → zod
const llm = new ChatOpenAI({
modelName: resolveModelName(options?.model),
modelKwargs: { response_format: { type: "json_object" } },
});
const jsonPrompt = `${prompt}\n\n请以 JSON 格式输出结果。`;
const response = await llm.invoke(jsonPrompt);
const raw = response.content.toString();
return repairAndValidate(raw);
}
} catch (err) {
return {
ok: false,
error: `LLM 调用失败: ${(err as Error).message}`,
stage: "json_mode",
};
}
}
function repairAndValidate(raw: string): ParseResult<UserExtraction> {
let cleaned = stripMarkdownWrappers(raw);
let parsed: unknown;
// 先尝试直接 parse
try {
parsed = JSON.parse(cleaned);
} catch {
// 直接 parse 失败,尝试 jsonrepair
try {
cleaned = jsonrepair(cleaned);
parsed = JSON.parse(cleaned);
console.log("[L3] jsonrepair 修复成功");
} catch (repairErr) {
return {
ok: false,
error: `JSON 修复失败: ${(repairErr as Error).message}\n原始输出: ${raw.slice(0, 200)}`,
stage: "parse",
};
}
}
// zod 校验
const validationResult = UserExtractionSchema.safeParse(parsed);
if (!validationResult.success) {
const issues = validationResult.error.issues
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
.join("; ");
return {
ok: false,
error: `Schema 校验失败(jsonrepair 只能修复格式,不能修复语义错误): ${issues}`,
stage: "parse",
};
}
return { ok: true, data: validationResult.data };
}
真实运行输出:
【L3】withStructuredOutput + jsonrepair 自动修复
[L3] jsonrepair 修复成功
🔧 截断 JSON 修复: 修复成功,name=张三
🔧 Markdown包裹清理: 清理成功,name=张三
withStructuredOutput 比 JSON Mode 好在哪里(这题面试常考):
- 它不仅强制 JSON 格式,还会根据 schema 自动调整 prompt;
- 优先走 function calling / tool calling,比 JSON Mode 更稳定;
- 直接传 Zod schema,内部自动转 JSON Schema;
- 返回值已经是类型化对象,不需要手动 parse;
- 解析失败时 LangChain 内部有重试。
但 jsonrepair 有明确边界——看 L3 的错误信息里那句话就写着:它只能修「格式」,不能修「语义」。
六、错误分类器:不是所有错误都值得重试
L4 之前必须先有这个。盲目重试是 L1 的病根。
import { ZodError } from "zod";
export enum ErrorCategory {
Retryable = "retryable",
NonRetryable = "non_retryable",
Unknown = "unknown",
}
// 自定义错误类,可以在抛出时直接标记类别
export class RetryableError extends Error {
readonly category = ErrorCategory.Retryable as const;
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = "RetryableError";
}
}
export class NonRetryableError extends Error {
readonly category = ErrorCategory.NonRetryable as const;
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = "NonRetryableError";
}
}
const RETRYABLE_PATTERNS = [
/429/i,
/rate.?limit/i,
/timeout/i,
/ETIMEDOUT/i,
/ECONNRESET/i,
/ECONNREFUSED/i,
/ENOTFOUND/i,
/5\d{2}/,
/too many requests/i,
/temporarily unavailable/i,
/overloaded/i,
];
const NON_RETRYABLE_PATTERNS = [
/400/i,
/401/i,
/403/i,
/404/i,
/invalid.?request/i,
/unauthorized/i,
/forbidden/i,
/not.?found/i,
/bad.?request/i,
/invalid.?api.?key/i,
/context.?length/i,
/invalid.?json/i,
/zod/i,
/validation/i,
/parse/i,
];
export function classifyError(error: unknown): ErrorCategory {
// 如果是我们自定义的错误类,直接返回类别
if (error instanceof RetryableError) {
return ErrorCategory.Retryable;
}
if (error instanceof NonRetryableError) {
return ErrorCategory.NonRetryable;
}
// Zod 校验错误 - 模型输出不符合 schema,重试大概率还是错
if (error instanceof ZodError) {
return ErrorCategory.NonRetryable;
}
// JSON 解析错误 - 格式错误,重试可能还是同样的问题
if (error instanceof SyntaxError && error.message.includes("JSON")) {
return ErrorCategory.NonRetryable;
}
// jsonrepair 修复失败错误
if (
error instanceof Error &&
(error.name === "JSONRepairError" || error.message.includes("repair"))
) {
return ErrorCategory.NonRetryable;
}
const message = extractMessage(error);
// 检查可重试模式
for (const pattern of RETRYABLE_PATTERNS) {
if (pattern.test(message)) {
return ErrorCategory.Retryable;
}
}
// 检查不可重试模式
for (const pattern of NON_RETRYABLE_PATTERNS) {
if (pattern.test(message)) {
return ErrorCategory.NonRetryable;
}
}
// 未知错误保守处理——当作可重试,但要打日志
// 因为误判一个不可重试错误为可重试,最多浪费几次 Token;
// 误判一个可重试错误为不可重试,就直接丢失了成功的机会
console.warn(`[ErrorClassifier] 未知错误类型,保守判定为可重试: ${message}`);
return ErrorCategory.Unknown;
}
function extractMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof (error as { message: unknown }).message === "string"
) {
return (error as { message: string }).message;
}
return String(error);
}
「未知错误保守判为可重试」是一个不对称权衡,理由写在注释里,很值得抄进自己的代码:
误判「不可重试」为「可重试」→ 最多浪费几次 Token; 误判「可重试」为「不可重试」→ 直接丢掉成功的机会。
代价不对称时,往「损失更小」的那一侧兜底。
七、DLQ:重试耗尽后的最后一道防线
import { ErrorCategory } from "./error-classifier.js";
export interface DLQMessage<T = unknown> {
id: string;
payload: T;
error: string;
attempts: number;
timestamp: number;
lastErrorCategory: ErrorCategory;
}
export interface DeadLetterQueue<T = unknown> {
enqueue(
message: Omit<DLQMessage<T>, "id" | "timestamp">,
): string;
dequeue(id: string): DLQMessage<T> | undefined;
peek(id: string): DLQMessage<T> | undefined;
list(): DLQMessage<T>[];
size(): number;
clear(): void;
}
export function createDLQ<T = unknown>(): DeadLetterQueue<T> {
// 教学场景用内存 Map 实现,生产环境应使用 Redis 或数据库
const store = new Map<string, DLQMessage<T>>();
function generateId(): string {
// 优先使用 crypto.randomUUID(),降级到时间戳+随机数
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
return {
enqueue(message) {
const id = generateId();
const fullMessage: DLQMessage<T> = {
...message,
id,
timestamp: Date.now(),
};
store.set(id, fullMessage);
console.log(
`[DLQ] 消息入队: id=${id}, attempts=${message.attempts}, error=${message.error.slice(0, 100)}`,
);
return id;
},
dequeue(id) {
const message = store.get(id);
if (message) {
store.delete(id);
console.log(`[DLQ] 消息出队: id=${id}`);
}
return message;
},
peek(id) {
return store.get(id);
},
list() {
// 按时间倒序排列,最新的在前
return Array.from(store.values()).sort((a, b) => b.timestamp - a.timestamp);
},
size() {
return store.size;
},
clear() {
const count = store.size;
store.clear();
console.log(`[DLQ] 已清空 ${count} 条消息`);
},
};
}
// 全局默认 DLQ 实例,方便直接使用
// 生产环境应该通过依赖注入传递,而不是用全局单例
export const defaultDLQ = createDLQ<unknown>();
DLQ 的定位要说清楚:它不是为了「让请求成功」,而是为了**「不丢消息」**——重试耗尽后留下证据,可以人工干预或离线修复。生产环境必须持久化(Redis List / Kafka DLQ topic / 数据库表),并且要监控 DLQ 堆积:堆积意味着系统出了系统性问题。
八、L4:完整五层防护链
import pRetry, { AbortError } from "p-retry";
import { type ZodSchema } from "zod";
import { jsonrepair } from "jsonrepair";
import { ChatOpenAI } from "@langchain/openai";
import { classifyError, ErrorCategory, type DeadLetterQueue, createDLQ } from "./index.js";
import { MOCK_RESPONSES, resolveModelName } from "./schemas.js";
export interface FullGuardOptions<T> {
schema: ZodSchema<T>;
maxRetries?: number;
useDLQ?: boolean;
dlq?: DeadLetterQueue<{ prompt: string }>;
mockMode?: boolean;
mockScenario?: keyof typeof MOCK_RESPONSES;
model?: string;
}
export interface FullGuardSuccess<T> {
ok: true;
data: T;
attempts: number;
repaired: boolean;
}
export interface FullGuardFailure {
ok: false;
error: string;
dlqId?: string;
attempts: number;
category: ErrorCategory;
}
export type FullGuardResult<T> = FullGuardSuccess<T> | FullGuardFailure;
function isMockMode(options?: { mockMode?: boolean }): boolean {
if (options?.mockMode) return true;
return (
process.env.USE_MOCK === "true" ||
process.env.MOCK_MODE === "true" ||
!process.env.OPENAI_API_KEY
);
}
function stripMarkdownWrappers(text: string): string {
let cleaned = text.trim();
if (cleaned.startsWith("```")) {
const firstNewline = cleaned.indexOf("\n");
if (firstNewline !== -1) {
cleaned = cleaned.slice(firstNewline + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.slice(0, -3);
}
}
return cleaned.trim();
}
async function attemptStructuredOutput<T>(
prompt: string,
schema: ZodSchema<T>,
options: {
mockMode?: boolean;
mockScenario?: keyof typeof MOCK_RESPONSES;
model?: string;
},
): Promise<{ data: T; repaired: boolean }> {
let repaired = false;
if (isMockMode(options)) {
await new Promise((r) => setTimeout(r, 30 + Math.random() * 50));
const scenario = options.mockScenario ?? "validJson";
let raw = MOCK_RESPONSES[scenario];
// 模拟场景:截断 JSON 需要修复
if (scenario === "truncatedJson" || scenario === "brokenJson" || scenario === "extraTextJson") {
try {
const cleaned = stripMarkdownWrappers(raw);
raw = jsonrepair(cleaned);
repaired = true;
} catch {
// 修复失败,继续尝试 parse,让它抛错
}
}
const parsed = JSON.parse(raw);
const validationResult = schema.safeParse(parsed);
if (!validationResult.success) {
throw validationResult.error;
}
return { data: validationResult.data, repaired };
}
// 真实 LLM 调用
try {
const llm = new ChatOpenAI({
modelName: resolveModelName(options.model),
});
const structuredLlm = llm.withStructuredOutput(schema);
const data = (await structuredLlm.invoke(prompt)) as T;
return { data, repaired: false };
} catch (structuredErr) {
// withStructuredOutput 失败,尝试 jsonrepair 兜底
console.log(`[L4] withStructuredOutput 失败,尝试 jsonrepair 兜底`);
const llm = new ChatOpenAI({
modelName: resolveModelName(options.model),
modelKwargs: { response_format: { type: "json_object" } },
});
const jsonPrompt = `${prompt}\n\n请以 JSON 格式输出结果。`;
const response = await llm.invoke(jsonPrompt);
let raw = response.content.toString();
raw = stripMarkdownWrappers(raw);
try {
raw = jsonrepair(raw);
repaired = true;
} catch (repairErr) {
throw new Error(`JSON 修复失败: ${(repairErr as Error).message}`);
}
const parsed = JSON.parse(raw);
const validationResult = schema.safeParse(parsed);
if (!validationResult.success) {
throw validationResult.error;
}
return { data: validationResult.data, repaired };
}
}
export async function structuredOutputFullGuard<T>(
prompt: string,
options: FullGuardOptions<T>,
): Promise<FullGuardResult<T>> {
const maxRetries = options.maxRetries ?? 3;
const useDLQ = options.useDLQ ?? true;
const dlq = options.dlq ?? createDLQ<{ prompt: string }>();
let attempts = 0;
let lastCategory: ErrorCategory = ErrorCategory.Unknown;
let repaired = false;
try {
const data = await pRetry(
async () => {
attempts++;
try {
const result = await attemptStructuredOutput(prompt, options.schema, {
mockMode: options.mockMode,
mockScenario: options.mockScenario,
model: options.model,
});
repaired = result.repaired;
return result.data;
} catch (err) {
const category = classifyError(err);
lastCategory = category;
if (category === ErrorCategory.NonRetryable) {
throw new AbortError(err as Error);
}
throw err;
}
},
{
retries: maxRetries,
minTimeout: 300,
maxTimeout: 3000,
factor: 2,
onFailedAttempt: (error: any) => {
console.log(
`[L4] 第 ${error.attemptNumber} 次尝试失败 (还有 ${error.retriesLeft} 次重试): ${error.message}`,
);
},
},
);
return {
ok: true,
data,
attempts,
repaired,
};
} catch (err) {
const error = err as Error;
const category = lastCategory;
const errorMessage = error.message;
console.log(`[L4] 所有 ${attempts} 次尝试均失败,错误类别: ${category}`);
let dlqId: string | undefined;
if (useDLQ) {
dlqId = dlq.enqueue({
payload: { prompt },
error: errorMessage,
attempts,
lastErrorCategory: category,
});
console.log(`[L4] 消息已写入 DLQ: ${dlqId}`);
}
return {
ok: false,
error: errorMessage,
dlqId,
attempts,
category,
};
}
}
真实运行输出(两种场景对比):
【L4】完整五层防护链:错误分类 + 智能重试 + DLQ
✅ 正常请求: 成功(1次尝试,修复=false)
[L4] 所有 1 次尝试均失败,错误类别: non_retryable
[DLQ] 消息入队: id=3341c846-..., attempts=1, error=[{ "code": "invalid_type", ... }]
[L4] 消息已写入 DLQ: 3341c846-...
❌ Zod校验失败(不可重试): 不重试(1次尝试),DLQ=已入队
📊 DLQ 当前消息数: 1
- id=3341c846, attempts=1, category=non_retryable
注意「类型错误」那一行只尝试了 1 次——maxRetries: 2 配了,但分类器判定它是 non_retryable,直接 AbortError 终止重试。这就是「智能重试」和「盲目重试」的区别:L1 那种写法会白白烧 3 次 Token,而结果是同样的错。
为什么链路顺序是「修复 → 校验 → 分类 → 重试 → DLQ」
这个问题在注释里答得很清楚,值得背下来:
- 先修复(jsonrepair):能修的先修掉,不浪费一次重试;
- 再校验(zod):修完之后确认结构是否真的符合要求;
- 再分类(classifyError):判断这个错误值不值得重试;
- 再重试(p-retry):只重试可重试的;
- 最后 DLQ:重试耗尽也不丢消息,留待人工或离线处理。
九、这条阶梯的「哲学」
五层其实在做一件很朴素的事:把「不可控」逐层收窄成「可控」。
L0 模型输出 → JSON.parse → 直接用 (不可控:任何异常都崩)
L1 模型输出 → 解析守卫 → 断言 (可控:不崩了,但数据可能是错的)
L2 模型输出 → 解析守卫 → schema 校验 (可信:结构一定对)
L3 模型输出 → 修复 → 校验 (更可信:坏的格式也能救回来)
L4 模型输出 → 修复 → 校验 → 分类 → 重试 → DLQ (可运营:错了也不丢,可追查)
每一层增加的不是「功能」,而是「确定性」。 这也是为什么面试官爱问这条链——它考的不是 API 用法,而是你有没有把「不确定性」当成一等公民来设计。
关联
- 前置:Agent 工程化总览
- 下一篇:弹性容错五层链
- Zod 深入:zod 系列(
safeParse、判别联合、类型推导) - 同样的「错误分类」思路用在传输层:MCP 与 Zod
自测
- 七种「坏 JSON」可以归成哪两类?
jsonrepair能修哪一类、修不了哪一类? as UserExtraction和UserExtractionSchema.safeParse()的本质区别是什么?- 为什么
email字段必须写成.nullable().optional()?只写.optional()会怎样? - 错误分类器为什么把「未知错误」保守判为可重试?这个不对称权衡的依据是什么?
- L4 里「类型错误」只尝试 1 次就进 DLQ,是怎么做到的?
- 为什么链路顺序是「修复 → 校验 → 分类 → 重试 → DLQ」?把修复放到校验之后会怎样?
参考
- 完整源码与真实运行日志(随仓库留档):
source/agent-engineering/(structured-output/目录下 L0–L4、错误分类器、DLQ、schema 全部实现) - 运行环境:Node v24.14.1;依赖
zod@3、p-retry、jsonrepair、@langchain/openai - 说明:输出为 Mock 模式真实运行结果;本机未运行单元测试(运行器未安装),文中不含测试结论