Claude Code 架构深度解析:从 Agent Loop 到 Tool、MCP 与 Context

本文基于本地仓库 claude-code/claude-code-main 的 TypeScript 源码快照整理。该仓库 README 将其描述为 2026 年 3 月 31 日公开暴露的研究快照,并非 Anthropic 官方仓库。因此,本文讨论的是这个快照体现的设计,而不是对所有历史版本或未来版本的承诺。

文中的代码均来自该快照。为突出架构,部分代码块删去了日志、埋点、实验开关和异常分支,并用注释标记省略处;读者可根据每节给出的路径和行号回到完整实现。

一、结论:Claude Code 不是"带几个工具的聊天框"

它最核心的架构判断有五个:

  1. 一次用户请求不是一次模型请求,而是一段可持续多轮的 Agent Loop。 模型产生 tool_use 后,程序执行工具,把 tool_result 作为新的 user message 追加,再请求模型;直到模型不再调用工具、用户中断、达到轮数/预算限制,或出现无法恢复的错误。
  2. 内置工具与 MCP 工具最终收敛到同一个 Tool 抽象。 Agent Loop 不需要在每次执行时判断"这是本地工具还是远程 MCP";MCP 只是在发现阶段被适配成本地 Tool。
  3. Context 不是一个 prompt 字符串。 它至少包括 system prompt、system context、user context、历史消息、附件、工具 schema、CLAUDE.md/规则文件和 MCP instructions;这些内容按照缓存稳定性和语义角色放在不同位置。
  4. 权限不是工具里的一个布尔值,而是一条独立的策略决策流水线。 全局 deny/ask/allow、模式、工具自检、安全检查、hook、分类器和人工确认共同产生最终决策。
  5. 会话的事实来源是追加式消息日志。 UI 是投影,SDK event 是投影,API 请求也是投影。JSONL 中的 UUID/parentUuid 链让恢复、压缩边界、分叉和 rewind 成为可能。

用一张图概括主链路:
#mermaid-svg-FSUbbwt5D4cfOt7r{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FSUbbwt5D4cfOt7r .error-icon{fill:#552222;}#mermaid-svg-FSUbbwt5D4cfOt7r .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FSUbbwt5D4cfOt7r .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FSUbbwt5D4cfOt7r .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FSUbbwt5D4cfOt7r .marker.cross{stroke:#333333;}#mermaid-svg-FSUbbwt5D4cfOt7r svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FSUbbwt5D4cfOt7r p{margin:0;}#mermaid-svg-FSUbbwt5D4cfOt7r .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FSUbbwt5D4cfOt7r .cluster-label text{fill:#333;}#mermaid-svg-FSUbbwt5D4cfOt7r .cluster-label span{color:#333;}#mermaid-svg-FSUbbwt5D4cfOt7r .cluster-label span p{background-color:transparent;}#mermaid-svg-FSUbbwt5D4cfOt7r .label text,#mermaid-svg-FSUbbwt5D4cfOt7r span{fill:#333;color:#333;}#mermaid-svg-FSUbbwt5D4cfOt7r .node rect,#mermaid-svg-FSUbbwt5D4cfOt7r .node circle,#mermaid-svg-FSUbbwt5D4cfOt7r .node ellipse,#mermaid-svg-FSUbbwt5D4cfOt7r .node polygon,#mermaid-svg-FSUbbwt5D4cfOt7r .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FSUbbwt5D4cfOt7r .rough-node .label text,#mermaid-svg-FSUbbwt5D4cfOt7r .node .label text,#mermaid-svg-FSUbbwt5D4cfOt7r .image-shape .label,#mermaid-svg-FSUbbwt5D4cfOt7r .icon-shape .label{text-anchor:middle;}#mermaid-svg-FSUbbwt5D4cfOt7r .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FSUbbwt5D4cfOt7r .rough-node .label,#mermaid-svg-FSUbbwt5D4cfOt7r .node .label,#mermaid-svg-FSUbbwt5D4cfOt7r .image-shape .label,#mermaid-svg-FSUbbwt5D4cfOt7r .icon-shape .label{text-align:center;}#mermaid-svg-FSUbbwt5D4cfOt7r .node.clickable{cursor:pointer;}#mermaid-svg-FSUbbwt5D4cfOt7r .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FSUbbwt5D4cfOt7r .arrowheadPath{fill:#333333;}#mermaid-svg-FSUbbwt5D4cfOt7r .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FSUbbwt5D4cfOt7r .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FSUbbwt5D4cfOt7r .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FSUbbwt5D4cfOt7r .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FSUbbwt5D4cfOt7r .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FSUbbwt5D4cfOt7r .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FSUbbwt5D4cfOt7r .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FSUbbwt5D4cfOt7r .cluster text{fill:#333;}#mermaid-svg-FSUbbwt5D4cfOt7r .cluster span{color:#333;}#mermaid-svg-FSUbbwt5D4cfOt7r div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FSUbbwt5D4cfOt7r .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FSUbbwt5D4cfOt7r rect.text{fill:none;stroke-width:0;}#mermaid-svg-FSUbbwt5D4cfOt7r .icon-shape,#mermaid-svg-FSUbbwt5D4cfOt7r .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FSUbbwt5D4cfOt7r .icon-shape p,#mermaid-svg-FSUbbwt5D4cfOt7r .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FSUbbwt5D4cfOt7r .icon-shape .label rect,#mermaid-svg-FSUbbwt5D4cfOt7r .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FSUbbwt5D4cfOt7r .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FSUbbwt5D4cfOt7r .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FSUbbwt5D4cfOt7r :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 普通文本
tool_use
无后续工具
用户输入
REPL / QueryEngine
构造 System/User/System Context
query Agent Loop
消息标准化与 API 请求
Claude 流式响应
UI / SDK 事件
Tool Executor
PreToolUse Hook
权限决策
内置 Tool 或 MCP Tool
PostToolUse Hook
tool_result
本轮结束
Transcript JSONL
压缩 / Context 管理

这张图最值得记住的是两个闭环:

  • model → tool → model 是执行闭环

  • messages → transcript → resume/compact → messages 是状态闭环。


二、入口层:不同产品形态,最终汇入同一个 query 生成器

Claude Code 有交互式终端、非交互/print、SDK、远程/Bridge 等多种入口。如果每个入口各写一套 agent 逻辑,很快会在工具行为、权限、压缩和错误恢复上产生分叉。这个快照采用的办法是:入口负责收集环境与把事件映射到自己的输出形态,核心循环统一进入 query()

交互式 REPL 的关键代码位于 src/screens/REPL.tsx:2661src/screens/REPL.tsx:2793。在真正请求前,它读取最新工具/MCP 客户端,构建三类上下文,然后消费 query 产生的事件:

ts 复制代码
// src/screens/REPL.tsx:2767-2803(节选)
const [, , defaultSystemPrompt, baseUserContext, systemContext] =
  await Promise.all([
    checkAndDisableBypassPermissionsIfNeeded(...),
    checkAndDisableAutoModeIfNeeded(...),
    getSystemPrompt(freshTools, mainLoopModelParam, directories, freshMcpClients),
    getUserContext(),
    getSystemContext(),
  ])

const systemPrompt = buildEffectiveSystemPrompt({
  toolUseContext,
  customSystemPrompt,
  defaultSystemPrompt,
  appendSystemPrompt,
})

for await (const event of query({
  messages: messagesIncludingNewMessages,
  systemPrompt,
  userContext: baseUserContext,
  systemContext,
  canUseTool,
  toolUseContext,
  querySource: getQuerySourceForREPL(),
})) {
  onQueryEvent(event)
}

SDK/无头路径则由 src/QueryEngine.ts:184QueryEngine 承担会话级状态。它明确规定"一段 conversation 对应一个 QueryEngine",mutableMessages、文件读取缓存、用量、AbortController 等在多次 submitMessage() 之间保持。到 src/QueryEngine.ts:675,它同样调用 query()

ts 复制代码
// src/QueryEngine.ts:675(节选)
for await (const message of query({
  messages,
  systemPrompt,
  userContext,
  systemContext,
  canUseTool: wrappedCanUseTool,
  toolUseContext: processUserInputContext,
  fallbackModel,
  querySource: 'sdk',
  maxTurns,
  taskBudget,
})) {
  // 转成 SDKMessage、累计 usage,并按需写 transcript
}

这是一种典型的"Functional Core + Stateful Shell"变体:核心并非完全无状态,但所有入口共享同一个循环;REPL 和 SDK 只保留必要的状态适配与展示逻辑。它带来三个好处:

  • Agent 的停止条件、工具续轮、压缩和恢复行为只有一份语义;
  • UI 可以关注 React/Ink 状态,SDK 可以关注序列化 event,而不复制推理逻辑;
  • query 的外部依赖通过 QueryDeps 注入,测试可以替换模型调用、压缩和预取,而不真的访问 API。

代价是入口层仍然不薄。REPL 需要处理标题、斜杠命令、权限弹窗、MCP 热更新、spinner、指标和本地状态;QueryEngine 也要处理 transcript、SDK acknowledgement 和 structured output。统一核心消除了最危险的语义重复,却没有消除产品表面的固有复杂度。


三、Agent Loop:Claude Code 真正的"发动机"

3.1 它是显式状态机,不是递归聊天函数

Agent Loop 位于 src/query.ts。外层 query() 主要维护命令生命周期,真正循环在 queryLoop()。源码把跨迭代状态集中在 State

ts 复制代码
// src/query.ts:204-217
type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  hasAttemptedReactiveCompact: boolean
  maxOutputTokensOverride: number | undefined
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  stopHookActive: boolean | undefined
  turnCount: number
  transition: Continue | undefined
}

这里有一个很实用的工程信号:只有真正跨模型请求迭代的数据才进入 State。像 systemPromptquerySourcemaxTurns 等在一次 query 内不变的参数,会在循环外解构;环境开关和 session gate 则由 buildQueryConfig() 在入口快照一次,避免长达几十秒的执行中途因配置翻转产生前后不一致。

循环本身使用 while (true),而不是函数递归。这使恢复分支可以显式设置 transitioncontinue,测试也能验证究竟发生了普通下一轮、context collapse 重试、reactive compact 还是 max-output 恢复。

3.2 每次迭代的四个阶段

把大量埋点和实验分支折叠后,一次迭代可以概括为:

ts 复制代码
// src/query.ts 的结构化伪代码,保持原实现语义
while (true) {
  const prepared = await prepareContext(state.messages)

  for await (const event of callModel(prepared)) {
    yield visibleEvent(event)
    collectAssistantMessages(event)
    collectAndMaybeStartToolUses(event)
  }

  if (noToolUse) {
    if (canRecoverFromError) {
      state = recoveredState
      continue
    }
    return terminalReason
  }

  const toolResults = await executeTools()
  state = appendAssistantAndToolResults(state, toolResults)
}

第一阶段是请求前整理。 它会做消息标准化、工具结果预算控制、microcompact/snip、自动压缩判断、上下文溢出保护、附件和延迟工具信息补充。第二阶段是流式模型调用第三阶段判断是否需要恢复或结束第四阶段执行工具 ,并把结果组成下一次请求的消息。

真实模型调用在 src/query.ts:659

ts 复制代码
// src/query.ts:659-705(节选)
for await (const message of deps.callModel({
  messages: prependUserContext(messagesForQuery, userContext),
  systemPrompt: fullSystemPrompt,
  thinkingConfig: toolUseContext.options.thinkingConfig,
  tools: toolUseContext.options.tools,
  signal: toolUseContext.abortController.signal,
  options: {
    model: currentModel,
    fallbackModel,
    querySource,
    agents: toolUseContext.options.agentDefinitions.activeAgents,
    mcpTools: appState.mcp.tools,
    hasPendingMcpServers: appState.mcp.clients.some(c => c.type === 'pending'),
    queryTracking,
    maxOutputTokensOverride,
  },
})) {
  // 流式 yield;收集 assistant message 和 tool_use block
}

deps.callModel 在生产环境指向 src/services/api/claude.tsqueryModelWithStreaming。这种依赖注入非常重要:Agent Loop 的确定性并不强,网络流中可能出现部分 thinking、部分 tool input、fallback 和错误消息;把 transport/API 层替换掉后,循环的每条状态转移可以单测。

3.3 tool_use 不是"调用一下函数",而是下一轮的控制信号

流式响应中的 assistant content block 可能包含文本、thinking 和 tool_use。query 在收集到 tool use 时设置 needsFollowUp。若工具支持 eager/streaming 执行,StreamingToolExecutor 可以在模型仍在生成后续 block 时启动它;否则流结束后交给 runTools()

ts 复制代码
// src/query.ts:829-851(节选)
if (message.type === 'assistant') {
  assistantMessages.push(message)

  const blocks = message.message.content.filter(
    content => content.type === 'tool_use',
  ) as ToolUseBlock[]

  if (blocks.length > 0) {
    toolUseBlocks.push(...blocks)
    needsFollowUp = true
  }

  for (const block of blocks) {
    streamingToolExecutor?.addTool(block, message)
  }
}

工具执行完成后,结果会被标准化为 API 能接受的 user message,其中包含与 tool_use.id 对应的 tool_result.tool_use_id。随后状态被整体替换:

ts 复制代码
// src/query.ts:1715-1729
const next: State = {
  messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
  toolUseContext: toolUseContextWithQueryTracking,
  autoCompactTracking: tracking,
  turnCount: nextTurnCount,
  maxOutputTokensRecoveryCount: 0,
  hasAttemptedReactiveCompact: false,
  pendingToolUseSummary: nextPendingToolUseSummary,
  maxOutputTokensOverride: undefined,
  stopHookActive,
  transition: { reason: 'next_turn' },
}
state = next

这说明 Claude Code 的"多步推理"并没有另建一套 workflow DSL。它利用 Anthropic message protocol 自身表达控制流:assistant 的 tool_use 是调用请求,user 的 tool_result 是返回值,新的 assistant response 是 continuation。消息序列既是协议,也是状态机日志。

3.4 异常处理的核心是不破坏消息配对不变量

Agent 程序比普通聊天更难的一点是:流式失败可能发生在模型已经输出 tool_use、工具部分启动、UI 已显示 thinking 之后。如果直接重试,旧 tool_use_id 与新结果可能串线,下一次 API 请求也可能因非法 thinking 签名或缺失 tool_result 被拒绝。

query 的处理很严格:

  • streaming fallback 时,为旧的部分 assistant messages 产生 tombstone,从 UI 和 transcript 中删除
  • 丢弃旧 StreamingToolExecutor 的 pending result,再创建新实例;
  • 用户中断或工具异常时,为已出现的 tool use 补齐错误/中断 tool_result;
  • prompt-too-long 和 max-output 错误先被"扣住",确认恢复失败后才展示;
  • 对可恢复错误,设置带原因的 transition 再进入下一迭代,避免无限重试。

因此,Agent Loop 的价值远不止一个 while。它真正维护的是一组协议不变量:每个 tool use 必须有终态、回送 API 的 assistant 内容不能被展示层修改、abort 要贯穿模型与工具、重试不能泄漏旧执行结果、压缩后仍要保持可续接的消息链。


四、模型/API 边界:稳定 schema、消息归一化与 Prompt Cache

Agent Loop 操作的是丰富的内部 Message;Anthropic API 接受的是更严格的 user/assistant messages、system blocks 和 tool schemas。两者之间必须有反腐层,否则 UI 消息、进度事件、虚拟工具调用和恢复标记都会污染 prompt。

src/utils/messages.ts:1989normalizeMessagesForAPI() 会先重排附件,再移除 display-only 的 virtual messages;后续还会过滤本地 system/progress 类型、处理媒体错误、修补工具结果、按 provider 需要合并连续 user message。它是"内部事件模型"到"外部 LLM 协议"的边界。

ts 复制代码
// src/utils/messages.ts:1989(节选)
export function normalizeMessagesForAPI(
  messages: Message[],
  tools: Tools = [],
): (UserMessage | AssistantMessage)[] {
  const availableToolNames = new Set(tools.map(t => t.name))

  const reorderedMessages = reorderAttachmentsForAPI(messages).filter(
    m => !((m.type === 'user' || m.type === 'assistant') && m.isVirtual),
  )

  // 后续:移除 UI-only/progress 消息、处理媒体错误、
  // 过滤不可用 tool_reference、修复/合并 API 消息......
}

工具也必须转成 API schema。src/utils/api.ts:119toolToAPISchema() 支持两种输入:内置 Tool 通常提供 Zod schema,MCP Tool 直接保留服务端给出的 JSON Schema。基础 schema 会按稳定 key 缓存,避免 feature gate 或动态描述在会话中途改变序列化字节,破坏 prompt cache。

ts 复制代码
// src/utils/api.ts:119-168(节选)
const cacheKey = tool.inputJSONSchema
  ? `${tool.name}:${jsonStringify(tool.inputJSONSchema)}`
  : tool.name

let base = getToolSchemaCache().get(cacheKey)
if (!base) {
  const input_schema = tool.inputJSONSchema
    ? tool.inputJSONSchema
    : zodToJsonSchema(tool.inputSchema)

  base = {
    name: tool.name,
    description: await tool.prompt({
      getToolPermissionContext: options.getToolPermissionContext,
      tools: options.tools,
      agents: options.agents,
    }),
    input_schema,
  }
}

Prompt cache 对 agent CLI 尤其重要,因为 system prompt 与几十个工具 schema 可能占数万 token,而同一用户回合内会因工具调用请求模型多次。源码里许多看似"排序洁癖"或"不要修改原对象"的决定,本质都在保护缓存前缀:

  • 内置工具和 MCP 工具分别排序,并保持内置工具是连续前缀;
  • system prompt 划分静态区与动态区;
  • tool schema 按会话稳定缓存;
  • 给 SDK/Hook 展示的 tool input 可以 clone 后 backfill,但回送 API 的原始 assistant message 不改;
  • fork 子 Agent 让多个孩子共享字节完全相同的历史和占位 tool result,仅把各自 directive 放在末尾。

这是一条贯穿架构的隐藏主线:上下文不仅要语义正确,还要字节稳定。


五、Tool 系统:执行、策略、协议和 UI 的统一能力对象

5.1 Tool 接口承载了什么

src/Tool.ts:362Tool 远不只是 (input) => output。它同时描述:

  • inputSchema / inputJSONSchema 与可选 outputSchema
  • call() 执行;
  • validateInput()checkPermissions()
  • isConcurrencySafeisReadOnlyisDestructiveisOpenWorld
  • 模型看到的 prompt() 与人看到的 description()
  • tool result 到 API block 的映射;
  • React/Ink 下的调用、进度、结果、拒绝状态渲染;
  • MCP 标识、延迟加载、结果落盘阈值、可中断行为等。
ts 复制代码
// src/Tool.ts:362(高度节选)
export type Tool<Input, Output, Progress> = {
  name: string
  inputSchema: Input
  inputJSONSchema?: ToolInputJSONSchema

  call(args, context, canUseTool, parentMessage, onProgress?):
    Promise<ToolResult<Output>>

  validateInput?(input, context): Promise<ValidationResult>
  checkPermissions(input, context): Promise<PermissionResult>
  isConcurrencySafe(input): boolean
  isReadOnly(input): boolean
  isDestructive?(input): boolean

  prompt(options): Promise<string>
  mapToolResultToToolResultBlockParam(output, toolUseID): ToolResultBlockParam
  renderToolUseMessage(input, options): React.ReactNode
  renderToolResultMessage?(output, progress, options): React.ReactNode
}

这是一个偏"产品聚合"的接口。优点是新增工具时,其协议、执行、安全语义和 UI 表现集中在同一模块,模型端与人类端不容易漏实现。缺点是 Tool 抽象很宽,核心执行层在类型上依赖 React;若未来增加非 React 客户端,可能要继续写适配器,或把 ToolCoreToolPresentation 拆开。

buildTool() 为常见字段提供默认值,尤其并发默认不安全、读写默认视为写,这是保守的 fail-closed 选择:

ts 复制代码
// src/Tool.ts:748-790(节选)
const TOOL_DEFAULTS = {
  isEnabled: () => true,
  isConcurrencySafe: () => false,
  isReadOnly: () => false,
  isDestructive: () => false,
  checkPermissions: input =>
    Promise.resolve({ behavior: 'allow', updatedInput: input }),
  toAutoClassifierInput: () => '',
}

export function buildTool(def) {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,
  }
}

5.2 工具池:静态注册,动态装配

src/tools.ts:193getAllBaseTools() 是内置能力全集;不同环境、模式和 deny rule 会从中筛选。assembleToolPool() 再将 MCP tools 合并进来:

ts 复制代码
// src/tools.ts:345-372
export function assembleToolPool(
  permissionContext: ToolPermissionContext,
  mcpTools: Tools,
): Tools {
  const builtInTools = getTools(permissionContext)
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)

  return uniqBy(
    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
    'name',
  )
}

这里"内置优先"解决重名冲突,"分区排序"保护 prompt cache,"装配时按权限过滤"减少模型看到但永远不能用的工具。它反映了一个成熟 agent runtime 的工具注册表不能只回答"有哪些函数",还要回答"本会话、本模式、本权限下,模型究竟应看见哪些能力"。

5.3 工具执行流水线

src/services/tools/toolExecution.ts:337runToolUse() 先按模型看到的工具池查找工具,兼容旧 transcript 中的 alias,然后处理 abort、未知工具、权限和错误,最终总是尽量产出合法 tool_result。

完整调用并非直接 tool.call(),而大致经过:

text 复制代码
查找 Tool
  → schema parse / validateInput
  → PreToolUse hooks(可改 input、deny、ask、stop)
  → canUseTool 权限决策
  → tool.call(带 AbortSignal 与 progress)
  → 输出大小治理 / API block 映射
  → PostToolUse 或 PostToolUseFailure hooks
  → MessageUpdate / tool_result

外层保证异常也被转换成协议结果:

ts 复制代码
// src/services/tools/toolExecution.ts:337-430(节选)
export async function* runToolUse(toolUse, assistantMessage, canUseTool, ctx) {
  const tool = findToolByName(ctx.options.tools, toolUse.name)

  if (!tool) {
    yield createNoSuchToolResult(toolUse.id, toolUse.name)
    return
  }

  if (ctx.abortController.signal.aborted) {
    yield createCancelledToolResult(toolUse.id)
    return
  }

  try {
    yield* streamedCheckPermissionsAndCallTool(
      tool, toolUse.id, toolUse.input, ctx, canUseTool, assistantMessage,
    )
  } catch (error) {
    yield createToolErrorResult(toolUse.id, error)
  }
}

5.4 并发不是按"多个调用"决定,而是按工具语义分批

模型可以在同一 assistant message 中返回多个 tool use。全部串行会很慢,全部并行又可能让两个 Edit/Bash 相互覆盖。src/services/tools/toolOrchestration.ts:91 对连续调用分批:并发安全的连续调用组成一批;不安全调用各自成为串行屏障。

ts 复制代码
// src/services/tools/toolOrchestration.ts:91-117(节选)
function partitionToolCalls(toolUses, context): Batch[] {
  return toolUses.reduce((batches, toolUse) => {
    const tool = findToolByName(context.options.tools, toolUse.name)
    const parsed = tool?.inputSchema.safeParse(toolUse.input)
    const safe = parsed?.success
      ? Boolean(tool?.isConcurrencySafe(parsed.data))
      : false

    if (safe && batches.at(-1)?.isConcurrencySafe) {
      batches.at(-1)!.blocks.push(toolUse)
    } else {
      batches.push({ isConcurrencySafe: safe, blocks: [toolUse] })
    }
    return batches
  }, [])
}

例如 Read(A)、Read(B)、Edit©、Read(D) 会形成 [Read A + Read B 并行] → [Edit C 串行] → [Read D]。这比单纯的并发池更正确,因为它保留模型给出的相对顺序,并以"只读/并发安全"作为语义依据。默认值又是 false,所以第三方/新工具若未声明安全,最多损失性能,不会悄悄引入写竞争。


六、Permission:一个独立策略引擎,而不是确认弹窗

权限系统最容易被低估。表面上它只是"是否允许执行 Bash",实际它需要同时处理:组织策略、用户 settings、命令级规则、plan/default/bypass/auto 模式、路径安全、sandbox、hook 决策、分类器、远程审批和交互 UI。

src/Tool.ts:123ToolPermissionContext 是一个不可变策略快照:

ts 复制代码
// src/Tool.ts:123-138
export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource
  alwaysDenyRules: ToolPermissionRulesBySource
  alwaysAskRules: ToolPermissionRulesBySource
  isBypassPermissionsModeAvailable: boolean
  shouldAvoidPermissionPrompts?: boolean
  awaitAutomatedChecksBeforeDialog?: boolean
  prePlanMode?: PermissionMode
}>

核心判定在 src/utils/permissions/permissions.ts:1158。顺序本身就是安全模型:

ts 复制代码
// hasPermissionsToUseToolInner 的决策顺序(按源码归纳)
1. whole-tool deny rule
2. whole-tool ask rule
3. tool.checkPermissions(input, context)
4. tool-specific deny
5. requiresUserInteraction
6. content-specific ask rule
7. bypass-immune safety check
8. bypassPermissions / plan-bypass mode
9. whole-tool allow rule
10. passthrough 转 ask

源码中特别值得学习的是:bypass 并不在最前面。 显式 deny、内容级 ask,以及 .git/.claude/、shell config 等安全检查可以在 bypass 之前生效。这避免"用户为了省确认开启 bypass"被解释为"任何安全边界都消失"。

ts 复制代码
// src/utils/permissions/permissions.ts:1228-1270(节选)
if (
  toolPermissionResult.behavior === 'ask' &&
  toolPermissionResult.decisionReason?.type === 'safetyCheck'
) {
  return toolPermissionResult
}

const shouldBypass =
  appState.toolPermissionContext.mode === 'bypassPermissions' ||
  (appState.toolPermissionContext.mode === 'plan' &&
    appState.toolPermissionContext.isBypassPermissionsModeAvailable)

if (shouldBypass) {
  return {
    behavior: 'allow',
    updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
    decisionReason: { type: 'mode', mode: appState.toolPermissionContext.mode },
  }
}

判定结果为 ask 后,src/hooks/useCanUseTool.tsx 才负责"如何获得答案"。它可以走协调器、swarm worker、分类器、Bridge/远程 channel 或本地交互弹窗。也就是说,policy decision 与 approval transport 被拆开:前者回答"需不需要问",后者回答"向谁问、怎么问"。这使相同 Tool 可以运行在本地 REPL、SDK 和远程协作环境中。

Hooks 又插在权限前后。PreToolUse 可以附加上下文、修改 input、要求 ask、直接 deny 或阻止 continuation;但 hook 的 allow 仍不能覆盖全局 deny/ask 规则。它是扩展点,却不是特权后门。

这套设计给工程实践的启示是:agent 的权限控制应该返回带来源的结构化决策,而不是 booleanbehavior + decisionReason + updatedInput + suggestions 既能支持 UI 解释,也能支持持久化 allow rule、审计和自动化审批。


七、MCP:协议接入层如何"消失"在 Agent Loop 里

7.1 连接先被建模成状态机

MCP 不是一个始终可用的静态工具列表。服务器可能尚未连接、需要 OAuth、连接失败、被禁用或已经在线。src/services/mcp/types.ts:179-226 用 discriminated union 明确表达这些状态:

ts 复制代码
// src/services/mcp/types.ts:179-226(节选)
type ConnectedMCPServer = {
  type: 'connected'
  client: Client
  name: string
  capabilities: ServerCapabilities
  instructions?: string
  config: ScopedMcpServerConfig
  cleanup: () => Promise<void>
}

export type MCPServerConnection =
  | ConnectedMCPServer
  | FailedMCPServer
  | NeedsAuthMCPServer
  | PendingMCPServer
  | DisabledMCPServer

client?: Client, error?: string 更好的地方在于,调用者必须先按 type 缩窄,不能误把 failed/pending 当 connected。连接层支持 stdio、HTTP/SSE、WebSocket、SDK/in-process 等 transport;构造 MCP Client 时声明 roots 与 elicitation,并响应 roots/list 暴露当前工作目录。连接操作与 timeout 竞争,失败时清理 transport,防止进程或 socket 泄漏。

ts 复制代码
// src/services/mcp/client.ts:985-1080(节选)
const client = new Client(
  { name: 'claude-code', title: 'Claude Code', version: VERSION },
  { capabilities: { roots: {}, elicitation: {} } },
)

client.setRequestHandler(ListRootsRequestSchema, async () => ({
  roots: [{ uri: `file://${getOriginalCwd()}` }],
}))

await Promise.race([
  client.connect(transport),
  rejectAfter(getConnectionTimeoutMs()),
])

7.2 远程 MCP Tool 被适配成普通 Tool

这是 MCP 架构最漂亮的一步。src/services/mcp/client.ts:1743 调用 tools/list,清洗服务器数据,再把每个 MCP tool 映射为 Claude Code 的 Tool

ts 复制代码
// src/services/mcp/client.ts:1743-1835(节选)
const result = await client.client.request(
  { method: 'tools/list' },
  ListToolsResultSchema,
)

return recursivelySanitizeUnicode(result.tools).map(tool => ({
  ...MCPTool,
  name: buildMcpToolName(client.name, tool.name),
  mcpInfo: { serverName: client.name, toolName: tool.name },
  isMcp: true,
  inputJSONSchema: tool.inputSchema,

  isConcurrencySafe: () => tool.annotations?.readOnlyHint ?? false,
  isReadOnly: () => tool.annotations?.readOnlyHint ?? false,
  isDestructive: () => tool.annotations?.destructiveHint ?? false,
  isOpenWorld: () => tool.annotations?.openWorldHint ?? false,

  async call(args, context, _canUseTool, parentMessage, onProgress) {
    const connected = await ensureConnectedClient(client)
    return callMCPToolWithUrlElicitationRetry({
      client: connected,
      args,
      signal: context.abortController.signal,
      onProgress,
    })
  },
}))

此后 Tool Executor 只看统一接口。MCP 的网络调用、progress、URL elicitation、session retry、structured content 和 _meta 都被封装在适配后的 call() 内。权限系统则可以使用全限定名 mcp__server__tool 写 allow/deny rule,避免不同 server 的同名工具冲突。

这是一种标准的 Ports & Adapters 思路:MCP 是外部协议,Tool 是内部端口。外部协议在边界被翻译,核心循环保持纯粹。未来接入另一种工具协议时,只要也能适配成 Tool,Agent Loop 基本无需改变。

7.3 动态连接为何不会把本轮工具列表锁死

MCP server 的连接发生在网络 I/O 上,可能在 REPL 已渲染后才完成。useManageMCPConnections 会把 16ms 窗口内的 server 更新合并到一次 AppState 更新,并按 server 前缀替换其 tools/commands/resources,减少 React 重渲染:

ts 复制代码
// src/services/mcp/useManageMCPConnections.ts:203-305(节选)
const MCP_BATCH_FLUSH_MS = 16

const updateServer = update => {
  pendingUpdatesRef.current.push(update)
  flushTimerRef.current ??= setTimeout(
    flushPendingUpdates,
    MCP_BATCH_FLUSH_MS,
  )
}

// flush 时按 server name 更新 client,按 mcp__server__ 前缀替换 tools,
// 同时更新 commands 与 resources。

动态性延伸到 query 内部。REPL 每个用户 turn 开始都从 store 重新读取连接;而一轮 agent 可能包含多次模型请求,所以 src/query.ts:1659 在工具结果回送模型之前还会调用 refreshTools()

ts 复制代码
// src/query.ts:1659-1671
if (updatedToolUseContext.options.refreshTools) {
  const refreshedTools = updatedToolUseContext.options.refreshTools()
  if (refreshedTools !== updatedToolUseContext.options.tools) {
    updatedToolUseContext = {
      ...updatedToolUseContext,
      options: { ...updatedToolUseContext.options, tools: refreshedTools },
    }
  }
}

于是,第一轮请求时仍 pending 的服务器,可以在第二个 model iteration 变成可用工具。与此同时,工具数组的排序与 dynamic instructions delta 又尽量减少这种变化对 prompt cache 的破坏。

7.4 MCP 不只提供 tools

连接成功后,Claude Code 还会获取 prompts/commands、resources、skills 和 server instructions。Resources 通过内置 List/Read MCP Resource Tool 暴露;commands 合入斜杠命令;instructions 进入 system prompt 或以 delta attachment 注入。插件甚至可以声明 MCP server,让一个插件同时带来命令、skill、hook 与远程工具。

因此 MCP 在 Claude Code 中不是"多一种 function calling",而是一个外部能力包协议。只是其中最频繁的 tool 调用,被有意压平到了统一 Tool 抽象中。


八、Context:不是字符串拼接,而是分层、分角色、分缓存稳定性

8.1 三个显式上下文入口

src/utils/queryContext.ts:44 将请求前的固定上下文拆为三个部分并行获取:

ts 复制代码
// src/utils/queryContext.ts:44-79(节选)
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([
  customSystemPrompt !== undefined
    ? Promise.resolve([])
    : getSystemPrompt(tools, model, directories, mcpClients),
  getUserContext(),
  customSystemPrompt !== undefined
    ? Promise.resolve({})
    : getSystemContext(),
])
  • systemPrompt:产品身份、行为原则、工具说明、环境、输出风格、memory 提示、MCP instructions 等。
  • userContextCLAUDE.md/规则记忆、当前日期等,以 meta user message 置于历史消息之前。
  • systemContext:例如会话开始时的 git 状态快照,附加到 system prompt 尾部。

自定义 system prompt 会替换默认 prompt,并跳过默认 system context;这避免用户以为自己完全接管 system prompt,实际仍被偷偷追加一大段产品上下文。

8.2 System Prompt 的静态区和动态区

src/constants/prompts.ts:444getSystemPrompt() 不直接返回一个巨型模板,而是返回 string array。最前面是稳定的产品指令,边界之后是 session guidance、memory、环境、language、output style、MCP instructions 等动态 section:

ts 复制代码
// src/constants/prompts.ts:560-576(节选)
return [
  // Static content: cacheable
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),

  ...(shouldUseGlobalCacheScope()
    ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY]
    : []),

  // Dynamic content
  ...resolvedDynamicSections,
].filter(s => s !== null)

systemPromptSections.ts 会缓存应保持稳定的 section;必须每轮重算的 section 要显式使用名为 DANGEROUS_uncachedSystemPromptSection 的 API,并写出为何值得打破缓存。这个命名很有教育意义:动态 prompt 不是免费灵活性,每次变化都可能让几十万会话付出额外 cache creation 成本和延迟。

8.3 CLAUDE.md:层级化、可导入的项目记忆

src/utils/claudemd.ts 定义了 memory 的发现顺序:

  1. managed memory,如 /etc/claude-code/CLAUDE.md
  2. 用户级 ~/.claude/CLAUDE.md
  3. 项目级 CLAUDE.md.claude/CLAUDE.md.claude/rules/*.md
  4. 本地私有 CLAUDE.local.md

文件按"低优先级先、高优先级后"装载,越靠近 cwd 的规则越晚出现。它支持 @path include,使用已处理集合避免循环,限制文本扩展名,并对单文件给出 40,000 字符的建议上限。规则还可以通过 frontmatter 的 path/glob 限定适用文件。

ts 复制代码
// src/utils/claudemd.ts:66 附近
const MEMORY_INSTRUCTION_PROMPT =
  'Codebase and user instructions are shown below. ' +
  'These instructions OVERRIDE any default behavior...'

export const MAX_MEMORY_CHARACTER_COUNT = 40000

const TEXT_FILE_EXTENSIONS = new Set([
  '.md', '.txt', '.text', /* ... */
])

这不是传统 RAG:它没有每次向量检索整个代码库,而是把人工维护的高价值规则做确定性发现和层级覆盖。优点是透明、可版本控制、容易审计;缺点是规则增多后会占据固定上下文,并可能产生冲突。源码通过条件规则、嵌套 memory 延迟加载和压缩后去重来缓解,但规则治理仍是使用者的责任。

8.4 为什么 user context 要伪装成 meta user message

src/utils/api.ts:452prependUserContext() 把 user context 包在 <system-reminder> 中,作为 isMeta user message 放到会话前面:

ts 复制代码
// src/utils/api.ts:452-471(节选)
return [
  createUserMessage({
    content: `<system-reminder>
As you answer the user's questions, you can use the following context:
${Object.entries(context)
  .map(([key, value]) => `# ${key}\n${value}`)
  .join('\n')}
</system-reminder>`,
    isMeta: true,
  }),
  ...messages,
]

这让产品默认 system prompt 与用户/项目提供的 memory 在协议角色上分开,也便于缓存和覆盖。但它引出一个重要安全事实:上下文来源并不等于信任级别。CLAUDE.md、MCP instructions、工具结果都可能影响模型,因此外围仍需权限与路径边界,不能靠"这是 system-reminder"替代执行安全。

8.5 Tool schema 和附件也是 Context

计算上下文窗口时,不能只数聊天文本。模型每轮还看到工具名、描述、JSON schema,延迟工具列表、agent 定义、MCP instructions delta、读过的文件、git 变化、plan、skill 内容和任务通知。源码将许多动态信息建模成 AttachmentMessage,再在 API 边界重排或过滤。

这是比"不断往 system prompt 拼字符串"更可维护的做法:附件有类型、UUID 和生命周期,可以在 transcript 中持久化、在 compact 后选择性回灌、在 API 前转换、在 UI 中选择展示或隐藏。


九、上下文窗口治理:裁剪、压缩、恢复是多层防线

9.1 自动压缩阈值不是模型窗口上限

src/services/compact/autoCompact.ts 先从模型 context window 中预留 summary 输出空间,再额外留出 buffer,得到 proactive auto-compact 阈值:

ts 复制代码
// src/services/compact/autoCompact.ts:25-75(节选)
const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000

export function getEffectiveContextWindowSize(model: string): number {
  const reserved = Math.min(
    getMaxOutputTokensForModel(model),
    MAX_OUTPUT_TOKENS_FOR_SUMMARY,
  )
  return getContextWindowForModel(model, getSdkBetas()) - reserved
}

export function getAutoCompactThreshold(model: string): number {
  return getEffectiveContextWindowSize(model) - AUTOCOMPACT_BUFFER_TOKENS
}

这样做是为了防止"刚好能塞进输入,却没有空间生成摘要/回答"。自动压缩连续失败三次后触发 circuit breaker,避免不可恢复的 session 每轮都额外轰炸一次 compact API。

9.2 压缩不是简单替换成一段摘要

autoCompactIfNeeded() 先尝试 session-memory compaction,再回退到传统 compactConversation()。后者大致做这些事:

  1. 执行 PreCompact hook,合并用户与 hook 的摘要指令;
  2. 让一个 forked agent/模型基于原历史生成结构化 summary;
  3. compact 请求本身若 prompt too long,按 API round 丢弃最老分组后重试,最多三次;
  4. 清理文件读取缓存和嵌套 memory 状态;
  5. 建立 compact boundary message 和 summary user message;
  6. 回灌最近读过的关键文件、异步 Agent 状态、plan、plan mode、已调用 skills;
  7. 重新宣告 deferred tools、agent 列表和 MCP instructions delta;
  8. 执行 SessionStart(compact) 与 PostCompact hooks;
  9. 写 transcript 元数据并重置 prompt-cache break baseline。
ts 复制代码
// src/services/compact/compact.ts:387 起(语义节选)
export async function compactConversation(messages, context, cacheParams, ...) {
  const preCompactTokenCount = tokenCountWithEstimation(messages)
  const hookResult = await executePreCompactHooks(...)

  const summaryResponse = await streamCompactSummary({
    messages,
    summaryRequest: createUserMessage({ content: getCompactPrompt(...) }),
    cacheSafeParams: cacheParams,
  })

  const boundaryMarker = createCompactBoundaryMessage(
    isAutoCompact ? 'auto' : 'manual',
    preCompactTokenCount,
    messages.at(-1)?.uuid,
  )

  return {
    boundaryMarker,
    summaryMessages,
    attachments: postCompactFileAttachments,
    hookResults,
  }
}

关键思想是:摘要只保存"语义状态",并不能自动保存运行状态。比如模型之前读过哪些文件、当前是否在 plan mode、哪个异步 Agent 仍在运行、哪些 deferred tools 已经加载,都需要结构化回灌。否则压缩表面成功,下一轮 agent 却丢失操作连续性。

9.3 主动压缩与被动恢复并存

token 估算不可能百分百准确,provider 也可能因图片、PDF 或 tool schema 给出真实的 prompt-too-long。于是 query 还有 reactive compact:先扣住错误消息,尝试 context collapse drain,再做完整摘要压缩;成功就带着新 State 重试,失败才把错误暴露给用户。

这体现了可靠系统常见的两层策略:估算阈值用于日常预防,服务端真实错误用于兜底纠偏。只做前者会被估算误差击穿,只做后者则每次都要先失败一次,体验与成本都差。


十、会话持久化:追加式 JSONL、父指针与可恢复性

10.1 为什么先写用户消息,再请求模型

QueryEngine 在进入 query 前就持久化用户消息。源码注释给出的原因非常具体:如果用户发送后几秒立即 Stop/杀进程,而 API 还没返回,若只在收到 assistant event 后写日志,该 session 只有 queue-operation,没有对话,--resume 会认为不存在会话。

ts 复制代码
// src/QueryEngine.ts:430-463(节选)
this.mutableMessages.push(...messagesFromUserInput)
const messages = [...this.mutableMessages]

// 在进入 query loop 前持久化,保证 kill-mid-request 仍可恢复
if (persistSession && messagesFromUserInput.length > 0) {
  const transcriptPromise = recordTranscript(messages)
  if (isBareMode()) void transcriptPromise
  else await transcriptPromise
}

这是一条值得迁移到任何 agent 产品的原则:接受用户意图和完成模型响应是两个不同的事务边界。 前者一旦对用户可见,就应先落盘。

10.2 Transcript 是图,不只是数组

src/utils/sessionStorage.ts:1408recordTranscript() 会清理不可记录消息、按 UUID 去重,再通过 insertMessageChain() 写追加式 JSONL。每条链参与者带 uuidparentUuid

ts 复制代码
// src/utils/sessionStorage.ts:1408-1450(节选)
export async function recordTranscript(messages, teamInfo, parentHint) {
  const cleaned = cleanMessagesForLogging(messages)
  const recorded = await getSessionMessages(getSessionId())
  const newMessages = []
  let parentUuid = parentHint

  for (const message of cleaned) {
    if (recorded.has(message.uuid)) {
      if (newMessages.length === 0 && isChainParticipant(message)) {
        parentUuid = message.uuid
      }
    } else {
      newMessages.push(message)
    }
  }

  await getProject().insertMessageChain(
    newMessages, false, undefined, parentUuid, teamInfo,
  )
}

为什么要父指针?因为 rewind/ctrl-z 后的新消息不是覆盖旧文件,而是从历史某点长出新分支。追加式写入避免频繁重写巨型 transcript,也保留审计历史;恢复时从最新 leaf 沿 parentUuid 反向构造当前 conversation chain,死分支自然被排除。

loadTranscriptFromFile() 的核心过程正是:解析 JSONL,找到最新 leaf,沿父指针回溯,再附上 title、tag、文件历史、context collapse commit 等元数据。

ts 复制代码
// src/utils/sessionStorage.ts:2294-2330(节选)
const { messages, leafUuids, summaries, ...meta } =
  await loadTranscriptFile(filePath)

const leaf = findLatestMessage(
  messages.values(),
  msg => leafUuids.has(msg.uuid),
)

const transcript = buildConversationChain(messages, leaf)
return convertToLogOption(transcript, /* summaries and metadata */)

大型 JSONL 还有字节级预过滤:在完整 JSON.parse 之前先从 EOF 沿父链定位活分支,从而跳过 rewind 留下的大量 dead branch。源码注释记录的测试中,41MB、99% dead 的日志解析从约 56ms 降到 3.9ms。这说明数据结构选对后,性能优化可以利用其不变量,而不必改存储格式。

10.3 压缩边界也是日志事件

compact 不会悄悄把旧 transcript 覆盖掉,而是写入 compact_boundary、summary 与 preserved segment 信息。这样 UI/恢复逻辑知道某处发生了语义折叠,parent chain 也能从新的摘要继续。相反,fallback 产生的孤儿 assistant message 会由 tombstone 从 transcript 移除,避免恢复后把非法 partial thinking 再发给 API。

所以 transcript 不只是"聊天记录",而是事件溯源式的 session store。它同时服务恢复、调试、UI、压缩和子 Agent resume。


十一、Subagent:复用同一运行时,而不是另造迷你 Agent

AgentTool 本身就是一个普通 Tool,定义在 src/tools/AgentTool/AgentTool.tsx:196。模型调用它时,可以选择 subagent type、模型、前后台执行、隔离/worktree 等。工具内部解析 Agent definition、过滤可用工具与权限,最后在 runAgent.ts 中建立子上下文。

最关键的一点是:子 Agent 仍然调用同一个 query()

ts 复制代码
// src/tools/AgentTool/runAgent.ts:748-760
for await (const message of query({
  messages: initialMessages,
  systemPrompt: agentSystemPrompt,
  userContext: resolvedUserContext,
  systemContext: resolvedSystemContext,
  canUseTool,
  toolUseContext: agentToolUseContext,
  querySource,
  maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) {
  // 转发进度,并写入 agent sidechain transcript
}

子 Agent 与主线程的差别来自配置和上下文,而不是另一套 engine:

  • 使用 agent definition 对 system prompt、工具集合、模型和最大轮数做约束;
  • 使用独立 AbortController、readFileState、agentId 与 sidechain transcript;
  • 同步 Agent 可以共享部分 AppState,异步 Agent 避免直接改主 UI 状态,但通过专门 callback 注册任务;
  • Agent 自己声明的 MCP server 在 finally 中清理;
  • 后台执行把状态转为任务/进度消息,主 Agent 不需要阻塞。

11.1 Fork 的缓存优化

多个子 Agent 同时从一个 assistant message 分叉时,如果每个孩子构造不同的前缀,会重复支付整段历史的 cache creation。src/tools/AgentTool/forkSubagent.ts:98 让所有孩子保留同一个完整 assistant message,并为其中每个 tool_use 建立字节相同的占位 tool_result;只有最后的 child directive 不同。

ts 复制代码
// src/tools/AgentTool/forkSubagent.ts:98 起(节选)
const toolResultBlocks = toolUseBlocks.map(block => ({
  type: 'tool_result' as const,
  tool_use_id: block.id,
  content: [{ type: 'text', text: FORK_PLACEHOLDER_RESULT }],
}))

return [
  fullAssistantMessage,
  createUserMessage({
    content: [
      ...toolResultBlocks,
      { type: 'text', text: buildChildMessage(directive) },
    ],
  }),
]

这是"把差异推到 prompt 尾部"的经典缓存设计。它也说明 multi-agent 的主要成本不只在额外模型调用,还在每个 fork 的上下文重复;架构需要从消息构造层解决,而非只在调度器上限并发数。

11.2 多 Agent 的真实复杂度在状态隔离

复用 query 很优雅,但真正难的是所有 session-scoped 状态是否该共享:权限、任务列表、file cache、MCP connection、hooks、消息日志、预算、cwd/worktree、取消信号,各自的共享规则不同。ToolUseContext 之所以宽,很大原因就是它成了这组 capability 的显式载体。

Claude Code 的做法比使用进程级 singleton 更可控:为子 Agent 创建派生 context,并对 setAppStatesetAppStateForTasks、AbortController 等逐项决定共享或隔离。尽管类型庞大,但语义是可审阅的。


十二、Hooks、Skills 与 Plugins:三种不同层次的扩展

这三个概念容易混在一起:

  • Tool 是模型可以发起的原子能力;
  • Skill 是按需加载的领域指令/工作流知识,通常通过 SkillTool 或命令触发;
  • Hook 是生命周期拦截器,可在 user prompt、tool use、compact、session start/stop 等节点观察或改变行为;
  • Plugin 是打包容器,可以同时携带 commands、agents、skills、hooks、output styles、MCP servers 和 LSP servers。

src/types/plugin.ts:48 很清楚地显示了 plugin 的聚合角色:

ts 复制代码
// src/types/plugin.ts:48-69(节选)
export type LoadedPlugin = {
  name: string
  manifest: PluginManifest
  path: string
  commandsPaths?: string[]
  agentsPaths?: string[]
  skillsPaths?: string[]
  outputStylesPaths?: string[]
  hooksConfig?: HooksSettings
  mcpServers?: Record<string, McpServerConfig>
  lspServers?: Record<string, LspServerConfig>
  settings?: Record<string, unknown>
}

Hook 则进入真实执行流水线,而非只做通知。以 PreToolUse 为例,src/services/tools/toolHooks.ts:435 会迭代 hook 结果,可能 yield UI/progress message、deny、updated input、additional context 或 stop signal:

ts 复制代码
// src/services/tools/toolHooks.ts:435 起(节选)
for await (const result of executePreToolHooks(
  tool.name,
  toolUseID,
  processedInput,
  toolUseContext,
  permissionMode,
  toolUseContext.abortController.signal,
)) {
  if (result.blockingError) yield denyDecision(result.blockingError)
  if (result.updatedInput) yield { type: 'hookUpdatedInput', updatedInput: result.updatedInput }
  if (result.preventContinuation) yield { type: 'preventContinuation', shouldPreventContinuation: true }
  if (result.additionalContext) yield additionalContextMessage(result)
}

这种能力很强,也意味着 hook 属于安全边界。源码因此规定 hook allow 不能盖过 deny/ask rule,updated input 还要进入后续 schema/权限语义,执行超时和 abort 也必须受控。

Skill 的架构位置更靠近 Context:它将大量专用说明从全局 system prompt 中移出,仅在需要时注入,从而降低常驻 token。Skill discovery 甚至会在模型流式生成/工具执行期间预取,用计算重叠隐藏延迟;compact 后只回灌已实际调用的 skill 内容,并限制每个 skill 与总 token budget。

插件把这些机制组合起来,却没有绕过核心边界:插件 MCP 仍变成 Tool,插件 hook 仍进入 hook runner,插件 skill 仍进入 context/SkillTool,插件 agent 仍复用 query。这种"扩展组件最终回归少数内部协议"的设计,能控制插件系统对核心复杂度的扩散。


十三、ToolUseContext:看似"大对象",实际是一次运行的 Capability Bag

阅读源码时,ToolUseContext 很容易成为困惑点。它从 src/Tool.ts:158 开始,包含工具/命令/模型/MCP、AbortController、文件缓存、AppState getter/setter、通知、elicitation、query tracking、agentId、progress、compact callback、消息和多种实验状态。

ts 复制代码
// src/Tool.ts:158 起(极度节选)
export type ToolUseContext = {
  options: {
    commands: Command[]
    mainLoopModel: string
    tools: Tools
    mcpClients: MCPServerConnection[]
    mcpResources: Record<string, ServerResource[]>
    refreshTools?: () => Tools
  }
  abortController: AbortController
  readFileState: FileStateCache
  getAppState(): AppState
  setAppState(update: (prev: AppState) => AppState): void
  agentId?: AgentId
  queryTracking?: QueryChainTracking
  messages?: Message[]
  renderedSystemPrompt?: string[]
}

从纯洁架构角度看,它确实过宽,存在"上帝对象"风险。但从 agent runtime 角度,它也有合理性:Tool 执行不是普通业务函数,它需要访问受控 cwd、权限快照、取消信号、父消息、文件读状态、MCP 客户端和 UI progress。若这些全部来自 module singleton,子 Agent 隔离、测试替换和并发运行会更危险。

更准确的理解是:它是一个显式 capability bag。拿到某个 callback 就拥有某项能力;子 Agent 派生它时可以选择不给或替换能力。未来若要改进,可以按稳定边界拆成 ExecutionContextSessionContextPresentationContext,但不应退回隐式全局状态。


十四、从一次真实请求看完整时序

假设用户输入:"读取配置文件,把超时时间改成 30 秒并运行测试。" 一次典型时序如下:

  1. REPL 的输入处理器把自然语言、可能的图片/命令附件转换成内部 Message,并应用斜杠命令带来的临时 allowedTools。
  2. REPL 从 AppState 获取最新 MCP clients/tools,构建 ToolUseContext。
  3. getSystemPromptgetUserContextgetSystemContext 并行:静态产品指令、CLAUDE.md、日期、git 快照分别进入正确层。
  4. query() 在请求前规范化历史,评估 context window,必要时 snip/compact,添加 deferred tool/MCP/skill 等附件。
  5. API 层把内部 Message 归一化,把 Zod/MCP JSON schema 转成稳定工具 schema,建立流式请求。
  6. 模型先输出文字,再产生 Read tool_use。query 收集 block;StreamingToolExecutor 若允许则提前启动。
  7. Tool Executor 解析 schema,执行 PreToolUse hook 和权限判定。Read 是只读工具,可能根据规则直接 allow。
  8. Read 返回内容,映射成 tool_result;结果太大时按工具的 size policy 截断或落盘,避免撑爆下一轮。
  9. query 把 assistant(Read tool_use) 和 user(Read tool_result) 追加到 messages,再请求模型。
  10. 模型产生 Edit tool_use。路径安全检查、deny/ask/allow rule、hook 与人工确认共同决定是否执行。
  11. Edit 完成后产生文件变化附件,并回送模型。模型可能继续调用 Bash 运行测试。
  12. Bash tool 的 sandbox/命令级规则参与权限;运行时 progress 作为内部事件实时进入 UI,但不会原样污染 API 历史。
  13. 测试结果作为 tool_result 回送;模型不再调用工具,给出总结,query 正常返回。
  14. 用户消息在 API 前已经写入 transcript;assistant/tool result/attachments 随事件追加,因而任何中断点都尽量可恢复。

对应的消息协议大致是:

json 复制代码
[
  { "role": "user", "content": "读取配置文件......" },
  { "role": "assistant", "content": [{ "type": "tool_use", "id": "t1", "name": "Read" }] },
  { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "t1" }] },
  { "role": "assistant", "content": [{ "type": "tool_use", "id": "t2", "name": "Edit" }] },
  { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "t2" }] },
  { "role": "assistant", "content": [{ "type": "tool_use", "id": "t3", "name": "Bash" }] },
  { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "t3" }] },
  { "role": "assistant", "content": "已修改并通过测试。" }
]

注意 UI 可能在这期间显示几十条 progress、权限状态和虚拟消息,但 API 视角仍然保持严格的 assistant tool_use / user tool_result 配对。这就是内部 Message 模型与 API normalization 边界的意义。


十五、架构上的优点、代价与可改进方向

15.1 最值得肯定的设计

第一,消息协议同时承担控制流和持久化语义。 query 不需要维护一套与 API 完全不同的 workflow graph;transcript 也不必把工具调用另存关系表。相同 tool_use/tool_result 结构贯穿模型、执行、UI 和恢复。

第二,MCP 在边界适配为 Tool。 核心循环不被 transport 和远程协议污染,权限、并发、schema、展示能力都能复用。

第三,权限被视为正式策略层。 deny、ask、安全检查、bypass、hook 和人工交互有明确优先级,并携带 decision reason,而不是散落在各工具里的 if (confirm)

第四,Prompt Cache 是一等架构约束。 工具顺序、静态/动态 prompt、schema 缓存、clone 而不 mutation、fork 前缀一致性都围绕它设计。对需要一轮调用模型多次的 agent,这是成本与延迟的关键。

第五,恢复能力深入到异常细节。 用户消息提前落盘、fallback tombstone、missing tool result 修补、append-only parent chain、compact boundary,都说明"进程随时会被杀、网络随时会断"是设计前提。

第六,同一个 query 被主线程、SDK 和子 Agent 复用。 多 agent 是配置化派生,不是复制一份简化引擎,因此行为一致性更强。

15.2 复杂度与技术债

query.ts 仍是一个超大控制中心。 压缩、恢复、预算、预取、tool search、附件、stop hooks 和实验功能都在单循环交织。显式 State/transition 已经帮助很多,但长期可以将"请求前 context pipeline""stream reducer""recovery policy""post-tool continuation"拆成独立状态节点或纯函数。

Tool 接口耦合执行与 React 展示。 对终端产品很高效,但让底层协议依赖 UI 类型。更清晰的演进方向是核心 Tool descriptor + 可选 presentation adapter,同时保留同模块共置。

ToolUseContext 太宽。 显式依赖优于 singleton,但任何工具理论上都能看到大量不需要的能力。可按最小权限拆成多个 capability interface,并由具体 Tool 在类型上声明需要哪些上下文。

大量 feature gate 增加路径组合。 快照里 feature('...')、GrowthBook gate、环境变量和用户 setting 同时存在。一些 gate 还受 Bun DCE 约束,导致代码结构必须围绕构建器写法。需要强大的矩阵测试与 gate 清理纪律。

Context 类型多而生命周期隐晦。 system prompt、meta user、attachment、local system、virtual message、progress、compact summary 在不同边界被包含或排除。类型已经提供保护,但读者仍需跟踪多层 transform。把每类 Message 的"UI / transcript / API / compact"可见性做成声明式 policy table,会更易维护。

插件与 hook 的能力面很大。 它们能改 input、扩上下文和影响 continuation,既强大又提高安全审计成本。理想情况下,插件 manifest 应更细粒度声明 capability,并在安装/运行时展示。

15.3 为什么这些代价仍然合理

Agent CLI 的复杂度不是传统 CRUD 复杂度。它要把概率性的模型流、非幂等工具、实时 UI、用户中断、外部 MCP、长期上下文和可恢复日志放进同一个运行时。许多"大接口"和"大循环"是在显式承载本来就存在的交叉约束。

评价这类架构时,不应只数文件行数,而要问:失败后消息协议是否仍合法?权限是否可解释?子 Agent 是否隔离?动态工具是否能被发现?压缩后任务是否连续?进程被杀后能否恢复?从这些问题看,这个快照体现了相当成熟的工程化。


十六、推荐的源码阅读顺序

如果目标是理解架构,而不是逐文件浏览,建议按下面顺序读:

  1. src/screens/REPL.tsx:2661:看一次交互输入如何准备 context 并进入 query。
  2. src/query.ts:204:241:659:1380:1715:掌握 State、模型流、工具执行与下一轮状态。
  3. src/Tool.ts:123:158:362:783:理解权限上下文、运行上下文和 Tool 合同。
  4. src/tools.ts:193:345:看内置能力注册与 MCP 合并。
  5. src/services/tools/toolExecution.ts:337toolOrchestration.ts:19:跟完整工具生命周期与并发分批。
  6. src/utils/permissions/permissions.ts:1158src/hooks/useCanUseTool.tsx:区分策略判定与交互审批。
  7. src/services/mcp/client.ts:1743types.ts:179useManageMCPConnections.ts:203:理解 MCP 状态、适配和热更新。
  8. src/constants/prompts.ts:444src/context.ts:116src/utils/claudemd.ts:理解 Context 的来源与层级。
  9. src/utils/messages.ts:1989src/utils/api.ts:119:理解内部消息如何跨 API 边界。
  10. src/services/compact/autoCompact.ts:241compact.ts:387:理解上下文窗口治理。
  11. src/utils/sessionStorage.ts:1408:2294:理解 transcript 写入、父链和恢复。
  12. src/tools/AgentTool/runAgent.ts:748forkSubagent.ts:98:理解子 Agent 复用与缓存优化。
  13. src/QueryEngine.ts:184:最后回看 SDK/无头模式如何把整个核心封装成会话对象。

十七、总结:Claude Code 的本质是一个围绕消息日志构建的 Agent Runtime

Claude Code 的架构中心不是 React,不是 MCP,甚至也不是某一个 Tool,而是 query() 所维护的消息状态机 。模型通过 assistant message 提出下一步动作,工具执行层在权限和 hook 约束下完成动作,结果作为 user message 回到模型;UI、SDK、transcript、compact 和 subagent 都围绕这条消息流建立自己的投影。

Tool 把异构能力统一为可描述、可校验、可授权、可并发调度、可展示的对象;MCP 在边界被翻译成 Tool,因而无需侵入核心;Context 被拆成不同角色和缓存层,避免把所有信息粗暴拼成 prompt;压缩把语义摘要与结构化运行状态回灌结合;JSONL 父链让会话能够在中断、rewind、compact 和 fork 后继续;子 Agent 则通过派生 ToolUseContext 再次运行同一个 query engine。

如果要从这套设计中提炼一句最值得带走的话,那就是:

一个可靠的 coding agent,不是让模型"会调用函数"就够了;它必须围绕消息协议,系统性解决能力描述、权限决策、并发执行、上下文预算、缓存稳定、失败恢复和长期状态。

相关推荐
todoitbo1 小时前
一个接口,三个模型:一个Token Plan 的前后端开发实践
ai·项目·前后端·token plan·aiionly
俊哥V1 小时前
每日 AI 研究简报 · 2026-08-23
人工智能·ai
卷无止境1 小时前
寻找不花钱的AI算力,看这一篇就够了
llm
zhaodezhu16881 小时前
四维技术全域赋能 一网推重构企业数字营销增长新范式
安全·架构·泰兴geo优化·可靠公司·苏州geo技术·四维技术·全域赋能
沉默王二1 小时前
爽用 DeepSeek V4 Flash、GLM-5.2、Qwen3.8 Max、GPT-5.6 Sol,EvoX 够猛
agent·ai编程
橘色的喵1 小时前
PySide6 工业上位机的实时帧链、零拷贝与跨语言架构
c++·架构·图像·pyside
山顶夕景1 小时前
【MLLM Agent】多模态理解Agent研究进展
agent·强化学习·多模态·rl·agentic
dong_junshuai1 小时前
# 每天一个开源项目#76 Skills:23万星的 Agent 工程技能库
开源·github·agent
tachibana22 小时前
把RAGAS跑起来
数据库·人工智能·ai·架构·大模型·llm·rag