登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

结构化输出校验失败时应用层怎么保留原始响应

来源:17golang原创

时间:2026-09-10 09:43:01 324浏览 收藏

结构化输出并不等于应用层可以只保存一个解析后的对象。模型可能拒答,生成也可能在达到上限时提前结束;即使拿到了合法 JSON,字段值仍可能不符合业务规则。若代码只记录“校验失败”,下一次排查时既不知道供应商返回了什么,也无法判断该不该重试。

更稳妥的做法是:请求刚返回就记录请求元数据、状态字段和脱敏后的原始响应;随后依次区分拒答或截断、JSON 解析失败、Schema 校验失败和业务校验失败。原文用于诊断,结构化对象用于业务,两者不要互相替代。

要点速览
  • 保存原始响应的摘要、长度和哈希,必要时再保存受控原文。
  • 先判断 refusal、incomplete、finish_reason,再尝试解析 content。
  • 结构错误、Schema 错误和业务错误分别决定告警、修复或重试。

先把原始响应留在请求边界内

应用层至少应保留四类信息:供应商 request id、模型与 schema 版本、响应状态字段,以及脱敏后的原始响应。日志不必无条件写入完整 prompt;可以保存原文长度、SHA-256、前后截取片段和对象存储引用,让值班人员先确认“是不是同一份响应”。

结构化输出请求边界中请求元数据、供应商响应、脱敏原文、响应摘要、解析器和诊断记录的关系图
图1:请求边界先保留脱敏后的供应商响应,再把同一份上下文交给摘要器和解析器。

下面的示例用一个归一化后的响应字典表示不同供应商的共同字段。真正接入 SDK 时,先把 SDK 对象转换成这样的内部记录。

import hashlib
import json

def snapshot_response(raw_response: dict) -> dict:
    # 只保存诊断需要的元数据;生产环境还应在这里做字段脱敏。
    raw_text = json.dumps(raw_response, ensure_ascii=False, sort_keys=True)
    return {
        "request_id": raw_response.get("id", "unknown"),
        "model": raw_response.get("model", "unknown"),
        "raw_length": len(raw_text),
        "raw_sha256": hashlib.sha256(raw_text.encode("utf-8")).hexdigest(),
        # 原文进入受控存储,不直接拼进普通业务日志。
        "raw_excerpt": raw_text[:1200],
    }

拒答和截断要先于 JSON 解析判断

结构化输出的“校验失败”不一定来自 JSON。若响应带有 refusal,应把它记录为模型拒答;若完成原因表示长度耗尽或 Responses API 状态为 incomplete,则更像一次不完整生成。两种情况都不适合直接把空内容交给 JSON 解析器,否则日志会被一个次生的“JSONDecodeError”带偏。

观察到的证据应用层分类处理建议
refusal 有值拒答记录拒答文本,按业务决定提示用户或换任务
incomplete 或 finish_reason 非正常结束截断/未完成保留原文,检查 token 上限后再有限重试
content 不是合法 JSON结构层失败记录解析位置,检查协议适配和响应原文
JSON 合法但字段不满足约束Schema/业务失败保留对象和错误路径,不要盲目重发

把失败分成三层,重试策略才不会混乱

建议把检查拆成三层。第一层是响应状态:HTTP、refusal、incomplete 和 content 是否存在;第二层是结构:字符串能否解析成 JSON、字段类型是否满足 Schema;第三层是业务:例如订单抽取结果中的金额是否为非负数、日期是否落在允许范围。每一层都写入同一条诊断记录,但错误代码不要混用。

结构化输出从响应状态到拒答截断、JSON 解析、Schema 校验、业务校验和重试决策的分层关系图
图2:四个校验层各自拥有不同的证据和处理动作,不能把所有失败都归为 JSON 解析错误。
def classify_structured_response(response: dict) -> dict:
    # 先判状态,再判结构,避免把拒答或截断误报成 JSON 错误。
    choice = (response.get("choices") or [{}])[0]
    message = choice.get("message") or {}
    if message.get("refusal"):
        return {"kind": "refusal", "retryable": False, "detail": message["refusal"]}
    if response.get("status") == "incomplete" or choice.get("finish_reason") not in (None, "stop"):
        return {"kind": "incomplete", "retryable": True, "detail": choice.get("finish_reason")}

    content = message.get("content")
    if not isinstance(content, str):
        return {"kind": "missing-content", "retryable": False, "detail": "content is absent"}
    try:
        value = json.loads(content)
    except json.JSONDecodeError as exc:
        # 保留错误位置;原文摘要已经在请求边界记录,不在此处重复打印全文。
        return {"kind": "json-parse", "retryable": True, "detail": {"pos": exc.pos}}
    return {"kind": "json-ready", "retryable": False, "value": value}

让日志既能重放,又不变成敏感数据仓库

保留原始响应时要同时做三件事:为 prompt、用户输入、邮箱、手机号和 token 做脱敏;给每份原文设置长度上限和过期时间;把 request id、schema 版本、错误路径、哈希和重试次数放到结构化字段中。这样开发者能用哈希把应用日志与受控原文对上,却不会让普通日志检索系统承载整段上下文。

重试也要有边界:截断可以在提高输出上限或缩短输入后重试一次;协议解析失败可以检查 SDK 适配;业务校验失败通常应进入人工或补偿队列;拒答则不应靠无限重发规避。最后把诊断记录和业务结果分开存储,避免一次脏响应覆盖上一条成功结果。

常见问题

只保存解析后的 Pydantic 或 Zod 对象可以吗?

不建议。成功对象适合业务使用,但失败时它根本不存在;至少要保存 request id、状态字段、错误路径和原文摘要。

Schema 校验失败要不要立刻重试?

先看失败层次。截断或临时传输异常可以有限重试,业务规则不满足时应先修正输入或进入补偿流程。

为什么要保存原文哈希?

哈希能在不扩散完整响应的前提下确认日志、对象存储和告警引用的是同一份数据,也方便后续去重。

真正可维护的结构化输出链路,不是“让模型永远不出错”,而是让每次异常都留下足够证据:发生在哪一层、原响应是哪一份、下一步是修请求、改 Schema、重试,还是交给业务处理。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>