← 算法与AI总览 首页 / 算法与AI / AI 基础入门 / 第21课 · 结构化输出
AI 基础入门 · 第21课

结构化输出:为什么自由文本不能当程序读,怎么让模型吐 JSON

你在聊天框里跟 AI 聊得很欢,但程序员最怕的一幕是:让模型"返回一个 JSON",它却写成 好的,结果如下:{...},或者少个逗号、多句解释,你的 JSON.parse() 直接崩。这一课讲清楚为什么自由文本不能直接当程序数据用,以及业界的三板斧:JSON 输出模式(json mode)、JSON Schema 约束、function calling 的 json mode,最后讲 llama.cpp 里 --grammar 用 EBNF 文法到底是怎么从根上把模型"逼"成只吐合法 JSON 的。这是 Agent(第10课)能调用工具的地基。

① 小白第一课怎么学(4 步走,约 65 分钟)

这一课偏工程,重点是理解"约束从哪来",不是背语法。

1先理解痛点(10 分钟)
读②③:自由文本为啥不能直接 parse。
2三板斧对照(20 分钟)
读④:json mode / JSON Schema / function calling。
3看懂 grammar 原理(15 分钟)
读⑤:EBNF 在采样时干了什么。
4刷题自测(20 分钟)
做⑦⑩,错题回⑥看易错点。
本课小目标学完你要能:① 说清为什么 prompt 里写"请输出 JSON"不可靠;② 区分 json mode 和 JSON Schema;③ 解释 llama.cpp 的 --grammar 是怎么在采样阶段把非法 token 概率清零的。

② 一图看懂:从"自由文本"到"可解析 JSON"

自由文本输出(默认) "好的,结果是:{... 哎呀" → 有废话、少逗号、字段乱 → JSON.parse() 报错崩溃 加约束 三层约束(由弱到强) ① prompt 提示(最弱,靠自觉) ② json mode:只保证是合法 JSON ③ JSON Schema/grammar:字段也固定 ④ function calling:连函数名都限定 结构化输出 {"city":"南京","temp":28} → 后端直接 parse 入库 → 程序能读、能路由 约束的本质:在采样阶段(第12课)把"非法 token"的概率清零 不是等模型说完再去修字符串,而是边生成边挡:下一个该是数字时, 就不许它输出字母;该写引号时,就不许它输出逗号。从根上保证合法。
读法:左边是"裸奔"——模型爱说啥说啥,后端 parse 必崩;中间是三层约束,越往下越严;右边是结果——输出变成程序能直接消费的数据。关键洞察在最下面:约束不是事后修字符串,而是在采样时就挡住非法 token。

③ 本质直觉:自由文本是"人话",程序要的是"表格"

为什么不能靠 prompt 里写一句"请输出 JSON"?因为大模型本质是在"预测下一个最可能的 token"(第12课)。它没有"必须遵守格式"的硬约束,只是被你提醒了"大概该输出 JSON"。心情好就规规矩矩,心情不好就加一句"以下是结果:"、或者在末尾补一句"希望对你有帮助"、或者把引号打成中文引号 “”——这些对人来说无所谓,对 JSON.parse() 来说全是致命错误。

核心矛盾 人话 = 连续、自由、可带修饰;程序数据 = 严格、固定、零容错。
让"说人话的模型"直接吐"零容错的数据",中间必须加一层硬约束。

那这层硬约束加在哪?回忆第12课:模型每生成一个 token,都会先算出一整张"下一个词的概率表"(logits),再按温度采样。结构化输出的所有技巧,本质都是同一件事——在采样前,把"不符合格式的那些 token"的概率强行改成 0。

· 比如现在语法上必须是个开引号 ",那就把整张概率表里所有不是 " 的 token 全部清零,模型只能选 ";写完字符串该闭引号了,就只允许 ";接下来该是冒号了,就只允许 :。这样一步步"牵着走",出来的必然是合法 JSON。

一句话本质结构化输出不是"求模型好好写",而是"在采样器门口设了个安检":不符合格式的 token 一律不准出门。模型的自由发挥,被限定在格式允许的格子里。

④ 完整体系:三板斧 + llama.cpp grammar

四种约束方式对比(由弱到强)

方式怎么触发约束到什么程度可靠性
Prompt 提示在 prompt 里写"请只输出 JSON"无硬约束,全靠模型自觉低,经常翻车
JSON mode(json_object)请求体加 "response_format":{"type":"json_object"}只保证输出是合法 JSON,字段不固定中高
JSON Schemaresponse_format 里塞 json_schema,规定字段名/类型/枚举连有哪些字段、什么类型、取值范围都卡死高
function calling 的 json mode传 tools 数组,模型决定调哪个函数、填什么参数函数名 + 参数结构都由 Schema 限定高(Agent 核心)
llama.cpp --grammar(EBNF)命令行 --grammar grammar.gbnf用文法精确描述任何输出结构,不止 JSON最高,本地引擎级

JSON Schema 长什么样

{
  "type": "object",
  "properties": {
    "city":    { "type": "string" },
    "temp":    { "type": "number" },
    "weather": { "type": "string", "enum": ["晴","阴","雨"] }
  },
  "required": ["city","temp"]
}

这段 Schema 等于告诉模型:你必须输出一个对象,里面要有 city(字符串)和 temp(数字),weather 只能是晴/阴/雨三个之一。后端收到后直接 JSON.parse 取字段,零容错。

function calling 和普通 json mode 的关系

Function calling 本质就是更具体的结构化输出:你不只要求"吐 JSON",而是要求"从给定的几个函数里选一个,并按它的参数 Schema 填好 JSON"。模型吐出的不是给人看的话,而是一段"调哪个函数、参数是什么"的结构化指令——这正是第10课 Agent 能调工具的原理。

json mode ≠ JSON Schema很多人混淆。json mode 只保证"大括号配对、逗号正确",里面放什么字段它随便编;JSON Schema 才把字段名、类型、枚举值都钉死。要让后端稳定取 data.temp,必须用 Schema,光开 json mode 不够。

⑤ llama.cpp 的 --grammar / EBNF 原理

什么是 EBNF 文法约束
EBNF(扩展巴科斯范式)是一种用文字描述"合法字符串长什么样"的形式语言。
llama.cpp 的 --grammar 参数接受一个 .gbnf 文法文件,它告诉引擎:接下来的输出必须符合这套语法。例如一个最简单的"城市+温度"文法:先写一个 {,然后 "city",冒号,一段字符串,逗号,"temp",冒号,一个数字……
它在采样时具体干了什么
关键就在第12课那张 logits 概率表。
引擎每算完一步 logits,文法解析器就看:"按语法,下一个位置允许出现哪些 token?"然后把所有不允许的 token 的 logit 改成 -∞(概率 0),再喂给采样器。于是模型在"被允许的候选"里选,选完文法推进到下一个状态,如此往复。这不是事后修补,是生成的同时就被文法牵着走。
为什么本地引擎要自己实现 grammar
云端 API 的 json mode 是服务端黑盒帮你做的。
但你本地用 llama.cpp 跑 GGUF(第15、20课)时,没有云端帮你约束,所以 llama.cpp 自己内置了一套 GBNF 文法引擎,用 --grammar 参数手动挂上。OpenAI 兼容的 response_format 在 llama.cpp server 里也会被自动转成等价文法。
# 一个极简 JSON 文法片段(llama.cpp .gbnf 风格示意)
root   ::= "{" ws "\"city\"" ws ":" ws string ws ","
           ws "\"temp\"" ws ":" ws number "}" ws
string ::= "\"" [^"]* "\""
number ::= [0-9]+ ("." [0-9]+)?
ws     ::= [ \t\n]*
应用场景清单① 让模型从简历里抽出固定字段入库;② 让模型输出分类标签供程序路由;③ function calling 让 Agent 调天气/日历/搜索工具;④ 强制输出 SQL、表格、评分(1~5 分)这类机器要直接消费的结构。

⑥ 高频错误诊断(4 条)

错误 1:只在 prompt 里写"请输出 JSON"就上线这是最常见的坑。没有硬约束时,模型偶尔会加解释文字、用中文引号、漏逗号,JSON.parse 在生产环境随机崩溃。必须开 json mode / JSON Schema。
错误 2:以为开了 json mode 字段就固定了json mode 只保证"是个合法 JSON",字段名模型仍可能乱编。要固定字段必须传 JSON Schema,写明 required 和类型。
错误 3:事后用正则去"修"模型输出很多人写一堆正则想把 好的:{...} 里的大括号抠出来。这很脆弱,遇到嵌套括号就错。正确做法是从采样端约束,而不是事后擦屁股。
错误 4:把 grammar 当成"改模型权重"grammar 不改变模型参数,也不改变它的知识,只是在采样时过滤候选 token。模型仍然可能"在合法 JSON 里填错误的内容"(比如把温度写成 -999)——格式对了,不代表内容对了。

⑦ 考点真题演练(4 题)

考点分布

考法出题形式应对
约束位置问约束加在哪一步采样前清零非法 token
json mode vs Schema问哪个固定字段JSON Schema
function calling问它本质是什么更具体的结构化输出
grammar 原理问 --grammar 怎么生效按 EBNF 过滤 logits

真题基础1. 为什么只在 prompt 里写"请输出 JSON"不可靠?

真题中档2. 想让模型固定输出 city、temp 两个字段且 temp 必须是数字,应该用?

真题中档3. llama.cpp 的 --grammar 约束,本质是在哪一步生效?

真题拔高4. 关于 function calling,正确的理解是?

⑧ 必背知识点卡

核心矛盾:人话自由 vs 程序数据零容错 中间必须加硬约束
约束本质:采样前把非法 token 的 logits 清零 边生成边挡,不是事后修
json mode:response_format type=json_object,只保证合法 JSON 字段不固定
JSON Schema:规定字段名/类型/枚举/required 字段也钉死
function calling:更具体的结构化输出,吐"调哪个函数+参数" Agent 地基
llama.cpp grammar:--grammar x.gbnf,EBNF 文法逐 token 约束 本地引擎级
易错:格式对 ≠ 内容对 grammar 不保证事实正确

⑨ 应用输出:写一个"抽天气"的结构化请求

实战场景:让模型把"明天南京大概28度,有点小雨"抽成固定 JSON
① 不要只写"请输出 JSON"——加 response_format,并附上 JSON Schema,要求 city、temp(数字)、weather(枚举晴/阴/雨)。
② 请求体:{"model":"...","messages":[...],"response_format":{"type":"json_schema","json_schema":{"schema":{...}}}}。
③ 后端:直接 JSON.parse(content),取 data.city、data.temp 入库,不用写正则清洗。
④ 本地跑 llama.cpp:用 --grammar weather.gbnf 挂上等价文法,离线也能拿到同样保证。
口述全链路"自由文本人能读、程序读不了,因为它有废话还可能缺逗号。解法不是事后用正则擦屁股,而是在采样那一步,按文法把非法 token 的概率清零:该写引号就只许引号,该写数字就只许数字。json mode 只保证合法 JSON,JSON Schema 连字段都钉死,function calling 则进一步规定调哪个函数——这就是 Agent 能可靠调用工具的地基。"

⑩ 分层练习 16 题(基础 6 + 中档 5 + 拔高 5)

▍基础 6 题

基础1为什么大模型的自由文本不能直接当程序数据用?
因为它自由、可能带废话、标点不规范(中文引号、漏逗号),而程序 JSON.parse 零容错,遇到就崩。
基础2json mode 的触发参数长什么样?
请求体加 "response_format":{"type":"json_object"}。
基础3json mode 保证了什么、没保证什么?
保证输出是语法合法的 JSON;没保证有哪些字段、字段类型——那是 JSON Schema 的事。
基础4JSON Schema 里 required 字段干嘛用?
列出哪些字段必须出现,缺了就不合法,避免模型漏掉关键数据。
基础5llama.cpp 挂文法用哪个命令行参数?
--grammar xxx.gbnf。
基础6function calling 吐出的内容由谁真正执行?
模型只吐"调哪个函数、参数是什么"的结构化 JSON,真正执行函数的是你自己的后端代码,模型不碰你的系统。

▍中档 5 题

中档7结构化约束为什么说是"在采样阶段"生效?
因为模型每步都要从 logits 采样下一个 token。约束就是在采样前,按文法把当前位置不允许的 token 的 logits 改成 -∞,让模型只能在合法候选里选。
中档8为什么不推荐用正则事后"抠" JSON?
模型输出可能嵌套括号、带解释、标点杂糅,正则很难覆盖所有情况,脆弱且误判率高。从采样端约束才能从根上保证合法。
中档9json mode 和 JSON Schema 到底差在哪?
json mode 只约束"是不是合法 JSON";JSON Schema 进一步约束"必须有哪些字段、每个字段什么类型、枚举值是什么"。要后端稳定取字段必须用 Schema。
中档10EBNF 是什么?和 grammar 什么关系?
EBNF(扩展巴科斯范式)是描述合法字符串结构的形式语言;llama.cpp 的 .gbnf 文法文件就是用 EBNF 写的,引擎据此逐 token 约束输出。
中档11"开了 grammar 模型就不会输出错误信息了"对吗?
不对。grammar 只保证格式合法(是个结构正确的 JSON),不保证内容正确(温度可能编错)。格式对和事实对是两回事。

▍拔高 5 题

拔高12function calling 和普通结构化输出的关系?为什么说它是 Agent 的地基?
function calling 是结构化输出的特化:你传一组工具定义(名字+参数 Schema),模型按 Schema 吐出该调哪个工具、参数填什么。后端拿到这个结构化指令去真正执行,再把结果喂回模型——循环起来就是 Agent(第10课)。
拔高13为什么云端 API 帮你做了 json mode,本地 llama.cpp 还要自己实现 grammar?
云端是服务端黑盒,它在自己的推理栈里做了 logits 过滤;你本地跑 GGUF 时没有这个黑盒,所以 llama.cpp 内置 GBNF 文法引擎,用 --grammar 手动挂上,在自己的采样循环里做同样的过滤。
拔高14grammar 约束会不会降低输出质量或速度?
它只是把非法候选清零、重新归一化概率,计算开销很小;在格式严格的场景下反而省去了重试和清洗,端到端更快。它不限制"在合法范围内选哪个",所以合法内容的质量不受损。
拔高15想让模型输出一个枚举:评分只能是 1~5 的整数,Schema 怎么写?
给 score 字段声明类型为整数并用枚举或范围限制,例如 "score":{"type":"integer","enum":[1,2,3,4,5]};配合 grammar,模型在该位置只能吐出 1~5 之一。
拔高16结合第12课采样,讲清"温度高"和"结构化约束"是否冲突?
不冲突。温度是在"合法候选"集合内调节随机性;结构化约束是先把非法候选剔除。流程是:先按文法清零非法 token → 再在剩余合法 token 上按温度/top_p 采样。温度高只是让它在合法格式内更灵活,不会逃出格式。

⑪ 记忆口诀 + 7 天复习计划

三句口诀 ① 自由文本人能读,程序数据零容错。
② 约束不在事后修,采样门口清非法 token。
③ json mode 保合法,Schema 钉字段,grammar 牵着走,function calling 调工具。
天任务自检
第 1 天读②③,理解"采样时清零"原理说清约束加在哪步
第 2 天背知识点卡 + 基础 1-6基础全对
第 3 天读④⑤,做中档 7-11区分 json mode/Schema
第 4 天做拔高 12-16讲清 grammar 与采样关系
第 5 天做⑦真题 4 题限时每题 2 分钟
第 6-7 天合上书口述三句口诀和四层约束不看资料全说对
📌 知识链路

前置知识(先学):第12课 采样与生成参数——结构化约束就是作用在 logits→采样这一步,不懂采样就看不懂 grammar;第20课 llama.cpp 配置实战——--grammar 是 llama.cpp 的参数,先会起服务再谈约束。

本节位置:模型默认吐自由文本,这一课给它套上"输出格式的缰绳",是从"聊天"走向"被程序调用"的关键。

下一步(学完去):function calling 详解——结构化输出的特化,让模型调工具;第10课 Agent——结构化输出 + function calling 拼起来,Agent 才能可靠地长出手脚。

← 第20课 · llama.cpp 配置实战 算法与AI总览