03 会话上下文与消息模型
上一章我们把 init.ts 视为“昂贵初始化边界”:它把网络、环境变量、遥测、清理器等基础设施准备好,确保系统能安全地进入主会话。
这一章回答 Part 1 的下一个关键问题:当主会话真的开始跑起来时,Claude Code 用什么“上下文容器”把能力与状态装进主循环?又用什么“消息模型”把多轮对话与工具回填稳定地串成闭环?
你会在 restored-src/src/query.ts 里看到一个非常工程化的事实:主循环并不是“一问一答”就结束,而是一个可能反复迭代的 query loop。只要 assistant 输出了 tool_use,系统就必须执行工具、生成 tool_result,再把它们回填进下一轮请求的消息历史中,直到这一轮真正收束。
1. 本章要解决什么问题
如果你把 Claude Code 想象成“把用户输入丢给模型,模型回一段文本”,你会马上遇到两类现实困难:
- 工具调用不是旁路,而是主链路的一部分。 assistant 不是“顺便”调用工具,而是把
tool_use作为输出的一部分交付给客户端;客户端必须把执行结果以tool_result的形式回填到同一条会话历史里。 - 一次用户输入可能触发多轮迭代。 在
query.ts里,messages会在循环中不断增长:assistant 消息、工具结果、附件消息(例如 file edits、queued commands)都可能被追加,形成下一轮的上下文。
因此本章聚焦两项核心抽象:
ToolUseContext:主循环的“会话上下文容器”,承载工具、权限、状态读写、取消信号、以及会话级能力(MCP、UI、通知等)。Message/UserMessage/AssistantMessage:主循环的“消息模型”,不仅要能表达对话文本,还要能表达tool_use/tool_result以及多种“只在本地生效”的辅助信息,并在发送到 API 前经过规范化(例如合并连续 user 消息、剔除 UI-only 消息)。
2. 先看上下文流转图
这张图只画本章关心的闭环:用户消息如何进入、上下文如何拼装、assistant 如何返回 tool_use、tool_result 如何回环进下一轮 query。
读图要点:
tool_use是 assistant 输出的一部分,因此消息模型必须能表达它。tool_result被编码为 user 侧内容块(type: 'tool_result'且带tool_use_id),因此工具执行结果必须以“消息”的形式回填到历史里,而不是仅作为函数返回值留在本地变量。ToolUseContext.messages是“本轮视图”:query.ts每轮都会把messagesForQuery写回到toolUseContext.messages,让工具执行、权限判断、hook、附件注入等能力看到同一份上下文。
3. 源码入口
本章主要锚定下列入口(按“闭环形成”所需的最小集合):
restored-src/src/query.ts:query()/queryLoop()的多轮推进,如何累积Message[]、识别tool_use、执行工具并在下一轮回填tool_result。restored-src/src/Tool.ts:ToolUseContext与ToolResult的定义,说明“上下文容器”如何把工具编排所需的依赖与状态统一起来。restored-src/src/services/tools/toolOrchestration.ts:runTools()如何按并发安全性分批执行工具,并在执行过程中逐步产出消息更新。restored-src/src/services/tools/toolExecution.ts:单个工具调用如何被包装成tool_result形式的UserMessage,以及错误/取消等路径如何仍然生成可回填的结果消息。restored-src/src/utils/messages.ts:createUserMessage()/createAssistantMessage()/normalizeMessagesForAPI(),展示消息模型如何兼容“本地 UI 消息”和“发往 API 的标准消息”之间的边界。
4. 主调用链拆解
这一节按 query.ts 的业务推进顺序拆解,重点解释:为什么 ToolUseContext 不只是工具列表,以及为什么消息模型必须服务于多轮循环和工具回填。
4.1 query() 的输入:把“对话历史 + 上下文容器”一并交给主循环
query() 的参数里最关键的几项是:
messages: Message[]:会话历史(不只 user/assistant,还包含 progress/system/attachment 等本地形态)。systemPrompt / userContext / systemContext:系统提示与补充上下文(它们会参与“拼装发往 API 的请求”)。toolUseContext: ToolUseContext:工具执行与会话能力所需的上下文容器。
在 queryLoop() 内部,会把这些不可变参数装进一个可变的 loop state(State),每轮都可能把新消息追加进 messages,并带着增长后的历史进入下一轮。
这里先建立一个更收敛的判断标准:**对“会话历史型信息”来说,如果它需要被模型在下一轮感知(例如上一轮 assistant 的输出、工具执行结果、需要延续的约束),就必须以 messages 或 system/user/systemContext 的形式进入下一轮。**但也要注意:影响下一轮行为的不只有历史内容本身,工具集合、请求侧配置/元数据等同样会改变下一次调用,却不必然以消息形态落在 messages/context 里。
4.2 ToolUseContext:不是“工具列表”,而是“主循环的会话级运行时”
从 restored-src/src/Tool.ts 可以直接看到,ToolUseContext 远不止 options.tools:
- 它携带“可变状态与读写入口”。
getAppState()/setAppState():工具执行过程中可能需要读写全局/会话状态(权限模式、MCP 动态连接状态、UI 状态等)。setInProgressToolUseIDs(...):UI 与调度需要知道哪些工具还在跑。
- 它携带“取消与资源边界”。
abortController:贯穿 query 与 tool 执行的取消信号;一旦用户中断或系统决定停止,工具必须能被统一终止,并生成对应的tool_result(通常是is_error: true的取消结果)回填。readFileState:文件读取状态缓存,用于附件/记忆注入等机制的去重与边界控制。
- 它携带“本轮消息视图”。
messages: Message[]:query.ts在每轮开始会写回toolUseContext.messages = messagesForQuery,目的是让工具权限判断、hook、以及“工具 schema 是否已发送”等逻辑能基于同一份历史做决策(见toolExecution.ts的buildSchemaNotSentHint(...)会读取toolUseContext.messages)。
- 它携带“会话能力的动态性”。
- 例如
refreshTools?: () => Tools:当 MCP server 在会话中途连接成功,下一轮query可以刷新options.tools,让模型在后续轮次看到新工具。
- 例如
把这些放在一个上下文对象里,而不是散落在全局单例或函数参数里,带来两个直接收益:
- 工具执行可以被当作主循环中的“子阶段”:它拿到同一份
ToolUseContext,就自然拥有权限、状态、取消、以及“本轮消息视图”。 - 多轮循环可以安全地携带状态前进:每一轮都能用“增长后的 messages + 更新后的 context”进入下一轮,而不需要在全局到处同步。
4.3 Message:一个“对话历史”里为什么要混合多种本地消息形态
在 Claude Code 里,Message 并不等价于“发给模型的消息”。它更像一个会话内的事件流:既要服务 UI/日志/回放,也要服务 API 调用。
从 utils/messages.ts 的 normalizeMessagesForAPI(messages, tools) 可以看到一个明确边界:
- 发往 API 的只会是
(UserMessage | AssistantMessage)[]。 progress、多数system、以及部分“合成的 API error 消息”等,会在规范化过程中被过滤掉。- 附件消息会被重排,以保证它们不会破坏 tool_result 相关的边界(源码里有“先 reorder attachments,再过滤 virtual messages”的步骤)。
- 连续的 user 消息会被合并(注释指出 Bedrock 不支持连续 user message;即便 1P API 支持,客户端也倾向于在边界处合并)。
换句话说:消息模型必须同时满足两种消费者:本地(UI/回放/调度)与远端(API)。 这也是为什么你会看到 isVirtual、isMeta、origin、mcpMeta 之类字段存在,但它们并不一定会进入模型上下文。
4.4 UserMessage 与 AssistantMessage:围绕 tool loop 的两种关键载体
本章只抓和工具回环强相关的要点:
AssistantMessage是 assistant 侧输出的载体。- 它的
message.content是一组内容块(blocks)。 - 其中
type: 'tool_use'的 block(带id/name/input)是“客户端必须执行工具”的结构化指令。
- 它的
UserMessage是 user 侧输入的载体,但它也承载 tool result 回填。- 工具执行结束后,客户端会构造一个
UserMessage,其内容块包含type: 'tool_result',并用tool_use_id指向对应的tool_use。 createUserMessage(...)还允许携带toolUseResult(本地类型的结果快照)与sourceToolAssistantUUID(指向产生该 tool_use 的 assistant 消息),这些字段更偏“本地可观测/可追溯”,并不等同于发给模型的文本。
- 工具执行结束后,客户端会构造一个
一个很关键的工程约束也在 query.ts 明确写出:
API 会在某些情况下报错:如果我们把
tool_result消息和普通 user 消息交错(interleave),模型侧会认为 tool trajectory 被破坏。
因此 query.ts 里会刻意把“工具执行阶段”产出的 tool_result 收拢成 toolResults,并在进入下一轮前统一追加到 messages 末尾。
4.5 ToolResult<T>:工具执行不是“返回值”,而是“消息生产器 + 上下文修饰器”
在 restored-src/src/Tool.ts 里,工具的 call(...) 返回 Promise<ToolResult<Output>>,其中几个字段决定了“工具如何影响主循环”:
data: T:工具的结构化输出(本地消费)。newMessages?: ...[]:工具可以额外产出消息(例如附件、系统提示、额外的 user 侧内容),让这些信息进入会话历史并参与后续轮次。contextModifier?: (context: ToolUseContext) => ToolUseContext:工具可以修改上下文(但注释强调:只对 非并发安全 的工具才会生效),这对应toolOrchestration.ts的分批策略:并发安全的工具可以同时跑,但上下文变更需要被有序应用。mcpMeta?: ...:MCP 工具的协议元数据可透传给 SDK 消费者(同样强调“不要发给模型”)。
这背后的设计意图是:工具执行的“副作用”必须以主循环可理解的方式显式表达出来。 不是靠工具自己去改全局变量,而是通过:
- 产出
tool_result(让模型知道结果) - 产出
newMessages(让会话历史承载额外上下文) - 产出
contextModifier(让上下文变更按规则落地)
4.6 query loop 如何把 tool use 变成回环:识别、执行、回填、再请求
把 restored-src/src/query.ts 的关键片段串起来,你能看到一个非常稳定的循环结构:
- 每轮开始先对齐上下文视图。
toolUseContext = { ...toolUseContext, messages: messagesForQuery }
- 请求模型并流式接收 assistant 输出。
- 每收到一个
AssistantMessage,就从message.content中提取tool_useblocks。 - 只要出现
tool_use,就把needsFollowUp置为true。
- 每收到一个
- 如果出现 tool_use,就进入工具执行阶段。
- 调用
runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)(或 streaming executor 路径)。 toolExecution.ts会把每个工具的结果包装成createUserMessage({ content: [{ type: 'tool_result', ... }] })。
- 调用
- 把工具结果与附件统一追加到 messages,进入下一轮
query。next.messages = [...messagesForQuery, ...assistantMessages, ...toolResults]- 下一轮继续,直到本轮不再需要 follow-up。
这就是为什么本章一直强调“消息模型要服务于回环”:tool_result 不是日志,不是 UI 提示,它是下一轮 query 的输入之一。
5. 关键设计意图
把本章抽象成可复用的工程意图,可以归纳为三点:
- 上下文容器化:让工具编排成为主循环的子阶段。
ToolUseContext把“工具、权限、状态、取消、会话能力”统一成一个对象,使得runTools(...)不需要到处摸全局单例,也不需要把十几个参数层层下传。
- 消息事件流化:本地形态与 API 形态分层。
Message允许存在 progress/system/attachment 等本地事件,但通过normalizeMessagesForAPI()在边界处收敛为(UserMessage | AssistantMessage)[],既满足 UI/回放,也满足模型 API 的严格约束。
- 回环显式化:tool_result 作为“下一轮输入”的一等公民。
- tool 执行结果以
tool_resultblock 回填到UserMessage,并被追加到历史messages;这保证了模型能在下一轮基于结果继续推理,而不是让客户端“私下记住”结果却不告知模型。
- tool 执行结果以
6. 从复刻视角看
如果你要复刻一个最小可用的 tool-augmented query loop,本章给出的最小抽象可以压缩成两块:
ToolUseContext(最小版):tools(可查找、可调用)abortController(统一取消)messages(本轮视图,用于权限/提示/去重等策略)
Message模型(最小版):UserMessage:既能承载用户文本,也能承载tool_result(tool_use_id, content, is_error)AssistantMessage:能承载tool_use(id,name,input)与普通文本
一个最小闭环伪代码(只保留关键语义):
messages = [UserMessage(text)]
loop:
assistant = callModel(normalize(messages), tools)
messages += [assistant]
toolUses = extractToolUses(assistant)
if toolUses empty: break
for each toolUse:
result = callTool(toolUse, context)
messages += [UserMessage(tool_result(tool_use_id=toolUse.id, content=result))]当你把这个闭环跑通,再逐步加上 Claude Code 的工程化能力(并发、安全权限、附件注入、compact、stop hooks),系统才会“像一个能长期跑的产品”,而不是“能演示的 demo”。
6.1 源码追踪提示
这一章最适合用“先抽象、再回文件”的方式读:
- 先看
restored-src/src/Tool.ts,只盯ToolUseContext、ToolResult、消息相关类型引用。 - 再看
restored-src/src/utils/messages.ts,重点找tool_use、tool_result、normalize、createUserMessage这些关键词。 - 最后回到
restored-src/src/query.ts,确认消息模型和上下文模型是怎样在 query loop 里被真正消费的。
7. 本章小练习
做一个“可跑的最小回环”练习,目标是验证你是否真的理解了本章的抽象:
- 设计一个最小
Messageunion:只包含UserMessage与AssistantMessage,并支持tool_use/tool_result两种块。 - 写一个
queryloop:assistant 只要返回tool_use就执行工具,并把结果回填为tool_result的UserMessage,直到 assistant 不再请求工具。 - 画出你自己的上下文流转图(要求与本章图一致:用户消息进入、上下文拼装、assistant 返回、tool 回环)。
8. 本章小结
ToolUseContext 与消息模型在 Claude Code 里不是“为了类型好看”而存在,而是为了支撑一个现实的主链路:多轮 query 推进 + 工具执行 + tool_result 回填。
理解这一点后,你就能自然地把上一章的初始化边界接到下一章的主循环:初始化准备的是“能跑的运行时”,而本章定义的是“运行时如何携带上下文与消息,稳定地把一次用户输入推进到可收尾的终点”。