☰
codex接入中转站API无法生成图片:本地适配层排查与归一化方案
2026/11/4 17:32:12 网站建设 项目流程

最近碰到一个挺典型的事:有朋友把 codex 的接口地址换成了第三方中转服务,聊天、补全、改代码都正常,唯独让他画张图,不是回一句"当前模型不支持图像生成",就是终端里吐半行链接然后卡死。他那套 key 拿去别的地方用是好的,所以第一反应是"中转站把功能阉了"。实际情况往往没这么简单——codex 接入中转站 API 后无法生成图片,绝大多数时候不是能力被砍,而是链路上某一段的协议契约对不上。这篇就把这件事掰开揉碎讲:先教你怎么在十分钟内定位病灶在哪一层,再给一套可以直接抄的本地适配层方案,一份 JSON 配置加一个两百来行的脚本,把路径、模型名、响应结构、超时这四类差异一次性抹平。刚配好中转地址的新手能照着跑通,已经在日志里泡了两天的老手也能从排查思路里拿到点东西。

1. 先把"出不了图"拆成三类现象

1.1 报错型、静默型、半成型

很多人一上来就说"生成图片失败",但这四个字底下其实藏着三种完全不同的故障,处理路径差得很远,混在一起讲只会越查越乱。

报错型最好办,客户端会明确告诉你哪一步挂了。常见的几种:404 not found,说明请求打到了一个上游根本不存在的路径;403 forbidden,说明鉴权过了但权限或者模型白名单没过;400 invalid_request_error通常指向参数不合规,比如size传了上游不认的枚举值,或者response_format传了b64_json但上游只支持url;429是限流,跟功能无关,等一会儿重试就行。这一类的价值在于状态码本身就是线索,别急着改代码,先把状态码和响应体完整看一遍。

静默型最坑。HTTP 返回 200,content-type是application/json,一切看起来都正常,但data数组是空的,或者choices[0].message.content里只有一句"抱歉,我无法生成图像"。这种多半是两个原因:模型名被中转服务映射到了一个纯文本模型上,或者中转服务在中间加了一层内容审核,把请求拦下来但礼貌地回了个 200。判断方法很简单,看响应体里有没有model字段,把它和你请求时传的模型名对一下,对不上就是映射问题。

半成型是最有迷惑性的。终端里确实出现了图片链接或者一段 base64,但图片打不开、显示破图、或者文件大小是 0。这类问题几乎百分之百出在响应结构的处理和落盘环节:链接是带时效的临时地址,等你复制出来已经过期;base64 字符串在传输过程中被截断,尾部 padding 缺失;或者客户端把图片当成普通文本渲染,压根没往磁盘写。

三类的排查顺序不一样。报错型从状态码往下走,静默型从模型映射往下走,半成型从响应体字节数往下走。先归类,再定位,能省掉一大半无用功。

1.2 五层定位法:十分钟锁定病灶

我自己排查这类问题有个固定套路,从外到内分五层,每层只问一个问题,答不上来就说明病灶在这一层,不用往下看。

层级检查对象一句话判断标准典型症状
第一层 配置层base URL、模型名、功能开关客户端发出的地址和你以为的是否一致地址多一层/v1、模型名拼错
第二层 网络层连通性、状态码请求有没有真的到达上游超时、连接被拒、502
第三层 协议层路径、方法、请求头上游认不认这个端点404、405、415
第四层 结构层响应 JSON 的字段形态客户端认不认这个返回格式200 但无图、破图
第五层 落盘层文件写入、终端渲染图有没有真的存下来文件 0 字节、终端不显示

第一层和第二层加起来能覆盖七成以上的案例,而且排查成本最低。我见过太多人直接跳到第四层去改代码,改了半天发现是地址末尾多了个斜杠。

具体怎么操作:先把 codex 的配置文件打开,把里面配置的 base URL 原样复制出来,手动拼上你要调的端点路径,打印到屏幕上肉眼比对一遍。这一步看着很傻,但真的能抓到问题。然后拿这个完整地址,用一个最简单的curl打一次,看返回的状态码和响应体。这两步做完,你已经知道病灶在第一到第三层还是第四到第五层了。

第三层往后就需要看日志了。让你的适配层或者中转服务把完整的请求行、请求头、请求体大小、响应状态、响应体前 500 字节都打出来,这一份日志基本能定位到具体字段。我一般会在日志里额外打三个值:请求体字节数、响应体字节数、耗时。这三个数字放一起看,很多问题自己就浮出来了——比如请求体 200 字节、响应体 80 字节、耗时 0.3 秒,那就是被上游直接挡回来了,根本没进生成流程。

2. 根因剖析:中转服务在链路上改了什么

2.1 文本端点和图像端点走的是两条独立链路

这是理解一切的前提。codex 这类编码助手在生成图片时,走的不是那条你天天用的对话接口/v1/chat/completions,而是另一个端点,通常是/v1/images/generations,做图生图或者局部重绘时是/v1/images/edits。这两个端点在上游是两个独立服务,参数结构、响应结构、计费方式、超时策略全都不一样。

中转服务在做的时候,绝大多数是把重心放在文本接口上——毕竟对话才是高频需求。图像端点要么压根没转发,要么转发了但没做参数适配,要么路径改了但文档没更新。你拿着https://某中转服务/v1当 base URL,客户端自己拼上/v1/images/generations,请求发过去,对面网关一看这个路径没在路由表里,直接 404。而你的对话功能完全正常,所以你会觉得"是图片功能被禁了",实际上是从一开始就走错了门。

还有一种更隐蔽的情况:中转服务确实转发了图像端点,但做了路径重写,比如对外暴露成/v1/image/create。如果你的客户端是硬编码/v1/images/generations,那就永远对不上。判断方法是直接问服务方要一份端点清单,或者拿curl把几个常见路径都打一遍,看哪个返回 200。

顺带说一句,有些客户端在生成图片时不是直接调图像端点,而是先让模型返回一个工具调用的结构化输出,客户端解析出来再去调图像端点。这条链路里只要中转服务在中间把tool_calls字段剥掉了、或者把finish_reason改写了,客户端就拿不到调用指令,表现出来的现象就是"模型说它不会画图"。这种案例我在社区里见过好几次,排查的时候一定要把中间那次对话的原始响应抓出来看,别只看最终结果。

2.2 base URL 拼接:最低级的坑,最高的复发率

说实话,这一类问题占了我在这个问题上处理过的案例的一半还多,而且每次都是同一个原因:/v1重复了。

大部分客户端的逻辑是base_url + "/v1/images/generations",注意它自己会补/v1。而中转服务在文档里给的示例地址经常是https://xxx.com/v1,你照着填进去,客户端拼出来就是https://xxx.com/v1/v1/images/generations,对面一看路径不对,404 或者 400 就回来了。反过来也有客户端不补/v1,你填了不带/v1的地址,拼出来就是https://xxx.com/images/generations,同样 404。

这两种情况的迷惑性在于,文本对话可能仍然是通的。因为有些服务端做了路径容错,/v1/v1/chat/completions也能给你路由到正确的地方,而图像端点没做这个容错。于是就出现了"聊天能用、画图不行"的经典症状。

处理办法不是去猜,而是把请求日志打开,看客户端实际发出的完整 URL 是什么。如果拿不到客户端日志,就在本地起一个最小服务,把 base URL 指过去,让它把收到的每一个请求路径打印出来。十秒钟就能确认。

还有一种变体是末尾斜杠。https://xxx.com/v1/和https://xxx.com/v1在字符串拼接时结果是不同的,某些服务端对//v1/images这种双斜杠不买账。我在配置里一律做归一化处理:去掉所有末尾斜杠,去掉末尾的/v1,自己维护一份干净的根地址,拼接时再按需补上。这个习惯能省掉大量莫名其妙的 404。

2.3 响应结构三形态:b64_json、url、markdown

即使请求成功打到了正确的端点,返回的格式也未必是客户端认识的。

图像端点常见的返回有三种形态。第一种是b64_json,data[0].b64_json里塞一整段 base64 编码的图片数据,客户端解码写文件。第二种是url,data[0].url给一个图片地址,通常带时效,几十分钟到几小时不等。第三种是纯文本形态,图片以 markdown 语法![](https://...)混在choices[0].message.content里返回。

问题在于,客户端可能只认其中一种。如果你的 codex 版本期待b64_json,而中转服务返回的是url,那客户端解析完发现字段不存在,直接吞掉,表现就是"请求成功但没图"。反过来,如果客户端期待url却拿到一大段 base64,可能直接当成错误内容处理。

url形态还有额外的坑:这个地址可能是临时的、需要额外鉴权的、或者是内网地址。客户端拿到链接后如果直接展示给用户,用户点开时链接已经过期了;如果客户端尝试自己下载,但没有带正确的请求头,可能拿到 403。

最麻烦的是第三种 markdown 形态。客户端把它当纯文本输出,你在终端里看到一行链接,复制出来可能因为换行符被截断,也可能因为终端自动折行多插了空格。这种问题的特征是"看着有链接,但怎么都打不开"。

解决思路很直接:在中间加一层归一化处理,不管上游返回哪种形态,统统转换成客户端能认的那一种。这也是我后面那套方案的核心动作。

2.4 模型名映射与能力声明

中转服务通常会维护一张模型名映射表,把用户传的模型名翻译成上游的真实模型标识。这张表如果没把图像模型配进去,请求会被路由到一个默认的文本模型上。

这时候就会发生一件很微妙的事:模型确实回应了你的请求,但它的回应是"我是一个文本模型,无法生成图像"。HTTP 状态码 200,响应结构合法,客户端拿到一句文字,结束。你在终端里看到的就是这句话,然后以为是功能被砍了。

判断方法:在响应体里找model字段,跟你请求时传的模型名对比。如果返回的model跟你传的完全不一样,那就是被映射走了。另一种情况是返回体里压根没有model字段,那说明这个响应不是图像端点生成的,可能是网关层的兜底返回。

还有一种能力声明的问题。有些模型支持图像生成,但需要通过对话接口以特定参数触发,比如在请求里带一个modalities数组或者tools定义。中转服务如果对请求体做了字段白名单过滤,把这些扩展字段剥掉了,那模型就不会走图像分支。这种情况下请求本身没毛病,是中间层的参数清洗太激进。排查方法是对比一下你发出去的请求体和上游收到的请求体——如果你的适配层能打日志,这两个字节数应该几乎一样,差得多了就是被改写了。

我一般的做法是把模型名映射也做成可配置的,放在适配层的配置里,一旦服务方换了模型标识,改一行 JSON 就行,不用去翻客户端源码。

2.5 体积、超时与截断

前四类问题解决完,还有一类会让整个流程在最接近成功的地方失败:超时。

图像数据天生体积大。一张 1024×1024 的 PNG,压缩后大概 1.2 到 2.5 MB,base64 编码后膨胀到原来的 1.33 倍左右,加上 JSON 的转义和结构开销,一个响应体三四 MB 是常态。如果走的是url形态,那响应体很小,但客户端还要额外发一次下载请求,多一跳网络。

很多客户端或者中间层的默认读取超时是 30 秒。生成图片本身就要 15 到 60 秒,大图或者复杂提示词能到 90 秒,再叠加传输时间,30 秒根本不够。结果就是请求发出去了,图也生成了,但客户端等不及,主动断开连接,报一个read timeout。用户看到的就是"失败了",但上游其实扣了费。

截断是另一个表现。有些中间层对大响应体做了限制,比如超过 2MB 就截断。base64 字符串被砍掉尾巴,客户端解码时 padding 不对,报一个 base64 解码错误,或者解出来一张破图。这种情况的特征是图片能打开但下半部分是灰色或者乱码。

这两个问题的解决方式很朴素:超时调到足够大,并且给下载环节单独设超时;响应体大小在日志里打出来,如果和预期差得多,就是被截了。我一般把图像请求的超时设成 240 秒,其中下载环节单独给 120 秒,并且对下载做两到三次重试,因为临时链接偶尔会抽风。

3. 一键方案的设计:在本地加一层薄薄的适配层

3.1 为什么不改客户端,而加一层本地适配

面对上面这些问题,有两条路:改客户端,或者加中间层。

改客户端意味着你要去读它的源码、找到发请求的地方、改路径拼接逻辑、改响应解析逻辑,然后每次它更新版本你还得重新打一遍补丁。对于个人折腾来说,成本高且不可持续。

加一层本地适配层的思路是:让客户端以为自己在跟一个标准接口说话,让上游以为自己在跟一个标准客户端说话,中间的差异全部由适配层吸收。好处有三个。第一,客户端和服务方都不需要改,升级互不影响。第二,所有差异集中在一个配置文件里,换服务方只改配置不改代码。第三,中间层天然是观测点,请求和响应都能完整落日志,下次出问题排查成本极低。

代价是多了一跳本地转发,理论上有性能损耗。但实际上本地回环的延迟在毫秒级,相对于几十秒的生成时间完全可以忽略。另外需要保证适配层只监听127.0.0.1,不要暴露到外网,这一点很重要。

至于为什么用 Python 而不是别的语言,理由很简单:大部分开发机上都有,标准库够用,不需要装任何第三方依赖,写完就能跑。所谓"一键",前提是环境准备也要一键。

3.2 配置文件:把差异集中到一个 JSON

适配层要吸收的差异有四类,对应的配置项也就四组。

{ "upstream": "https://your-relay-host.example.com/v1", "path_map": { "/v1/images/generations": "/v1/images/generations", "/v1/images/edits": "/v1/images/edits", "/v1/chat/completions": "/v1/chat/completions" }, "model_map": { "img-gen-1": "img-gen-1-20250101", "vision-chat": "chat-large" }, "timeout": 240, "download_timeout": 120, "retry": 2, "outdir": "./out", "save_files": true, "log": "relay.log" }

几个字段的用意说明一下。

upstream填中转服务给你的根地址,带不带/v1都行,代码里会做归一化。归一化规则是:先rstrip("/")去掉末尾斜杠,如果结尾是/v1就再切掉,得到一个干净的根。后面拼接时统一从根开始拼完整路径。

path_map是关键。左边是客户端会请求的路径,右边是上游实际支持的路径。如果你发现服务方的图像端点是/v1/image/create,就在这里写"/v1/images/generations": "/v1/image/create"。这一行配置能解决掉相当一部分 404。

model_map处理模型名翻译。左边是你客户端里配的名字,右边是上游认的名字。不确定的时候可以留空,适配层会原样透传。

timeout和download_timeout分开设,是因为生成和下载是两个阶段的耗时,混在一起设要么生成阶段不够用,要么下载阶段白等。

outdir和save_files控制要不要把图片额外落一份到本地。这个功能在终端不渲染图片的情况下特别有用——你至少能拿到文件。

3.3 核心实现:转发、映射、归一化、落盘

整个脚本两百行以内,四个动作:转发、映射、归一化、落盘。

#!/usr/bin/env python3 # relay.py 本地图像接口适配层,仅依赖标准库 import base64 import json import os import re import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer CFG_PATH = os.environ.get("RELAY_CONFIG", "relay.json") with open(CFG_PATH, "r", encoding="utf-8") as f: CFG = json.load(f) UPSTREAM = CFG["upstream"].rstrip("/") if UPSTREAM.endswith("/v1"): UPSTREAM = UPSTREAM[:-3] PATH_MAP = CFG.get("path_map", {}) MODEL_MAP = CFG.get("model_map", {}) TIMEOUT = int(CFG.get("timeout", 240)) DL_TIMEOUT = int(CFG.get("download_timeout", 120)) RETRY = int(CFG.get("retry", 2)) OUTDIR = CFG.get("outdir", "./out") SAVE = bool(CFG.get("save_files", True)) LOG = CFG.get("log", "relay.log") MD_IMG = re.compile(r"!\[[^\]]*\]\((https?://[^)\s]+)\)") def log(msg): line = "[%s] %s" % (time.strftime("%H:%M:%S"), msg) print(line, flush=True) with open(LOG, "a", encoding="utf-8") as f: f.write(line + "\n") def request(method, url, headers, body=None, timeout=None): req = urllib.request.Request(url, data=body, headers=headers, method=method) with urllib.request.urlopen(req, timeout=timeout or TIMEOUT) as r: return r.status, r.read() def fetch_image(url): last = None for i in range(RETRY + 1): try: req = urllib.request.Request(url, headers={ "User-Agent": "Mozilla/5.0", "Accept": "image/*,*/*", }) with urllib.request.urlopen(req, timeout=DL_TIMEOUT) as r: return r.read() except Exception as e: last = e time.sleep(1.5 ** i) raise RuntimeError("fetch_image failed: %s" % last) def save_bytes(raw, tag="img"): if not SAVE: return None os.makedirs(OUTDIR, exist_ok=True) name = "%s-%d.png" % (tag, int(time.time() * 1000)) path = os.path.join(OUTDIR, name) with open(path, "wb") as f: f.write(raw) log("saved %s (%d bytes)" % (path, len(raw))) return path def normalize(obj): """把 url 形态统一成 b64_json 形态""" items = obj.get("data") if not isinstance(items, list): return obj for it in items: if it.get("b64_json"): continue url = it.get("url") if not url: continue raw = fetch_image(url) it["b64_json"] = base64.b64encode(raw).decode() it["bytes"] = len(raw) save_bytes(raw, "gen") return obj def rescue_markdown(obj): """对话响应里的 markdown 图片链接,补成 data URL 并落盘""" for ch in obj.get("choices", []) or []: content = (ch.get("message") or {}).get("content") if not isinstance(content, str): continue for link in MD_IMG.findall(content): try: raw = fetch_image(link) save_bytes(raw, "md") data_url = "data:image/png;base64," + base64.b64encode(raw).decode() content = content.replace(link, data_url) except Exception as e: log("markdown rescue skip: %s" % e) ch["message"]["content"] = content return obj class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def reply(self, code, body, ctype="application/json"): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path.startswith("/healthz"): return self.reply(200, b'{"ok":true}') return self.reply(404, b'{"error":"not found"}') def do_POST(self): path = self.path.split("?")[0] body = self.rfile.read(int(self.headers.get("Content-Length", 0))) target = PATH_MAP.get(path, path) url = UPSTREAM + target try: payload = json.loads(body) except Exception: payload = None if isinstance(payload, dict): old = payload.get("model") if old in MODEL_MAP: payload["model"] = MODEL_MAP[old] body = json.dumps(payload).encode() log("model %s -> %s" % (old, payload["model"])) headers = { "Content-Type": "application/json", "Accept": "application/json", "Authorization": self.headers.get("Authorization", ""), } log("POST %s -> %s body=%dB" % (path, url, len(body))) t0 = time.time() try: status, data = request("POST", url, headers, body) except urllib.error.HTTPError as e: err = e.read() log("upstream %d %s" % (e.code, err[:300])) return self.reply(e.code, err) except Exception as e: log("upstream error: %s" % e) return self.reply(502, json.dumps({"error": str(e)}).encode()) cost = time.time() - t0 log("resp %d %dB %.1fs" % (status, len(data), cost)) try: obj = json.loads(data) if isinstance(obj, dict): obj = normalize(obj) obj = rescue_markdown(obj) data = json.dumps(obj).encode() log("normalized %dB" % len(data)) except Exception as e: log("normalize skip: %s" % e) return self.reply(status, data) if __name__ == "__main__": port = int(CFG.get("port", 8787)) log("relay listening on 127.0.0.1:%d -> %s" % (port, UPSTREAM)) ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()

几个实现细节值得展开。

protocol_version = "HTTP/1.1"必须显式设置,否则默认是 1.0,客户端会因为缺少Content-Length或者连接不复用而卡住。这是我自己踩过的坑,第一次写完发现响应明明发出了,客户端一直在等,加了这一行就通了。

normalize里对url形态的处理是下载后转 base64,同时保留原始url字段。这样做的好处是双保险——如果客户端认b64_json就用新的,如果认url就还是老地址。坏处是响应体变大,但因为只在本地回环上跑,问题不大。

rescue_markdown这一段是为了处理那些把图片塞进对话响应的服务。把链接换成data:URL 之后,支持 inline 渲染的终端就能直接显示了。即使不渲染,save_bytes也已经把文件存到了outdir,你手动打开就行。

日志里我特意打了三个数字:请求体字节数、响应体字节数和耗时。这三个数放在一起,异常与否一眼就能看出来。正常一次图像生成,请求体几百字节,响应体几兆,耗时几十秒。如果响应体只有几十字节,那就是被挡回来了;如果耗时只有 0.5 秒但响应体是几兆,那多半是命中缓存。

3.4 启动与接入:三行命令跑起来

配置文件和脚本放在同一个目录,执行:

cd ~/relay python3 relay.py

看到relay listening on 127.0.0.1:8787就成了。第一次启动建议前台跑,方便看日志,确认没问题后再用nohup python3 relay.py &放到后台。

接下来改 codex 的配置,把接口地址指向本地:http://127.0.0.1:8787/v1。注意这里要不要带/v1,取决于你的客户端会不会自己补。前面那个配置里的path_map是以/v1/images/generations为键的,所以客户端最终请求的路径里必须带/v1。如果你不确定,看一次日志就知道了——日志里打的POST /xxx就是客户端实际发出的路径。

API key 这一项填你从中转服务拿到的原始 key,适配层会原样透传到Authorization头里。不要在这里做任何改写,鉴权的事交给上游。

验证一下:

curl -s http://127.0.0.1:8787/healthz curl -s http://127.0.0.1:8787/v1/images/generations \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $YOUR_KEY" \ -d '{"model":"img-gen-1","prompt":"a ceramic mug on a wooden desk, soft light", "size":"1024x1024","n":1,"response_format":"b64_json"}' \ -o out.json ls -lh out.json

如果out.json有几兆,打开能看到data[0].b64_json,说明链路通了。如果只有几百字节,cat一下看里面的错误信息,配合日志里那行upstream xxx就能定位。

4. 实操记录:从 404 到成功落盘

4.1 第一步:确认请求真的离开过客户端

我处理这类问题的第一步永远是确认客户端到底发了什么。方法是在relay.json里把upstream先指向一个不存在的地址,比如https://127.0.0.1:1/v1,然后触发一次图片生成。

适配层会立刻返回 502,但日志里已经记下了完整的请求路径、请求体大小和请求头。这一招的价值在于,它把"客户端的行为"和"上游的行为"彻底分开了。很多人在排查时把这两件事混在一起,一会儿怀疑客户端,一会儿怀疑服务方,来回横跳。

我曾经遇到过一个案例,日志显示客户端请求的是/v1/images/generations,但请求体里model字段的值是空字符串。这就说明客户端根本没配置图像模型,或者配置项的名字不对。这种情况下你改再多网络配置都没用。另一回,日志显示路径是/images/generations,少了/v1,那问题就在 base URL 的填法上。

这一步花不了两分钟,但能把排查范围砍掉一大半。

4.2 第二步:绕开客户端直打上游

确认了客户端行为之后,把适配层停掉,用curl直接打中转服务的图像端点。

curl -sv https://your-relay-host.example.com/v1/images/generations \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $YOUR_KEY" \ -d '{"model":"img-gen-1","prompt":"test","size":"1024x1024","n":1}' \ -o direct.json 2>&1 | tail -30

加-v是为了看完整的请求头和响应头。重点看四个东西:返回的状态码、content-type、响应体的字节数、以及响应头里有没有x-request-id这类追踪字段。

如果这一步就失败了,那问题完全在上游,跟客户端无关。拿着这份curl的输出去找服务方,沟通效率高得多——对方一看就知道你排查过了。

如果这一步成功,但客户端还是不行,那问题百分之百在适配或者解析环节。

这里有个小技巧:把direct.json的字节数记下来,后面接上适配层之后再打一次,两个数字应该很接近(适配层可能会稍微大一点,因为它可能补了字段)。如果差得很多,说明归一化逻辑动了不该动的东西。

4.3 第三步:接上适配层,对着日志逐行核

上游确认可用之后,把 codex 的地址切到本地,再触发一次生成,同时盯住relay.log。

一份正常的日志长这样:

[21:03:11] POST /v1/images/generations -> https://.../v1/images/generations body=178B [21:03:11] model img-gen-1 -> img-gen-1-20250101 [21:03:48] resp 200 2415623B 36.9s [21:03:48] saved ./out/gen-1730000000000.png (1811720 bytes) [21:03:48] normalized 3220864B

逐行读:第一行确认路径映射对不对,第二行确认模型名有没有被翻译,第三行看耗时和响应体大小,第四行看落盘成功没有,第五行看归一化后的大小。这几个数字串起来,整个链路的状态就清楚了。

异常的日志也很好认。比如:

[21:05:02] POST /v1/images/generations -> https://.../v1/images/generations body=178B [21:05:02] upstream 404 b'{"error":{"message":"unknown endpoint"}}'

这里响应体只有几十字节,耗时接近 0,说明请求根本没进生成流程,是路径问题。对照path_map改配置就行。

再比如:

[21:07:15] POST /v1/images/generations -> https://.../v1/images/generations body=178B [21:07:16] resp 200 96B 0.8s [21:07:16] normalized 96B

200,但响应体 96 字节,耗时 0.8 秒。这就是典型的静默失败,cat一下响应体,多半是一句"model not support image generation"。回头改model_map。

4.4 关键参数怎么定:尺寸、超时、重试次数

这几个参数不是拍脑袋定的,有个简单的估算方法。

尺寸。常见的方图是 1024×1024,横图 1536×1024,竖图 1024×1536。像素总数直接影响生成耗时和文件体积。1024×1024 是 1048576 像素,1536×1024 是 1572864 像素,后者大约是前者的 1.5 倍。经验上,生成耗时大致和像素数线性相关,文件体积也是。

文件体积和超时。下面这张表是我在几个不同服务上测出来的量级,供参考,实际会有浮动:

尺寸原始 PNG 体积base64 后建议生成超时建议下载超时
1024×10241.2–2.5 MB1.6–3.3 MB120 s60 s
1536×10241.8–3.5 MB2.4–4.7 MB180 s90 s
2048×20484–7 MB5.3–9.3 MB300 s120 s

计算逻辑很简单:总超时 = 排队时间 + 生成时间 + 传输时间 + 余量。生成时间按上表的经验值取,传输时间按体积 / 带宽估,余量给 30% 到 50%。本机带宽一般不是瓶颈,瓶颈在服务方的出口,所以我宁可给大一倍也不给紧。

重试次数。生成类请求的重试要谨慎,因为它有副作用——重试一次可能就多扣一次费。我的做法是:生成请求不自动重试,失败了让用户手动决定;下载请求重试两到三次,因为下载是幂等的,重试没有副作用。这个区分很重要,很多自动重试的脚本在这上面吃过亏。

并发。适配层用的是ThreadingHTTPServer,能同时处理多个请求,但图像生成本身在服务端是有并发限制的。如果你一次发五个请求,大概率会有两三个返回 429。实际使用中建议串行,或者最多两个并发。

5. 常见问题速查与踩坑清单

5.1 问题速查表

现象最可能的原因快速验证处理
404,响应体几十字节路径不对或/v1重复看日志里的完整 URL改path_map或修 base URL
403,提示无权限key 无效或模型不在白名单用同一 key 打文本端点找服务方确认模型权限
400,提示参数非法size或response_format不被支持去掉可选参数只留必填按上游支持的枚举值改
200,但data为空模型名被映射到文本模型看响应里的model字段改model_map
200,content 里有一句"无法生成"中间层剥掉了图像参数或工具调用对比请求体字节数联系服务方放开字段
请求成功但终端不显示图客户端不渲染,只落文件看outdir有没有文件手动打开文件
图片打不开,提示链接失效临时 URL 过期看 URL 里的时间参数用b64_json形态
图片下半部分花屏base64 被截断对比日志里的响应体大小调大中间层的体积上限
read timeout超时太短看日志里的耗时调到 240 s 以上
base64 解码报错padding 缺失或含换行检查字符串长度是否为 4 的倍数归一化时补齐 padding

表格里的"对比请求体字节数"这一招值得多说一句。请求体从客户端发出时是多少字节,到了上游是多少字节,这两个数字如果不一样,说明中间有东西动了你的参数。我遇到过一次,请求体从 300 多字节被砍到 120 字节,原因是中间层做了字段白名单,把modalities和tools都过滤了。这种情况改客户端没用,只能让服务方放开。

5.2 几条踩出来的经验

第一,先怀疑地址,再怀疑代码。我处理过的案例里,超过一半是 base URL 填法的问题。每次排查我都强迫自己先花三十秒把 URL 念一遍,确认没有多一层/v1、没有多余的斜杠、没有把/v1/images/generations整个当 base URL 填进去。这个动作看着蠢,但它救过我无数次。

第二,日志要打字节数,不要只打状态码。状态码 200 是最没有信息量的东西,它只说明"服务器收到了并且没崩"。真正有价值的是请求体大小、响应体大小、耗时这三个数字。三个数字一摆,问题在哪一层基本就清楚了。

第三,生成请求不要自动重试。这一点我吃过亏。早期写的脚本无脑重试三次,结果一次失败的请求变成了三次扣费,而且后两次生成的是完全不一样的图。后来改成生成阶段只发一次,下载阶段才重试,问题就没了。

第四,把图片落一份到本地,别只依赖终端渲染。codex 这类工具在终端里跑,很多终端对 inline 图片的支持很有限,有的只显示一个占位符,有的直接输出一长串 base64。与其纠结怎么让它显示,不如让它把文件写到固定目录,你手动打开看。适配层里的save_files和outdir就是干这个的,我建议默认打开。

第五,换服务方时只改配置。这套方案最大的价值不是解决眼前这一个问题,而是把"服务方实现差异"这个东西隔离出去了。下次换个中转服务,你把upstream一改,路径和模型名对着新文档调一下,五分钟就能跑起来,客户端那边一行都不用动。

第六,注意数据合规。用第三方中转服务处理图片时,提示词和生成结果都会经过第三方的服务器。涉及内部资料、未公开设计稿这类内容,用之前先确认一下对方的服务条款和数据留存策略,这条比技术问题重要得多。技术上的坑可以重试,数据上的坑没法重来。

第七,适配层只监听本地。脚本里写死了127.0.0.1,不要改成0.0.0.0。它会把你的Authorization头原样透传出去,暴露到局域网等于把 key 挂出去。如果确实需要跨机器用,至少加一层本地鉴权,别图省事。

最后再补一句我自己用下来觉得最省事的习惯:把relay.log放在一个固定的位置,每次出问题先tail -f再复现,日志滚动的过程中你能直观看到请求走到哪一步停住了。比起事后翻文件,实时看滚动日志对定位问题的帮助大得多——尤其是那种"请求发出去了但一直没回应"的场景,日志停在哪一行,病灶就在那一行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询