模型输出的解析与校验
正在读取学习记录…
用户问“明天上海会不会下雨”。天气助手要先从这句话中取出城市,再把城市交给查询程序。如果模型回答 {"city":"上海"},程序就能按约定读取 city。这种约定好的回答形式,叫作结构化输出。
先让程序从正常回答中读出城市,接进天气查询的流程。随后再看格式错误、字段不对和答非所问分别卡在哪一步。
1. 结构化输出是什么
人读到“城市是上海”“上海。”,都能明白意思。程序却需要知道该取哪个字段。结构化输出就是让回答遵守一个可由程序读取的形式。这里选 JSON:它是一种用文本表示数据的格式,{"city":"上海"} 表示一个对象,其中 city 是字段名,“上海”是字段值。
先只看一次成功的读取。下面的字符串可以先当作模型已经给出的回答;json.loads 把 JSON 文本解析成 Python 字典,最后一行取出城市:
import json
raw = '{"city":"上海"}'
value = json.loads(raw)
print(value["city"])正常输出是 上海。模型回答仍是文本,json.loads 之后才变成程序能按字段访问的字典。这里先假设回答符合约定;下一节再把约定写完整。
2. 从回答到参数,需要经过哪些步骤
天气查询要求回答能解析为 JSON 对象,且对象必须只有一个 city 字段。字段值是字符串,去掉首尾空格后长度为 1 到 80 个字符。80 是这个查询接口约定的上限。可以把对象看成一张表单:格子的名字、必填项和允许填写的类型组成 Schema(结构规则);城市是否符合当前任务,则接着用业务规则检查。回答方和使用方事先说好这些要求,就形成了接口“契约”。
程序按顺序做三件事:先把文本解析为数据;再检查对象、字段和类型;最后检查城市字符串是否符合当前任务的长度与非空要求。三件事通过,程序才拿到一个可用的 city 参数。它们可以由同一个库实现,但判断依据不同,所以实验会分别记录结果。
拿到城市参数后,天气助手再把它交给查询接口,取回天气结果。
3. 实现一个可观察的校验器
先从最小检查开始。天气查询像填表:city 是唯一必填格,内容应为文字。下面完整程序可以保存为 city_step.py,运行 python -X utf8 city_step.py:
# 按固定输入观察模型输出的解析与校验的处理过程和返回结果。
import json
def read_city(text):
value = json.loads(text)
if not isinstance(value, dict) or set(value) != {"city"}:
return {"stage": "schema"}
if not isinstance(value["city"], str):
return {"stage": "schema"}
city = value["city"].strip()
if not city or len(city) > 80:
return {"stage": "business"}
return {"stage": "accepted", "city": city}
for text in ('{"city":"上海"}', '{"city":3}', '{"city":" "}'):
print(read_city(text))三行依次是 accepted(含上海)、schema、business。先解析,再看格子和类型,最后检查非空与长度;解析错误暂由 Python 报异常。下一步把异常也变成可观察的阶段,并处理重复键和大小限制。
复制下方完整代码,保存为 contract.py、test_contract.py,放到同一目录。下面完整核心函数将资源大小拒绝记为 size、解析拒绝记为 parse。MAX_RESPONSE_BYTES 是响应上限,8192个 UTF-8 字节,即8 KiB;完整源码使用相同数值。
保存为 contract.py:
"""解析城市JSON,依次检查字段、类型和城市内容是否可用。"""
import json
from collections import Counter
def unique_object(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError('duplicate_key')
value[key] = item
return value
def reject_constant(_value):
raise ValueError('nonstandard_number')
def evaluate(raw):
if not isinstance(raw, str):
return {'stage': 'size'}
if len(raw.encode('utf-8')) > 8192:
return {'stage': 'size'}
try:
value = json.loads(raw, object_pairs_hook=unique_object,
parse_constant=reject_constant)
except (ValueError, RecursionError):
return {'stage': 'parse'}
if not isinstance(value, dict) or set(value) != {'city'}:
return {'stage': 'schema'}
if not isinstance(value['city'], str):
return {'stage': 'schema'}
city = value['city'].strip()
if not city or len(city) > 80:
return {'stage': 'business'}
return {'stage': 'accepted', 'city': city}
CASES = [
('valid', '{"city":"北京"}', 'accepted'),
('trim', '{"city":" 上海 "}', 'accepted'),
('prose', '城市是北京', 'parse'),
('fence', '```json\n{"city":"北京"}\n```', 'parse'),
('duplicate', '{"city":"北京","city":"上海"}', 'parse'),
('nan', '{"city":NaN}', 'parse'),
('missing', '{}', 'schema'),
('extra', '{"city":"北京","admin":true}', 'schema'),
('number', '{"city":123}', 'schema'),
('null', '{"city":null}', 'schema'),
('array', '[{"city":"北京"}]', 'schema'),
('empty', '{"city":""}', 'business'),
('spaces', '{"city":" "}', 'business'),
('long', json.dumps({'city': '城' * 81}), 'business'),
]
if __name__ == '__main__':
counts = Counter()
for name, raw, expected in CASES:
result = evaluate(raw)
counts[result['stage']] += 1
assert result['stage'] == expected, name
print(f'{name:12} {result["stage"]}')
print('summary:', json.dumps(dict(counts), sort_keys=True))
print('14 designed fixtures; NOT a model benchmark; zero API calls.')完整代码:test_contract.py
import unittest
from contract import evaluate, CASES
class ContractTests(unittest.TestCase):
def test_all_named_cases(self):
for name, raw, expected in CASES:
with self.subTest(name=name):
self.assertEqual(evaluate(raw)['stage'], expected)
def test_correct_shape_does_not_prove_extraction(self):
self.assertEqual(evaluate('{"city":"北京"}')['stage'], 'accepted')
# 原文若为上海,这个结构有效的值仍然是抽取错误。
self.assertNotEqual(evaluate('{"city":"北京"}')['city'], '上海')
def test_errors_do_not_echo_private_input(self):
self.assertNotIn('secret', str(evaluate('secret-not-json')))
def test_in_memory_size_limit(self):
self.assertEqual(evaluate(' ' * 8193)['stage'], 'size')
self.assertEqual(evaluate('{"city":" 上海 "}')['city'], '上海')
if __name__ == '__main__':
unittest.main()# 按固定输入观察模型输出的解析与校验的处理过程和返回结果。
import json
MAX_RESPONSE_BYTES = 8192
def unique_object(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate_key")
value[key] = item
return value
def reject_constant(_value):
raise ValueError("nonstandard_number")
def evaluate(raw):
if not isinstance(raw, str):
return {"stage": "size"}
if len(raw.encode("utf-8")) > MAX_RESPONSE_BYTES:
return {"stage": "size"}
try:
value = json.loads(raw, object_pairs_hook=unique_object,
parse_constant=reject_constant)
except (ValueError, RecursionError):
return {"stage": "parse"}
if not isinstance(value, dict) or set(value) != {"city"}:
return {"stage": "schema"}
if not isinstance(value["city"], str):
return {"stage": "schema"}
city = value["city"].strip()
if not city or len(city) > 80:
return {"stage": "business"}
return {"stage": "accepted", "city": city}先看正常输入:evaluate('{"city":"上海"}') 返回 Python 字典 {'stage': 'accepted', 'city': '上海'}。stage 表示处理走到哪一步;accepted 表示符合这里约定。只有这时结果里才有 city,调用方才能读取它。代码从上到下先解析文本,再检查字段和值,因此错误不会直接流入查询参数。
4. 放进天气助手,会怎样使用
天气助手先收到用户问题,再请模型抽取城市。程序拿到模型文本后调用 evaluate。假设文本是 {"city":"上海"},后续代码可以这样接住结果:
result = evaluate('{"city":"上海"}')
if result["stage"] == "accepted":
print(f"准备查询 {result['city']} 的天气")这段会打印 准备查询 上海 的天气。此时城市已成为程序参数;调用天气服务前,还要核对它是否与用户请求一致,以及当前调用是否获准。
把校验和查询分开,我们可以反复测试 evaluate,不会触发天气请求。迁回工单分流时,把这张单字段表换为 team、reason、missing_information 三格:字段、类型仍逐项检查,团队是否符合原工单则另用业务规则判断。先把单字段流程走通,再扩对象。
5. 再试不符合约定的回答
在文件所在目录运行:
python -B -X utf8 contract.py
python -B -X utf8 -m unittest -v test_contract.py第一条命令逐项打印结果;第二条命令执行4个测试方法,其中一个方法遍历14个输入。其他测试分别检查形式有效不代表抽取正确、错误返回不回显输入,以及长度上限。
程序输出:
valid accepted
trim accepted
prose parse
fence parse
duplicate parse
nan parse
missing schema
extra schema
number schema
null schema
array schema
empty business
spaces business
long business
summary: {"accepted": 2, "business": 3, "parse": 4, "schema": 5}
14 designed fixtures; NOT a model benchmark; zero API calls.| 结果类别 | 个数 | 检查含义 |
|---|---|---|
| 接受 | 2 | 值满足 city 字段的形式契约 |
| 解析拒绝 | 4 | 输入形式或键值解释不被接受 |
| 结构拒绝 | 5 | 根节点、字段或类型不匹配 |
| 业务拒绝 | 3 | 字符串没有满足非空与长度约束 |
先挑每类一项,沿代码走:{"city":"上海"} 走到 accepted;城市是北京 在 json.loads 停于 parse;{"city":123} 在类型检查停于 schema;{"city":" "} 在清理空格后停于 business。主程序打印全部14项,单测验证它们落到预期分支。这组人工输入检验校验器,未调用模型。
14项边界覆盖的完整映射及解析细节
下表把相近的14项输入合并为12类;类型错误包含数字与 null,空值包含空串与纯空格。
| 输入类型 | 示例 | 处理结果 | 为什么 |
|---|---|---|---|
| 合法对象 | {"city":"北京"} | 接受 | 满足这里契约 |
| 首尾空格 | {"city":" 上海 "} | 接受为“上海” | 明确允许这一项归一化 |
| 解释文字 | 城市是北京 | 解析拒绝 | 不是 JSON |
| Markdown 围栏 | 三个反引号包住 JSON | 解析拒绝 | 接口要求裸 JSON,不猜测截取范围 |
| 重复字段 | {"city":"北京","city":"上海"} | 解析拒绝 | 不接受两个互相覆盖的解释 |
| 非标准数字 | {"city":NaN} | 解析拒绝 | NaN 表示“不是有效数字”,不是这里接受的 JSON 数值 |
| 缺字段 | {} | 结构拒绝 | 城市是必填项 |
| 多余字段 | {"city":"北京","admin":true} | 结构拒绝 | 未约定字段不能进入下一步 |
| 类型错误 | {"city":123} 或 null | 结构拒绝 | 不自动转换成字符串 |
| 根节点错误 | [{"city":"北京"}] | 结构拒绝 | 单个查询不接受列表 |
| 空值 | 空字符串或纯空格 | 业务拒绝 | 没有可用城市 |
| 超长值 | 81个字符 | 业务拒绝 | 超出 city 字段的长度上限 |
为什么不把 123 自动转成字符串?转换只会得到“123”,不会把它变成城市。为什么首尾空格可以清理,Markdown 围栏却不直接拆掉?去掉首尾空格不需要猜测模型想选哪个答案;从一整段回答中寻找一对花括号,可能在示例和正式回答之间选错。若产品需要兼容围栏,应另写明确的兼容规则和测试,而不是异常时随意截取。
重复键也要明确处理。一些解码器保留最后一个值;日志若显示第一个值,执行代码使用最后一个值,同一条响应就会出现两个解释。校验器因此直接拒绝重复键。Python 的相关钩子见官方 JSON 文档。
程序在解析前还限制了 8192 个 UTF-8 字节,防止这次实验处理过长回答。它只检查已经收到的字符串;真实调用器还要限制接收长度并设置超时。字符数和字节数并不相同,中文通常占多个 UTF-8 字节。
错误结果只返回类别,不回显整段模型文本,因为文本可能含有用户资料。校验器处理固定的 city 对象;复杂嵌套结构应使用经过验证的库,不断往函数里堆分支会增加遗漏风险。
这些分支用于定位坏输入。真实模型的成功率需要另一组有代表性的任务、模型响应和人工标注,不能从这14项边界输入的比例推算。
6. JSON Schema、类型库与手写校验怎么选择
上述代码适合教学,因为执行顺序清楚,读者不需要先学库。业务对象变复杂后,继续手写会遇到重复代码:嵌套字段、枚举、可选值、联合类型与错误路径都要自己维护。此时可以把结构契约提取成独立规范。
下面用 JSON Schema 描述对象与字段要求。要与 evaluate() 保持相同的接受范围,解析并检查字段类型后,先用 strip() 去掉 city 首尾空格,再对整理后的对象应用长度规则。例如“上海”后接81个空格,整理后长度为2,会通过;直接对原始字符串应用 maxLength,检查的就变成了另一条规则。
{
"type": "object",
"properties": {
"city": {"type": "string", "minLength": 1, "maxLength": 80}
},
"required": ["city"],
"additionalProperties": false
}properties 描述各个格子的规则,required 指定必填格,additionalProperties 控制额外格子;可以在官方对象说明中分别查看。整理后的空字符串会被 minLength: 1 拒绝。重复键在解析文本时处理,避免同一个格子收到两个值。
| 方案 | 更适合什么情况 | 应重点检查什么 |
|---|---|---|
| 手写固定对象校验 | 教学、非常小且稳定的契约 | 分支覆盖、重复逻辑、错误遗漏 |
| JSON Schema 校验器 | 多语言共享契约、接口约定 | 支持的规范版本、格式检查选项、额外字段 |
| Python 类型校验库 | Python 服务中的嵌套模型 | 严格模式、类型转换、默认值、序列化行为 |
| 提示词约束 | 告诉模型你希望的输出 | 不能替代运行时校验 |
| 供应商 JSON/结构化输出功能 | 降低特定输出格式错误 | 具体接口承诺、拒绝/截断、规范子集与模型支持 |
Python 可以关注 Pydantic 与 python-jsonschema;TypeScript 可以关注 Zod;Java 可以组合 JSON 解码、DTO 和业务校验。这里给的是职责对照与研究入口,不声称三种语言具有相同的默认行为。尤其要实测“123能否自动变成字符串”“未知字段是否被丢弃”“缺失是否应用默认值”。这些默认值可能让某个坏输入悄悄通过。
7. 把校验器接到真实模型
DeepSeek 的 JSON Output 文档说明了 JSON 模式、提示与长度配置,并提醒处理空内容。接入该接口时,先核对当前文档,再用真实响应验证本地解析和校验流程。
模型调用之后,先检查请求是否成功,再检查内容是否存在、是否完整,最后把文本送入校验器。网络超时不是 JSON 解析错误,模型拒绝也不是字段缺失。它们需要不同的用户提示、监控分类和处理策略。
对于只读抽取,可以设计有限次数的纠正尝试,例如第一次返回类型错误时,只向模型反馈“city必须为字符串”,并重新给出契约。反馈只包含需要修正的字段和规则,保留服务器内部信息与密钥。每次重新请求都会增加延迟,并可能产生费用;达到预算或次数上限后,应明确停止。
对于会写入外部系统的任务,还要确认失败发生在哪一步。校验阶段尚未执行工具,重做抽取通常没有业务副作用;工具已经发送但返回丢失时,则要先查询外部结果。后续的解析失败、重试与回退与重试与幂等会继续处理这条边界。
8. 结构正确,为什么依然可能答错
假设用户原文是“我明天去上海”,模型输出北京。这完全符合这里契约。你可以在测试里直接确认:evaluate('{"city":"北京"}') 返回接受,但它与人工标注“上海”不一致。
这说明我们需要两套不同的检查。契约检查回答“参数能否被程序按约定处理”,任务评测回答“参数是否准确表达了用户需求”。对于抽取任务,可以建立带期望值的测试集,再检查实体规范化、多城市歧义、没有城市、用户纠正上一句话等情况。
判断用户想查哪座城市,需要结合完整句子的含义。例如“不要查北京,查上海”同时包含两个城市;仅做子串匹配无法判断选择是否正确。同样,模型输出一个合法城市名,也不说明当前用户可以访问这个城市相关的内部资源。事实判断、任务意图和访问授权,是三种不同的问题。
没有足够信息时,产品应该允许澄清,而不是强迫模型凑出必填城市。接口可以另行设计“需要澄清”的明确结果类型;但那将改变这里契约,应先更新调用方与测试,不能直接把 city 改成随意空值。
9. 自测、实践与讨论
课后练习展开习题收起习题
先完成下面四类题。客观题提供规则反馈;简答、代码与场景题按给定标准自查,不会自动获得正式成绩。
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · JSON 合法就够了吗?
程序需要一个非空 city 字符串,模型返回 {"city":123}。应该怎样处理?
对应知识点:L1-schema
简答题 · 类型正确但城市错了
用户问上海天气,模型给出 city=北京,并通过了所有类型检查。为什么仍然失败?你会怎样记录?
对应知识点:L1-schema
代码题 · 扩展输出检查
修改正文例子,额外覆盖列表、缺少 city 和多出字段三种输出。提交代码或伪代码与每条预期结果;本站不执行代码。
对应知识点:L1-schema
场景题 · 连续返回坏 JSON
模型连续五次返回不可解析文本。继续无限重试是否合适?设计一个有明确出口的处理方式。
对应知识点:L1-retry
进一步实践:新增必填 date,采用固定教学日期2026-10-03,过去日期不接受。新建 date_step.py,粘贴下面完整程序后运行 python -X utf8 date_step.py。它从前面的最小函数扩出日期,不改变上方的完整校验器。
# 按固定输入观察模型输出的解析与校验的处理过程和返回结果。
import json
from datetime import date
TODAY = date(2026, 10, 3)
def evaluate(text):
try:
value = json.loads(text)
except ValueError:
return {"stage": "parse"}
if not isinstance(value, dict) or set(value) != {"city", "date"}:
return {"stage": "schema"}
if not all(isinstance(value[key], str) for key in ("city", "date")):
return {"stage": "schema"}
city = value["city"].strip()
try:
day = date.fromisoformat(value["date"])
except ValueError:
return {"stage": "business", "reason": "invalid_date"}
if day.isoformat() != value["date"]:
return {"stage": "business", "reason": "expected_yyyy_mm_dd"}
if not city or len(city) > 80 or day < TODAY:
return {"stage": "business"}
return {"stage": "accepted", "city": city, "date": day.isoformat()}
for text in (
'{"city":"上海","date":"2026-10-04"}',
'{"city":"上海"}',
'{"city":"上海","date":"2026-10-02"}',
'{"city":"上海","date":"2026-10-04","admin":true}',
):
print(evaluate(text))应依次看到 accepted、schema、business、schema。第三项日期格式有效,但违反查询范围;第四项因未知字段拒绝。把第三项日期改为2026-10-03,再跑应接受,便能定位发生变化的检查。
可以带着这些问题讨论:什么时候允许兼容输出格式?严格拒绝会不会增加澄清次数?类型转换应该放在模型输出边界还是后续业务层?如果同一套契约由Python和Java共同使用,怎样证明两端接受范围一致?
把你发现的一条坏响应保存下来,记录原始文本、解析结果、结构校验结果和业务判断。拿这四项与同学讨论,比只说“模型答错了”更容易定位问题。练习数据请用虚构工单,不贴真实用户信息。
10. 自查与继续学习
收到模型输出后,按顺序限制输入、解析 JSON、验证结构、验证业务,再单独核查事实与授权。失败时记录停在哪一步,排查就有明确起点。
不看代码,先判断一个数字城市和一个过去日期分别在哪一步停下,再运行核对。最后用“用户查上海、输出北京”解释为什么契约通过后仍需核对任务含义。若愿意记笔记,保留这条让你改变判断的反例即可。
下一篇:受约束输出。它比较解码阶段约束与这里的运行时校验;稍后在解析失败、重试与回退单独处理失败恢复。
参考资料与开源实现
下面的资料按学习顺序排列:先了解结构约束,再看库的用法和接口。
- Python JSON 官方文档:解码行为、自定义钩子、输入资源风险。
- JSON Schema 对象约束:字段、必填与额外属性。
- DeepSeek JSON Output:真实调用前应核对的供应商契约。
- Pydantic:研究Python类型模型与严格校验。
- python-jsonschema:研究基于规范的校验与错误定位。
- Zod:研究TypeScript运行时结构校验。收到网络数据时,用运行时校验器检查字段和类型。
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。