别只靠一句「请输出 JSON」

让模型稳定吐出可解析的结构,要叠三层:提示词里给 schema 和一个完整示例;推理时开模型的 JSON mode 或 grammar 约束解码;出参再做一次 schema 校验,失败带错误信息重试。只做第一层,本地 7B 模型十次里总有两三次给你裹一层 Markdown 代码块、加个尾随逗号,或者把数字写成字符串。三层都做,失败率能压到千分位以下。

服务端与本地各自怎么开

兼容 OpenAI 的接口用 response_format 指定 json_schema 并开 strict,注意 strict 模式要求所有字段都进 required、对象加 additionalProperties: false,枚举用 enum 列死比让模型自由发挥可靠得多。

{
  "model": "gpt-4o-mini",
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "extract",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "title": {"type": "string"},
          "tags": {"type": "array", "items": {"type": "string"}}
        },
        "required": ["title", "tags"],
        "additionalProperties": false
      }
    }
  },
  "messages": [{"role": "user", "content": "抽取标题和标签:Ollama 用 num_ctx 控制上下文"}]
}

Ollama 这边 format 可以传 "json",也可以直接传一份 JSON Schema,底层由 llama.cpp 的 GBNF 语法在解码时逐 token 约束,能从根本上保证语法合法。再往下想完全自定义格式,就用 llama.cpp 的 --grammar-file,或者 Outlines、Xgrammar 这类约束解码库,用正则甚至上下文无关文法限定输出形状。

curl http://localhost:11434/api/generate -d '{
  "model": "qwen2.5:7b",
  "format": {
    "type": "object",
    "properties": {
      "title": {"type": "string"},
      "tags": {"type": "array", "items": {"type": "string"}}
    },
    "required": ["title", "tags"]
  },
  "prompt": "从下面文本抽取标题和标签,只输出 JSON:Ollama 用 num_ctx 控制上下文长度。",
  "stream": false,
  "options": {"temperature": 0}
}'

校验、剥壳与重试

即便开了约束,也要做防御性解析:剥掉可能残留的 Markdown 代码围栏、用 json.loads、再用 Pydantic 或 jsonschema 做类型校验;失败时把具体错误拼回提示词重试,最多三次,仍失败就返回默认值并告警,别无限循环。

import json
from pydantic import BaseModel, ValidationError

class Extract(BaseModel):
    title: str
    tags: list[str]

def parse(raw):
    s = raw.strip()
    if s.startswith("```"):
        s = s.split("
", 1)[1].rsplit("```", 1)[0].strip()
    return Extract.model_validate(json.loads(s))

def run(prompt, call_model):
    for attempt in range(3):
        raw = call_model(prompt)
        try:
            return parse(raw)
        except (json.JSONDecodeError, ValidationError) as e:
            prompt += "
上次输出不合法:" + str(e) + ",请严格只输出 JSON 对象。"
    raise RuntimeError("结构化输出连续三次校验失败")

踩坑清单

  • 类型漂移:模型爱把 "12" 写成字符串、把单个标签写成字符串而不是数组,schema 里把类型和 items 写死。
  • 编造字段:冒出个没定义的 confidence 字段,strict 模式下靠 additionalProperties: false 挡。
  • 中文转义:部分实现会输出 u 转义,json.loads 能还原,别用字符串截取去抠字段。
  • strict 的表达力代价:不支持复杂的 anyOf 组合和可选字段,schema 设计要迁就约束,必要时拆成两次调用。
  • 温度仍要设 0:grammar 只管语法不管选词,采样参数不收敛,字段值照样会飘。