约束解码:限制格式,不替代事实核验
正在读取学习记录…
模型会为下一段文字打分,再选择一段写出来,重复这个过程形成回答。这种从打分到输出文字的过程叫解码。约束解码就在每次选择时筛选候选,让结果符合指定格式。上一篇结构化输出则在收到完整回答后检查;两者位于流程的不同位置。
先看一个极小的生成类比:程序逐个字符构造 {"ok":true},只接受能组成该固定文本的候选。你会用离线演示观察候选怎样被筛掉。随后用 {"city":"上海"} 做独立的结果校验,讨论即使格式正确、城市选错或工具没有授权时该由谁处理。固定字符让筛选过程更容易逐步观察。
1. 先看一次正常输出
如果任务要求返回一个带城市的 JSON 对象,理想结果是 {"city":"上海"}:程序能把文本解析成对象,找到 city 字段,再检查它是否为本次请求的城市。约束解码只参与前面“生成合法格式”的过程;生成完成后,程序仍要做解析、字段和业务检查。先把这条成功路径记住,下面的三种动作就容易区分了。
2. 三个容易混淆的动作
| 术语 | 发生时刻与动作 | 城市示例 |
|---|---|---|
| 提示词约定 | 请求模型“请输出 JSON” | 告诉模型需要 city 字段 |
| 约束解码 | 生成每一步筛选符合语法与 Schema(对象形状规则)的候选 | 值的位置只允许字符串语法 |
| 结果校验 | 收到完整结果后检查解析、结构和业务条件 | 核对 city 是否匹配用户问题 |
token 是模型处理的片段,不总等于一个汉字或 JSON 符号;Schema 是描述对象形状的规则。OpenAI 的结构化输出技术说明介绍按当前前缀动态限制后续 token,也明确列出拒绝、截断和字段值错误等限制。这是一个提供方的实现,不代表所有模型 API 都采用同一机制。
用一个前缀理解“动态”
假设应用只接受一个 JSON 对象,且键固定为 city。在输出为空时,直接写汉字“上海”不是合法开头;写下左花括号 { 后,接下来的位置应遵守 JSON 对象的语法;写下键名和冒号后,值的位置又有新的候选集合。过滤器并不是提前列出所有可能答案,而是根据已生成前缀持续判断哪些后续路径仍有机会满足约束。
这解释了为什么“提示词里说请输出 JSON”和“生成时限制候选”不同:前者是一段模型可能遵守或忽略的文字,后者参与生成过程。但约束范围仍取决于服务实现与支持的 Schema 子集。复杂嵌套、引用、长度上限、拒绝响应、流式中断,都需要按供应商当前文档测试。一个提供方标称支持结构化输出,并不等于你的每一条 Schema 规则都能在解码阶段执行。
下面的演示把这件事缩小为“照固定目标逐字符选择”:expected 已写死为 {"ok":true},程序比较候选是否等于这个位置的目标字符。一般 JSON 语法允许多条合法路径,例如对象首部还可能有空格,值也可能有多种内容;它需要语法状态判断。固定目标演示让我们观察过滤顺序,再回到真实 token 与语法约束。
3. 为什么格式正确仍会失败
用户说“上海”,{"city":"北京"} 可以解析,也满足只有 city 字符串的结构,却不符合任务。即便 {"city":"上海"} 正确,也不能证明用户授权“下单”或“扣款”。Agent 应分别留存原始任务、模型输出、校验结果、程序权限决定和工具回执;压缩成一个 valid=true 会隐藏故障位置。
| 文本 | 语法 | 结构 | 本次任务语义 | 下一步 |
|---|---|---|---|---|
{"city":"上海"} | 通过 | 通过 | 通过 | 仍查权限 |
{"city":"北京"} | 通过 | 通过 | 失败 | 不调用工具 |
{"city":3} | 通过 | 失败 | 未检查 | 不调用工具 |
{"city": | 失败 | 未检查 | 未检查 | 先查结束原因 |
4. 自己运行离线实验
把 constrained_lab.py、test_constrained_lab.py 按下方代码逐个保存到同一个新文件夹。打开文件夹,在地址栏输入 powershell,再运行:
macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。
保存为 constrained_lab.py:
"""逐个字符构造固定JSON文本,观察候选字符怎样被筛选。"""
import json
def choose_path(proposals):
"""当前字符只保留与固定目标对应的候选,再把它接到已生成的文本后面。"""
expected = '{"ok":true}'
accepted = ""
trace = []
for index, wanted in enumerate(expected):
offered = proposals.get(index, [wanted])
allowed = [token for token in offered if token == wanted]
if not allowed:
return {"status": "blocked", "position": index, "prefix": accepted,
"trace": trace}
accepted += allowed[0]
trace.append({"position": index, "offered": offered, "allowed": allowed,
"chosen": allowed[0], "prefix": accepted})
return {"status": "complete", "text": accepted, "trace": trace}
def validate_ticket(raw, requested_city):
"""分别检查文本语法、字段结构与任务含义,此函数不执行工具。"""
try:
obj = json.loads(raw)
except (TypeError, ValueError):
return {"stage": "syntax"}
if not isinstance(obj, dict) or set(obj) != {"city"} or not isinstance(obj["city"], str):
return {"stage": "shape"}
if not obj["city"].strip() or obj["city"] != requested_city:
return {"stage": "meaning"}
return {"stage": "accepted", "city": obj["city"]}
if __name__ == "__main__":
print(json.dumps({"decoder": choose_path({1: ["X", '"']}),
"valid": validate_ticket('{"city":"上海"}', "上海"),
"wrong_city": validate_ticket('{"city":"北京"}', "上海"),
"truncated": validate_ticket('{"city":', "上海")},
ensure_ascii=False, indent=2))完整代码:test_constrained_lab.py
# 核对约束解码的正常结果和错误分支;断言失败时会显示对应的测试。
import unittest
from constrained_lab import choose_path, validate_ticket
class ConstrainedLabTests(unittest.TestCase):
def test_allowed_path(self):
self.assertEqual(choose_path({1: ["X", '"']})["status"], "complete")
def test_no_allowed_token_stops(self):
self.assertEqual(choose_path({1: ["X"]})["position"], 1)
def test_three_validation_boundaries(self):
self.assertEqual(validate_ticket('{"city":', "上海")["stage"], "syntax")
self.assertEqual(validate_ticket('{"city":3}', "上海")["stage"], "shape")
self.assertEqual(validate_ticket('{"city":"北京"}', "上海")["stage"], "meaning")
self.assertEqual(validate_ticket('{"city":"上海"}', "上海")["stage"], "accepted")
if __name__ == "__main__":
unittest.main()ls
python constrained_lab.py
python -m unittest -v test_constrained_lab.py找不到 py 时回到 Python 环境篇检查安装与完整版本。若报 No module named constrained_lab,用 Get-ChildItem 核对两个文件是否放在同一目录。
第一步看 choose_path。位置从0开始,位置0已经选出 {;位置1给出 offered=["X", '"'],与 expected[1] 比较后得到 allowed=['"'],选中引号后 prefix 变为 {"。主程序 decoder.trace 里的同名字段可以逐项对照。expected 保存目标字符,allowed 保存这一位置允许的候选。
不用先改上方的完整源码,也能只改变这个候选条件:运行下面一行。它会在位置 1 停住,因为此处只给 X,没有合法的引号候选。
python -c "from constrained_lab import choose_path; print(choose_path({1:['X']}))"预期得到 status='blocked'、position=1、prefix='{'。恢复合法候选时,用 chr(34) 表示引号字符,避免 PowerShell 和 Python 的引号互相干扰:
python -c "from constrained_lab import choose_path; print(choose_path({1:['X',chr(34)]}))"应得到 complete 和固定文本。如果结果没变化,检查位置是否写成数字 1。
第二步看 validate_ticket。虽然函数沿用 ticket 名字,它检查的是城市对象:解析错误返回 syntax(语法),字段或类型错误返回 shape(结构),与请求城市不一致返回 meaning(任务含义),成功返回 accepted。前篇的 schema/business 是另一组阶段名,检查非空和长度;这里的 meaning 还检查是否精确等于请求城市,不要混用枚举。
下面四种输入已经写进 test_constrained_lab.py 的 test_three_validation_boundaries 测试。先逐行阅读该测试里的 validate_ticket(原始文本, "上海"),查看预期的 stage,再运行 python -m unittest -v test_constrained_lab.ConstrainedLabTests.test_three_validation_boundaries 核对。测试通过表示四个预期与程序结果一致;它不会逐条打印 stage。要看到分类文字,可以在自己保存的 constrained_lab.py 底部演示字典中换一条输入,运行 python .\constrained_lab.py,最后改回原值并重跑完整测试:
| 改动 | 预期分类 | 为什么 |
|---|---|---|
| 删除最后的右花括号 | syntax:语法失败 | JSON 尚未完整 |
| 把城市写成数字 3 | shape:结构失败 | 合法 JSON,但类型不符 |
| 把上海改成北京 | meaning:任务含义失败 | 结构合格,却背离用户需求 |
| 把固定候选列表只保留非法项 | 生成路径阻断 | 教学过滤器找不到可继续的候选 |
如果改的是程序底部的演示输入,测试可能仍然通过:测试调用的是 validate_ticket 函数,不检查你改过的演示文字。要验证校验器确实会拒绝错误输入,应对照上表中的 stage 和底部打印结果;做完把演示输入改回原样,再跑完整测试。记录故障停在哪一关,比一句“模型输出失败”更利于排查。真实系统可分别记录原始响应摘要、结束原因、错误层级和请求版本,但用户内容要按隐私政策脱敏或限制访问。
5. 真实接入时如何取舍
{"city":"北京"} 从左往右走:语法和结构都通过,第三关才发现与“查上海”不符。不能把它叫作解析失败,更不能跳过第四关。这里的 validate_ticket 到第三关为止;第四关属于实际工具侧的授权检查。使用供应商提供的严格结构输出,只可能帮你改善前几关的格式稳定性,不能替你判断用户到底想查哪座城市、是否允许下单。
提供方若支持严格结构化输出,可用它减少格式错误,同时固定并记录模型、API 版本、Schema,核对支持子集与拒绝/截断状态。OpenAI 原始说明区分 JSON 模式和严格结构化输出;其他提供方应查各自当前契约。本地仍要限制响应大小、处理解析异常、检查字段类型和任务语义,再经程序侧授权。管理员身份和授权范围由程序从登录与权限系统读取。
实际接入模型时,再测试拒绝响应、长度上限、断流、多候选和模型版本变化;这些情况会改变结构校验的结果。
自查与继续学习
记住两个边界:约束解码限制生成时的形式;解析通过后,还要检查字段、任务含义和授权。{"city":"北京"} 可以是合法 JSON,却不能替“查上海”执行工具。把候选列表中的合法字符删去,观察程序在哪个前缀停下。
上一站:结构化输出。下一站:解析失败、重试与回退。
现有练习
课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 先判断边界
关于“约束生成与解析边界”,哪种做法更稳妥?
对应知识点:L1-constrained
简答题 · 解释失败
写出一个失败输入并说明记录字段。
对应知识点:L1-constrained
代码题 · 边界测试
补充正常、边界、失败三组输入和预期结果。
对应知识点:L1-constrained
场景题 · 结果未知
证据不足时如何设计状态和人工接管?
对应知识点:L1-constrained
资料
进一步阅读:OpenAI 结构化输出原始说明介绍机制与限制;Python json 文档介绍本地解析;JSON Schema 官方教程介绍结构规则。
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。