Claude Code Harness 设计精读——从源码看 Agent 运行时

本文基于 Claude Code 的 TypeScript 源码(src/)精读,讲清一个核心问题:“harness” 到底是什么、它由哪些子系统构成、它们怎么协作把"一次用户输入"变成"一串工具调用 + 最终回答"

注:参考的某篇中文专栏文章无法访问,本文以源码为唯一事实来源,结构上覆盖 harness 的全部关键面。


一、什么是 harness——Agent 的"操作系统层"

在 Agent / LLM 工程语境里,harness 指包在模型之外的运行时——模型本身只会"输入文本→输出文本",但一个能干活的 Agent 需要:

  • 维护对话历史和上下文窗口(长了要压缩)
  • 把模型输出的 tool_use 解析出来、调度执行、把结果喂回模型
  • 控制工具的并发/串行、权限、安全
  • 在出错、超 token、被打断时正确恢复
  • 管子代理、计划模式、hooks、内存、技能、任务

这些"模型不管的工程事"加起来就是 harness。 Claude Code 的 harness 把模型当成一个纯函数,harness 负责喂它、跑它、回收它的输出、循环到结束。

源码鸟瞰(src/):

1
2
3
4
5
6
7
8
9
10
11
query.ts                  # 主循环 queryLoop(): 模型流式调用 → 工具调度 → 结果回流 → 再调模型
services/tools/ # 工具执行: toolOrchestration(并发/串行分批) + StreamingToolExecutor
Tool.ts # Tool 接口: call/inputSchema/checkPermissions/isReadOnly/...
tools/ # 每个具体工具: Bash/Edit/Read/Agent/Todo/Task/...
services/compact/ # 上下文管理: autoCompact/microCompact/reactiveCompact/snip
utils/hooks/ # Hooks 系统: PreToolUse/PostToolUse/Stop/...
tools/AgentTool/ # 子代理: fork/后台 agent/agent memory
coordinator/ # Coordinator 模式: 主线程只调度, 工具执行交给 worker
memdir/ + services/SessionMemory/ # 记忆与 CLAUDE.md
skills/ # 技能加载
query/ # tokenBudget/stopHooks/config: 每回合预算与停止

主线:query.tsqueryLoop 是心脏——它流式调模型、收 tool_use、交给 runTools/StreamingToolExecutor 执行、把结果作为 user message 喂回去、循环;上下文长了由 compact 子系统压缩、每回合用 tokenBudget/stopHooks 决定是否继续、子代理和 hooks 在旁路参与。


二、主循环 queryLoop:harness 的心脏

src/query.tsquery 是一个 async generatorasync function*),通过 yield 把流式事件吐给 UI/SDK,最终 return 一个 Terminal(正常结束)或抛错。核心结构(精简):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async function* queryLoop(params) {
const { systemPrompt, canUseTool, maxTurns, ... } = params
let state: State = {
messages: params.messages, // 对话历史(可变)
toolUseContext: params.toolUseContext,
autoCompactTracking: undefined, // 自动压缩状态
turnCount: 1, // 回合计数
maxOutputTokensRecoveryCount: 0, // 输出超长恢复
...
}
const budgetTracker = createBudgetTracker() // token 预算跟踪
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(...) // 旁路预取记忆

while (true) {
yield { type: 'stream_request_start' } // 通知 UI 开始
// 1. 流式调模型, 收 assistant 消息(含 tool_use 块)
// 2. 跑 hooks(postSampling), 处理工具
// 3. runTools(...) 执行这一批 tool_use
// 4. 把 tool_result 作为 user message append 回 messages
// 5. checkTokenBudget: 决定继续(发 nudge) 还是停
// 6. handleStopHooks: 模型说停时, 问 Stop hook 要不要真停
// 7. 触发 autoCompact / microCompact 当上下文逼近上限
}
}

关键设计点

  1. async generator 流式:用 yield 而不是回调,UI/SDK 边流式渲染边收。generator 的 .return() 能干净终止、.throw() 传播错误——源码注释专门说明这保证"started-without-completed"信号一致。

  2. 不可变 params + 可变 statesystemPrompt 等进循环就不变;可变状态集中在 state,每个迭代开头解构,continue 时整体重置 state = {...} 而不是 9 个单独赋值——这是源码刻意写的工程整洁。

  3. using 资源管理using pendingMemoryPrefetch = ... 用 TC39 Explicit Resource Management,generator 无论从哪条路径退出(正常/抛错/return)都自动 dispose 旁路任务,避免泄漏。

  4. 旁路预取startRelevantMemoryPrefetchstartSkillDiscoveryPrefetch 在模型流式输出和工具执行同时跑,事后才 await 消费——把"找相关记忆/技能"藏进算力里,不阻塞主路径。

面试金句:“harness 主循环是一个 async generator,每轮=流式调模型→解析 tool_use→调度执行→把 tool_result 喂回→检查预算和 stop hooks→按需压缩上下文,循环到模型不再发 tool_use 为止。generator 的 yield/return/throw 给了它干净的流式、终止、错误传播语义。”


三、Tool 接口:每个工具的契约(Tool.ts

harness 不直接认识每个工具的细节,它靠一个统一接口调度。Tool<Input, Output> 的关键字段(源码 Tool.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export type Tool<Input, Output, P> = {
name: string
inputSchema: Input // Zod schema, 模型要遵守的参数
description(input, options): Promise<string> // 给模型看的工具说明(可随输入变)
call(args, context, canUseTool, ...): Promise<ToolResult<Output>> // 真正执行

// 元信息: 控制调度与权限
isReadOnly(input): boolean // 只读? 只读可并发
isConcurrencySafe(input): boolean // 可与其他并发执行?
isDestructive?(input): boolean // 删除/覆盖/外发等不可逆
isEnabled(): boolean // 当前是否可用
interruptBehavior?(): 'cancel' | 'block' // 用户发新消息时该工具怎么办

// 权限
checkPermissions(input, context): Promise<PermissionResult>
validateInput?(input): ValidationResult // 调用前的输入校验

// UI 与结果呈现
renderToolUseMessage(input): string // 工具调用在界面上怎么显示
maxResultSizeChars: number // 结果多大就落盘只给预览
prompt(options): string // 权限请求文案

// 延迟加载(MCP/ToolSearch)
shouldDefer?: boolean // 需 ToolSearch 才能用
alwaysLoad?: boolean // 永不延迟, 第1回合就加载
searchHint?: string // ToolSearch 关键词
...
}

几个值得讲的字段

  • isReadOnly + isConcurrencySafe:决定调度——只读且并发安全的工具可一批并行跑,写工具串行。这是下一节并发调度的根据。
  • maxResultSizeChars:工具结果太大不直接进上下文(会爆),落盘只给模型一个预览+文件路径。ReadInfinity(自己已经限大小,落盘会形成 Read→文件→Read 死循环)。
  • shouldDefer / alwaysLoad / searchHint:工具数量可能几十上百(加 MCP 后上百),全塞进 system prompt 太占 token。shouldDefer 的工具用 defer_loading 延迟,模型要时先调 ToolSearch 按关键词找,再加载。alwaysLoad 的核心工具第 1 回合就给。这就是工具集规模与上下文成本的平衡。
  • description 是函数不是常量:工具说明可随输入动态生成(如 Bash 命令把当前命令也展示),让模型看到更贴合的说明。
  • checkPermissions + canUseTool:双层权限——工具自己声明需要的权限(checkPermissions),harness 还有全局的 canUseTool(用户批准/hook 审批)。forceUseToolAlways 字段表示即使 hook 自动批准也要问用户。

面试金句:“Tool 接口是 harness 与具体工具的契约:schema 约束输入、call 执行、isReadOnly/isConcurrencySafe 决定调度、checkPermissions 决定权限、maxResultSizeChars 控制结果不爆上下文、shouldDefer+searchHint 让工具集可扩展到上百个而不占满 system prompt。”


四、工具调度:并发与串行的分批(toolOrchestration.ts

模型一次 assistant 消息可能输出多个 tool_use 块(并行工具调用)。harness 不是无脑全并行,而是按安全性分批:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 精简自 services/tools/toolOrchestration.ts
export async function* runTools(...) {
// partitionToolCalls 把这批 tool_use 分成:
// - read-only batch: 全是 isReadOnly+isConcurrencySafe 的, 可并发
// - non-read-only batch: 写/副作用工具, 串行
while (...) {
if (batch全是只读并发安全) {
yield* runToolsConcurrently(batch) // 并发跑
} else {
yield* runToolsSerially(batch) // 串行跑, 一个完再下一个
}
}
}

StreamingToolExecutor:边流式边执行

更精细的是 StreamingToolExecutor——模型还在流式输出 tool_use 时就开始执行已完成的那个

  • 并发安全工具可与其它并发安全工具并行执行。
  • 非并发工具必须独占执行(exclusive access),且为维持顺序,遇到它就停止后续调度直到它完。
  • 一个并行工具出错,同批的并行工具会被取消(Cancelled: parallel tool call errored)。

为什么这么设计

  1. 只读可并发Grep/Glob/Read 互相不影响,并行省 wall-clock。
  2. 写工具串行Edit/Write/Bash 可能改文件系统,并行会踩踏(两个 Edit 同一个文件)。串行保证安全。
  3. 边流边执行:模型还在吐第二个 tool_use 时,第一个已能执行,工具执行和模型流式重叠,减总延迟。

面试金句:“工具调度按安全性分批:只读+并发安全的工具并发跑、写/副作用工具串行独占;StreamingToolExecutor 让工具执行和模型流式输出重叠。安全(写工具不踩踏)和性能(读工具并行、流式重叠)兼得。”


五、上下文管理:compact 家族(最复杂的子系统)

模型上下文窗口有限(如 200k),对话一长就超。Claude Code 有一整套上下文压缩机制,都在 services/compact/

5.1 autoCompact(自动压缩,主防线)

autoCompact.ts:当上下文逼近上限自动触发 compactConversation,把旧对话总结成一条 summary message,释放空间。关键参数:

  • getEffectiveContextWindowSize = 上下文窗口 − 预留给 summary 输出的 token(按 p99.99 约 17387 token 算,留 20000)。
  • AutoCompactTrackingState:记录是否压缩过、turn 计数、连续失败次数(断路器——连续压缩失败说明上下文无可救药超限,停止重试,避免死循环)。
  • 压缩后通知 prompt cache 失效(notifyCacheBreakDetection),因为摘要改了历史。

5.2 microCompact(微压缩,按工具结果逐条清)

microCompact.ts:不是整体压缩,而是把单个旧 tool_result 的内容清掉(替换成 [Old tool result content cleared])。因为对话里最占地方的是历史 tool_result(比如一次 Read 几千行),而这些"旧工具输出"模型现在多半用不上。按时间维度(timeBasedMCConfig)和大小逐条清,省 token 同时尽量保 cache。

5.3 reactiveCompact / snipCompact / sessionMemoryCompact

  • reactiveCompact:被动压缩——在"快超限"的临界点响应式触发,而非到点就压。
  • snipCompact(HISTORY_SNIP 特性):把一段历史"剪掉"用摘要替代。
  • sessionMemoryCompact:把会话关键信息存进 SessionMemory,压缩后保留可恢复线索。
  • apiMicrocompact:服务端辅助的微压缩。

5.4 上下文预算与 prompt cache

压缩不是无脑删——要保 prompt cache 命中。Claude 的 prompt cache 有 5 分钟 TTL,压缩边界(compactBoundaryMessage / microcompactBoundaryMessage)刻意对齐 cache 边界,让压缩后仍能命中未变部分的缓存。源码里大量注释讲"cache safe"“cache break detection”,因为压缩动历史就破坏 cache,得精细对齐边界。

面试金句:“上下文管理是个谱系:autoCompact 整体摘要兜底、microCompact 按条清旧 tool_result、reactiveCompact 临界响应、sessionMemoryCompact 把关键信息存走以便恢复。所有压缩都要和 prompt cache 边界对齐,否则破坏缓存得不偿失。”

5.5 tokenBudget:每回合的预算控制(query/tokenBudget.ts

用户可设 +500k 这种"本回合目标输出 token"。checkTokenBudget 在每轮结束决定:

  • 已用 < 90% 预算且没递减 → continue(发 nudge 消息让模型继续)。
  • 递减(连续 3 轮每轮增量 < 500 token,说明在空转)→ stop
  • 达 90% 预算 → stop。
  • 子代理(agentId 非空)或无预算 → 不 continue。

这给了"让模型自己跑够量再停"的能力,同时检测递减收益避免无脑续命空烧 token。


六、Hooks 系统:可编程的旁路插桩(utils/hooks/

Hooks 是 harness 给用户/工具的可编程扩展点——在关键事件点跑外部命令或脚本,干预或观察。源码 entrypoints/sdk/coreSchemas.ts 列了全部事件:

1
2
3
4
5
6
7
PreToolUse / PostToolUse / PostToolUseFailure
UserPromptSubmit / SessionStart / SessionEnd
Stop / StopFailure / SubagentStart / SubagentStop
PreCompact / PostCompact
PermissionRequest / PermissionDenied
Notification / Setup / TeammateIdle
TaskCreated / TaskCompleted / Elicitation

6.1 同步 vs 异步 hook

syncHookResponseSchema vs AsyncHookJSONOutput

  • 同步 hook(PreToolUse 等):可阻断决策。返回 { decision: 'approve'|'block', reason, updatedInput, additionalContext }——能直接批准/拦截工具调用、改输入、加额外上下文。
  • 异步 hook:只观察记录,不阻断(如 TaskCompleted 后记日志)。

6.2 关键 hook 的作用

  • PreToolUse:工具执行前。可批准/拦截/改输入——这是"自动化审批/安全护栏"的钩子。toolPermission 子系统消费它。
  • PostToolUse:工具执行后,可做副作用(如 fileChangedWatcher 监文件变化)。
  • UserPromptSubmit:用户发消息时,可改写/注入上下文。
  • Stop:模型说停时,hook 可决定真停还是继续handleStopHooks)——这是"防止模型过早收尾"的机制。
  • PreCompact/PostCompact:压缩前后插桩,可注入压缩要保留的信息。
  • SubagentStart/Stop:子代理生命周期。

6.3 hook 的注册来源

registerFrontmatterHooksregisterSkillHookshooksConfigManagersessionHooks——hook 可来自 settings 配置、skill 的 frontmatter、MCP 等。execAgentHook/execHttpHook/execPromptHook 是不同执行后端(子进程/HTTP/提示式)。AsyncHookRegistry 管异步 hook 的注册与生命周期。

面试金句:“Hooks 是 harness 的可编程旁路:PreToolUse 能批准/拦截/改工具输入、Stop 能决定模型要不要真停、PreCompact 能干预压缩保留什么。同步 hook 阻断决策、异步 hook 只观察。这是 Claude Code 把’硬编码行为’变成’可配置扩展’的关键,让安全护栏/自动审批/外部集成都能挂进来。”


七、子代理与 Coordinator 模式

7.1 AgentTool:子代理(tools/AgentTool/

主线程可调 Agent 工具派子代理。源码显示几条路线:

  • 后台子代理:异步跑,主线程不阻塞,完成后发 <task-notification> 通知。子代理有自己的 agentIdagentType、独立 ToolUseContext、可能用 model: 'inherit'(继承父模型以共享 prompt cache)。
  • fork 子代理forkSubagent.ts,实验特性):省略 subagent_type 触发,继承父的完整对话上下文和已渲染 system prompt——源码特别强调"threading the rendered bytes is byte-exact"为了不打破 prompt cache,所有 fork 子代理产出的占位结果 byte-identical 以共享缓存。
  • worktree 隔离子代理:子代理跑在独立 git worktree,源码注入提示"你在隔离 worktree,父可能改过文件,编辑前重读"——安全并发修改。
  • agent memoryagentMemory.ts/agentMemorySnapshot.ts):子代理有自己的记忆目录。

7.2 Coordinator 模式(coordinator/coordinatorMode.ts

一种特殊运行模式:主线程变成"调度者",自己不直接执行工具,只负责思考+派活,工具执行交给 workerisCoordinatorMode 由环境变量 + 特性开关控制,matchSessionMode 保证 resume 会话时模式一致。INTERNAL_WORKER_TOOLS 列出只有 coordinator 才用的工具(TeamCreate/Delete/SendMessage/SyntheticOutput)。

这是"harness 内部再分层"——把"决策"和"执行"解耦,主模型专注高层规划,执行细节下沉到 worker,便于规模化、降主模型上下文负担。

面试金句:“子代理后台异步跑、fork 继承上下文共享 prompt cache(字节级一致)、worktree 隔离并发改文件、各有自己的 agent memory。Coordinator 模式把主线程变成调度者、执行下沉 worker,是 harness 内部再分层。”


八、记忆、技能、任务、权限等支撑子系统

8.1 记忆(memdir/ + services/SessionMemory/

  • memdir:基于文件的持久记忆(MEMORY.md 索引 + 每条一个 frontmatter 文件),分 user/feedback/project/reference 类型。findRelevantMemories + memoryScan + memoryAge 在每回合旁路预取相关记忆(startRelevantMemoryPrefetch),消费时 getAttachmentMessages 注入为 attachment message。
  • SessionMemory:会话级记忆,压缩时存关键信息以便恢复。
  • CLAUDE.md:项目记忆,进上下文做背景。
  • 记忆 prefetch 是"非阻塞"的——settle 了才 poll,不挡主循环。

8.2 技能(skills/

  • bundledSkills + loadSkillsDir + mcpSkillBuilders:内置技能、目录技能、MCP 技能。技能是"打包的指令+可选脚本",EXPERIMENTAL_SKILL_SEARCH 下用 startSkillDiscoveryPrefetch 按需发现(和 ToolSearch 一样延迟加载省 context)。
  • 技能可带 frontmatter hooks(registerSkillHooks)。

8.3 任务(tasks/ + tools/Task*

  • TodoWrite/TaskCreate/TaskUpdate/TaskList/TaskGet/TaskOutput/TaskStop:结构化任务管理。src/tasks/ 下有 LocalAgentTaskInProcessTeammateTaskDreamTask 等任务类型。
  • 任务 hooks(TaskCreated/TaskCompleted)让外部能观察任务生命周期。

8.4 权限(utils/permissions/ + hooks/toolPermission

  • 三层:工具 checkPermissions → hooks(PreToolUse)→ 用户 canUseTool
  • PermissionRule / PermissionResult / denialTracking:规则化权限 + 拒绝追踪。
  • forceUseToolAlways:即使 hook 自动批准也强制问用户(高敏操作)。
  • ssrfGuard:防 SSRF(WebFetch 的 URL 校验)。

8.5 计划模式、worktree、MCP

  • EnterPlanModeTool/ExitPlanModeTool:计划模式,只读探查+写 plan 文件待批。
  • EnterWorktreeTool/ExitWorktreeTool:隔离 worktree 工作。
  • MCPTool/ToolSearchTool:MCP 工具按需加载、ToolSearch 关键词找工具。

九、把 harness 的"一次回合"串起来

用户发一句话后,harness 的完整动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. UserPromptSubmit hook(可改写/注入上下文)
2. assemble context: systemPrompt + CLAUDE.md + memory(prefetch 消费) + 当前 messages
3. ┌─ queryLoop iteration ─────────────────────────────────────┐
│ a. yield stream_request_start │
│ b. 流式调模型(边流边: skill/memory prefetch 旁路跑) │
│ c. 收到 assistant 消息(thinking + tool_use 块) │
│ d. postSampling hooks │
│ e. runTools/StreamingToolExecutor: │
│ - PreToolUse hook(批准/拦截/改输入) │
│ - checkPermissions + canUseTool(用户批准) │
│ - 按只读并发/写串行分批执行 │
│ - PostToolUse hook │
│ f. tool_result → user message append 回 messages │
│ g. checkTokenBudget: 续(发 nudge) or 停 │
│ h. 若上下文逼近上限: autoCompact/microCompact(对齐 cache) │
│ i. 若模型 stop: handleStopHooks(真停 or 继续) │
└─ 循环到无 tool_use 或 stop hook 放行 ─────────────────────┘
4. Stop hook(最终放行) / SessionEnd
5. extractMemories(后台从对话抽记忆写入 memdir)

每一步都对应源码里一个模块。harness 的工程量不在"调模型",而在这一整圈的状态管理、安全、恢复、压缩、扩展点


十、设计哲学小结

从源码能读出 Claude Code harness 的几条哲学:

  1. 模型是纯函数,harness 管一切状态——历史、压缩、调度、权限、恢复都 harness 做,模型只接收文本输出文本。
  2. async generator + using——流式、终止、错误、资源释放都有干净语义。
  3. 旁路预取藏进算力——memory/skill prefetch 与模型流式并行,不阻塞主路径。
  4. 接口契约 + 元信息驱动调度——Tool 接口的 isReadOnly/isConcurrencySafe/maxResultSizeChars/shouldDefer 等字段是调度与权限的依据,harness 不硬编码每个工具行为。
  5. 安全与性能双维度分批——只读并发、写串行、流式重叠,安全不牺牲性能。
  6. 可编程扩展点——hooks 把硬编码行为变可配置,安全护栏/自动审批/外部集成挂进来。
  7. 上下文是稀缺资源——compact 谱系 + ToolSearch 延迟加载 + maxResultSizeChars 落盘,处处在省 context、保 cache。
  8. prompt cache 字节级守护——fork 子代理 byte-identical、压缩对齐 cache 边界,cache 命中率是性能命脉。

一句话:Claude Code 的 harness 是"模型之外的一层操作系统"——主循环 queryLoop 流式调模型、按 Tool 契约调度工具(读并发写串行)、compact 谱系管上下文、hooks 做可编程旁路、子代理/coordinator 做执行分层、memory/skill/task 做支撑,所有设计围绕"让模型专注推理、让 harness 兜底一切工程状态与安全"。