Akshay 🚀 (@akshay_pachaar)
2026-06-10
D
原文
---
title: "训练 LLM 生成可靠的结构化输出"
author: "Akshay 🚀 (@akshay_pachaar)"
source_url: "https://x.com/akshay_pachaar/status/2064700531600458093"
published_at: "2026-06-10T13:25:41.000Z"
fetched_at: "2026-06-11T01:38:29Z"
updated_at: "2026-06-11T01:57:12Z"
language: "zh"
review_status: "draft"
---

# 训练 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](https://github.com/fw-ai/cookbook) 同时处理这两边。你在自己机器上用 Python 写训练逻辑。
剩下的交给他们的基础设施。它会分配 GPU,运行 forward 和 backward passes,保存 checkpoints,并且在每一步之后重新同步 inference deployment。
整个 setup 分三步:写 reward function、上传数据集、配置运行任务。
我们逐个看。
## 构建训练 Loop
Fireworks 在他们的 [Training API docs](https://github.com/fw-ai/cookbook) 里记录了完整 setup。里面包括 `rl_loop`,也就是替你跑完整 GRPO loop 的配方。
克隆 cookbook,并安装训练依赖。
```bash
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。
```python
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
```
***jsonsche*ma** 会在一次调用里处理类型检查、必填字段,以及任何嵌套规则。
换一个任务,比如 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 都长得差不多,只会训练出一个一遇到真实发票变化就坏掉的模型。
```json
{"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 才能使用它。
```python
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_lo*op** 会跑完整流程。它会分配 trainer,调度 rollouts,同步 weights,并在运行完成后清理资源。
你通过包装自己的 *score()* 函数,并把它赋给 *rl_loop.reward_fn*,把它接进 training loop。wrapper 会同时拿到补全结果和 dataset row,所以如果你的 reward 需要 ground-truth metadata,也可以从里面取。
```python
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 →**
](https://github.com/fw-ai/cookbook)**[Finetuning Code →**](https://github.com/patchy631/ai-engineering-hub/tree/main/grpo-finetuning-qwen3)
(别忘了 star 🌟)
---
感谢阅读,也感谢 Fireworks 对今天这篇文章的合作支持。