结构化输出:JSON schema、模板与变量、多语言输出
让模型的回答能被程序直接消费:用 schema 约束输出形状、用模板与变量把提示词变成可复用的函数、让同一份提示词稳定输出多种语言。
今日目标
- 能解释为什么结构化输出必须靠 schema 而不是靠一句「请输出 JSON」
- 能把提示词写成带变量的模板,并说出变量与常量该怎么分
- 能写一个从自然语言抽取结构化字段的脚本,并处理解析失败
前两天的便签都是写给人读的:模型输出一段文字,你看一眼,满意就用。从今天开始换一个读者——程序。程序不会「看一眼」,它要的是固定位置上的固定字段。今天讲怎么让模型的输出从一段话变成一张表,以及这张表填错了怎么办。读完正文、做完实验之后,回到页面顶部把三条目标勾掉。
小白版讲解
为什么程序读不懂模型的回答
你让新同事统计一下这周的接口报错,他回你一段话:「大概有三百多次吧,主要是 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 长这样:
{
"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 列出的字段一个都不能少,模型不能因为数组为空就省掉 validations。enum 把 method 锁死在五个值里,模型不会写出 post 或 Post。type: integer 让 failureStatus 一定是数字而不是字符串 "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 的字符串。
// 两家 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
}import json
import os
import requests
# 两家 provider 各一个函数,返回「未经校验的任意对象」,校验交给下一节
def extract_with_anthropic(system: str, requirement: str):
res = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": "claude-sonnet-5",
"max_tokens": 1024,
"system": system,
"messages": [{"role": "user", "content": requirement}],
"tools": [{"name": "record_spec", "description": "记录抽取出的接口校验规格", "input_schema": SPEC_SCHEMA}],
"tool_choice": {"type": "tool", "name": "record_spec"}, # 强制走工具,就不会有自由文本
},
)
blocks = res.json()["content"]
return next(b["input"] for b in blocks if b["type"] == "tool_use")
def extract_with_openai(system: str, requirement: str):
res = requests.post(
"https://api.openai.com/v1/responses",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}", "content-type": "application/json"},
json={
"model": os.environ["OPENAI_MODEL"], # 不写死型号,以官方文档当前推荐为准
"instructions": system,
"input": requirement,
"text": {"format": {"type": "json_schema", "name": "spec", "schema": SPEC_SCHEMA, "strict": True}},
},
)
for item in res.json()["output"]:
for part in item.get("content", []):
if part["type"] == "output_text":
return json.loads(part["text"])
return None限制各有一条。工具方式的限制是它占用了「工具」这个通道:如果你的调用里本来就有真正的工具要让模型选,强制调用抽取工具会跟它们打架,这时要改成不强制、只在系统提示里要求。严格模式的限制是它支持的 schema 子集有限——某些关键字(比如复杂的条件组合)不支持,第一次用某个 schema 会有一次编译开销,字段太多时也有上限。共同的限制是:schema 约束的是形状,不是内容。它能保证 failureStatus 是整数,保证不了它是 4xx;能保证 rule 是字符串,保证不了它不是空串。内容的正确性还是要靠提示词写清楚,再靠代码校验兜底。
模板与变量:把提示词从一段话变成一个函数
到这里,提示词已经有了角色、任务、格式、约束、示例、schema。它很长,而且你会发现每次调用时大部分内容是一样的,只有几处在变:这次抽取的需求文本、这次要输出的语言、这个团队默认的失败状态码。写代码的人对这个模式很熟悉——不变的部分是函数体,变的部分是参数。提示词也应该这么写:一个接收几个参数、返回完整提示词字符串的函数。
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)import os
LANG_NAME = {"zh": "中文", "en": "English"}
# 常量写死在模板里,每次都可能变的做成参数;调用方不需要知道提示词长什么样
def build_system_prompt(lang: str, default_status: int) -> str:
lang_name = LANG_NAME.get(lang, LANG_NAME["zh"])
return "\n".join([
"你是一位负责接口设计评审的后端工程师。",
"任务:从用户给出的需求描述里抽取接口路径、HTTP 方法、每个请求字段的校验规则与失败状态码。",
f"需求里没提到失败状态码时用 {default_status}。字段类型只能是 string / number / boolean / date 四种。",
f"summary 字段必须使用{lang_name}书写,其余字段原样保留需求里的英文标识符。",
"只填写需求里明确出现的字段,不要臆造。",
])
system = build_system_prompt(os.environ.get("OUTPUT_LANG", "zh"), 400)变量和常量怎么分,有一条和 D1 拆系统提示时一模一样的判据:下一次调用还会一样的,是常量;可能不一样的,是变量。角色、任务描述、约束、schema 是常量;需求文本、输出语言、默认状态码是变量。再加一条工程上的考虑:变量越少越好。每多一个变量,提示词的可能形态就多一个维度,D4 建测试集时要覆盖的组合就多一倍。如果一个「变量」在你的所有调用里其实只取过一个值,把它变回常量。
模板还有一个不那么明显的好处:提示词有了身份。它是一个函数,有名字、有参数签名、有测试、能进版本管理——D4 讲提示词版本化时,版本化的对象就是这个函数,而不是散落在各处的字符串拼接。
多语言输出:语言是变量,不是另一份提示词
很多团队做多语言时的做法是复制一份提示词、把中文翻成英文,于是有了 prompt_zh 和 prompt_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,该回去改模板而不是继续重试。
第三层,降级。两次都失败,返回空值并记录,交给调用方决定是跳过还是人工处理。这里最重要的一条是不要抛异常:抽取失败是正常业务分支,不是程序错误,抛异常会让一条坏需求打断整批任务。也不要把上一次「差一点」的结果凑合着用——半对的结构化数据比没有数据更危险,因为下游会把它当真的。
源码导读
动手实验
这是本课第一个可以跑的实验,没有 API key 也能做完前四条。打开 labs/prompt-engineering-5days/day-03-structured-extraction,先跑 solution/ 看现象,再回到 starter/ 补四个练习点:
- 在
solution/里pnpm install --ignore-workspace后MOCK=1 pnpm start,看清三条需求各自输出的 JSON,以及第三条的「校验失败 → 重试成功」过程。 - 切到
starter/,跑一次MOCK=1 pnpm start,注意第三条需求的failureStatus是字符串却被放过了——这就是练习 3 要修的现象。 - 补全练习 1 的 schema:
validations.items的四个字段、枚举、required与additionalProperties: false。 - 补全练习 2 的模板:用
lang与defaultStatus两个参数替换写死的中文与 400,然后用OUTPUT_LANG=en跑一遍验证 summary 变成英文。 - 补全练习 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分析过程 · 先想清楚再作答
- 这题在筛「有没有真的把模型输出接进过程序」。只在聊天窗口里用过模型的人会觉得「请输出 JSON」够了,因为他们是用眼睛读的。
- 拆法:列出「请输出 JSON」挡不住的几种踩空——外面包一段解释、字段名拼法不一致、数字变字符串、多出字段、空数组时省掉键。每一种都对应 schema 里的一个关键字:required、enum、type、additionalProperties。
- 再答原理:schema 在生成时约束形状,模型不是「生成完再检查」而是「只能生成这个形状」,所以稳定性是质变而不是量变。
- 后半句是区分度:schema 只能约束形状,不能约束内容——整数不等于 4xx,字符串不等于非空。业务规则必须在代码里再查一遍,校验函数返回错误列表供重试使用。
- 可预期的追问:严格模式有什么限制?所有字段都要进 required、要写 additionalProperties false、只支持 schema 子集、首次编译有开销;以及「可选字段怎么表达」——类型允许 null 而不是从 required 里去掉。
How to reason about it · think before answering
- 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.
- 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.
- Then the mechanism: the schema constrains generation itself, the model can only produce that shape, so the gain is qualitative rather than incremental.
- 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.
- 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分析过程 · 先想清楚再作答
- 这题看起来是设计题,实际在考「有没有维护过一份跑在生产里的提示词」。没维护过的人会把所有能变的都做成变量,觉得灵活;维护过的人知道每个变量都是一条测试维度。
- 拆法:判据只有一条——下一次调用还会一样的是常量,可能不一样的是变量。角色、任务、约束、schema 通常是常量;输入文本、输出语言、团队默认值是变量。
- 再答代价:每多一个变量,提示词的可能形态多一个维度,测试集要覆盖的组合翻倍;变量之间还可能互相影响(语言变量与格式说明冲突)。所以变量越少越好,只取过一个值的「变量」应该变回常量。
- 结论:模板是一个有名字、有参数签名的函数,变量是它的参数,常量是函数体;这样提示词才有身份,才能版本化、才能写测试。
- 追问:多语言应该是变量还是多份模板?变量——只有一份模板,语言只影响给人读的字段,枚举与标识符不跟着变;否则改一条规则要改多份,三个月后一定分叉。
How to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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分析过程 · 先想清楚再作答
- 这题是生产题,考的是「有没有见过模型抽风」。答「加个 try catch 重试三次」是新手答案,它没回答重试时发什么、也没回答最后怎么办。
- 拆法:分三层。校验层返回错误列表而不是布尔值;重试层把错误列表拼进用户消息,让模型知道上一次错在哪,原样重发大概率同样的错;降级层返回空值并记录,交调用方决定跳过还是人工处理。
- 重试次数:一次就够。两次以上还不对说明问题不在这条输入而在提示词或 schema,应该修模板而不是继续重试;每次重试都是一次完整调用的钱和延迟。
- 结论里最重要的一条:降级不要抛异常,也不要把「差一点」的结果凑合着用。抽取失败是正常业务分支;半对的结构化数据比没有数据更危险,因为下游会把它当真的。
- 追问方向:怎么区分「模型抽风」和「提示词有问题」?看失败率——偶发是抽风,某类输入稳定失败是提示词或 schema 缺覆盖,应该把那类输入加进测试集;另一个追问是重试会不会放大成本,答案是要有预算上限并监控重试率。
How to reason about it · think before answering
- 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.
- 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.
- 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.
- 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.
- 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
评论
登录后即可参与讨论
还没有评论,来说第一句。