记录一下做题目结构化时遇到的几个问题。
最开始很容易把它理解成 prompt 问题:只要在提示词里写清楚“请按 JSON 返回”,模型就应该输出一个合法 JSON。后来发现不是这样。JSON 本身只是格式,真正麻烦的是训练数据、模型输出、后处理、校验逻辑和业务消费端要对同一份结构有相同理解。
下面主要记录几个当时反复遇到的问题:题型怎么拆、字段类型怎么定、训练和推理的处理怎么对齐、校验放在哪里,以及 prompt 在这里到底起什么作用。
最开始的问题
如果只是做一个 demo,结构化输出可能只是:
1 | { |
但真实业务里的题目并不是这样。单选题、多选题、完形填空、阅读理解、填空题、大题小题嵌套,输入和输出形状都不同。题干里可能混着 HTML;公式可能是图片,也可能是 LaTeX;选项可能在表格里,也可能夹在正文里;子题之间可能共享一段材料,也可能各自带解析。
以一条简化后的阅读填空题为例,原始题目可能长这样:
1 | <div class="question"> |
业务要的也不是“答案是 B”这么简单,而是要知道题干、图片、选项、空格、子题和答案之间的关系:
1 | { |
这个例子里,图片不是普通附件,空格也不是普通下划线。它们都要变成可以被后处理和业务系统引用的对象。
所以“输出 JSON”只能说明模型返回了一种文本格式。它不能说明:
- 字段是否稳定;
- 类型是否稳定;
- 下游语言能不能解析;
- 训练前处理和推理前处理是否一致;
- 推理后处理能不能把结果还原成业务需要的形态;
- 错误输出是否能被发现,而不是悄悄进入业务链路。
这里的问题就不只是 prompt 能不能约束模型了。业务输入、训练数据、模型输出、解析代码、校验代码、推理前后处理、调用方消费方式,只要有一环没有对齐,最后看起来都会像“模型不稳定”。
按题型拆的问题
最早做题目结构化时,一个自然想法是:按题型拆能力。单选题训练一种输出,多选题训练一种输出,完形填空训练一种输出。这个思路很直观,因为业务本来就是按题型组织的。
但题型一多,问题就会变复杂。不同题型会共享一部分字段,比如
content、answer、solution、accessories;也会有一些字段只在特定题型出现,比如子题列表、选项列表、空格标记。
第一,能力边界会变模糊。模型可能在某个题型里学到了另一个题型的输出习惯,出现所谓“串味”:不该出现的字段出现了,该出现的层级没出现,或者题型判断和字段组合不一致。
第二,下游消费端并不关心训练时按什么能力拆。它只关心收到的结构能不能稳定解析。对消费端来说,字段是不是同名、类型是不是固定、嵌套是不是可控,比模型内部能力怎么拆更重要。
所以后来就不能只按题型理解结构化,而是要反过来看消费端到底要什么字段、什么类型、什么层级。
如果消费端希望拿到的是:
1 | class QuestionAnswer(BaseModel): |
那模型输出、训练数据、后处理、校验都应该围绕这个结构来设计。否则模型可能生成“看起来合理”的 JSON,但消费端还是要写一堆兼容逻辑才能用。
字段类型不稳定
结构化输出里还有一个很容易忽略的问题:字段类型要稳定。
举个简化例子:
1 | { |
在 Python 里,我们可以把它理解成
List[Union[str, List[str]]],写几行判断也能处理。但如果这个结果要被
Java
服务长期消费,复杂度会立刻上升。消费端需要判断每个元素到底是字符串还是列表,再分别处理。字段越多,分支越多,长期维护成本越高。
所以后来整理输出格式时,基本原则就是:一个 key 只对应一种数据结构。
如果某个字段有时是 str,有时是
list[str],最好拆开或统一。如果可以把复杂嵌套压平,就不要把动态类型留给消费端。如果不能压平,也要让
schema 明确告诉调用方它会遇到什么。
这不是代码洁癖。模型输出本来就有不确定性,下游结构再做成动态类型,排查问题时会很痛苦。
类似地,字段命名也不是小事。Python 里习惯写
sub_answer_list,Java 侧可能更希望是
subAnswerList。如果这个结构要穿过多个语言栈,字段命名最好一开始就按消费端约定,而不是后面靠适配层到处转换。
一个可以参考的改造方式是,把混合结构拆成两个稳定字段。
改造前:
1 | { |
这个结构表达的是:有些空只有一个答案,有些空有多个等价答案。但它把“空的顺序”和“候选答案集合”混在同一层里,消费端每次都要判断元素类型。
改造后可以变成:
1 | { |
这样 Java 侧只需要按 List<BlankAnswer>
解析,不需要在循环里反复判断当前元素是 String 还是
List<String>。字段变多了,但类型更稳定,后续校验也更容易写:空的数量、序号、候选答案是否为空,都能直接检查。
训练和推理处理不一致
结构化输出还有一个容易被低估的问题:训练和推理不是同一个场景。
训练时,我们希望数据尽量干净、紧凑、稳定。比如可以把冗余 HTML
属性去掉,把公式图片替换成公式文本,把不同形态的空格统一成
<TAG>。这些处理能降低模型学习难度,也能减少无意义
token。
但推理时,输入来自线上业务。它的分布不一定像训练数据那么干净。如果训练前处理和推理前处理不一致,模型学到的是一种输入分布,线上看到的是另一种输入分布,最后问题会被误判成“模型泛化不好”。
因此,输入侧最好让训练前处理和推理前处理共用一套逻辑。
输出侧则更复杂。为了让模型更容易学,有些业务输出可能会在训练前被压缩或标准化。比如业务最终想要的是一个冗长格式,但训练时为了减少 token,把它变成一个更短的标记。这样做可以,但推理后必须能可靠还原。
回顾一下,输出处理不是一条单向链路,而是一个闭环:
1 | 业务原始格式 -> 训练输出格式 -> 模型生成 -> 推理后还原 -> 业务消费格式 |
如果中间任何一步不可逆,或者没有校验,就会出现一种很危险的情况:模型生成了“训练侧合法”的输出,但业务侧不可用。
所以,题目结构化里的数据处理最好不要变成几份互相独立的脚本。训练前、推理前、推理后都要按同一套约定来做。
一个比较理想的处理链路,是把可逆映射显式记录下来。例如输入里有一张公式图片:
1 | <img src="https://example.com/formula_001.png" alt="x^2 + y^2 = z^2" /> |
训练时可以把它标准化成:
1 | <FORMULA id="f1">x^2 + y^2 = z^2</FORMULA> |
模型只需要学习稳定文本标记,推理后再根据 id=f1
还原成业务需要的公式对象或图片引用。填空题也类似,原始 HTML
里的下划线、空格、<span> 可以先统一成
<BLANK id="b1" />,输出里再引用 b1:
1 | { |
这类映射的重点不是把输入“清洗干净”,而是保留可还原的锚点。只要锚点稳定,训练侧可以简化输入,业务侧也能把模型输出准确放回原始题目结构里。
校验逻辑
输出格式定下来后,下一步就是校验。
这也是为什么我倾向于使用 pydantic 这类工具管理训练数据和输出结构。它的价值不只是“写类型更舒服”,而是能把很多隐蔽错误提前暴露出来。
例如:
- 某个字段应该有值,但数据源为空;
- 某个字段应该是列表,但模型输出成字符串;
- 子题数量和空格数量不匹配;
- 答案字段存在,但对应题干结构缺失;
- 输出 JSON 合法,但不符合业务 schema。
如果没有校验,这些问题可能会混进训练集,也可能在线上推理后才被业务发现。到那时再排查,通常已经很难判断是数据问题、模型问题、prompt 问题,还是后处理问题。
校验的另一个价值是统一训练侧和业务侧的判断标准。训练侧认为合法的样本,业务侧也应该能接受;业务侧认为非法的输出,训练侧也不应该把它当成高质量数据继续学习。
这也是 guided JSON、outlines、guardrails 等工具背后的共同方向:不要只靠自然语言告诉模型“应该输出什么”,而是尽量把输出约束变成机器可验证的规则。
当然,生成约束工具不是银弹。强约束能保证格式,但不能保证语义正确;schema 能保证字段存在,但不能保证答案对。它解决的是“形状可靠性”,不是“内容正确性”。这两者需要分开评估。
prompt 放在哪里
在题目结构化里,prompt 和一些运行时技巧当然还是有用的。
如果已经知道题型,可以在生成开头强行拼一段固定内容,例如提前放入
type 字段,降低模型在已知题型上跑偏的概率。这类
force_prompt 对稳定输出有帮助。
另一个技巧是限制
maxTokens。很多结构化失败并不是模型完全不会,而是生成过程中开始重复,最后拖慢速度甚至产生无效输出。根据业务特点限制输出长度,可以减少重复生成,也能提升服务稳定性。
但这两类约束只能解决局部问题。
force_prompt
只是把部分字段钉在开头,不能保证后续字段类型都稳定。maxTokens
可以限制输出长度,但不能保证截断前一定输出完整结构。真正要长期稳定,还是要靠
schema、处理链路、校验、错误处理和消费端兼容一起约束。
后来怎么拆
回头看,题目结构化的路线变化大概是:从按题型拆能力,逐渐转向按输出结构拆能力。
按题型拆适合早期探索。它直观,也容易和业务需求对齐。但随着题型越来越多,能力之间会共享字段、共享处理逻辑,也会互相干扰。
后面更关心的就不是“这是哪个题型”,而是:
- 这个输入需要被拆成哪些稳定字段?
- 这些字段之间有什么约束?
- 下游服务希望如何消费?
- 训练和推理是否共用同一套前处理?
- 输出是否能被校验和后处理?
- 错误时能不能定位到具体字段或阶段?
这些问题比“prompt 怎么写”麻烦,但绕不开。
小结
最后记录一下当时留下来的几个原则:
- 先定义消费端契约,再设计模型输出。
- 一个字段只对应一种稳定类型。
- 输入处理尽量贴近线上分布,少做破坏性清洗。
- 输出尽量短、稳定、可校验。
- 训练前处理、推理前处理、推理后处理要成闭环。
- schema 要能被代码校验,而不是只写在 prompt 里。
- 失败输出要分类记录,不要只看总体准确率。
- prompt 是契约入口,不是契约本身。
如果只是 demo,prompt 能解决很多问题。但要把大模型能力接进业务系统,只让模型“像 JSON”还不够,最后还是要看这份 JSON 能不能被后面的系统稳定消费。