逐日AI
第 1 周 · D3约 4 小时

结构化输出:JSON schema、模板与变量、多语言输出

让模型的回答能被程序直接消费:用 schema 约束输出形状、用模板与变量把提示词变成可复用的函数、让同一份提示词稳定输出多种语言。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能解释为什么结构化输出必须靠 schema 而不是靠一句「请输出 JSON」
  2. 能把提示词写成带变量的模板,并说出变量与常量该怎么分
  3. 能写一个从自然语言抽取结构化字段的脚本,并处理解析失败

前两天的便签都是写给人读的:模型输出一段文字,你看一眼,满意就用。从今天开始换一个读者——程序。程序不会「看一眼」,它要的是固定位置上的固定字段。今天讲怎么让模型的输出从一段话变成一张表,以及这张表填错了怎么办。读完正文、做完实验之后,回到页面顶部把三条目标勾掉。

小白版讲解

为什么程序读不懂模型的回答

你让新同事统计一下这周的接口报错,他回你一段话:「大概有三百多次吧,主要是 400,还有几个 500,具体的我记在本子上了。」这段话人听得懂,但你要把它录进表格时会卡住:「三百多」是多少?「几个」是几个?「主要」占比多少?你得再问一轮。如果你一开始就给他一张表——「状态码、次数、占比」三列——他填完你直接录入,一次都不用问。

模型的自由文本输出就是那段话。它可能是「共 312 次错误,其中 400 占 289 次」,也可能是「错误主要集中在 400(289 次)」,还可能顺手加一句「建议检查参数校验」。三种写法意思一样,但你的程序要从里面挖出 289 这个数字,就得写一堆正则,而且下一次模型换个说法正则就失效。这是自由文本与固定形状之间的根本矛盾:文本的表达方式有无数种,程序只认一种

很多人的第一反应是在提示词末尾加一句「请以 JSON 格式输出」。这句话有用,但不够用。模型会输出 JSON,但它可能把 JSON 包在一段解释文字里、可能在前面加一行「好的,以下是结果:」、可能把数字写成字符串、可能把你要的 failureStatus 写成 failure_status、可能多加一个你没要的字段、也可能在数组该为空时干脆省掉这个键。每一种都会让 JSON.parse 之后的代码踩空。「请输出 JSON」只约束了「是 JSON」,没约束「是哪一种 JSON」。

JSON schema 是给模型的表格

解决办法就是把那张表真的画出来给模型:字段叫什么、每个字段什么类型、哪些是必填、枚举字段只能取哪几个值。这张表的标准写法叫 JSON schema,它本来是用来校验数据的,现在两家主流 API 都支持把它直接交给模型,让模型的输出在生成时就被约束在这个形状里,而不是生成完再靠你祈祷。

以贯穿全课的任务为例,我们要从一段需求描述里抽出「接口、方法、每个字段的校验规则、失败状态码、一句摘要」,schema 长这样:

JSONJSON
{
  "type": "object",
  "additionalProperties": false,
  "required": ["endpoint", "method", "validations", "failureStatus", "summary"],
  "properties": {
    "endpoint": { "type": "string" },
    "method": { "type": "string", "enum": ["GET", "POST", "PATCH", "PUT", "DELETE"] },
    "validations": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["field", "type", "required", "rule"],
        "properties": {
          "field": { "type": "string" },
          "type": { "type": "string", "enum": ["string", "number", "boolean", "date"] },
          "required": { "type": "boolean" },
          "rule": { "type": "string" }
        }
      }
    },
    "failureStatus": { "type": "integer" },
    "summary": { "type": "string" }
  }
}

逐项看它约束了什么。required 列出的字段一个都不能少,模型不能因为数组为空就省掉 validationsenummethod 锁死在五个值里,模型不会写出 postPosttype: integerfailureStatus 一定是数字而不是字符串 "400"additionalProperties: false 禁止模型多加你没要的字段。每一条都对应上一节列出的一种「踩空」。

有两点经常被忽略。第一,严格模式下每个对象的所有字段都要出现在 required 里,并且要写 additionalProperties: false,两家 API 都是这个要求,漏了会直接报 400 而且错误信息不太好读;「可选字段」的表达方式是把类型写成允许 null,而不是从 required 里拿掉。第二,schema 里的 description 不是注释,是给模型看的说明——「rule:一句话说明校验规则,例如长度不超过 200」这种描述会显著提高模型填对的概率,写 schema 时把它当成便签上「格式」栏的延伸来写。

两家 API 的结构化输出开关

同一份 schema,两家 API 接进去的方式不一样,各有一个值得知道的限制。

Anthropic 这边最稳的做法是「强制调用一个工具」:把 schema 当成一个工具的输入定义交给模型,再用 tool_choice 强制它必须调用这个工具。模型「调用」的动作就是填一份符合 schema 的参数,你从响应里拿到的就是那个对象,没有任何自由文本混在里面。OpenAI 这边用 Responses API 的 text.format,类型设成 json_schema 并开 strict,模型的文本输出就会被约束在 schema 里,你拿到的是一段一定能 JSON.parse 的字符串。

extract.ts
// 两家 provider 各一个函数,返回「未经校验的任意对象」,校验交给下一节
async function extractWithAnthropic(system: string, requirement: string): Promise<unknown> {
  const res = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.ANTHROPIC_API_KEY ?? '',
      'anthropic-version': '2023-06-01',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      model: 'claude-sonnet-5',
      max_tokens: 1024,
      system,
      messages: [{ role: 'user', content: requirement }],
      tools: [{ name: 'record_spec', description: '记录抽取出的接口校验规格', input_schema: SPEC_SCHEMA }],
      tool_choice: { type: 'tool', name: 'record_spec' }, // 强制走工具,就不会有自由文本
    }),
  })
  const json = (await res.json()) as { content: Array<{ type: string; input?: unknown }> }
  return json.content.find((c) => c.type === 'tool_use')?.input
}
 
async function extractWithOpenAI(system: string, requirement: string): Promise<unknown> {
  const res = await fetch('https://api.openai.com/v1/responses', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 'content-type': 'application/json' },
    body: JSON.stringify({
      model: process.env.OPENAI_MODEL, // 不写死型号,以官方文档当前推荐为准
      instructions: system,
      input: requirement,
      text: { format: { type: 'json_schema', name: 'spec', schema: SPEC_SCHEMA, strict: true } },
    }),
  })
  const json = (await res.json()) as { output: Array<{ content?: Array<{ type: string; text?: string }> }> }
  const text = json.output.flatMap((o) => o.content ?? []).find((c) => c.type === 'output_text')?.text
  return JSON.parse(text ?? 'null') as unknown
}

限制各有一条。工具方式的限制是它占用了「工具」这个通道:如果你的调用里本来就有真正的工具要让模型选,强制调用抽取工具会跟它们打架,这时要改成不强制、只在系统提示里要求。严格模式的限制是它支持的 schema 子集有限——某些关键字(比如复杂的条件组合)不支持,第一次用某个 schema 会有一次编译开销,字段太多时也有上限。共同的限制是:schema 约束的是形状,不是内容。它能保证 failureStatus 是整数,保证不了它是 4xx;能保证 rule 是字符串,保证不了它不是空串。内容的正确性还是要靠提示词写清楚,再靠代码校验兜底。

模板与变量:把提示词从一段话变成一个函数

到这里,提示词已经有了角色、任务、格式、约束、示例、schema。它很长,而且你会发现每次调用时大部分内容是一样的,只有几处在变:这次抽取的需求文本、这次要输出的语言、这个团队默认的失败状态码。写代码的人对这个模式很熟悉——不变的部分是函数体,变的部分是参数。提示词也应该这么写:一个接收几个参数、返回完整提示词字符串的函数。

template.ts
const LANG_NAME: Record<string, string> = { zh: '中文', en: 'English' }
 
// 常量写死在模板里,每次都可能变的做成参数;调用方不需要知道提示词长什么样
function buildSystemPrompt(lang: string, defaultStatus: number): string {
  const langName = LANG_NAME[lang] ?? LANG_NAME.zh
  return [
    '你是一位负责接口设计评审的后端工程师。',
    '任务:从用户给出的需求描述里抽取接口路径、HTTP 方法、每个请求字段的校验规则与失败状态码。',
    `需求里没提到失败状态码时用 ${defaultStatus}。字段类型只能是 string / number / boolean / date 四种。`,
    `summary 字段必须使用${langName}书写,其余字段原样保留需求里的英文标识符。`,
    '只填写需求里明确出现的字段,不要臆造。',
  ].join('\n')
}
 
const system = buildSystemPrompt(process.env.OUTPUT_LANG ?? 'zh', 400)

变量和常量怎么分,有一条和 D1 拆系统提示时一模一样的判据:下一次调用还会一样的,是常量;可能不一样的,是变量。角色、任务描述、约束、schema 是常量;需求文本、输出语言、默认状态码是变量。再加一条工程上的考虑:变量越少越好。每多一个变量,提示词的可能形态就多一个维度,D4 建测试集时要覆盖的组合就多一倍。如果一个「变量」在你的所有调用里其实只取过一个值,把它变回常量。

模板还有一个不那么明显的好处:提示词有了身份。它是一个函数,有名字、有参数签名、有测试、能进版本管理——D4 讲提示词版本化时,版本化的对象就是这个函数,而不是散落在各处的字符串拼接。

多语言输出:语言是变量,不是另一份提示词

很多团队做多语言时的做法是复制一份提示词、把中文翻成英文,于是有了 prompt_zhprompt_en 两个文件。这个做法的问题在三个月后显现:你改了中文版的一条约束,忘了改英文版,两个语言的行为开始分叉,而且你很难发现。

正确的做法上一节的代码已经演示了:提示词本体只有一份,输出语言是模板的一个参数,模板里只有一句话在读它——「summary 字段必须使用某某语言书写」。这样任何一条规则的修改都自动对所有语言生效。注意那句「其余字段原样保留需求里的英文标识符」,它在防一个真实的坑:让模型输出英文时,它会顺手把 dueDate 翻成 due_date 甚至 deadline,把 /todos 翻成 /tasks。哪些字段跟着语言变、哪些字段永远不变,要在模板里写明。

还有一条与 schema 的配合:枚举值不要跟着语言变。method 永远是 POST 而不是「提交」,type 永远是 date 而不是「日期」。枚举是给程序读的,只有 summary 这类给人读的字段才切换语言。这条规则说白了就是:表头是程序的,表格里的备注才是人的

解析失败怎么办:校验、重试与降级

新同事填的表格也会出错:把状态码写成了 200,把「必填」那栏空着。你不会因为一张表填错就把整个统计工程作废,你会指出错在哪、让他改一次,改完还不对就先放一边、标上「待人工确认」。模型输出的兜底完全一样,分三层。

第一层,校验。拿到对象之后,用代码逐条检查业务规则:endpoint 以斜杠开头,method 在枚举里,failureStatus 是 4xx 整数,summary 非空,validations 每一项四个键类型都对。这一层和 schema 不重复——schema 在生成时约束形状,校验在生成后核对内容。校验函数返回的应该是错误列表而不是布尔值,因为下一层要用到它。

第二层,重试一次。校验失败时,把错误列表拼进用户消息再调一次:「上一次输出有这些问题,请修正:failureStatus 必须是 4xx 整数」。关键在于带着错误原因重试——原样重发一遍,大概率得到同样的错误;告诉它错在哪,多数情况一次就能修好。重试次数不要多,一次足够,两次以上说明问题不在这条输入而在提示词或 schema,该回去改模板而不是继续重试。

第三层,降级。两次都失败,返回空值并记录,交给调用方决定是跳过还是人工处理。这里最重要的一条是不要抛异常:抽取失败是正常业务分支,不是程序错误,抛异常会让一条坏需求打断整批任务。也不要把上一次「差一点」的结果凑合着用——半对的结构化数据比没有数据更危险,因为下游会把它当真的。

源码导读

动手实验

🧪 D3 实验:结构化抽取脚本

代码位置:labs/prompt-engineering-5days/day-03-structured-extraction

验收标准:

  1. MOCK=1 pnpm start 跑 starter,三条需求都输出完整 JSON,validations 每一项都有四个键。
  2. MOCK=1 OUTPUT_LANG=en pnpm start,三条需求的 summary 变成英文,其余字段不变。
  3. 第三条需求能看到「第 1 次校验失败」的提示,随后拿到 failureStatus 为数字 400 的正确结果。
  4. 把 4xx 检查临时改成 5xx 再跑第三条,看到「已降级为 null」而不是报错崩掉。
  5. 有 key 的话去掉 MOCK=1 跑三次同一条需求,三个关键字段三次完全一致。

这是本课第一个可以跑的实验,没有 API key 也能做完前四条。打开 labs/prompt-engineering-5days/day-03-structured-extraction,先跑 solution/ 看现象,再回到 starter/ 补四个练习点:

  1. solution/pnpm install --ignore-workspaceMOCK=1 pnpm start,看清三条需求各自输出的 JSON,以及第三条的「校验失败 → 重试成功」过程。
  2. 切到 starter/,跑一次 MOCK=1 pnpm start,注意第三条需求的 failureStatus 是字符串却被放过了——这就是练习 3 要修的现象。
  3. 补全练习 1 的 schema:validations.items 的四个字段、枚举、requiredadditionalProperties: false
  4. 补全练习 2 的模板:用 langdefaultStatus 两个参数替换写死的中文与 400,然后用 OUTPUT_LANG=en 跑一遍验证 summary 变成英文。
  5. 补全练习 3 的校验与练习 4 的重试,再跑第三条需求,看到「第 1 次校验失败」后拿到正确结果;有 key 的话去掉 MOCK=1 跑真模型对比稳定性。

面试题

今天 3 道题在下方题库区,分别对应 schema 约束的原理与边界、模板变量的取舍、解析失败的兜底设计。第三题的追问是生产系统里最常被问到的,先自己推一遍再看分析。

检查清单与明日预告

  • 能解释为什么结构化输出必须靠 schema 而不是靠一句「请输出 JSON」
  • 能把提示词写成带变量的模板,并说出变量与常量该怎么分
  • 能写一个从自然语言抽取结构化字段的脚本,并处理解析失败
  • 结构化抽取脚本的前 4 条验收标准全部通过
  • 3 道面试题不看要点也能答出至少 2 道

明天(D4)回答一个到现在一直悬着的问题:你改了一版提示词,怎么知道它是变好了还是变坏了?「感觉这版更好」在生产里是不能用的,我们要把它变成「这版在十条测试上多对了三条」。D4 会教你建一份小样本测试集、写一个 A/B 评估脚本、给提示词上版本号,并认出五种最常见的反模式。今天写的结构化输出正好是评估的基础——输出是固定字段,判对错才能用代码做。

面试题库

  • 结构化输出为什么要用 schema 约束,而不是在提示词里写「请输出 JSON」?schema 通过之后还需要校验吗?Why should structured output be enforced with a schema instead of a 'please respond in JSON' instruction, and do you still need validation once the schema passes?
    国内高频海外高频基础#structured-output#json-schema

    分析过程 · 先想清楚再作答

    1. 这题在筛「有没有真的把模型输出接进过程序」。只在聊天窗口里用过模型的人会觉得「请输出 JSON」够了,因为他们是用眼睛读的。
    2. 拆法:列出「请输出 JSON」挡不住的几种踩空——外面包一段解释、字段名拼法不一致、数字变字符串、多出字段、空数组时省掉键。每一种都对应 schema 里的一个关键字:required、enum、type、additionalProperties。
    3. 再答原理:schema 在生成时约束形状,模型不是「生成完再检查」而是「只能生成这个形状」,所以稳定性是质变而不是量变。
    4. 后半句是区分度:schema 只能约束形状,不能约束内容——整数不等于 4xx,字符串不等于非空。业务规则必须在代码里再查一遍,校验函数返回错误列表供重试使用。
    5. 可预期的追问:严格模式有什么限制?所有字段都要进 required、要写 additionalProperties false、只支持 schema 子集、首次编译有开销;以及「可选字段怎么表达」——类型允许 null 而不是从 required 里去掉。

    How to reason about it · think before answering

    1. This screens for whether the candidate has ever wired model output into code. People who only read output with their eyes think 'respond in JSON' is enough.
    2. List what that instruction cannot prevent: prose wrapped around the JSON, inconsistent key spelling, numbers as strings, extra keys, missing keys when an array is empty. Each maps to a schema keyword: required, enum, type, additionalProperties.
    3. Then the mechanism: the schema constrains generation itself, the model can only produce that shape, so the gain is qualitative rather than incremental.
    4. The second half is the differentiator: schemas constrain shape, not content — integer is not 4xx, string is not non-empty. Business rules still need code-level validation that returns an error list for retries.
    5. Follow-ups: strict-mode limits — every property in required, additionalProperties false, a supported subset of JSON schema, a first-use compile cost; and how to express optional fields — allow null in the type rather than dropping the key from required.

    答题要点

    • 「请输出 JSON」只约束「是 JSON」,挡不住包解释文字、字段名不一致、数字变字符串、多字段、省键这几种踩空
    • schema 在生成时约束形状:required、enum、type、additionalProperties 各挡一种错误
    • schema 通过之后仍要校验业务规则,因为形状正确不等于内容正确
    • 严格模式要求所有字段进 required 且 additionalProperties 为 false;可选字段用允许 null 表达

    Key points

    • 'Respond in JSON' only guarantees JSON, not which JSON: wrapper prose, key spelling, stringified numbers, extra or missing keys all slip through
    • A schema constrains generation itself; required, enum, type and additionalProperties each block one failure class
    • Validation is still needed after the schema passes because correct shape does not mean correct content
    • Strict mode needs every property in required and additionalProperties false; express optional fields by allowing null
  • 提示词模板里哪些内容该做成变量,哪些该写死?变量多了会有什么问题?In a prompt template, what should become a variable and what should stay constant, and what goes wrong when you have too many variables?
    国内高频海外高频进阶#prompt-template#structured-output

    分析过程 · 先想清楚再作答

    1. 这题看起来是设计题,实际在考「有没有维护过一份跑在生产里的提示词」。没维护过的人会把所有能变的都做成变量,觉得灵活;维护过的人知道每个变量都是一条测试维度。
    2. 拆法:判据只有一条——下一次调用还会一样的是常量,可能不一样的是变量。角色、任务、约束、schema 通常是常量;输入文本、输出语言、团队默认值是变量。
    3. 再答代价:每多一个变量,提示词的可能形态多一个维度,测试集要覆盖的组合翻倍;变量之间还可能互相影响(语言变量与格式说明冲突)。所以变量越少越好,只取过一个值的「变量」应该变回常量。
    4. 结论:模板是一个有名字、有参数签名的函数,变量是它的参数,常量是函数体;这样提示词才有身份,才能版本化、才能写测试。
    5. 追问:多语言应该是变量还是多份模板?变量——只有一份模板,语言只影响给人读的字段,枚举与标识符不跟着变;否则改一条规则要改多份,三个月后一定分叉。

    How to reason about it · think before answering

    1. It looks like a design question but really asks whether you have maintained a prompt in production. The untested instinct is to parameterize everything; experience teaches that every variable is a test dimension.
    2. One rule: what stays the same on the next call is a constant, what may differ is a variable. Role, task, constraints and schema are usually constants; input text, output language and team defaults are variables.
    3. Then the cost: each variable adds a dimension to the space of prompts, doubling the combinations a test set must cover, and variables can interact. Fewer is better; a variable that only ever took one value should become a constant.
    4. Conclusion: the template is a named function with a signature; variables are parameters, constants are the body. That identity is what makes versioning and testing possible.
    5. Follow-up: is multi-language a variable or separate templates? A variable — one template, language affects only human-facing fields, enums and identifiers never change; separate copies drift within months.

    答题要点

    • 判据:下一次调用还一样的是常量,可能不一样的是变量
    • 角色、任务、约束、schema 是常量;输入文本、输出语言、默认值是变量
    • 每个变量都是一条测试维度,变量越少越好,只取过一个值的变回常量
    • 多语言是一个变量,只影响给人读的字段,枚举与标识符不变

    Key points

    • Rule: same on the next call means constant, may differ means variable
    • Role, task, constraints and schema are constants; input text, output language and defaults are variables
    • Every variable is a test dimension, so keep them minimal and fold single-valued ones back into constants
    • Multi-language is one variable affecting only human-facing fields; enums and identifiers stay fixed
  • 模型返回的 JSON 解析或校验失败时,你会怎么设计兜底?重试几次、怎么重试、失败之后怎么办?When the model's JSON fails to parse or validate, how do you design the fallback — how many retries, how do you retry, and what happens after the last failure?
    国内高频海外高频进阶#structured-output#error-handling

    分析过程 · 先想清楚再作答

    1. 这题是生产题,考的是「有没有见过模型抽风」。答「加个 try catch 重试三次」是新手答案,它没回答重试时发什么、也没回答最后怎么办。
    2. 拆法:分三层。校验层返回错误列表而不是布尔值;重试层把错误列表拼进用户消息,让模型知道上一次错在哪,原样重发大概率同样的错;降级层返回空值并记录,交调用方决定跳过还是人工处理。
    3. 重试次数:一次就够。两次以上还不对说明问题不在这条输入而在提示词或 schema,应该修模板而不是继续重试;每次重试都是一次完整调用的钱和延迟。
    4. 结论里最重要的一条:降级不要抛异常,也不要把「差一点」的结果凑合着用。抽取失败是正常业务分支;半对的结构化数据比没有数据更危险,因为下游会把它当真的。
    5. 追问方向:怎么区分「模型抽风」和「提示词有问题」?看失败率——偶发是抽风,某类输入稳定失败是提示词或 schema 缺覆盖,应该把那类输入加进测试集;另一个追问是重试会不会放大成本,答案是要有预算上限并监控重试率。

    How to reason about it · think before answering

    1. A production question that checks whether you have seen a model misbehave. 'Wrap it in try/catch and retry three times' is the novice answer — it says nothing about what you resend or what happens at the end.
    2. Three layers. Validation returns an error list, not a boolean. Retry appends that list to the user message so the model knows what to fix; resending verbatim mostly reproduces the error. Degradation returns null and logs, leaving skip-or-human to the caller.
    3. Retry count: one is enough. Persistent failure means the prompt or schema lacks coverage, so fix the template instead of retrying; each retry costs a full call.
    4. The key conclusion: do not throw on degradation, and do not use a near-miss result. Extraction failure is a normal branch; half-correct structured data is worse than none because downstream code trusts it.
    5. Follow-ups: how to tell flakiness from a prompt bug? Failure rate — sporadic is flakiness, a stable failing input class is missing coverage and belongs in the test set. And does retrying inflate cost? Cap it and monitor the retry rate.

    答题要点

    • 三层:校验返回错误列表、带着错误原因重试一次、失败后返回空值并记录
    • 重试时必须把错误列表拼回用户消息,原样重发大概率同样的错
    • 重试一次足够,稳定失败说明模板或 schema 缺覆盖,该修模板不该继续重试
    • 降级不抛异常、不用半对的结果;监控重试率,稳定失败的输入加进测试集

    Key points

    • Three layers: validation returns an error list, one retry carries those errors back, then degrade to null and log
    • Retries must include the error list in the user message; verbatim resends reproduce the error
    • One retry is enough; persistent failure means the template or schema lacks coverage
    • Never throw on degradation or use near-miss output; monitor retry rate and add failing inputs to the test set

评论

登录后即可参与讨论

还没有评论,来说第一句。