AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

约束解码:限制格式,不替代事实核验

我的学习 →

正在读取学习记录…

模型会为下一段文字打分,再选择一段写出来,重复这个过程形成回答。这种从打分到输出文字的过程叫解码。约束解码就在每次选择时筛选候选,让结果符合指定格式。上一篇结构化输出则在收到完整回答后检查;两者位于流程的不同位置。

生成过程中在每步候选选择处进行约束解码;完整回答到达后再解析JSON、检查字段与任务
图:像写字时沿着格子落笔,写完再核对表单内容;前者控制生成的形式,后者检查拿到的数据。

先看一个极小的生成类比:程序逐个字符构造 {"ok":true},只接受能组成该固定文本的候选。你会用离线演示观察候选怎样被筛掉。随后用 {"city":"上海"} 做独立的结果校验,讨论即使格式正确、城市选错或工具没有授权时该由谁处理。固定字符让筛选过程更容易逐步观察。

1. 先看一次正常输出

如果任务要求返回一个带城市的 JSON 对象,理想结果是 {"city":"上海"}:程序能把文本解析成对象,找到 city 字段,再检查它是否为本次请求的城市。约束解码只参与前面“生成合法格式”的过程;生成完成后,程序仍要做解析、字段和业务检查。先把这条成功路径记住,下面的三种动作就容易区分了。

2. 三个容易混淆的动作

术语发生时刻与动作城市示例
提示词约定请求模型“请输出 JSON”告诉模型需要 city 字段
约束解码生成每一步筛选符合语法与 Schema(对象形状规则)的候选值的位置只允许字符串语法
结果校验收到完整结果后检查解析、结构和业务条件核对 city 是否匹配用户问题

token 是模型处理的片段,不总等于一个汉字或 JSON 符号;Schema 是描述对象形状的规则。OpenAI 的结构化输出技术说明介绍按当前前缀动态限制后续 token,也明确列出拒绝、截断和字段值错误等限制。这是一个提供方的实现,不代表所有模型 API 都采用同一机制。

候选字符经过动态格式门,非法候选拦下,合法路径继续
图1:从左到右读。允许集合随输出前缀而变。

用一个前缀理解“动态”

假设应用只接受一个 JSON 对象,且键固定为 city。在输出为空时,直接写汉字“上海”不是合法开头;写下左花括号 { 后,接下来的位置应遵守 JSON 对象的语法;写下键名和冒号后,值的位置又有新的候选集合。过滤器并不是提前列出所有可能答案,而是根据已生成前缀持续判断哪些后续路径仍有机会满足约束。

这解释了为什么“提示词里说请输出 JSON”和“生成时限制候选”不同:前者是一段模型可能遵守或忽略的文字,后者参与生成过程。但约束范围仍取决于服务实现与支持的 Schema 子集。复杂嵌套、引用、长度上限、拒绝响应、流式中断,都需要按供应商当前文档测试。一个提供方标称支持结构化输出,并不等于你的每一条 Schema 规则都能在解码阶段执行。

下面的演示把这件事缩小为“照固定目标逐字符选择”:expected 已写死为 {"ok":true},程序比较候选是否等于这个位置的目标字符。一般 JSON 语法允许多条合法路径,例如对象首部还可能有空格,值也可能有多种内容;它需要语法状态判断。固定目标演示让我们观察过滤顺序,再回到真实 token 与语法约束。

3. 为什么格式正确仍会失败

用户说“上海”,{"city":"北京"} 可以解析,也满足只有 city 字符串的结构,却不符合任务。即便 {"city":"上海"} 正确,也不能证明用户授权“下单”或“扣款”。Agent 应分别留存原始任务、模型输出、校验结果、程序权限决定和工具回执;压缩成一个 valid=true 会隐藏故障位置。

语法、结构、语义、授权四级检查阶梯,前级通过不等于后级通过
图 2:语法、结构、语义、授权是四道不同的关。程序走到语义检查;授权仍由实际工具执行时核对。
文本语法结构本次任务语义下一步
{"city":"上海"}通过通过通过仍查权限
{"city":"北京"}通过通过失败不调用工具
{"city":3}通过失败未检查不调用工具
{"city":失败未检查未检查先查结束原因

4. 自己运行离线实验

把 constrained_lab.py、test_constrained_lab.py 按下方代码逐个保存到同一个新文件夹。打开文件夹,在地址栏输入 powershell,再运行:

macOS:先在 Python 篇的学习文件夹中打开终端,执行 source .venv/bin/activate。再输入 cd ,把当前例子的文件夹拖入终端并回车,运行下面的命令。

保存为 constrained_lab.py:

Python
"""逐个字符构造固定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
Python
# 核对约束解码的正常结果和错误分支;断言失败时会显示对应的测试。
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()
Bash
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,没有合法的引号候选。

Bash
python -c "from constrained_lab import choose_path; print(choose_path({1:['X']}))"

预期得到 status='blocked'、position=1、prefix='{'。恢复合法候选时,用 chr(34) 表示引号字符,避免 PowerShell 和 Python 的引号互相干扰:

Bash
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 尚未完整
把城市写成数字 3shape:结构失败合法 JSON,但类型不符
把上海改成北京meaning:任务含义失败结构合格,却背离用户需求
把固定候选列表只保留非法项生成路径阻断教学过滤器找不到可继续的候选

如果改的是程序底部的演示输入,测试可能仍然通过:测试调用的是 validate_ticket 函数,不检查你改过的演示文字。要验证校验器确实会拒绝错误输入,应对照上表中的 stage 和底部打印结果;做完把演示输入改回原样,再跑完整测试。记录故障停在哪一关,比一句“模型输出失败”更利于排查。真实系统可分别记录原始响应摘要、结束原因、错误层级和请求版本,但用户内容要按隐私政策脱敏或限制访问。

5. 真实接入时如何取舍

城市输出依次经过 JSON 语法、字段结构、用户任务语义和动作授权四关;北京城市虽格式正确仍不符合查上海任务
图 3:拿 {"city":"北京"} 从左往右走:语法和结构都通过,第三关才发现与“查上海”不符。不能把它叫作解析失败,更不能跳过第四关。

这里的 validate_ticket 到第三关为止;第四关属于实际工具侧的授权检查。使用供应商提供的严格结构输出,只可能帮你改善前几关的格式稳定性,不能替你判断用户到底想查哪座城市、是否允许下单。

提供方若支持严格结构化输出,可用它减少格式错误,同时固定并记录模型、API 版本、Schema,核对支持子集与拒绝/截断状态。OpenAI 原始说明区分 JSON 模式和严格结构化输出;其他提供方应查各自当前契约。本地仍要限制响应大小、处理解析异常、检查字段类型和任务语义,再经程序侧授权。管理员身份和授权范围由程序从登录与权限系统读取。

实际接入模型时,再测试拒绝响应、长度上限、断流、多候选和模型版本变化;这些情况会改变结构校验的结果。

自查与继续学习

记住两个边界:约束解码限制生成时的形式;解析通过后,还要检查字段、任务含义和授权。{"city":"北京"} 可以是合法 JSON,却不能替“查上海”执行工具。把候选列表中的合法字符删去,观察程序在哪个前缀停下。

上一站:结构化输出。下一站:解析失败、重试与回退。

现有练习

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.1

选择题 · 先判断边界

关于“约束生成与解析边界”,哪种做法更稳妥?

对应知识点:L1-constrained

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 解释失败

写出一个失败输入并说明记录字段。

对应知识点:L1-constrained

练习 3 / 4 · v1.0.0

代码题 · 边界测试

补充正常、边界、失败三组输入和预期结果。

对应知识点:L1-constrained

练习 4 / 4 · v1.0.0

场景题 · 结果未知

证据不足时如何设计状态和人工接管?

对应知识点:L1-constrained

资料

进一步阅读:OpenAI 结构化输出原始说明介绍机制与限制;Python json 文档介绍本地解析;JSON Schema 官方教程介绍结构规则。

图片查看器
100%

文章配图 · 按 Esc 关闭