Gemini API 解析失败怎么兜底

2026-11-06 16:52 更新

Gemini API 结构化输出的失败分三层:不是 JSON、JSON 合法但字段不合、API 报错;第一层可重试一次,中间一层重试无效,应降级并记录原始输出。(核验 2026-11-05,F17、F27)

一、Gemini API 解析失败怎么兜底:问题不是重试几次,而是先分清哪一层失败

线上抓到一个 ValueError: Expecting value: line 1 column 1,多数人的第一反应是加 for 循环,重试三次,每次把同一个请求原样再发一遍。三次都失败,抛出去,用户看到一个空白。

这套做法的问题不在于「重试」本身,而在于它把三种完全不同的失败当成了一种:

  • 第一层,字符串不是 JSON。 拿到的文本里有解释、有围栏、或者干脆是个道歉句,json.loads 直接抛异常。
  • 第二层,JSON 合法但字段不合。 能解析,但 required 里的键缺了,或者 priority 给了个业务上不存在的值。
  • 第三层,API 层报错。 请求本身出问题,服务端返回错误码。官方为「输出结构有问题」准备的结构性错误码,是 malformed_function_call、malformed_tool_call 这一类(F27),它们针对的是函数调用与工具调用,不是「你的 JSON 解析失败了」——因为 JSON 解析失败发生在你的代码里,不在 API 里。

分不清这三层,重试就是在赌。语法层的失败有随机性,重发一次确实可能变成合法 JSON;字段层的失败往往是因为信息不足,同一个请求发十次,缺的字段还是缺。官方在最佳实践里也提醒过:输出「语法正确」不等于「值可用」,语义校验永远在应用侧(F17)。

二、Gemini API 降级状态机:最小可运行示例

下面这段把三层压成一个两层状态机:首发一次、受控重试一次、然后降级。降级不是抛异常,是返回一个下游能用的结果。

## EXPECT: 第 1 次:解析失败,重试 | 第 2 次:校验失败,降级 | 最终状态=degraded | 失败样本=2 条
import json

SCHEMA = {"required": ["ticket_id", "title", "priority"]}
MAX_ATTEMPTS = 2   # 首发 1 次 + 受控重试 1 次

RAW_1 = '好的,抽取结果如下:{"ticket_id": "TKT-A", "title": "导出报表超时"}'
RAW_2 = '{"ticket_id": "TKT-A", "title": "导出报表超时"}'

def check(raw, schema):
    try:
        data = json.loads(raw)
    except ValueError as exc:
        return "parse_error", str(exc)[:40]
    missing = [k for k in schema["required"] if k not in data]
    if missing:
        return "invalid", missing
    return "ok", data

def pipeline(raws, schema):
    assert len(raws) == MAX_ATTEMPTS, "准备与上限同样份数的输出,避免『说了重试却没重试』"
    trace, samples = [], []
    attempt = 1
    while attempt <= MAX_ATTEMPTS:
        raw = raws[attempt - 1]
        state, detail = check(raw, schema)
        samples.append({"attempt": attempt, "state": state, "detail": detail, "raw": raw})
        if state == "ok":
            trace.append(f"第 {attempt} 次:通过")
            return "ok", trace, samples
        label = "解析失败" if state == "parse_error" else "校验失败"
        if attempt == MAX_ATTEMPTS:
            trace.append(f"第 {attempt} 次:{label},降级")
            return "degraded", trace, samples
        trace.append(f"第 {attempt} 次:{label},重试")
        attempt += 1
    return "degraded", trace, samples

status, trace, samples = pipeline([RAW_1, RAW_2], SCHEMA)
assert status == "degraded" and len(samples) == 2
print(" | ".join(trace) + f" | 最终状态={status} | 失败样本={len(samples)} 条")

注意第二次的 RAW_2:语法完全合法,只是缺 priority。如果状态机只有「能解析/不能解析」两态,这一条会被判成功,然后带着缺字段进数据库。校验失败必须单独算一态,它是本节最重要的一个分支。

三、解释与边界:为什么只重试一次

语法层重试有效,是因为它有随机性。 同一请求两次返回不一定一样,第一次带了解释文字,第二次可能就干净了。所以第一次失败后追加一句更强硬的约束(例如「只输出 JSON,不要任何说明」)再发,是合理的动作。

字段层重试基本无效,因为缺的是信息,不是运气。 用户原话里没写优先级,重试一百次也变不出来。这类失败该走降级:把「模型给不出的部分」标成待人工补充,而不是把整条工单丢掉。

上限是一次的理由:每多一次重试,延迟和成本都乘以轮数,而语法层的成功率在第二次之后几乎没有边际收益。真正需要多次尝试的场景(比如带反馈的自我修复)属于另一个问题,不是本节说的兜底。这个上限要写在配置里,别散落在各个调用点。

降级必须可交付。 最常见的错误降级是返回 {} 或 None,下游以为成功,把空工单入了库。可用的降级至少要保留三样东西:模型原始输出、失败原因(解析还是校验)、以及一个明确的「需人工处理」标记。

降级要有出口,不只是有个标记。 标了「需人工」却没人看,等于把故障从「接口报错」挪到了「数据脏了」,只是变得更安静。落地时至少定三件事:谁看(值班人还是工单池)、多久看一次(按小时还是按天)、看到之后怎么补(照着原文重填,还是退回给用户补信息)。这三件事没定,降级分支就只是把异常换成了沉默。

重试要算成本账。 一次调用两次尝试,最坏情况是双倍延迟与双倍 token;如果这个接口在用户的同步链路上,延迟翻倍比失败更容易被投诉。所以「重试一次」的默认值不是拍脑袋,而是权衡:语法层的失败率通常低,第二次能救回来的比例有限,再往上加次数的收益迅速趋零。反过来,如果是离线批处理,延迟不敏感,把上限放到三次也无妨——判断依据是这条链路在不在用户等待路径上。

失败样本要能复现问题。 只记「失败」两个字没有价值;把 raw 原文、尝试次数、失败态,以及 detail(缺了哪些字段、解析错在哪)一起写进日志,才有下一次调试的入口。上面的 samples 里存的正是这四项——少了 detail,你只能看到「失败了」,看不到「为什么」。注意原文里可能带用户隐私,落盘前先脱敏,口径见 12.2。

别让「重试」变成一句空话。 代码里那句 assert len(raws) == MAX_ATTEMPTS 不是防御性编程的洁癖:传入的输出比上限少时,状态机会打印「重试」然后直接退出,日志上写着重试过,实际上根本没有第二次请求。这类「日志与事实不符」比失败本身更难查,所以要在入口就把数量对上。

边界。 429、402 这类传输层错误不在这里处理,分流见 11.1;流式输出流内报错见 11.3。本节只管「已经拿到一段文本,但它不能变成可用对象」这一种情况。

四、预期输出与异常分支

真跑输出:

第 1 次:解析失败,重试 | 第 2 次:校验失败,降级 | 最终状态=degraded | 失败样本=2 条

六类分支。重试时发同一份请求:等于把随机性赌两次,第二次的输入没有任何新信息;正确的重试要带纠正(更强硬格式要求,或指出缺哪个字段)。把校验失败当解析失败重试:字段缺失不会因为重发而补齐,白花一次调用。降级返回空对象:最危险的一类,接口 200、对象为空,监控看不出异常,账对不上才发现。无限修复循环:喂回错误让模型自己改,看起来聪明,实际会在「改出新错」和「改回原样」之间来回,必须有硬上限。只记状态不记原文:三天后想复现,只能靠猜。降级率突然抬头:单日降级率从个位数跳到两位数,通常不是模型变差,而是你的 schema 或提示词刚改过;先去比对最近一次变更,别急着调模型。这一类要配阈值告警,否则只能等对账时才发现。

五、判据卡:重试几次该停

该这么做 不该这么做
解析失败重试一次,并带纠正提示 同请求原样重发三次
校验失败直接降级,标「需人工」 把缺字段当运气问题重试
降级保留原文+原因+标记 返回 {} 让下游以为成功
失败样本记原文、次数、状态 日志里只写「解析失败」

六、来源与核验

  • https://aistudio.google.com/docs/api-errors.md(核验 2026-11-05,F27)
  • https://aistudio.google.com/docs/structured-output.md(核验 2026-11-05,F17)

「结构性错误码只覆盖函数调用与工具调用」这一事实属于「API 结构」档,复核周期三个月,见 1.3。若官方后续为 JSON 输出新增专门的错误码,本节的三层分法要跟着改。

七、动手试

  1. 把 RAW_1 换成一个干净但缺字段的 JSON,重跑。预期第一次就是「校验失败,重试」,第二次同样「校验失败,降级」;验收标准是你能说清「为什么这次重试注定无效,但代码仍允许它发生」。
  2. 把 MAX_ATTEMPTS 改成 3,并把 RAW_2 换成第三条同样缺字段的 JSON。预期最终仍 degraded、失败样本 3 条;验收标准是给出一个「重试次数」与「成本」的换算,说明为什么默认值是 2。
  3. 打开你项目里的降级分支,看它返回什么。验收标准:能指出返回值里是否同时有原文、原因、人工标记;三样缺一样,就在下一次发布里补上。
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号