从抓包看 Claude Code 的 Token 经济学

前言

当我们在 Claude Code 中敲下提示词时,数据便开始流转,智能开始涌现。然后,token 的账单越拉越长。

Token 很贵。但在 AI 蓬勃发展的早期,人们更乐于展示 token 的消耗量,似乎数字越大,能力越强。而作为一款 Code Agent,Claude Code 则需要用有限的算力服务更多的用户,因此在节省 token 这件事上下了不少功夫。

在详述节省策略之前,我们先理清 Claude Code 的数据流转。

整个链路涉及三个主体:程序员、Claude Code(Client)和 Anthropic 的服务器(Server)。下面以一个例子走完完整流程。

image

 

第 1 步:在 Shell 中执行 claude 启动 Claude Code,然后输入提示词。

第 2 步:Claude Code 将这条消息连同 System Prompt 和 Tool List 打包成 JSON,POST 到 api.anthropic.com/v1/messages。请求体大致如下(已简化,但结构与字段名取自真实抓包):

{
  "model": "claude-opus-4-7",
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "text",
          "text": "<system-reminder>可用的 skills、环境信息……</system-reminder>"
        },
        {
          "type": "text",
          "text": "帮我创建一个python版本的helloworld",
          "cache_control": { "type": "ephemeral", "ttl": "1h" }
        }
      ]
    }
  ],
  "system": [
    {
      "type": "text",
      "text": "You are Claude Code, Anthropic's official CLI for Claude."
    },
    {
      "type": "text",
      "text": "You are an interactive agent that helps users with software engineering tasks...",
      "cache_control": { "type": "ephemeral", "ttl": "1h", "scope": "global" }
    }
  ],
  "tools": [
    {
      "name": "Read",
      "description": "Reads a file from the local filesystem...",
      "input_schema": {
        "type": "object",
        "properties": {
          "file_path": { "type": "string" },
          "offset": { "type": "integer" },
          "limit": { "type": "integer" }
        },
        "required": ["file_path"]
      }
    },
    { "name": "Bash", "description": "...", "input_schema": { } },
    { "name": "Edit", "description": "...", "input_schema": { } }
  ],
  "metadata": { "user_id": "{\"session_id\":\"...\"}" },
  "max_tokens": 64000,
  "stream": true
}

可以看到,整个 JSON 包里关键的部分有三块:systemtoolsmessagessystem 是 System Prompt,给模型立规矩的全局指令。tools 是 Tool List,列出模型可调用的工具及其参数定义。messages 是对话历史,按时间顺序记录每一轮交互。其中 system 和 tools 在一次会话里几乎不变,messages 却随着交互越堆越长。这道出了大模型的无状态本质,每次请求都得把全部历史重新塞进 messages 一并送出。于是对话轮数越多,单次请求越臃肿,token 开销也越大。

第 3 步:Server 解包后,按 Anthropic 内部模板把请求重新拼接成一段长字符串,这才是模型实际看到的输入。

这里要厘清一点:第 2 步 JSON 里字段的排列顺序并无意义,因为 JSON 对象本就是一组无序的键值对,messages 写在前还是 system 写在前,对接口没有任何影响。真正固定的是服务端的拼接顺序:tools → system → messages,三者依次连成模型读到的那段文本。这个顺序并非随意而定,后文的 Prompt Caching 正是建立在它之上。

一.、Prompt Caching

上文提到,对话轮数越多,单次请求里堆积的输入 token 就越多。但只要深入 Transformer 推理的内部原理就会发现,这些 token 对应的计算大部分是可以复用的。

前缀为什么能复用

推理分两个阶段。prefill 把整段输入读进模型,逐层算出每个 token 的 KV,decode 再据此逐个生成输出 token。算 KV 这一步极耗算力,而 KV 只取决于输入,因此可以缓存下来,留给下次请求复用。

复用能成立的前提,是两次请求开头的 token 完全一致。因为注意力存在因果性,算第 n 个 token 的 KV 要用到它前面所有 token,中途只要有一个 token 不同,它之后每个 token 的 KV 都得重新计算。所以两次请求能复用的,只是从开头逐 token 比对、到第一个不同处为止的那段前缀。这也是它叫 Prefix Caching(前缀缓存)的原因。

image

 

用 cache_control 标出缓存前缀

不过缓存不会凭空发生,客户端得在请求里用 cache_control 把要缓存的前缀标出来,不标就不缓存。这是因为把一段前缀的 KV 留住,要持续占用服务端的显存,所以 Anthropic 做成了按需开启。

cache_control 有两种用法。一种是自动模式,在请求顶层挂一个 cache_control 字段,由服务端自己把断点贴在最后一个可缓存块上,并随对话推进自动前移。另一种是手动模式,在指定的内容块上逐个打断点,一次请求里最多 4 处。Claude Code 用的是手动模式,而且它实际上只用了 3 个断点。

手动打的断点直接挂在某个内容块上。下面是抓包里的第一处断点,落在 System Prompt 前半部分的末尾:

{
  "type": "text",
  "text": "You are an interactive agent that helps users with software engineering tasks. ... Reference code as `file_path:line_number` — it's clickable.",
  "cache_control": { "type": "ephemeral", "ttl": "1h", "scope": "global" }
}

Claude Code 的三处断点

为了把缓存的命中率榨到最高,Anthropic 在两件事上下了功夫:prompt 的排列顺序,和 cache_control 的断点位置。先说排列顺序,这一点前面已经埋下伏笔。一次会话里,tools 和 system 从头到尾几乎不变,对话历史却每轮都在追加。把不变的 toolssystem 拼在前面,动态的 messages 放在最后,前缀就更趋稳定。

断点的位置同样有讲究。Claude Code 在一次请求里打 3 处断点。最后一处好理解,落在 messages 末尾,并随每轮对话后移,于是截至上一轮的全部历史都能当前缀复用,只有最新一轮按全价计算。

让人意外的是另外两处,都在 system 区域,其中第一处还落在区域中间。看似别扭,却是有意为之。System Prompt 分为两部分,前半部分是 Claude Code 的身份、交互准则、Harness 规则等,对每个用户都一样,后半部分则掺进了工作目录、操作系统、记忆路径这些因人而异的信息。第一处断点恰好卡在两部分的接缝上。

image

 卡在接缝处,是因为前后两段能共享的范围不一样。第一处断点的 cache_control 比别处多一个 scope 字段,值为 global。这个字段官方文档里查不到,含义只能从抓包和上下文推断。文档明说缓存默认按工作区隔离,跨工作区不共享。scope: global 看字面意思,是要打破这种隔离,把这段前缀的缓存共享到更大范围。这么做也合理,System Prompt 前半部分对每个 Claude Code 用户逐字相同,是公共内容,缓存共享出去不泄露任何东西。若 global 真指全局共享,你新开一个会话发出的第一个请求,就可能直接命中别人早先写下的缓存。后半部分掺了私有信息,只能留在自己的范围里缓存,所以第二处断点不带 scope

缓存的存活时长 ttl

再看 cache_control 里的 ttl,即缓存的存活时长。Anthropic 给了两档,5 分钟和 1 小时,不指定就是 5 分钟,Claude Code 现在把断点统一设成了 1 小时。这对用户很友好,因为写代码时人常停下来读代码、思考,会话一旦空闲超过 5 分钟,5 分钟档的缓存就整个失效,下一条消息只能从头计算 KV。1 小时档则会大大减少失效的情况。当然,1 小时也并非没有代价,它的缓存写入按 2 倍价格计算,而 5 分钟则按 1.25 倍价格计算。

从 usage 看省下多少

省了多少,最后写在响应里。模型回包带一个 usage 对象,记下这次请求的 token 账单。下面是抓包里某次请求的真实 usage

"usage": {
  "input_tokens": 2,
  "cache_creation_input_tokens": 169,
  "cache_read_input_tokens": 12781,
  "output_tokens": 117
}

三个输入字段,对应 token 的三种命运。

cache_read_input_tokens 最大,这 12781 个就是前面三处断点缓存下来的前缀,也就是 toolssystem 加上截至上一轮的历史,本轮原样命中,只按 0.1 倍的缓存读价结算。

cache_creation_input_tokens 这 169 个,是本轮新追加、写进缓存留给下轮的内容,写入价由 ttl 档决定,1 小时档是 2 倍。

input_tokens 只有 2 个,乍看费解,第三处断点不是已经打在最后一个内容块上了吗。翻看了一遍抓包,发现只要断点落在最末的内容块上,input_tokens 始终是 2。可见这 2 个不是内容,而是对话模板在用户消息收尾后补的回合标记,把对话从用户回合切到模型回合。它们排在所有缓存断点之后,进不了缓存前缀,只能按全价 1 倍计算。

至于 output_tokens,它是模型生成的输出,单独计价,本文用的 Opus 4.7,输出价是输入价的 5 倍。

于是这一轮一万两千多个输入 token,按费率折算只相当于 1618 tokens。

image

 二、Dynamic Loading

Prompt Caching 省的是重复计算的钱,Dynamic Loading 则是让用不到的东西从一开始就不进入 prompt。它作用在两类对象上:tool 和 skill。

tool

一次 Opus 请求的 tools 数组里只装了 10 个 tool 的完整定义:Agent、AskUserQuestion、Bash、Edit、Read、ScheduleWakeup、ShareOnboardingGuide、Skill、ToolSearch、Write。每个定义是一份 JSONSchema,写明了它的名字、用途和参数结构。抓包里这 10 个 tool 合计两万四千多字节,相当于几千个 token。

但 Claude Code 的 tool 其实不止这些。另外 25 个被挡在 tools 之外,它们只有名字出现在 messages 的第一条用户消息里:

<system-reminder>
The following deferred tools are now available via ToolSearch...
CronCreate
CronDelete
...
NotebookEdit
...
WebSearch
mcp__claude_ai_Gmail__authenticate
...
</system-reminder>

这套让部分 tool 先不载入、用到再取的做法,官方叫做 deferred loading。至于其中的 system-reminder,它由 Claude Code 客户端注入,既非用户所发,也非模型生成。当模型读到这对标签时,会将里面的内容当作系统级上下文来理解。实际上,Claude Code 的 System Prompt 里专门有一句话点明了这点。

`<system-reminder>` tags in messages and tool results are injected by the harness, not the user.

整段 reminder 连同这 25 个名字才 777 字节,因此对于 token 的消耗是极小的。当模型真要用其中某个 tool 时,就会先调用一次 ToolSearch,按名字精确取回或按关键词搜索。ToolSearch 本身也是那 10 个常驻 tool 之一。也就是说,连加载 tool 这件事,本身都由一个 tool 来完成,颇有些自举的感觉。

取回的 schema 不会被塞进 tools 数组。因为 tools 排在整个请求的最前面,是缓存前缀的一部分,中途改动它,后面的缓存就全作废了。所以新取的 schema 接在 messages 后面,由一段 system-reminder 包裹。模型从这段 reminder 里读到完整定义,之后才能像普通 tool 一样调用它。

这 35 个 tool,绝大多数是 Claude Code 自带的内置 tool,还有几个名字带 mcp__ 前缀的来自 MCP。对模型来说,内置 tool 和 MCP tool 并无区别,都是一个名字加一份 JSONSchema 的 tool 定义。因此,deferred loading 对它们一视同仁。

skill

skill 走的是同样的思路,它也出现在 messages 的第一条用户消息里,用 system-reminder 包裹。

<system-reminder>
The following skills are available for use with the Skill tool:

- archive-to-obsidian: Summarize the current conversation and archive it to the Obsidian knowledge base
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json...
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys...
...
</system-reminder>

上面这段抓包列了 11 个 skill,约三千字节,相当于几百个 token,但这只是一份目录。每个 skill 的正文是一份完整的指令文档,目录的开销和它相比几乎可以忽略。只有当模型用 Skill 这个 tool 点名调用某个 skill 时,那份正文才作为返回结果接到 messages 的末尾。

值得注意的是,同样是目录,deferred tool 那份只列了名字,而 skill 这份却在每个名字后面附带了描述。为什么要如此设计?因为 tool 是一个动作,看到 WebSearch 就知道它做什么,名字几乎等于功能,因此描述可以省略。

而 skill 则不行,名字说不出它什么时候该用。就看上面那个 update-config,光凭名字只知道它和配置有关。但它的描述把触发条件挑明了:像“from now on when X”“before/after X”这类带自动化意味的要求,都得靠 hook 实现,而 hook 配在 settings.json 里。于是用户说“以后你每改完代码就自动跑一遍测试”,模型拿这句话跟描述一对,就认出它属于“before/after X”那一类,该走 update-config。只有名字的话,这一步可能接不上,因为那句描述才是让模型真正判断的依据。

共同的思想

tool 的 deferred loading 和 skill 的按需加载,骨子里是同一个思想:渐进式披露(progressive disclosure)。这个词本是界面设计的老概念,意思是别把所有东西一次摊给用户,先给一个简洁的入口,要深入了再逐层展开。Claude Code 把它搬到了上下文工程中:完整内容先不进 prompt,只设置一个极廉价的占位:一个名字,或者再加一句描述,等模型真正需要时,再把背后那份 schema 或指令正文放进来。

三、 杂活交给便宜的模型

一次会话里,主对话当然要用 Opus 这种顶级模型。但一个 Agent 跑起来背后还有不少杂活,每一件都让 Opus 出马并不划算。譬如,Opus 4.7 的单价大约是 Haiku 4.5 的 15 倍,因此很多杂活都可以交给它。

切到 Haiku 的几类杂活

抓包里 Haiku 主要承担了三类杂活。

第一类是轻量请求。启动 Claude Code 时会有一次 Haiku 请求,内容只有一个词叫 quota,中文含义是配额。

"model": "claude-haiku-4-5-20251001",
"messages": [{ "role": "user", "content": "quota" }],
"max_tokens": 1

Claude Code 要的根本不是模型的回答,而是响应头里那一组 anthropic-ratelimit-unified-* 字段。max_tokens=1 把回复压到一个 token(实际就回了 #),等于把模型的开销降到最低。

anthropic-ratelimit-unified-5h-utilization: 0.23
anthropic-ratelimit-unified-7d-utilization: 0.3
anthropic-ratelimit-unified-status: allowed

三行分别是 5h / 7d 窗口的用量比例和当前是否允许请求。

同类的还有会话标题。第一次提问之后,Claude Code 会补上一次 Haiku 请求,system prompt 是一段固定指令:

Generate a concise, sentence-case title (3-7 words) that captures
the main topic or goal of this coding session...

Haiku 回一个 3 到 7 词的短标题,留作日后会话列表里的显示名。

第二类是内容提纯。主对话 Opus 跟外网打交道的工具有两个:WebSearch 用来搜索,WebFetch 用来读网页。两者都是 Claude Code 定义的客户端工具,但内部实现都把活儿外包给了 Haiku 子任务,Opus 拿到的永远是 Haiku 嚼过一遍的提纯版。

先看 WebSearch。客户端发起一次 Haiku 请求,system 就一句 You are an assistant for performing a web search tool usetools 里给 Haiku 装的是 Anthropic API 内置的 server tool:

{ "type": "web_search_20250305", "name": "web_search", "max_uses": 8 }

这个 web_search 跟上文中提到的 WebSearch 不是同一个东西,它是 Anthropic API 内置的 server tool,工具循环跑在 Anthropic 服务器内部。模型调用它时,不需要回到 Claude Code 走一轮 tool_use 交互,服务器侧自己执行完就可以一次性返回。

之所以让 Haiku 来发起这个请求,是因为搜回来的东西很大。抓包里某次搜索拿到 10 条结果,每条除了标题和 URL,还附带一段约 1000 token 的网页摘要(web_search 帮你把网页内容预读了一遍),算上其他元数据,10 条合起来一万四千多 token。如果让 Opus 在主对话直接调 server tool,这些 token 既要按 Opus 单价算,还会留在主对话上下文里被后续每轮带着走。而交给 Haiku 子任务提纯一遍后,只输出约 400 token 交回主对话。

image

再看 WebFetch。Opus 调用它时传 url 和 prompt 两个参数,后者由 Opus 写明想从这个网页里提炼什么,例如:

列出文档中最近的更新、变更日志、新功能或新特性,重点关注 2026 年的变化。

客户端拿到调用后,先做 domain_info 域名校验,再 HTTP GET 把网页正文拉下来,然后发起一次 Haiku 请求,user 消息里把网页正文(可能数十万字符)和那段 prompt 拼在一起,让 Haiku 按指定方向精炼。最终提炼出几千个字符送回主对话。

image

 

Opus 不用自己去搜、自己去读、自己去消化,只要把"我想知道什么"写清楚,这两个工具内部都会用 Haiku 处理一遍再交回。

第三类是机械循环。譬如主对话里启动一个 Explore sub agent,抓包里立刻出现一连串 Haiku 请求,system prompt 是:

You are a file search specialist for Claude Code...

接下来八轮请求一气呵成,全部都是 Haiku:ls 看目录、再看子目录、读每个 Python 文件、再读几个细节,最后整理成一份回答。八轮全程 Haiku,只有最末汇总好的那份结果通过 Agent 工具的返回值进到主对话。

Claude Code 内置的 sub agent 不止 Explore 一个,但很多并不都跑在 Haiku 上。譬如 Plan 这种做架构设计的用 Opus,code-reviewer 这类要看出代码深层问题的也用 Opus。Explore 之所以能下放给 Haiku,是因为它同时满足几个条件:任务模式固定(grep、ls、read、汇总,几乎不涉及创造性决策),工具循环密集(动辄八轮十几轮,单价 15 倍的差距在这里被放大),主对话只看最终汇总(中间过程粗糙一些感知不到)。三者叠加,把 Haiku 的性价比拉到最大。换成 Plan 那种任务,单纯求便宜会降低智能,反而要让主对话多轮纠正,整体更贵。

留给 Opus 的两类反例

不是所有杂活都交给 Haiku。抓包里有两类杂活看上去同样轻量,却仍然留给 Opus。

第一类是 auto mode 的权限检查。每次主对话发出一个潜在风险动作(Bash、Edit、Write、Agent 等),Claude Code 都会另外发起一次请求,由模型判断这个动作要不要 block。请求带的是独立的一套规则:

model: claude-opus-4-7
max_tokens: 64
system: 33 KB 的 "You are a security monitor for autonomous AI coding agents..."
messages: 一条 user,里面用 <transcript> 文本包了对话片段和待评估动作

模型只需输出 <block>yes</block> 或 <block>no</block> 这种短答,所以 max_tokens 给 64 就足够了。

这件事跟主对话完全独立,按单价算 Haiku 能省 15 倍,本是便宜模型该接的活。但 Claude Code 偏要 Opus 上,是因为安全决策出错的代价远大于 token 差价,错放一个该 block 的动作可能就是数据外泄或不可逆破坏。这一类“智能 > 价格”的场景中,Opus 首当其冲。

第二类是辅助信息的两个机制,分别是 recap 和输入建议。

recap 在窗口闲置三分钟时触发(起始点是上一次请求结束,不是用户最后一次操作)。客户端发起一次请求,指令以 "The user stepped away and is coming back" 开头,让模型以 40 词以内的描述告诉用户上一段对话在做什么、当前进展和一个下一步动作。输出只显示给用户看一眼,不进主对话的 messages,所以不影响主对话后续的缓存命中。

输入建议是用户停留在输入框等待输入时,Claude Code 自动发起的一次请求,预测用户接下来要打什么字,结果作为 ghost text 显示在输入框里。指令开头一句话如下:

Your job is to predict what THEY would type - not what you think they should do.

它要预测的是用户会打的下一句话,不是 AI 自认为用户该做的事。输出限制 2 到 12 个词,不能是评价、问题、Claude 口吻、新想法或多句,不确定时直接沉默。

这两件事抓包看下来是同一套套路:

model: claude-opus-4-7
max_tokens: 64000              # 复用主对话设置
system / tools: 跟主对话逐字相同
messages: 主对话整段历史 + 末尾追加一条指令 user

为什么要带这么多上下文?这两件事的输出虽然只有几十个词,但要写得靠谱,模型得知道用户在干什么、进展到哪,只能从对话原文里提炼,没法事先压缩。客户端把主对话现成的 system + tools + 整段 messages 整套复用过来,最省事,也顺手命中了主对话已经写好的前缀缓存。

40 词的 recap、12 个词的输入建议,听上去和会话标题一样轻量,像是 Haiku 该干的活。事实上 recap 功能上线的前几个版本确实是 Haiku,后来才切回 Opus。而用 Opus 的原因就在这套上下文上:如果换 Haiku,模型变了缓存全废,这些前缀输入得作为 cache_creation 重写一遍,总账反而比让 Opus 顺着原前缀再跑一次贵。

模型选择的两条边界

回头看前面的那么多杂活:会话标题、WebSearch、WebFetch、Explore 用了 Haiku,auto mode 权限检查、recap 和输入建议留给了 Opus。从这些例子能总结出两条决策考量。

一是智能。auto mode 权限检查的上下文完全独立,按单价 Haiku 该接手,但安全决策出错的代价远大于差价,所以宁愿用 Opus。

二是缓存。recap 和输入建议要复用主对话上下文,换模型就废了主对话已经写好的前缀缓存,把前缀 token 重新缓存一遍的成本远高于单价省下的钱。

也就是说,选用哪个模型从来不是只看单价。选用 Haiku,要先过智能这一关,再过缓存这一关。

四.、文件读取的两道阀门

读文件是 Claude Code 最容易把 token 烧穿的动作。一份 10 MB 的 log 不分段读进来,几万 token 就这么没了。因此 Read 工具在客户端这一侧设了两道阀门:

  1. 1. 文件 ≤ 256 KB
  2. 2. 返回内容 ≤ 25000 tokens

文件大小上限 256 KB

文件超过 256 KB 且没传 offsetlimit,工具直接报错,模型一个字节都拿不到。以下是工具返回的示例错误:

File content (981.4KB) exceeds maximum allowed size (256KB).
Use offset and limit parameters to read specific portions of the file,
or search for specific content instead of reading the whole file.

这一条不会做截断,超过就直接报错。错误信息同时点明了出路:要么用 offsetlimit 分页,要么换 grep。

内容上限 25000 tokens

文件大小没超 256 KB,但展开后超过 25000 tokens,工具会按行截到 25000 以内,并附一段 <system-reminder>,把下一页参数算好交给模型:

<system-reminder>[Truncated: PARTIAL view — showing lines 1-112 of 501 total
(94850 tokens, cap 25000). Call Read with offset=113 limit=112 for the next page,
or Grep to find a specific section.]</system-reminder>

模型复用 offset=113 limit=112 就能接着读,不用自己算分页边界。这跟 Dynamic Loading 的渐进式披露思路类似。

2000 行到底有没有用

工具描述里其实还提到第三条限制,写的是“默认读不超过 2000 行”,但实测不起效。一份 5000 行的小文件 Read 一次全量返回,没有任何截断。

这条限制只写在给模型看的工具描述里,客户端工具并不会真去拦。它更像是给模型的一个保守提示,让 Claude 面对未知大小的文件时倾向先分批读。行数本身并不能准确反映 token 数,同样 2000 行,纯英文短行和长 JSON 行的 token 数能差出十倍,所以最终只能拿 token 当上限。

五、 图片如何瘦身

图片算 token 主要看像素。因此瘦身的核心,就是在图片传出去之前把像素减下来。Claude Code 客户端会在送出前做两件事:缩像素和转格式。缩像素直接关系到 token。转格式做的是 PNG → JPEG 这一步,PNG 是无损压缩,对照片、噪点这类高熵内容几乎压不动,转成有损的 JPEG 后体积通常掉一个数量级。这一步跟 token 无关,只是降低 HTTP 请求体积,避开文件大小相关的限制,所以放在这里一并讲。

怎么传图片

HTTP 和 JSON 都是文本协议,JSON 字符串不能直接塞原始二进制,否则编码会乱、还会撞上控制字符。所以图片要先 base64 编码成纯 ASCII 文本再放进请求体。base64 把每 3 字节二进制映射成 4 个可打印字符,代价是体积 +33%。抓包里一张 92 KB 的 PNG 转出来是 122728 个字符。

请求里图片放在 image 类型的 content block 中:

{
  "type": "image",
  "source": {
    "type": "base64",
    "media_type": "image/png",
    "data": "iVBORw0KGgoAAAANSUhEUgAA..."
  }
}

为什么 token 不按 base64 字符算

这段 base64 有 12 万字符,要是按字符串 tokenize,至少几万 token,远超模型实际报的几百。实际上 base64 只是传输格式,根本不是模型读到的内容。

服务端拿到请求后,先 base64 decode 回二进制图片,再交给 Vision Encoder。Vision Encoder 是一个独立于语言模型的视觉子网络(ViT 一类的架构),它把图像切成固定大小的 patch,每个 patch 经过一次前向计算输出一个或一组 embedding。这些 image embedding 跟文本 token 的 embedding 并排拼进同一条序列,再送进语言模型。

所谓“图片 token”就是 Vision Encoder 输出的 embedding 个数,跟 base64 字符数无关,只跟图像的像素总数有关。Anthropic 文档给的近似公式是:

tokens ≈ (width × height) / 750

一张 1000×1000 的图大约 1334 token,一张 2000×2000 的图大约 5333 token。

服务端的限制

官方文档写明了服务端的兜底缩放规则:

image

 

 

超过上限的图,服务端保持长宽比缩到能塞下的最大尺寸。丢一张 4000×4000 的截图给 Opus 4.7,到模型那里其实只剩 2576×2576。

两种图片上传方式

服务端是兜底,但客户端还有更加严格的限制。对 Claude Code 而言,图片上传到服务器有两种方式,方式不同,token 的开销也不同。

第一种方式,是用户主动给。ctrl+v 粘贴截图,或者 @ 一个本地图片文件,Claude Code 客户端会拦截这两种输入,把图作为一个 image 块直接塞进 user message。这种情况多半是用户特意拿了张图让 claude 仔细看。

第二种方式,是调用 Read 工具读图。模型在任务中自己判断要 Read 一张图,或者用户在提示词里写图片路径(比如 “分析这张图 /tmp/screen.png”),模型看到路径后也会主动调 Read。两种都通过 tool_result 把图返回到对话里。这种情况多半是模型自己在探索。

两种方式的瘦身都分两步:第一步像素归一化,把长边压到 2000 以内;第二步体积控制,让文件不至于太大。第一步两种方式规则完全一样,差异在第二步。第一种方式宽松(单一阈值单一动作),第二种方式严格(多档逐级尝试)。

像素归一化

长边 ≤ 2000 原图不动,长边 > 2000 缩到 2000 px。这步是硬性要求,两种方式都需要走这一步。

像素归一化后还要走第二步体积控制。两种方式的差异从这里开始。

第一种方式的体积控制

规则只有一条:归一化后的 PNG 文件如果超过 512 KB,转成 JPEG(像素不变),否则原样发。

实测三条印证规则:

image

 

不管原图多大,最后最多变成 2000×2000 的 JPEG,对应约 5300 token,这就是它的 token 上限。文件大小没写死,但缩放过程会平滑高频,加上 JPEG 有损编码,实测稳定在 500 KB 以内。

第二种方式的体积控制

多档瘦身:客户端按 ①②③④ 顺序依次生成候选,挑第一个文件 ≤ 150 KB 的发出去,后面的就不再尝试。

image

 

四档的瘦身力度逐档加大。① 是上一步像素归一化的直接产物,最大可能 2000×2000。如果它超阈值,进 ② 把长边对半砍;再超进 ③ 砍到 1/4;都不行就走 ④,长边压到 600 px 同时把 PNG 转成低画质 JPEG。④ 是兜底,缩到 600 px 时细节会被自动平滑掉,加上 JPEG 有损编码,输出体积稳在几十 KB。

四档各举一例实测数据:

image

 

看表里 ② 那行可能会觉得不对劲:原图 161 KB,长边 ÷2 后像素总数变成 1/4,按理文件也该缩到 1/4 也就是 40 KB 左右,实际却是 101 KB,约是原图的 63%。÷4 那行也类似,240 KB 缩到 60 KB,是 25% 而非 1/16。

原因是 PNG 文件大小跟像素数不是线性关系。PNG 用 DEFLATE 算法压缩,体积主要由内容复杂度决定。缩放后图像主体的低频信息基本还在,只是高频细节被平滑掉了,PNG 压缩率反而稍微提高,所以文件不会按面积同比例缩小。实测里 ÷2 PNG 体积约是原图的 60-65%,÷4 约是 25-30%。

这也是为什么 ÷2 不一定能压到阈值以内。如果像素跟体积严格 1/4 对应,一张 161 KB 的图 ÷2 就是 40 KB,根本不需要 ÷4 这一档。正因为压缩率非线性,÷2 的瘦身力度远不如直觉那么大,才需要 ÷4 甚至 ④ 兜底的级联。

看不清时模型怎么办

第二种方式瘦身这么重,要是模型读图就是想看清原图里某处小字呢?

我做了一个实验,绘制一张 2000×2000 像素的 PNG,左下角藏着一行 16 px 的小字密码。文件 2.1 MB,按规则会被 Read 瘦到 600 px JPEG,16 px 字会被缩到约 5 px,糊到看不清。而我在提示词里让模型用 Read 读这张图把密码告诉我,没加任何“主动想办法”之类的暗示。

当它看到模糊图后,先用 Bash 查原图实际尺寸,然后得出结论“Read 工具自动缩小到 600 导致看不清”。然后主动尝试“用 PIL 裁切左下角局部、存成一个新文件、再 Read 那个新文件”的方法。裁切后的图片由于像素少、文件小,Read 不再瘦身,于是模型拿到密码。可以看到,当模型看不清时,会渐进式地查找局部来补充细节。

 

后记

本文所有抓包基于 Claude Code v2.1.150(2026 年 5 月)。

这篇文章花了不少时间,构造了各种实验,抓包了各种数据。但说实话,在 Claude Code 飞速进化的今天,深入分析就像在追一个移动的靶子,写下来的东西挂一漏万、难以全面,有时也会想,自己费劲写这些干嘛。

不过随着写作的深入,把脑海里那些杂乱的思绪整理成有条理的逻辑时,会慢慢明白:真正的收获不在最终的文章,而在这个从混沌走向清晰的过程。

另外,Anthropic 的很多行为我都反感,但 Claude Code 依旧是我非常喜欢的产品。它的产品理念是简洁和自然。它带着一些封闭,但这可能就是代价。而这种自然,则是一种克制和反复打磨的表现。"Anthropic is the new Apple",我觉得这句话没错。

 

posted @ 2026-06-24 19:53  当下是吾  阅读(35)  评论(0)    收藏  举报