跳到主要内容

创建日期: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)

两个必须点破的地方:

  1. as UserExtraction 是编译时的谎言。TypeScript 类型在编译后完全消失,运行时 parsed 可以是任何东西——parsed.age 完全可能是字符串 "二十八",而类型系统不会拦你。
  2. 只靠 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省略 emailemail: 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 好在哪里(这题面试常考):

  1. 它不仅强制 JSON 格式,还会根据 schema 自动调整 prompt;
  2. 优先走 function calling / tool calling,比 JSON Mode 更稳定;
  3. 直接传 Zod schema,内部自动转 JSON Schema;
  4. 返回值已经是类型化对象,不需要手动 parse;
  5. 解析失败时 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 用法,而是你有没有把「不确定性」当成一等公民来设计。

关联​

自测​

  1. 七种「坏 JSON」可以归成哪两类?jsonrepair 能修哪一类、修不了哪一类?
  2. as UserExtraction 和 UserExtractionSchema.safeParse() 的本质区别是什么?
  3. 为什么 email 字段必须写成 .nullable().optional()?只写 .optional() 会怎样?
  4. 错误分类器为什么把「未知错误」保守判为可重试?这个不对称权衡的依据是什么?
  5. L4 里「类型错误」只尝试 1 次就进 DLQ,是怎么做到的?
  6. 为什么链路顺序是「修复 → 校验 → 分类 → 重试 → DLQ」?把修复放到校验之后会怎样?

参考​

  • 完整源码与真实运行日志(随仓库留档):source/agent-engineering/(structured-output/ 目录下 L0–L4、错误分类器、DLQ、schema 全部实现)
  • 运行环境:Node v24.14.1;依赖 zod@3、p-retry、jsonrepair、@langchain/openai
  • 说明:输出为 Mock 模式真实运行结果;本机未运行单元测试(运行器未安装),文中不含测试结论