Mage 接进自己的服务 / 先把单张图和长文本的耗时分开测

文章导读
Mage 接进自有服务后延迟忽长忽短,通常不是模型本身变慢,而是还没有把「图片预处理与编码、前向、解码」三段拆开看。接服务前先做一件事:把输出长度固定住,分别跑纯文本、单图、多图三类请求,并在这三处各埋一个时间点。先知道时间花在哪一段,再决定是改图片处理流程还是调解码参数。
📋 目录
  1. 一 固定输出长度,分别测纯文本、单图、多图请求的端到端耗时
  2. 二 在预处理、前向、解码三处各打一个时间点
  3. 三 改输入图片尺寸,观察首 token 时间的变化
  4. 四 把并发从 1 往上加,找耗时曲线的拐点
  5. 五 据此写出服务侧的输入限制与超时配置
A A

Mage 接进自有服务后延迟忽长忽短,通常不是模型本身变慢,而是还没有把「图片预处理与编码、前向、解码」三段拆开看。接服务前先做一件事:把输出长度固定住,分别跑纯文本、单图、多图三类请求,并在这三处各埋一个时间点。先知道时间花在哪一段,再决定是改图片处理流程还是调解码参数。

适用场景:Mage 已能跑通、准备接进自有服务,但图文混排请求的延迟不可控。操作动作:固定 max_new_tokens 与贪心解码,分别跑纯文本、单图、多图,并在预处理、前向、解码三处写时间戳,再单独扫图片边长和并发。验证方式:看多次运行的中位数、首 token 时间和显存读数,而不是单次总耗时。风险边界:单机单卡、固定量化与批处理配置下的结论不能直接外推,换硬件、换版本或开启批处理后需要重测。

固定输出长度,分别测纯文本、单图、多图请求的端到端耗时

这一步要回答的是:耗时差异到底来自图片数量,还是来自输入总长度。做法是把生成侧变量按住不动——max_new_tokens 固定成同一个值、关闭采样(贪心)、prompt 结构一致,只改图片张数。

# 通用接入骨架:mage_client 换成你实际调用的推理 client 或 HTTP 封装
LONG_PROMPT = '请阅读下面的材料并逐条摘要,每条不超过 20 字……'  # 固定文本
SHORT_PROMPT = '描述图片内容,输出要点列表。'
IMG = ['a.jpg', 'b.jpg', 'c.jpg', 'd.jpg']

def run_once(kind, image_paths, text, max_new_tokens=256):
    req = {
        'text': text,
        'images': image_paths,
        'max_new_tokens': max_new_tokens,  # 输出长度固定在这里
        'do_sample': False,
        'temperature': 0,
    }
    return mage_client.generate(req)

CASES = {
    'text_only':   dict(image_paths=[],       text=LONG_PROMPT),
    'one_image':   dict(image_paths=IMG[:1],  text=SHORT_PROMPT),
    'multi_image': dict(image_paths=IMG,      text=SHORT_PROMPT),
}

输出长度固定要落在三处:max_new_tokens 相同、关闭采样、prompt 里要求固定格式,避免模型自由发挥导致解码长度漂移。若模型在到达上限前就输出 EOS,记录实际 out_tokens,比较时只取 out_tokens 接近的轮次。

import statistics

def median_total(fn, n=5):
    xs = []
    for i in range(n):
        r = fn()
        if i == 0:
            print('warmup_ms=', r['total_ms'])  # 首次单独看,不进中位数
            continue
        xs.append(r['total_ms'])
    return statistics.median(xs), len(xs)

三类请求各跑 5 次以上,去掉首次预热,取中位数写进下表。同时记录 out_tokens 和输入图片张数,方便后面回看。

  • text_only:中位数 total_ms = 待填,out_tokens = 待填
  • one_image:中位数 total_ms = 待填,out_tokens = 待填
  • multi_image:中位数 total_ms = 待填,out_tokens = 待填

如果单图明显高于纯文本、多图又明显高于单图,图片数量就是主要变量,直接进入后面的分段计时;如果三类接近,说明瓶颈在文本长度或解码,不要把时间花在图片压缩上。

在预处理、前向、解码三处各打一个时间点

只记总耗时没法区分是图片解码慢,还是首 token 慢,还是逐 token 解码慢。建议在请求入口、预处理结束、前向开始、首 token 返回、生成结束五个位置取时间戳,其中预处理与前向之间、前向与解码之间就是两条分界线。

import time

t0 = time.perf_counter()                      # 请求进入
imgs = preprocess(req['images'])              # 解码、缩放、归一化、切图
t1 = time.perf_counter()                      # 预处理结束
ttft = None
tokens = []
for tok in mage_client.stream(req, imgs, max_new_tokens=256):
    if ttft is None:
        ttft = time.perf_counter()            # 首个 token
tokens.append(tok)
t2 = time.perf_counter()                      # 解码结束

log = {
    'kind': kind, 'in_img': len(imgs), 'out_tokens': len(tokens),
    'pre_ms':   round((t1 - t0) * 1000, 1),   # 预处理
    'prefill_ms': round((ttft - t1) * 1000, 1) if ttft else None,  # 前向到首 token
    'decode_ms': round((t2 - ttft) * 1000, 1) if ttft else None,   # 逐 token 解码
    'ttft_ms':  round((ttft - t0) * 1000, 1) if ttft else None,
    'total_ms': round((t2 - t0) * 1000, 1),
}
print(log)

计时输出用一行 JSON,便于直接落日志、后面按 kind 聚合中位数。一次完整请求的分段记录大致是:

  • kind = one_image,in_img = 1,out_tokens = 待填
  • pre_ms = 待填,prefill_ms = 待填,decode_ms = 待填
  • ttft_ms = 待填,total_ms = 待填

哪一段占用最大,优化就往哪一段走:pre_ms 高说明缩放、解码或切图是瓶颈;prefill_ms 高说明图片编码后占用的输入 token 太多;decode_ms 高说明输出长度或解码策略需要约束。

改输入图片尺寸,观察首 token 时间的变化

图片编码阶段一般会随像素量增长,但未必线性,需要自己扫一遍。做法:同一张图、同一 prompt、同样 max_new_tokens,只改送入前的缩放边长,至少取三个点。

def resize_keep_ratio(img, side):
    w, h = img.size
    s = side / max(w, h)
    return img.resize((int(w * s), int(h * s)))   # 等比缩放,不裁切

for side in (512, 768, 1024, 1280):
    r = run_once('one_image', [resize_keep_ratio(base_img, side)], SHORT_PROMPT)
    print(side, r['ttft_ms'], r['total_ms'], r['in_tokens'])

首 token 时间用流式返回的第一个 token 时间戳减去请求发出时间;如果服务只能整段返回,就退而用 total_ms 对比,但要说明它同时包含解码时间。把结果填进下表,看曲线形状:

  • 边长 512:首 token 待填 ms,端到端 待填 ms,输入 token 待填
  • 边长 768:首 token 待填 ms,端到端 待填 ms,输入 token 待填
  • 边长 1024:首 token 待填 ms,端到端 待填 ms,输入 token 待填
  • 边长 1280:首 token 待填 ms,端到端 待填 ms,输入 token 待填

若 768 到 1024 之间出现明显跳升,就把 768 附近作为服务默认上限;若几乎持平,说明该尺寸区间内图片编码不是主要成本,限制可以放宽。裁剪和缩放要区分清楚,中心裁剪可能丢信息,等比缩放会改变输入 token 数,记录时要注明用的是哪种。

把并发从 1 往上加,找耗时曲线的拐点

单请求的耗时不能代表服务能力,并发上来后排队、显存和批处理策略都会改变曲线。建议从并发 1 开始,按 1、2、4、6、8、12 递增,每档持续跑固定时长或固定请求数,记录 p50、p95 和显存读数。

for c in (1, 2, 4, 6, 8, 12):
    run_load(concurrency=c, duration=60)     # 同一批图片与文本,输出长度与其他档一致
    print(c, p50_ms, p95_ms)

# 显存单独采一列
# nvidia-smi `--query-gpu`=memory.used,memory.total `--format`=csv -l 1
  • 并发 1:p50 待填,p95 待填,显存 待填
  • 并发 2:p50 待填,p95 待填,显存 待填
  • 并发 4:p50 待填,p95 待填,显存 待填
  • 并发 8:p50 待填,p95 待填,显存 待填

拐点判定看三件事:p95 相对上一档的增幅明显超过请求数增幅;显存余量变小并开始出现碎片或重试;队列持续堆积、吞吐不再增长而延迟继续上升。出现其中两条,就把拐点前一档作为服务侧并发上限,再留一档余量给突发流量。显存读数用 nvidia-smi 或框架自带的内存统计都可以,但整轮测试要用同一种口径。

据此写出服务侧的输入限制与超时配置

测试结论要落成配置,否则线上仍然会被大图和长文本打穿。下面这份骨架把图片尺寸、图片张数、输入长度、输出长度和超时分开写,数值按上一步实测的拐点前一档填。

input_limits:
  max_image_side: 1024        # 单边像素上限,超过先等比缩放
  max_image_pixels: 1048576   # 单图总像素上限
  max_images_per_request: 4   # 超过直接拒绝,不在服务里循环处理
  max_input_tokens: 4096
  max_new_tokens: 512
timeouts:
  queue_wait_ms: 3000         # 排队等待上限
  ttft_ms: 15000              # 首 token 上限,超过断开
  total_ms: 60000             # 整段生成上限
  on_exceed: reject           # 超限直接拒绝,不静默截断、不无限重试

超限返回建议用明确的状态码加结构化错误体,例如图片过大或张数超限返回 413,输入文本超长返回 400,并带上 error_code、limit 字段和实际值,让上游知道该缩图还是该拆请求。超时返回 504,由调用方决定是否降级为纯文本请求。不要在服务内部自动截断图片或截断 prompt,这样会把「超限」伪装成「正常但答得差」,排查时更麻烦。