工单分类器回答:“这是退款问题,比较紧急,订单号是 A100。”人能看懂,程序却还得猜哪些字对应类别、紧急程度和订单号。要求模型输出 JSON 以后,解析似乎容易了,但新的问题很快出现:字段拼错了,金额变成字符串,或者格式完全正确,订单却根本不存在。
这些失败发生在不同层次。只有先分清每层保证什么,才能知道该补提示、加 Schema,还是查询业务系统。
JSON 解决了语法,契约解决了含义
JSON 规定如何表示对象、数组、字符串、数字、布尔值和 null。解析成功只说明这段文本符合语法。一个只有空对象的结果是合法 JSON,一个写着 category=“退款” 的对象也可能是合法 JSON,但它们都不符合我们的分类契约。
沿用上一篇:category 只允许 billing、security、general、unknown;urgency 只允许 low、medium、high;order_id 是字符串或 null;requested_amount 是非负数字或 null;summary 是最多四十个字符的字符串。五个字段都必须出现,不允许额外字段。
“字段必须出现”和“字段不能为 null”是两个条件。要求 order_id 必须出现,可以让下游稳定找到字段;同时允许它为 null,是承认原文可能没有订单号。如果只有 required,却把类型限定为字符串,模型就没有诚实表达未知的空间。
同样,零元和未知金额不能混用。零是一个确定数值,null 表示没有取得该信息。把所有缺失金额填成 0,会让统计程序把“没说金额”当成“申请零元”,也会让后续追问失去依据。
JSON Schema 能表达字段类型、枚举、必填项和额外属性限制。对象规范说明特别区分了属性定义与 required;只在 properties 里列出一个字段,并不会自动要求它存在。
把五个字段展开成一份真正的 Schema
先用 JSON 看一遍契约,后面的生成和校验才有明确对象。properties 描述每个属性,required 要求属性出现,additionalProperties: false 拒绝没有声明的属性。注意字段说明只是供阅读和生成参考,不能替代类型约束。
{
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "security", "general", "unknown"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
"order_id": {"type": ["string", "null"], "minLength": 1},
"requested_amount": {"type": ["number", "null"], "minimum": 0},
"summary": {"type": "string", "minLength": 1, "maxLength": 40}
},
"required": ["category", "urgency", "order_id", "requested_amount", "summary"],
"additionalProperties": false
}
JSON 不支持注释,因此解释放在正文中:minLength 约束字符串,minimum 约束数字,它们不把允许的 null 变成非法值。这里的长度也不是模型 Token 数;中文、Emoji 以及组合字符的视觉宽度不能简单当成这个长度限制。
如果对象还包含嵌套对象,每一层都要考虑自己的额外字段约束,根部的 false 不会自动递归约束所有子对象。将来想同时允许“成功工单”和“需要澄清”,可以在外层协议中区分状态与载荷,或用带明确区分字段的联合类型;不能在同一个字段里混放对象、字符串错误和空数组,让消费者猜是哪种情况。
标准 JSON Schema 与具体模型接口支持的 Schema 子集也不一定相同。应用内的完整契约可以保留更多校验,发送给模型的版本需按接口适配;如果某条约束未能传过去,应该明确由后端负责,而不是悄悄删掉后端检查。
结构化生成发生在哪里
普通文本生成时,模型可以在词表里选择各种续写。某些服务提供结构化输出机制,在解码过程中根据 Schema 限制可继续生成的 Token,让结果更容易满足结构。也有工具调用模式,让模型生成工具名和参数。具体支持哪些 Schema 关键字、拒绝和截断如何返回,要看所选接口。
这与“生成后再拿正则补引号”不同:约束解码是在生成过程中限制可行路径,后处理是在结果已经产生后判断或修改。两者都不能凭空证明订单存在。Schema 能规定字符串形状,却不能知道数据库里今天的订单状态。
即使服务承诺结构化输出,应用仍要处理超时、拒绝、输出截断和协议错误。不要把一个不完整响应当成有效业务对象,也不要在输出开始流式到达时就执行退款。完整性与业务校验应该位于动作之前。
约束解码怎样把一个错误续写挡在生成之前
假设输出正在写 category 的值,只允许 "billing" 或 "security"。在起始双引号后,b 和 s 可以走向合法答案,x 无法走向任何一个合法答案。如果模型原本最想生成 x,约束器应把这条路径排除,再在剩余候选中选择。
用 zᵢ 表示候选 Token 的原始分数,用 A 表示当前前缀允许的 Token 集合。约束后的概率为:
P(i) = exp(zᵢ) / ∑ⱼ∈A exp(zⱼ),当 i 不在 A 中时,P(i) = 0。
这里的关键是 A 会随前缀改变。已经生成 "b 之后,只有能够继续组成 billing 的路径有效;完整字符串结束后,才轮到逗号或对象结束符。不能只在开头设置一次“允许哪些字符”,否则依然可能生成语法错误的排列。LMQL 论文讨论了将输出约束与推理过程结合的方式。
下面用微型字符词表展示一次筛选和重新归一化。它不是完整解码器,也不声称实现了真实服务的算法。完整文件为 constrained_decoding_demo.py下载。
"""在一个微型字符词表上演示约束解码;不加载模型,不解析通用 JSON Schema。"""
import math
# 简化场景:只允许输出两个带引号的类别字符串。
# 真正模型的词表单位是 Token,这里用字符只是为了能逐个看清分支。
allowed_answers = ['"billing"', '"security"']
prefix = '"'
# 人工设置下一字符的分数:非法的 x 得分反而最高。
logits = {"b": 2.0, "s": 1.0, "x": 3.0}
# 一个字符只有在追加后仍能补全为某个合法答案,才允许被选择。
legal = {
char: score for char, score in logits.items()
if any(answer.startswith(prefix + char) for answer in allowed_answers)
}
# 先减最大分数再取指数,避免大数溢出;这不改变 softmax 比例。
peak = max(legal.values())
weights = {char: math.exp(score - peak) for char, score in legal.items()}
total = sum(weights.values())
probabilities = {char: weight / total for char, weight in weights.items()}
print("合法下一字符的概率:", probabilities)
# b 的条件概率约为 0.731。这个结果只保证合法选择,不证明 billing 分类正确。
print("贪心选择:", max(probabilities, key=probabilities.get))
原始分数 b=2、s=1、x=3,对应概率约 0.245、0.090、0.665;排除 x 后变为 b≈0.731、s≈0.269。合法候选概率提高,只是因为分母变小,不能据此说模型更有把握地判断了业务事实。security 和 billing 都合法时,选错类别依旧完全符合约束。
真实 Token 可能覆盖多个字符,甚至同时覆盖引号和部分字段值,所以约束器必须检查整个候选 Token 追加后是否还能形成合法结果,而不是机械套用这里的逐字符程序。复杂 Schema 还会涉及数组、嵌套对象、可选分支及服务支持范围;本例只解释共同的筛选思想。
JSON 模式、Schema 与工具调用各负责哪一部分
要求“只输出 JSON”的普通 Prompt 是自然语言要求。某些服务的 JSON 模式进一步保证完整成功响应是 JSON,却未必保证 category 只能取四个值。Schema 约束再限定字段和取值,但只有服务明确支持并启用的约束才能算模型端保证。
工具调用返回的通常是工具名、参数和调用关联 ID。参数也可以使用 Schema;严格程度仍取决于接口配置。它表达“模型建议调用什么”,执行则由应用分发器完成。把同一个工单 Schema 挂到一个名为 create_ticket 的工具上,不会自动让模型具备创建工单的权限,也不会自动创建数据库记录。
例如模型返回 create_ticket 的参数后,应用先检查响应是否完整、参数是否符合契约,再检查用户是否允许创建相应工单。执行结果随后作为工具结果回到对话。用于关联响应的 call ID 不是身份凭证,也不是天然的业务幂等键。为了单纯取得分类结果而注册退款工具,会平白扩大任务范围。
把这几层检查写成程序
下面使用 Python 标准库手工实现这份小契约,便于看清检查顺序。它不是一个通用 JSON Schema 实现;复杂项目可以改用成熟校验库,并让 Schema 成为唯一字段定义。完整代码见 validation_demo.py下载。
import json
import math
# 字段集合来自本系列共用的工单契约;多字段和少字段都拒绝。
FIELDS = {"category", "urgency", "order_id", "requested_amount", "summary"}
def unique_object(pairs):
"""拒绝重复 JSON 属性,避免前后两个 category 被不同组件解释为不同值。"""
result = {}
for key, value in pairs:
if key in result:
raise ValueError("JSON 含重复字段")
result[key] = value
return result
def reject_constant(value):
"""Python 默认容忍某些非标准常量;本契约拒绝它们。"""
raise ValueError("JSON 不允许 NaN 或 Infinity")
def validate(raw):
"""把原始 JSON 文本解析成已通过类型检查的工单;失败时抛出异常。"""
item = json.loads(raw, object_pairs_hook=unique_object, parse_constant=reject_constant)
if not isinstance(item, dict) or set(item) != FIELDS:
raise ValueError("字段缺失或包含未定义字段")
if item["category"] not in ("billing", "security", "general", "unknown"):
raise ValueError("category 不属于约定枚举")
if item["urgency"] not in ("low", "medium", "high"):
raise ValueError("urgency 不属于约定枚举")
order_id = item["order_id"]
if order_id is not None and (not isinstance(order_id, str) or not order_id.strip()):
raise ValueError("order_id 必须为非空字符串或 null")
amount = item["requested_amount"]
# Python 中 bool 是 int 的子类;这里用精确类型检查,防止把 true 当金额 1。
# 拒绝 NaN 和无穷大,因为它们不是合法的业务金额。
if amount is not None and (
type(amount) not in (int, float) or not math.isfinite(amount) or amount < 0
):
raise ValueError("requested_amount 必须为非负有限数字或 null")
if not isinstance(item["summary"], str) or not 1 <= len(item["summary"]) <= 40:
raise ValueError("summary 长度必须在 1 到 40 个字符之间")
return item
def check_order(item, orders, actor):
"""核验订单事实和归属;actor 必须来自登录态,不能从模型输出取得。"""
if item["order_id"] is None:
return "需要补充订单号"
order = orders.get(item["order_id"])
# 不向调用者区分不存在与无权访问,避免泄露其他用户的订单信息。
if order is None or order["owner"] != actor:
return "订单不可用"
return "订单已找到,退款资格仍需另行核验"
if __name__ == "__main__":
# 教学响应:故意给出一个不存在的订单,观察两层校验的不同结果。
raw = json.dumps({
"category": "billing", "urgency": "medium", "order_id": "A999",
"requested_amount": 99, "summary": "申请退款",
}, ensure_ascii=False)
ticket = validate(raw)
print("格式校验通过")
print(check_order(ticket, {"A100": {"owner": "user-1"}}, "user-1"))
按程序逻辑,预期打印“格式校验通过”,随后“订单不可用”。这就是为什么合法 JSON 不能直接驱动业务动作。前一层没有失败,它本来就只负责结构;后一层拿到了真实记录,才发现业务依据缺失。
金额这里用浮点数只是演示类型校验。涉及真实结算时,应采用最小货币单位整数或明确的十进制方案,避免二进制浮点误差。模型提取出来的 requested_amount 也只是请求金额,不能覆盖后台核算结果。
还有一层没有放进这段程序:提取忠实度。订单 A100 真实存在且属于当前用户,不代表它一定出现在本条原文里。分类器可能猜中了一个合法编号,所以需要把输出与原文对应,必要时保留证据位置或单独检验。结构、来源、业务事实和操作权限形成不同检查,不应让某一项通过替代全部通过。
从手工校验走向一份可复用的类型定义
前面的标准库程序适合看清层次。字段变多后,手写每一条判断会越来越难维护。下面用 Pydantic 2 声明同一契约,同时导出 Schema;所需依赖沿用配套 requirements。它仍然只是本地对象校验,未调用模型,也不代表某个在线服务接受全部导出关键字。完整文件见 schema_walkthrough.py下载。
"""用 Pydantic 2 定义并导出工单契约;只处理本地教学数据,不调用模型。"""
import json
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class Ticket(BaseModel):
# 严格模式拒绝常见隐式转换;额外字段也拒绝,避免混入 approved 等字段。
model_config = ConfigDict(strict=True, extra="forbid", allow_inf_nan=False)
category: Literal["billing", "security", "general", "unknown"]
urgency: Literal["low", "medium", "high"]
# 没有 default,所以字段必须出现;联合 None 则允许显式 null。
order_id: str | None = Field(min_length=1, description="原文订单号;缺失为 null")
requested_amount: float | None = Field(ge=0, description="原文请求的人民币元金额")
summary: str = Field(min_length=1, max_length=40, description="不添加事实的简短摘要")
if __name__ == "__main__":
# 同一个定义导出 Schema 和执行校验,降低两份契约分别维护造成的漂移。
print(json.dumps(Ticket.model_json_schema(), ensure_ascii=False, indent=2))
# 这是作者编写的响应,用来展示 null 合法且字段没有缺失。
raw = '{"category":"billing","urgency":"medium","order_id":null,"requested_amount":null,"summary":"申请退款但未提供订单号"}'
ticket = Ticket.model_validate_json(raw)
print(ticket.model_dump_json(indent=2))
特别注意 str | None 后面没有 = None:前者允许值为空,后者还会提供字段缺失时的默认值。本例希望发现漏字段,所以不提供默认值。extra="forbid" 则让模型额外生成的 approved 无法混进有效对象。Pydantic 模型文档说明了模型定义、转换、序列化和额外字段行为。
严格模式也不是业务验证。order_id="A999" 是类型正确的字符串,summary="已经退款" 也可能满足长度限制,库无法由此知道订单不存在或退款未执行。空白字符串、跨字段关系和原文来源还需业务校验。本例中 security 对应 high,是应用约定;如果出现 security、low,两项枚举分别合法,组合仍然违背当前规则。
提取来源检查同样需要说明精度。简单检查 order_id in customer_text,可能让 A10 命中 A100,或命中“不要处理 A10”这样的否定表达。更可审查的方案是记录原文中的起止位置,验证切片等于候选证据,再判断该证据的语义角色。位置匹配证明“这段文字出现过”,不证明“它就是当前请求的订单”。两步不能合并。
同理,一条消息有 199、179、50、129 四个金额,验证 129 出现在原文,只完成了表面对应。需要结合“我还想退”确认其为请求金额。金额核算则用后台事实和十进制数或最小货币单位,不能用模型摘要替代。
校验失败后,怎样修复才不会越修越错
如果模型多输出了解释文字,或者漏了一个字段,可以将原始任务、原始输入和明确的校验错误交回模型,要求重新生成。这叫有限修复。错误信息要具体,例如“缺少 order_id,未知时请为 null”,比“你的回答错了”更有用。
修复次数必须有上限。反复尝试直到“总有一次通过”,既增加费用,也可能让模型为了通过而填入猜测。每次修复仍必须经过同一套校验,不能因为它是第二次回答就降低要求。超过上限应返回明确失败或进入人工处理。
业务失败则不应该用同样方式处理。如果 A999 不存在,不能要求模型“换一个存在的订单号”。这会把没有依据的信息修成看似合法的事实。正确路径是向用户补充询问,或者通过有权限的业务查询取得信息。输出修复只能修表达,不能制造证据。
同样,系统不能让模型通过“改小退款金额”来绕过额度限制。额度限制说明任务需要另一条业务流程,例如审核,而不是说明 JSON 写得不好。校验器应该返回不同类型的失败,让应用决定下一步,避免把一切都塞给模型重试。
可以把四条响应放在一起思考。第一条少了右花括号,失败在语法解析;第二条类别写成 refund,失败在枚举约束;第三条格式正确但订单 A999 不存在,失败在业务查询;第四条订单存在,但客户原文从未提过它,失败在提取忠实度。这四条都叫“结果不可用”,下一步却完全不同。
语法与字段错误可以有限重试,缺少事实需要补充资料,权限失败需要停止或走授权流程。让错误类型显式传递给调用方,才能避免一个统一的 repair 函数偷偷承担所有业务判断。
Schema 本身也需要与代码一致。若模型接收到的 Schema 允许 summary 缺失,而后端要求必填,模型可能合法地生成一个后端拒绝的结果。成熟工程通常从同一个类型定义导出 Schema,或者用契约测试检查二者,避免在提示、校验代码和文档里维护三份不同规则。
可空字段在不同语言中也要注意映射。Python 的 None 对应 JSON null,Java 可能用可空对象或其他明确表达方式。缺失字段与显式 null 是否视为同一种状态,应该在接口层决定,而不是让各客户端自行猜测。这里要求五个字段都出现,就是为了减少这种分歧。
业务金额还需要单位。requested_amount 在本例明确是人民币元;若用户说“退 10 美元”,当前契约没有货币字段,就不能把 10 悄悄当成人民币。要么限制输入范围并请求澄清,要么增加 currency 字段并同步版本。结构化输出的优势是让这些假设暴露出来,而不是把它们藏在一段看似自然的文字里。
为了检查程序是否把边界真正落地,可以故意给金额传 true、给摘要传数组、增加 approved 字段,观察它们是否被拒绝;再给合法但不属于当前用户的订单,观察是否泄露其内容。这些测试不需要真实模型,成本低而且能精确定位程序问题。模型效果测试则另外验证它是否能从自然语言稳定生成正确对象。
把“最多修一次”落实到控制流
有限修复最好在代码里数得清楚。下面第一次收到缺字段对象,第二次收到完整对象。回调是固定响应模拟,不是在线修复效果;完整文件见 repair_loop.py下载,需与 validation_demo.py 放在同一目录。
"""有限修复的控制流程:响应来自固定列表,不发网络请求。"""
from validation_demo import validate
class NeedsReview(Exception):
"""表达失败已达上限,应交给调用方处理,而不是无限生成。"""
def receive_ticket(generate, customer_text, max_repairs=1):
# max_repairs=1 表示首次生成之后最多再修一次,总调用次数为 2。
if max_repairs < 0:
raise ValueError("修复次数不能为负")
errors = []
for attempt in range(max_repairs + 1):
# 回调同时收到原始输入和有界错误信息,不会丢掉原始任务依据。
# 在线实现还必须保留可信任务规则,并限制总时长和输出长度。
raw = generate(customer_text, tuple(errors))
try:
return validate(raw)
except ValueError as exc:
# 这里只捕获解析/结构层错误;业务查库应在循环结束以后执行。
# 避免把数据库异常、权限错误包装成“请换一个值”。
errors.append(str(exc)[:200])
raise NeedsReview("表达校验未通过,停止自动修复")
if __name__ == "__main__":
# 第一个对象缺字段,第二个对象完整;二者都是教学预置数据。
responses = iter([
'{"category":"billing"}',
'{"category":"billing","urgency":"medium","order_id":null,"requested_amount":null,"summary":"申请退款"}',
])
def fake_generate(customer_text, errors):
# 打印错误能看到第二次生成获得了什么反馈;这里并没有模型理解反馈。
print("本次反馈:", errors)
return next(responses)
print(receive_ticket(fake_generate, "我想退款"))
在线适配时,把错误反馈作为应用维护的修复说明发送,并继续携带原始客户消息和任务契约;不要把整份异常堆栈、密钥或其他用户记录送进 Prompt。若携带上次响应,也必须标为待纠正数据,不能提升为新的应用指令。
这个循环只返回通过结构检查的对象,调用方随后做来源和业务检查。网络超时也没有被当成结构错误捕获:传输重试应有自己的预算。如果 SDK 自动重试三次、外层又重新调用三次,就可能产生远多于预期的请求。计数时明确“总尝试次数”还是“首次之外的重试次数”。
流式输出尤其不能在看到完整 order_id 后立即执行动作。后面的 category、金额和响应完成状态尚未到达;传输中断留下的半份 JSON 也不能靠补一个花括号冒充完成。应先检查服务返回的终止状态,再解析完整载荷,最后进入上面的校验与业务流程。
当分类器接入真实工作流
应用收到经过检查的工单后,可以把它送到对应队列。若后来增加退款流程,建议先让程序查询订单与政策,再形成待审批建议,获得授权后才调用写入工具。模型的分类或建议本身不构成授权。
暂停与恢复需要保存业务状态:当前处理哪个订单、已经取得什么证据、等待谁确认。仅仅把对话保存在列表里,不能保证进程重启后准确继续。现有 LangGraph 退款示例展示了检查点与恢复,但内存保存方式只适合进程内演示;持久化、并发恢复和幂等需要在工程接入时补齐。
这些内容放在 Prompt 实验与工作流指南下载,包括现有 Python 退款流程和 Java 发票审核入口。先掌握本文的分层边界,再读工作流代码,更容易理解为什么不能把模型返回值直接当成数据库操作指令。
现在分类器已有明确规则和结果检查。下一篇讨论外部文字与任务边界,解释为什么格式正确仍不能替代程序授权。之后再用对照评测判断提示修改的效果。