从敲下回车到吐出第一个字:一次 LLM 请求的全链路解剖
你在 Claude Code 里敲下回车,光标闪了大约两秒,第一个字冒出来,然后文字开始像打字机一样往外淌。
这两秒里发生的事,横跨了四个完全不同的世界:一个 Node.js 进程在本地拼装 JSON、一条 TLS 连接跨越大洲、一个调度器把你的请求塞进一批正在跑的活儿里、几万个 GPU 核心把你的文字乘进几十层矩阵。
这篇文章把这条链路从键盘一路拆到显存,逐字段、逐阶段。我会尽量做到:每个字段都告诉你它是什么、干什么用、不填会怎样——因为链路里几乎每个设计都是被某个具体代价逼出来的。
先划清证据边界(重要,直接决定本文哪些话能信)
这条链路跨越客户端和服务端,两半的"可知程度"差别极大。本文的依据是三样东西:本地 ~/.claude/ 的真实运行数据、Anthropic 的公开 API 文档与公开协议标准、以及一份社区对 Claude Code CLI 的逆向还原代码(对应 v2.1.87)。
第三样东西需要格外小心。我做了一次交叉验证:本机装有真实的 Claude Code 原生二进制(v2.1.247),拿它去反查那份还原代码,结果是——真实产物里所有标识符已混淆、所有注释已被完全剥离,只有字符串字面量存活。
于是证据自然分成三档,本文会逐处标注:
| 档位 | 内容 | 可信度 |
|---|---|---|
| A 档 | 字符串常量、beta 头值、SSE 事件名、环境变量名、错误匹配串 | 高。 26 项已逐条在真实二进制中命中比对 |
| B 档 | 字段组装顺序、控制流、条件分支 | 中。 结构合理但无法用官方产物独立验证 |
| C 档 | 所有代码注释与函数命名 | 低。 真实产物里注释根本不存在,无从比对 |
C 档为什么特别可疑: 那个还原项目的 README 自承"后台有 Opus 持续优化"。这意味着那些读起来极有说服力的注释,很可能是大模型根据混淆后的代码结构重写出来的,而不是 Anthropic 工程师的原话。
本文引用注释时会明确写成「还原版本的注释中写道」而非「源码显示」。读到 C 档内容,请当作"一个合理的解释",而不是"官方的说法"。
至于服务端推理栈:Anthropic 从未公开它的实现。所以第五节大部分内容是开源推理引擎(vLLM / SGLang / TensorRT-LLM 这类)的通用原理,用来建立正确的心智模型,不代表 Anthropic 的做法。凡涉及此类内容我都会标注。
一、先看一张地图:两秒钟都去哪了
在钻进细节之前,先建立时间尺度的直觉。下面是一次典型请求的阶段划分——注意各阶段的时间量级差了三个数量级:
你敲下回车
│
│ ① 本地装配:读文件、拼 JSON、算 token ~1-50 ms (CPU 活儿)
▼
│ ② TLS + 网络往返:跨洲物理距离决定下限 ~30-200 ms (光速与路由)
▼
│ ③ 服务端排队:等调度器把你塞进一个批次 ~0-数百 ms (看负载)
▼
│ ④ Prefill:把你几万个 token 一次性算完 ~100-2000 ms(并行,算力密集)
▼
第一个字出现 ←──── 这就是 TTFT(Time To First Token)
│
│ ⑤ Decode:一个 token 一个 token 地挤牙膏 ~10-50 ms/token(串行,带宽密集)
▼
文字流完,stop_reason 告诉你为什么停这张图里有两个关键的不对称,是理解整条链路的钥匙:
钥匙一:Prefill 并行,Decode 串行
你输入的 5 万个 token,可以在同一次前向里一起算(阶段 ④)。
注意这里的"并行"不是说它们互不相关——恰恰相反,注意力机制下每个位置都依赖它前面的所有位置。真正的原因是:输入序列已经全部已知,所以没有任何一个 token 需要等待另一个 token 的输出,可以直接用矩阵形式一次算完。
但输出的每个 token 必须等前一个算完(阶段 ⑤)——因为要把上一个字当输入才能猜下一个字。这个依赖是无法绕过的,它是自回归模型的本质。
所以:输入长度主要影响"等多久出第一个字",输出长度主要影响"要淌多久才淌完"。 两者的性能特征完全不同。
钥匙二:Prefill 拼算力,Decode 拼带宽
Prefill 时,成千上万个 token 一起过网络层,GPU 的计算单元被喂得饱饱的——瓶颈是浮点算力。
Decode 时,为了算一个 token,要把模型权重从显存搬进计算单元一遍(MoE 模型只搬被激活的那部分专家)。算一个 token 的计算量极小,但权重搬运量与"这一步算几个 token"无关——所以瓶颈是显存带宽,算力大量闲置。
除权重之外,decode 每步还要读取全部已有的 KV cache,而这部分的读取量正比于上下文长度。这就是为什么长上下文不仅让首字变慢,也会让后续每个字慢一些。
一个必要的边界条件: 上面这个"带宽受限"的结论在中小批次下成立。批次足够大时,计算量随之上升,decode 也会撞上算力墙——这恰好是连续批处理收益的天花板(见 5.7)。
理解了这个不对称,后面一系列设计的动机就各归各位了:
- KV cache——消除历史 token 的重复计算(拿显存换算力);
- 分页管理——提高显存装载密度,从而提高并发数;
- 连续批处理 / 推测解码——直接冲着"decode 算力闲置"去的两个办法。
记住这两把钥匙,后面所有设计都有迹可循。下面按阶段展开。
二、第一段:客户端如何把你的话变成一个 HTTP 请求
你敲的是一句话,发出去的是一个几十 KB 的 JSON。这一节把这个 JSON 逐字段拆开——每个字段是什么、干什么用、不填会怎样。
先看整体骨架:
POST https://api.anthropic.com/v1/messages
{
"model": "claude-...",
"system": [ { "type": "text", "text": "...", "cache_control": {...} } ],
"messages": [ ... ],
"max_tokens": 8000, // 实际默认值见 2.4,原生默认是 32000
"thinking": { "type": "adaptive" },
"tools": [ ... ],
// tool_choice 被省略(见 2.8)
"metadata": { "user_id": "{...JSON字符串...}" },
"stream": true,
"betas": [ "claude-code-20250219", ... ]
}下面逐个说明。
2.1 model:要哪个模型
是什么: 模型标识符,实测格式形如 claude-opus-5、claude-sonnet-4-6。
作用: 决定路由到哪套权重。它同时隐含决定了上下文窗口大小和默认最大输出——客户端要靠它来算"还剩多少空间、什么时候该压缩"。
注意: 用哪个模型标识由客户端指定,不是服务端挑默认值。但标识符到具体权重快照的映射、以及后续的路由决策(见 5.1 的 service_tier / inference_geo)都在服务端——所以客户端说了算的是"点哪道菜",不是"哪个厨房来做"。
另外实测发现响应里还会出现一个特殊值 <synthetic>——那不是真实模型的输出,而是客户端自己伪造的消息(比如 API 报错时生成的占位消息、或者为了满足"角色必须交替"而补的空回复)。它不来自任何 GPU。
2.2 system:为什么是数组,不是字符串
是什么: 系统提示。很多人以为它是一个字符串,但实际发的是一个 block 数组,每块是 {type: "text", text: "..."}。
为什么要拆成数组: 因为 cache_control 只能打在 block 上。拆块的唯一目的就是在块之间划缓存边界:稳定不变的部分标记成可缓存,易变的部分留在缓存外。
实测中它被切成 2–4 块。还原版本的注释里带着一句醒目的警告(C 档,无法用官方产物验证):
// IMPORTANT: Do not add any more blocks for caching or you will get a 400原因: API 规定整个请求最多只能有 4 个 cache_control 断点。这 4 个额度要在 system 和 messages 之间分配,所以 system 不能随便多切。
装了什么: 身份说明、工具使用规范、环境信息(工作目录、平台、OS 版本、是否 git 仓库)、以及 git 状态。
关键的反直觉点: CLAUDE.md 和记忆索引不在这里。它们在 messages[0] 的一条 user 消息里——这个我在上一篇专门拆过,是个流传很广的误解。
2.3 messages:对话数组,规矩比想象的多
是什么: 按时间顺序排列的对话历史,每条有 role(user 或 assistant)和 content。
作用: 这是模型唯一的"记忆"来源。上一篇的结论在这里再强调一次:模型无状态,历史必须每轮完整重发。
几条容易踩的规矩:
role必须交替。 不能出现两条连续的user。所以客户端会把相邻的同角色消息合并成一条(C 档:还原版本的注释提到某些部署环境对此尤其严格)。- 工具结果属于
user。tool_result不是一个独立角色,它包在user消息的 content 里。逻辑上是"程序把执行结果告诉模型",形式上算用户说的话。 - 最后一条不能是
assistant结尾的空回合。 客户端在恢复会话时如果发现最后一条是 user,会补一条内容为No response requested.的 assistant 消息,纯粹为了满足格式要求。 - 不是所有本地消息都会发出去。 进度提示、纯展示用的虚拟消息、客户端合成的错误消息,都会在装配时被过滤掉。你在终端看到的和实际发出去的,并不是一回事。
2.4 max_tokens:不只是上限,还是一张"预约单"
是什么: 本次请求最多允许生成多少输出 token。这是必填字段。
作用(表面): 到达上限就停,stop_reason 会返回 max_tokens。
作用(真实代价): 它同时可能是一张资源预约单——服务端按这个数字预留推理容量,因为它必须假设你真的会用满。填得过大,占的是本可以服务其他请求的额度。(这层因果关系的依据见下框,属较弱证据。)
这里有一组实测常量(B 档):
| 常量 | 值 | 含义 |
|---|---|---|
| 模型原生默认 | 32,000 | 不做任何干预时的默认输出上限 |
| 模型上限 | 64,000 | 能设的最大值 |
| 实际下调后的默认 | 8,000 | 一个实验开关把默认值压到这里 |
| 撞上限后的升级值 | 64,000 | 一旦真的被 max_tokens 截断,下次单独提到这个值 |
这套设计的巧妙之处在于它是自适应的: 默认给一个偏小的值(8k),因为绝大多数回答用不了那么多;万一真的不够、被截断了,下一次就直接给到 64k。这样既不长期占坑,又不会让长回答写不完。(常量本身属 B 档,这个自适应结论只依赖常量与控制流,不依赖注释。)
而"为什么敢压到 8k",还原版本的注释里给了理由(C 档):线上统计显示输出 token 的 p99 只有约 4,900,而 32k/64k 的默认值过度预留了 8–16 倍的"槽位容量"。
如果这条注释成立,它顺带透露了一个服务端概念
注释里"槽位容量"这个说法若属实,则意味着服务端调度器是按 max_tokens 预占资源的,而不是等你真的生成到那么多才分配。
这会和 5.6 节的 KV cache 显存分页管理直接相关——预留的正是显存。按这个推测,填大 max_tokens 的代价就不只是抽象的"不礼貌"。
但要留意:这条因果链整条建立在 C 档证据上。 可以确证的只有"默认值被压到了 8k"这个事实,以及"压低默认值"这个动作本身——它强烈暗示 max_tokens 有某种超出"输出上限"的额外代价,否则没必要压。动作是真的,解释是推测的。
实践含义: 无论那条注释是否属实,"别无脑把 max_tokens 拉满"这个建议都成立——因为拉满还更容易撞上 3.8 节要讲的那个上下文溢出错误,那是可确证的(错误匹配串属 A 档)。
2.5 temperature / top_p / top_k / stop_sequences:采样旋钮
这几个的含义和作用我在 5.8 节结合 decode 阶段一起讲(那里才是它们真正生效的地方),而且实测发现后三个在主路径上根本不发送——细节见 5.8。这里只说客户端侧的一个关键条件分支:
temperature 只在关闭 extended thinking 时才发送,默认值为 1。
原因是:thinking 开启时,API 只接受 temperature = 1(即这个旋钮不可调整),而 1 正好就是默认值。既然不能调,客户端索性不放这个字段——这比发一个必然等于默认值的参数更干净。
如果你自己调 API 时开了 thinking 又发一个 ≠1 的温度,会直接收到 400。 这类"参数在某个模式下被锁定"的约束在请求体里不止一处,是自建客户端最常见的翻车点。
2.6 thinking:两种形态,判定条件不同
是什么: 控制 extended thinking(深度思考)。
作用: 让模型在正式回答前先输出一段推理过程。这段过程是计费的输出 token(实测响应里有 output_tokens_details.thinking_tokens)。
两种形态:
| 形态 | 请求体 | 含义 |
|---|---|---|
| adaptive | {"type": "adaptive"} | 不指定预算,由模型自己决定想多久。新模型支持这种 |
| budget | {"type": "enabled", "budget_tokens": N} | 显式给思考预算,且 N 必须小于最大输出 token |
客户端按模型能力二选一。还原版本在这段判定旁挂着一句相当罕见的警告(C 档),大意是"改动此处的 adaptive/budget 选择逻辑前必须通知模型发布负责人和研究团队"——如果这句是真的,说明这个选择对模型行为的影响比看起来大得多。
2.7 tools:定义能力,顺序还影响钱
是什么: 工具定义数组,每个工具含名称、描述、以及参数的 JSON Schema。
作用: 告诉模型"你可以要求执行这些动作"。模型不会真的执行任何工具——它只会返回一个 tool_use,说"我想调这个、参数是这些",然后停下来等客户端执行。这就是那 92.8% 的 stop_reason。
一个反直觉的点:工具的排列顺序会影响缓存效率。
客户端刻意把内置工具排成连续的前缀,把 MCP(外部接入)工具排在后面。还原版本的注释给出的原因是(C 档):服务端的缓存策略会把断点打在"最后一个匹配已知前缀的内置工具"之后。如果你把一个自定义工具插在内置工具中间,就会打断这个前缀,让缓存失效。
这意味着 tools 数组是缓存前缀的一部分。 加一个 MCP 服务器、改一个工具描述,都可能让整个缓存重建——这也是为什么挂载大量 MCP 工具的成本比想象的高。
2.8 tool_choice:谁决定调不调
是什么: 强制模型的工具使用行为——可以强制必须调工具、强制调某个特定工具、或者禁止调工具。
Claude Code 的选择:代码里给它赋值 undefined。 而 undefined 不是合法的 JSON 值,JSON.stringify 会直接丢掉值为 undefined 的键——所以真正发出去的请求体里这个字段根本不存在,等价于不传。
不传的含义就是:完全交给模型自己判断。
这是个有意思的设计取舍:agent 的自主性正来自于此。如果强制每轮都调工具,模型就没法给出最终答复;如果禁止,它就变成纯聊天。"不指定"本身是一个主动的选择。
2.9 metadata.user_id:不是给你看的用户名
是什么: 表面上是"用户标识",实际是一个被序列化成字符串的 JSON 对象,里面装了三样东西(这三个键名属 A 档,已在真实二进制中验证):
"metadata": {
"user_id": "{\"device_id\":\"...\",\"account_uuid\":\"...\",\"session_id\":\"...\"}"
}device_id——设备标识,本机持久化生成;account_uuid——账号 uuid,仅 OAuth 登录时非空;session_id——本次会话 id。
作用: 服务端用它做滥用检测、限流、异常行为关联。它不影响模型输出。
为什么值得一提: 这是个典型的"字段名和实际内容不匹配"的例子——照字段名理解会以为它就是个用户名字符串,实际是个三层标识的打包。
一个有意思的细节: 装配时,来自环境变量的额外字段被放在最前面展开,这意味着用户可以通过环境变量覆盖掉上面这三个值。(B 档)这大概是为企业环境或测试预留的,但它同时说明:这些标识是客户端自报的,不是服务端强制的。
2.10 stream:为什么恒定为 true
是什么: 是否流式返回。
Claude Code 的选择:永远是 true,主路径不存在非流式调用。
为什么必须流式:
- 体验——TTFT 之后就能开始渲染,不用等全部生成完;
- 超时——一个长回答可能生成几十秒到几分钟,非流式的单个 HTTP 响应很容易撞上各层代理的超时;
- 可中断——你按 Ctrl+C 时,客户端能立刻断开连接、停止接收。(服务端是否同步停止生成与计费,属于服务端行为,本文没有证据,不下结论。)
代价是客户端必须自己处理增量拼装、以及"消息只收到一半"的各种中间状态——这部分下一节讲。
2.11 betas:一串开关,且一旦发出就不能中途改
是什么: 特性开关列表,形如 claude-code-20250219、context-management-2025-06-27、interleaved-thinking-2025-05-14 等,通过请求头或请求体传给服务端,用来启用尚未 GA 的能力。
作用: 决定这次请求能用哪些新特性(更大的上下文窗口、服务端上下文管理、结构化输出、token 高效工具调用等)。
一个非常值得学的设计:sticky latch(粘滞锁定)。
有几个 beta 开关一旦在会话中发送过,就会整个会话持续发送,不允许中途关掉。还原版本的注释给的理由是(C 档):
中途翻转会改变服务端的缓存 key,打爆已有的 50–70K token 缓存。
这条经验可以直接抄:任何影响缓存 key 的参数,宁可整会话锁死,也不要中途改。 上一篇讲缓存 TTL 时也见过同一条原则,它在这份还原代码里出现了不止一次——如果两处注释都属实,说明这是被真实代价教出来的。
2.12 context_management:把上下文清理外包给服务端
是什么: 配合 context-management beta 使用的字段,允许服务端主动删减你发来的上下文。
作用: 当输入超过阈值时,服务端可以自动清掉一些旧的工具调用结果和思考内容,腾出空间。实测响应里会返回 context_management: {applied_edits: [...]} 告诉你它实际删了什么。
还原代码中的相关阈值(B 档,非运行时实测):最大输入约 180K、目标输入约 40K、单次至少清理 140K。
一个有意思的细节: 服务端清理刻意排除了文件编辑类工具(Edit / Write 这类)的结果。原因不难理解——工具结果被删掉不影响正确性,但文件修改历史一旦丢失,模型就不知道自己改过什么了。
所以上下文管理其实有两层: 客户端自己压缩(上一篇讲的 compaction),服务端还有一层兜底清理。两层的策略和保留优先级并不相同。
2.13 几个不常见但值得知道的字段
output_config——输出行为的集合配置。 非空才发送,里面可以带 effort(努力程度档位)、format(结构化输出格式约束)、task_budget(任务级 token 预算)。它对应的是较新的 beta 能力,把"要多努力、输出什么形状、总共花多少"这三件事收在一起。
speed: 'fast'——命中快速模式时发送。 和 5.1 节讲的响应侧 speed 字段是一对:请求侧声明想要什么档位,响应侧告诉你实际给了什么档位。
anti_distillation: ['fake_tools']——反蒸馏。 这个字段的作用是往模型上下文里注入不存在的假工具定义。
为什么要这么做?一个合理的推测是:如果有人大量采集"请求 → 响应"数据来蒸馏一个小模型,这些假工具会污染训练数据——蒸出来的模型会学到一堆根本不存在的工具。(字段名与取值属 B 档;这个动机解释是我的推断,不是代码里写的。该字段受多个开关门控,不是所有请求都带。)
...extraBodyParams——逃生舱。 允许通过环境变量往请求体里注入任意字段。这是典型的"给高级用户留后门"设计:官方不可能预见所有需求,留一个通道比不断加参数要好。
这几个字段的共同点是:它们都不影响"模型说什么",而是影响"这次请求以什么方式被处理"。 请求体里其实混着两类东西——内容(system/messages)和策略(其余大部分)。分清这两类,读 API 文档会轻松很多。
三、第二段:请求怎么飞过去,答案怎么流回来
JSON 装配好了,现在要把它送出去,并处理一个持续几十秒的流式响应。这一段的工程量比想象中大得多。
3.1 用什么发出去
HTTP 客户端:官方 Anthropic SDK(@anthropic-ai/sdk,还原版本里是 ^0.80.0)。底层传输是 fetch,运行时是 Bun。只有在需要代理或 mTLS 时才会懒加载 undici。(B 档)
SDK 按后端分四种客户端:直连 Anthropic、AWS Bedrock、Google Vertex、Azure Foundry。这解释了为什么前面提到"某些部署环境对消息格式更严格"——同一份 messages 要同时满足四家的约束。
关于 HTTP/2,我必须说一句"没找到": 全仓在 Anthropic API 这条路径上没有任何 HTTP/2、ALPN 或 h2 相关配置。协议版本完全由 fetch 的实现决定,客户端不显式协商。
有意思的是唯一一处 HTTP/2 相关代码是反向的:Bedrock 那条路径上明确把协议降级到 HTTP/1.1。(B 档)
所以那种"Claude Code 用 HTTP/2 多路复用所以更快"的说法,在这份证据里找不到支撑。我不能替它编一个。
3.2 为什么必须自己解析流
前面 2.10 说了"为什么用流式"。这里讲一个实现选择:客户端刻意绕开了 SDK 提供的高级流式封装,改用原始流自己解析。
还原版本里给的理由是:避免高级封装做的增量 JSON 解析产生 O(n²) 开销。(C 档,但这个理由在工程上完全站得住)
这个坑值得解释一下,因为自己写客户端很容易踩:
模型返回工具参数时,JSON 是一个字符一个字符流过来的。如果每收到一个片段就尝试解析一次整个 JSON(好处是能提前拿到部分字段),那么一个 10 KB 的参数会被解析上千次,每次都从头开始——总开销是长度的平方。
Claude Code 的做法很朴素:纯字符串累加,一个字都不解析,等这个块结束了再 JSON.parse 一次。(B 档)
3.3 SSE 事件序列:逐个说明它们干什么
流式响应走 SSE(Server-Sent Events)。事件类型如下(事件名属 A 档,已在真实二进制验证):
| 事件 | 含义 | 客户端拿它干什么 |
|---|---|---|
message_start | 消息开始,带初始 usage | 记录 TTFT,初始化计费累加器 |
content_block_start | 一个内容块开始(文本/思考/工具调用) | 建立空骨架。注意:会清空这个事件自带的内容,只认后续 delta |
content_block_delta | 增量内容 | 往对应块上累加 |
content_block_stop | 一个块结束 | 此时才 JSON.parse 工具参数,然后把这条消息交给上层 |
message_delta | 消息级增量 | 回填 stop_reason 和最终的 usage |
message_stop | 消息结束 | 空实现——真正的收尾在 message_delta 里做完了 |
content_block_delta 里还有子类型,各自对应不同内容:
text_delta——正文文本;thinking_delta——思考内容(extended thinking 的过程);input_json_delta——工具参数的 JSON 片段;signature_delta——思考块的签名,用于后续轮次校验思考内容未被篡改;citations_delta——引用信息,还原版本里只有一个 TODO,未见实现(C 档);connector_text_delta——与服务端侧的文本摘要机制有关。
一个"没有专门处理"的发现
Anthropic 流式 API 还定义了 ping(心跳保活)和 error 两类事件,但在这个 switch 里没有专门分支——它们落到默认分支被统一透传,error 实际由 SDK 抛成异常。
顺带纠正一个常见误解:ping 和 error 不是 SSE 规范定义的事件类型。SSE 规范(WHATWG HTML 标准)只定义了 event / data / id / retry 四个字段,规范层面的保活手段是以 : 开头的注释行。这两类事件是 Anthropic 在其流式 API 中自行定义的,官方文档有明确列出。
至于心跳为什么不需要业务逻辑:收到就说明连接还活着,这本身就是它的全部作用。
3.4 增量拼装的三个坑
这三个细节是自己写流式客户端最容易出错的地方(均为 B 档):
① 工具参数是字符串累加,不是对象合并。 tool_use.input 初始化成空字符串而不是空对象,所有 input_json_delta 直接字符串 +=,最后一次性解析。如果你按"对象深合并"的思路写,会彻底错。
② signature 是赋值,不是拼接。 同样在 delta 里来,但它每次是完整值,直接覆盖。把它当成累加会得到一串垃圾。
③ usage 是累计值,不是增量。 流式过程中多次收到 usage,每次都是到目前为止的总数,不是本次新增。如果你累加它们,token 统计会翻好几倍。
这三个坑的共性是:同样都在 delta 事件里,语义却分别是"累加""覆盖""快照"。 没有统一规则,只能逐个记。
3.5 超时:三套机制,且第一个字没有超时
这部分的设计相当克制(B 档):
| 机制 | 阈值 | 行为 |
|---|---|---|
| 请求总超时 | 默认 600,000 ms(10 分钟),可用 API_TIMEOUT_MS 覆盖 | 超时中断 |
| 空闲看门狗 | idle 90 s / 警告 45 s | 默认关闭,需环境变量显式开启 |
| 停滞检测 | 30 s | 只打点上报,不中断 |
| 首 token 超时 | —— | 不存在。 TTFT 只被测量,不设限 |
(API_TIMEOUT_MS 属 A 档,已在真实二进制验证。)
最后一行值得琢磨:为什么不给第一个字设超时?
因为长上下文的 prefill 本来就可能要好几秒甚至几十秒(回想 5.4)。给 TTFT 设一个短超时,会把正常的长上下文请求误杀。而"迟迟不出第一个字"和"服务端挂了"从客户端看是一样的——分不清,所以宁可不管,交给 10 分钟的总超时兜底。
3.6 重试与退避:具体到常量
关键常量(B 档,数值来自还原代码):
DEFAULT_MAX_RETRIES = 10 // 即最多 11 次尝试
BASE_DELAY_MS = 500
MAX_529_RETRIES = 3
FLOOR_OUTPUT_TOKENS = 3000退避算法: min(500 × 2^(n-1), 32000) 毫秒,再加上最多 25% 的正向抖动(随机延长,不会缩短)。注意抖动叠在封顶之后,所以实际最长等待约 40 秒,不是 32 秒。
抖动的作用是打散重试波峰:如果一次服务端抖动导致上千个客户端同时失败,它们会在同一时刻一起重试,把服务端再打一次。加随机抖动让重试时刻分散开。这是分布式系统的基本礼貌。
一个重要细节:如果响应带了 retry-after 头,它直接覆盖上面的计算结果,并且绕过上面的封顶。 服务端说等多久就等多久——服务端比客户端更清楚自己什么时候能恢复。
哪些情况重试(B 档):408 超时、409 冲突、429 限流(订阅用户除外)、401、403 且 token 被吊销、所有 ≥500(含 529 过载)、以及网络连接错误。
除了标准的 retry-after,客户端还会读两个 Anthropic 自定义头(A 档,已验证):
x-should-retry——服务端直接告诉客户端"这个错该不该重试",比客户端猜状态码语义更准;anthropic-ratelimit-unified-reset——限流窗口何时重置。
还有一件事很有意思:SDK 自带的重试被显式关掉了(maxRetries: 0),全部重试逻辑自己实现。原因不难猜——客户端需要在重试之间做更复杂的事(下面 3.8 就是例子),而 SDK 的重试是个黑盒。
降级路径: 连续 3 次 529(过载)后,如果配了备用模型,就切过去。"宁可用小一点的模型,也别让用户干等" ——这是个产品判断,不是技术判断。
3.7 请求头:每个都有明确职责
客户端显式设置的头(部分属 A 档):
| 请求头 | 作用 |
|---|---|
Authorization: Bearer / x-api-key | 认证。订阅用户走前者,API key 用户走后者 |
anthropic-beta | 特性开关(由 body 里的 betas 数组转成头) |
User-Agent | 形如 claude-cli/<版本> (<用户类型>, <入口>),服务端用来做版本分布统计与问题定位 |
x-app: cli | 标识调用方是 CLI |
X-Claude-Code-Session-Id | 会话标识,用于把同一会话的多次请求关联起来 |
x-client-request-id | 每次请求一个随机 UUID,仅第一方 API 注入 |
最后这个字段值得单独说,因为它解决一个很实际的问题:当请求超时时,客户端根本不知道服务端到底有没有收到、有没有执行。 有了这个客户端生成的 id,就能拿它去和服务端日志对齐,事后查清"那次超时的请求究竟发生了什么"。
这是个值得抄的模式:让客户端生成请求 id,而不是等服务端返回。 因为服务端返回的 id 在"没返回"的场景下恰好是拿不到的——而那正是你最需要它的时候。
代理与证书方面:支持 https_proxy / HTTPS_PROXY(小写优先)与 no_proxy,以及三个环境变量配置的双向 TLS(mTLS)客户端证书。(B 档)
3.8 一个漂亮的自愈:max_tokens 溢出后自动下调
这是整条链路上我最喜欢的一个设计(B 档,但错误匹配串属 A 档已验证)。
问题场景: 你的输入很长,max_tokens 又设得很大,两者相加超过了模型的上下文窗口。API 会返回一个错误,里面带着具体数字:
input length and `max_tokens` exceed context limit: 190000 + 32000 > 200000朴素处理: 报错,让用户自己去改参数。
Claude Code 的处理: 用正则把这三个数字解析出来,算出还剩多少空间,扣掉 1000 token 的安全余量,用新的 max_tokens 自动重试。只有当算出来的空间低于 FLOOR_OUTPUT_TOKENS = 3000(即连 3000 token 都放不下了)才真正放弃。
为什么这个设计好: 服务端在错误信息里给了足够的信息来自我修复,客户端就该用它,而不是把错误原样丢给用户。错误信息里的数字是资产,不是文案。
顺带一提,这也解释了为什么第二节说 max_tokens 别无脑拉满——拉满不仅占坑,还容易直接撞上这个溢出。
另外两条自愈也值得一提:
- 媒体错误的历史回查:如果因为图片/PDF 出错,客户端会用面向用户的错误文案本身当字典键,反查历史消息里对应的媒体块并摘掉它,避免每次请求都在同一个地方重复失败。(B 档,这个做法有点野,但确实有效)
- 上下文超限触发压缩重试:调用链可确证,但具体实现在还原产物里是个空壳(
// Auto-generated stub),所以我无法描述它的内部逻辑——这正是 B/C 档局限的一个具体例子。
四、一个必须先澄清的混淆:embedding 和 rerank 在哪
很多人一提"LLM 请求的全流程",会自然想到这么一条链:
提问 → embedding → 向量检索 → rerank → 喂给 LLM → 回答这条链是存在的,但它不是本文讲的那条。 而且里面的 "embedding" 和 LLM 内部的 "embedding" 是两个不同的东西,同名不同物。这是整个领域最容易混淆的地方之一,值得单独澄清。
4.1 两个 "embedding"
| token embedding | 句向量 embedding | |
|---|---|---|
| 在哪 | LLM 内部的第一层(输入层查表) | 一个独立的小模型(如 bge、text-embedding-3) |
| 输入 | 一个 token id(整数) | 一整段文本 |
| 输出 | 一个向量,代表这个 token | 一个向量,代表这整段话的语义 |
| 谁用 | 模型自己,每次前向传播必用 | 向量数据库,用来算相似度 |
| 在本文链路上吗 | 在,是阶段 ④ 的第一步 | 不在 |
也就是说:你调用 /v1/messages 时,未命中缓存的那部分 token 一定会走 token embedding 查表(它是推理的第一步);但句向量 embedding 完全不会发生——除非你自己在调用 LLM 之前另外跑了一套检索系统。
(补一句严谨的:命中前缀缓存的那部分 token 连 embedding 查表都跳过了,因为它们的 K/V 已经算好存着——详见 5.6。)
4.2 rerank 完全不在这条链路上
rerank(重排序)是检索系统的组件:向量检索先粗筛出几十条候选,rerank 模型再逐条精算相关性、重新排序,把最相关的几条留下。
它是一个独立模型的独立调用,发生在"决定往 prompt 里塞什么材料"的阶段。一次纯粹的 /v1/messages 调用里,服务端不会做任何 rerank。
4.3 那两条链路是什么关系
┌─ RAG 检索链路(可选,发生在调用 LLM 之前)───────────┐
│ 用户提问 │
│ → embedding 模型:把提问变成向量 │
│ → 向量库:粗筛 Top-50 │
│ → rerank 模型:精排留 Top-5 │
│ → 把这 5 段文字拼进 prompt │
└──────────────────────┬───────────────────────────────┘
│ 产出:一段更长的 prompt 文本
▼
┌─ 本文讲的链路(每次调用必然发生)───────────────────┐
│ prompt 文本 → tokenize → token embedding 查表 │
│ → transformer 逐层 → KV cache → 采样 → 流式返回 │
└──────────────────────────────────────────────────────┘上半截是"你决定给模型看什么",下半截是"模型怎么读和写"。 上半截可以完全没有(Claude Code 就没有:它靠模型自己调工具读文件,不用向量检索——上一篇文章详细拆过这个设计)。下半截无论如何都会发生。
理清这一点,后面讲推理内部时就不会串线了。
五、第三段:请求到达服务端之后
这一段开始,我们进入看不见的地方。下面除了明确标注为"实测"的部分,其余都是开源推理引擎的通用做法,用来帮你建立正确的心智模型,不代表 Anthropic 的实现。
5.1 落地第一站:网关、鉴权、路由
请求到达机房,第一件事不是推理,而是一系列"前台工作":验 API key、查配额、限流、然后决定把这个请求发给哪一堆 GPU。
这一站有真实证据。API 返回的 usage 里带着几个字段,直接暴露了路由决策的结果(以下均为实测):
| 字段 | 含义 | 实测取值 |
|---|---|---|
service_tier | 服务层级。 决定你的请求在调度队列里的优先级 | standard(Anthropic 另有更高优先级与批处理档位) |
speed | 速度档位。 与 fast mode 之类的加速通道相关 | standard |
inference_geo | 推理地理位置。 请求最终在哪个区域的机房被执行 | global 占约 63%,not_available 占约 36%,余下不足 1% 为空值 |
inference_geo 这个字段特别值得说。它的存在说明推理是有地理分区的——不是所有请求都进同一个机房。global 大致可理解为"不绑定特定区域、由平台就近或按负载调度",而 not_available 则是这次没返回该信息。
对使用者的实际意义:物理距离决定了延迟下限。 阶段 ② 那 30–200 ms 的网络往返,以及请求被路由到哪个区域,共同决定了你无论如何都躲不掉的固定开销。这也是为什么"同样的 prompt,我这儿就是比别人慢"往往不是模型的问题。
5.2 Tokenize:把文字切成模型认识的数字
模型不认识字符,只认识整数 id。所以第一步是分词。
现代 LLM 用的是 BPE(Byte Pair Encoding) 一类的子词分词:既不是按字符切(太碎,序列太长),也不是按单词切(词表爆炸、遇到生词就完蛋),而是按"高频片段"切。
输入: "帮我把这个 migration 发到生产"
↓ tokenize
tokens: ["帮我", "把", "这个", " migration", " 发到", "生产"]
↓ 查词表
ids: [12043, 383, 5127, 21894, 8871, 45201] ← 模型真正收到的东西
(id 为示意,非真实词表值)几个对使用者有实际影响的性质:
- 同等信息量下,中文往往切出更多 token。 但这强依赖具体分词器:早期词表(如 cl100k 时代)中英差距明显,现代大词表(o200k 一类)已大幅缩小,不同厂商结论也不同。更准确的说法是「中文单个 token 承载的信息量通常更少」,而不是笼统的"中文更贵"。想确认自己场景下的差距,用 count_tokens API 对同一段中英文各测一次即可。
- 空格和换行也是 token。 注意上面
" migration"前面那个空格——它属于这个 token。这就是为什么排版格式会实实在在地消耗预算。 - 分词让字符级操作变得反直觉。 经典的"strawberry 里有几个 r"之所以容易出错,重要成因之一是模型看到的是几个 token 块而不是一串字母。但要注意这不是完整解释——模型其实能把单词拆成字母序列(说明字符信息并非完全不可见),失败也来自缺少"逐项计数"这类算法性操作。提示它"先逐字母拆开再数"通常就对了。
客户端的"token 计数"其实粗糙得惊人
Claude Code 在本地也要算 token——用来判断"是不是快满了、该压缩了"。你可能以为它内置了一份真正的分词器。
实际上没有。 它的本地估算就是一行除法(B 档):
Math.round(content.length / 4) // 每 4 个字符算 1 个 tokenJSON 类内容因为符号密集,改用除以 2。工具侧另有 2.5 字符/token 的变体。就这样,没有词表,没有 BPE。
那精确值从哪来?回调服务端——有专门的 count_tokens API,需要准确数字时问服务端要。项目依赖里没有任何本地分词库。
这个设计其实很合理:真正的分词器要跟模型版本严格对应,本地维护一份就意味着每次模型更新都要同步,还可能不一致。与其维护一个可能错的精确值,不如用一个明确知道自己不准的估算,再在关键时刻问权威方。
所以客户端的策略是分层的:优先采信 API 返回的真实 usage,只对"最后一条 API 返回之后新增的内容"用 length/4 补齐。能拿到权威数据就别自己猜,猜只用来填权威数据的空隙。
5.3 Token Embedding:数字变向量
拿到一串整数 id 之后,第一层运算是查表:模型内部有一张巨大的矩阵(词表大小 × 隐藏维度),每个 token id 对应其中一行。
id 12043 → 查 embedding 表第 12043 行 → [0.021, -0.187, 0.334, ... ](几千维)这就是第四节说的那个 token embedding——它是推理的第一步,每次请求必然发生,和向量检索用的那个"句向量 embedding"毫无关系。
这一层之后,"文字"就彻底消失了。接下来几十层网络里流动的全是浮点数张量。模型不是在处理文字,是在处理数字——文字只存在于输入的最开始和输出的最末尾。
5.4 Prefill:把你的全部输入一次算完
现在进入最耗算力的阶段。
你的输入(可能是 5 万个 token)会并行地通过整个模型:每一层做注意力计算和前馈计算,逐层往上传。因为输入的所有 token 都已知,它们不需要等彼此的输出,可以塞进同一次前向里用矩阵形式一起算——这对 GPU 来说是最舒服的活儿,计算单元能被喂满。
(实现上,很长的 prompt 通常会被切成若干块分几次跑,叫 chunked prefill,好处是能和其他请求的 decode 交错、避免单步延迟尖刺。但对外表现仍是一段连续的 prefill。)
这个阶段的产出有两样:
- 最后一个位置的输出分布——用来采样出第一个新 token;
- 所有位置、所有层的 K 和 V 张量——也就是下面要讲的 KV cache。
Prefill 耗时随输入长度怎么增长? 这里要拆开看,因为流传的说法常常归因错:
prefill 的总计算量由两部分组成——线性项(各层的投影与前馈网络,正比于 token 数 n)和平方项(注意力,正比于 n²)。
- 在常见长度(几千到几万 token)下,线性项通常占主导,所以实测曲线接近线性;
- 只有上下文足够长时,平方项才开始抬头。
一个常见的错误归因
经常能看到"FlashAttention 之类的优化把平方复杂度压平了"这种说法——不对。 FlashAttention 降低的是显存读写量和峰值显存占用,它的渐进计算复杂度仍然是 O(n²)。
真正让"上下文变长但首字没慢那么多"的因素是另一个:前缀缓存命中。命中的部分根本不用重算(这也是本文实测到的 89.5% 缓存读取占比的意义所在)。
无论如何,结论方向不变,它解释了那个非常常见的体感:
上下文越长(准确说是未命中缓存的输入越多),第一个字出现得越慢。
而第一个字出来之后,流速受上下文长度的影响远小于 prefill——但并非无关。下一节会讲到原因:decode 每一步都要读取全部 KV cache,上下文越长,要读的就越多,逐 token 延迟会缓慢上升。
5.5 KV Cache:整条链路上最重要的那块显存
这是全文最关键的机制,值得慢慢讲。
问题是什么。 自回归生成第 N 个 token 时,模型需要"回头看"前面所有 token。注意力机制里,每个历史 token 都提供一对张量:K(Key,被查询的索引)和 V(Value,被取用的内容)。
如果不缓存,那么每生成一个新 token,都要把前面所有 token 重新过一遍整个模型来重算 K 和 V。生成 500 个 token 就要重算 500 次——这是 O(n²) 的彻底浪费,且完全没必要,因为历史 token 的 K/V 根本不会变。
解法。 算过一次就存起来。
Prefill 阶段:算完 5 万个 token 的 K/V,全部存进显存 ← 一次性投入
Decode 第 1 步:只算新 token 的 K/V,追加进去,读取全部历史 ← 增量
Decode 第 2 步:同上,缓存又长一个
...于是每步 decode 的计算量从"重算整个历史"降到"只算一个新 token"。KV cache 是让长上下文推理在经济上可行的前提。
代价:它非常吃显存。 KV cache 的大小正比于:
序列长度 × 层数 × KV 头数 × 每头维度 × 2(K 和 V) × 精度字节数注意是 KV 头数,不是注意力(查询)头数——这个区别很关键。现代模型几乎都用 GQA(分组查询注意力):多个查询头共享同一组 K/V 头,KV 头数远小于查询头数。按查询头数估算会把 KV cache 高估 4–8 倍。
代入一个公开的例子(Llama-3-70B:80 层、8 个 KV 头、每头 128 维、fp16):
每 token ≈ 2 × 80 × 8 × 128 × 2 B ≈ 0.33 MB
10 万 token 上下文 ≈ 32 GB一个长上下文请求的 KV cache 就能吃掉整块 GPU 的显存。(另外,DeepSeek 系用的 MLA 是另一套压缩方案,上面这个公式不适用。)
于是显存成了真正的稀缺资源,并直接决定了一台机器能同时服务多少个请求。这引出下一个问题。
5.6 PagedAttention:把显存当内存来管
朴素做法的问题。 早期实现给每个请求预分配一块连续显存,按"最大可能长度"来留。结果:一个实际只用了 2 千 token 的请求,占着 20 万 token 的坑。
vLLM 的 PagedAttention 论文(Kwon et al., SOSP 2023)测量过这个浪费:当时的主流实现在 KV cache 这块显存上,因内部/外部碎片与最大长度预留,浪费达到 60–80%,而 vLLM 自身可以压到 4% 以下。注意这个数字说的是KV cache 区域,不是"显存总体利用率"的普适常数。
PagedAttention 的解法:照搬操作系统的虚拟内存分页。
- 把 KV cache 切成固定大小的块(block/page),vLLM 的默认值是每块 16 个 token;
- 逻辑上连续的序列,物理上可以散落在显存各处,用一张**块表(block table)**记录映射;
- 需要时按块增量分配,不必预留最大长度。
顺便解开一个矛盾:那 max_tokens 到底占了什么?
读到这里你可能发现和 2.4 节冲突了:那边说 max_tokens 是"预约单"、要预留资源;这边说分页机制的卖点恰恰是不需要预留最大长度。
两者的调和是:现代引擎的显存是按块增量分配的,不会一次性给你划走 64K token 的物理显存。 max_tokens 更可能参与的是调度器的准入判断与抢占预算——调度器需要估计"最坏情况下这个请求能不能不 OOM 地跑完",据此决定现在放不放它进批次、以及负载升高时优先抢占谁。
所以 2.4 说的"占坑",准确讲是占调度额度,而不是静态划走一块显存。(而且别忘了:那边"预留"这层因果的依据本身是 C 档注释,只有"默认值被压到 8k"这个动作是可确证的。)
跨请求的前缀共享:分页只是地基。 分页让"多个请求指向同一份物理 KV 块"在机制上成为可能,但自动识别并复用相同前缀是建立在它之上的另一套机制——vLLM 的 Automatic Prefix Caching(APC)、SGLang 的 RadixAttention 这类。PagedAttention 论文本身讨论的共享场景主要是同一请求内的并行采样与 beam search(配合写时复制)。
这个区分有个实际后果:前缀共享是按块对齐的。 前缀相同但不足一整块的尾部无法复用——所以缓存命中往往不是"全中或全不中",而是"中了前 N 个整块"。
这正是 prompt cache 的物理基础
上一篇文章实测到:一轮对话里 65,612 个 token 命中缓存、只有 3 个是新的,整个工作区的缓存读取占比 89.5%。
在服务端,"命中缓存"的物理含义就是:那部分 token 的 K/V 张量已经躺在显存(或更慢的存储层)里了,把块表指过去即可,跳过绝大部分 prefill 计算。(说"绝大部分"是因为至少还要跑最后一个位置来拿 logits,且尾部不足一块的部分要重算。)
这也解释了为什么缓存是前缀敏感的:块表是按顺序映射的,你在开头插一个字,后面所有块的内容全变,缓存整片失效。这就是为什么 CLAUDE.md 那种大块内容必须放在最前面、且必须逐字节稳定。
而这里正好有一段据称从客户端代码里泄漏出来的服务端痕迹。它信息量极大,但可信度最低,我必须把限制说在前面:
读之前请注意:这是 C 档证据
下面这段注释来自社区逆向还原版本。我拿真实的 Claude Code 二进制去比对,结果是:这段注释里的每一个专有名词,在真实产物中都是 0 命中——因为真实二进制的注释已被完全剥离。
而那个还原项目自承"后台有 Opus 持续优化"。所以这段话有可能是大模型根据混淆后的代码结构编写出来的合理解释,而不是 Anthropic 工程师的原话。
我仍然引用它,因为它描述的机制与公开的 PagedAttention 原理高度自洽、且具体到了不像凭空捏造。但请当作"一个有趣的线索"看,不要当作事实引用给别人。
注释的核心内容是解释"为什么整个 messages 数组只打一个缓存标记",大意是:
服务端(注释里给出了一个内部代号,以及一个 Rust 侧页管理文件的路径与函数名,此处略去)会在轮次之间回收 local-attention KV page——具体是回收那些"处于缓存前缀位置、但不在某个缓存边界集合里"的页。如果打两个标记,倒数第二个位置会被额外保护,它的 local 页会多存活一轮,而实际上永远不会有请求从那个位置恢复。此外还提到 dense page 是引用计数的。
(这些具体标识符我不转录:它们对理解机制没有任何增量,而按上面的验证结果,它们恰恰属于最可能被重写出来的那一类内容。转录一个可能是幻觉的内部代号,只会让它扩散。)
如果这段描述成立,它至少暗示了四件事:
- Anthropic 的推理引擎按 page 管理 KV cache(与 PagedAttention 同一思路);
- 存在页回收策略,且与"缓存边界"绑定;
- 出现 local attention 与 dense page 的区分——暗示模型混用了局部注意力和全局注意力,两类的 KV 页管理方式不同;
- dense page 用引用计数管理生命周期,这与本节前面讲的"多请求共享同一份物理块"吻合。
如果这条线索成立,那么客户端一个"打几个标记"的小决定,背后连着的就是 GPU 显存的分页回收——上下文工程的优化深度可以下探到这一层。
5.6.1 另外几条服务端线索(同为 C 档)
还原版本的注释里还散落着几条指向服务端的痕迹,一并列出,同样只当线索:
① 服务端 API 层大概是 Python。 注释里出现了若干服务端文件路径(具体路径此处略去),其后缀显示面向用户的 API 层是 Python,而推理引擎核心是 Rust——这是相当典型的分工(Python 做协议与业务,Rust 做性能关键路径)。
② 服务端会自己缩放图片。 其中一条注释提到服务端会把超过 1568 px 的图片缩小。这条有公开旁证:Anthropic 的 vision 文档里明确写了长边超过 1568 像素会被缩放——所以它不再只是 C 档线索。有意思的是客户端的本地上限故意设得更宽松(2000 px)——客户端不抢服务端的活儿,只保证不超过硬限制。
③ 退出时会给推理侧发一个"缓存可以扔了"的提示。 注释称客户端会记录最后一个 request id,在会话结束时通知推理侧释放 KV。而实际实现走的是遥测/分析事件通道,而不是 API 请求。
第 ③ 条如果成立,是个挺妙的设计:释放缓存这件事不值得占用一次正式 API 调用,但顺手搭遥测的车捎过去,就能让服务端早点回收显存。 双方都划得来。
5.7 Continuous Batching:你的请求和陌生人的请求挤在一起
单个 decode 步骤的算力利用率极低(还记得钥匙二吗——decode 卡在显存带宽上,算力大量闲置)。解决办法是批处理:把很多请求的 decode 步骤合并成一次大矩阵运算,摊薄权重搬运的成本。
朴素批处理的问题:等凑齐一批再一起跑,跑完一起返回。可请求的长度千差万别——一个要生成 10 个 token,一个要生成 2000 个,前者得干等后者,整批的吞吐被最慢的拖死。
**Continuous batching(连续批处理)**的做法:批次是流动的,不是固定的。
时刻 t : [请求A, 请求B, 请求C] 一起走一步 decode
请求 B 完成 → 立刻从批次里移除
时刻 t+1 : [请求A, 请求C, 请求D] ← D 是刚到的新请求,马上加进来每一步都重新组批,谁完成谁走,谁来了谁进。这让 GPU 几乎不空转,吞吐能提升数倍。
但它有个对使用者可感知的副作用:负载高时,同一个 prompt 的流速会明显变慢。 原因不是"你在抢权重带宽"——恰恰相反,批处理的全部意义就是让权重搬运被同批请求摊薄,邻居多在这一项上其实是帮你的。真实的变慢来自另外三处:
- 批次变大,每步的计算量上升。 摊薄权重的代价是算力占用增加,批次足够大时会从"带宽受限"翻转成"算力受限"——这也是连续批处理收益的天花板。
- KV cache 的读取量不可摊薄。 权重是共享的,但每个请求的 KV 是自己的。批次里有 10 个长上下文请求,就要读 10 份 KV。
- 新请求的 prefill 会打断 decode。 这条才是"一卡一卡"最直接的来源:上面那个"D 是刚到的新请求,马上加进来",代价是它的 prefill 要挤进来占用一整步,让所有正在 decode 的请求都等一下。(缓解办法正是 5.4 提到的 chunked prefill——把长 prefill 切碎,摊到多步里去。)
再加上排队等待、以及显存紧张时的抢占与换出,这些叠起来就是高峰期的体感。不是模型变笨了,是调度变挤了。
5.7.1 顺带一提:Prefill / Decode 分离
既然 prefill 拼算力、decode 拼带宽,两个阶段对硬件的需求根本不同——那为什么要让它们挤在同一批 GPU 上互相干扰?
这就是 P/D 分离(disaggregated prefill/decode)的思路:把两个阶段拆到不同的 GPU 池,各自独立扩缩容,prefill 算完后通过高速互联把 KV cache 传给 decode 池。好处是两边都能按自己的瓶颈优化,而且 prefill 再也不会打断 decode(上面第 3 条副作用直接消失)。
代价是 KV cache 要跨节点传输,对互联带宽要求很高。这是近年推理架构的一个重要方向,本文不展开——提它是因为它正是"钥匙二"那个不对称推到极致的产物。
5.8 Decode 与采样:一个 token 一个 token 地挤
Prefill 结束后,模型进入循环:
循环 {
1. 取上一步采样得到的 token 作为本步输入
2. 前向传播一次:
· 算出这个输入 token 的 K/V,追加进 KV cache
· 同时读取全部历史 KV → 得到词表上每个 token 的分数(logits)
3. 按采样策略从 logits 里挑出下一个 token
4. detokenize 并流式推送给客户端
5. 是终止条件吗?不是就回到 1
}这里的时序很容易写反
常见的错误写法是"采样出 token → 把它的 K/V 追加进 cache"。但这不可能——刚采样出来的 token 还没经过模型,它的 K/V 根本还没算出来。
正确的时序是:K/V 是在第 2 步前向传播中被计算并追加的,追加的是"本步输入 token"的 K/V,而不是这一步刚采出来的那个。那个新 token 要等到下一轮成为输入时,才会贡献自己的 K/V。
第 3 步"挑一个"就是采样。这里逐个说明这些参数的含义和作用——但要先做一个重要区分:
API 支持 ≠ 这个客户端会发
下表是 Anthropic API 支持的采样参数。但实测发现:Claude Code 的主请求路径里,top_p、top_k、stop_sequences 完全不出现(全仓搜索,主循环不发送这三个字段;只有几个旁路场景用到 stop_sequences)。
也就是说,Claude Code 只动 temperature(而且仅在 thinking 关闭时),其余采样参数一律交给服务端默认值。
这本身是个值得注意的产品判断:采样策略不给用户暴露旋钮,也不自己调——对代码类任务,稳定可复现比"可调"更重要。
| 参数 | 含义 | 作用与后果 | Claude Code 是否发送 |
|---|---|---|---|
temperature | 温度。 缩放 logits 的尖锐程度 | 越低越保守(趋向选最高分那个),越高越发散 | 仅 thinking 关闭时发,默认值 1 |
top_p | 核采样。 只在累计概率达到 p 的最小候选集里选 | 动态截断长尾,避免选到概率极低的怪词 | 不发 |
top_k | 只保留分数最高的 k 个候选 | 固定截断长尾,比 top_p 粗暴 | 不发 |
stop_sequences | 停止序列。 生成到这些字符串就立刻停 | 用来卡住输出格式边界 | 主路径不发 |
max_tokens | 输出上限 | 到了就硬停,stop_reason 返回 max_tokens | 必发(见 2.4) |
一个容易踩的坑
在 Anthropic API 里,开启 extended thinking 时 temperature 被锁定为 1(即不可调整),而 1 正好是默认值。top_k 同样不可改,top_p 只允许接近 1 的窄区间。
所以 Claude Code 的做法是:thinking 开着(默认状态)就干脆不放这个字段;只有关闭 thinking 时才发送。
如果你自己调 API 时开了 thinking 又发一个 ≠1 的温度,会直接收到 400。这类"参数在特定模式下被锁定"是自建客户端最常见的翻车点。
5.8.1 推测解码:用闲置的算力去换时间
前面说 decode 是串行的、卡在显存带宽上、算力大量闲置。既然算力闲着,能不能拿来干点别的?
**推测解码(speculative decoding)**就是这个思路,而且它是个非常漂亮的技巧(以下为通用原理,Anthropic 是否采用未知)。
做法:
找一个又小又快的草稿模型(draft model),让它一口气猜出接下来的 k 个 token(比如 5 个)。小模型很快,猜 5 个的代价远小于大模型走 1 步。
把这 5 个候选 token 一次性并行喂给大模型验证——注意,这就变成了一次类似 prefill 的并行计算,正好用上闲置的算力。
逐个决定接受还是拒绝。这一步的规则比"猜对了就要"要讲究得多:
- 设大模型给出的概率为
p、草稿模型的为q。对每个草稿 token,以min(1, p/q)的概率接受它; - 一旦某个位置被拒绝,替换 token 不是直接从
p里采,而是从残差分布normalize(max(0, p − q))里采,然后丢弃后面全部草稿; - 如果 k 个全部被接受,还能在第 k+1 个位置从
p免费多采一个——所以一轮最多产出 k+1 个 token。
- 设大模型给出的概率为
循环。
效果: 如果草稿模型猜中率不错(简单的、可预测的文本很容易猜中),一次验证就能产出好几个 token,相当于把 decode 的有效速度提升数倍。
关键在那个"残差分布"
第 3 步里最容易写错、也最容易被科普文章省略的,就是拒绝后从残差分布重采这一步。
直觉上你会觉得"猜错了就用大模型自己的选择"——这在 temperature=0(贪心解码)时确实退化成那样,本文开头那种直觉描述对应的正是这个特例。
但在一般采样下,"直接用 p 的结果"会破坏分布等价性:它会系统性地偏向草稿模型猜不中的那些区域。等价性恰恰依赖 max(0, p − q) 这个修正项——它精确地补上了"接受步骤所偏移掉的那部分概率质量"。
关于"输出质量不打折",要把前提说全
这个技巧最漂亮的性质是:输出的概率分布与直接用大模型逐个生成完全一致(Leviathan 等人 2023 年的论文给出了证明)。小模型只负责"提议",大模型始终握有否决权。
但这个结论有四个前提,省掉任何一个都会让它变得不准确:
- 是"分布等价",不是"逐 token 一致"。 同一个 prompt 跑两次不会得到同一段输出——它只保证你抽到的样本服从同一个分布。
- 需要精确算术。 浮点误差、不同 kernel 实现、批次大小变化都会破坏逐位可复现性。
- 大小模型的采样参数必须一致(温度、top_p 等)。
- 不适用于有损变体。 typical acceptance、放松阈值、以及 Medusa / EAGLE 这类近似接受策略都会牺牲严格等价来换更高接受率——它们的输出分布确实会变。
所以准确的说法是"输出分布不变",而不是笼统的"质量不打折"。
代价也要说清楚,它不是纯粹免费的:
- 草稿模型本身要占显存,而且猜草稿这一步是串行的、在关键路径上;
- 接受率低时(比如生成高度不可预测的内容),验证白做,净收益可能为负;
- 最重要的:高负载、大批次下算力本来就不闲置了,此时推测解码反而会压低整体吞吐。所以工程上常按负载动态开关它。
回头看"钥匙二"就清楚了:decode 在中低批次下算力闲置、带宽饱和,所以"把串行变并行"的技巧是划算的——哪怕要多算一些最后被丢弃的 token。但批次足够大时,decode 也会撞上算力墙,这个前提就消失了。
顺带说明一个容易混淆的点:Claude Code 的会话记录里有一种叫 speculation-accept 的条目,看名字很像推测解码。但它其实完全无关——那是客户端的一个功能:预测你下一句可能要问什么,提前在沙箱里跑一遍,你真问了就直接给结果,并记录"省了多少毫秒"。同名不同物,一个在 GPU 里,一个在你的本地磁盘上。
5.9 Detokenize 与流式回传
采样出的是 token id,要变回文字给人看,这一步叫 detokenize。
这里有个细节决定了流式输出的观感:一个 token 不一定是一个完整字符。 中文、emoji 等多字节字符可能被切在两个 token 里。所以流式返回时不能拿到 token 就直接解码——必须缓冲,攒够完整字符再发,否则用户会看到乱码。
这也是为什么流式响应传的是增量文本片段,而不是 token id:服务端已经替你处理好了字符边界问题。
5.10 从响应字段反推:服务端还偷偷做了什么
这一节全部是实测——不是猜测。API 返回的字段里藏着不少服务端行为的线索,逐个解释它们的含义:
① iterations:一次请求,服务端内部可能跑了多轮
这个字段是个数组,每个元素是一份完整的 token 计费明细:
"iterations": [
{ "input_tokens": 2, "output_tokens": 562,
"cache_read_input_tokens": 0, "cache_creation_input_tokens": 108888,
"type": "message" }
]含义: 你发出的"一次"API 调用,在服务端可能被拆成多次内部模型调用,每次单独计费,最后汇总返回。实测中数组长度是 0 或 1(即通常就一轮)。
结合另一个实测字段 server_tool_use(内容为 {"web_search_requests": 0, "web_fetch_requests": 0})可以合理推断:当模型使用服务端执行的工具(如联网搜索)时,服务端会自己完成"调用工具 → 拿结果 → 再推理"的循环,对客户端仍然只表现为一次请求。(这一步是推断,不是确证。)
② output_tokens_details:thinking 是单独计费的
"output_tokens_details": { "thinking_tokens": 107 }含义: 输出 token 里有多少属于"思考"过程。这实证了 extended thinking 的思考内容是要计费的输出 token,不是免费赠送的。
③ context_management:服务端会主动编辑你的上下文
"context_management": { "applied_edits": [] }含义: 服务端有能力主动"清理"你发来的上下文(例如删掉旧的工具调用结果来腾出空间),applied_edits 列出它实际做了哪些编辑。实测中这个数组是空的,说明那些请求没有触发清理。
这个机制很值得注意:上下文管理不只发生在客户端,服务端也有一手。 客户端有自己的压缩逻辑,服务端还有一层兜底清理。
④ stop_reason:模型为什么停下来
这是最实用的一个字段。实测分布(7,707 次响应):
stop_reason | 含义 | 占比 |
|---|---|---|
tool_use | 模型要调工具,把控制权交回客户端 | 92.8% |
end_turn | 说完了,正常结束 | 6.8% |
stop_sequence | 撞上了你设的停止序列 | 0.3% |
refusal | 触发安全策略,拒绝回答 | 2 次 |
92.8% 的响应是以"我要调工具"结束的。 这个数字很能说明 agent 类应用的本质:模型绝大部分时间不在"聊天",而在"要求执行动作"。所谓 agent 循环,就是不断地"模型请求 → 客户端执行 → 结果回传",以纯对话收尾的回合不到 7%。
顺带一个细节:refusal 会伴随一个 stop_details 字段,里面带有拒绝的分类(实测见到过 category 字段),说明安全护栏不是一个笼统的开关,而是分门别类的。
六、这条链路解释了哪些日常现象
前面拆了一堆机制。这一节把它们对回你每天能感觉到的现象——如果一个原理不能解释任何现象,它对你就没用。
| 你的体感 | 真正的原因 | 对应上文 |
|---|---|---|
| 等好久才出第一个字,然后哗哗地流 | Prefill 要把未命中缓存的输入一次算完(慢),之后 decode 是逐字挤出(快而稳) | 钥匙一、5.4 |
| 对话越长,第一个字越慢 | Prefill 耗时随未命中缓存的输入量增长——不是随总长度 | 5.4、5.6 |
| 出字的速度也会随对话变长而略降 | Decode 每步都要读取全部 KV cache,上下文越长读得越多 | 钥匙二、5.5 |
| 同样的问题,高峰期明显更慢 | 批次变大→算力占用上升+KV 读取不可摊薄;新请求的 prefill 打断 decode;外加排队与抢占 | 5.7 |
| 只在 prompt 开头改了一个字,整个请求就变慢了 | 缓存按前缀匹配且按块对齐。开头一改,后面所有块全部失效 | 5.6 |
| 停下来想了几分钟,再问一句反而变慢 | 缓存 TTL 只有 5 分钟。过期后整个前缀要重新写入缓存 | 上一篇实测:5 分钟档占 99.4% |
| 开了深度思考,又慢又贵 | 思考内容是真实的输出 token(thinking_tokens 单独计费),而输出必须串行 decode | 5.8、5.10 ② |
| 同样意思的中文比英文更快耗尽额度 | 同等信息量下中文往往切出更多 token(取决于具体分词器) | 5.2 |
| 让它做个任务,感觉大半时间在"空转" | 92.8% 的响应以 tool_use 结束——每次工具调用都是一次完整往返 + 一次(通常大部分命中缓存的)prefill | 5.10 ④ |
| 长文档任务里,它把刚看过的文件又读了一遍 | 上下文压缩把文件内容清出去了,并故意清空"已读"标记 | 上一篇 6.5 |
一个反直觉的推论:输出比输入贵得多
从这条链路能推出一个很多人忽略的成本结构:
- 输入是并行处理的,还能被缓存复用(命中时近乎免费);
- 输出必须一个 token 一个 token 串行生成,每个都要把整个模型的权重搬一遍显存,且无法缓存。
与之相符的是:按 token 单价算,输出通常比输入贵数倍;而如果输入还命中了缓存,差距会拉到十倍以上。(定价终究是商业决策,不能直接由上面的机制推导出来——但两者方向一致。)
上一篇文章的实测数据正好印证了这个结构:总输入约 19 亿 token,总输出只有约 1,120 万——比例约 170:1,但成本结构完全不是 170:1 的样子。
实践含义: 想省钱省时间,"让它少说话"(控制输出长度、别让它复述、别让它写冗长解释)通常比"让它少看东西"更有效——尤其是当你的输入本来就能命中缓存的时候。
七、全景:一次请求的完整旅程
把三段合成一张图。左边是可确证的部分,右边越往下越是通用原理:
【客户端 · 可确证】
你敲下回车
│
├─ 读取 CLAUDE.md ×N 层、memory 索引、对话历史
├─ 装配 JSON:model / system / messages / max_tokens / thinking / tools ...
├─ 打 cache_control 断点(system 1–2 个 + 最后一条消息 1 个)
└─ 本地估算 token,判断要不要先压缩
│
【协议层 · 可确证】
├─ TLS 握手(连接复用时跳过)
├─ HTTPS POST /v1/messages,恒定 stream: true
└─ 跨洲物理往返:30–200 ms 的硬性下限
│
【服务端 · 前台,有实测旁证】
├─ 鉴权、配额、限流
└─ 路由决策 → service_tier / speed / inference_geo
│
【推理引擎 · 通用原理,Anthropic 实现不可知】
├─ tokenize:文字 → token id
├─ embedding 查表:id → 向量(文字在此消失)
├─ 调度器:塞进正在流动的批次(continuous batching)
├─ 缓存检查:前缀命中?
│ ├─ 命中 → 块表指向已有 KV 页,跳过 prefill ← 实测缓存读取占比 89.5%
│ └─ 未命中 → 全量 prefill,写入新 KV 页
├─ Prefill:全部输入并行过几十层网络(拼算力)
│ └─ 产出:首 token 分布 + 全量 KV cache
│
▼ ← 第一个字出现(TTFT)
│
├─ Decode 循环(拼显存带宽,串行):
│ 前向一次 → logits → 采样 → 追加 KV → detokenize → 推送
│ ↑___________________________________________|
│
└─ 终止:end_turn / tool_use / stop_sequence / max_tokens / refusal
│
【回到客户端】
├─ SSE 增量拼装,边收边渲染
├─ 落盘 transcript(含 usage 计费明细)
└─ 若 stop_reason = tool_use → 执行工具 → 结果塞回 messages → 再来一轮最后那条回边值得注意:agent 的一"步",在链路上是一次完整的全程往返。 92.8% 的响应都走这条回边,所以一个多步任务实际上是这张图被重复执行十几次——每次都要重新装配、重新传输、重新(部分)prefill。
这也是为什么 prompt cache 对 agent 类应用几乎是生死攸关的:没有它,这条回边每走一次都要为全部历史重新付一遍全价。
结语
回到开头那两秒。
现在你知道它被切成了几段:几毫秒的本地拼装、几十毫秒的光速极限、可能几百毫秒的排队、然后几百毫秒把你的几万个 token 一次性乘进几十层矩阵——第一个字出现。之后每个字十几毫秒,串行地、一个接一个地挤出来。
这条链路上,有三件事我觉得最值得记住:
第一,文字只存在于两端。 中间全程是整数和浮点数。所谓"模型理解了你的意思",物理上是一串 id 查表成向量、在几十层网络里做矩阵乘法、最后在词表上得到一个概率分布。理解这一点,很多关于模型"想什么"的拟人化困惑会自动消解。
第二,prefill 和 decode 的不对称,解释了几乎所有性能现象。 输入并行、可缓存、拼算力;输出串行、不可缓存、拼带宽。你观察到的每一个"为什么这里快那里慢",基本都能落回这条不对称上。
第三,链路上的每个设计都是被某个具体代价逼出来的。 KV cache 是为了不做 O(n²) 的重复计算;分页是为了不浪费 60% 的显存;连续批处理是为了不让 GPU 空转;只打一个缓存断点是为了不让显存页白白多活一轮。没有一个是为了优雅——全都是被账单和延迟逼出来的。
而对使用者来说,最有用的结论可能是最朴素的那条:你能控制的是链路的最前端。 你决定发多少 token、发的顺序稳不稳定(决定缓存能不能命中)、让它输出多少字。链路后面那些 GPU 上的精妙工程,你既看不见也改不了——但前端这几个选择,实实在在决定了你等多久、花多少。