结构化输出
结构化输出 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
读完本课你能让模型稳定输出 JSON,并为它修好三道防线(解析、校验、失败处理),保证坏输出进不了你的程序。
问题场景
你让模型「输出 JSON 学习计划」,代码里直接 JSON.parse。十次里九次成功,第十次它先来一句「好的,以下是您的计划:」再给 JSON——解析炸了,接口 500。偶发的错最难修,因为你从没把「输出不可信」当成设计前提。
核心解释
三道防线,层层往后退:
interface Plan {
level: "beginner" | "intermediate" | "advanced";
steps: string[];
}
const LEVELS = ["beginner", "intermediate", "advanced"] as const;
function parsePlan(raw: string): Plan {
// 防线一:剥掉杂质(markdown 围栏、客套话)。部分提供方支持 JSON 模式,
// 可从源头减少杂质,支持与否以提供方文档为准。
const m = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
const text = (m ? m[1] : raw).trim();
// 防线二:解析失败不炸接口
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error("not_json");
}
// 防线三:逐字段校验类型与取值
const d = data as Record<string, unknown>;
if (typeof d.level !== "string" || !LEVELS.includes(d.level as never)) {
throw new Error("invalid_enum");
}
if (!Array.isArray(d.steps) || d.steps.some((s) => typeof s !== "string")) {
throw new Error("missing_or_bad_steps");
}
return { level: d.level as Plan["level"], steps: d.steps as string[] };
}
防线三的核心手法是枚举白名单和字段类型守卫:允许什么写清楚,其余一律拒绝,而不是「检查它不像坏的」。
失败处理三策略——错了不等于死:
- 带原因重试 1-2 次:把校验错误喂回去(「上次输出的 level 不在枚举内,请改为 beginner/intermediate/advanced 之一」),多数能修好。
- 降级:兜底默认值或退回纯文本展示,不让用户看到 500。
- 终止并记录:入库前校验失败就记错误不写库——宁可少一条,不要脏数据。
请求侧配合:提示词里给字段清单和枚举取值(A07 的格式要素),示例对齐格式(A08),都能显著降低出错率。
示例拆解
带重试与降级的完整封装:
async function planWithRetry(ask: (hint: string) => Promise<string>): Promise<Plan> {
let hint = "";
for (let i = 0; i < 2; i++) {
const raw = await ask(hint); // ask 内部把 hint 作为修正提示追加
try {
return parsePlan(raw);
} catch (e) {
hint = `上次输出校验失败:${(e as Error).message}。请只输出修正后的 JSON。`;
}
}
// 两次都失败:降级到安全默认,而不是崩溃
return { level: "beginner", steps: ["先阅读课程正文", "再完成动手任务"] };
}
关键点:
- 重试必须带错误原因,让模型知道改什么;不带原因的重试只是掷骰子。
- 重试上限 1-2 次:每次失败输出都在烧 token 和时间(A02 的账)。
- 降级值要保守安全:给默认计划,不要编一个假的「成功」。
- 本站风格:字段多时用 schema 库声明形状再校验(
app/api/ai/chat/route.ts的请求体校验用 zod),思路与本例一致——先声明形状,再让数据过闸。
动手任务
- 在实验区跑五类样例,记录每类的失败原因;新增一个「level 合法但 steps 是字符串」的样例,确认防线三能拦住。
- 给
parsePlan加规则:steps 超过 5 项截断到 5 项。想清楚为什么这条用「截断」而 level 用「报错」。 - 在注释里回答:为什么缺字段要返回结构化的失败原因,而不是把裸异常直接抛给用户?
常见错误
- 裸
JSON.parse不加防御:模型一句客套话就能打挂接口,且日志里只有 SyntaxError,看不出是上游问题。 - 只判类型不判枚举:
level: "hobby"照样入库,下游所有 switch 分支全乱。 - 失败无限重试:错误输出往往稳定复现,重试烧钱不解决问题;1-2 次后必须降级。
- 校验通过就完全信任内容:schema 管的是「形状」,内容的真实性仍需核验(A06 的核验原则在这里继续有效)。
检查与复盘
自查清单:
- 能说出三道防线各自拦什么
- 能为非 JSON、缺字段、非法枚举三类失败写出对应处理分支
- 记住:校验形状 ≠ 担保内容,入库前仍有核验
参考资料
- MDN:JSON.parse —— 解析行为与异常
- OpenAI 文档:Structured outputs —— 结构化输出能力的官方说明(以所用提供方实际支持为准)
- 下一课 A11 把这一切接进界面:流式聊天界面。
读完这一课了?
标记完成后,工作台会安排接下来的学习。