同时要读图和按描述出图,最容易踩的坑不是模型能力,而是两条链路的输入输出形状不一样:读图问答吃的是图片加问题文本、吐的是文本;出图吃的是描述文本、吐的是图像张量或文件。把它们塞进同一个函数、共用一个返回值,排错时很难判断问题出在预处理、模型还是后处理。可行的路径是先把两条链路各自的字段契约写死,各自单独调通并记录一次真实开销,再判断预处理与后处理值不值得合并。
建议把理解链路和生成链路当作两个独立入口先跑通:各自固定输入输出字段、各自有函数签名和日志,不共用返回值;预处理按尺寸、归一化、通道顺序逐项确认能否共用,拿不准就先各自实现。两条都稳定后,再在同一个入口里按需加载模型并显式释放中间变量,用显存读数确认回落。适用场景是本地或单机部署;如果两条链路本来就分别走远程接口,合并的收益有限。
先把理解链路的输入输出字段写死
先只处理一条链路,把字段名和取值范围固定下来,中途不要改字段名。读图问答通常需要两个输入——图片和问题文本;输出至少要区分“给人看的文本”和“给程序用的结构化结果”。下面这张对照表里的字段名可以按项目习惯替换,但一旦确定就写进函数签名和入口校验里。
| 字段 | 方向 | 类型 | 约束 | 用途 |
|---|---|---|---|---|
| image_bytes / image_path | 输入 | bytes / str | 二选一,不要同时传 | 读取待理解图片 |
| image_format | 输入 | str | 白名单校验(如 jpg/png/webp) | 提前拒绝不支持格式 |
| question | 输入 | str | 非空,单轮问题 | 提问文本 |
| max_new_tokens | 输入 | int | 设上限,防止输出失控 | 限制回答长度 |
| request_id | 输入/输出 | str | 每次调用唯一 | 串联日志 |
| text | 输出 | str | 原始回答文本 | 给人看 |
| structured | 输出 | dict | 键固定,不随问题变化 | 给程序用 |
函数签名骨架只返回一种形状,不要把中间张量、模型对象、原始 logits 一起塞回来。调用方只需要看 text 和 structured 两个键,日志里只带 request_id,出问题时不用先猜返回结构。
def understand(image_bytes: bytes, question: str, *, max_new_tokens: int = 256) -> dict:
rid = new_request_id()
validate_image(image_bytes)
validate_question(question)
log(rid, "understand.start", img_len=len(image_bytes), q_len=len(question))
# 1) 预处理 2) 前向 3) 后处理
return {"request_id": rid, "text": text,
"structured": {"answer": text, "parse_ok": True}}
校验放在入口:图片为空、问题为空、格式不在白名单时直接抛错,不要拖到模型内部才报。结构化结果如果是解析出来的 JSON,解析失败建议保留原始文本并把 parse_ok 置为 false,而不是抛异常丢掉整轮结果——否则日志里只剩“理解链路报错”,看不出是模型答错还是后处理解析错。
再单独把生成链路的输入输出字段写死
生成链路的输入是描述文本加输出尺寸,输出是图像张量或文件,字段集合和理解链路几乎不重叠。重要的是它要有自己的返回值:不要为了省事让 generate 复用 understand 的 dict 结构,也不要让两条链路共用一个 result 对象。共用的那一刻起,改一个字段就会同时影响读图和出图两处调用。
| 字段 | 方向 | 类型 | 约束 | 用途 |
|---|---|---|---|---|
| prompt | 输入 | str | 非空 | 画面描述文本 |
| negative_prompt | 输入 | str / None | 可空 | 需要排除的内容 |
| width / height | 输入 | int | 通常需要是 8 或 16 的倍数,需按所用模型确认 | 输出尺寸 |
| steps | 输入 | int | 设上下限 | 采样步数 |
| seed | 输入 | int | 固定以便复现 | 随机种子 |
| image_bytes | 输出 | bytes | PNG/JPEG | 图像本体 |
| used_seed | 输出 | int | 回传实际生效值 | 复现同一张图 |
| meta | 输出 | dict | 尺寸、步数等 | 排查异常尺寸 |
def generate(prompt: str, *, width: int = 1024, height: int = 1024,
steps: int = 30, seed: int = 0) -> dict:
rid = new_request_id()
assert width % 8 == 0 and height % 8 == 0 # 倍数按所用模型确认
log(rid, "generate.start", w=width, h=height, steps=steps, seed=seed)
return {"request_id": rid, "image_bytes": png_bytes,
"used_seed": used_seed, "meta": {"w": width, "h": height}}
如果暂时只做文生图,生成链路里不要出现图片输入字段;等到确实要做图生图时再加 reference_image,并把这份契约单独升一版,而不是悄悄塞进现有的 image_bytes 字段里——那会和理解链路的语义撞车。
把预处理拆出来,确认哪些共用哪些必须分开
预处理是最容易被“顺手复用”拖下水的部分。两条链路对图像的要求本来就不同:理解链路需要把图片搬成模型能吃的张量,生成链路多数时候根本不吃输入图,只是把宽高对齐到模型允许的倍数。逐项过一遍,能判断的写死,判断不了的就先各自实现。
| 项目 | 理解链路 | 生成链路 | 能否共用 |
|---|---|---|---|
| 文件解码 / 字节流转图像 | 需要 | 文生图不需要,图生图需要 | 可共用同一个解码函数 |
| 颜色空间 | 统一转 RGB | 文生图不涉及 | 不共用 |
| 通道顺序 | 转成模型期望的 CHW/HWC | 不涉及 | 不共用 |
| 归一化与均值方差 | 缩放到模型期望区间,可能带均值方差 | 文生图不做图像归一化 | 不共用 |
| 尺寸处理 | 短边缩放再裁剪或补边 | 宽高对齐到模型倍数 | 不共用 |
| dtype | 通常 float32 或半精度 | 文生图不涉及输入张量 | 不共用 |
| request_id / 日志 | 需要 | 需要 | 可共用同一套 |
落地时建议按目录分开:preprocess/understand.py、preprocess/generate.py 各自持有导出函数,能复用的解码逻辑放 preprocess/common.py,由前两个文件显式调用。这样改理解链路的归一化参数不会波及出图,反过来也一样。合并的时机是两条链路都跑通之后,看差异点是否真的只有一两行,而不是一上来就抽公共层。
分别用同一批素材跑一次并记录耗时与显存
两条链路各自能出结果之后,用同一批素材各跑一次,拿真实开销。这里不预填任何数值,只给采集方式;数值必须以自己环境跑出来的读数为准,并注明硬件、精度和 batch 大小,否则数字没有比较意义。
| 记录字段 | 说明 |
|---|---|
| run_id | 同一次批量跑的标识,两条链路用同一个便于对齐 |
| pipeline | understand 或 generate |
| asset_id | 素材编号,两条链路用同一批编号 |
| input_shape | 图片尺寸,或生成时的宽高 |
| batch | 批大小 |
| latency_ms | 单次调用耗时 |
| peak_mem_mb | 该次调用的峰值显存 |
| ok / err | 是否成功,失败时的报错摘要 |
- 计时用 time.perf_counter() 包住单次调用;正式记录前先预热一两次,避免把首次加载或编译时间算进去。
- 显存先调用 torch.cuda.reset_peak_memory_stats(),调用后读 torch.cuda.max_memory_allocated();再开 nvidia-smi -l 1 从卡外交叉看整卡占用,两者对不上时优先信 nvidia-smi。
- 固定 seed、固定素材清单、固定 batch,两条链路的差异才可比。
- 把表格存成 CSV 或写日志,不要只记在终端里,合并入口后还要拿这份数据做对照。
合并成一个入口时怎么避免相互影响
两条链路都稳定后,才会遇到同进程内抢显存的问题。选择常驻还是按需加载,取决于调用频次和显存余量,没有通用答案:
| 方案 | 适用情况 | 代价 |
|---|---|---|
| 两个模型都常驻 | 两条链路调用都频繁,显存够用 | 显存占用最高 |
| 理解常驻、生成按需 | 以读图为主,出图偶发 | 出图首次调用变慢 |
| 都按需加载、用后卸载 | 调用稀疏,显存紧张 | 每次加载有固定延迟 |
释放中间变量后确认显存是否真的回落,按步骤做:先记录释放前的 torch.cuda.memory_allocated(),del 掉图像张量、中间特征和返回对象里不需要保留的引用,再调用 torch.cuda.empty_cache(),重新读一次占用,并用 nvidia-smi 复核整卡。如果读数没有回落,通常是还有引用被闭包、全局缓存或日志回调持有——把 tensor 塞进日志或异常信息里是很常见的原因,检查这些位置比反复调 empty_cache 更有效。需要说明的是,empty_cache 只是把已释放的缓存还给驱动,它本身不是显存优化手段,真正起作用的是切断引用。
入口层建议只做三件事:按 request 类型分发到 understand 或 generate、统一生成 request_id 并记日志、统一做字段校验。两条链路的业务逻辑仍然留在各自模块里,返回结构也各自独立。这样即使以后要给理解链路加多轮对话、给出图链路加参考图,改动都局限在单条链路内部,不会因为共用返回值而互相牵制。