Claude Code Harness 设计精读——从源码看 Agent 运行时
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 | query.ts # 主循环 queryLoop(): 模型流式调用 → 工具调度 → 结果回流 → 再调模型 |
主线:query.ts 的 queryLoop 是心脏——它流式调模型、收 tool_use、交给 runTools/StreamingToolExecutor 执行、把结果作为 user message 喂回去、循环;上下文长了由 compact 子系统压缩、每回合用 tokenBudget/stopHooks 决定是否继续、子代理和 hooks 在旁路参与。
二、主循环 queryLoop:harness 的心脏
src/query.ts 的 query 是一个 async generator(async function*),通过 yield 把流式事件吐给 UI/SDK,最终 return 一个 Terminal(正常结束)或抛错。核心结构(精简):
1 | async function* queryLoop(params) { |
关键设计点
-
async generator 流式:用
yield而不是回调,UI/SDK 边流式渲染边收。generator 的.return()能干净终止、.throw()传播错误——源码注释专门说明这保证"started-without-completed"信号一致。 -
不可变 params + 可变 state:
systemPrompt等进循环就不变;可变状态集中在state,每个迭代开头解构,continue时整体重置state = {...}而不是 9 个单独赋值——这是源码刻意写的工程整洁。 -
using资源管理:using pendingMemoryPrefetch = ...用 TC39 Explicit Resource Management,generator 无论从哪条路径退出(正常/抛错/return)都自动 dispose 旁路任务,避免泄漏。 -
旁路预取:
startRelevantMemoryPrefetch、startSkillDiscoveryPrefetch在模型流式输出和工具执行同时跑,事后才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 | export type Tool<Input, Output, P> = { |
几个值得讲的字段
isReadOnly+isConcurrencySafe:决定调度——只读且并发安全的工具可一批并行跑,写工具串行。这是下一节并发调度的根据。maxResultSizeChars:工具结果太大不直接进上下文(会爆),落盘只给模型一个预览+文件路径。Read设Infinity(自己已经限大小,落盘会形成 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 | // 精简自 services/tools/toolOrchestration.ts |
StreamingToolExecutor:边流式边执行
更精细的是 StreamingToolExecutor——模型还在流式输出 tool_use 时就开始执行已完成的那个:
- 并发安全工具可与其它并发安全工具并行执行。
- 非并发工具必须独占执行(exclusive access),且为维持顺序,遇到它就停止后续调度直到它完。
- 一个并行工具出错,同批的并行工具会被取消(
Cancelled: parallel tool call errored)。
为什么这么设计
- 只读可并发:
Grep/Glob/Read互相不影响,并行省 wall-clock。 - 写工具串行:
Edit/Write/Bash可能改文件系统,并行会踩踏(两个 Edit 同一个文件)。串行保证安全。 - 边流边执行:模型还在吐第二个 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 | PreToolUse / PostToolUse / PostToolUseFailure |
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 的注册来源
registerFrontmatterHooks、registerSkillHooks、hooksConfigManager、sessionHooks——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>通知。子代理有自己的agentId、agentType、独立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 memory(
agentMemory.ts/agentMemorySnapshot.ts):子代理有自己的记忆目录。
7.2 Coordinator 模式(coordinator/coordinatorMode.ts)
一种特殊运行模式:主线程变成"调度者",自己不直接执行工具,只负责思考+派活,工具执行交给 worker。isCoordinatorMode 由环境变量 + 特性开关控制,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/下有LocalAgentTask、InProcessTeammateTask、DreamTask等任务类型。- 任务 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 | 1. UserPromptSubmit hook(可改写/注入上下文) |
每一步都对应源码里一个模块。harness 的工程量不在"调模型",而在这一整圈的状态管理、安全、恢复、压缩、扩展点。
十、设计哲学小结
从源码能读出 Claude Code harness 的几条哲学:
- 模型是纯函数,harness 管一切状态——历史、压缩、调度、权限、恢复都 harness 做,模型只接收文本输出文本。
- async generator +
using——流式、终止、错误、资源释放都有干净语义。 - 旁路预取藏进算力——memory/skill prefetch 与模型流式并行,不阻塞主路径。
- 接口契约 + 元信息驱动调度——Tool 接口的 isReadOnly/isConcurrencySafe/maxResultSizeChars/shouldDefer 等字段是调度与权限的依据,harness 不硬编码每个工具行为。
- 安全与性能双维度分批——只读并发、写串行、流式重叠,安全不牺牲性能。
- 可编程扩展点——hooks 把硬编码行为变可配置,安全护栏/自动审批/外部集成挂进来。
- 上下文是稀缺资源——compact 谱系 + ToolSearch 延迟加载 + maxResultSizeChars 落盘,处处在省 context、保 cache。
- prompt cache 字节级守护——fork 子代理 byte-identical、压缩对齐 cache 边界,cache 命中率是性能命脉。
一句话:Claude Code 的 harness 是"模型之外的一层操作系统"——主循环 queryLoop 流式调模型、按 Tool 契约调度工具(读并发写串行)、compact 谱系管上下文、hooks 做可编程旁路、子代理/coordinator 做执行分层、memory/skill/task 做支撑,所有设计围绕"让模型专注推理、让 harness 兜底一切工程状态与安全"。





