Nano Banana 2.1 没有返回图片?重试前先检查响应

Nano Banana 2.1 返回成功却没有图片?检查 inlineData、结束原因、HTTP 错误与超时状态,附离线诊断代码,区分解析问题、生成失败和不应盲目重试的情况。

浅色卡纸上的齿轮黑色线稿、几何点缀和 Nano Banana 2.1 标题。

HTTP 请求成功,不等于响应中包含图片。使用 Gemini 生成图片时,图片会包含在候选项的 content.parts[].inlineData(或 inline_data)中,也可能与文本部分一同返回。如果应用只读取 response.text,即使响应里实际包含图片,也可能报告“没有图片”。

本指南适用于 Ofox 模型 ID google/gemini-nano-banana-2.1。本文由 Ofox 发布,文中所述测试使用的网关也由 Ofox 运营。以下说明会区分该网关在这次测试中返回的内容与 Google 通常记录的文档信息;不会假设每次请求失败的原因都相同。

首先确认响应中是否包含图片

对于原生 Gemini 请求,请检查每个候选项的各个部分,查找包含图片 MIME 类型且 data 非空的 inlineData 对象。不要把文本字段为空或缺失,当作生成失败的证据。决定下一步操作前,也要记录 HTTP 状态码、候选项的 finishReason 和 promptFeedback。

以下是 2026 年 10 月 13 日 Ofox 网关一次成功测试的脱敏结构摘录,不是图片字节的副本:

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "inlineData": {
              "mimeType": "image/png",
              "data": "[base64 image bytes omitted]"
            }
          }
        ]
      },
      "finishReason": "STOP"
    }
  ]
}

此次测试的请求返回 HTTP 200 和一张 1024 × 1024 的 PNG 图片。摘录省略了响应中的其他内容;测试记录的用量包括 54 个输入 token、用量元数据报告的 80 个文本输出 token、1,120 个图片输出 token,以及 639 个思考 token。这些数字描述的是这次请求,并不代表响应必须采用这种结构,也不代表固定费用。Google 的图片生成文档介绍了包含图片的响应部分;其模型文档则介绍了该模型的能力。

发起一次便于检查的请求

在环境中设置 OFOX_API_KEY,然后发送测试中使用的完整海报提示词。模型 ID 很重要:google/gemini-nano-banana-2.1 不是旧版 google/gemini-3.1-flash-image,也不是“Lite”变体。

cat > request.json <<'JSON'
{
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "Create a square minimalist event poster on warm cream paper. Large exact headline: ORCHARD MARKET. Smaller exact line: SATURDAY 10 AM. Draw three red apples in a blue ceramic bowl below the text. No other text, no logos, no border."
        }
      ]
    }
  ],
  "generationConfig": {
    "responseModalities": ["TEXT", "IMAGE"],
    "imageConfig": {
      "aspectRatio": "1:1",
      "imageSize": "1K"
    }
  }
}
JSON

curl --silent --show-error \
  --output response.json \
  --write-out 'HTTP %{http_code}\n' \
  -H "x-goog-api-key: ${OFOX_API_KEY:?Set OFOX_API_KEY first}" \
  -H 'Content-Type: application/json' \
  --data-binary @request.json \
  'https://api.ofox.io/gemini/v1beta/models/google/gemini-nano-banana-2.1:generateContent'

curl 打印的 HTTP 状态码很重要:请将它与响应一并保存,不要因为存在 response.json 就认定调用成功。请勿将包含机密提示词或图片数据的请求、响应文件放入公开日志或代码仓库。如需了解此示例以外的端点设置,请参阅 Nano Banana 2.1 API 指南。

更改提示词前,先运行离线诊断

将以下内容保存为 diagnose_gemini_image.py。它会读取已保存的 JSON 响应,不会调用 API。使用 --http-status 参数传入客户端记录的 HTTP 状态码。如果客户端超时,且没有可用响应,则改用 --timeout。输出内容有意限制为诊断字段:它不会打印密钥、原始错误消息、提示词或 base64 图片数据。

#!/usr/bin/env python3
import argparse
import json
import sys
from pathlib import Path

def field(obj, camel, snake):
    if not isinstance(obj, dict):
        return None
    return obj.get(camel, obj.get(snake))

def safe_feedback(obj):
    if not isinstance(obj, dict):
        return None
    ratings = field(obj, "safetyRatings", "safety_ratings")
    result = {"blockReason": field(obj, "blockReason", "block_reason")}
    if isinstance(ratings, list):
        result["safetyRatings"] = [
            {
                "category": r.get("category"),
                "probability": r.get("probability"),
            }
            for r in ratings if isinstance(r, dict)
        ]
    return result

def diagnose(body, http_status=None, timed_out=False):
    # Accept either a native response or {"body": <native response>}.
    if isinstance(body, dict) and isinstance(body.get("body"), dict):
        if http_status is None:
            http_status = body.get("status_code", body.get("httpStatus"))
        body = body["body"]

    result = {
        "status": "no_image",
        "httpStatus": http_status,
        "imagesCount": 0,
        "finishReasons": [],
        "promptFeedback": safe_feedback(
            field(body, "promptFeedback", "prompt_feedback")
        ),
    }

    if timed_out:
        result["status"] = "transport_uncertain"
        return result

    if http_status is not None and not 200 <= http_status < 300:
        result["status"] = (
            "http_error_check_retryability"
            if http_status == 429 or 500 <= http_status <= 599
            else "http_error"
        )
        return result

    candidates = body.get("candidates", []) if isinstance(body, dict) else []
    if not isinstance(candidates, list):
        candidates = []

    for candidate in candidates:
        if not isinstance(candidate, dict):
            continue
        reason = field(candidate, "finishReason", "finish_reason")
        if reason is not None:
            result["finishReasons"].append(reason)
        content = candidate.get("content")
        parts = content.get("parts", []) if isinstance(content, dict) else []
        if not isinstance(parts, list):
            continue
        for part in parts:
            inline = field(part, "inlineData", "inline_data")
            if not isinstance(inline, dict):
                continue
            mime = field(inline, "mimeType", "mime_type")
            if (isinstance(mime, str) and mime.startswith("image/")
                    and isinstance(inline.get("data"), str)
                    and inline["data"]):
                result["imagesCount"] += 1

    if result["imagesCount"]:
        result["status"] = "image_present"
    elif (result["promptFeedback"] or {}).get("blockReason"):
        result["status"] = "blocked"
    elif any(
        r in {"SAFETY", "PROHIBITED_CONTENT", "SPII", "RECITATION"}
        for r in result["finishReasons"]
    ):
        result["status"] = "blocked"
    elif "MAX_TOKENS" in result["finishReasons"]:
        result["status"] = "max_tokens_no_image"
    return result

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("response_json", type=Path, nargs="?")
    parser.add_argument("--http-status", type=int)
    parser.add_argument("--timeout", action="store_true")
    args = parser.parse_args()
    if args.timeout:
        print(json.dumps(diagnose({}, args.http_status, timed_out=True)))
        return 0
    if args.response_json is None:
        parser.error("Provide response_json unless using --timeout")
    try:
        body = json.loads(args.response_json.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError):
        print("Cannot read a valid JSON response", file=sys.stderr)
        return 2
    print(json.dumps(
        diagnose(body, args.http_status, args.timeout),
        separators=(",", ":")
    ))
    return 0

if __name__ == "__main__":
    sys.exit(main())

例如,运行 python3 diagnose_gemini_image.py response.json --http-status 200,即可查看是否找到了图片部分。imagesCount 统计的是声明存在且内容非空的图片部分;它不会解码数据,也不能确认图片文件有效。如果提取失败,请保留原始响应,以便在本地进一步检查。

你可以在不消耗 API 额度的情况下检查诊断脚本的分支逻辑。以下是人工构造的测试数据,不是对模型的模拟测试:

printf '%s\n' \
  '{"candidates":[{"content":{"parts":[{"inline_data":{"mime_type":"image/png","data":"aGVsbG8="}}]},"finish_reason":"STOP"}]}' \
  > fixture-image.json
python3 diagnose_gemini_image.py fixture-image.json --http-status 200
# {"status":"image_present","httpStatus":200,"imagesCount":1,"finishReasons":["STOP"],"promptFeedback":null}

printf '%s\n' \
  '{"candidates":[{"finishReason":"MAX_TOKENS","content":{"parts":[]}}]}' \
  > fixture-limit.json
python3 diagnose_gemini_image.py fixture-limit.json --http-status 200
# {"status":"max_tokens_no_image","httpStatus":200,"imagesCount":0,"finishReasons":["MAX_TOKENS"],"promptFeedback":null}

测试数据中的 aGVsbG8= 只是非空的 base64 文本,不是 PNG。这一区别很重要:检测到图片部分只是第一步;接下来还要解码并验证文件。

如果诊断结果发现了图片,请提取它,而不是重试生成。这个小型本地提取脚本会保存所有图片部分,并且不会打印图片内容:

import base64
import json
from pathlib import Path

response = json.loads(Path("response.json").read_text(encoding="utf-8"))
saved = 0

for candidate in response.get("candidates", []):
    for part in candidate.get("content", {}).get("parts", []):
        inline = part.get("inlineData", part.get("inline_data", {}))
        mime = inline.get("mimeType", inline.get("mime_type", ""))
        data = inline.get("data")
        if not (mime.startswith("image/") and data):
            continue
        extension = {"image/png": ".png", "image/jpeg": ".jpg"}.get(mime)
        if extension is None:
            raise ValueError(f"Inspect unsupported image MIME type: {mime}")
        image = base64.b64decode(data, validate=True)
        saved += 1
        Path(f"image-{saved}{extension}").write_bytes(image)

print(f"Saved {saved} image(s)")

此示例明确支持 PNG 和 JPEG,不会为每种 MIME 类型都猜一个文件名。如果解码失败,请保留 HTTP 状态码和响应元数据,但不要把 base64 内容贴到支持工单或公开问题中。

如何判断没有图片的响应

下一步操作取决于请求在哪个环节失败。Google 的 API 故障排查指南提供了通用错误处理建议;下表将这些建议整理为适用于此次网关调用的实际排查顺序,不会把每种错误都视为可重试错误。

观察到的情况优先检查建议操作
400请求 JSON、responseModalities、imageConfig 和字段名称修正无效参数或请求内容。原样重复相同请求很可能无济于事。
401网关密钥是否已传入 x-goog-api-key,以及密钥是否有效修复身份验证问题,同时避免在日志中暴露密钥。
403账户或模型权限,以及返回的错误分类解决访问权限或政策限制;不要把它归类为暂时性图片生成失败。
404准确的端点和模型 ID确认路径使用 google/gemini-nano-banana-2.1。过去某个日期针对其他 ID 的 404 报告,不能证明该模型目前不可用。
429错误信息和账户状态中显示的是速率限制,还是额度/配额问题只有属于可重试的速率限制时才退避重试。额度耗尽或需要处理的账户限制,不会因增加重试次数而解决。
5xx网关/服务器状态,以及是否存在请求记录或处理结果可以考虑有限次数的重试,但首先要判断请求是否可能已经处理。
客户端超时或连接中断本地请求 ID、可用时的网关请求记录,以及是否保存了响应结果尚不确定。不能仅仅因为客户端没有收到响应,就认定“没有生成图片”或“没有计费”。

如果状态码为 200,但 finishReason 为 STOP 且没有图片部分,应检查所有候选项和各部分、请求的模态,以及保存的 JSON。如果状态码为 200,但 finishReason 为 MAX_TOKENS 且没有图片,则是另一种线索:输出可能在图片送达前就已结束。Google 开发者论坛上的一则用户报告描述了这一现象;这是一则未经验证的用户报告,并非我们在这里的六次成功请求中复现的故障。报告的 token 数低于配置上限,本身并不能证明模型存在 bug;在得出结论前,需要核查各项限制、响应计量方式,以及相关计数器的具体含义。

如果 promptFeedback 显示拦截原因,或候选项因安全相关原因结束,请将其视为政策判定。请检查请求是否符合适用的 Gemini API 条款;不要用重试或改写提示词的方式规避限制。如需了解设置细节,请参阅 API 指南。

谨慎重试,并在后续轮次中保留上下文

不要把所有未返回图片的结果都放进自动重试循环。先解决 400、401、403 和 404 的原因。对于已确认可重试的 429 或 5xx,应将尝试次数限制在较小范围内,逐次增加延迟并加入抖动,同时遵循客户端所支持的服务器重试指引。发生超时后,如果可能,应先检查请求记录再重新提交:响应丢失会让生成和计费状态都变得不确定。定价指南解释了为什么额外生成一张图片并非免费,即使第一张并非你想要的结果。

在编辑场景中,“重试”和“后续轮次”也是两种不同操作。后续轮次必须包含先前的对话内容,包括模型返回的内容部分。不要只根据可见文本重建上一条模型消息:这样可能会丢失图片和思考签名。经过测试的三步链路,在每次新的用户轮次之前,都原样传入了此前的 candidates[0].content。后续提示词如下:

Change only the headline from ORCHARD MARKET to ORCHARD FAIR. Keep SATURDAY 10 AM exactly unchanged. Preserve the three red apples, blue bowl, cream background, composition and all other pixels as closely as possible.

Now change only the bowl from blue to dark green. Preserve ORCHARD FAIR, SATURDAY 10 AM, the three red apples and the layout.

在 Python 中,关键的历史记录操作如下:

# previous_response is the complete parsed response from the preceding turn.
model_content = previous_response["candidates"][0]["content"]
contents.append(model_content)  # Keep its parts and signatures intact.
contents.append({
    "role": "user",
    "parts": [{"text": next_prompt}]
})

添加下一轮对话前,请先保存更新后的响应。如果先前的响应遭到拦截、不完整,或缺少编辑所需的内容,就不要把它说成等同于正常的对话延续。有关编辑预期的说明,包括“保留”能保证和不能保证的内容,请参阅海报编辑操作指南和较早的产品保留指南。多参考图工作流的取舍另见单张还是两张产品参考图。

成功测试说明了什么,又没有说明什么

2026 年 10 月 13 日,通过 Ofox 的原生 Gemini 路由发出的六次实际请求,全部返回 HTTP 200、STOP 和一张 1024 × 1024 的 PNG 图片:其中三次使用 Nano Banana 2.1,分别生成海报、编辑标题,以及后续修改碗的颜色;另外三次使用较旧的 Nano Banana 2,执行相同的三种提示词。2.1 的输出在编辑过程中明显保留了所要求的英文文字和物体。这些是有参考价值的成功响应示例,并不代表复现了故障、测出了可靠性,也不是生产质量排名;测试没有重复进行,而且两个模型可能使用不同默认设置。2.1 与 2 的对比讨论了这项有限的对比。

检查时,公开的 Ofox Nano Banana 2.1 模型页面列出了该模型。该公开页面只能作为模型可用性的证据,不能证明需要登录的 Playground 曾成功生成结果;也没有测试经过身份验证的 Agent 中的模型选择。反过来,在该路由尚未开放之前出现的历史 404,也不能推翻后来观察到的 2.1 响应。请检查当前模型 ID 和你自己的访问权限,不要拿一种观察结果替代另一种。

排查问题时,最后还要注意费用:测试中生成的 1K 2.1 海报报告了 1,120 个图片输出 token。按观察到的目录费率——每百万个图片输出 token 收费 $30——仅该图片部分的费用将为 $0.0336,这不是经过核实的实际账单总额;输入、文本、思考及其他适用费用还可能增加总额。调用超时,或应用未能解析响应,都不能证明没有产生可计费的工作。先保存状态码和诊断字段,再判断是否值得发起新请求。