扫码打开虎嗅APP
本文来自微信公众号: 叶小钗 ,作者:叶小钗,原文标题:《【万字】拆完 Claude Code 51万行源码后,我才明白什么叫 Harness》
然后,文章基于Claude Code源码(TypeScript/Bun运行时,约1900个源文件,51w+行代码)进行解读,尝试从一个具体场景出发,追踪一条消息从用户输入到模型回复的完整生命周期
Claude Code源码暴露已经有些日子了,网上已经有不少解读了,我也也在很多认真的阅读(说实话,不借助AI还是有些吃力的...),但一直不知道该如何将这个事情聊清楚,最后决定还是从一次场景出发,也许能串起来!
打开终端,输入claude,REPL启动了,你粘贴了一张截图,某个报错页面的截屏——然后敲了一句:
这个报错是什么意思?帮我修一下
几秒钟后,终端里开始出现回复。它先认出了图片里的错误信息,然后告诉你它想去看某个文件,再然后它直接把文件改了,最后给你一个总结。
你大概知道背后在调用大模型,但这中间到底发生了什么?模型怎么知道该去看哪个文件?它凭什么能直接改你的代码?改之前有没有什么安全机制?如果你聊了很久、对话已经很长了,它怎么处理?
这篇文章就是回答这些问题的,我会带着你,从你按下回车的那一刻开始,一步步跟完整条处理链路:

全链路总览:从按下回车到收到回复,消息依次穿过UI层、编排层、核心层、服务层
你在终端里看到的交互界面,底层是一个React应用(前端框架,不是Agent的ReAct框架,类似的是Vue框架),用Ink框架渲染在终端里。
整个界面最核心的组件叫REPL.tsx,这个文件有5000多行,算是Claude Code里最大的单文件之一了。
你按下回车的那一刻,触发的是onSubmit回调,它做几件事:
先看看是不是即时命令,像/clear这种带有immediate标记的斜杠命令,根本不经过模型,直接执行(清屏就是清屏,不需要大模型参与)。
然后处理一些边缘情况:如果你之前中断过对话,它会尝试恢复上下文。接着把你的输入加入历史记录,清空输入框,开始显示等待动画。
最后调用handlePromptSubmit(),一路走到onQuery(),到这里为止,你的输入还只是一段原始文本。接下来,它要被加工成一条正式的消息。
我们的场景里还有一张截图,图片的处理是另一条独立的管线,入口在src/utils/imagePaste.ts。
从剪贴板拿到图片,在macOS上会优先走原生NSPasteboard API,这是亚毫秒级的操作,通过image-processor-napi模块直接拿到PNG字节。
如果原生方式失败,会降级到用osascript调系统剪贴板:先把图片存成临时文件,再读字节,遇到BMP格式还得转成PNG。
拿到图片只是第一步,API对图片有尺寸和大小的限制,所以必须压缩。
压缩的策略是一套多级降级方案:尺寸和大小都合规就直接用;文件大了但尺寸没问题,就依次试PNG调色板压缩...
尺寸超了就先等比缩放;缩放后还是太大,就PNG压缩再走JPEG各级质量,最后还有一手极端压缩。
总之,一定要把它压到API能接受的程度。处理完的图片被转成base64,封装成API要求的格式:
{
"type":"image",
"source":{
"type":"base64",
"media_type":"image/png",
"data":"
"
}
}
到这一步,你的文本和图片一起被组装成了一条UserMessage,准备好进入下一段旅程了。

用户输入处理流程:onSubmit决策+图片处理管线(剪贴板→多级压缩→base64)+消息组装

你的消息要发给模型,但它还不能直接发,在那之前,需要组装系统提示词。
系统提示词是Claude Code里最核心的设计之一,它不是一段固定文本,而是由多个模块动态拼出来的,而且拼接的方式经过了精心设计,目的是让Anthropic API的Prompt Caching尽可能命中。
无论什么Agent系统,在换成信息命中这块都是极大的难点!
Claude Code这里也是如此,后面大家会看到,因为这里的设计,直接决定了成本,命中的时候能省大约90%。
源码入口是src/constants/prompts.ts,核心函数叫getSystemPrompt()。
系统提示词的内容并非全部写在这个文件里,静态段落(身份、指令、编程哲学等)是独立的函数,动态段落通过一个systemPromptSection注册表来加载,包括环境信息、记忆、MCP指令、输出风格等。
有一些内容在每次对话里几乎不会变,会被标记上cache_control:ephemeral。这些是缓存的主要受益者:
身份和角色:"你是一个交互式代理,帮助用户完成软件工程任务"
系统指令:Markdown渲染规则、工具权限模式说明、注入攻击防御
编码哲学:不要过度工程、不要提前抽象、三行相似代码好过一个过早抽象
工具使用指南:优先用Read不用cat、优先用Edit不用sed、优先用Glob不用find
语气风格:简洁直接、不用emoji、一句能说清的别用三句
这些内容加起来不算少,但因为每次都一样,API会在服务端缓存住,后续每次调用只要缓存没过期,直接省掉90%的成本。
另一些内容每轮查询都会重新计算:
环境信息:当前目录、git分支、最近几条提交、平台信息、模型知识截止日期
用户上下文:CLAUDE.md的内容、MEMORY.md的持久记忆
MCP指令:已连接的MCP服务器提供的工具描述
技能列表:当前可用的斜杠命令
Hook指令:用户配置的钩子说明
输出风格:基于用户配置的格式偏好
怎么拼的,为什么工具列表要按字母排
组装的时候,先把那些需要IO的部分并行加载(技能列表、输出风格配置、环境信息),再把静态段落和动态段落拼接起来。
这里有个容易忽略但很有意思的细节:工具列表是按字母顺序排列的,这不是随意的,是为了保持prompt cache的稳定性。
如果每次工具的顺序不一样,缓存就会失效,之前花在缓存上的投入就白费了。
Claude Code几乎每次API调用都会带上完整的工具列表,所以缓存命中率直接决定了成本,按字母排看似是个小决定,实际上影响很大。
环境信息的注入值得单独说一下,因为它直接回答了"模型怎么知道去哪找文件"这个问题。
每轮查询时,系统会把这些东西嵌入到系统提示词里:当前工作目录的绝对路径、当前git分支名和最近几条提交、操作系统和shell类型、模型的知识截止日期。
这些东西构成了模型理解当前项目环境的"眼睛/窗口"。当你说帮我修bug的时候,模型看到的不仅仅是这句话。
它还看到了你在一个TypeScript项目里,当前在feature/auth分支,最近的提交是fix login validation,它据此推断该从哪些文件开始找。
这和IDE里的Copilot不一样,Copilot有整个项目文件树的索引。
Claude Code的做法是把关键的环境信息浓缩进提示词,然后让模型自己用工具去探索,好处是不需要维护索引,代价是模型可能需要多调用几次工具才能找到正确的文件。
系统提示词组装流程:静态段落(缓存友好)+动态段落(每轮刷新)+工具列表(按字母排序),三路并行组装

在继续追踪消息之前,需要先把两个容易混淆的概念说清楚。
工具(Tool)是模型可以直接调用的函数。
Read读文件、Edit改文件、Bash执行命令,这些都是工具。
它们在API请求里以tools参数发出去,模型在回复里通过tool_use类型的内容块来表达"我想调用Read工具读某个文件"。
Claude Code的运行时收到这个意图后执行对应函数,把结果通过tool_result返回给模型。
技能(Skill)是一种能力增强模块,每个技能都声明了自己的触发条件。触发方式有两种:
一是用户显式输入斜杠命令,比如/commit、/compact。
二是模型收到用户的自然语言请求后,判断是否匹配某个技能的触发条件,自动调用;
比如用户说"帮我提交代码",模型会自动匹配并调用commit技能,用户根本不需要知道斜杠命令的存在。而且这是强制性的:模型在回应用户之前必须先检查是否有匹配的技能,匹配到了就必须优先调用。
技能的本质是一段预设的prompt模板,被触发后,Claude Code把它展开成一条详细指令。
比如commit技能展开后变成"查看git diff,分析变更,写规范的提交信息"。模型收到这段展开后的指令,和收到一条普通用户消息没有区别,它还是通过Read、Bash、Edit这些工具来完成任务。
一句话区分:工具是Tools,技能是模型可以主动匹配并加载的提示词模板,技能(Skills)不能发明新工具,但他可以组合调用Tools。
工具与技能的两条路径对比:普通消息直接进API,技能触发后先展开prompt模板再进API,最终都通过同样的工具来完成任务
Claude Code有数十个内置工具,具体数量取决于feature flags和环境配置,src/tools/下有约42个工具子目录,按职责分成几类:
-文件操作:Read/Edit/Write/NotebookEdit
-搜索:Glob/Grep
-执行:Bash
-代理:AgentTool/TeamCreate/SendMessage
-任务:TaskCreate/TaskUpdate/TaskGet/TaskList/TaskOutput/TaskStop
-网络:WebFetch/WebSearch
-模式:EnterPlanMode/ExitPlanMode/EnterWorktree/ExitWorktree
-调度:CronCreate/CronDelete/CronList
-交互:AskUserQuestion
-集成:Skill/ToolSearch/LSP/MCP
但它们不是一股脑全部加载的。组装流程有三步过滤:
第一,getAllBaseTools()收集所有内置工具,但会根据特性门控(Feature Flag)过滤——比如CronCreate需要AGENT_TRIGGERS特性开启才会被包含。
第二,getTools()进一步过滤,移除被用户禁用的工具和当前模式不允许的工具。
第三,assembleToolPool()把内置工具和MCP外部工具合并,按字母排序(又看到了吧,为了缓存),去重(内置工具优先)。
最终的工具列表作为API请求的tools参数发送,每个工具都要实现一个统一的接口,包括参数校验(用Zod定义)、执行逻辑、权限检查、以及两个关键标记:isConcurrencySafe(是否可以并行执行)和isReadOnly(是否只读)。
Read、Grep、Glob都是只读的,可以并行,Edit、Write、Bash会改变文件系统,必须串行。这个标记直接决定了工具的调度策略(后面你会看到它是怎么起作用的)。
这里很多工具受特性门控管理。构建的时候,Bun会做Dead Code Elimination,没启用的工具代码直接从二进制文件里移除,不会多占一个字节。

你的消息和系统提示词都准备好了,现在进入整条链路的核心:查询循环。
流程是这样的:你的消息先经过onQueryImpl(),这个函数并行做三件事:构建系统提示词、组装工具列表、检查是否需要压缩。
然后把用户上下文和系统上下文合并,调用query()函数,进入queryLoop()。
queryLoop()在src/query.ts中,是一个while(true)循环,每一轮迭代做的事情是这样的:
先把消息准备好。检查对话是不是太长需要压缩(这个后面会详细讲),然后组装完整的消息列表:系统提示词+历史消息+当前的用户消息或工具结果。
然后调API,流式。向Anthropic API发POST/v1/messages,带上stream:true。API通过SSE(Server-Sent Events)逐块返回内容。
边收边处理。文本内容实时渲染到终端。如果出现了tool_use类型的内容块,交给StreamingToolExecutor处理(这个组件值得单独说,稍后展开)。
执行工具。StreamingToolExecutor根据每个工具的isConcurrencySafe标记来决定怎么调度——能并行的立即启动,必须串行的排队等前面的完成。
具体来说,它用的是一种基于队列的调度:每收到一个tool_use块就检查当前是否有工具在运行,如果都在空闲或者当前运行的都是可并行工具且新工具也可并行,就立即启动执行;否则排队。
完成任何一个工具后重新处理队列。每个工具执行前都要过权限检查。
判断要不要继续。这里的机制可能和你想的不一样:代码并不依赖响应里的stop_reason字段(源码注释明确写了"stop_reason is unreliable")。
实际做法是在流式接收过程中维护一个toolUseBlocks数组,每出现一个tool_use内容块就记下来。如果这个数组不为空,说明模型还想继续用工具,那就把工具执行结果追加到消息列表,回到循环开头,开始下一轮。
如果数组为空,说明模型说完了,循环结束。
查询循环流程:while(true)循环,每轮经历压缩检查→组装消息→调API→处理响应→执行工具→判断是否继续
用场景走一遍
空说流程有点抽象,我们用"帮我修bug+截图"这个场景实地走一遍。
第一轮循环:你的消息(图片+文本)和系统提示词一起发给API,模型看了图片,认出了TypeError:Cannot read properties of null,然后说"让我查看src/utils/handler.ts",附带了tool_use(要求Read这个文件)。因为检测到了tool_use内容块,循环不会结束。
第二轮循环:上一轮的assistant回复和Read工具返回的文件内容一起追加到消息列表,再次发给API。模型看到文件内容,分析出问题所在,返回新的tool_use(要求Edit修改文件),循环继续。
第三轮循环:Edit的执行结果追加进去,再发API。模型确认修复完成,没有新的tool_use请求,循环结束。
循环结束,你在终端里看到了完整的修复过程和最终结论。
从你按下回车到看到结果,经历了三轮API调用、两次工具执行,如果问题更复杂,可能是十轮二十轮,但每一轮做的事情,本质上都是一样的。
模型可能在一个回复里同时请求多个工具,比如它可能同时要Read一个文件、Grep搜索一个模式、Edit另一个文件,如果等所有tool_use块都收齐了再执行,就会白白浪费时间。
StreamingToolExecutor的做法是:收到一个tool_use就开始执行。
Read和Grep都标记了isConcurrencySafe=true,所以它们可以同时跑。
Edit标记了isConcurrencySafe=false,因为要改文件,必须等前面的都完成后再执行。
实际调度机制不是简单的Promise.all(),而是一个队列系统——每个工具完成后会重新触发队列处理(promise.finally(()=>processQueue())),等待结果时用Promise.race逐个等。
如果并行执行的工具中有一个失败了,其他并行的兄弟工具都会被取消,这是为了防止部分失败导致不一致的状态:

StreamingToolExecutor时间线:收到tool_use立即执行,Read/Grep并行,Edit排队等待前序完成后再串行执行。
上面的流程里,模型要Edit一个文件,但不是说了就改,在工具真正执行之前,有一套多层的权限检查。
Claude Code有多种权限模式,从严格到宽松依次是:
plan→default→acceptEdits→auto→dontAsk→bypassPermissions
普通用户最常用的是default,对危险操作会弹确认框。
auto模式下会用一个分类器来自动判断操作是否安全(这个模式需要TRANSCRIPT_CLASSIFIERfeature flag开启)。
plan模式下所有写操作直接拒绝——你只能在计划阶段,不能动文件。

当工具要执行时,过的是这样一条流水线:
第一步,看有没有被你明确禁止。你可以在settings.json里写deny规则,禁止某些工具或某些操作。如果命中了,直接拒绝,后面什么都不看了。
第二步,看有没有被你标记为需要询问。某些MCP工具可能配了"每次都问我"。
第三步,工具自己的权限逻辑。每个工具有自己的checkPermissions()方法。BashTool会解析命令的结构来判断风险等级,底层用shell-quote库做命令解析,再结合LLM做语义分析,FileEditTool会检查文件路径是否在允许的工作目录范围内。
第四步,根据权限模式做决策。bypassPermissions模式下前三步没拒绝就放行,auto模式下会调用一个叫YOLO分类器的东西(用LLM做语义判断,模型默认和主对话用的是同一个)。default模式下弹出确认框让你选。
第五步,Hook系统。即便前面都过了,还有一关:用户注册的PreToolUse钩子。任何一个钩子返回block,整体拒绝,一票否决。
权限检查流水线:五步检查依次执行——否决规则→询问规则→工具自身权限→模式决策→Hook一票否决
Bash的风险评估比较有意思,实际的命令解析在src/utils/bash/commands.ts,用shell-quote库把命令拆解成结构化token(处理管道、重定向、命令替换等),再结合LLM提取命令前缀做语义分析。
风险等级(LOW/MEDIUM/HIGH)不是硬编码的规则表,而是由LLM实时判断的:
`ls`、`git status`会被判定为低风险,
`npm install`、`docker build`是中等风险,
`rm-rf`、`sudo`、`git push--force`是高风险。
这些都是模型根据语义推断出来的,不是写死的映射。
auto模式下的YOLO分类器(src/utils/permissions/yoloClassifier.ts)也会调LLM来做语义判断。
它用的模型不固定为某个特定的小模型,而是通过getClassifierModel()解析,默认回退到当前会话的主循环模型(getMainLoopModel())。
这比静态规则灵活不少——比如它能理解"删除node_modules再重装"虽然包含rm命令,但其实是合理的操作。
在default模式下,遇到需要确认的操作,终端会弹出这么一个东西:
┌─────────────────────────────────────────┐
│Claude wants to edit src/main.ts│
││
│Allow?[Y]Yes[N]No│
│[A]Always allowforthis tool│
│[E]Edit the file myself│
└─────────────────────────────────────────┘
选Yes这次放行。选"Always allow"会把规则写进settings.json,以后同类型的操作不再询问。
聊到这里,你可能有个疑问:如果我对模型说了"以后用bun不要用npm",下一个会话它还记得吗?如果昨天聊了一半的对话,今天接着聊,上下文还在吗?
Claude Code的记忆机制有四层,分别解决不同的问题,但在讲每一层之前,先搞清楚一个前提:这些记忆是什么时候加载的,加载了什么?
记忆的加载分两条并行的管线,各管各的:
第一条管线负责读取记忆文件的实际内容
它在一个叫getUserContext()的函数里运行,调用getMemoryFiles()去发现和读取文件,然后用getClaudeMds()把内容格式化成文本,注入到每轮对话的用户上下文里。
第二条管线不注入文件内容,而是注入行为指令
告诉模型"你有一本笔记可以写,规则是这样的,什么时候该更新"。模型知道自己有记忆能力,但具体的笔记内容是从第一条管线看到的。
两条管线并行运行,一起组装成完整的上下文。

第一条管线具体加载哪些文件?按优先级从低到高:
管理员指令。路径是/etc/claude-code/CLAUDE.md(Linux)或/Library/Application Support/ClaudeCode/CLAUDE.md(macOS)。系统管理员为所有用户设定的全局规则,普通用户一般碰不到。
用户全局指令。~/.claude/CLAUDE.md和~/.claude/rules/*.md。你自己写的、对所有项目生效的偏好,比如"回复用中文"。
项目指令。这是最常用的。从你的当前工作目录开始,向上遍历到文件系统根目录,收集沿途每一级目录下的CLAUDE.md、.claude/CLAUDE.md、.claude/rules/*.md、CLAUDE.local.md。
遍历方向有个讲究:代码先把从当前目录到根的所有路径收集到一个数组里,然后反转数组,从根开始处理到当前目录。
这意味着越靠近你当前位置的文件,加载顺序越靠后,优先级越高。你可以在项目根目录写通用规则,在某个子目录写覆盖规则。
这里面有个巧妙的设计是CLAUDE.local.md,它和CLAUDE.md放在同一个目录,但你把它加进.gitignore,只在自己本地生效。适合放"我喜欢用bun而不是npm"这种个人偏好,不影响团队其他人。
这是最简单的记忆形式:你写什么,模型就看到什么。本质上就是一堆Markdown文件被读取后拼进了上下文。
CLAUDE.md文件发现与优先级:管理员指令→用户全局→项目指令(从根到cwd遍历),越靠近当前位置优先级越高
CLAUDE.md需要你手动写,MEMORY.md不需要。
它存放在~/.claude/projects/<项目路径>/memory/下面,由模型在对话过程中自动维护。
更新的方式是这样的:系统会在后台启动一个子代理(Forked Agent)。
这个子代理继承了父对话的prompt cache。所以API成本很低,但只能执行文件编辑操作,而且只能编辑记忆文件。
它会读取当前的记忆内容,分析最近的对话,然后决定新增、修改或删除哪些条目。
触发条件是几个指标的组合判断:累计token数超过10000、距上次提取新增token超过5000、距上次提取的工具调用超过3次、最近一轮没有工具调用(说明对话进入了"总结"阶段,是提取记忆的好时机)。
记忆文件长这样:
#项目记忆
##架构
-前端:React+Ink用于终端界面
-状态管理:类似Redux的createStore模式
##用户偏好
-使用bun而非npm
-提交信息使用中文
##调试笔记
-queryLoop中的while(true)是有意为之,并非bug
这一层解决的问题是跨会话的持续性,你在一个会话里告诉模型的东西,下一个会话还能记得,因为MEMORY.md在每轮查询时都会被读取并注入上下文。
Session Memory和MEMORY.md不是一回事。它也是一个Markdown文件,但由一个独立的后台服务维护,有自己的模板结构,包含Session Title、Current State、Task specification这些章节。
区别在于职责不同:MEMORY.md是长期的通用笔记,记录架构决策和用户偏好;Session Memory是面向当前会话的运行摘要,记录当前正在做什么、做到哪一步了。
Session Memory是保存在磁盘上的,新会话启动时会通过记忆加载机制被读到上下文里。所以新会话能看到上一个会话的进度。
对话进行到一定长度时,token数量会接近模型的上下文窗口限制。这时需要压缩。

三种压缩机制对比:Micro压缩只清工具返回值内容;Session Memory压缩保留近期消息、用现成摘要;标准压缩全部替换、调API生成摘要。自动压缩时优先尝试Session Memory路径
Micro压缩是最轻量的。每轮查询前都会跑一次,不生成摘要,不调API,也不删任何消息。
它做的事情是找到旧的Read、Bash、Grep、Edit这些工具的返回值,把文本内容替换成一句[Old tool result content cleared]。
消息结构还在,只是内容被清空了。早期的工具返回值大概率已经过时了,最近几轮的可能还有用,所以保留近期的。
完整压缩才是真正意义上的"压缩"。把对话历史替换成摘要,但它有两条路径,行为完全不同:
路径一:Session Memory压缩(优先尝试)。触发时,系统先检查Session Memory是否可用。如果可用,直接拿Session Memory文件的内容当摘要,不调API,而且它会保留近期消息。
Session Memory后台服务维护了一个lastSummarizedMessageId,标记"我已经提取到哪条消息了"。
压缩时,这个标记之后的近期消息全部原封不动保留,还有最低保护:至少5条消息、至少1w tokens。模型最终看到的是:Session Memory摘要+保留的近期消息。
路径二:标准压缩(回退方案)。如果Session Memory不可用(比如手动压缩时用户提供了自定义指令),就走标准路径:
调一次API,让模型看一遍完整对话历史,现场写一段摘要。然后所有消息被替换掉,一条近期消息都不保留,模型最终看到的是:只有摘要。
无论哪条路径,压缩完成后都会在消息列表中插入一条CompactBoundaryMessage作为边界标记,还会重新触发SessionStarthooks,让模型"重新感知"文件系统环境。
这里有个问题:压缩的结果跨会话吗?
压缩摘要本身不会。它替换了内存中的消息列表,也写入了当前会话的transcript文件。用--resume恢复同一会话时能看到,但启动全新会话时不会带上。
跨会话的持续性靠的是前面说的Session Memory文件——它独立于压缩机制,一直在后台维护。
自动压缩的触发阈值大约是有效上下文窗口大小-13000缓冲token。对于200K上下文的模型,大约在167,000 tokens时触发。连续失败3次后会停止尝试(熔断器),避免无限循环。

四层记忆系统总览:两条并行加载管线+CLAUDE.md/MEMORY.md/Session Memory/上下文压缩四层记忆各司其职

API返回的不是一次性返回完整JSON,而是通过SSE逐块返回,这是你能实时看到输出、而不是对着空白屏幕等十几秒的原因。
事件序列大概是这样:
message_start→收到token使用量、模型信息
│
content_block_start→开始一个内容块(文本或tool_use)
│
content_block_delta×N→文本片段或tool_use参数片段,逐块到达
│
content_block_stop→当前内容块结束
│
content_block_start→可能开始下一个内容块
...
message_stop→整个响应结束,附带stop_reason
每收到一个text_delta事件,文本就立即渲染到终端,你看到的"一个字一个字出现"的效果就是这么来的。
tool_use块的参数也是流式到达的。模型要调用Edit工具时,参数old_string和new_string可能分好几个delta事件才传完。
StreamingToolExecutor会持续收集这些delta,等参数完整后才开始执行。
SSE事件序列与终端渲染:message_start→content_block_start/delta/stop→message_stop,文本实时渲染,tool_use参数流式收集
如果中途出了问题,查询循环也有错误处理:429限流就指数退避重试,500/502/503服务端错误也重试,401认证过期就自动刷新OAuth Token,Prompt Too Long就触发压缩后重试,Max Output Tokens就缩短输出限制后重试。
最后:把所有环节串起来
现在你已经走完了每个环节,我们再把"帮我修bug+截图"这条消息的完整旅程快速过一遍:

完整链路回顾:8个步骤、3轮API调用、2次工具执行、1次权限确认,从按下回车到修复完成。
你按下回车。REPL.tsx的onSubmit()触发。imagePaste.ts从剪贴板拿到图片,imageResizer.ts压缩编码。文本和图片组装成一条UserMessage。
查询准备。onQueryImpl()并行做三件事:getSystemPrompt()组装系统提示词(加载CLAUDE.md、MEMORY.md、环境信息),assembleToolPool()组装工具列表,compact模块检查是否需要自动压缩。
第一轮API调用。query()→queryLoop()→POST/v1/messages(stream)。发给模型的消息里有系统提示词和你的消息(图片+文本)。模型返回文本(识别了图片中的错误)+tool_use(要求Read某个文件)。
Read工具执行。Read是只读工具,权限检查通过,读文件,返回内容。
第二轮API调用。历史消息+Read的结果一起发给API。模型分析代码,返回tool_use(要求Edit修改文件)。
Edit工具执行。Edit不是只读的,权限检查流水线启动:没被禁止→路径在项目内→default模式弹出确认框→你按了Y→执行替换→返回diff结果。
第三轮API调用。模型看到编辑结果,确认修复完成,没有新的工具调用请求。
循环结束。最终回复渲染到终端,token使用量被记录,后台检查是否需要提取会话记忆,等待你下一条输入。
从按下回车到看到结果,这条消息穿过了UI层、编排层、核心系统层、服务层,经过了好几轮API调用和工具执行,通过了权限检查,最终带着修复结果回到了你的终端。
走完流程,再看设计
说实话,我之前写文章几乎是不使用AI的,但是这篇文章是在跟AI协作了3天搞出来的,因为那个源码几乎很难完全硬着头皮去读,先不说能不能完全读懂(没办法调试的),核心原因是可能ROI没那么高。
换句话说:我更倾向去读OpenClaw这种可调式的完整项目
走完全程后,也是有几个点值得拿出来大家看看的,这和最近流行的*Harness也是有关系的:
子代理模式。压缩、记忆提取这些"管理对话"的操作,都是通过创建子代理来完成的。子代理共享父对话的prompt cache,成本极低。这把"管理对话"和"回答问题"分成了两个独立的关注点,是一个很实用的设计模式。
流式工具执行。不等所有tool_use到齐再执行,而是边接收边执行。能并行的并行,必须串行的排队。在模型一次请求多个工具的场景下(这在Claude Code里很常见),这减少了等待时间。
多层权限模型。命令解析结合shell-quote结构化分析和LLM语义理解来判断Bash命令的风险,YOLO分类器同样用LLM来判断操作安全性,Hook系统给用户留了一票否决的最终手段。不是简单的"允许/禁止"二选一,而是在自动化效率和安全性之间找到了一个不错的平衡。
Prompt Cache友好设计。系统提示词分静态和动态两段,工具列表按字母排序,一切都是为了让缓存尽可能命中。缓存命中时成本降大约90%。对一次会话可能调用几十次API的工具来说,不做这个优化,成本会很高。
多层记忆的协作。CLAUDE.md、MEMORY.md、Session Memory、Compact是几个独立的系统,但它们有协作:自动压缩时优先用Session Memory的摘要省掉一次API调用,CLAUDE.md和MEMORY.md跨会话持久化,Compact摘要只在当前会话内生效。"长期记忆"和"短期压缩"各司其职,不互相干扰。
总结一下,我们为了更好的理解Claude Code的架构,追了一条消息的生命周期:从在终端里按下回车,到图片被处理、系统提示词被组装、工具被加载、查询循环开始转动,再到权限检查、记忆提取、上下文压缩、流式返回,最后把结果送回屏幕。
大家要注意,梳理流程是为了了解架构
所以,如果把这些环节再往上提一层,Claude Code真正值得看的,是它如何把模型能力包进一整套可以持续运行的工程系统里。
这恰恰就是最近的热词:Harness的价值。
所谓Harness,说白了,不是某个新组件,也不是什么玄学黑话,而是把模型能力变成稳定执行能力的那套工程化装置。
它要解决的,从来都不是“模型会不会答”,而是“模型能不能在真实环境里持续、稳定、可验证地把事做完”,从这个角度说,Claude Code的源码确实是一个很好的Harness案例。
因为它已经不只是一个聊天产品,而是在认真处理Agent落地时最麻烦的那批工程问题:长链路执行、工具并发、权限分层、记忆沉淀、压缩恢复、子代理协作。这些东西,才是真正把AI从“会说”推向“能干活”的关键。
当然,如果是从学习的角度看,Claude Code也有它的限制,核心问题是他不完整,他能让你看到很多局部设计为什么会长成这样,但实际学起来就差点意思。
所以拿来系统性理解Agent Runtime/Harness,全局视角Claude Code未必是最舒服的,他更适合国内搞AI Coding这批公司,我们这些更多是外围赏析下。
真要说看明白Harness整套东西是怎么跑起来的,OpenClaw这种更完整、可调、链路也更外露的项目,学习体验反而会更好一些。
所以这篇文章写到最后,我自己的结论其实很简单:
Claude Code值得看,但不值得研究,因为各位大概率是学不明白的,他身上涵盖的那套Harness,我们下次会继续探讨,但是结论可能也很扎心:不建议深度研究...