Claude Code 架构深度解析
基于泄露源码的逆向工程分析 — 从宏观架构到微观实现,全面解读 Anthropic 的本地 Agent 平台设计
源码阅读地图:怎么用这个仓库学 Harness
Claude Code 的源码是一份难得的 harness 设计范本,但 src/ 下近两千个文件直接读极易迷路。本章给出"该读哪些文件、按什么顺序读、每个文件盯哪个函数"的导航——让你带着问题进源码,而不是大海捞针。
先认识仓库里的三层资料
这个学习仓库里有三种互补的材料,建议配合使用,而不是只盯一份:
| 资料 | 是什么 | 怎么用 |
|---|---|---|
src/ | 逆向还原的 Claude Code 源码(约 1900 文件 / 513K 行) | 唯一真相来源。按下方路线定向跳读,切忌从头通读 |
analysis/ | 29 篇分模块的深度笔记(Markdown) | 读源码前先扫对应笔记建立预期,再回源码核对 |
| 本文档 | 架构全景 + 工程心法(你正在看的这份) | 建立心智模型与术语索引,当作"地图册" |
analysis/ 笔记建立「预期」 → src/ 源码「对照验证」。三者来回印证,比单看任何一份都快得多。三条难度递进的阅读路线
按你的目标选一条线,务必从绿线开始。每条线都能独立读完、独立收获,不必一次吃透全部。下图自上而下即推荐阅读顺序:
这条线的唯一目标:亲手看清 一个 LLM 是怎么被"循环 + 工具"变成 agent 的。读完你应能复述出主循环的每一步。
| 站 | 打开文件 | 聚焦符号 | 带着这个问题读 | 详解 |
|---|---|---|---|---|
| ① | src/query.ts | query() 主 while 循环 | tool_use 如何变成下一轮的输入?循环靠什么终止? | Harness |
| ② | src/Tool.ts | buildTool() | 三个安全旗标为何默认全 false?忘记声明会怎样? | 工具系统 |
| ③ | services/tools/toolOrchestration.ts | runTools / partitionToolCalls | 一轮多个工具调用,如何决定并发还是串行? | 执行内核 |
| ④ | services/api/claude.ts | stream() | 模型输出怎么逐 token 流出来、又怎么被收集成动作? | 执行内核 |
闭环能跑通只是 demo。真实任务一长,必然撞上"上下文溢出"和"进程崩溃"两座大山。这条线看 Claude Code 怎么把硬错误降级成软兜底。
| 站 | 打开文件 | 聚焦符号 | 带着这个问题读 | 详解 |
|---|---|---|---|---|
| ⑤ | src/context/(compact 相关) | autoCompactIfNeeded() | 上下文逼近上限时怎么不崩?熔断器在防什么活锁? | Context |
| ⑥ | 同上(PTL 处理) | prompt-too-long 重试 | 压缩都不够时,最后一道兜底怎么截? | Context |
| ⑦ | 会话存储(JSONL) | append-only / resume 修复链 | 崩溃后怎么续聊?孤儿 tool_result 怎么补? | 会话存储 |
| ⑧ | memdir/ / compact.ts | createPostCompactFileAttachments | 压缩之后模型为什么不会"失忆"忘掉打开过的文件? | Memory |
当你的 harness 要省钱、要接外部工具、要多 agent 协作、要在不可信环境里安全运行时,读这条线。它是"工程化的天花板"。
| 站 | 打开文件 | 聚焦符号 | 带着这个问题读 | 详解 |
|---|---|---|---|---|
| ⑨ | Prompt 管理相关 | buildEffectiveSystemPrompt / boundary | 静态前缀如何切出来命中 prompt cache 省钱? | Prompt |
| ⑩ | services/mcp/ | wrapFetchWithTimeout / 工具去重 | 外部工具如何接入、同名时谁优先、怎么防雪崩? | MCP |
| ⑪ | multi-agent / swarm/ | forkSubagent / Coordinator | 子 agent 如何复用父字节?多 agent 怎么通信协作? | Multi-Agent |
| ⑫ | sandbox / skills | executeShellCommandsInPrompt / loadedFrom | 同一份 Shell 能力为何对本地放开、对远程切断? | Sandbox |
读源码前,强烈建议先把下面这段 能真正 node 跑通的最小 harness 跑一遍——它把绿线的概念浓缩成约 60 行可执行代码。跑通它,再去读 query.ts,你会发现"Claude Code 的内核不过是这段代码的工业级版本"。
// 运行:ANTHROPIC_API_KEY=sk-... node mini-harness.mjs "算 (12+5)*3" const API = 'https://api.anthropic.com/v1/messages' const KEY = process.env.ANTHROPIC_API_KEY // ① 工具表:每个工具 = schema + 执行函数 + fail-closed 安全旗标 const tools = { calculator: { schema: { name: 'calculator', description: '计算一个算术表达式', input_schema: { type: 'object', properties: { expr: { type: 'string' } }, required: ['expr'] } }, readOnly: true, // 默认应为 false;这里显式声明只读 call: ({ expr }) => String(eval(expr)), // 仅教学,生产勿用 eval }, } // ② 朴素上下文兜底:超阈值就丢最早几轮(生产应换成 autoCompact) function trim(messages, max = 40) { return messages.length <= max ? messages : messages.slice(-max) } async function callModel(messages) { const res = await fetch(API, { method: 'POST', headers: { 'x-api-key': KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify({ model: '<在此填入模型名>', max_tokens: 1024, tools: Object.values(tools).map(t => t.schema), messages, }), }) return res.json() } // ③ harness 主循环:问模型 → 执行工具 → 回灌 → 再问 async function run(userInput) { let messages = [{ role: 'user', content: userInput }] while (true) { const reply = await callModel(trim(messages)) messages.push({ role: 'assistant', content: reply.content }) const calls = reply.content.filter(b => b.type === 'tool_use') if (calls.length === 0) { // 模型不再点工具 = 任务完成 console.log(reply.content.find(b => b.type === 'text')?.text) break } const results = [] for (const c of calls) { const tool = tools[c.name] let content try { content = tool ? tool.call(c.input) : `未知工具: ${c.name}` // fail-closed:未知不放行 } catch (e) { content = `工具出错: ${e.message}` // 结构化回灌,不炸循环 } results.push({ type: 'tool_result', tool_use_id: c.id, content }) } messages.push({ role: 'user', content: results }) // 关键:结果以 user 角色回灌 } } run(process.argv[2] ?? 'hello')
六层分层架构
Claude Code 不是单纯的命令行聊天程序,而是一套本地 Agent 平台。采用六层分层架构,每一层职责清晰、接口明确。
CLI 引导层
多入口分流器,早期快路径分流,支持 6 种运行形态
TUI/REPL 交互层
基于 Ink (React) 构建的终端 UI 工作台,消息区+输入区双中枢
Query/Agent 执行内核
统一执行主循环,AsyncGenerator 流式输出,所有形态共用
Tool/Permission 层
43 内建工具 + MCP 工具池,内建权限(fail-closed 默认安全)
Memory/Persistence 层
四层记忆体系,文件化可审计,分层 compact 策略
MCP/Remote/Swarm 扩展层
MCP 协议集成、远程连接、多 Agent 协作平台化扩展
核心架构全景图
Agent Harness 剖析
Harness(执行骨架)是把"一个会聊天的 LLM"改造成"能自主完成任务的 Agent"的脚手架——它不改模型权重,只在模型外面包一圈循环、工具桥、上下文治理与恢复逻辑。本节从最小可用 harness 出发,逐层叠加 Claude Code 的生产级加固,并给出"自己动手搭 harness"的心智模型。
什么是 Agent Harness
模型 API 本身是一个无状态的 text → text 函数:给它一段对话,它吐回一段文本。它不会自己读文件、不会自己跑命令、记不住上一次说过什么。Harness 就是补齐这些缺口的那层"外骨骼"——它把模型放进一个循环里,给它一双手(工具)、一段记忆(上下文/持久化)、一套护栏(权限/沙箱),从而让"预测下一个 token"演化成"自主推进任务"。
Harness 的五大支柱
| 支柱 | 解决的问题 | Claude Code 落点 |
|---|---|---|
| ① 循环 Loop | 单次请求不够,需多轮"思考→行动→观察" | query() 的 while(true) AsyncGenerator |
| ② 工具桥 Tools | 让模型能影响真实世界(读写文件、跑命令) | Tool 协议 + runTools() 调度 |
| ③ 上下文 Context | 在有限窗口里塞下历史+工具结果不溢出 | 预算预留 + autoCompact + PTL 兜底 |
| ④ 持久化 State | 跨崩溃/退出/设备续聊,长期记忆 | append-only JSONL transcript + Memory |
| ⑤ 护栏 Safety | 自主行动不能失控、不能被劫持 | fail-closed 权限 + 沙箱 + trust 边界 |
最小可用 harness:30 行心智模型
剥掉所有工程加固,一个能跑的 agent harness 其实只需要三件事:(1) 把工具描述塞进请求 → (2) 解析模型吐出的 tool_use → (3) 执行后把 tool_result 回灌,再循环。理解了这个骨架,后面 Claude Code 的一切复杂度都只是在它之上"加固"。
async function* miniHarness(userInput, tools) { let messages = [{ role: 'user', content: userInput }] while (true) { // ① 带着"工具说明书"调用模型(流式) const reply = await callModel({ messages, tools: describe(tools) }) messages.push(reply) yield reply // ② 模型没要调工具 → 它认为任务完成,退出循环 const calls = reply.content.filter(b => b.type === 'tool_use') if (calls.length === 0) break // ③ 执行工具,把结果作为 user 消息回灌 → 下一轮模型就"看见"了观察结果 for (const c of calls) { const result = await tools[c.name].call(c.input) messages.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: c.id, content: result }] }) } } }
tool_result 永远以 user 角色回灌——在模型眼里,"工具执行结果"和"用户说的话"是同一种输入。Claude Code 的 query.ts 正是这个骨架的工业级版本。单轮生命周期:一次 turn 里到底发生了什么
把上面循环的"一圈"放大,就是 harness 的单轮生命周期。Claude Code 在每个环节都插入了真实的工程处理(函数名为源码真实命名):
最小 harness 能跑 demo,但放到真实生产环境会处处崩。下表把"最小做法"与"Claude Code 做法"逐项对照——这七层差距,正是 harness 工程化的全部价值所在。
| 关注点 | 最小 harness | Claude Code 生产级做法 |
|---|---|---|
| 成本 | 每轮重发全部 prompt | 静态前缀切 DYNAMIC_BOUNDARY,命中 prompt cache;subagent forkSubagent 复用父字节 |
| 上下文溢出 | 撞墙直接 400 报错 | 三层纵深:预算预留 → autoCompact 主动压缩 → PTL 头部截断兜底 |
| 工具并发 | 串行慢,或并发踩踏 | partitionToolCalls 按 isConcurrencySafe 分批,只读并发、写操作独占 |
| 安全 | 模型说啥跑啥 | fail-closed 权限 + PreToolUse hook + 沙箱 + trust 两阶段初始化 |
| 崩溃恢复 | 内存态丢失即归零 | append-only JSONL + resume 修复链(孤儿 tool_result 重挂) |
| 失忆 | 压缩后忘记打开过的文件 | 压缩后 createPostCompactFileAttachments 复灌关键状态 |
| 可观测 | 黑盒,出错难查 | --dump-prompts JSONL 落盘 + 结构化错误回灌 + 全程 transcript |
Claude Code 把一切"模型想做的事"都收敛成同一种事件——tool_use → tool_result。这让 query() 内核只需理解一种交互模型,复杂度被牢牢钉在工具层内部:
| 看似不同的行为 | 在 harness 里都是 |
|---|---|
| 改文件 / 跑命令 | 普通工具调用(FileEditTool / BashTool) |
| 向用户提问 | 一个工具(AskUserQuestionTool):渲染选项卡,用户作答即 tool_result |
| 派生子 agent | 一个工具(AgentTool):子 query 的输出汇总成 tool_result |
| 记笔记 / 管任务 | 工具(MemoryTool / TodoTool),写盘后回灌 |
若你想基于任意模型 API 复刻一套自己的 agent harness,按这份清单逐项落地即可(难度从低到高):
Level 1 — 跑通闭环(半天)
- 实现
while循环:调模型 → 解析 tool_use → 执行 → 回灌 tool_result。 - 用 AsyncGenerator/流式接口对外吐事件,方便 UI 与 SDK 共用同一内核。
- 终止条件:模型本轮无 tool_use 即结束。
Level 2 — 工具协议与安全默认(1~2 天)
- 定义统一
Tool接口:inputSchema(Zod) +call()+ 安全旗标。 - fail-closed:
isConcurrencySafe / isReadOnly默认 false,忘记声明=最安全档。 - 工具入参先 schema 校验,失败把错误回灌让模型自纠,而非抛异常退出。
Level 3 — 上下文与持久化(2~3 天)
- token 预算:
有效窗口 = 模型窗口 − 输出预留 − 压缩缓冲,触顶前主动压缩。 - transcript 用 append-only 落盘,崩溃可 resume;压缩后复灌关键文件状态。
- system prompt 切静态/动态边界,让静态前缀命中 provider 的 prompt cache。
Level 4 — 护栏与扩展(按需)
- 权限判定(allow/ask/deny)+ PreToolUse hook 拦截点 + 命令沙箱。
- 并发分批调度;subagent 派生;MCP 等外部工具接入(注意同名时内建优先)。
程序入口与启动链路
多入口系统设计,cli.tsx 作为早期分流器,main.tsx 是编排中心,支持 6 种运行形态。
设计思路
Claude Code 的入口采用分流器模式。CLI 入口点做大量快路径判断,将不同运行需求分流到各自路径,使同一个二进制包支持多种运行形态:
- REPL 模式 — 默认交互式终端对话循环
- Headless/Print — 一次性查询无 UI(
--print) - SDK 模式 — 被外部程序调用的 API
- MCP Server — 作为 MCP 服务端运行
- Bridge 模式 — IDE 集成桥接(VS Code)
- Remote/Daemon — 后台运行或远程连接
async function main() { const argv = parseArgs(process.argv) // 快路径:版本信息 if (argv['--version']) { console.log(version); process.exit(0) } // 快路径:导出 system prompt if (argv['--dump-system-prompt']) { await dumpSystemPrompt(); process.exit(0) } // 快路径:远程控制 / 守护进程 if (argv['remote-control']) { return runRemoteControl(argv) } if (argv['daemon'] || argv['bg']) { return runDaemonOrBackground(argv) } // 默认路径:进入 main.tsx 编排中心 await import('./main.tsx').then(m => m.main(argv)) }
六种运行形态对照
同一份二进制,靠 cli.tsx 的早期 argv 嗅探分流到完全不同的运行时。每种形态消费 query() 内核的方式不同,但内核本身完全复用。
| 形态 | 触发条件 | 入口/运行时 | UI |
|---|---|---|---|
| REPL | 默认(无特殊 flag) | main.tsx → launchRepl() | Ink 全屏 TUI |
| Headless / Print | -p / --print | QueryEngine 一次性消费流 | 纯 stdout |
| SDK | 被库调用(程序内嵌) | 导出 query() AsyncGenerator | 无(调用方接管) |
| MCP Server | mcp serve | mcp.ts → 暴露工具/资源 | 无(JSON-RPC) |
| Bridge | IDE 扩展拉起 | bridgeMain.ts ↔ VS Code | 宿主 IDE |
| Remote / Daemon | remote-control / daemon / bg | 后台守护 + 远端 ingress | 可附着 |
cli.tsx 内被快路径短路,避免无谓加载庞大的 TUI 依赖树,显著加快冷启动。初始化被刻意切成两段,以 工作目录是否可信(trust) 为分界。任何会读取/执行项目内容(自定义命令、project agents、本地 MCP、skills)的装配都必须等到用户确认 trust 之后才能跑——这是防止"打开恶意仓库即被 RCE"的第一道闸。
setup 装配出的能力不是散落各处,而是收敛到一个 AppState 总线,再通过 React Context 下发给整个 TUI。它既是"装配产物的容器",也是"运行期可变状态的单一事实源"。
| 字段族 | 内容 | 消费方 |
|---|---|---|
| 能力池 | tools / commands / agents / skills / mcpClients | query 内核、命令层 |
| 会话状态 | messages、sessionId、transcript 句柄 | Messages 渲染、持久化 |
| 权限/模式 | permissionMode、allowed/denied 规则 | 权限层、Bash 沙箱 |
| 统计 | token 用量、cost、上下文占用 | 状态栏、autoCompact |
| 运行配置 | model、betas、feature flags 快照 | API 调用、prompt 组装 |
Query/Agent 执行内核
统一执行内核是整个系统的心脏。所有运行形态共用同一套 query() 主循环,输出 AsyncGenerator<StreamEvent>。
为什么选择 AsyncGenerator?
- 统一消费接口 — 不管内部多少轮工具调用,外部都是 for-await-of
- 天然流式 — 每个 token、每个工具结果都可即时 yield
- 可组合 — subagent 的 query 可被父 agent 消费
- 背压控制 — 消费方不拉取,生产方自然暂停
export async function* query( userMessages: Message[], systemPrompt: SystemPrompt, toolUseContext: ToolUseContext, deps: QueryDeps, ): AsyncGenerator<StreamEvent> { let messages = userMessages while (true) { // 1. 消息规范化(去重、格式化、截断) const apiMessages = normalizeMessagesForAPI(messages) // 2. 调用模型 API(流式输出) for await (const event of deps.claudeApi.stream(apiMessages, systemPrompt)) { yield event } // 3. 提取 tool_use blocks const toolUseBlocks = extractToolUseBlocks(messages) if (toolUseBlocks.length === 0) break // 无工具调用 → 结束 // 4. 执行工具(按并发安全性分批) const { concurrent, serial } = partitionToolCalls(toolUseBlocks) const results = await runToolsConcurrently(concurrent) results.push(...await runToolsSerially(serial)) // 5. 结果追加并检查会话治理 messages = [...messages, ...toolResultsToMessages(results)] if (shouldCompact(messages)) await compactConversation(messages) } }
| 属性 | 含义 | 执行方式 |
|---|---|---|
isConcurrencySafe = true | 只读工具,可并发 | Promise.all |
isConcurrencySafe = false | 有副作用,需串行 | 逐个 await |
isReadOnly = true | 纯只读 | 跳过权限检查 |
- text_delta — 模型输出文本片段(逐 token)
- tool_use_begin — 开始执行工具调用
- tool_result — 工具执行完成
- thinking — 模型思考过程(extended thinking)
- message_complete — 一轮消息完整输出
- error / usage — 错误事件 / token 统计
一轮模型输出可能携带多个 tool_use。内核不会一股脑并发,而是按工具自报的 isConcurrencySafe 把调用切成"可并发块"与"必须串行块",在保证副作用安全的前提下尽量榨取并行度。
function partitionToolCalls(blocks: ToolUseBlock[]) { const groups: ToolUseBlock[][] = [] let current: ToolUseBlock[] = [] for (const block of blocks) { const tool = findTool(block.name) // 只读且并发安全 → 可与前面的并发块合并 if (tool?.isConcurrencySafe) { current.push(block) } else { // 遇到有副作用的工具 → 先收口当前并发块,再单独成组 if (current.length) groups.push(current), current = [] groups.push([block]) // 串行块(独占一组) } } if (current.length) groups.push(current) return groups // 组内并发,组间串行,保持原始顺序 }
| 属性 | 含义 | 执行方式 |
|---|---|---|
isConcurrencySafe = true | 只读工具,可并发 | 组内 Promise.all |
isConcurrencySafe = false | 有副作用,需串行 | 独占一组,逐个 await |
isReadOnly = true | 纯只读 | 权限可快速放行 |
工具入参往往是模型流式吐出的 JSON。执行器不必等整段 tool_use 完整到达——它维护一个状态机,在入参 JSON 累积可解析时即开始预热,并把工具执行产生的 contextModifier(如对后续轮次的上下文修改)延迟到本组执行完毕后统一应用,避免并发组内相互踩踏。
工具系统与权限控制
43 个内建工具 + MCP 工具池,权限系统是主干设计而非后期补丁,采用 fail-closed 默认安全策略。
工具分类全景
| 类别 | 工具示例 | 数量 |
|---|---|---|
| 文件操作 | FileReadTool, FileEditTool, FileWriteTool, GlobTool | ~8 |
| 代码执行 | BashTool, NotebookTool | ~3 |
| 搜索定位 | GrepTool, FindTool, LSPTool | ~5 |
| Agent 协作 | AgentTool, TeamCreateTool, SendMessageTool | ~6 |
| 外部连接 | WebFetchTool, MCPTool | ~4 |
| Memory/会话 | MemoryTool, TodoTool, CompactTool | ~5 |
| 其他 | ThinkTool, ImageGenTool, SkillTool | ~12 |
interface ToolDefinition { name: string description: string inputSchema: ZodSchema // 安全属性 — fail-closed 默认值 isConcurrencySafe: boolean // 默认 false isReadOnly: boolean // 默认 false isDestructive: boolean // 默认 false checkPermissions(input): PermissionResult call(input, context): Promise<ToolResult> } // 工厂函数强制安全默认值 export function buildTool(config): Tool { return { isConcurrencySafe: false, isReadOnly: false, isDestructive: false, ...config } }
stripDangerousPermissionsForAutoMode() 自动剥离危险权限,确保无人值守安全。getAllBaseTools() 收集全部内建工具,assembleToolPool() 再把 MCP 工具并入,并用 uniqBy 去重——同名时内建工具优先,防止恶意 MCP server 用同名工具劫持内建能力(如伪造一个 "Bash")。
export function assembleToolPool(permCtx, mcpTools): Tool[] { const builtinTools = getAllBaseTools(permCtx) // 内建工具排在前面 → uniqBy 保留首个 → 内建优先 return uniqBy( [...builtinTools, ...mcpTools.filter(isIncluded)], t => t.name, ) }
所有工具都经 buildTool() 出厂,它先铺一层 TOOL_DEFAULTS 再覆盖。三个安全旗标默认全 false——工具作者忘记声明 = 自动落到最安全档位(不并发、可能有副作用、需要权限)。这是"安全是默认值,开放是显式选择"的设计。
const TOOL_DEFAULTS = { isConcurrencySafe: false, // 默认串行(保守) isReadOnly: false, // 默认视为有写入 isDestructive: false, // 默认非破坏(但仍需权限) } export function buildTool<I, O>(config: ToolConfig<I, O>): Tool { return { ...TOOL_DEFAULTS, ...config } // 显式声明才能放开 }
从模型给出 tool_use 到产出 tool_result,每个工具都要走完一条固定的责任链。任何一步失败都会"短路"为一条结构化错误结果回灌给模型,而不是抛异常炸掉主循环。
FileEditTool — 精确字符串替换
- 安全档位:
isReadOnly=false、isConcurrencySafe=false——写操作必然独占串行组,且每次都过权限。 - 唯一性约束:
old_string必须在文件中唯一,否则拒绝,逼模型提供足够上下文,避免误改。 - 路径沙箱:写路径须落在允许的工作区内,与 Sandbox 的 path validation 反哺联动。
AskUserQuestionTool — 工具即交互
- 把"向用户提问"建模成一个普通工具:模型发起
tool_use,TUI 渲染选项卡,用户作答后以tool_result回灌。 - 意义:人类决策被纳入同一条 query 主循环,无需为"等待用户输入"开特殊通道,统一了"调用工具"与"问人"两种行为的执行模型。
Prompt 管理体系
System Prompt 不是一坨字符串常量,而是一套有运行时、有优先级、有缓存边界、可观测的组装系统。它决定了模型"是谁、能做什么、看到什么"。
六层 Prompt Runtime
最终喂给模型的 system prompt 由多层叠加而成,getSystemPrompt() 返回的是一个字符串数组(而非单串),数组元素之间用一条 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 哨兵切开"可缓存的静态段"与"每轮可能变的动态段"。
// 同一时刻可能有多个来源想定义 system prompt, // 按优先级"取最高者为基底",再统一追加 append 片段。 function buildEffectiveSystemPrompt(ctx): string[] { const base = ctx.override // ① 显式 override(最高) ?? ctx.coordinatorPrompt // ② coordinator 模式身份 ?? ctx.agentPrompt // ③ subagent 专属 prompt ?? ctx.customPrompt // ④ 用户自定义 ?? getDefaultSystemPrompt(ctx) // ⑤ 默认(兜底) return [...base, ...ctx.appendSections] // append 不覆盖、只追加 }
组装单元是 systemPromptSection(),默认产出"可缓存"片段。框架另外保留一个刻意命名得很吓人的 DANGEROUS_uncachedSystemPromptSection(),用于那些每轮都必须重算、绝不能被缓存命中的内容(如带时间戳、带随机状态的片段)。
| API | 用途 | 缓存 |
|---|---|---|
systemPromptSection() | 常规静态/半静态片段 | ✅ 可缓存 |
DANGEROUS_uncached…() | 必须每轮重算的易变片段 | ❌ 强制不缓存 |
DANGEROUS_ 前缀逼迫调用者停下来思考"这段真的不能缓存吗",把性能陷阱前置到编码期暴露。- getSystemContext() — 与"机器/会话"相关:操作系统、当前工作目录、git 状态、日期时间、模型与权限模式。偏环境事实。
- getUserContext() — 与"项目/人"相关:各层 memory 内容、
.claude/rules/自定义规则、可用 agents 与 skills 清单。偏意图与约束。
两者都落在动态边界之后,因为它们会随目录切换、git 提交、memory 编辑而变化。
除主对话 prompt,系统还维护一组专项 prompt:compact 摘要、memory 提取、title 生成、coordinator 编排、quota/计费提示等,各自独立演进、独立测试。
--dump-prompts,框架会把每次实际发送的 system/user prompt 以 JSONL 落盘,便于逐行 diff、回归测试、排查"模型为什么这么答"。Prompt 在这里被当作一等工程产物来治理,而非黑盒。
Memory 多层记忆体系
四层 Memory 系统,所有记忆是磁盘 .md 文件,用户可直接查看编辑。这是与传统 AI 系统最大的差异化设计。
四层记忆架构
| 层级 | 位置 | 作用 | 触发 |
|---|---|---|---|
| Auto/项目 Memory | .claude/memory.md + MEMORY.md 索引 | 项目规范、偏好、规则 | 手动/Agent写入 |
| Session Memory | ~/.claude/sessions/ | 单次会话摘要记忆 | 自动提取(forked agent) |
| Agent Memory | 三 scope(见下) | 跨会话长期记忆 | Agent 自主写入 |
| Team Memory | .claude/team/ | 团队共享知识 | Swarm 协作共享 |
memdir 索引与安全截断
memdir.ts 以 MEMORY.md 为入口索引,buildMemoryLines() / buildMemoryPrompt() 负责把磁盘内容拼成注入 prompt 的片段。为防止 Memory 撑爆 context,入口文件有硬限制:
| 常量 | 值 | 含义 |
|---|---|---|
MAX_ENTRYPOINT_LINES | 200 行 | 入口 MEMORY.md 最大行数 |
MAX_ENTRYPOINT_BYTES | 25,000 字节 | 入口 MEMORY.md 最大字节 |
createMemoryFileCanUseTool() 把可写路径锁死为那一个 memory 文件;目录权限 0o700、文件 0o600 确保只有当前用户可读写,记忆不外泄。Agent 的长期记忆按"作用域"分三层存放,决定了"这条记忆跟着谁走、是否进版本库、是否跨项目"。
| scope | 典型路径 | 跟随 | 版本库 |
|---|---|---|---|
| user | ~/.claude/agent-memory/ | 跟用户,跨所有项目 | 否(全局私有) |
| project | .claude/agent-memory/ | 跟项目,团队共享 | 可提交 |
| local | .claude/agent-memory.local/ | 跟项目,但仅本机 | gitignore |
Agent Memory 的写入不是直接覆盖,而是经过一个 snapshot 状态机,避免"半截写入"或"重复初始化"。
| 状态 | 含义 | 动作 |
|---|---|---|
none | 尚无快照 | 跳过加载 |
initialize | 首次建立 | 创建目录与索引骨架 |
prompt-update | 已有快照、需增量更新 | 读现状 → 模型增改 → 落盘 |
| 策略 | 触发条件 | 实现 |
|---|---|---|
| 手动 Compact | 用户 /compact | 全量压缩当前会话 |
| 自动 Compact | Token 逼近窗口(autoCompactIfNeeded) | 压缩早期消息,保留近期 |
| Session Memory | 会话结束/resume | 提取关键信息到文件 |
| Micro Compact | 单个工具输出过长 | 截断该工具结果 |
压缩会丢弃大量原始消息,因此压缩后必须"复灌"必要状态,否则模型会"失忆"——忘了打开过哪些文件、有哪些延迟工具结果:
createPostCompactFileAttachments()— 把压缩前打开/读过的关键文件以附件形式重新挂回。getDeferredToolsDeltaAttachment()— 补回压缩窗口期间产生但尚未消费的工具增量结果。
Context 上下文窗口治理
在有限的上下文窗口里塞下"系统提示 + 历史 + 工具结果 + 输出预留",还要防止偶发的 prompt-too-long 把会话打死。这是一套带预算、带熔断、带降级重试的工程系统。
窗口预算与关键常量
有效可用窗口不是简单的"模型上限",而要扣掉给"模型输出"和"自动压缩缓冲"预留的部分。getEffectiveContextWindowSize() 负责算出真正能用来装历史的额度。
| 常量 | 值 | 含义 |
|---|---|---|
MODEL_CONTEXT_WINDOW_DEFAULT | 200,000 | 默认上下文窗口;has1mContext() 为真时升至 100 万 |
CAPPED_DEFAULT_MAX_TOKENS | 8,000 | 常规输出上限(保守,给历史让位) |
ESCALATED_MAX_TOKENS | 64,000 | 需要长输出时升档 |
MAX_OUTPUT_TOKENS_FOR_SUMMARY | 20,000 | 压缩摘要生成的输出上限 |
AUTOCOMPACT_BUFFER_TOKENS | 13,000 | 预留缓冲,触顶前就压缩 |
AUTOCOMPACT_BUFFER_TOKENS 这层缓冲确保在真正撞墙之前就主动压缩,把"硬错误"转化为"软降级"。每轮请求前评估占用,逼近"窗口 − 缓冲"即触发压缩。关键在于它带一个熔断器:如果压缩连续失败(如模型抽风、内容无法再缩),不会无限重试拖死会话,而是在达到 MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES 后停手并告警。
即便有预算和自动压缩,仍可能因 token 估算偏差收到 API 的 prompt-too-long (PTL) 错误。此时不是直接报错退出,而是触发 truncateHeadForPTLRetry()——从历史头部砍掉一段后重试,把不可恢复的失败变成一次有损但可继续的重试。
has1mContext()为真(特定模型/beta)时,窗口从 20 万跃升到 100 万,getEffectiveContextWindowSize()据此重算预算。- 输出上限默认压在保守的
CAPPED_DEFAULT_MAX_TOKENS = 8,000,把更多额度留给历史;当任务确实需要长输出时再升到ESCALATED_MAX_TOKENS = 64,000。 - 这种"默认保守、按需升档"的策略,避免每次请求都为极少数长输出场景白白预留大量 token。
会话存储与 Resume
每个会话是一条 append-only 的 JSONL 事件流。崩溃、退出、跨机器,都能从这条流里把"会话图"重建出来——这是 Claude Code"长期协作"属性的物理基础。
append-only JSONL 事件流
会话不是"存一个最终状态对象",而是把每条消息按发生顺序逐行追加到 .jsonl。只追加、不回改,天然抗崩溃(写一半最多丢最后一行),也天然可审计(完整时间线)。
写入前由 isTranscriptMessage() 把关——只有 user / assistant / attachment / system 这几类进入持久化,而 progress 等纯 UI 瞬时事件被排除,避免污染可重放的事实流。
| 写入分流 | 规则 |
|---|---|
| 主链消息 | 去重后追加,保证线性主干无重复 |
| sidechain(子 agent) | 保真写入,不与主链去重,保留分支全貌 |
| 远端镜像 | 只跟主链,sidechain 不上行 |
会话元数据(标题、模型、用量统计等)会变,但文件是 append-only 不能改旧行。解法很"流式原生":把最新 metadata 再追加到文件尾部,读取时以"最后一条"为准。
JSONL 是一维的行序列,但真实会话是带父子关系(工具调用、并行子 agent、分支)的图。loadTranscriptFile() 逐行解析并按 uuid / parent 关系把图重建出来,再交给 resume 修复链做一致性校正。
Lite Reader:只读尾部的轻量读取
很多场景(列出会话、取标题、判断能否 resume)并不需要读完整条流。Lite reader 用固定缓冲 LITE_READ_BUF_SIZE = 65536 字节从尾部回扫,快速拿到末尾的 metadata,避免为一个标题去解析数百 MB 的历史。
崩溃可能留下"半截"会话:有 tool_use 没对应 tool_result、并行工具结果成了孤儿、被截断的悬空引用。直接喂给 API 会被拒。Resume 前有一条修复链做体检与修补:
| 修复函数 | 处理的"脏"情况 |
|---|---|
applySnipRemovals() | 移除被标记裁剪的片段,保持引用闭合 |
recoverOrphanedParallelToolResults() | 并行工具结果与其调用失联 → 重新挂回或补占位 |
checkResumeConsistency() | 整体一致性体检:tool_use ↔ tool_result 配对完整 |
会话可镜像到远端(跨设备续聊)。上行写入用 Last-Uuid 做乐观并发控制:客户端声称"我基于这条 uuid 之后追加",若服务端发现中间已有别处写入(冲突),返回 409,客户端拉取最新后重试。
MCP 协议集成
Model Context Protocol — 外部能力扩展框架,统一工具命名 mcp__server__tool,支持 4 种传输协议。
四种传输协议
| 协议 | 场景 | 并发 |
|---|---|---|
| stdio | 本地进程(默认) | 3 |
| SSE | 远程 HTTP 长连接 | 20 |
| WebSocket | IDE 集成 | 20 |
| streamable-http | claude.ai 代理 | 20 |
核心设计决策
- 连接 memoize — 同一 server 配置只建一次连接
- 认证防雪崩 — 15 分钟 Auth Cache,防止并发认证风暴
- 描述截断 — MCP 工具描述最大 2048 字符
- IDE 白名单隔离 — 只允许少数高权限 IDE 工具推送
export const connectToServer = memoize( async (name, serverRef) => { let transport if (serverRef.type === 'sse') transport = new SSEClientTransport(serverRef.url, { authProvider }) else if (serverRef.type === 'ws') transport = new WebSocketTransport(serverRef.url) else if (serverRef.type === 'streamable-http') transport = new StreamableHTTPClientTransport(serverRef.url) else // stdio 本地进程 transport = new StdioClientTransport({ command, args, env }) const client = new Client({ name: 'claude-code', version }) await client.connect(transport) return { client, transport } }, getServerCacheKey )
远程传输需要给 fetch 加超时。常规做法是 AbortController + AbortSignal.timeout(),但在 Bun 运行时下该路径存在已知内存泄漏。Claude Code 改用手动 setTimeout 触发 abort、并在请求结束时 clearTimeout,绕开泄漏点。
function wrapFetchWithTimeout(ms: number) { return async (url, init) => { const controller = new AbortController() // 不用 AbortSignal.timeout() —— 在 Bun 下会泄漏 const timer = setTimeout(() => controller.abort(), ms) try { return await fetch(url, { ...init, signal: controller.signal }) } finally { clearTimeout(timer) // 关键:及时清理,避免句柄堆积 } } }
多个工具/资源可能在同一时刻向同一个需要 OAuth 的远程 server 发起调用。若每次都独立走认证,会瞬间触发"认证风暴"。框架用一个 15 分钟有效期的 Auth Cache 收敛并发认证:首次认证后凭证缓存复用,窗口内的并发请求共享同一份凭证。
Session 过期:404 + JSON-RPC -32001
对 streamable-http 等长连接,服务端 session 可能过期。框架识别HTTP 404 叠加 JSON-RPC 错误码 -32001的组合信号,判定为"会话已失效",触发重连重建,而不是把它当普通网络错误反复重试。
描述截断与并发上限
| 约束 | 值 | 目的 |
|---|---|---|
MAX_MCP_DESCRIPTION_LENGTH | 2048 字符 | 防止恶意/啰嗦 server 撑爆 prompt |
| 本地 stdio 并发 | 3 | 本地进程资源有限 |
| 远程并发 | 20 | 远端可承载更高并发 |
IDE 工具白名单
IDE(如 VS Code)经 MCP 推送的工具权限较高(能操作编辑器),因此只有少数被显式白名单的 IDE 工具才允许注入工具池,杜绝任意 IDE 端工具借通道获得高权限。
Multi-Agent 多代理协作
三套并存的多 agent 模型——从简单子代理到完整团队协作,本质是一个小型 agent runtime。
三种协作模式
| 模式 | 复杂度 | 特点 | 场景 |
|---|---|---|---|
| 普通 SubAgent | 低 | 同步/后台,独立 context | 单一子任务 |
| Coordinator | 中 | 主线程变调度器,多 worker | 并发任务协调 |
| Swarm/Teammates | 高 | 显式团队,Mailbox,权限回流 | 大型项目 |
// 根据参数决定走 teammate 还是 subagent if (teamName && name) { // Swarm/Teammate 路径 await spawnTeammate({ name, prompt, team_name: teamName, ... }) } else { // 普通 subagent 路径 await runAgent({ prompt, tools: agentTools, model }) }
普通 subagent 走 runAgent();但当子任务与父任务共享同一套系统提示时,框架优先用 forkSubagent()——它复用父级 system prompt 的"完全相同字节",让子 agent 的首个请求也能命中父级已建立的 prompt cache,避免为每个子 agent 重新冷算几千 token 的前缀。
进入 coordinator 模式后,主线程自身的身份被重写:system prompt 切换为 orchestrator 人格,并启用 task-notification 机制。主线程不再亲自干活,而是拆解任务、派发给 worker、汇总结果。
Swarm 是最重的一档,本质是一个小型分布式 agent runtime,落地在几个具体机制上:
| 机制 | 作用 |
|---|---|
| team file | 团队的共享事实源:成员、目标、状态 |
| task list + claimTask() | 共享任务清单;teammate 主动"认领"任务,避免重复抢工 |
| mailbox | 成员间异步消息收发;leader 用 inbox poller 拉取汇报 |
| leaderPermissionBridge | teammate 无权批准的高危操作,权限请求回流给 leader 决策 |
// teammate 从共享 task list 原子认领下一个未分配任务 async function claimTask(teamName, memberName) { const task = await atomicallyPickUnclaimed(teamName) if (!task) return null // 无活可干 → 空闲/退出 task.assignee = memberName // 标记归属,防重复领取 await persistTaskList(teamName) return task }
tmux > iTerm2 > in-process 顺序探测,in-process teammate 用 AsyncLocalStorage 做上下文隔离,同进程内也互不串台。Skills 与 Plugin 扩展
Skills 是按需加载的领域知识包;Plugin 是结构化能力扩展点。共同构成可扩展的能力层。
Skill 三种来源
- Bundled — 内置技能 (test-driven-development, systematic-debugging 等)
- File-based —
.claude/skills/目录下的自定义技能 - MCP — 通过 MCP server 提供的远程技能
getSkillDirCommands() 经 memoize 缓存并并行扫描目录,把每个技能编译成一条可调用命令;技能元信息写在 Markdown 的 Frontmatter 里。
技能 Markdown 头部的 Frontmatter 字段决定它"何时出现、谁能调、如何注入":
| 字段 | 作用 |
|---|---|
paths | 条件技能:仅当工作区命中指定文件 glob 时才激活(如有 *.tf 才上 Terraform 技能) |
user_invocable | 是否允许用户用斜杠命令直接触发 |
context | inline=注入当前对话;fork=开独立子 agent 执行,结果回灌 |
paths 条件让技能池不会无脑全量塞进 prompt——只有与当前项目真正相关的技能才占用上下文,是另一种 context 节流。技能/命令的 prompt 模板里可以内嵌 Shell 命令(如把 git status 的输出动态注入提示)。这能力很强,也很危险——一个恶意 MCP server 若能塞入带内嵌 Shell 的技能,就等于远程代码执行(RCE)。
防线是一个判断:仅当技能 loadedFrom !== 'mcp' 时才执行内嵌 Shell。来自远程 MCP 的技能,其内嵌命令被当作纯文本,绝不执行。
function executeShellCommandsInPrompt(skill, prompt) { // 关键安全门:远程来源技能不准跑内嵌 Shell if (skill.loadedFrom === 'mcp') { return prompt // 原样返回,命令视为普通文本 } return runEmbeddedCommands(prompt) // 仅本地/bundled 技能可执行 }
Sandbox 沙箱隔离
沙箱不是给 BashTool 套个壳那么简单,而是一套四层结构 + 语义翻译 + 逃逸防护 + 与权限系统双向反哺的体系。它让"自动放行命令"成为可能,又不牺牲安全底线。
四层结构
| 层 | 职责 |
|---|---|
| 决策层 | shouldUseSandbox() 判定本次执行是否走沙箱 |
| 翻译层 | convertToSandboxRuntimeConfig() 把权限规则翻译成 OS 沙箱配置 |
| 执行层 | macOS: seatbelt profile (sandbox-exec);Linux: 容器/命名空间隔离 |
| 校验层 | pathValidation 双向校验路径,与沙箱白名单互相反哺 |
convertToSandboxRuntimeConfig() 把它翻译成 seatbelt 的 allow file-write* 等底层原语。用户不必懂 seatbelt 语法。一个隐蔽的逃逸面:攻击者在工作区放一个 bare git repo,其中的 hooks(如 post-checkout)会在 git 操作时被执行——相当于绕过沙箱跑任意代码。scrubBareGitRepoFiles() 专门识别并清理这类危险文件,堵住该逃逸路径。
有些路径无论用户怎么配都强制禁止写入——典型是 settings 文件与 .claude/skills。原因:若放开,模型/恶意内容就能改写自己的权限配置或注入新技能,形成提权与持久化后门。
| 路径 | 策略 | 防的是 |
|---|---|---|
| settings 文件 | 强制 denyWrite | 篡改权限规则自我提权 |
.claude/skills | 强制 denyWrite | 注入技能实现持久化 RCE |
当命令运行在沙箱内时,autoAllowBashIfSandboxed 允许自动放行 Bash——因为沙箱已兜底。但这个"自动放行"仍然尊重用户显式的 deny / ask 规则:沙箱降低了风险,但不凌驾于用户的明确禁令之上。
反向地,isPathInSandboxWriteAllowlist() 把"沙箱允许写的路径"反哺给上层 path validation——两套机制共享同一份白名单事实,避免"沙箱说能写、权限层说不能写"的不一致。
BashTool 安全模型
BashTool 是权限要求最高的工具。注意 excludedCommands(沙箱排除列表)不是安全边界——它只是性能/兼容优化(某些命令在沙箱里跑不起来而放行),真正的边界永远是权限规则 + 路径校验。
TUI 组件体系
基于 Ink (React for Terminal) 构建的完整 TUI 组件平台,采用双中枢设计。
分层组件架构
| 层级 | 职责 | 核心文件 | 组件数 |
|---|---|---|---|
| Provider 根包装 | 全局状态/统计 | App.tsx, AppState.tsx | ~5 |
| 会话工作台 | 消息区+输入区装配 | FullscreenLayout, Messages, PromptInput | ~10 |
| 消息渲染 | 虚拟列表、折叠 | VirtualMessageList, MessageRow | ~41 |
| 输入编排 | 补全、历史、模式切换 | PromptInput/ (21文件) | ~21 |
| 能力面板 | 权限/agent/MCP/任务 | permissions/, agents/, mcp/ | ~51 |
| 设计系统 | 终端 UI 基础件 | design-system/, ui/ | ~16 |
双中枢设计
- Messages.tsx — 展示已发生的事(transcript 视图转换器)
- PromptInput.tsx — 组织下一步要做什么(输入编排中心)
VirtualMessageList 技术含量最高:终端高度测量 + 缓存 + IntersectionObserver + JumpHandle 搜索跳转。
五大设计亮点
从工程角度看 Claude Code 最值得学习的架构设计决策。
亮点一:统一执行内核
所有 6 种运行形态共用同一套 query() 主循环。不同形态只是消费 StreamEvent 流的方式不同:REPL 渲染到终端,SDK 返回给调用方,headless 输出到 stdout。
亮点二:Memory 透明可审计
所有记忆都是 .md 文件。用户可以 cat、grep、手动编辑。不存在"AI 偷偷记住了什么你不知道"的问题。四层独立开关,关哪层用户自己决定。
亮点三:权限系统是主干
不是上线后打补丁加的安全层,而是 Tool 接口设计之初就内建的。buildTool() 的 fail-closed 默认值意味着:忘记声明安全属性 = 最高安全级别。
亮点四:渐进式多 Agent
三种模式(SubAgent → Coordinator → Swarm)从简到繁,小任务不需要完整 Swarm 开销,大项目可以用 Teammate 并行协作。后端自动检测(tmux > iTerm2 > in-process)。
亮点五:长会话治理体系化
四种 compact 策略不是临时应对,而是系统化的长会话管理。从 micro(单工具输出截断)到 macro(全会话压缩),覆盖了所有可能导致 context 溢出的场景。
工程心法:从 Claude Code 学到什么
前面各章逐一拆解了 Claude Code 的源码实现。本章把这些散落的设计决策反向蒸馏成可迁移的 Agent/Harness 开发原则——每一条都标注"Claude Code 怎么做 → 为什么有效 → 你该怎么搬"。这不是源码导读,而是把别人的工程经验变成自己的设计直觉。
十条可迁移心法总览
如果只带走一张表,就是这张。每条心法都能在前文找到对应的源码落点(点击侧栏对应章节深入)。
| # | 心法 | Claude Code 的落点 | 对你的意义 |
|---|---|---|---|
| 1 | 单一内核,多形态复用 | 6 种运行形态共用一个 query() AsyncGenerator | 先把"模型↔工具循环"做成唯一内核,UI/SDK/CLI 都只是它的消费者 |
| 2 | 一切皆工具 | 改文件/跑命令/问人/派子 agent 全是 tool_use | 不为任何特殊行为在主循环开后门,扩展=加一个 Tool |
| 3 | 安全是默认值 | buildTool() fail-closed,三旗标默认 false | 忘记声明=落到最安全档,而不是最危险档 |
| 4 | 上下文是预算 | 预留 buffer → autoCompact → PTL 截断三层防御 | 从第一天就把窗口当有限资源管理,别等撞墙 |
| 5 | 状态用事件流持久化 | append-only JSONL + resume 修复链 | 只追加不回改,天然抗崩溃、可审计、可续聊 |
| 6 | 失败结构化回灌 | 每步出错短路成 tool_result,不抛异常 | 让模型"看见错误并自纠",而非让循环崩掉 |
| 7 | 缓存意识到字节级 | 静态/动态边界切割,forkSubagent 复用父字节 | 相同前缀=省钱省延迟,prompt 也要做"缓存友好设计" |
| 8 | 终止权交给模型 | 本轮无 tool_use 即结束,无外部判定器 | 用极简的终止条件,避免脆弱的"任务完成检测器" |
| 9 | 信任分级 + 纵深防御 | trust 两阶段、来源决定能力、沙箱兜底 | 能力是否启用取决于来源可信度,单点失守不致命 |
| 10 | 渐进式复杂度 | SubAgent → Coordinator → Swarm 三档 | 小任务不付大架构的税,复杂度按需引入 |
把十条心法按"维度"重组,能看清 Claude Code 的设计是沿着四条主线系统推进的,而非零散技巧的堆砌。
维度一 · 结构:用统一抽象压住复杂度
- 单一内核:所有运行形态共用
query()。你的 agent 也应只有一个权威主循环,其余都是它的"皮肤"。 - 一切皆工具:把"问人""派活""记笔记"都建模成工具调用。主循环只认
tool_use → tool_result一种事件,新能力靠加 Tool 扩展,循环零改动。 - 流式 AsyncGenerator:内核对外吐事件流,UI 实时渲染、SDK 程序化消费、headless 落 stdout——同一份内核,三种消费姿势。
维度二 · 成本:把省钱做进每一层
- 静态/动态边界:system prompt 切出"逐字节不变的静态前缀"命中 provider 缓存;易变内容放边界之后。
- fork 而非重建:派生 subagent 时复用父 prompt 的相同字节,让子 agent 首请求也命中缓存。
- 输出档位按需升降:默认压低
max_tokens把额度留给历史,需要长输出时再升档,避免每请求白白预留。
维度三 · 韧性:默认"会崩",主动兜底
- 三层上下文防御:预算预留(不用满)→ autoCompact(主动压缩,带熔断器防活锁)→ PTL 头部截断(兜底重试)。把"硬错误"逐级降级为"软降级"。
- append-only + 修复链:会话是只追加的事件流;resume 默认磁盘上的会话是"脏"的,主动修补孤儿 tool_result 到 API 可接受形态。
- 压缩≠失忆:压缩后用
createPostCompactFileAttachments复灌关键文件状态——"换一种更省 token 的方式记住",而非删除。
维度四 · 安全:信任分级的纵深防御
- fail-closed 默认:安全属性默认全 false,开放是显式选择。
- trust 两阶段:读全局配置(无副作用)与执行项目配置(有副作用)分到 trust 前/后两阶段,防"打开恶意仓库即 RCE"。
- 来源决定能力:同一份内嵌 Shell 能力,对本地技能放开、对远程 MCP 切断;沙箱自动放行仍尊重用户 deny/ask。安全不是单点开关,而是分层叠加。
从 Claude Code 的设计可以反推出一组"新手 harness 常踩、它刻意规避"的反模式。对照自查,能省下大量返工。
| 反模式(别这么做) | 后果 | Claude Code 的正解 |
|---|---|---|
| tool_result 用错角色(非 user 侧) | 模型无法消费工具结果,对话错乱 | 工具结果一律以 user 角色回灌 |
| 工具抛异常直接冒泡 | 一次工具失败炸掉整个主循环 | 每步失败短路成结构化错误回灌,循环继续 |
| 不做上下文预算,用满才管 | 长任务必撞窗口,直接 400 | 预留 buffer + 主动压缩 + 兜底截断 |
| 为"问用户""派子任务"开特殊分支 | 主循环越来越臃肿、难维护 | 统一建模为工具调用,循环零特例 |
| 每轮重发全量 prompt | 缓存全失效,成本与延迟暴涨 | 静态前缀稳定、命中 prompt cache |
| 把状态只存在内存里 | 崩溃/退出即归零,无法续聊 | append-only 事件流落盘,可 resume |
| 安全属性默认开放(fail-open) | 新工具忘记声明=最危险档 | fail-closed,忘记声明=最安全档 |
| 压缩后不复灌状态 | 模型"失忆",忘了打开过哪些文件 | 复灌关键文件/延迟工具结果 |
| 一上来就上多 agent/Swarm | 简单任务背负巨大协调开销 | 渐进式:能用单 agent 绝不上 Swarm |
把心法转成有序的施工计划。严格按此顺序推进——每一阶段都能独立跑通、独立验证,不必等全部做完才见效。
在你给自己的 harness 加任何新能力之前,用这份清单过一遍,能避开 90% 的架构债。
结构
- 这个新能力能否表达成"一个 Tool"?如果要改主循环,先停下来想想是不是抽象错了。
- 主循环是否仍然只认
tool_use → tool_result一种事件?
安全
- 新工具的并发/只读/破坏性旗标是否显式声明?默认是否落在最安全档?
- 这个能力会不会因"来源不可信"而被滥用(如远程注入)?是否需要按来源分级?
上下文与成本
- 这个工具的输出会不会很大?是否需要截断/摘要,避免一次撑爆窗口?
- 我新增的 prompt 内容放在静态段还是动态段?会不会打破前缀缓存?
韧性
- 这一步失败时,是结构化回灌让模型自纠,还是会炸掉主循环?
- 崩溃后从事件流恢复,这个能力产生的状态能否被正确重建?
术语速查表
harness 领域的术语散落在各章,这里集中一句话解释 + 跳转到详述章节。读源码或本文档时遇到不认识的词,回这里查。
核心循环
| 术语 | 一句话解释 | 详见 |
|---|---|---|
| Harness | 模型外面那层"执行骨架":循环 + 工具桥 + 上下文 + 持久化 + 护栏,把会聊天的 LLM 变成能干活的 agent | Harness |
tool_use / tool_result | 模型请求调用工具的块 / 工具执行结果回灌的块;后者一律以 user 角色回灌 | Harness |
| AsyncGenerator | 主循环的载体:边执行边 yield 事件,让 UI/SDK/headless 共用同一内核 | 执行内核 |
partitionToolCalls | 把一轮的多个工具调用按"并发安全"切成可并发块与必须串行块 | 执行内核 |
contextModifier | 工具对后续轮次上下文的修改,延迟到本组执行完毕统一应用,避免并发踩踏 | 执行内核 |
上下文与成本
| 术语 | 一句话解释 | 详见 |
|---|---|---|
| 预算预留 buffer | 有效窗口 = 模型窗口 − 输出预留 − 压缩缓冲;从不把窗口用满 | Context |
| autoCompact | 上下文逼近上限时自动压缩历史;带熔断器,连续失败就停手防活锁 | Context |
| PTL(prompt-too-long) | 压缩仍超长时的最后兜底:头部截断后重试请求 | Context |
| 静态/动态边界 | system prompt 切出"逐字节不变的静态前缀"以命中 provider 缓存,易变内容放边界之后 | Prompt |
| prompt cache | provider 对相同前缀的缓存命中,省钱省延迟;harness 要做"缓存友好设计" | Prompt |
forkSubagent | 派生子 agent 时复用父 prompt 的相同字节,让子 agent 首请求也命中缓存 | Multi-Agent |
持久化与韧性
| 术语 | 一句话解释 | 详见 |
|---|---|---|
| append-only JSONL | 会话记为只追加、不回改的事件流,天然抗崩溃、可审计、可续聊 | 会话存储 |
| transcript | 完整的会话事件记录流,resume 与可观测的基础 | 会话存储 |
| resume 修复链 | 重连时默认磁盘会话是"脏"的,主动修补孤儿 tool_result 到 API 可接受形态 | 会话存储 |
| 结构化回灌 | 工具失败时短路成错误 tool_result 让模型自纠,而非抛异常炸循环 | Harness |
createPostCompactFileAttachments | 压缩后复灌关键文件状态,让模型"换更省 token 的方式记住"而非失忆 | Memory |
安全与扩展
| 术语 | 一句话解释 | 详见 |
|---|---|---|
| fail-closed | 安全旗标默认全 false(不并发/非只读/需权限),开放是显式选择,忘记声明=最安全档 | 工具系统 |
buildTool | 工具工厂函数,统一注入 fail-closed 默认值与 schema 校验 | 工具系统 |
| trust 两阶段 | trust 前只读全局配置(无副作用),trust 后才执行项目配置,防"打开恶意仓库即 RCE" | 程序入口 |
| 来源决定能力 | 同一能力按来源可信度分级开放:本地放开、远程切断(loadedFrom) | Skills |
| 沙箱 sandbox | 命令执行的隔离兜底层,仍尊重用户 deny/ask | Sandbox |
| MCP | 外部工具接入协议,统一命名 mcp__server__tool,同名时内建工具优先 | MCP |
| SubAgent / Coordinator / Swarm | 渐进式多 agent 三档:小任务不付大架构的税,复杂度按需引入 | Multi-Agent |
最终总结
一句话定义
Claude Code 是一个"能力极强、平台感很重、长期协作属性明显"的本地代码 Agent 系统,而不是一个一次性问答工具。
三维度核心特征
- 多入口 — CLI / REPL / SDK / MCP / Bridge / Remote
- 多层次 — 命令 → 执行内核 → 工具 → 权限 → Memory → 扩展
- 多形态协作 — 单agent / subagent / background / teammate / swarm
架构核心优势
- 统一的 query / agent / tool / permission 内核
- 文件化、可审计、分层的 memory 系统
- Local-first,但可扩展到 remote / bridge / swarm