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
5 MB 图片 ≠ 500 万个上下文字节 ≠ 500 万 Token

更准确的链路是:

1
2
3
4
5
图片文件
-> 图片解码与预处理
-> 模型供应商的视觉编码器
-> 视觉表示 / 视觉 Token
-> 与文字 Token 一起参与推理

具体一张图片最终占多少视觉 Token,取决于模型、图片分辨率、视觉切块方式和供应商接口策略。当前 Pi 代码没有实现 Qwen 图片 Token 计算公式,也没有证据支持“每张图片固定 576 Token”或“固定 1000 Token”这类说法。

Base64 变大了,为什么也不是文本 Token

Pi 在调用 OpenAI 兼容接口时,会把内部图片对象序列化成 Data URL:

1
2
3
4
5
6
{
type: "image_url",
image_url: {
url: `data:${mimeType};base64,${data}`,
},
}

Base64 通常会让传输体积比原始二进制增加约三分之一。一份 5 MiB 的原始数据,直接编码后大约是 6.67 MiB。这部分增长会增加 HTTP 请求大小、网络流量和服务端内存,但模型接口知道该字段表示图片,不会把整段 Base64 当成普通文章逐字分词。

模型服务先解码图片,再进入视觉预处理和视觉编码阶段。进入模型上下文的是供应商定义的视觉表示,不是 Base64 字符串本身。

所以要同时记住两件事:

  1. 图片不按文件字节数占用上下文 Token。
  2. 图片依然会占用上传带宽、请求内存和模型视觉计算资源。

“不会按文件字节吃上下文”不等于“图片没有成本”。

5 MiB 原图在当前实现里会发生什么

当前链路有三组独立限制:

1
2
3
原始上传:最多 5 张,单张不超过 10 MiB
会话 HTTP Body:不超过 52 MiB
processImage 输出:最大 2000 × 2000,Base64 负载小于 4.5 MiB

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Management Console
File[] + query
│ multipart/form-data

Pi Runtime 同源代理 /api/ai
│ 保留 Content-Type 和 multipart boundary

Java AI Gateway /api/ai
│ 解析用户/租户,注入签名业务上下文,重新组装 multipart

Pi Runtime 内部接口 /ai
│ 校验文件头、大小与数量,调用 processImage

ConversationInput
query: string
images: ImageContent[]
│ session.prompt(query, { images })

Pi AgentSession
│ OpenAI Chat Completions 内容块

Qwen Provider
text + image_url(Data URL)

业务前端或小程序部署后可以直接访问 Java Gateway。Management Console 多出来的第一段 Pi 同源代理,是本地调试页面的部署方式带来的路径。

下面跟踪同一张图片在代码里的数据变化。

1. Management Console:File[] 组装成 multipart

输入框维护两份状态:文字是 liveInput,图片是 liveImages: File[]。点击选择和粘贴图片共用 addLiveImages,因此都会经过“最多 5 张、单张 10 MiB、拒绝空文件”的前端校验。

发送入口 handleLivePrimaryAction 先固定本轮快照,再调用 runSse(query, images)。真正决定请求格式的是 buildConversationBody

1
2
3
4
5
6
7
8
9
10
function buildConversationBody(query: string, images: File[]): BodyInit {
if (images.length === 0) return JSON.stringify({ query });

const formData = new FormData();
if (query) formData.append("query", query);
for (const image of images) {
formData.append("images", image, image.name);
}
return formData;
}

纯文字请求继续使用 JSON;出现图片后才切换到 multipart/form-data。图片都放在同名 images 字段,文字放在 query 字段,这与 Spring 和 Pi Runtime 的参数名保持一致。

apiStream 遇到 FormData 时不会手工设置 Content-Type

1
2
3
4
5
if (!headers.has("Content-Type")
&& init.body
&& !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}

multipart 的 boundary 由浏览器生成。手工写成 multipart/form-data 却漏掉 boundary,Java 和 Pi 都无法拆出图片字段。

相关入口:

  • packages/java-agent-runtime/manage-console/src/useLiveDebugTransport.ts
    • handleLivePrimaryAction
    • runSse
    • buildConversationBody
  • packages/java-agent-runtime/manage-console/src/api.ts
    • apiStream

2. Pi 同源代理:保留请求协议

Management Console 请求 /api/ai/conversations 时,Pi HTTP Server 的 proxyJava 会读取请求体,并把原始 Content-Type 交给 Java 管理代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const requestBody = await readRequestBody(
request,
url.pathname.startsWith("/api/ai")
? MAX_CONVERSATION_REQUEST_BODY_BYTES
: MAX_JSON_REQUEST_BODY_BYTES,
);

await management.proxyJava(
method,
`${url.pathname}${url.search}`,
authorization,
requestBody,
accept,
abortController?.signal,
cookie,
getHeader(request, "content-type"),
);

这里针对 /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
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostMapping(
value = "/conversations",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public ResponseEntity<StreamingResponseBody> createConversationWithImages(
@RequestParam(value = "query", required = false) String query,
@RequestParam(value = "images", required = false) List<MultipartFile> images,
HttpServletRequest request
) {
AiBizPrincipal principal = resolvePrincipal(request);
return proxyService.proxyCreateConversationMultipartStream(
query, images, null, null, principal
);
}

示例省略了当前接口里的 PDF 参数和幂等参数,只保留图片链路。

Controller 解析当前用户和租户,Service 将可信业务上下文写入内部请求头。图片内容仍按字节转发,Java 不做视觉识别、缩放或重编码。

writeMultipartRequest 使用 JDK 输入输出流重组请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
conn.setChunkedStreamingMode(IO_BUFFER_SIZE);
try (OutputStream output = conn.getOutputStream()) {
for (int index = 0; index < images.size(); index++) {
MultipartFile image = images.get(index);
writeUtf8(output, "--" + boundary + "\r\n");
writeUtf8(output,
"Content-Disposition: form-data; name=\"images\"; "
+ "filename=\"image-" + (index + 1) + "\"\r\n");
writeUtf8(output,
"Content-Type: " + safeMultipartContentType(image.getContentType())
+ "\r\n\r\n");
try (InputStream input = image.getInputStream()) {
copy(input, output, false);
}
writeUtf8(output, "\r\n");
}
}

实际复制缓冲区是 8 KiB。Gateway 不保留用户文件名,转发时生成稳定序号,避免特殊字符进入 multipart 请求头。

这层的职责集中在四件事:

1
2
3
4
解析登录身份
解析租户与业务上下文
签名内部上下文
转发 multipart 与 SSE

图片规范由 Pi Runtime 维护。Java 再实现一份图片压缩会产生两套格式列表、两套质量参数和两套错误语义,后续很容易漂移。

相关入口:

  • AiController.createConversationWithImages
  • AiGatewayProxyService.proxyMultipartEventStream
  • AiGatewayProxyService.writeMultipartRequest

4. Pi Runtime:从上传文件变成 ImageContent

Java 请求进入 Pi 内部 /ai 接口后,readConversationBody 根据 Content-Type 选择 JSON 或 multipart 解析。multipart 分支读取 query 和全部 images,随后执行权威校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const imageFields = form.getAll("images");
if (imageFields.length > MAX_IMAGE_COUNT) {
throw new HttpRequestError(413, "too many images");
}

for (const [index, file] of imageFields.entries()) {
if (file.size === 0) throw new HttpRequestError(400, "image is empty");
if (file.size > MAX_IMAGE_BYTES) {
throw new HttpRequestError(413, "image is too large");
}

const bytes = new Uint8Array(await file.arrayBuffer());
const mimeType = detectSupportedImageMimeType(bytes);
if (!mimeType) throw new HttpRequestError(415, "unsupported image");

const processed = await processImage(bytes, mimeType);
if (!processed.ok) throw new HttpRequestError(422, "image processing failed");
images.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
}

前端传来的文件名和 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export async function processImage(bytes, mimeType, options) {
const normalized = await normalizeImage(bytes, mimeType);
if (!normalized) return { ok: false, message: "..." };

const autoResizeImages = options?.autoResizeImages ?? true;
if (!autoResizeImages) {
return {
ok: true,
data: Buffer.from(normalized.bytes).toString("base64"),
mimeType: normalized.mimeType,
hints: [],
};
}

const resized = await resizeImage(
normalized.bytes,
normalized.mimeType,
options?.resizeOptions,
);
if (!resized) return { ok: false, message: "..." };

return {
ok: true,
data: resized.data,
mimeType: resized.mimeType,
hints: [], // 省略格式转换和尺寸变化提示的组装代码
};
}

代码片段省略了具体错误文案和提示组装;源码通过 conversionHintformatDimensionNote 记录格式转换、尺寸变化。

默认参数位于 image-resize-core.ts

1
2
3
4
5
6
const DEFAULT_OPTIONS = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: 4.5 * 1024 * 1024,
jpegQuality: 80,
};

处理顺序如下:

  1. 解码图片并应用 EXIF 方向。手机照片的像素矩阵和显示方向可能不同,这一步防止模型收到侧着的图片。
  2. 检查宽高与 Base64 估算大小。两项都在上限内时保留原数据。
  3. 按比例缩到 2000 × 2000 以内。
  4. 尝试 PNG,以及质量 80、85、70、55、40 的 JPEG 候选。
  5. 编码结果仍超过 4.5 MiB 时,宽高各缩为当前值的 75%,继续尝试。

4.5 MiB 约束的是处理后 Base64 字符串。它最初为 Anthropic 的 5 MiB 图片限制预留余量,当前 Qwen Runtime 复用了这条偏保守的跨 Provider 默认值。

相关入口:

  • packages/coding-agent/src/utils/image-process.ts
  • packages/coding-agent/src/utils/image-resize.ts
  • packages/coding-agent/src/utils/image-resize-core.ts

6. Java Runtime:ImageContent[] 进入 AgentSession

图片处理完成后,HTTP 层返回统一的 ConversationInput

1
2
3
4
5
6
7
8
9
10
11
type ConversationInput = {
query: string;
images: ImageContent[];
businessContext?: ConversationBusinessContext;
};

type ImageContent = {
type: "image";
data: string; // Base64
mimeType: string;
};

PiConversationRuntime.runConversation 打开或恢复会话,再调用 Pi 原生接口:

1
2
3
4
await session.prompt(input.query, {
source: "rpc",
images: input.images.length > 0 ? input.images : undefined,
});

到这里,图片已经进入 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
2
3
4
5
6
7
8
9
10
11
12
13
const content = msg.content.map((item) => {
if (item.type === "text") {
return { type: "text", text: item.text };
}
return {
type: "image_url",
image_url: {
url: `data:${item.mimeType};base64,${item.data}`,
},
};
});

params.push({ role: "user", content });

发送给 Provider 的一轮消息形态接近:

1
2
3
4
5
6
7
{
"role": "user",
"content": [
{ "type": "text", "text": "这张图片里有什么?" },
{ "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,..." } }
]
}

这段代码回答了“Pi 怎样把文字和图片一起传给模型”:它们属于同一条 user 消息,同时保持为不同内容块。Qwen 服务端拿到 image_url 后才进入自己的图片解码与视觉编码过程。

相关入口:packages/ai/src/api/openai-completions.ts 中的消息转换逻辑。

Pi 如何估算图片上下文

Runtime 注册 Qwen Provider 时声明:

1
2
3
4
5
{
input: ["text", "image"],
contextWindow: 1_000_000,
maxTokens: 32_768,
}

contextWindow 是输入上下文容量;maxTokens 是单轮最大输出,两者含义不同。

Pi 在供应商 usage 尚不可用时,需要先估算当前消息大小。packages/ai/src/utils/estimate.ts 的本地规则是:

1
2
3
4
5
6
7
8
const CHARS_PER_TOKEN = 4;
const ESTIMATED_IMAGE_CHARS = 4800;

for (const block of content) {
chars += block.type === "text"
? block.text.length
: ESTIMATED_IMAGE_CHARS;
}

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 单独生成 systemdeveloper 消息;会话历史来自 context.messages。AgentSession 判断上下文接近 contextWindow - reserveTokens 时,会压缩较早的历史消息,保留摘要和最近消息,再发起下一轮请求。

1
2
3
4
5
systemPrompt
+ 历史摘要
+ 最近的 user / assistant / tool 消息
+ 本轮文字和图片
-> Provider 请求

系统提示词仍然占用上下文。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 文件大小直接推出。


5 MB 图片为什么不会直接吃掉 5 MB 上下文:拆解大模型与 Pi 的图片处理链路
https://willfordzhan.github.io/2026/08/12/pi-image-input-context/
作者
詹文杰
发布于
2026年8月12日
许可协议