训练 LLM 生成可靠的结构化输出

你让语言模型输出 JSON,大多数时候它确实能做到。

然后某一次,返回结果坏了。一个数字变成了字符串,或者模型在 JSON 外面包了一行解释,解析器直接报错。

问题就在这点很小的失败率。输出会进入一次函数调用,或者写进数据库;看起来像合法 JSON,不等于它真的是合法 JSON。

下游代码只有在出错时,才知道自己拿到的是哪一种。

这就是 structured output(结构化输出)的含义。模型返回的是固定形状的数据,匹配一个 schema,而不是碰巧读起来对的自由文本。

Agents、tool calls 和数据流水线现在都靠这个跑。模型写出来的东西,是给代码执行的,不是给人阅读的。

难点在于让它稳定可靠。通常的做法是继续给模型加料:多放样例、收紧 prompt,再用正确输出做微调。

这会有一点帮助,但很快就到头了。瓶颈从来不是数据量,而是训练目标。

DeepSeek-R1 展示了一条绕过去的路。过去,训练一个强模型意味着标注流水线、偏好对比数据,以及一整个标注团队。

DeepSeek 用一个 Python 函数替代了这一整套流程:检查答案是否正确。如果你能用代码定义“正确性”,剩下那些就都不需要了。

这就是 GRPO 背后的想法。模型不是从样例里学习,而是从你写的 reward function 里学习。

对每个 prompt,它会生成几个候选答案。reward function 给这些答案打分,模型会被推向分数更高的那些答案。

在这篇 walkthrough 里,我用它来微调 Qwen3-8B,目标是 JSON 抽取。这个 loop 从本地 notebook 启动,模型则在远端 H200 上训练。

reward function 只做一件事:检查每个输出能不能解析,并且是否匹配 schema。

符合 schema 的输出比例,从基础模型的 62% 提升到了训练后的 82%,超过了同一评测上 58% 的 GPT-4.1。

在开始构建之前,先看清楚为什么那个显而易见的方法会卡住,会让后面的东西更容易串起来。

为什么 SFT 会撞到上限

SFT 是通过复制样例来学习的。你给它看正确的补全结果,它就会擅长生成看起来像那些补全结果的输出。

但看起来像合法 JSON,和真的是合法 JSON,是两个不同目标。SFT 追的始终只是前者。

loss 是按 token 逐个衡量的。一个字段类型错了的补全结果,得分几乎和完美补全结果差不多。

所以你继续加样例。数字会往上跳一点,然后变平,因为限制来自训练目标,不是数据。

针对正确性训练,而不是针对样例训练

一旦看清问题,修法就很明确。你用代码定义“正确”,然后直接按这个定义训练模型。

这就是 GRPO 做的事。它把带标签样例换成了 reward function。

对每个 prompt,模型生成一小组答案,通常是 4 到 8 个。你的 reward function 会给每个答案打分。

分数会在组内归一化。随后这次 update 会强化那些高于组平均分的答案。

所以模型始终是在把自己的输出互相比较。它学到的是对你的任务来说什么叫“更正确”,而不是“更像某个样例”。

下面是 reward function 对同一个 prompt 的三个不同输出的打分方式。

  • 不能解析成 JSON 的输出得 0.0 分。
  • 能解析、但 schema 校验失败的输出得 0.5 分。
  • 能解析、并且匹配 schema 的输出得 1.0 分。

中间那个分数比看起来更重要。没有它,字段类型错误的合法 JSON 会和彻底的垃圾输出一样,都得零分。

模型会丢掉一个重要信号:结构已经合法,本身就是进步。0.5 分就是训练继续往上爬的台阶。

为什么 GRPO 需要真正的基础设施

GRPO 比 SFT 重得多。在 8B 模型上,它需要 H200,并且要跑好几个小时。

每一步,它都会为每个 prompt 生成多个补全结果,给它们全部打分,然后更新 weights。这个过程会在整个数据集上反复跑很多遍。

这不是你能在笔记本上跑的东西。

这里还有一个 SFT 从来不会遇到的时序问题。rollout 期间,模型会根据当前 weights 采样答案;training 期间,这些 weights 又在不断变化。

如果 inference 侧和 trainer 不同步,你就会从一个过期模型里采样,然后用当前模型永远不会给出的答案来训练当前模型。大多数自定义 RL setup 都是在这里崩掉的。

Fireworks 的 Training API 同时处理这两边。你在自己机器上用 Python 写训练逻辑。

剩下的交给他们的基础设施。它会分配 GPU,运行 forward 和 backward passes,保存 checkpoints,并且在每一步之后重新同步 inference deployment。

整个 setup 分三步:写 reward function、上传数据集、配置运行任务。

我们逐个看。

构建训练 Loop

Fireworks 在他们的 Training API docs 里记录了完整 setup。里面包括 rl_loop,也就是替你跑完整 GRPO loop 的配方。

克隆 cookbook,并安装训练依赖。

git clone https://github.com/fw-ai/cookbook.git
cd cookbook/training && pip install -e ".[training]"
export FIREWORKS_API_KEY="your-training-scoped-key"

Step 1:写 Reward Function

这是唯一一个定义你任务的地方。schema 说明正确输出长什么样,score() 则逐个检查补全结果是否符合 schema。

以发票抽取为例,我会从原始文本里抽取四个字段:vendor、date、amount 和 currency。

import json
from jsonschema import validate, ValidationError

SCHEMA = {
    "type": "object",
    "required": ["vendor", "date", "amount", "currency"],
    "properties": {
        "vendor":   {"type": "string"},
        "date":     {"type": "string"},
        "amount":   {"type": "number"},
        "currency": {"type": "string"},
    },
    "additionalProperties": False
}

def score(completion: str) -> float:
    try:
        parsed = json.loads(completion.strip())
    except (json.JSONDecodeError, ValueError):
        return 0.0
    try:
        validate(instance=parsed, schema=SCHEMA)
        return 1.0
    except ValidationError:
        return 0.5

jsonschema 会在一次调用里处理类型检查、必填字段,以及任何嵌套规则。

换一个任务,比如 SQL 或 tool-call formatting,只要换一个新 schema。score() 函数保持不变。

Step 2:准备 Dataset

GRPO 不需要带标签的输出。数据集只是你在生产里会发送的那些 prompts。

训练期间,模型会自己写补全结果,score() 会在它们生成时给它们打分。

我用了 200 个训练 prompts。它们覆盖不同的 vendor names、date formats、amount styles 和 currency codes。

我还留出了 50 个评测 prompts,模型训练时从没见过它们。

这里多样性比数量更重要。所有 prompts 都长得差不多,只会训练出一个一遇到真实发票变化就坏掉的模型。

{"messages": [{"role": "user", "content": "Extract the following fields from this invoice:\n\nBill from Acme Corp, dated 2024-03-15, total $1,250.00 USD.\n\nReturn valid JSON only."}]}
{"messages": [{"role": "user", "content": "Extract the following fields from this invoice:\n\nReceived from TechSupplies Inc on January 8 2024, amount due: 340 euros.\n\nReturn valid JSON only."}]}

上传数据集,并等它进入 READY 状态;只有这样 training job 才能使用它。

from fireworks import Fireworks

client = Fireworks()
client.datasets.create(dataset_id="invoice-extraction-grpo-v1",
                       dataset={"exampleCount": "200"})
client.datasets.upload(dataset_id="invoice-extraction-grpo-v1",
                       file="./train_prompts.jsonl")

# poll client.datasets.get(...).state until "READY" before proceeding

Step 3:配置并运行 Loop

rl_loop 会跑完整流程。它会分配 trainer,调度 rollouts,同步 weights,并在运行完成后清理资源。

你通过包装自己的 score() 函数,并把它赋给 rl_loop.reward_fn,把它接进 training loop。wrapper 会同时拿到补全结果和 dataset row,所以如果你的 reward 需要 ground-truth metadata,也可以从里面取。

from training.recipes.rl_loop import Config, main
from training.utils import DeployConfig, InfraConfig, WeightSyncConfig
import training.recipes.rl_loop as rl_loop

# Wire your reward function to the training loop
def invoice_reward(completion: str, row: dict) -> float:
    return score(completion)

rl_loop.reward_fn = invoice_reward

cfg = Config(
    base_model="accounts/fireworks/models/qwen3-8b",
    dataset="invoice-extraction-grpo-v1",
    max_rows=200,
    epochs=1,
    completions_per_prompt=4,
    max_completion_tokens=256,
    temperature=1.0,
    max_seq_len=4096,
    policy_loss="grpo",
    output_model_id="invoice-extractor-v1",
    infra=InfraConfig(
        training_shape_id="accounts/fireworks/trainingShapes/qwen3-8b-128k",
    ),
    deployment=DeployConfig(
        deployment_id="invoice-extractor-v1",
        tokenizer_model="Qwen/Qwen3-8B",
    ),
    weight_sync=WeightSyncConfig(weight_sync_interval=1),
)

main(cfg)

其中有几个设置值得说明。

  • dataset 指向你在 Step 2 上传的 dataset_id。Fireworks 会直接从他们的存储里拉取它。
  • completions_per_prompt=4 设置 GRPO 的 group size。生产运行通常会用 8 到 16,这样每一步信号更多,但计算成本也更高。这里 4 就够了。reward 足够清晰,即便是小 group,也能看出答案之间真实的差异。
  • weight_sync_interval=1 会在每一步之后重新同步 inference deployment。这样 rollout sampling 始终来自正在训练的那个确切模型。生产运行为了速度通常会设成 4 或 8。对一个短的 200-step run 来说,1 能给出最紧密的反馈回路,而这正是你想要的。
  • 有一个 Qwen3 quirk 要处理。它默认是 thinking mode,会在答案前加 <think>...</think> blocks。eval 时用 content.split("</think>")[-1].strip() 把它们去掉。训练时在 system prompt 里加 /no-think 来压住它们。否则 reward function 读到的是 reasoning text,而不是 JSON,所有输出都会得 0.0 分。

Held-Out Eval 上的结果

Base Qwen3-8B 在 50 个 held-out prompts 上 schema-valid 得分是 62%。在 Fireworks H200 上做完 GRPO training 后,微调后的模型达到 82%。

这超过了同一评测上的 GPT-4.1,后者是 58%。

下面先看 baseline run,用的是模型训练时从没见过的 50 个 prompts。

下面是训练后的同一个评测。

训练后的模型跑在 Fireworks serverless endpoint 上,每 token 成本只有 GPT-4.1 的一小部分。延迟也更低,因为输出短而且可预测。

真正的差异会出现在乱一点的输入上。靠 prompt 撑起来的通用模型会开始滑坡,而训练后的模型能稳住,因为它学到的是约束本身,而不是样例长什么样。

什么能迁移到你的任务

这种模式适用于任何可以用代码检查正确性的任务:必须能解析的 SQL、必须匹配某种形状的 API 响应、tool calls、必须通过 linter 的代码。

只要你能给输出打分,就能训练一个模型去追这个分数。

DeepSeek-R1 在 frontier scale 上证明的事,对你自己的小任务同样成立。你得到的模型练过的是你对“正确”的定义,而不是背下了一堆正确样例。

在它从未见过的输入上,这就是能不能稳住的差别。

完整代码在下面的 repo 里。里面包括 reward function、training config、dataset builder 和评测脚本。

Training API docs →** Finetuning Code →**

(别忘了 star 🌟)


感谢阅读,也感谢 Fireworks 对今天这篇文章的合作支持。