SenseNova-Vision 读图问答一条链路、按描述出图另一条,先分开调通

文章导读
同时要读图和按描述出图,最容易踩的坑不是模型能力,而是两条链路的输入输出形状不一样:读图问答吃的是图片加问题文本、吐的是文本;出图吃的是描述文本、吐的是图像张量或文件。把它们塞进同一个函数、共用一个返回值,排错时很难判断问题出在预处理、模型还是后处理。可行的路径是先把两条链路各自的字段契约写死,各自单独调通并记录一次真实开销,再判断预处理与后处理值不值得合并。
📋 目录
  1. 一 先把理解链路的输入输出字段写死
  2. 二 再单独把生成链路的输入输出字段写死
  3. 三 把预处理拆出来,确认哪些共用哪些必须分开
  4. 四 分别用同一批素材跑一次并记录耗时与显存
  5. 五 合并成一个入口时怎么避免相互影响
A A

同时要读图和按描述出图,最容易踩的坑不是模型能力,而是两条链路的输入输出形状不一样:读图问答吃的是图片加问题文本、吐的是文本;出图吃的是描述文本、吐的是图像张量或文件。把它们塞进同一个函数、共用一个返回值,排错时很难判断问题出在预处理、模型还是后处理。可行的路径是先把两条链路各自的字段契约写死,各自单独调通并记录一次真实开销,再判断预处理与后处理值不值得合并。

建议把理解链路和生成链路当作两个独立入口先跑通:各自固定输入输出字段、各自有函数签名和日志,不共用返回值;预处理按尺寸、归一化、通道顺序逐项确认能否共用,拿不准就先各自实现。两条都稳定后,再在同一个入口里按需加载模型并显式释放中间变量,用显存读数确认回落。适用场景是本地或单机部署;如果两条链路本来就分别走远程接口,合并的收益有限。

先把理解链路的输入输出字段写死

先只处理一条链路,把字段名和取值范围固定下来,中途不要改字段名。读图问答通常需要两个输入——图片和问题文本;输出至少要区分“给人看的文本”和“给程序用的结构化结果”。下面这张对照表里的字段名可以按项目习惯替换,但一旦确定就写进函数签名和入口校验里。

字段方向类型约束用途
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,而不是抛异常丢掉整轮结果——否则日志里只剩“理解链路报错”,看不出是模型答错还是后处理解析错。

SenseNova-Vision 读图问答一条链路、按描述出图另一条,先分开调通

再单独把生成链路的输入输出字段写死

生成链路的输入是描述文本加输出尺寸,输出是图像张量或文件,字段集合和理解链路几乎不重叠。重要的是它要有自己的返回值:不要为了省事让 generate 复用 understand 的 dict 结构,也不要让两条链路共用一个 result 对象。共用的那一刻起,改一个字段就会同时影响读图和出图两处调用。

字段方向类型约束用途
prompt输入str非空画面描述文本
negative_prompt输入str / None可空需要排除的内容
width / height输入int通常需要是 8 或 16 的倍数,需按所用模型确认输出尺寸
steps输入int设上下限采样步数
seed输入int固定以便复现随机种子
image_bytes输出bytesPNG/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 字段里——那会和理解链路的语义撞车。

把预处理拆出来,确认哪些共用哪些必须分开

预处理是最容易被“顺手复用”拖下水的部分。两条链路对图像的要求本来就不同:理解链路需要把图片搬成模型能吃的张量,生成链路多数时候根本不吃输入图,只是把宽高对齐到模型允许的倍数。逐项过一遍,能判断的写死,判断不了的就先各自实现。

SenseNova-Vision 读图问答一条链路、按描述出图另一条,先分开调通
项目理解链路生成链路能否共用
文件解码 / 字节流转图像需要文生图不需要,图生图需要可共用同一个解码函数
颜色空间统一转 RGB文生图不涉及不共用
通道顺序转成模型期望的 CHW/HWC不涉及不共用
归一化与均值方差缩放到模型期望区间,可能带均值方差文生图不做图像归一化不共用
尺寸处理短边缩放再裁剪或补边宽高对齐到模型倍数不共用
dtype通常 float32 或半精度文生图不涉及输入张量不共用
request_id / 日志需要需要可共用同一套

落地时建议按目录分开:preprocess/understand.py、preprocess/generate.py 各自持有导出函数,能复用的解码逻辑放 preprocess/common.py,由前两个文件显式调用。这样改理解链路的归一化参数不会波及出图,反过来也一样。合并的时机是两条链路都跑通之后,看差异点是否真的只有一两行,而不是一上来就抽公共层。

分别用同一批素材跑一次并记录耗时与显存

两条链路各自能出结果之后,用同一批素材各跑一次,拿真实开销。这里不预填任何数值,只给采集方式;数值必须以自己环境跑出来的读数为准,并注明硬件、精度和 batch 大小,否则数字没有比较意义。

SenseNova-Vision 读图问答一条链路、按描述出图另一条,先分开调通
记录字段说明
run_id同一次批量跑的标识,两条链路用同一个便于对齐
pipelineunderstand 或 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 并记日志、统一做字段校验。两条链路的业务逻辑仍然留在各自模块里,返回结构也各自独立。这样即使以后要给理解链路加多轮对话、给出图链路加参考图,改动都局限在单条链路内部,不会因为共用返回值而互相牵制。