上一篇讨论请求进入推理服务之前,如何给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定义接收对象的形状,业务代码决定对象是否适用于当前用户和操作。任务归属、状态转换与时间关系需要明确的服务端规则。
校验通过之后仍要处理事务、幂等和执行结果。把生成、校验和执行分开,才能知道每一层究竟保证了什么。
下一篇随笔继续讨论工具调用:模型给出函数名和参数之后,应用如何选择工具、返回结果并控制重复执行。
👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊