Structured Output: JSON Schema, Templates and Variables, Multilingual Output
Make the model's answer directly consumable by code: constrain the output shape with a schema, turn a prompt into a reusable function with templates and variables, and get the same prompt to reliably output multiple languages.
Today's Goals
- Explain why structured output must rely on a schema rather than a line saying "please output JSON"
- Write a prompt as a template with variables, and explain how to split variables from constants
- Write a script that extracts structured fields from natural language, and handle parse failures
For two days the ticket has been written for a human reader: the model outputs prose, you glance at it, and if you like it you use it. Starting today the reader changes — it is a program. A program does not glance. It wants fixed fields in fixed places. Today is about turning the model's output from a paragraph into a table, and about what to do when the table comes back filled in wrong. When you have read the walkthrough and finished the lab, come back to the top of the page and check off the three goals.
Plain-Language Walkthrough
Why a program cannot read the model's answer
The colleague reports the week's API errors in a sentence — "a few hundred, mostly 400s" — versus you handing them a three-column form (status code / count / percentage) to fill in. The sentence is perfectly comprehensible to a human, and it stalls the moment you try to put it in a spreadsheet. How many is a few hundred? How many is "a couple of 500s"? What share is "mostly"? You have to go back and ask another round. Hand them the three-column form up front and you type the result straight in, with no follow-up at all.
The model's free-text output is that sentence. It might say "312 errors total, of which 400 accounts for 289," or "errors are concentrated on 400 (289 occurrences)," or it might helpfully append "you may want to check your parameter validation." All three mean the same thing, and for your program to dig the number 289 out of them you have to write a pile of regular expressions that stop working the next time the model phrases it differently. That is the fundamental tension between free text and a fixed shape: text has endless ways to say a thing, and a program accepts exactly one.
Most people's first instinct is to append "please output in JSON format" to the prompt. That line helps, and it is not enough. The model will output JSON, and it may also wrap that JSON in explanatory prose, prepend a line saying "Sure, here are the results:", write numbers as strings, spell the failureStatus you asked for as failure_status, add a field you never requested, or drop the key entirely when the array should have been empty. Every one of those makes the code after JSON.parse reach into nothing. "Please output JSON" constrains it to being JSON; it does not constrain which JSON.
A JSON schema is the form you hand the model
The fix is to actually draw the form and hand it over: what the fields are called, what type each one is, which are mandatory, and which values an enumerated field may take. The standard way of writing that form is a JSON schema. It was designed for validating data, and both major APIs now let you hand it straight to the model so the output is constrained into that shape as it is generated, rather than generated first and prayed over afterward.
Take the task that runs through the course. We want to pull an endpoint, an HTTP method, per-field validation rules, a failure status code, and a one-line summary out of a requirement description. The schema looks like this:
{
"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" }
}
}Go through what it constrains, one clause at a time. Everything listed in required must be present, so the model cannot omit validations just because the array came out empty. The enum pins method to five values, so it will not emit post or Post. type: integer guarantees failureStatus is a number rather than the string "400". And additionalProperties: false forbids fields you did not ask for. Each clause corresponds to one of the ways the previous section's code reached into nothing.
Two points get overlooked constantly. First, in strict mode every field of every object must appear in required, and every object must carry additionalProperties: false. Both APIs require this, and omitting it earns you a 400 with an error message that is not much fun to read. The way to express an optional field is to allow null in its type, not to remove it from required. Second, description inside a schema is not a comment — it is instructions the model reads. A description like "rule: one sentence describing the validation rule, for example length at most 200" measurably raises the odds that the model fills the field in correctly. Write those descriptions as an extension of the format box on your ticket.
The structured-output switch on each API
The same schema gets wired in differently on each API, and each way carries one limit worth knowing.
On the Anthropic side, the most reliable approach is to force a tool call: hand the schema over as a tool's input definition, then use tool_choice to require that the model call that tool. The model's act of "calling" it is filling in arguments that conform to the schema, and what you pull out of the response is that object, with no free text mixed in anywhere. On the OpenAI side you use the Responses API's text.format, with the type set to json_schema and strict turned on; the model's text output is then constrained to the schema, and what you receive is a string guaranteed to survive JSON.parse.
// One function per provider, each returning an unvalidated arbitrary object.
// Validation belongs to the next section.
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: 'Record the extracted endpoint validation spec',
input_schema: SPEC_SCHEMA,
},
],
tool_choice: { type: 'tool', name: 'record_spec' }, // forcing the tool means no free text
}),
})
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, // do not hard-code a model; follow the current docs
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
# One function per provider, each returning an unvalidated arbitrary object.
# Validation belongs to the next section.
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": "Record the extracted endpoint validation spec",
"input_schema": SPEC_SCHEMA,
}
],
"tool_choice": {"type": "tool", "name": "record_spec"}, # forcing the tool means no free text
},
)
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"], # do not hard-code a model; follow the current docs
"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 NoneEach has one limit. The tool approach occupies the tool channel: if your call already has real tools for the model to choose among, forcing the extraction tool fights with them, and you have to drop the forcing and ask for the shape in the system prompt instead. Strict mode's limit is that it supports only a subset of schema keywords — some, such as complex conditional combinations, are unsupported; the first use of a given schema carries a one-time compilation cost; and there is a ceiling on field count. The shared limit is the important one: a schema constrains shape, not content. It can guarantee failureStatus is an integer; it cannot guarantee that integer is a 4xx. It can guarantee rule is a string; it cannot guarantee the string is not empty. Correct content still comes from writing the prompt clearly, with code validation as the backstop.
Templates and variables: from a paragraph to a function
By now the prompt has a role, a task, a format, constraints, examples, and a schema. It is long, and you will notice that most of it is identical on every call, with only a few things changing: the requirement text being extracted this time, the language to output this time, this team's default failure status code. Anyone who writes code recognizes that pattern — the unchanging part is the function body, the changing part is the parameters. A prompt should be written the same way: a function that takes a few arguments and returns the complete prompt string.
const LANG_NAME: Record<string, string> = { zh: 'Chinese', en: 'English' }
// Constants are baked into the template; anything that may change becomes a parameter.
// Callers never need to know what the prompt looks like.
function buildSystemPrompt(lang: string, defaultStatus: number): string {
const langName = LANG_NAME[lang] ?? LANG_NAME.zh
return [
'You are a backend engineer reviewing endpoint designs.',
'Task: from the requirement description the user gives you, extract the endpoint path, the HTTP method, the validation rule for each request field, and the failure status code.',
`When the requirement does not mention a failure status code, use ${defaultStatus}. Field types may only be string, number, boolean, or date.`,
`The summary field must be written in ${langName}; every other field keeps the English identifiers exactly as they appear in the requirement.`,
'Fill in only fields that explicitly appear in the requirement. Do not invent any.',
].join('\n')
}
const system = buildSystemPrompt(process.env.OUTPUT_LANG ?? 'zh', 400)import os
LANG_NAME = {"zh": "Chinese", "en": "English"}
# Constants are baked into the template; anything that may change becomes a parameter.
# Callers never need to know what the prompt looks like.
def build_system_prompt(lang: str, default_status: int) -> str:
lang_name = LANG_NAME.get(lang, LANG_NAME["zh"])
return "\n".join([
"You are a backend engineer reviewing endpoint designs.",
"Task: from the requirement description the user gives you, extract the endpoint path, the HTTP method, the validation rule for each request field, and the failure status code.",
f"When the requirement does not mention a failure status code, use {default_status}. Field types may only be string, number, boolean, or date.",
f"The summary field must be written in {lang_name}; every other field keeps the English identifiers exactly as they appear in the requirement.",
"Fill in only fields that explicitly appear in the requirement. Do not invent any.",
])
system = build_system_prompt(os.environ.get("OUTPUT_LANG", "zh"), 400)How do you split variables from constants? By exactly the same test you used on D1 to split the system prompt: what will be the same on the next call is a constant; what might differ is a variable. Role, task description, constraints, and schema are constants. Requirement text, output language, and default status code are variables. Add one engineering consideration on top: fewer variables is better. Every additional variable adds a dimension to the space of possible prompts, which doubles the combinations D4's test set has to cover. If a "variable" has only ever taken one value across all your calls, turn it back into a constant.
Templates have a less obvious benefit too: the prompt acquires an identity. It is a function, with a name, a parameter signature, tests, and a place in version control. When D4 covers prompt versioning, this function is the thing being versioned — not string concatenation scattered across the codebase.
Multilingual output: language is a variable, not a second prompt
Many teams approach multiple languages by copying the prompt and translating it, which leaves them with two files, one per language. The problem surfaces three months later: you change one constraint in the first version, forget the second, the two languages start behaving differently, and you have a hard time noticing.
The previous section's code already shows the right approach: there is only one prompt body, and the output language is a template parameter, read by exactly one line — "the summary field must be written in such-and-such language." Any change to any rule then applies to every language automatically. Note the line about other fields keeping the English identifiers exactly as they appear: it guards a real trap. When you ask for English output, the model will happily translate dueDate into due_date or even deadline, and /todos into /tasks. Which fields follow the language and which never change has to be stated in the template.
There is a companion rule on the schema side: enum values do not follow the language. method is always POST, never a localized verb. type is always date, never a localized noun. Enums are read by programs; only human-facing fields such as summary switch languages. Put plainly: the column headers belong to the program, and only the notes inside the cells belong to the human.
What to do when parsing fails: validate, retry, degrade
The forms the new colleague fills in go wrong too: the status code comes back as 200, the mandatory column is left blank. The colleague fills the form wrong; you point it out and ask for a redo, and if it's still wrong you set it aside marked "needs a human." You do not scrap the whole reporting effort over one bad form. Handling model output works exactly the same way, in three layers.
Layer one, validation. Once you have the object, check the business rules in code, one at a time: endpoint starts with a slash, method is in the enum, failureStatus is a 4xx integer, summary is non-empty, every item of validations has the four keys with the right types. This layer does not duplicate the schema — the schema constrains shape during generation, validation checks content after it. The validation function should return an error list, not a boolean, because the next layer needs that list.
Layer two, retry once. When validation fails, splice the error list into the user message and call again: "the previous output had these problems, please fix them: failureStatus must be a 4xx integer." The essential part is retrying with the reason attached — resend the same thing verbatim and you will most likely get the same failure; tell it what was wrong and it usually fixes it on the first try. Do not retry many times; once is enough. Needing more than two says the problem is not this input but your prompt or your schema, and you should go change the template rather than keep retrying.
Layer three, degrade. If both attempts fail, return an empty value, log it, and let the caller decide whether to skip or route it to a human. The most important rule here is do not throw. A failed extraction is a normal business branch, not a program error, and throwing lets one bad requirement abort an entire batch. Do not settle for the "almost right" result from the previous attempt either — half-correct structured data is more dangerous than no data, because everything downstream treats it as true.
Source Reading
Hands-On Lab
This is the course's first runnable lab, and the first four criteria need no API key. Open labs/prompt-engineering-5days/day-03-structured-extraction, run solution/ first to see the behavior, then go back to starter/ and fill in the four exercise points:
- In
solution/, runpnpm install --ignore-workspaceand thenMOCK=1 pnpm start. Study the JSON each of the three requirements produces, and watch the third one fail validation and then succeed on retry. - Switch to
starter/and runMOCK=1 pnpm startonce. Notice that the third requirement'sfailureStatusis a string and gets through anyway — that is the behavior exercise 3 fixes. - Complete exercise 1's schema: the four fields of
validations.items, the enums,required, andadditionalProperties: false. - Complete exercise 2's template: replace the hard-coded language and the hard-coded 400 with the
langanddefaultStatusparameters, then run withOUTPUT_LANG=ento verify the summary comes back in English. - Complete exercise 3's validation and exercise 4's retry, then rerun the third requirement and confirm you get a correct result after the attempt-1 failure. If you have a key, drop
MOCK=1and compare stability against the real model.
Interview Questions
Today's three questions are in the question bank below, covering how schema constraints work and where they stop, the trade-offs in template variables, and the design of parse-failure fallbacks. The follow-up on the third question is the one production systems get asked most often; derive it yourself before reading the analysis.
Checklist and Tomorrow
- Explain why structured output must rely on a schema rather than a line saying "please output JSON"
- Write a prompt as a template with variables, and explain how to split variables from constants
- Write a script that extracts structured fields from natural language, and handle parse failures
- The first 4 acceptance criteria of the structured extraction script pass
- You can answer at least 2 of the 3 interview questions without looking at the points
Tomorrow (D4) answers a question that has been hanging over us: you changed a version of the prompt, so how do you know whether it got better or worse? "This version feels better" is not usable in production, and we are going to turn it into "this version got three more of ten test cases right." D4 teaches you to build a small test set, write an A/B evaluation script, put version numbers on prompts, and recognize five of the most common anti-patterns. Today's structured output is exactly what makes evaluation possible — when the output is fixed fields, scoring can be done in code.
Interview questions
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?结构化输出为什么要用 schema 约束,而不是在提示词里写「请输出 JSON」?schema 通过之后还需要校验吗?
Common in ChinaCommon overseasBasic#structured-output#json-schemaHow 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 在生成时约束形状,模型不是「生成完再检查」而是「只能生成这个形状」,所以稳定性是质变而不是量变。
- 后半句是区分度:schema 只能约束形状,不能约束内容——整数不等于 4xx,字符串不等于非空。业务规则必须在代码里再查一遍,校验函数返回错误列表供重试使用。
- 可预期的追问:严格模式有什么限制?所有字段都要进 required、要写 additionalProperties false、只支持 schema 子集、首次编译有开销;以及「可选字段怎么表达」——类型允许 null 而不是从 required 里去掉。
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
答题要点
- 「请输出 JSON」只约束「是 JSON」,挡不住包解释文字、字段名不一致、数字变字符串、多字段、省键这几种踩空
- schema 在生成时约束形状:required、enum、type、additionalProperties 各挡一种错误
- schema 通过之后仍要校验业务规则,因为形状正确不等于内容正确
- 严格模式要求所有字段进 required 且 additionalProperties 为 false;可选字段用允许 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?提示词模板里哪些内容该做成变量,哪些该写死?变量多了会有什么问题?
Common in ChinaCommon overseasIntermediate#prompt-template#structured-outputHow 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
答题要点
- 判据:下一次调用还一样的是常量,可能不一样的是变量
- 角色、任务、约束、schema 是常量;输入文本、输出语言、默认值是变量
- 每个变量都是一条测试维度,变量越少越好,只取过一个值的变回常量
- 多语言是一个变量,只影响给人读的字段,枚举与标识符不变
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?模型返回的 JSON 解析或校验失败时,你会怎么设计兜底?重试几次、怎么重试、失败之后怎么办?
Common in ChinaCommon overseasIntermediate#structured-output#error-handlingHow 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.
分析过程 · 先想清楚再作答
- 这题是生产题,考的是「有没有见过模型抽风」。答「加个 try catch 重试三次」是新手答案,它没回答重试时发什么、也没回答最后怎么办。
- 拆法:分三层。校验层返回错误列表而不是布尔值;重试层把错误列表拼进用户消息,让模型知道上一次错在哪,原样重发大概率同样的错;降级层返回空值并记录,交调用方决定跳过还是人工处理。
- 重试次数:一次就够。两次以上还不对说明问题不在这条输入而在提示词或 schema,应该修模板而不是继续重试;每次重试都是一次完整调用的钱和延迟。
- 结论里最重要的一条:降级不要抛异常,也不要把「差一点」的结果凑合着用。抽取失败是正常业务分支;半对的结构化数据比没有数据更危险,因为下游会把它当真的。
- 追问方向:怎么区分「模型抽风」和「提示词有问题」?看失败率——偶发是抽风,某类输入稳定失败是提示词或 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
答题要点
- 三层:校验返回错误列表、带着错误原因重试一次、失败后返回空值并记录
- 重试时必须把错误列表拼回用户消息,原样重发大概率同样的错
- 重试一次足够,稳定失败说明模板或 schema 缺覆盖,该修模板不该继续重试
- 降级不抛异常、不用半对的结果;监控重试率,稳定失败的输入加进测试集
Comments
Sign in to join the discussion
No comments yet — be the first.