5 MB 图片为什么不会直接吃掉 5 MB 上下文:拆解大模型与 Pi 的图片处理链路
给 Management Console 加图片输入时,我先定了两个限制:一轮最多 5 张,原始单图不超过 10 MiB。随后看到 Runtime 给 Qwen 登记的上下文窗口是 1,000,000 Token,一个问题马上冒出来:一张 5 MB 图片发给模型,会不会直接吃掉几百万 Token,顺手把前面的系统指令挤出去?
这两个数字属于不同阶段。5 MB 描述图片文件和 HTTP 传输;1M 描述模型一次推理能够接收的 Token。图片进入模型前还要经过 Base64 编码、Pi 图片预处理和供应商的视觉编码,文件大小与上下文大小没有直接换算关系。
本文标题沿用日常说法中的 MB;涉及代码限制时使用 MiB,1 MiB = 1024 × 1024 Byte。
先说结论:5 MB 文件不等于 5 MB 上下文
一张图片从浏览器进入大模型,要经过三个阶段:
| 阶段 | 主要计量单位 | 它回答的问题 |
|---|---|---|
| 文件上传 | Byte / MiB | 网络要传多少数据,服务端要占多少内存 |
| API 请求 | Base64 字符、HTTP Body | 调用模型接口时,请求体有多大 |
| 模型推理 | 文本 Token、视觉 Token | 实际占用多少上下文和推理资源 |
5 MB 是压缩后的 JPEG、PNG 等文件大小。它受图片内容、编码格式和压缩率影响,但并不直接代表图片有多少视觉信息。
例如,两张分辨率相同的图片,一张是纯色截图,一张是细节丰富的照片,文件大小可能相差很多;对某些视觉模型来说,它们经过相同的缩放和切块策略后,视觉 Token 数量却可能接近。反过来,一张压缩率很高、文件很小但分辨率很大的图片,也可能被切出不少视觉块。
因此不能用下面这种方式换算:
1 | |
更准确的链路是:
1 | |
具体一张图片最终占多少视觉 Token,取决于模型、图片分辨率、视觉切块方式和供应商接口策略。当前 Pi 代码没有实现 Qwen 图片 Token 计算公式,也没有证据支持“每张图片固定 576 Token”或“固定 1000 Token”这类说法。
Base64 变大了,为什么也不是文本 Token
Pi 在调用 OpenAI 兼容接口时,会把内部图片对象序列化成 Data URL:
1 | |
Base64 通常会让传输体积比原始二进制增加约三分之一。一份 5 MiB 的原始数据,直接编码后大约是 6.67 MiB。这部分增长会增加 HTTP 请求大小、网络流量和服务端内存,但模型接口知道该字段表示图片,不会把整段 Base64 当成普通文章逐字分词。
模型服务先解码图片,再进入视觉预处理和视觉编码阶段。进入模型上下文的是供应商定义的视觉表示,不是 Base64 字符串本身。
所以要同时记住两件事:
- 图片不按文件字节数占用上下文 Token。
- 图片依然会占用上传带宽、请求内存和模型视觉计算资源。
“不会按文件字节吃上下文”不等于“图片没有成本”。
5 MiB 原图在当前实现里会发生什么
当前链路有三组独立限制:
1 | |
5 MiB 原图能通过第一层上传校验。它的 Base64 估算值已经超过 4.5 MiB,进入 processImage 后会触发缩放或重新编码。最终传给 Provider 的图片已经不是那份 5 MiB 原始二进制。
52 MiB 请求上限服务于 HTTP 层。五张 10 MiB 原图加上 multipart 元数据仍有少量余量,超出后请求会在代理或 Runtime 边界被拒绝。这项限制保护进程内存,与 1M Token 上下文没有换算关系。
当前图片请求链路
Management Console 由 Pi Runtime 托管。浏览器默认走同源接口,因此调试链路会先经过 Pi 的 Java 代理,再进入 Java Gateway,最后回到 Pi 的内部会话接口:
1 | |
业务前端或小程序部署后可以直接访问 Java Gateway。Management Console 多出来的第一段 Pi 同源代理,是本地调试页面的部署方式带来的路径。
下面跟踪同一张图片在代码里的数据变化。
1. Management Console:File[] 组装成 multipart
输入框维护两份状态:文字是 liveInput,图片是 liveImages: File[]。点击选择和粘贴图片共用 addLiveImages,因此都会经过“最多 5 张、单张 10 MiB、拒绝空文件”的前端校验。
发送入口 handleLivePrimaryAction 先固定本轮快照,再调用 runSse(query, images)。真正决定请求格式的是 buildConversationBody:
1 | |
纯文字请求继续使用 JSON;出现图片后才切换到 multipart/form-data。图片都放在同名 images 字段,文字放在 query 字段,这与 Spring 和 Pi Runtime 的参数名保持一致。
apiStream 遇到 FormData 时不会手工设置 Content-Type:
1 | |
multipart 的 boundary 由浏览器生成。手工写成 multipart/form-data 却漏掉 boundary,Java 和 Pi 都无法拆出图片字段。
相关入口:
packages/java-agent-runtime/manage-console/src/useLiveDebugTransport.tshandleLivePrimaryActionrunSsebuildConversationBody
packages/java-agent-runtime/manage-console/src/api.tsapiStream
2. Pi 同源代理:保留请求协议
Management Console 请求 /api/ai/conversations 时,Pi HTTP Server 的 proxyJava 会读取请求体,并把原始 Content-Type 交给 Java 管理代理:
1 | |
这里针对 /api/ai 放宽到 52 MiB,其他 Java 管理接口仍维持 1 MiB。图片能力没有扩大所有代理入口的攻击面。
这层会缓冲完整请求体。它适合当前 Management Console 调试,但五张大图并发上传时会产生明确的内存成本。生产业务入口直接到 Java 后,可以绕开这次额外缓冲。
相关入口:packages/java-agent-runtime/src/http-server.ts 中的 proxyJava。
3. Java Gateway:解析业务身份并流式重组 multipart
Java Controller 为同一路径声明两种 consumes。JSON 方法继续处理纯文字,multipart 方法接收文字和图片:
1 | |
示例省略了当前接口里的 PDF 参数和幂等参数,只保留图片链路。
Controller 解析当前用户和租户,Service 将可信业务上下文写入内部请求头。图片内容仍按字节转发,Java 不做视觉识别、缩放或重编码。
writeMultipartRequest 使用 JDK 输入输出流重组请求:
1 | |
实际复制缓冲区是 8 KiB。Gateway 不保留用户文件名,转发时生成稳定序号,避免特殊字符进入 multipart 请求头。
这层的职责集中在四件事:
1 | |
图片规范由 Pi Runtime 维护。Java 再实现一份图片压缩会产生两套格式列表、两套质量参数和两套错误语义,后续很容易漂移。
相关入口:
AiController.createConversationWithImagesAiGatewayProxyService.proxyMultipartEventStreamAiGatewayProxyService.writeMultipartRequest
4. Pi Runtime:从上传文件变成 ImageContent
Java 请求进入 Pi 内部 /ai 接口后,readConversationBody 根据 Content-Type 选择 JSON 或 multipart 解析。multipart 分支读取 query 和全部 images,随后执行权威校验:
1 | |
前端传来的文件名和 Content-Type 只能作为提示。Runtime 使用文件头识别真实格式,避免把改过扩展名的任意文件送进图片解码器。
错误状态也在这里定型:
| HTTP 状态 | 当前含义 |
|---|---|
400 |
multipart 无法解析、字段类型错误、空图片 |
413 |
图片数量或大小超限 |
415 |
文件头无法识别为支持的图片 |
422 |
图片已经识别,但转换或缩放失败 |
整批图片采用全有或全无处理。一张失败后,本轮不会继续拿剩余图片调用模型,页面看到的图片数量与模型实际收到的数量不会悄悄分叉。
相关入口:packages/java-agent-runtime/src/http-server.ts 中的 readConversationBody 与 multipart 解析函数。
5. processImage:规范化、纠正方向、缩放和重编码
Runtime 直接复用 Pi 的 processImage:
1 | |
代码片段省略了具体错误文案和提示组装;源码通过 conversionHint 和 formatDimensionNote 记录格式转换、尺寸变化。
默认参数位于 image-resize-core.ts:
1 | |
处理顺序如下:
- 解码图片并应用 EXIF 方向。手机照片的像素矩阵和显示方向可能不同,这一步防止模型收到侧着的图片。
- 检查宽高与 Base64 估算大小。两项都在上限内时保留原数据。
- 按比例缩到 2000 × 2000 以内。
- 尝试 PNG,以及质量 80、85、70、55、40 的 JPEG 候选。
- 编码结果仍超过 4.5 MiB 时,宽高各缩为当前值的 75%,继续尝试。
4.5 MiB 约束的是处理后 Base64 字符串。它最初为 Anthropic 的 5 MiB 图片限制预留余量,当前 Qwen Runtime 复用了这条偏保守的跨 Provider 默认值。
相关入口:
packages/coding-agent/src/utils/image-process.tspackages/coding-agent/src/utils/image-resize.tspackages/coding-agent/src/utils/image-resize-core.ts
6. Java Runtime:ImageContent[] 进入 AgentSession
图片处理完成后,HTTP 层返回统一的 ConversationInput:
1 | |
PiConversationRuntime.runConversation 打开或恢复会话,再调用 Pi 原生接口:
1 | |
到这里,图片已经进入 Pi 标准消息结构。文字仍是 query,图片仍是独立的 ImageContent[];Runtime 没有把 Base64 拼进提示词。
这项结构化处理还影响会话恢复。Pi 的 SessionManager 会把用户消息及图片内容写入会话 JSONL,后续追问恢复同一会话时,历史图片仍在消息树中。相应代价是会话文件会被 Base64 明显放大。
相关入口:packages/java-agent-runtime/src/runtime.ts 中的 runConversation。
7. Provider:ImageContent 变成 image_url
当前 Qwen 模型通过 Pi 的 OpenAI Chat Completions Provider 调用。Provider 将系统提示词、历史消息和本轮多模态内容组装成请求参数。
用户消息是数组时,文本与图片分别转换:
1 | |
发送给 Provider 的一轮消息形态接近:
1 | |
这段代码回答了“Pi 怎样把文字和图片一起传给模型”:它们属于同一条 user 消息,同时保持为不同内容块。Qwen 服务端拿到 image_url 后才进入自己的图片解码与视觉编码过程。
相关入口:packages/ai/src/api/openai-completions.ts 中的消息转换逻辑。
Pi 如何估算图片上下文
Runtime 注册 Qwen Provider 时声明:
1 | |
contextWindow 是输入上下文容量;maxTokens 是单轮最大输出,两者含义不同。
Pi 在供应商 usage 尚不可用时,需要先估算当前消息大小。packages/ai/src/utils/estimate.ts 的本地规则是:
1 | |
按 4800 / 4 计算,Pi 暂时把每张图片记作约 1,200 Token。五张图片在本地估算里约为 6,000 Token。
1,200 是 Pi 为上下文管理准备的启发式数字,不能用来计算 Qwen 账单,也不能代表模型真实切出了 1,200 个视觉 Token。模型响应返回 usage 后,Pi 会优先使用供应商报告的 Token 数,再估算 usage 之后新增的消息。
图片会不会把前面的系统指令挤出去
当前 Pi 的请求组装和 compaction 没有使用“新图片按字节覆盖旧文字”的处理方式。
OpenAI Provider 从 context.systemPrompt 单独生成 system 或 developer 消息;会话历史来自 context.messages。AgentSession 判断上下文接近 contextWindow - reserveTokens 时,会压缩较早的历史消息,保留摘要和最近消息,再发起下一轮请求。
1 | |
系统提示词仍然占用上下文。1M 也不承诺模型会对第一条和最后一条信息保持完全相同的注意力。供应商实际限制低于 Runtime 配置、usage 报告不准确或服务端自行截断时,请求仍可能失败或丢失信息。
这次能由代码确认的范围是:Pi 把系统提示词与可压缩的消息历史分开管理;图片以结构化内容块进入消息;本地图片 Token 使用约 1,200 的估算值。Qwen 内部怎样切视觉 Token、是否采用某种注意力窗口,当前项目源码无法证明。
回到最初那张 5 MiB 图片:它先受 10 MiB 上传限制约束,随后被 Pi 处理到 2000 × 2000 和 4.5 MiB Base64 负载以内,再以 image_url 内容块发送给 Qwen。它会消耗网络、内存、磁盘和视觉推理资源;上下文占用按照视觉 Token 计算,无法从 5 MiB 文件大小直接推出。