【随笔】结构化输出:JSON格式过关,业务数据就能入库吗

上一篇讨论请求进入推理服务之前,如何给KV缓存留出空间。请求顺利完成后,应用还有另一道关:返回的内容能否被程序直接使用?

让模型"只输出JSON"很常见,但后端真正需要的往往是一个更具体的对象:任务ID必须存在,状态只能是todo或done,完成状态必须带有时间。一个大括号完整、字段类型正确的响应,仍可能把别人的任务改掉。

结构化输出把输出形式变成推理过程中的约束。本文从任务状态提取出发,解释约束解码的机制,再用一个可以独立运行的校验程序,把格式正确与业务可执行分开处理。

一、先拆开三种"正确"

假设后端接收下面的候选对象:

json 复制代码
{"task_id":"T-99","status":"done","completed_at":null}

它可能同时满足"合法JSON"和"字段符合Schema",却不满足应用要求。这里有三层不同的问题。

层次 需要回答的问题 失败例子
JSON语法 能否解析成数据? 引号缺失、结尾截断
Schema结构 字段、类型、枚举是否符合约定? status写成finished,多了admin字段
业务语义 数据是否适合当前操作? 任务不属于当前用户,done没有完成时间

这张表也是接口设计的分工表:模型服务尽量约束前两层;应用接收端仍然检查完整性和结构,并负责权限、业务状态以及执行时机。接收端校验可以拦住供应方变化、响应中断或中间环节破坏,且错误能够定位到具体层次。

二、约束解码怎样限制下一个token

普通生成会从模型给出的候选token分布中采样。约束解码在采样前多加一个判断:把某个候选token接到当前前缀后,是否仍然符合目标语法?

XGrammar为每一步生成token掩码,将不允许的候选logit设为负无穷,使其采样概率变为0。采样得到的token会更新匹配器状态,下一步据此产生新的掩码。XGrammar:约束解码

可以用"填写一份格式固定的表单"理解它:模型决定填什么内容,规则限制现在允许填哪些形式。到了status字段,枚举只有todo和done,就不能把自由解释的一整段话放在这里。

实际掩码作用于token,token不等于单个字符,也不一定等于一个字段。图中的"候选片段"只是便于理解的抽象;引号、分隔符和多个字符可能属于同一个token。

这种机制不会读取任务数据库,也不知道T-99是不是当前用户的任务。即使任务ID是一个语法合法的字符串,授权仍需应用来判断。把可选ID限制为枚举可以缩小候选范围,但执行前仍要确认权限和数据状态没有变化。

图中流程发生在生成阶段,最终对象仍要经过接收端校验。

三、写一份足够小的Schema

我们的接口只收三个字段。把以下内容保存为schema.json:

json 复制代码
{
  "type": "object",
  "properties": {
    "task_id": {
      "type": "string"
    },
    "status": {
      "type": "string",
      "enum": [
        "todo",
        "done"
      ]
    },
    "completed_at": {
      "type": [
        "string",
        "null"
      ]
    }
  },
  "required": [
    "task_id",
    "status",
    "completed_at"
  ],
  "additionalProperties": false
}

properties描述字段类型,required要求字段出现,additionalProperties: false禁止加入其他字段。这三者的职责不同:只写properties并不表示字段一定存在。JSON Schema:对象

这里允许completed_at是字符串或null。待办任务没有完成时间,因此不能强迫模型编造一个字符串。与此同时,Schema尚未表达"done必须有时间"这一跨字段规则,它将由下一节业务校验完成。

通用JSON语法与具体JSON Schema要区分。前者允许许多不同形状的JSON值;后者描述本接口需要的字段和类型。XGrammar分别提供内置JSON语法编译与Schema编译入口。选用JSON模式时,应确认服务实际收到的是哪种约束。XGrammar:JSON生成

复杂Schema并不总是更好。先保留业务必需的字段,再验证所选后端对关键字的支持。对于任务ID集合、跨字段关系和数据库状态,在应用端写清晰规则更便于复查。Schema中的文字说明用于帮助理解,不能替代真正的约束关键字或业务代码。

四、用vLLM发送结构化请求

下面的请求按vLLM 0.33.0 官方接口编写,使用顶层structured_outputs.json传入Schema。旧资料中的guided_json已在v0.12.0移除,不应直接复制到这套接口中。服务端可使用auto选择后端,也可明确配置XGrammar。vLLM:结构化输出

假设已有一个可正常完成聊天请求的vLLM服务,设置VLLM_MODEL为该服务实际暴露的模型名;默认地址是本机8000端口。把下面的request_task.py与schema.json及下一节的validate_task.py放在同一目录,从该目录运行。

python 复制代码
import json
import os
from pathlib import Path
from urllib.request import Request, urlopen
from validate_task import check

schema = json.loads(Path("schema.json").read_text(encoding="utf-8"))
base_url = os.environ.get("VLLM_BASE_URL", "http://127.0.0.1:8000")
model = os.environ["VLLM_MODEL"]
payload = {
    "model": model,
    "messages": [{
        "role": "user",
        "content": (
            "把任务T-17标记为done,完成时间为"
            "2026-11-09T09:00:00+08:00。"
            "只输出JSON,字段为task_id、status、completed_at。"
        ),
    }],
    "max_tokens": 256,
    "temperature": 0,
    "structured_outputs": {"json": schema},
}
headers = {"Content-Type": "application/json"}
if os.environ.get("VLLM_API_KEY"):
    headers["Authorization"] = "Bearer " + os.environ["VLLM_API_KEY"]
request = Request(
    base_url.rstrip("/") + "/v1/chat/completions",
    data=json.dumps(payload).encode("utf-8"),
    headers=headers,
)
with urlopen(request, timeout=60) as response:
    result = json.load(response)
choice = result["choices"][0]
content = choice["message"].get("content")
if not isinstance(content, str):
    raise RuntimeError("响应没有可校验的文本内容")
print(content)
print(check(content, choice["finish_reason"]))

若服务启用API认证,通过环境变量VLLM_API_KEY传入已有凭据。不要把密钥写进示例源码。HTTP错误会使请求失败,不能把错误响应当作待入库数据。

这段代码使用非流式响应,便于先把校验链路跑通。max_tokens仍会限制输出长度;约束解码不会取消长度上限。看到finish_reason="length"时,本例直接拒收,不尝试补一个右大括号蒙混过关。temperature=0也不代表业务事实必然正确。

请求示例需要读者已有的模型服务。下面给出的实测输出来自本地校验用例,未将其当作模型推理结果。

五、完整接收校验:从字符串到候选数据

安装jsonschema后,将下面的程序保存为validate_task.py。本例在Python 3.12.7、jsonschema 4.23.0下运行。Schema先检查一次,之后复用验证器;官方接口支持这种重复校验方式。jsonschema:验证器

python 复制代码
import json
from datetime import datetime
from jsonschema import Draft202012Validator, ValidationError

SCHEMA = {
    "type": "object",
    "properties": {
        "task_id": {
            "type": "string"
        },
        "status": {
            "type": "string",
            "enum": [
                "todo",
                "done"
            ]
        },
        "completed_at": {
            "type": [
                "string",
                "null"
            ]
        }
    },
    "required": [
        "task_id",
        "status",
        "completed_at"
    ],
    "additionalProperties": False
}
Draft202012Validator.check_schema(SCHEMA)
validator = Draft202012Validator(SCHEMA)
ALLOWED_TASK_IDS = {"T-17", "T-18"}

def check(raw, finish_reason="stop"):
    if finish_reason != "stop":
        return "未正常结束"
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return "JSON解析失败"
    try:
        validator.validate(data)
    except ValidationError:
        return "Schema校验失败"
    if data["task_id"] not in ALLOWED_TASK_IDS:
        return "任务不在当前用户的允许集合"
    timestamp = data["completed_at"]
    if data["status"] == "done":
        if timestamp is None:
            return "完成状态缺少时间"
        try:
            parsed = datetime.fromisoformat(timestamp)
        except ValueError:
            return "完成时间无法解析"
        if parsed.utcoffset() is None:
            return "完成时间缺少时区"
    elif timestamp is not None:
        return "待办状态不应有完成时间"
    return "通过"

if __name__ == "__main__":
    cases = [
        ("截断", '{"task_id":', "length"),
        ("坏JSON", '{"task_id":}', "stop"),
        ("多余字段", '{"task_id":"T-17","status":"todo","completed_at":null,"admin":true}', "stop"),
        ("未知任务", '{"task_id":"T-99","status":"todo","completed_at":null}', "stop"),
        ("缺少时间", '{"task_id":"T-17","status":"done","completed_at":null}', "stop"),
        ("无时区", '{"task_id":"T-17","status":"done","completed_at":"2026-11-09T09:00:00"}', "stop"),
        ("合法候选", '{"task_id":"T-17","status":"done","completed_at":"2026-11-09T09:00:00+08:00"}', "stop"),
    ]
    for name, raw, reason in cases:
        print(f"{name}: {check(raw, reason)}")

执行python validate_task.py,本地实际输出为:

text 复制代码
截断: 未正常结束
坏JSON: JSON解析失败
多余字段: Schema校验失败
未知任务: 任务不在当前用户的允许集合
缺少时间: 完成状态缺少时间
无时区: 完成时间缺少时区
合法候选: 通过

最值得观察的是"未知任务"和"缺少时间":两份输入已经通过Schema验证,仍在业务阶段被拒绝。这证明字段符合约定只是接收过程中的一步。

ALLOWED_TASK_IDS是为了让演示独立运行而写死的允许集合。真实系统应从已认证用户和服务端权限数据确定范围,不能信任模型同时返回的user_id来决定谁有权修改任务。

示例还要求完成时间带时区,使这个时间能对应明确的时刻。它接受Python可解析的ISO格式,不声称完成了所有业务日期规则;如果产品禁止未来完成时间、要求完成时间晚于创建时间,应继续与服务端记录比较。

六、什么时候可以执行

通过校验的对象仍是候选数据。写入时要在事务内重新检查任务归属和当前状态;同一请求重试时,用幂等标识防止重复触发通知等副作用。格式约束不会自动提供事务或幂等性。

失败处理也应分层:传输超时先确定请求状态;生成被截断时调整输出预算或缩短结构;Schema错误检查后端与规则兼容;业务错误应提供具体原因,要求重新选择合法任务或补全信息。不要把所有错误都变成"再问模型一次",否则错误输入可能不断循环。

选择方案时,可以从需求倒推。只要一个有限分类值,用枚举式约束即可;需要稳定对象字段,用Schema;要调用会改变业务状态的工具,则还需要授权与执行确认机制。结构越接近接口实际需要,接收端越容易验证。

落地时分别统计JSON解析失败、Schema失败、业务拒绝和执行失败。只看"JSON成功率"会把几个完全不同的问题合并,难以判断应该改提示词、改Schema还是修权限判断。

七、🧠 思维导图

#mermaid-svg-uEPXaWvqIyvk7USG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uEPXaWvqIyvk7USG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uEPXaWvqIyvk7USG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uEPXaWvqIyvk7USG .error-icon{fill:#552222;}#mermaid-svg-uEPXaWvqIyvk7USG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uEPXaWvqIyvk7USG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uEPXaWvqIyvk7USG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uEPXaWvqIyvk7USG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uEPXaWvqIyvk7USG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uEPXaWvqIyvk7USG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uEPXaWvqIyvk7USG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uEPXaWvqIyvk7USG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uEPXaWvqIyvk7USG .marker.cross{stroke:#333333;}#mermaid-svg-uEPXaWvqIyvk7USG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uEPXaWvqIyvk7USG p{margin:0;}#mermaid-svg-uEPXaWvqIyvk7USG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uEPXaWvqIyvk7USG .cluster-label text{fill:#333;}#mermaid-svg-uEPXaWvqIyvk7USG .cluster-label span{color:#333;}#mermaid-svg-uEPXaWvqIyvk7USG .cluster-label span p{background-color:transparent;}#mermaid-svg-uEPXaWvqIyvk7USG .label text,#mermaid-svg-uEPXaWvqIyvk7USG span{fill:#333;color:#333;}#mermaid-svg-uEPXaWvqIyvk7USG .node rect,#mermaid-svg-uEPXaWvqIyvk7USG .node circle,#mermaid-svg-uEPXaWvqIyvk7USG .node ellipse,#mermaid-svg-uEPXaWvqIyvk7USG .node polygon,#mermaid-svg-uEPXaWvqIyvk7USG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uEPXaWvqIyvk7USG .rough-node .label text,#mermaid-svg-uEPXaWvqIyvk7USG .node .label text,#mermaid-svg-uEPXaWvqIyvk7USG .image-shape .label,#mermaid-svg-uEPXaWvqIyvk7USG .icon-shape .label{text-anchor:middle;}#mermaid-svg-uEPXaWvqIyvk7USG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uEPXaWvqIyvk7USG .rough-node .label,#mermaid-svg-uEPXaWvqIyvk7USG .node .label,#mermaid-svg-uEPXaWvqIyvk7USG .image-shape .label,#mermaid-svg-uEPXaWvqIyvk7USG .icon-shape .label{text-align:center;}#mermaid-svg-uEPXaWvqIyvk7USG .node.clickable{cursor:pointer;}#mermaid-svg-uEPXaWvqIyvk7USG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uEPXaWvqIyvk7USG .arrowheadPath{fill:#333333;}#mermaid-svg-uEPXaWvqIyvk7USG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uEPXaWvqIyvk7USG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uEPXaWvqIyvk7USG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uEPXaWvqIyvk7USG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uEPXaWvqIyvk7USG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uEPXaWvqIyvk7USG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uEPXaWvqIyvk7USG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uEPXaWvqIyvk7USG .cluster text{fill:#333;}#mermaid-svg-uEPXaWvqIyvk7USG .cluster span{color:#333;}#mermaid-svg-uEPXaWvqIyvk7USG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uEPXaWvqIyvk7USG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uEPXaWvqIyvk7USG rect.text{fill:none;stroke-width:0;}#mermaid-svg-uEPXaWvqIyvk7USG .icon-shape,#mermaid-svg-uEPXaWvqIyvk7USG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uEPXaWvqIyvk7USG .icon-shape p,#mermaid-svg-uEPXaWvqIyvk7USG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uEPXaWvqIyvk7USG .icon-shape .label rect,#mermaid-svg-uEPXaWvqIyvk7USG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uEPXaWvqIyvk7USG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uEPXaWvqIyvk7USG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uEPXaWvqIyvk7USG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 结构化输出
生成约束
Schema定义边界
逐步屏蔽非法token
接收校验
完成状态与JSON解析
Schema验证
权限与业务规则
执行边界
候选数据
事务和幂等

八、总结

总结要点

结构化输出把格式要求放进生成过程。约束解码根据当前前缀屏蔽不合法token,帮助输出遵循指定结构。

Schema定义接收对象的形状,业务代码决定对象是否适用于当前用户和操作。任务归属、状态转换与时间关系需要明确的服务端规则。

校验通过之后仍要处理事务、幂等和执行结果。把生成、校验和执行分开,才能知道每一层究竟保证了什么。

下一篇随笔继续讨论工具调用:模型给出函数名和参数之后,应用如何选择工具、返回结果并控制重复执行。

👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊

相关推荐
爱听歌的周童鞋1 天前
MIT 6.5940 | TinyML 与高效 AI 计算 | Fall 2026 | 笔记 | Lecture 14:LLM Post Training
大语言模型·强化学习·tinyml·后训练·mit 6.5940·高效 ai 计算
code2cat1 天前
【随笔】推理准入控制:请求进来之前,先给KV缓存留出位置
缓存·大语言模型·vllm
code2cat3 天前
【随笔】KV缓存量化:省下的显存,要付出多少数值误差
人工智能·缓存·大语言模型
code2cat4 天前
【随笔】KV缓存卸载:逐层预取能藏住多少传输时间
人工智能·缓存·大语言模型
Hello_Pyhx4 天前
NWinfo 导出 Windows 硬件信息:从 JSON 报告到缺项排查
windows·json
程序员大阳4 天前
副队长JavaScript教程(66)-本地存储实战:待办事项网页的开发
javascript·json·实战·待办事项·本地存储·案例
艺杯羹4 天前
MCP本地服务集成避坑:从Stdio管道僵死到双向JSON-RPC稳健通信
人工智能·python·ai·系统架构·大语言模型·ai编程·mcp
视觉小萌新5 天前
AI Class 3 · Chapter 2-3:函数、异常、文件读写+JSON / JSONL 读写(小白版)
人工智能·python·json
翊众建站6 天前
用 JSON-LD 给企业官网做结构化数据:LocalBusiness 与 FAQPage 实践笔记
人工智能·搜索引擎·json