资讯详情

资讯详情

codex接入中转站API无法生成图片:本地适配层排查与归一化方案

最近碰到一个挺典型的事有朋友把 codex 的接口地址换成了第三方中转服务聊天、补全、改代码都正常唯独让他画张图不是回一句当前模型不支持图像生成就是终端里吐半行链接然后卡死。他那套 key 拿去别的地方用是好的所以第一反应是中转站把功能阉了。实际情况往往没这么简单——codex 接入中转站 API 后无法生成图片绝大多数时候不是能力被砍而是链路上某一段的协议契约对不上。这篇就把这件事掰开揉碎讲先教你怎么在十分钟内定位病灶在哪一层再给一套可以直接抄的本地适配层方案一份 JSON 配置加一个两百来行的脚本把路径、模型名、响应结构、超时这四类差异一次性抹平。刚配好中转地址的新手能照着跑通已经在日志里泡了两天的老手也能从排查思路里拿到点东西。1. 先把出不了图拆成三类现象1.1 报错型、静默型、半成型很多人一上来就说生成图片失败但这四个字底下其实藏着三种完全不同的故障处理路径差得很远混在一起讲只会越查越乱。报错型最好办客户端会明确告诉你哪一步挂了。常见的几种404 not found说明请求打到了一个上游根本不存在的路径403 forbidden说明鉴权过了但权限或者模型白名单没过400 invalid_request_error通常指向参数不合规比如size传了上游不认的枚举值或者response_format传了b64_json但上游只支持url429是限流跟功能无关等一会儿重试就行。这一类的价值在于状态码本身就是线索别急着改代码先把状态码和响应体完整看一遍。静默型最坑。HTTP 返回 200content-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_jsondata[0].b64_json里塞一整段 base64 编码的图片数据客户端解码写文件。第二种是urldata[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 MBbase64 编码后膨胀到原来的 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, encodingutf-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, flushTrue) with open(LOG, a, encodingutf-8) as f: f.write(line \n) def request(method, url, headers, bodyNone, timeoutNone): req urllib.request.Request(url, databody, headersheaders, methodmethod) with urllib.request.urlopen(req, timeouttimeout 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, timeoutDL_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, tagimg): if not SAVE: return None os.makedirs(OUTDIR, exist_okTrue) 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, ctypeapplication/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 21 | 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 body178B [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 body178B [21:05:02] upstream 404 b{error:{message:unknown endpoint}}这里响应体只有几十字节耗时接近 0说明请求根本没进生成流程是路径问题。对照path_map改配置就行。再比如[21:07:15] POST /v1/images/generations - https://.../v1/images/generations body178B [21:07:16] resp 200 96B 0.8s [21:07:16] normalized 96B200但响应体 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 s1536×10241.8–3.5 MB2.4–4.7 MB180 s90 s2048×20484–7 MB5.3–9.3 MB300 s120 s计算逻辑很简单总超时 排队时间 生成时间 传输时间 余量。生成时间按上表的经验值取传输时间按体积 / 带宽估余量给 30% 到 50%。本机带宽一般不是瓶颈瓶颈在服务方的出口所以我宁可给大一倍也不给紧。重试次数。生成类请求的重试要谨慎因为它有副作用——重试一次可能就多扣一次费。我的做法是生成请求不自动重试失败了让用户手动决定下载请求重试两到三次因为下载是幂等的重试没有副作用。这个区分很重要很多自动重试的脚本在这上面吃过亏。并发。适配层用的是ThreadingHTTPServer能同时处理多个请求但图像生成本身在服务端是有并发限制的。如果你一次发五个请求大概率会有两三个返回 429。实际使用中建议串行或者最多两个并发。5. 常见问题速查与踩坑清单5.1 问题速查表现象最可能的原因快速验证处理404响应体几十字节路径不对或/v1重复看日志里的完整 URL改path_map或修 base URL403提示无权限key 无效或模型不在白名单用同一 key 打文本端点找服务方确认模型权限400提示参数非法size或response_format不被支持去掉可选参数只留必填按上游支持的枚举值改200但data为空模型名被映射到文本模型看响应里的model字段改model_map200content 里有一句无法生成中间层剥掉了图像参数或工具调用对比请求体字节数联系服务方放开字段请求成功但终端不显示图客户端不渲染只落文件看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再复现日志滚动的过程中你能直观看到请求走到哪一步停住了。比起事后翻文件实时看滚动日志对定位问题的帮助大得多——尤其是那种请求发出去了但一直没回应的场景日志停在哪一行病灶就在那一行。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →