用 Ofox 调用 Nano Banana 2.1:生成、保存并编辑图片
Nano Banana 2.1 怎么接入 Ofox?核对准确模型 ID,用完整 Python 示例生成、保存图片并连续修改,区分官方能力、实测结果与界面限制。
更新于 2026 年 10 月 13 日。 Ofox 既是本指南的发布方,也是文中所述请求使用的 API 网关。本文由 AI 辅助撰写,根据真实 API 和工具结果进行核对,并在发布前经过独立 AI 审阅。下文讨论的六次图片请求,均是通过 Ofox 发出的真实原生 Gemini 协议请求;示例程序供你自行发起请求,并不表示测试过所有可能的路由或设置。
Nano Banana 2.1 在 Ofox 上的模型 ID 是 google/gemini-nano-banana-2.1。本文用来比较的旧模型是 google/gemini-3.1-flash-image,通常称为 Nano Banana 2。迁移请求时,不要用旧 ID 替换新 ID。Ofox 公开模型页列出了 2.1 支持 Gemini 和 OpenAI 协议,并标明其提供方为 Vertex。本指南中实际运行的请求使用的是原生 Gemini generateContent 路由,而不是 OpenAI 兼容路由。
你将生成一张海报,把返回的图片数据写入磁盘,还可以把模型的完整响应传入后续两轮编辑。另有一个上传选项,展示如何提供已有的本地图片。若想专门了解编辑与版式决策,请参阅海报编辑指南。
发起第一个请求前的准备
你需要一个有权访问该模型的 Ofox API 密钥、足够的账户余额、Python 3 和 requests 包。安装依赖,并在 shell 中设置密钥:
python -m pip install requests
export OFOX_API_KEY="YOUR_OFOX_API_KEY"
不要把密钥放进源文件、截图或共享日志中。经验证的请求使用了以下 API 端点:
POST https://api.ofox.io/gemini/v1beta/models/google/gemini-nano-banana-2.1:generateContent
x-goog-api-key: YOUR_OFOX_API_KEY
下面的请求会同时要求文本和图片两种响应模态、正方形宽高比,以及 1K 图片尺寸。Google 图片生成文档介绍了 1K、2K 和 4K 选项;本文通过网关实际验证的只有 1K。不要把文档所述选项视为已经通过你所用的路由全部测试。512 不是该模型文档列出的尺寸选项。
完整的 Python 程序
将下面的代码保存为 nano21.py。默认运行会发起一次生成请求。添加 --chain 会先发起该请求,再发送测试中使用的两条原样编辑提示词。添加 --upload PATH 则会用指定的本地图片发起新请求,并附上修改标题的提示词;如果想让这条提示词有意义,请使用原始海报图片。这两个选项不能同时使用。
import argparse
import base64
import binascii
import json
import mimetypes
import os
from pathlib import Path
import sys
import requests
URL = (
"https://api.ofox.io/gemini/v1beta/models/"
"google/gemini-nano-banana-2.1:generateContent"
)
PROMPTS = [
"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.",
"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.",
]
EXTENSIONS = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}
def send(contents, api_key):
body = {
"contents": contents,
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {
"aspectRatio": "1:1",
"imageSize": "1K",
},
},
}
try:
# Deliberately one attempt: image requests are not automatically retried.
response = requests.post(
URL,
headers={"x-goog-api-key": api_key},
json=body,
timeout=(15, 180), # connection timeout, then read timeout
)
except requests.RequestException as exc:
raise RuntimeError(f"Request failed before a usable response: {exc}") from exc
try:
data = response.json()
except ValueError as exc:
raise RuntimeError(
f"HTTP {response.status_code}; non-JSON response: "
f"{response.text[:1000]}"
) from exc
if not response.ok:
error = data.get("error", data) if isinstance(data, dict) else data
raise RuntimeError(
f"HTTP {response.status_code}; API error: "
f"{json.dumps(error, ensure_ascii=False)[:2000]}"
)
if not isinstance(data, dict):
raise RuntimeError("Successful HTTP response was not a JSON object.")
return data
def save_response(data, output_dir, turn_number):
candidates = data.get("candidates") or []
if not candidates or not isinstance(candidates[0], dict):
raise RuntimeError(f"No candidate returned: {json.dumps(data)[:2000]}")
candidate = candidates[0]
finish_reason = candidate.get("finishReason")
if finish_reason not in (None, "STOP"):
raise RuntimeError(
f"Generation did not finish normally ({finish_reason}): "
f"{json.dumps(candidate, ensure_ascii=False)[:2000]}"
)
# Keep this original object intact for the next turn. In particular, do not
# rebuild its parts from printed text and saved image files.
content = candidate.get("content")
if not isinstance(content, dict):
raise RuntimeError("First candidate has no content object.")
output_dir.mkdir(parents=True, exist_ok=True)
image_count = 0
for part in content.get("parts", []):
if "text" in part and part["text"]:
print(f"text: {part['text']}")
inline = part.get("inlineData")
if not inline:
continue
mime = inline.get("mimeType", "").split(";", 1)[0].lower()
if not mime.startswith("image/"):
continue
encoded = inline.get("data")
if not isinstance(encoded, str):
raise RuntimeError("Image part has no base64 string.")
try:
image_bytes = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise RuntimeError("Image part contains invalid base64.") from exc
if not image_bytes:
raise RuntimeError("Image part decoded to zero bytes.")
image_count += 1
extension = EXTENSIONS.get(mime, ".bin")
path = output_dir / f"turn-{turn_number}-image-{image_count}{extension}"
path.write_bytes(image_bytes)
print(f"saved image: {path} ({mime})")
if not image_count:
raise RuntimeError(
"Response contained no image inlineData; inspect the returned "
"candidate and any text instead of treating text as an image."
)
if "usageMetadata" in data:
print("usageMetadata:", json.dumps(data["usageMetadata"]))
return content
def uploaded_image_part(path):
mime, _ = mimetypes.guess_type(path.name)
if mime not in EXTENSIONS:
raise ValueError("Upload a .png, .jpg, .jpeg, or .webp image.")
image_bytes = path.read_bytes()
if not image_bytes:
raise ValueError("Upload file is empty.")
return {
"inlineData": {
"mimeType": mime,
"data": base64.b64encode(image_bytes).decode("ascii"),
}
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--chain", action="store_true")
parser.add_argument("--upload", type=Path)
parser.add_argument("--output-dir", type=Path, default=Path("outputs"))
args = parser.parse_args()
if args.chain and args.upload:
parser.error("--chain and --upload are separate workflows")
api_key = os.environ.get("OFOX_API_KEY")
if not api_key:
parser.error("Set OFOX_API_KEY in the environment first")
if args.upload:
if not args.upload.is_file():
parser.error(f"Image file does not exist: {args.upload}")
# A new conversation with an uploaded image, not the earlier
# generation/edit conversation.
contents = [{
"role": "user",
"parts": [
uploaded_image_part(args.upload),
{"text": PROMPTS[1]},
],
}]
result = send(contents, api_key)
save_response(result, args.output_dir, 1)
return
contents = []
selected_prompts = PROMPTS if args.chain else PROMPTS[:1]
for turn_number, prompt in enumerate(selected_prompts, start=1):
contents.append({"role": "user", "parts": [{"text": prompt}]})
result = send(contents, api_key)
model_content = save_response(result, args.output_dir, turn_number)
# Preserve the full returned content, including inline image data,
# text, and any signatures on parts, for the next request.
contents.append(model_content)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, RuntimeError) as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
运行 python nano21.py 发起一次调用。若要运行全部三轮,执行 python nano21.py --chain。若要在一个新对话中编辑磁盘上已有的海报,执行 python nano21.py --upload outputs/turn-1-image-1.png。这些是上文程序的 Python 调用示例,不是 Ofox 专用 CLI 命令。
如果响应中包含图片,预期会看到类似 saved image: outputs/turn-1-image-1.png (image/png) 的一行,以及可能返回的文本和 usageMetadata。这行内容是输出格式示例,不是这段脚本另一次运行的记录。在经验证的海报请求中,响应状态为 HTTP 200,结束原因为 STOP,包含一张 1024 × 1024 的 PNG 图片,并报告 54 个输入 token、80 个文本输出 token、1,120 个图片输出 token 和 639 个思考 token。原始响应中没有可见的文本部分,尽管它报告了 80 个 TEXT 输出 token。请把这个数字视为返回的用量元数据,而不是可见文本。你实际收到的文本、元数据、耗时和图片都可能不同。
代码会保存第一个候选中的每个图片 inlineData 部分,不会假定第一部分就是图片。代码会根据响应中的 MIME 类型选择文件扩展名;如果图片 MIME 类型无法识别,就保存为 .bin,而不会错误地标成 PNG。代码会尽可能把 HTTP 错误解析为 JSON;如果缺少图片,则视为失败,而不是成功。程序不会自动重试:超时后盲目重复生成,可能会生成另一张图片并再次产生费用。如果请求失败,请检查其状态、错误 JSON 和账户状态,再决定是否重新提交。Google 故障排查指南有助于理解 Gemini 侧的错误类别,但 Ofox 网关响应仍需根据其自身内容判断。
为什么编辑历史必须包含完整响应
保存的 PNG 是输出产物,不能替代对话历史。在 --chain 工作流中,每个后续请求都会包含先前的用户轮次、模型完整返回的 candidates[0].content,然后才是新的用户轮次。该内容可能包含文本、内嵌图片数据,以及各部分的思考签名。仅凭图片文件和可见文本重建它,可能会丢失后续有效跟进所需的数据。
这也是上传路径与连续编辑流程分开的原因。--upload 会将本地文件的 MIME 类型和 base64 字节作为 inlineData 部分,与指令一起发送。它不会把上传文件伪装成模型先前的一轮响应,也不会重建该响应的签名。其他产品保留相关任务,请参阅蒙版与产品保留工作流和使用一张还是两张场景参考图。
“只改……”是一种明确的编辑指令,不是像素锁定功能。在我们的三步测试中,2.1 和旧模型都在第一张海报上生成了要求的英文标题和时间;编辑时,两者都把标题改成了 ORCHARD FAIR,同时保留了 SATURDAY 10 AM、三个红苹果和一个蓝碗。第三轮中,两者都把碗改成了深绿色,并保留了要求的文字。两个模型的输出都没有显示可编辑图层。尽管提示词要求“无边框”,构图仍将海报放在带有外侧边距或阴影的纸张上。正式采用编辑结果前,请检查实际返回的图片,尤其是在精确文字、产品细节或版式很重要时。
每个模型都进行了一条三步编辑链测试,使用相同的提示词、1K 和 1:1 配置,且未设置 seed 或思考级别。六次请求均返回 HTTP 200 和 STOP,并生成 1024 × 1024 的 PNG。这不是重复可靠性测试,也不是总体质量排名;两个模型使用的默认设置也可能不同。下方图片是这些 API 请求的结果,并非 Playground 运行截图:
| 步骤 | Nano Banana 2.1 | 较旧的 Nano Banana 2 |
|---|---|---|
| 海报 | ![]() | ![]() |
| 标题编辑 | ![]() | ![]() |
| 碗的编辑 | ![]() | ![]() |
可下载的对比资料包包含相关文件。如果你正在迁移现有集成,2.1 与 2 的对比会将观察到的行为与更广泛的迁移决策分开讨论。
公开模型页与经过身份验证的 API 请求

公开模型页截图用于说明界面可用性和请求准备选项;它并不能证明 Playground 曾生成图片。
模型页提供了一个内嵌 Playground,包含提示词输入框、附件功能和后续编辑入口。使用该 Playground 需要登录。界面中的宽高比说明也提示,请求的比例尽力满足;因此,在 UI 中选择某个比例,不代表每次结果都有保证。本指南不声称测试过经过身份验证的 Ofox Agent 界面,也不声称 Agent 一定会选择 2.1。如果需要可复现的集成,请使用准确的模型 ID,并像上文所示检查 API 响应。
迁移时需要核对的事项、限制与费用
将 ID 从 google/gemini-3.1-flash-image 改为 google/gemini-nano-banana-2.1,不只是拼写上的变化。请核对响应解析器、图片尺寸选择、输出 token 限制,以及对文本或思考输出的预期。在实测海报请求中,2.1 报告了文本输出 token 和思考 token,而旧模型的海报响应没有报告这些类别。某个类别未被报告,不代表其用量为零;提示词相同,也不能保证用量或默认设置相同。请保留完整响应并记录返回的用量,不要沿用旧模型的计费统计假设。
当前公开目录快照列出的 2.1 费率为:每百万输入 token 1.50 美元、每百万文本输出 token 7.50 美元、每百万图片输出 token 30 美元;目录还单独列出了按请求计费的网络搜索费率。这些是目录中的观察值,不是经过验证的账单,也不是每张生成图片的固定价格。Google 的定价页面也列出 2.1 生成 1K 图片输出需 1,120 个图片 token,但 Google 发布的材料对 4K 图片 token 数量存在差异;本文没有通过网关运行 4K 请求。在估算批量成本或假设图片 token 费率较低就意味着完整请求更便宜之前,请先阅读定价分析。
Google 的模型文档介绍了多张图片参考等能力,图片生成指南则涵盖图片配置。这些官方介绍的能力,并不意味着每种设置、参考图数量或分辨率都已经通过这条 Ofox 路由进行过测试。尤其不要想当然地认为 seed、temperature 或 topP 参数适用于这个由 Vertex 提供支持的模型,然后把未经验证的调参方法加入请求。
最后,生成失败和使用权是两个不同的问题。遇到空图片响应或请求错误时,请根据实际返回的 JSON 和状态进行诊断;未返回图片错误指南对此有更详细的说明。用于发布或商业用途时,请确保你有权使用所提供的图片,并遵守适用的 Gemini 条款。SynthID 相关信息并不构成可见水印、所有权、独占权、不侵权或机密数据方面的保证。








