A09第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

结构化输出

结构化输出 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

读完本课你能让模型稳定输出 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. 带原因重试 1-2 次:把校验错误喂回去(「上次输出的 level 不在枚举内,请改为 beginner/intermediate/advanced 之一」),多数能修好。
  2. 降级:兜底默认值或退回纯文本展示,不让用户看到 500。
  3. 终止并记录:入库前校验失败就记错误不写库——宁可少一条,不要脏数据。

请求侧配合:提示词里给字段清单和枚举取值(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),思路与本例一致——先声明形状,再让数据过闸。

动手任务

  1. 在实验区跑五类样例,记录每类的失败原因;新增一个「level 合法但 steps 是字符串」的样例,确认防线三能拦住。
  2. 给 parsePlan 加规则:steps 超过 5 项截断到 5 项。想清楚为什么这条用「截断」而 level 用「报错」。
  3. 在注释里回答:为什么缺字段要返回结构化的失败原因,而不是把裸异常直接抛给用户?

常见错误

  • 裸 JSON.parse 不加防御:模型一句客套话就能打挂接口,且日志里只有 SyntaxError,看不出是上游问题。
  • 只判类型不判枚举:level: "hobby" 照样入库,下游所有 switch 分支全乱。
  • 失败无限重试:错误输出往往稳定复现,重试烧钱不解决问题;1-2 次后必须降级。
  • 校验通过就完全信任内容:schema 管的是「形状」,内容的真实性仍需核验(A06 的核验原则在这里继续有效)。

检查与复盘

自查清单:

  • 能说出三道防线各自拦什么
  • 能为非 JSON、缺字段、非法枚举三类失败写出对应处理分支
  • 记住:校验形状 ≠ 担保内容,入库前仍有核验

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 3 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

阅读结构化输出的解析代码。模型被要求输出 {"level":"beginner|intermediate|advanced","steps":[...]}。下列哪类坏输出会同时穿过防线一(剥围栏)和防线二(try JSON.parse),最终被防线三拦下?

const m = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
const text = (m ? m[1] : raw).trim();
let data;
try { data = JSON.parse(text); } catch { throw new Error("not_json"); }
const LEVELS = ["beginner", "intermediate", "advanced"];
if (typeof data.level !== "string" || !LEVELS.includes(data.level)) throw new Error("invalid_enum");
if (!Array.isArray(data.steps) || data.steps.some((s) => typeof s !== "string")) throw new Error("bad_steps");
Q2故障定位中等

bug 现场:「输出 JSON 学习计划」的功能十次里九次正常,第十次模型先来一句「好的,以下是您的计划:」再给 JSON,JSON.parse 直接炸了,接口 500。偶发错误最难修。按三道防线的思路给出修法。

Q3多选中等(多选,全部选对才算正确)

结构化输出校验失败后的处理策略,下列说法正确的有?(多选)

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...