Prompt 工程与结构化输出(JSON Schema)
Prompt 工程与结构化输出(JSON Schema)
在 Agent 落地过程中,最容易被低估的往往不是模型能力,而是「输出是否可被程序可靠消费」。当你的下游是一段确定性代码、一个 JSON 解析器、一条数据库写入链路时,模型的「文笔好」「理解到位」统统要让位于一个朴素的标准:这次输出能不能被无脑解析。本文不讨论怎么写「更聪明的提示词」,而是从工程视角拆解一条可落地的管线:指令设计 → few-shot 示范 → 约束解码兜底 → JSON Schema 强制结构化输出。所有结论都来自真实生产环境里踩过的坑。
一、为什么「让模型返回 JSON」这件事天然不可靠
先说一个反直觉的事实:在绝大多数通用模型上,「请返回 JSON」只是一个概率性的软约束。模型不是为你生成 JSON,而是生成「看起来像 JSON 的文本序列」,其中每一个 token 都是在整个词表上的概率分布中采样出来的。这意味着两个问题:
- 语法层:模型可能在
{后忘记",可能在数组里混入非字符串元素,可能输出None/undefined/NaN这类 JSON 标准里根本不存在的字面量。 - Schema 层:即使语法合法,字段名、类型、是否可空、枚举取值也可能与你的契约不一致——比如把
status写成"statusCode",把数字写成字符串"200"。
以一次真实故障为例:某客服工单 Agent 用提示词要求模型输出 {"category": "...", "priority": 1..5},模型偶发返回:
{"category": "退款", "priority": "3", "note": "用户情绪激动"}priority 是字符串而非整数,导致下游 int(priority) 抛异常,整批工单路由失败。问题不在模型「不够聪明」,而在我们把契约正确性寄托在生成概率上。理解了这一点,后面的每一步都是在把「概率性遵守」改造成「确定性保证」。
二、指令设计:把契约写成「可判定的规则」
Prompt 工程的第一原则不是堆形容词,而是消除歧义、让约束可判定。判断标准很简单:如果一个人拿到你的指令后,能写出一段代码来「校验输出是否合规」,这条指令就是可判定的;否则就是模糊的。
对比两组指令:
| 维度 | 差指令 | 好指令 |
|---|---|---|
| 字段定义 | 输出订单信息 | 输出 orderId(string)、amount(number, 保留两位小数)、paid(boolean) |
| 边界条件 | 金额别写错 | amount 必须为正数,单位是元,0 < amount ≤ 999999 |
| 枚举 | 状态写清楚 | status 只能取 pending/paid/refunded 之一 |
| 缺失处理 | 没有就随便 | 缺失字段用 null,不要省略键,也不要造一个 "N/A" |
| 格式 | 用 JSON | 输出仅包含一个 JSON 对象,禁止 Markdown 代码块,禁止解释性文字 |
一个可执行的指令模板如下:
你是订单解析器。从给定文本中提取订单信息,严格按以下契约输出。
契约:
- orderId: string,必填,原样提取
- amount: number,单位元,正数
- paid: boolean
- items: array of string,可为空数组 []
规则:
1. 输出仅包含一个合法 JSON 对象,禁止 ``` 包裹,禁止任何解释文字。
2. 缺失字段用 null,禁止省略键。
3. 不确定时宁可用 null,也不要猜测。注意三条工程细节:
- 「仅包含」比「包含」重要。模型默认爱「闲聊」,
好的,结果如下:{...}是高频污染源。 - 显式声明 null 语义。
null与「省略键」在 JSON 里是两种东西,下游 schema 校验对二者态度不同。 - 给出「不确定时的降级策略」。让模型知道「猜错比空着更糟」,能显著减少幻觉字段。
三、Few-shot:用示范定义「边界样本」而非「正确样本」
很多人的 few-shot 只给「标准正确示例」,这在简单场景够用,但在生产环境远远不够。真正的坑在于:模型对没见过输入分布时的表现,取决于你示范过多少「难例」。
推荐的示范结构是「边界优先」:
examples = [
{
"input": "订单一共 128.5 元,已支付",
"output": {"orderId": None, "amount": 128.5, "paid": True, "items": []},
},
{
"input": "买了三样:苹果、牛奶、面包",
"output": {"orderId": None, "amount": None, "paid": False, "items": ["苹果", "牛奶", "面包"]},
},
{
"input": "金额是负的 -10 元", # 越界难例
"output": {"orderId": None, "amount": None, "paid": False, "items": []},
},
]几个实战结论:
- 每条 few-shot 都要「示范失败/降级路径」,而不是只有阳光大道。上面第三例教模型:遇到非法值时输出
null而不是编造一个正数。 - 保持键的稳定顺序。虽然 JSON 对象顺序无关紧要,但稳定的示范顺序会降低模型「重新排列字段」的概率,减少 token 浪费。
- few-shot 不是越多越好。对同一模型的实测中,3~5 条高质量边界样本优于 20 条同质样本;后者不仅拉长 prompt、稀释注意力,还推高成本和首 token 延迟。一个可操作的调参顺序是:先加「难例」→ 再加「降级例」→ 最后才加「正确例」。
四、约束解码:把「软约束」升级为「硬保证」
上面所有手段本质上仍是「建议」。要从根本上杜绝语法层错误,得靠约束解码(Constrained Decoding):在自回归生成的每一步,根据当前状态动态地屏蔽不合法的 token,只允许采样「能使前缀仍可解析为合法 JSON」的候选。
约束解码不是后处理,而是前向保证:它直接改变模型的采样空间。其收益可以量化对比:
| 方案 | 语法错误率 | Schema 符合率 | 额外延迟 | 实现成本 |
|---|---|---|---|---|
| 纯 Prompt | 1%~5%(偶发) | 依赖运气 | 无 | 低 |
| Prompt + 重试/修复 | 接近 0(多轮) | 中 | 重试开销大 | 中 |
| 约束解码(JSON 模式) | 0 | 高(仅语法层) | 每步需 mask,略增 | 中 |
| 结构化输出(JSON Schema) | 0 | 100%(语法+字段层) | 略增 | 低(SDK 支持好) |
以 OpenAI 为例,最简用法是在响应格式里声明 json_schema:
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "解析:订单一共 128.5 元,已支付"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "order",
"strict": True,
"schema": {
"type": "object",
"properties": {
"orderId": {"type": ["string", "null"]},
"amount": {"type": ["number", "null"]},
"paid": {"type": "boolean"},
"items": {"type": "array", "items": {"type": "string"}},
},
"required": ["orderId", "amount", "paid", "items"],
"additionalProperties": False,
},
},
},
)
print(resp.choices[0].message.content)三个必须知道的坑:
additionalProperties: false是防幻觉的利器。默认情况下模型可能「好心」地多返回一个confidence字段,false会在生成阶段就把这些多余键封死。若你的下游做了严格校验,漏加这一项会带来隐蔽的字段漂移。null必须显式写进 type。{"type": "string"}与{"type": ["string", "null"]}是两套契约,前者要求字段必填且非空。遗漏null会导致缺失场景直接违反 schema。- 顶层
strict: true要求所有字段都在required里,缺一个都会在请求阶段报错。这是很多人第一次接入时踩的「请求 400」高发点。
五、JSON Schema 的结构化输出实践:从「能解析」到「能上线」
语法层解决了,剩下的战场在 Schema 设计与下游消费。以下是一套经过生产验证的实践清单。
5.1 让 Schema 贴近业务而不是贴近模型
Schema 的第一读者是下游代码,第二才是模型。设计时优先考虑「下游好写」,而不是「模型好懂」:
# 反例:过度嵌套,逼模型做多余抽象
result:
data:
payload:
orderInfo:
amount: 128.5
# 正例:扁平、直给
orderId: "A-1001"
amount: 128.5
paid: true嵌套每深一层,模型就多一次「结构对齐」的机会,出错率随之上升。原则是:能扁平的不要嵌套,能用枚举的不要自由文本。
5.2 枚举优先于自由文本
凡是取值可枚举的,一律用 enum 收口:
status:
type: string
enum: [pending, paid, refunded, canceled]理由有二:其一,enum 在约束解码下会被直接映射到有限 token 集合,模型想「创新」都创不出来;其二,下游 switch 判断不需要再写一堆容错分支。生产里「状态字段五花八门」是最常见的返工原因之一。
5.3 数字精度与单位要前置声明
金额、时间戳这类字段,先定「单位」和「精度」再谈别的。amount 是「元」还是「分」?timestamp 是秒、毫秒还是 ISO 字符串?这些不一致的代价,往往在一两个月后数据积累到一定量时才爆发,那时迁移成本极高。
5.4 把「解析失败」当作一等公民
即便上了结构化输出,仍然要有防御性解析,因为传输层截断、上游并发超时、供应商降级都可能给你半个 JSON:
import json
def safe_parse(raw: str) -> dict | None:
try:
data = json.loads(raw)
# 二次契约校验,别信任供应商的"已保证"
assert set(data) == {"orderId", "amount", "paid", "items"}
return data
except (json.JSONDecodeError, AssertionError, TypeError):
return None # 记录到错误队列,进入人工/重试通道即使供应商声称「保证合法」,本地二次校验也不能省:它是你唯一不依赖第三方承诺的兜底,也是观测系统里最可靠的故障信号。
六、排查思路:输出仍然不对时,按这条链路定位
当线上报告「模型又返回了坏结构」,不要上来就改 prompt。按顺序走:
- 确认是哪一层坏了:抓原始响应,先问「语法是否合法」,再问「是否符合 schema」。两者是不同的根因。
- 语法层坏 → 检查是否真的启用了约束解码/结构化输出。很多「以为开了其实没开」的故障,源于 SDK 版本、参数名拼写或降级到普通 chat 分支。
- Schema 层坏 → 检查
additionalProperties、null声明、required是否完整;再检查 few-shot 里有没有和 schema 冲突的示范(示范与契约打架,模型会更倾向示范)。 - 字段值「错」但结构对 → 这是理解/幻觉问题,回到指令设计与 few-shot,补充边界样本和「不确定输出 null」的降级例。
- 偶发、难复现 → 查是否命中了供应商的降级/重试路径,以及并发下是否有截断;在解析层加告警统计错误率与错误类型分布。
小结与建议
- 把契约当作可判定的规则写进指令,判断标准是「能否用代码校验」,而非「读起来是否通顺」。
- few-shot 示范难例与降级路径,用 3~5 条高质量边界样本替代海量同质样本,并保持字段顺序稳定。
- 语法保证靠约束解码,Schema 保证靠结构化输出:
additionalProperties: false、显式null、完整required三件套缺一不可。 - Schema 设计服务下游:扁平化、枚举收口、单位与精度前置声明。
- 永远做本地二次解析与契约校验,把解析失败当作一等公民,落到错误队列和监控里。
- 排查按「语法层 → Schema 层 → 值正确性 → 偶发路径」分层定位,不要一上来就改 prompt。