乐于分享
好东西不私藏

Claude Code 为什么更听话?源码级深度解析

Claude Code 为什么更听话?源码级深度解析

这是 Claude Code 与其他 AI 编程工具最本质的区别。

每次工具调用都会经过权限检查,返回三种行为之一:

type PermissionBehavior = 'allow' | 'deny' | 'ask'
  • allow
    :直接执行
  • deny
    :拒绝执行,记录原因,继续
  • ask
    :弹窗让用户确认,用户可以选择 allow 或 deny
  • 这三种行为覆盖了所有场景:安全的操作直接放行,有风险的操作要么拒绝要么确认。

用户可以配置权限模式,不同场景用不同的松紧度:

const EXTERNAL_PERMISSION_MODES = [
  'acceptEdits',      // 直接接受所有编辑
  'bypassPermissions',  // 完全绕过权限检查,危险但高效
  'default',           // 默认模式
  'dontAsk',           // 不询问,直接 deny,任何触发 ask 的操作都会被转为 deny
  'plan',              // 只在 plan 模式下询问
] as const

// 如果开启了 TRANSCRIPT_CLASSIFIER feature gate,还有:
type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'

每种模式对应一种使用场景:

模式
适用场景
default
日常开发,AI 帮你写代码
acceptEdits
AI 审查代码后直接接受修改
bypassPermissions
自动化脚本,不需要确认
dontAsk
不询问,直接 deny
plan
先让 AI 规划方案,确认后再执行
auto
AI 自动判断,置信度高就 allow,低就 deny

auto 模式是 Claude Code 最特别的地方。它内置了一个分类器,用 AI 来判断工具调用是否安全。 分类结果有三种置信度:

type ClassifierResult = {
  matches: boolean
  confidence: 'high' | 'medium' | 'low'
  reason: string
}

Claude Code 是怎么判断置信度的? 整个判断流程分两个阶段: 第一阶段:Fast Path(快速通道) 如果判断”高置信度 allow”,直接返回,不弹窗也不记日志。这是日常操作的常见路径——常用命令(git status、npm install 等)已经被内置到白名单里,高置信度匹配,直接放行。 第二阶段:Thinking Stage(深度推理) 如果第一阶段没有得出高置信度结论,进入深度推理阶段。Claude 会分析更完整的上下文,包括:命令语义、工作目录、项目配置文件(.claude/ 目录)、用户之前的操作历史,来做更准确的判断。 置信度到行为的映射:

置信度
行为
高 + matches=true
allow(直接执行)
高 + matches=false
deny(直接拒绝)
中/低
ask(弹窗询问)

日常操作不会被频繁打断,只有真正模糊的情况才会触发确认。

auto 模式有个隐藏的风险:如果 AI 一直说”这个不安全,我拒绝”,用户怎么知道发生了什么? Claude Code 用了 denialTracking 来解决:

// denialTracking.ts
export const DENIAL_LIMITS = {
  maxConsecutive: 3,   // 连续拒绝超过 3 次
  maxTotal: 20,        // 或累计拒绝超过 20 次
} as const

export function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
  return (
    state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
    state.totalDenials >= DENIAL_LIMITS.maxTotal
  )
}

逻辑很清晰:

  • 连续拒绝 3 次 → 下次直接弹窗,不再自动拒绝
  • 累计拒绝 20 次 → 彻底放弃 auto 模式,回退到弹窗
// 成功执行后,重置连续计数
export function recordSuccess(state: DenialTrackingState): DenialTrackingState {
  if (state.consecutiveDenials === 0) return state
  return {
    ...state,
    consecutiveDenials: 0,  // 重置连续计数
  }
}
  • 一次成功的操作,就重置连续拒绝计数。这防止了 AI 因为一次误判就持续拒绝所有后续操作。
  • 这个机制回答了一个关键问题:如果 AI 一直自作主张拒绝执行,我怎么干预?答案是:连续拒绝3次以上,Claude Code 会强制弹窗;累计拒绝20次,彻底回退到弹窗模式。AI 的判断可以被打破,用户始终有最终决定权。

当 AI 在 auto 模式下拒绝了某个操作,这些拒绝会被记录下来:

// denialTracking.ts
export function recordAutoModeDenial(params: {
  toolName: string
  display: string
  reason: string
  timestamp: number
}): void

通过 /permissions 命令可以查看所有的权限决策记录,包括哪次操作被拒绝、为什么被拒绝。AI 的每一次”自作主张”都是透明可见的。

Claude Code 不只是处理对话,它管理着一套完整的任务系统。

export type TaskType =
  | 'local_bash'         // 本地命令执行
  | 'local_agent'         // 本地子 Agent
  | 'remote_agent'        // 远程 Agent
  | 'in_process_teammate' // 同进程内的子 Agent
  | 'local_workflow'      // 本地工作流脚本
  | 'monitor_mcp'         // MCP 服务器状态监控
  | 'dream'               // 探索/头脑风暴模式

每种任务类型对应不同的执行策略:

任务类型
触发方式
使用场景
local_bash 自动

 — Claude 调用 Bash 工具时自动创建
执行 git、npm、文件操作等系统命令
local_agent 手动

 — 用户或 Claude 调用 Agent 工具时创建
并行执行多个独立子任务(需要指定 subagent_type)
remote_agent 手动

 — Agent 工具指定远程配置时创建
连接到远程机器执行任务(如 SSH 远端开发)
in_process_teammate 自动/手动

 — 轻量任务自动升级,或手动指定
Coordinator 模式下的子 worker,轻量无需独立进程
local_workflow 手动

 — 用户执行 workflow 命令
运行预先定义好的多步骤自动化脚本(Feature-gated)
monitor_mcp 自动

 — MCP 服务器连接后自动创建
持续监控 MCP 资源变化,有变化时主动通知
dream 自动

 — 后台自动触发,无需用户干预
记忆整合:空闲时自动分析会话,更新上下文摘要

触发方式的核心区别:

  • 自动触发
    (local_bash、monitor_mcp、dream):Claude 决定是否创建,用户不感知
  • 手动触发
    (local_agent、remote_agent、local_workflow):需要用户或 Claude 显式调用工具
  • subagent_type 的作用:
  • 当调用 Agent 工具时,可以通过 subagent_type 指定子 Agent 的类型:
// 不指定 subagent_type → fork(继承完整上下文)
// 指定 subagent_type → 启动独立子会话
subagent_type: "code-reviewer"  // 代码审查专家
subagent_type: "worker"         // Coordinator 模式下的通用 worker
  • 有 subagent_type 时,子 Agent 从零开始,没有之前的对话上下文,需要在 prompt 里提供完整任务描述。没有 subagent_type 时,子 Agent 继承父会话的完整上下文,适合中间步骤不需要保留结果的场景。
export type TaskStatus =
  | 'pending'    // 已创建,等待执行
  | 'running'    // 执行中
  | 'completed'  // 正常完成
  | 'failed'     // 执行失败
  | 'killed'     // 被手动终止

每一步都有状态记录,状态机会确保任务不会在未知状态停留。

export function isTerminalTaskStatus(status: TaskStatus): boolean {
  return status === 'completed'
    || status === 'failed'
    || status === 'killed'
}

只有进入终态(completed/failed/killed)的任务才会从 AppState 中清理。这意味着:任务在界面消失 ≠ 任务已经结束,必须等状态机确认才认为任务真正结束。

每个任务都有一个唯一 ID,用随机字节生成:

const TASK_ID_PREFIXES: Record<string, string> = {
  local_bash: 'b',
  local_agent: 'a',
  remote_agent: 'r',
  in_process_teammate: 't',
  local_workflow: 'w',
  monitor_mcp: 'm',
  dream: 'd',
}

export function generateTaskId(type: TaskType): string {
  const prefix = getTaskIdPrefix(type)
  const bytes = randomBytes(8)
  let id = prefix
  for (let i = 0; i < 8; i++) {
    id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
  }
  return id  // 例如: a3f7c2d1e9b4
}

ID 前缀标识任务类型,后8位是随机字符串。36^8 ≈ 2.8万亿组合,足以防止暴力碰撞攻击。

QueryEngine.ts(1300+行)是整个系统的中枢,管理一次对话生命周期内的所有状态。

源码里有一段关键的注释和实现:

// Persist the user's message(s) to transcript BEFORE entering the query
// loop. If the process is killed before the API responds,
// the transcript is left with only queue-operation entries.
// Writing now makes the transcript resumable from the point
// the user message was accepted, even if no API response ever arrives.
if (persistSession && messagesFromUserInput.length > 0) {
  const transcriptPromise = recordTranscript(messages)
  if (isBareMode()) {
    void transcriptPromise  // 异步,不阻塞
  } else {
    await transcriptPromise
    // ...
  }
}

用户消息先写到磁盘,再开始 API 调用。 这样即使在 API 响应过程中进程被杀死,会话也是可恢复的。这是 Claude Code 实现 --resume 功能的底层基础。

QueryEngine 用 wrappedCanUseTool 包装了所有工具调用:

const wrappedCanUseTool: CanUseToolFn = async (
  tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision
) => {
  const result = await canUseTool(
    tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision
  )

  // Track denials for SDK reporting
  if (result.behavior !== 'allow') {
    this.permissionDenials.push({
      tool_name: tool.name,
      tool_use_id: toolUseID,
      tool_input: input,
    })
  }

  return result
}

所有非 allow 的调用都被记录到 permissionDenials 数组里,最终随结果一起报告给用户。这让 AI 的每一次”自作主张”都有案可查。

submitMessage 是一个 AsyncGenerator,它流式返回消息:

async *submitMessage(
  prompt: string | ContentBlockParam[],
  options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown> {
  for await (const message of query(...)) {
    yield* normalizeMessage(message)
    // 每条消息都实时 yield,支持流式 UI 更新
  }
}

这种设计让 Claude Code 能实时显示 AI 的思考过程和工具调用,而不需要等到 AI 完全回答完才看到结果。

if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
  yield {
    type: 'result',
    subtype: 'error_max_budget_usd',
    total_cost_usd: getTotalCost(),
    permission_denials: this.permissionDenials,
    num_turns: turnCount,
    // ...
  }
}

Claude Code 会追踪每次 API 调用的消耗,超出预算时主动停止并生成详细报告,包括:总花费、Token 用量、拒绝操作列表、对话轮次。

以上是底层机制的三个核心模块。权限系统决定了 AI 能做什么、不能做什么;任务状态机确保每个任务都能被追踪和管理;QueryEngine 则是整个执行流程的调度中枢。 下篇我们会深入上层能力:Hooks 生命周期如何实现全链路拦截、Coordinator Mode 怎样让多个 Agent 协同工作、以及 verify 和 /stuck 这些内置 Skills 怎么让 Claude Code 实现自我验证和自我诊断。 敬请期待。