让大模型稳定输出 JSON:Prompt 约束与结构化输出实战
系列:Python + FastAPI 大模型应用基础(第 14 篇)
1. "要求输出 JSON"为什么还不够
模型生成的是 Token 序列,不是数据库记录。它可能输出 Markdown 代码围栏、漏字段、错误枚举或错误类型。可靠链路应当是:
text
定义 Schema → 请求结构化输出 → 解析 → 业务校验 → 有限重试或失败
若模型 API 支持 JSON Schema(JSON 模式),优先使用其官方结构化输出能力;具体参数随厂商和版本变化,应核对当前文档。即便如此,业务规则仍要在服务端校验。
2. 使用 Pydantic 定义输出契约
python
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class TicketResult(BaseModel):
"""模型输出必须满足的结构;禁止悄悄接收未知字段。"""
model_config = ConfigDict(extra="forbid")
category: Literal["物流", "退款", "产品", "其他"]
urgency: Literal["low", "medium", "high"]
summary: str = Field(min_length=1, max_length=200)
needs_human: bool
def parse_ticket_result(raw_text: str) -> TicketResult:
"""Pydantic 同时完成 JSON 解析和字段校验。"""
try:
return TicketResult.model_validate_json(raw_text)
except ValidationError as exc:
# 对外返回统一错误;完整原文是否记录需服从数据安全规范
raise ValueError("模型输出不符合 TicketResult 契约") from exc
3. Prompt 只描述任务,Schema 才是机器契约
python
import json
def build_extraction_messages(ticket_text: str) -> list[dict[str, str]]:
if not ticket_text.strip():
raise ValueError("工单内容不能为空")
# 由程序生成 Schema,避免 Prompt 描述与代码模型发生漂移
schema = json.dumps(
TicketResult.model_json_schema(),
ensure_ascii=False,
)
return [
{
"role": "system",
"content": (
"你是工单信息抽取器。只提取原文事实;"
"无法确认时选择"其他",高风险或不确定时 needs_human=true。"
),
},
{
"role": "user",
"content": (
f"严格返回符合下列 JSON Schema 的 JSON,不要使用 Markdown:\n"
f"{schema}\n\n"
f"<ticket>\n{ticket_text.strip()}\n</ticket>"
),
},
]
这里没有把 schema 当成绝对保证。它只是告诉模型目标格式;最终仍以 TicketResult 的校验结果为准。
4. 失败后如何处理
无限重试会放大成本和故障。可以限定一次"修复重试",仍失败就进入人工或降级流程:
python
from collections.abc import Callable
def extract_with_one_retry(
call_model: Callable[[list[dict[str, str]]], str],
ticket_text: str,
) -> TicketResult:
messages = build_extraction_messages(ticket_text)
first_output = call_model(messages)
try:
return parse_ticket_result(first_output)
except ValueError:
repair_messages = messages + [
{"role": "assistant", "content": first_output},
{
"role": "user",
"content": "上次输出未通过 Schema 校验。请只返回修正后的 JSON。",
},
]
second_output = call_model(repair_messages)
return parse_ticket_result(second_output)
重试只解决偶发格式问题,不能修复错误的业务定义。如果失败率持续上升,应查看模型版本、输入分布和 Prompt 版本,而不是继续加重试。
5. 可复验测试
python
def test_valid_json_can_be_parsed() -> None:
result = parse_ticket_result(
'{"category":"物流","urgency":"high",'
'"summary":"包裹超过承诺时间仍未送达","needs_human":true}'
)
assert result.category == "物流"
assert result.needs_human is True
def test_unknown_field_is_rejected() -> None:
invalid = (
'{"category":"物流","urgency":"high","summary":"延迟",'
'"needs_human":true,"refund_amount":9999}'
)
try:
parse_ticket_result(invalid)
except ValueError:
pass
else:
raise AssertionError("未知字段不应被静默接收")
6. 对抗性审查
- JSON 合法不代表内容真实,金额、ID、时间仍需与事实来源核对;
- 不使用正则表达式从大段回答里"抠 JSON",它难以正确处理嵌套和转义;
- 不自动执行模型返回的
action,先映射到服务端白名单; - 对未知字段采用拒绝策略,避免攻击者夹带参数;
- 记录结构校验失败率,但敏感原文应脱敏或不落日志;
- Schema 变更要兼容消费者,并与 Prompt 版本一起发布。
7. 总结
稳定 JSON 不是一句 Prompt 的功劳,而是 Schema、厂商结构化能力、服务端校验和有限失败策略共同形成的闭环。