说了只输出 JSON,模型为什么还会写错
文字稿约 3400 字 · 按画面章节整理展开收起
让一个本地小模型,从报销消息里,抽出姓名、日期、金额这些字段。提示词里特意写了一句:只输出 JSON,不要输出别的内容。第一条,它这样回复。程序拿去一读,就报错了:回复开头,多了一行代码块标记。换一条消息,这回能读了,可它把结果,装进了一个数组;程序按字段去取金额,又报错。JSON,是一种写给程序读的数据格式,后面细说。说了只输出 JSON,为什么还会写错?
今天,我们用三种做法,各跑 80 次,数一数到底坏在哪;再看看,有哪些问题,是约束也管不了的。
读模型回复的,是程序
先说说,为什么非要 JSON。Agent,也就是让模型一步步调用工具、完成任务的程序,要从模型的回复里,取出工具的参数;抽取数据,要把字段,一项项存进数据库。读这些回复的,是程序,不是人。所以通常约定,用 JSON 来写。JSON,全称是 JavaScript Object Notation,中文叫 JavaScript 对象表示法:用花括号,把一对一对的“名字”和“值”写在一起。程序用 json.loads 这样的函数来读;回复里多一行代码块标记,它就读不了。
任务:从随手写的报销消息里,抽出五项
我们的任务是这样的。我们自己写了 16 条报销消息,故意写得很随意:有的说“上周五”,有的金额写成汉字,有的没提项目。每条都要抽出五项:姓名、日期、金额、类别、项目。这些要求,可以写成一份 JSON Schema,它说明这个 JSON 该长什么样:有哪些字段,每个字段是什么类型,类别只能从五个里选,项目可以是空的,写 null。每条消息,我们都事先写好了标准答案。
三种做法
让模型照这个格式写,有三种做法。第一种,只靠提示词:把字段写清楚,再加一句“只输出 JSON”。第二种,JSON 模式。我们用 Ollama 这个工具,在本地跑模型,调用时可以要求:回复必须是 JSON。第三种,把整份 schema 交给 Ollama,按它来约束。三种做法,提示词一字不差,16 条消息,每条各跑 5 次,一共 80 次。温度,也就是挑选时的随机程度,和其他采样设置一样,都用模型的默认值;思考关掉。
每次回复,按同一套规则归进四类
每一次回复,都按同一套规则,归进四类。先拿 json.loads 读,读不了,算解析失败。读得了,再拿 schema 检查:装进了数组、缺了或多了字段、类型不对、类别不在那五个里、日期不是年月日的样子,都算格式不符。格式也对,再跟标准答案逐项、逐字地比:有一项不一样,就算内容错。五项全对,才算全对。
三种做法,各 次回复
结果是这样。一格就是一次回复,一列是一条消息。只靠提示词:8 次解析失败,28 次格式不符,加起来 36 次,程序没法直接用。JSON 模式:解析失败,一次也没有;格式不符,还剩 5 次。按 schema 约束:解析失败和格式不符,都是零;这正是约束保证的。不过,全对的,三种做法分别只有 25、39、35 次。这个后面再说。
只靠提示词:坏在哪
先看只靠提示词,坏在哪。解析失败的 8 次里,7 次,开头多了一行代码块标记;1 次,是结尾多了一行。我们猜,这是模型在聊天里,把代码装进代码块的习惯。格式不符的 28 次里,26 次,是把结果装进了数组;剩下两次,一次把金额写成了字符串,一次日期写了 null。
把提示词写清楚,能好多少?
不过,提示词只说了“只输出 JSON”,数组也是合法的 JSON;可我们的程序,要的是一个对象。那就把提示词写清楚:改成“只输出一个 JSON 对象”,其他一字不改,同样再跑 80 次。代码块标记和数组,一次也没有了;可还有 7 次格式不符,都是日期写成了 null。不能用的,从 36 次,降到了 7 次:少多了,但不是零。按 schema 约束,才是零。提示词只是请求,约束是规定。
JSON 模式:只管“是一个对象”
JSON 模式,就是一种约束,只是宽一些。Ollama 的 JSON 模式,规定回复必须是一个 JSON 对象:花括号开头,花括号结尾。代码块标记、数组,从第一个字起,就写不出来。可它只管“是一个对象”,不管里面有哪些字段、是什么类型:剩下的 5 次格式不符,都有日期写成了 null,其中 4 次,还加了引号。把 schema 交给它,日期就只能写成年月日的样子。
约束解码:挑之前,先划掉会走错的 token
这个“只能”,是怎么做到的?模型是一小段一小段往外写的,每一段,叫一个 token。每写一步,它都给词表里的每个 token 打一个分,换算成概率,再按概率挑一个。看一步真实的:周杰那条消息,回复的第一步。这里把温度设成 0,也就是每次直接拿概率最大的。模型最想写的,是方括号,占百分之 91.23;其次是代码块标记,4.17。不加约束,它就写下方括号,整个回复,装进了数组。按 schema,回复只能以花括号开头:方括号、代码块标记,全被划掉。前 10 名里,只剩第 5 名,花括号加引号,只占百分之 0.47,前 10 名里,只剩它合规;温度是 0,就写下了它。于是,回复从花括号开始。顺便说一句:这两条回复的日期,其实都错了,昨晚是 10 月 7 日。这个后面再说。
schema 先变成一套语法规则
具体来说,Ollama 把 schema 交给开源项目 llama.cpp,翻译成一套语法规则:先是花括号,接着必须是 name,然后是冒号和一个字符串,再是逗号和 date……一直到最后的花括号。每写一步,就按这套规则,划掉所有会走错的 token,只在剩下的里面挑。这就叫约束解码。周杰这条回复,一共写了 41 步,只有第一步,模型最想写的不合规;其余 40 步,比如下一步,name 占百分之 97.12,约束都不用改它的选择。JSON 模式也是这么划,只是规则宽得多:只要是一个对象就行。还有一点:Ollama 返回的这张概率表,是划掉之前的;加不加 schema,这一步的表一模一样。
格式都对了,内容呢?
约束这么管用,问题就都解决了吗?按 schema 约束的 80 次,每一次都能读,格式都对。可是,其中 45 次,内容是错的。错得最多的,是日期:36 次。“前天”,算成了 10 月 7 日;“9月28日”,年份写成了 2025。金额“三百二”,写成了 3.2;键盘和鼠标,类别填成了“其他”。schema 能规定,日期长成“年-月-日”的样子,可规定不了,到底是哪一天。三种做法,全对的,都不到六成。JSON 模式 39 次,schema 35 次;这点差距,只跑 80 次,分不清是不是碰巧。这是一个 40 多亿参数的小模型、一个我们自己出的任务;换一个模型、换一个任务,数字会不一样,要自己测。
写到上限,就是半截 JSON
第二件管不了的事:长度。调用时可以设一个上限,最多写多少个 token,在 Ollama 里,它叫 num_predict。一条完整的回复,大约 42 个 token。我们把上限设成 40,按 schema 约束,16 条消息各跑 2 次。32 次里,22 次写到上限,被停下了;其中 21 次,只留下半截 JSON,读都读不了。比如这一条,写到项目的引号,就断了;返回里的 done_reason 写着 length,意思是写到了上限。约束只保证,每一步都还走在合规的路上,不保证走到终点。
思考,也占这个上限
还有思考:模型先写一段推理,再给答案。这段推理,也算在同一个上限里。开着思考,约束要等思考写完,才开始管。我们挑了 5 条常错的消息,开着思考、按 schema 约束,上限给到 4096 个 token。2 条想完了,都答对了,可各写了一千二百个 token 左右,每条等了 50 多秒。另外 3 条,想到上限还没想完,回复是空的。关掉思考,一条回复只要 1.2 秒左右;所以前面那 320 次,都关掉了思考。只试了 5 条,每条只跑了一次;思考能让内容对多少,我们没有系统地测。
几家主要的云端服务,也有这个功能
几家主要的云端模型服务,也有这个功能。OpenAI、Anthropic、Google,都叫它 Structured Outputs,也就是结构化输出:把 JSON Schema 交过去,让回复符合它;OpenAI 和 Anthropic 的文档,写的是“保证”。工具调用的参数,也可以这样约束,比如 Anthropic 的 strict 选项。它们的文档,也写明了边界。OpenAI 和 Anthropic 都写着:写到长度上限,或者模型拒绝回答,结果就可能不合 schema;Google 的文档提醒:值对不对,要在你自己的程序里检查。
让模型输出 JSON,记住四条
下次要让模型输出 JSON,记住四条。第一,用约束,不用祈祷:提示词写清楚,能少出错;可只有约束,才保证格式。第二,格式对,不等于内容对:拿标准答案,抽查内容。第三,留够长度:检查 done_reason 是不是 length;开着思考,要留得更多。第四,程序里照样要校验:读不了、格式不对,就重试或者报错,别硬往下用。
约束解码,管的是格式;内容对不对、写没写完,还得你自己的程序来把关。