AI 返回的 JSON 看起来没问题,为什么程序还是报错?从结构化输出到业务校验
让 AI 给前端生成一张任务卡片,乍看很简单:把用户的一段话交给模型,拿到 JSON,再把字段放进页面。
真正接入后,经常遇到的问题是:模型明明返回了内容,页面却显示空白;偶尔可以解析,数据却无法保存;同一段代码昨天正常,今天又因为少了字段报错。
这篇文章用一个Agent 生成任务卡片的例子,把 JSON 解析、结构校验、业务校验、截断、拒绝、重试和前端状态串起来。文末给出一个不调用真实模型、不依赖第三方库的 Python 离线示例,方便先把流程跑通。

一、先看一个看似正常的返回
假设前端期待三个字段:任务标题 title、优先级 priority、截止日期 due_date。
{"title":"整理接口文档","priority":"2","due_date":"2026-11-12"}
这段文字能被 JSON 解析器读出来,却依然可能无法直接进入业务:如果后端要求 priority 是整数,上面的 "2" 是字符串;如果前端根据整数值映射样式,也可能出现展示异常。
再看这一段:
{"title":"整理接口文档","priority":2,"due_date":"2026-11-01"}
假设任务创建日期是 2026 年 11 月 8 日,这段数据语法、字段、类型都正确,但截止日期已经过去。如果业务不允许创建过期任务,就应当在业务校验阶段拦住。
核心区别:JSON 能解析、结构符合约定、符合当前业务,分别是三个不同的问题。
二、从模型到页面,数据会经过哪些环节?
一个可落地的链路通常包括:请求模型、识别响应状态、解析数据、校验结构、验证业务规则、保存或返回前端。调用链中的错误不一定都来自模型,也可能来自接口封装、前后端字段约定或数据库约束。
| 层次 | 要回答的问题 | 例子 | 下一步 |
|---|---|---|---|
| 响应状态 | 请求是否完成? | 拒绝、超时、输出不完整 | 先处理状态,不能假定有完整 JSON |
| JSON 语法 | 能否解析为数据? | 少一个右括号、出现非法字符 | 记录解析失败,按策略处理 |
| 结构约定 | 字段、类型、枚举是否符合要求? | 缺 title,优先级为字符串 | 用 schema 或代码校验 |
| 业务规则 | 数据放进当前业务是否合理? | 截止日期早于创建日期 | 按业务上下文验证 |
| 持久化和展示 | 能否保存、正确展示? | 重复提交、数据库约束失败 | 幂等、事务和前端状态提示 |
例如 priority=2 在结构层面是合法的;但“这个任务真的应该是中优先级吗”,还要看用户输入和业务规则。类型正确不代表内容真实。
三、最常见的五类故障
| 情况 | 示例 | 程序可能出现什么问题 |
|---|---|---|
| 无法解析 | {"title":"整理文档" | JSON 解码报错 |
| 缺失字段 | {"title":"整理文档"} | 后续读取字段时报错 |
| 类型不一致 | "priority":"2" | 比较、运算或映射出现异常 |
| 枚举越界 | "priority":9 | 前端找不到对应标签 |
| 业务不合理 | 日期有效,但早于业务日期 | 入库或后续流程被拒绝 |
还有一种容易被忽视的情况:返回对象有额外字段。是否允许额外字段应由接口契约决定,不能假设所有项目都一样。对于要求严格的接口,额外字段可以主动拒绝;对于兼容性要求更高的场景,也可以采用明确的忽略策略。
四、只靠提示词要求“返回 JSON”够吗?
提示词可以帮助模型理解目标,例如:“只返回包含 title、priority、due_date 的 JSON,优先级为 1、2、3。”但应用仍需要处理完整性、拒绝、解析和业务边界。
如果所用模型和接口支持结构化输出,可以定义 JSON Schema,让成功完成的结构化响应遵循约定的字段和类型。相比单纯要求返回 JSON,它能把结构约束放在接口能力层面。
需要区分两个概念:
| 方法 | 主要约束 | 仍需注意 |
|---|---|---|
| 提示词要求 JSON | 告诉模型输出目标 | 输出可能无法解析或不符合字段要求 |
| JSON 模式 | 主要关注 JSON 格式 | 合法 JSON 不等于符合业务结构 |
| 结构化输出 | 使用受支持的 Schema 约束输出结构 | 模型拒绝、输出不完整、业务真实性仍需处理 |
| 后端业务校验 | 按实际业务上下文核验 | 需要由应用维护规则和异常处理 |
不同厂商、模型和接口的支持范围会变化,接入前应查看对应文档。例如 OpenAI 的说明中明确介绍了结构化输出、拒绝和不完整响应的处理方式:Structured model outputs。不要把其中某一家的字段名直接当成所有模型服务的统一协议。
五、Schema 是合同,业务规则是运行现场
把 Schema 想成前后端约定的数据合同:
title 必填,字符串
priority 必填,整数,只允许 1、2、3
due_date 必填,YYYY-MM-DD 格式字符串
业务规则还要结合现场信息。比如截止日期是否早于当天、某用户是否有权创建任务、任务名称是否在同一项目内重复,都要读取可信的业务状态。
例如模型返回 {"priority":2},我们能验证它是允许的整数;但是无法仅凭这个数字断定优先级符合用户真实意图。对于高影响操作,应该进一步确认或者交给明确的业务规则判断。
建议顺序:先处理响应状态,再解析,再做结构校验,最后结合可信业务数据做验证。
六、拒绝和截断,不要硬塞进 JSON 解析器
模型响应除了成功,也可能被拒绝、在生成过程中结束,或者因为服务和网络问题没有拿到完整结果。接入时先查看服务提供方定义的状态,而不是无条件取一个文本字段并调用 json.loads()。
| 状态 | 应用应考虑的处理方式 |
|---|---|
| 已完成且有输出 | 尝试解析并验证 |
| 拒绝 | 展示可理解的拒绝结果,按产品策略处理;不要无限重试 |
| 不完整或截断 | 记录原因;允许时有限重试,或让用户缩小输入范围 |
| 超时、限流、临时失败 | 区分错误类别,按服务文档与成本预算决定是否重试 |
| 未知状态 | 明确记录并返回失败,不要当作成功 |
**流式输出也有这个问题:**前端收到的一段文本只是片段时,通常还不是完整 JSON。可以展示“正在生成”,但应在收到完成信号后再进行完整解析;如果确实需要边生成边展示结构,需要单独设计增量解析与更新协议。
七、一个完整的离线示例
下面的例子用固定数据模拟不同响应。它不调用任何模型、不连接数据库,也不产生业务副作用;completed、incomplete、refused 是教学用的统一状态,不是某个供应商的原始返回字段。
代码使用 Python 标准库,保存为 json_demo.py 后运行。示例将业务日期固定为 2026 年 11 月 8 日,便于以后重复运行时看到一致结果。
"""离线演示:模拟模型响应,不调用网络、不写入业务系统。Python 3.9+。"""
import json
from datetime import date
REQUIRED = {"title", "priority", "due_date"}
class InvalidOutput(ValueError):
pass
def validate(text, today):
try:
data = json.loads(text, parse_constant=lambda _: (_ for _ in ()).throw(ValueError("非法常量")))
except (ValueError, TypeError) as exc:
raise InvalidOutput("JSON 无法解析") from exc
if not isinstance(data, dict) or set(data) != REQUIRED:
raise InvalidOutput("字段缺失或存在未允许的字段")
if not isinstance(data["title"], str) or not data["title"].strip():
raise InvalidOutput("title 必须是非空字符串")
if type(data["priority"]) is not int or data["priority"] not in (1, 2, 3):
raise InvalidOutput("priority 必须是整数 1、2 或 3")
raw = data["due_date"]
if not isinstance(raw, str):
raise InvalidOutput("due_date 必须是 YYYY-MM-DD 字符串")
try:
parsed = date.fromisoformat(raw)
except ValueError as exc:
raise InvalidOutput("due_date 不是有效日期") from exc
if parsed.isoformat() != raw:
raise InvalidOutput("due_date 必须使用 YYYY-MM-DD 格式")
if parsed < today:
raise InvalidOutput("截止日期早于业务日期")
return data
def create_task(responses, today, max_attempts=2):
"""演示 provider 状态与解析分支;每项响应是模拟数据。"""
for attempt, response in enumerate(responses[:max_attempts], 1):
status = response["status"]
if status == "refused":
return {"state": "refused", "attempts": attempt}
if status == "incomplete":
reason = "响应不完整"
elif status == "completed":
try:
return {"state": "success", "data": validate(response["text"], today), "attempts": attempt}
except InvalidOutput as exc:
reason = str(exc)
else:
return {"state": "failed", "reason": "未知响应状态", "attempts": attempt}
# 真实系统可将脱敏后的错误类别作为下一次生成的反馈;此处不发送网络请求。
return {"state": "failed", "reason": reason if responses and max_attempts > 0 else "没有响应", "attempts": min(len(responses), max_attempts)}
if __name__ == "__main__":
today = date(2026, 11, 8) # 固定业务日期,保证演示可复现
good = '{"title":"整理接口文档","priority":2,"due_date":"2026-11-12"}'
cases = {
"缺字段后修复": [{"status":"completed", "text":"{}"}, {"status":"completed", "text":good}],
"类型错误": [{"status":"completed", "text":'{"title":"整理文档","priority":"2","due_date":"2026-11-12"}'}],
"业务日期错误": [{"status":"completed", "text":'{"title":"整理文档","priority":2,"due_date":"2026-11-01"}'}],
"响应被截断": [{"status":"incomplete"}, {"status":"completed", "text":good}],
"模型拒绝": [{"status":"refused"}],
}
for name, replies in cases.items():
print(name, json.dumps(create_task(replies, today), ensure_ascii=False))
运行:
python json_demo.py
你会看到:缺字段后给出正确响应的模拟场景最终成功;字符串优先级与过期日期被拒绝;截断后提供正确响应的场景成功;模型拒绝被直接标记为 refused。
这里的“重试”只是依次读取预先准备好的模拟响应。**它不代表模型真的根据错误自动修正,也不能作为真实成功率或性能数据。**在真实系统里,要把脱敏后的错误类别传给生成环节、执行下一次请求,并单独处理网络异常、费用和幂等。
八、重试为什么不能无限进行?
对解析失败或结构不符合的结果,可以在满足产品与供应商策略的前提下有限重试:说明哪个字段不符合要求,让模型重新生成。但重试次数应有上限,还要考虑总耗时和费用。
| 问题 | 是否适合直接重试 | 设计建议 |
|---|---|---|
| JSON 无法解析 | 可考虑有限重试 | 提供简短、脱敏的错误类型 |
| 缺字段或类型错误 | 可考虑有限重试 | 明确指出字段约束 |
| 信息不足 | 视场景而定 | 让用户补充信息,不要让模型猜 |
| 模型明确拒绝 | 不应盲目重试 | 给用户展示合理提示 |
| 临时限流 | 视接口政策 | 有上限的退避并遵守服务规则 |
| 业务写入失败 | 不能简单重复整个流程 | 先确认是否已写入,使用幂等键与事务 |
尤其需要注意:**生成成功后调用了外部写操作,再整体重试,可能产生重复任务。**生成和验证可以独立重试;真正写入时应有幂等保护,并以实际业务系统的结果为准。

九、前端应该如何展示?
如果所有异常都显示“AI 出错了”,用户不知道发生了什么,也不知道能否继续。更实用的做法是将后端状态转换为清晰的界面反馈。
| 页面状态 | 用户看到什么 | 可提供的操作 |
|---|---|---|
| 生成中 | 任务正在生成 | 等待或取消,具体取决于系统能力 |
| 成功 | 标题、优先级和截止日期 | 用户确认后再提交 |
| 信息不足 | 需要补充哪些内容 | 编辑并重新提交 |
| 生成失败 | 简洁说明暂时无法完成 | 重试或手动填写 |
| 被拒绝 | 展示适当的拒绝说明 | 修改输入或停止 |
页面不要直接展示原始堆栈、密钥或完整内部提示词;开发排查信息应进入受控日志。对用户展示的失败原因与开发者日志可以采用不同粒度。

十、如何定位一次线上错误?
先别急着改提示词,按链路逐层查:
- 看请求是否成功返回,是否超时或被限流。
- 看响应状态是完成、拒绝还是不完整。
- 看解析是否成功,以及失败的位置和类别。
- 对照接口契约检查字段、类型和枚举。
- 核对业务规则需要的时间、用户和项目状态。
- 确认数据是否已写入,避免重复操作。
- 检查前端处理的是哪一种状态,有没有把失败渲染成空白。
日志可记录请求 ID、模型或服务版本、响应状态、校验类别、耗时和重试次数。真实用户内容是否记录、保存多久、谁能访问,都应遵循项目自己的隐私和数据管理要求;不要把密钥写入日志。
十一、接入生产项目时可以继续补齐什么?
教学示例为了清楚展示流程,使用了标准库手写校验。真实系统可以考虑使用 Pydantic 等工具集中管理字段规则,并生成或维护 Schema。使用 Pydantic 时,还要了解默认类型转换与严格模式的区别:例如业务不允许字符串数字自动转成整数,就需要明确设置严格校验。Pydantic 严格模式文档
此外,还可以补上输入和输出大小限制、字段长度、日期时区规则、敏感内容脱敏、服务端鉴权、幂等键和监控告警。Python 官方也提示,解析不可信 JSON 前应考虑输入规模限制:Python JSON 文档。
不同规则的责任方也要明确:模型负责生成候选内容;后端负责验证和授权;前端负责清晰展示;最终保存结果由业务系统决定。
十二、写在最后
AI 返回了一段 JSON,只表示应用拿到了候选数据。要让它稳定进入产品,需要先区分响应是否完成、内容能否解析、结构是否符合约定,再结合可信上下文检查业务规则。
从最小项目开始,可以先做三件事:定义字段合同、给后端加校验、为前端准备明确的成功和失败状态。之后再根据业务需要增加有限重试、幂等和监控。这样即使模型偶尔给出异常结果,系统也有机会把问题留在边界内,而不是让错误直接传到用户面前。
参考资料
更多推荐

所有评论(0)