Claude Code 消息管线完全解析

本文是 Claude Code 消息管线(Message Pipeline)的完整学习笔记,覆盖从用户输入到模型回复的全链路。以自然语言讲解为主,补充源码中的关键常量与数值以辅助理解。

一、整体架构:一条流水线,七个阶段

消息管线描述的是从用户按下回车到模型回复显示在屏幕上,数据流经的全部环节:

用户输入 → 入口层(QueryEngine) → 四级压缩管线 → API请求组装(normalizeMessagesForAPI)
→ HTTP流式接收 → 工具执行循环 → 后台收尾
                    ↑                │
                    └── 有tool_use时循环 ──┘

核心循环在 query.ts 中实现,是一个 do-while 结构:每次 API 调用前运行一轮压缩,拿到回复后如果包含 tool_use 就执行工具,把结果带回循环顶部继续,直到模型返回 stop_reason 为 end_turn。


二、阶段一:用户输入到达

2.1 统一入口 QueryEngine.submitMessage()

不管来自 REPL 终端、SDK 调用还是 CLI 管道,所有输入都收敛到 QueryEngine.submitMessage()。它完成四件事:创建用户消息对象、推入内存数组 mutableMessages、持久化到磁盘 JSONL、对消息数组打快照后传入核心查询循环。

2.2 单一真相源模式

mutableMessages 是整个对话的权威数据源。所有操作都直接修改这个数组,API 层拿它做快照,压缩层在快照上做投影,REPL 同步读取做 UI 渲染。

2.3 双重视图分离

这是核心架构思想。REPL 永远能看到完整历史,发给 API 的是经过压缩和投影的精简版。Snip 投影去掉被删除的消息,Collapse 投影折叠已完成的任务区间。二者互不干扰。

2.4 为什么 UI 消息和对话消息存在同一个数组里

mutableMessages 不是纯粹的"对话数组",而是事件时间线。里面有七种类型的消息:user(用户对话输入)、assistant(模型回复,含文本/思考/工具调用)、progress(工具执行进度,如 “Running bash…”)、system(系统通知,如"上下文已压缩")、attachment(Hook 注入的记忆文件或技能文件)、以及标记了 isVirtual 的 user 和 assistant 消息(内部生成的、绝不发给 API 的消息)。

三个消费者从同一条时间线上各取所需:REPL 渲染所有类型的消息来显示完整事件流;API 请求层只取 user、assistant、attachment,过滤掉 progress、system 和虚拟消息;JSONL 持久化则全量记录以支持 session 恢复。

虚拟消息(isVirtual)特别值得关注。当模型调用 REPL 工具时,REPL 内部会执行多个原语工具(Read、Bash、Grep 等),每个原语工具产生一对 assistant(tool_use) + user(tool_result) 消息,被标记为 isVirtual。REPL 需要渲染这些内部调用链,但模型不需要看到自己刚做的事,所以 API 层把它们全过滤掉。

自己做 agent 时,关键是实现消息的读写分离:写入时所有事件无条件追加到同一条时间线,读取时每个消费者定义自己的过滤函数只取需要的子集。单一真相源 + 多视图,比维护多份独立数组要简单和稳定得多。


三、阶段二:四级压缩管线

每轮 API 请求前,消息按顺序经过四级递进式压缩,从最轻量(免费、客户端操作)到最重量(LLM 调用、花钱)。

不可变顺序的原因:Snip 删掉消息减少 Microcompact 的扫描量;Microcompact 的 cache_edits 必须在 Collapse 之前注入;Collapse 可能解决问题从而避免 AutoCompact 的 LLM 调用;AutoCompact 兜底。

3.1 第一级:Snip Compact —— 模型主动删除消息

让 AI 模型自己判断哪些历史消息不再需要并主动标记删除。系统在每条用户消息末尾注入短 ID 标签(UUID 前 10 位 hex 转 base36,约 6 个字符)。模型调用 SnipTool 传入要删除的消息 ID 列表,系统从 mutableMessages 中移除这些消息并生成 snip_boundary 记录。

关键设计之一是磁盘不可变:JSONL 文件保留所有消息和 boundary 记录,不真正删除。Resume 时 applySnipRemovals() 根据 boundary 记录的 removedUuids 重建删除视图并修复 parentUuid 链。parentUuid 修复逻辑是:对每条幸存消息,如果它的 parentUuid 指向了被删除的消息,就沿着被删链往回走直到找到第一个幸存者,重新指向它。没有被删除的节点绝不会被跳过。

Snip 是零 API 费用、纯客户端的一级,也是唯一由模型自主决策的一级。

3.2 第二级:Microcompact —— 清除旧工具结果而不破坏缓存

面临的核心矛盾是:旧的工具结果(Read 的大文件、Bash 的输出)占用了大量 token 但模型消费过后原文不再需要;如果直接把内容替换为"[已清除]",消息的字节序列变了,prompt cache 的 hash 不匹配,整个缓存前缀全部作废。

Microcompact 根据不同情况选择两条路径。当缓存已冷(距离上一条 assistant 消息超过 60 分钟)时走 Time-Based 路径:服务端 prompt cache 的 TTL 已过期,不管怎么改都不会有缓存代价,直接在客户端替换内容为占位符。当缓存还热时走 Cached 路径:完全不动消息体本身,而是利用 Anthropic API 的 cache_edits 机制在请求中附带删除指令。

可压缩工具的白名单包括 Read、Bash、Grep、Glob、WebSearch、WebFetch、Edit、Write——这些工具的输出是一次性消费的上下文参考。不在白名单中的如 AgentTool、TaskTool,它们的结果可能在后续需要引用。

cache_edits 的工作原理

cache_edits 不是编辑缓存内容,而是告诉服务端在 attention 计算时跳过某些 token 位置。每条 tool_result 在首次发送时带有 cache_reference 标签,服务端记录了标签到缓存位置的映射。后续请求中,客户端在消息体里插入 cache_edits block,格式为 edits 数组,每个 edit 包含 delete 类型和目标 cache_reference。消息体本身的字节序列不变,所以 prompt cache 仍然命中。

服务端解析 cache_edits 后,查映射表将标签翻译为 token 位置区间,在每层 attention 计算时对这些位置施加 mask(设为负无穷),使这些 token 对当前 token 的 softmax 权重归零。cache_edits 指令本身作为特殊标记被服务端拦截,从不送入模型。模型从头到尾不知道这些操作的存在。

cache_edits 的效果只管一轮请求,下一轮如果不重发,被标记跳过的缓存条目会恢复生效。因此系统用 Pin 机制——记录每个 cache_edits block 在哪个消息位置注入,后续每轮请求都在完全相同的位置重新注入相同的删除指令。位置不能变,否则该位置的字节变了,缓存依然会断裂。

为什么必须在服务端做

客户端只知道语义(“tool_abc123 这个工具结果不要了”),但不知道 token 到位置的映射、KV Cache 存在服务端的哪个物理位置、以及服务端用什么 hash 算法定位缓存。服务端持有完整的 token→位置→缓存页映射,负责将语义翻译成具体的 token 位置再变成 attention mask 作用在每一层的计算上。这不是模型层面的操作,是推理引擎层面。

3.3 第三级:Context Collapse —— 异步多段折叠

传统 Compact 的"一刀切"总结有两大局限:所有细节一次性丢失变成面条式总结,无法保留重要区间只折叠已完成的部分,而且同步 LLM 调用让用户干等。

Context Collapse 的核心创新:用一个独立运行的 ctx-agent 在后台分析对话,识别已完成的任务区间并生成总结;主线程不等待。

两级状态机制是画龙点睛之笔。Stage(暂存)是 ctx-agent 分析完成但尚未应用的折叠,存在 collapseStore.stagedQueue 中。Commit(已应用)是 applyCollapsesIfNeeded() 正式激活的折叠——该函数不调用 LLM,只是检查 staged 队列并标记为 committed,毫秒级完成。

读时投影使得 summary 不进 REPL 的消息数组,而是存在独立的 collapseStore 中。每次 API 请求时 projectView() 动态投影,被折叠的消息区间被 summary 文本替代。用户滚动历史时看到的始终是完整对话。由于每一轮都从 commit 日志重放所有折叠,一旦折叠被 commit 就在后续所有轮次中持续生效。

Collapse 与 AutoCompact 互斥——Collapse 是更精细的替代方案。启用 Collapse 后 AutoCompact 退场,只在 API 返回 413 时作为最后兜底。触发阈值有多级:有效窗口的 90% 开始 commit staged fold,95% 强制触发 ctx-agent 紧急折叠。

3.4 第四级:AutoCompact —— LLM 兜底总结

当前三级处理完 token 仍超限时出手。以 200K 模型窗口为例,扣除 20K 输出预留后有效窗口为 180K。自动压缩线设在 167K(180K 减 13K 缓冲),超过就触发。警告线在 147K 时 UI 显示黄色提示。阻断线在 177K 时直接报错不让发。

执行流程优先尝试 Session Memory:如果后台维护了会话记忆文件,直接用它的内容做摘要,零 API 费用。如果不可用则走完整压缩:执行 PreCompact Hooks、构建包含 9 个维度的结构化的总结 prompt(用户意图、关键概念、文件代码、错误修复、问题解决、用户消息、待办任务、当前工作、可选下一步)、调用 LLM 生成总结。LLM 调用使用 Forked Agent 复用主对话的 prompt cache——相同的 system prompt、tools、model 让服务端缓存命中,将几乎 98% 的 cache_creation 降到近乎为零。

如果压缩请求本身因为对话太多而超长,进入 PTL 重试流程:按 API round 分组消息,每次丢弃最旧的一批,最多重试 3 次。

熔断机制是另一项关键防护。连续 3 次自动压缩失败后停止重试。真实数据显示 1279 个 session 有 50 次以上连续失败(最高 3272 次),每天浪费约 25 万次 API 调用。熔断后这些调用被消除。熔断与 KV Cache 的关系在于:每次失败的压缩都是一次真实 API 调用消耗真金白银的 token;缓存断裂让每次调用更贵——Forked Agent 试图复用缓存但上下文已烂,缓存大概率不命中而全部 token 变成全价。这个恶性循环就是:上下文不可恢复→每轮触发压缩→每次失败→上下文仍不可恢复→继续烧钱。熔断器在这个循环外面加一道闸。


四、阶段三:API 请求组装

压缩后的消息经过 normalizeMessagesForAPI() 完成消息整形,再经 addCacheBreakpoints() 注入缓存标记,最后由 paramsFromContext() 拼装完整请求。

4.1 normalizeMessagesForAPI() 的完整处理流程

这条函数是自建 agent 时最有参考价值的代码之一。输入是 Claude Code 内部的 Message 数组,输出可以直接发给 Anthropic API。

第零步:过滤不能发给 API 的消息

去掉虚拟消息(isVirtual 标记的 user 和 assistant,它们是 REPL 内部的显示需要)、进度消息(progress 类型,工具执行进度条不需要模型看到)、内部系统消息(非 local_command 的 system 类型)、合成 API 错误消息(isApiErrorMessage 标记的 assistant,是错误恢复机制的标记而非真实对话)。

第一步:重排附件位置

Hook 系统可以在消息流中插入 attachment,需要重排到正确的对话位置供模型正确关联。

第二步:剥离超大图片和 PDF 块(错误恢复机制)

这是一个跨请求的自动恢复闭环。当用户发送超限图片或 PDF 导致 API 返回 400 时,系统生成一条合成 API 错误消息(标记 isApiErrorMessage 和 synthetic 模型名)存入消息数组。下一轮请求前,normalizeMessagesForAPI 扫描到合成错误消息,识别错误文本(如 “Image was too large”),查表确定要剥离的 block 类型,向前追溯最近的一条 isMeta user 消息标记其需要剥离对应 block。

五种错误各有不同的剥离范围:PDF 太大只剥离 document 块、图片太大只剥离 image 块、请求太大同时剥离 document 和 image。追溯时只找 isMeta 消息,因为只有系统注入的附件消息才包含这些 block 类型;遇到普通 assistant 或非 meta user 消息就停止追溯,不会碰用户的文字输入。如果某条消息的全部 block 都被剥离则整条跳过。合成错误消息本身也被过滤掉不发给 API。

这背后是一个重要的设计智慧:用错误消息本身做标记,不需要额外的元数据通道,消息数组本身就是完整的恢复日志。

第三步:合并同 message.id 的 assistant 消息

流式返回时,一次 API 响应的内容(文本、thinking、tool_use)被拆成多条 assistant 消息但共享同一个 message.id。API 要求 user 和 assistant 严格交替出现,连续两条 assistant 会报 400。需要把同一轮的所有 content block 合并成一条。

第四步:合并相邻 user 消息

AWS Bedrock 不支持连续的 user 消息,Anthropic 第一方 API 虽支持也会内部合并。hook 注入 attachment 时常导致连续 user 消息的出现。

第五步:注入 Snip 短 ID 标签

给每条 user 消息末尾追加短 ID 标签,供 SnipTool 引用。仅在 Snip 功能启用且非测试环境时执行。

第六步:清理边界情况

这是整个函数中最琐碎的部分,每个子步骤都对应一个踩过的 API 400 坑:

过滤孤儿 thinking-only assistant:流式中断或 compaction 切割导致某条 assistant 消息只剩下 thinking block 并且没有同 message.id 的姐妹消息包含非 thinking 内容——发 API 会报 “thinking blocks cannot be modified”。解决方法是两遍扫描:第一遍收集所有有非 thinking 内容的 message.id,第二遍过滤掉只有 thinking 且 id 不在集合中的消息。

去掉末尾 assistant 的 thinking block:API 不允许 assistant 消息以 thinking 或 redacted_thinking 结尾。流式中 thinking block 作为最后一块到达但用户取消了请求时就会出现这种情况。从尾部逐个弹掉 thinking block 直到遇到非 thinking block。

过滤纯空白 assistant 消息:API 要求 text block 必须包含非空白文本。模型有时输出换行符然后被取消,留下纯空白文本。此步检查每条 assistant 消息:如果所有 text block 都是纯空白且没有任何非 text block,整条删除。这条必须在"去掉末尾 thinking"之后运行——否则一条包含 thinking 的空白消息会因有非 text block 而躲过过滤,随后 thinking 被去掉,暴露纯空白文本而导致 400。

补空非末尾 assistant 消息:API 要求除最后一条 assistant 外,所有消息必须有非空 content。对非末尾的空 assistant 消息插入占位符文本。

再次合并相邻 user:前面的过滤步骤可能删掉了中间的 assistant 消息,导致原本不相邻的 user 现在相邻了,必须再次合并。

smoosh system-reminder 到 tool_result:将独立文本块中的 system-reminder 合并到旁边 tool_result 块的 content 里,防止它作为独立文本块改变 user/assistant 交替模式。

清理 is_error tool_result 中的非文本 block:API 硬性要求 is_error 为 true 的 tool_result 只能包含 text block。如果有 image 或 document 则需要移除,只保留文本部分。

第七步:验证图片

在发请求前验证所有图片的大小、分辨率、数量是否在 API 限制内。

4.2 addCacheBreakpoints() —— 缓存标记注入

标记入口固定为倒数第二或最后一条消息。同时给缓存前缀中的 tool_result 加 cache_reference 名字,重插之前 pin 的 cache_edits,注入本轮 Microcompact 产生的新 cache_edits,并 pin 住新注入位置供后续轮次复用。

4.3 paramsFromContext() —— 完整请求拼装

最终发往 API 的请求包含:模型名、分段的 system prompt(带 cache_control 标记)、经过 normalize + cache 标记的消息数组、完整的工具列表、think 配置、最大输出 token 数以及实验性功能 beta header 列表。


五、阶段四与五:流式接收与工具执行循环

HTTP 请求发出后进入流式事件循环。stream_event 类型用于逐字显示到 UI。完整的 assistant 消息到达后推入 mutableMessages 并持久化,同时登记其中的 tool_use 块。如果有 tool_use 就标记需要跟进并注册到流式工具执行器。工具执行完成后带着 tool_result 回到循环顶部重走完整管线。

工具执行循环的退出条件是 stop_reason 为 end_turn 或被中止。可能遇到的问题包括 API 413(prompt too long)触发 Collapse drain→Reactive Compact→用户手动 compact 的恢复链;以及 Max Output Tokens 超限时自动增加输出 token 上限后重试。


六、阶段六:后台收尾

query loop 结束后执行 recordTranscript 确保所有消息写入 JSONL;ctx-agent(Collapse 启用时)分析本轮对话找出新的可折叠区间;extractMemories(Session Memory 启用时)更新会话记忆文件;遥测事件上报;清理各种临时状态。


七、KV Cache 与 Prompt Caching 深度解析

7.1 KV Cache 是什么

Transformer 模型在生成每个新 token 时需要让它"关注"之前所有的 token,Q 只需要当前 token,K 和 V 需要全部历史的。KV Cache 就是把已算好的历史 K、V 向量存起来,新 token 只算自己的 Q,然后 Q 去和缓存里的 K、V 做 attention。

这是单次请求内的优化。Anthropic 往前多走了一步——跨请求保存 KV Cache。通过 cache_control 标记告诉服务端将标记点的前缀 KV Cache 持久化存储。

7.2 "命中缓存"意味着什么

本次请求前缀的字节序列和上次 cache_control 标记点之前的前缀逐字节完全一致。服务端直接复用存储的 KV Cache,只对新 token 计算 attention。cache_read 的 token 价格只有正常价格的约十分之一。

7.3 Tokenizer 的角色

每个大模型都自带一个 tokenizer(分词器),这是模型文件的一部分,和模型权重一起打包。用户输入的文本经过 tokenizer 变成 token ID 序列,然后经过 embedding 层变成向量,再经过 Transformer 各层,最后输出 token ID 经过 detokenizer 显示为文字。本地部署(Ollama、llama.cpp)和云端部署在这个流程上完全相同,区别只是本地自己跑推理、云端在远程 GPU 集群上跑推理并附加了 prompt caching 等商业化服务层。

模型接收的永远是 token 序列不是自然语言文本。cache_edits 和 cache_control 这类标记被服务端拦截处理,从不送入模型。

7.4 缓存断裂检测

promptCacheBreakDetection.ts 实现了一个两阶段检测器。

阶段一在请求前:对 system prompt 文本(去掉 cache_control 后的 hash)、system prompt 中的 cache_control 布局(独立 hash,捕获 scope/TTL 翻转)、工具定义整体 hash、每个工具独立 hash(77% 的 tool break 是 schema 变但数量不变)、model 名称、fast mode 状态、beta headers 列表(排序后对比)、thinking effort 值、全局缓存策略、额外 body 参数——总共十个维度逐一 hash 对比。每个维度独立 hash,哪个变了精确定位。

阶段二在请求后:对比 API 返回的 cache_read_input_tokens 和上次的值。需同时满足两个条件才能判定断裂:缓存读 token 下降超过 5%,且绝对值下降超过 2000(过滤正常波动)。确认断裂后用阶段一存储的 pendingChanges 解释原因。如果客户端没有任何变化且时间间隔小于 TTL,约 90% 的情况是服务端路由切换或缓存逐出。

每次确认断裂后自动把前后 prompt 内容输出为 diff 文件写入临时目录。

7.5 Header Latching —— 最重要的预防机制

核心问题是:如果用户在对话中途切换 fast mode 或 auto mode,请求 header 变了,服务端缓存 key 变了,整个缓存全废。解决方案是 sticky-on latch:一旦某个 beta header 第一次被发送,就锁住整个 session 不再改变。

Header Latching 锁的是 HTTP 请求中 betas 数组的内容,不是模型选择也不是实际速度。锁住的 header 始终发送(保证缓存 key 不变),但实际功能参数(speed 是否为 fast、cache_edits 是否注入、thinking effort)每次独立判断。这是一种"宁可多发无用 header,也不能让 header 列表中途变化"的策略——多发无用 header 没有副作用,header 列表变了缓存全废。

7.6 缓存断裂后能修复吗

KV Cache 在服务端,一旦断裂无法修复,只能重建。系统有三种应对:预期断裂时主动重置基线(cache_edits 删除后 cache_read 下降是正常的、AutoCompact 压缩后消息数量减少导致的下降也是正常的、/clear 或 resume 时全量重建清空所有 tracking state),这就是把已知原因导致的下降屏蔽掉不让它误报;熔断器阻止在不可恢复状态下反复尝试烧钱;最根本的恢复是断裂后下一次请求自动建立全新缓存。


八、关键技术决策一览

决策 说明
mutableMessages 是单一真相源 所有操作直接修改同一数组,REPL 和 API 各取所需
双重视图分离 REPL 看全量,API 看压缩版,互不干扰
四级压缩递进,顺序不可变 从免费到昂贵,从轻量到重量;每级为下一级减少处理量
prompt cache 极致尊重 字节序列不能变;header 不能中途变;cache_edits 位置不能变
异步优先 ctx-agent 后台工作,主线程不等待
Header Latching 宁可多发无用 header 也不能让 header 列表中途变化
熔断止损 连续失败后停止,避免无限烧钱
错误恢复用消息自身做标记 合成 API 错误消息就是剥离指令的触发器,无需额外元数据通道
多轮循环 一次用户输入可能触发多次 API 调用,每轮都走完整管线

九、四级压缩对比

Snip Microcompact Context Collapse AutoCompact
删除什么 整条消息 tool_result 缓存条目 多段折叠为摘要 前缀替换为摘要
使用 LLM 否(模型调工具) 异步 LLM(后台) 同步 LLM
缓存影响 可能破坏 不破坏(cache_edits) 不破坏(投影) 破坏后重建
API 费用 后台 LLM 费用 一次 LLM 调用
用户感知 进度指示器 Compacting 提示
触发方式 模型主动调用 每轮自动 阈值触发异步 阈值触发同步

本文基于 Claude Code 源码学习整理,持续更新中。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐