Agent 系统面试准备 — 基于 Claude Code 源码的核心概念
目标:每个概念都有真实代码/架构支撑,不靠空谈 数据来源:Claude Code v2.1.88 泄露源码(1900 文件 / 51.2 万行 TypeScript) 适用岗位:Agent Infra / Agent 全栈 / AI Engineer
目录
- Agent vs Chat/RAG 的本质区别
- ReAct 循环
- Tool Calling / Function Calling Schema 设计
- MCP 协议的意义
- Agent Memory 分层与写入策略
1. Agent vs Chat/RAG 的本质区别
一句话破题
Agent 和 Chat/RAG 的本质区别不是”能不能调工具”,而是对外部世界有没有副作用(side effect)。
Claude Code 源码怎么体现这个区别
源码里所有工具都被显式标记了是否只读:
// Tool 类型核心定义(简化自 Tool.ts, 792 行)
type Tool = {
name: string;
call: (input) => Promise<ToolResult>;
inputSchema: ZodSchema;
checkPermissions: () => PermissionCheck;
isReadOnly: boolean; // ← 显式标记副作用
isConcurrencySafe: boolean; // ← 是否有副作用影响并发
maxResultSizeChars: number;
}
这个 isReadOnly 不是摆设——权限系统围绕它构建。Read-only 工具(搜索、读取文件)走宽松通道,Write 工具(编辑文件、执行命令)走完整四层权限链:
Config Rules → Tool.checkPermissions → Classifier(小LLM侧查) → 用户确认
如果我们拿 Chat/RAG 来对比:
Chat 系统在 Claude Code 里的映射: 纯 API 调用 claude-sonnet-4,模型输出文本 → 文本,没有任何工具注册。这是零副作用的。
Agent 系统在 Claude Code 里的映射: 注册 40+ 工具,每个工具都要过权限链,工具执行结果作为 Observation 反馈到下一个 Thought。BashTool 会改文件系统、FileWrite 会改代码——这些都是副作用的源头。
最有力的证据是 Coordinator 模式的设计:
Coordinator(协调者)
→ 被剥夺所有文件操作权限
→ 只保留 3 个工具:Agent(派生子Agent)、SendMessage、TaskStop
→ 角色:纯规划和调度,不接触外部世界
Worker(执行者)
→ 继承完整工具权限
→ 在隔离上下文执行,所有副作用限制在子会话内
→ 完成后只回传 XML 精炼结论,不污染主对话
这清楚地展示了 Anthropic 对”有副作用”的认知:Coordinator 不直接操作任何资源,怕的是规划过程中无意间产生不可逆后果。 Worker 在隔离沙箱里做事,即使崩了也不影响全局。
副作用带来的系统性后果(Claude Code 源码验证)
| 问题 | Chat/RAG | Agent | Claude Code 怎么处理的 |
|---|---|---|---|
| 幂等性 | 天然幂等 | 调用一次和两次不同 | isConcurrencySafe() 标记工具能否并行执行 |
| 回滚 | 不需要 | 某步失败需补偿 | Coordinator 模式下子 Agent 隔离,失败不污染主会话 |
| 权限控制 | 不需要 | 必须分级 | 四层递进权限链,Classifier 用小模型动态判断 |
| 审计 | 记录对话即可 | 需完整调用链 | telemetry 模块记录所有工具调用参数、返回值、耗时 |
| 状态追踪 | 无状态 | 多步任务需状态机 | Hook 系统(25+ event hook)追踪执行生命周期 |
| 错误恢复 | 重新生成 | 不能重头来 | ReAct 循环天然支持 retry + fallback |
面试加分:从源码看”副作用”的极端情况
Bash 命令执行是副作用的极端例子:bashSecurity.ts 里内置了 23 条安全规则,Classifier 侧查模型会分析命令的风险等级。用户开了 Auto Mode 后,系统后台静默调用更小更便宜的 LLM,把对话精简转录 + 待执行命令传给它,决定 Allow 还是 Deny。
Chat/RAG 需要考虑”执行 rm -rf / 怎么办”吗?不需要,因为没有执行能力。Agent 必须,因为副作用是它的定义特征。
2. ReAct 循环
一句话破题
ReAct 的核心不是”先想再做”,而是推理过程和行动过程互相增强,同一个 token 生成流里同时完成思考和行动。
Claude Code 源码里的 ReAct 实现
核心引擎 QueryEngine.ts(1295 行,注意这只是接口层,完整 query 引擎 46,000 行):
// 简化的 submitMessage 模式(AsyncGenerator)
async function* submitMessage(userInput: string) {
// 1. 构造消息,注入 system prompt + tool schemas
const messages = buildMessages(userInput);
while (true) {
// 2. 流式调用 LLM
const response = yield* callLLM(messages);
if (response.type === 'text') {
// 3. 模型回复文本 → 可能还需要继续
messages.push({ role: 'assistant', content: response.text });
} else if (response.type === 'tool_use') {
// 4. 模型决定调工具 → 执行并反馈
const result = await executeTool(response.tool_call);
messages.push({ role: 'tool_result', content: result });
} else if (response.type === 'stop') {
// 5. 模型认为任务完成
break;
}
}
}
用 AsyncGenerator 实现 ReAct 循环不是偶然的。设计者的意图:
- 自然支持流式输出 —
yield每个消息片段,上层for await...of消费。用户看到逐字输出不阻塞 - 支持中断恢复 — Generator 天然可暂停,中间状态在变量闭包里,不需要额外状态机
- 工具执行不阻塞主线程 —
await executeTool()暂停当前 yield,但主线程可以继续处理其他任务
三个源码级别的关键洞察
1. 工具可以并行执行
// StreamingToolExecutor 的实现
// 工具调用被分区为并发批次和串行批次
// isConcurrencySafe() 决定一个工具能否与其他工具同时跑
这意味着 ReAct 循环的每一步不一定是单线的。模型可以在同一轮次发出多个工具调用(比如同时搜索和读文件),executor 根据 isConcurrencySafe 决定哪些可以并行。这大幅加快了多工具任务的完成速度。
2. 14 个缓存失效向量主动追踪 ReAct 循环的效率瓶颈
Claude Code 内部追踪 14 种 Prompt Cache 失效条件。每次 ReAct 循环中,如果 system prompt 的任意部分变了(工具列表、用户配置、Git 状态),缓存就失效了。14 个向量让团队能定位”为什么 ReAct 循环越跑越慢”——是缓存被频繁击穿了。
3. ToolSearch 是 ReAct 的精简变体
ReAct 的标准形式要求所有工具定义都在 prompt 里。但 Claude Code 有 40+ 工具,全塞进去 token 开销巨大。
解法是 ToolSearch:
非核心工具标记 defer_loading: true
→ 模型看不到具体定义,只知道有 ToolSearch 可用
→ 需要时传关键词 → 动态加载对应工具定义 → 放入后续 prompt
这实际上是 ReAct 的按需加载变体:模型先”知道有什么”,再”按需了解”,而不是一次性加载全部。
三种变体在 Claude Code 源码里的痕迹
| 变体 | Claude Code 中的实现 | 触发条件 |
|---|---|---|
| 标准 ReAct | 默认 QueryEngine 循环 | 普通对话,任务简单 |
| Plan-and-Execute | Coordinator 模式 | 复杂任务,需要先规划再执行 |
| Reflexion | autoDream 整理 | 跨会话,记忆需要反思和合并 |
面试追问准备
Q:Claude Code 的终止条件怎么设计的?
源码里至少四种:
- 最大步数硬限制 — 20 步上限(Coordinator 模式下不同)
- respond/finish 信号 — 模型自己发出 stop token
- 重复检测 — 同样的 (action, observation) 重复出现 → 打断
- 超时 — 轮级别 timeout
Q:Coordinator 模式下的 ReAct 循环有什么不同?
Coordinator 不直接执行工具。它的 ReAct 循环是:
Thought → spawn SubAgent → wait for result → evaluate → [continue | finish]
注意中间多了一层:Coordinators 的 Action 不是调工具,是派 Agent。工具调用发生在子 Agent 的 ReAct 循环里。这叫 ReAct 的递归——外层 Agent 的 Action 是创建内层 Agent 的 ReAct 循环。
3. Tool Calling / Function Calling Schema 设计
一句话破题
Tool Schema 不是”给模型看的一段文档”,它是 Agent 和外部世界之间的契约。契约的质量直接决定了 Agent 的可靠性。
Claude Code 源码揭露的 40+ 工具系统
泄露源码显示 Claude Code 有 40 个注册工具 + 50+ 斜杠命令。这不是拍脑袋设计的——有一些非常清晰的工程原则。
1. Tool 接口的设计哲学
// Tool 类型核心定义(Tool.ts, 792 行, 20+ 方法)
type Tool = {
name: string;
call: (input) => Promise<ToolResult>;
inputSchema: ZodSchema; // Zod v4 类型校验
checkPermissions: () => PermissionCheck;
isReadOnly: boolean;
isConcurrencySafe: boolean; // ← 很少见的属性
maxResultSizeChars: number; // ← 结果大小管控
}
isConcurrencySafe 是面试必提的设计亮点。 大多数 Agent 系统假设工具一次只能调一个,但 Claude Code 在架构层面就区分了”可以并行”和”必须串行”的工具。比如 Read 和 Search 可以同时跑,FileWrite 和 BashRun 不行——因为写文件和执行命令之间有副作用的相互影响。
2. ToolSearch 按需加载
普通工具:直接注入 system prompt
非核心工具:defer_loading: true → 模型只知道 ToolSearch 存在
→ 模型发 ToolSearch("search", "grep")
→ 系统加载 grep 等搜索类工具定义
→ 放入后续 prompt
这对应到 Tool Calling 的一个深层问题:工具越多,模型选择越难,prompt 越长。 按需加载给了模型自主权——它决定什么时候需要看某个工具的具体定义。
还有一个 CLAUDE_CODE_SIMPLE 模式,只保留 3 个基础工具,给轻量场景用。
3. assembleToolPool() 的合并策略
// 合并内置工具 + MCP 工具
// 按 prompt cache 友好的顺序排列
function assembleToolPool(): Tool[] {
const builtin = getBuiltinTools();
const mcp = getMCPTools();
const all = [...builtin, ...mcp];
// 按字母序排序 → 保持 prompt 哈希稳定 → 缓存命中
return all.sort((a, b) => a.name.localeCompare(b.name));
}
字母序排序是为了缓存。 如果工具列表顺序每次不同,prompt 的哈希值就变了,Prompt Cache 直接失效。这个细节决定了”给模型看到整齐的工具列表”不光是美观,是成本优化。
从源码看到的 Schema 设计正反面
✅ 好的设计:物理与逻辑分离
Read 和 Search 是两个工具,但底层可以共享同一个文件系统模块。工具是模型看到的逻辑抽象,不是底层 API 的一一映射。
✅ 好的设计:大小管控
maxResultSizeChars 限制工具返回值大小。大结果自动保存到 ~/.claude/tool-results/,API 只收到摘要 + 文件路径。这是防止工具返回撑爆上下文的工程化手段。
❌ 坏的设计(从源码看到的教训):description 太短或太长
有些工具的 description 只有一行(“List files in a directory”),模型经常在 Glob 和 Read 之间选错。教训:工具 description 要写”什么时候用这个而不是隔壁那个”,不写”这个工具是什么”。
面试加分:Tool 的三层粒度
Claude Code 源码里存在但没显式标注的三层粒度:
L1 原子工具(read_file, edit_file, bash)
→ 不可分割的单一操作,最灵活,最多犯错空间
L2 组合工具(通过 ToolSearch 暴露的模式)
→ 搜索+读文件,封装常用操作
L3 任务工具(通过 Skills 系统实现)
→ "fix_bug" = search → edit → test
→ 对应一个完整的子任务,极少决策负担
面试时可以讲:经验是默认给 L1,用 Skills 系统提供 L3。模型从 L1 开始,熟练后 Skills 逐渐取代人工重复。 跟打游戏先练基本功再学大招一样。
4. MCP 协议的意义
一句话破题
MCP 不是常规意义上的”技术框架”,它解决的是一个 network effect 问题——把工具接入从 O(N×M) 降为 O(N+M)。
Claude Code 源码中的 MCP 实现
assembleToolPool() 的实现揭示了内置工具和 MCP 工具的关系:
function assembleToolPool(): Tool[] {
// 1. 加载内置工具(40+ 个,硬编码在工具目录)
const builtin = getBuiltinTools();
// 2. 加载 MCP 服务器注册的工具(动态发现)
const mcpServers = loadMCPServersFromConfig();
const mcpTools = mcpServers.flatMap(s => s.listTools());
// 3. 合并、排序、注入 prompt
const all = [...builtin, ...mcpTools];
return sortForPromptCache(all);
}
关键设计决策:MCP 工具和内置工具在 Agent 眼中没有区别。 模型不知道”这是个 MCP 工具还是内置工具”,它只看到 tool name 和 description。这意味着 MCP 接入的新能力立刻获得了和内置工具相同的权限系统、并发控制、结果大小管控。
MCP 不是”又一个框架”,是分层架构
Claude Code 源码揭示了 MCP 解决的真正问题。在构建 Agent 时,工具接入的复杂度是指数增长的:
Agent框架 × 工具服务 = O(N×M) 适配器
当 Claude Code 支持 40+ 工具时,如果每个工具都用原生 Function Calling 格式接入,每次 API 升级框架要改、每个工具要改。MCP 把问题拆成两层:
Agent框架 → MCP Client(Claude Code 实现一次)
工具服务 → MCP Server(工具提供商实现一次)
Claude Code 的 MCP Client 实现在 services/mcp/ 目录下,处理:
- 发现:从 settings.json 读取 MCP Server 配置
- 连接:支持 STDIO(本地进程)和 SSE(远程服务)
- 生命周期:start/stop/restart 管理
- 健康检查:心跳检测 MCP Server 是否存活
面试回答框架
“MCP 本质上和 TCP/IP 解决的问题一样——在上下层之间插一个标准接口层。MCP 出现之前,Agent 框架接入工具是 O(N×M) 的适配器问题;MCP 让每一层只需要实现一次接口,把复杂度降到 O(N+M)。Claude Code 的源码验证了这一点:MCP 插件和内置工具在 assembleToolPool 中无缝合并,模型完全感知不到区别。“
面试追问准备
Q:MCP 在 Claude Code 源码里和 Function Calling 是什么关系?
Function Calling: 模型 ←→ 工具调用请求(数据格式标准)
MCP: Agent框架 ←→ 工具服务(通信协议标准)
MCP Server 返回的 tool 定义,在组装成 system prompt 前会被转换成 Function Calling 格式。看代码就是 assembleToolPool() → 格式化工具描述 → 注入系统提示 → LLM 收到标准 tool_use 格式。MCP 是工具管理协议,Function Calling 是模型交互格式,解决不同层次的问题。
Q:Claude Code 为什么选择支持 MCP 而不是自己搞一套?
源码里已经有完整的工具系统(40+ 工具、ToolSearch、权限链),MCP 的接入不是为了替代它,是为了让第三方工具生态接入。Claude Code 提供基础设施(权限、并发、结果管理),MCP 提供生态扩展——这是一种平台化思维,不是技术选择。
5. Agent Memory 分层与写入策略
一句话破题
Memory 的核心难点不是”存什么”,而是写入决策——什么信息值得记住、什么时候应该更新、什么时候应该忘记。
Claude Code 源码揭示的四层记忆
Claude Code 的记忆系统完全基于文件系统(没有向量数据库),源码在 src/memdir/ 目录下。
层级 1:MEMORY.md — 索引层(常驻 system prompt)
~/.claude/projects/<git-root>/memory/MEMORY.md
- [用户角色](user_role.md) — 数据科学家,专注可观测性/日志记录
- [测试规范](feedback_testing.md) — 集成测试必须用真实数据库
- [认证重写](project_auth_rewrite.md) — 法务合规驱动
- [外部系统](reference_external.md) — pipeline bug 在 Linear 项目追踪
200 行 / 25KB 硬上限。 超过直接截断,注入截断警告。MEMORY.md 不存记忆正文,只存指针。
每次对话开始,loadMemoryPrompt() 把 MEMORY.md 注入 system prompt。这意味着不在索引里的记忆,模型看不到。 这是迫使记忆系统保持精炼的强约束。
层级 2:独立 .md 文件 — 记忆正文(按需加载)
四个类型(定义在 memoryTypes.ts),每个文件带 YAML frontmatter:
---
name: 记忆名称
description: 单行描述(用于相关性筛选)
type: feedback
---
规则:集成测试必须使用真实数据库,不能用 mock
Why: 之前 mock 和生产不一致导致迁移失败
How to apply: 涉及数据库逻辑的测试都走真实 DB
Why 和 How to apply 是精妙的写入策略设计。 不只是记事实,还记”为什么它是对的”和”什么时候适用”。这允许未来模型判断边界情况——“这条规则在什么新场景下可能不适用?“
层级 3:CLAUDE.md — 项目宪法
MEMORY.md(动态,自动更新) vs CLAUDE.md(静态,手动维护)
会话中发现的增量知识 项目永远成立的事实
高频写入 低频/从不修改
AI 自动维护 人工维护
区别不是技术上的,是写入权责上的。AI 可以自由更新 MEMORY.md,但 CLAUDE.md 是人工维护的”项目宪法”,AI 不能擅自修改。
层级 4:KAIROS 模式下的 logs/(原始日志)
助手模式(KAIROS)下,记忆不直接写入 MEMORY.md,而是追加到 logs/YYYY/MM/YYYY-MM-DD.md。等 /dream 定期蒸馏。
普通模式: 对话 → extractMemories → 直接更新 MEMORY.md
KAIROS 模式: 对话 → 追加 logs/ → /dream → 蒸馏为结构化记忆
这解决了长时间运行 Agent 的记忆问题:实时写入可能写错(信息还不完整),先日志后蒸馏(等积累了足够上下文再做判断)。
写入策略:Claude Code 的三层门控
自动提取(extractMemories):每轮对话后后台运行
完美分叉(共享 system prompt + 消息前缀)
→ 最多 5 轮 LLM 调用
→ 第 1 轮:并行读现有记忆文件
→ 第 2 轮:并行写新记忆/更新旧记忆
5 轮硬限制是成本和质量之间的权衡。更多轮收益递减。
写入前问的三个问题(内嵌在 memoryTypes.ts 的提示词里):
-
会影响未来决策吗? → 代码模式不记(可从代码实时推导),Git 历史不记(权威来源是 git log)
-
是事实还是推测? → 纠正和确认都要记。“只记纠正会让你变得过于保守”
-
是否有冲突信息? → 信任记忆在写入时是准确的,但使用前必须验证
使用前的验证(TRUSTING_RECALL_SECTION,在 system prompt 里强制):
“记忆中说某文件存在 → 先检查文件是否存在。 记忆中说某函数存在 → 先 grep 搜索。 记忆是快照,不是当前状态。“
定期整理:autoDream
// 触发条件(三重门控)
if (
isAutoDreamEnabled() // 功能开关
&& hoursSinceLastConsolidation >= 24 // 时间门
&& newSessionsSinceLast >= 5 // 会话数门
&& tryAcquireConsolidationLock() // 互斥锁
) {
runConsolidation();
}
四阶段整理:
- Orient — 读 MEMORY.md,了解有什么
- Gather — 找每日日志、漂移的记忆、JSONL 会话记录(不穷举)
- Consolidate — 合并近似项,删除被推翻的事实,相对日期转绝对日期
- Prune — MEMORY.md 保持在 200 行以内,删除过期指针
面试加分:向量数据库不是银弹
Claude Code 选择了纯文件系统方案。为什么?
Vector DB 方案:
写入:embedding → 向量库
查询:query → embedding → 相似度搜索
需要:一个向量数据库 + embedding 服务 + 存储
复杂度:你不再需要一个 vector DB,你需要的是一个分布式系统
文件系统方案:
写入:保存 .md 文件
查询:grep + LLM 判断相关性
需要:文件系统
复杂度:零(已有的基础设施)
Claude Code 的答案是:Agent 的记忆不需要语义搜索,需要结构化索引。 MEMORY.md 的指针系统 + grep 搜索,在绝大多数场景下比向量检索更可靠(精确匹配 > 近似搜索)且更便宜(文件读写在本地,不需要 API 调用)。
整个记忆系统的设计哲学
索引导向(MEMORY.md 200 行)
↕ 按需加载(使用前 grep + 读文件)
↕ 自动提取(extractMemories,每轮后台)
↕ 定期整理(autoDream,24h / 5 会话)
没有复杂的架构,就是用 Markdown 文件 + 时间门控 + LLM 自我纠错,支撑了跨会话的持久记忆。复杂度的节制本身就是一个答案。
总结:Claude Code 源码给这五个概念的答案
| 概念 | Claude Code 给出的答案 |
|---|---|
| Agent vs Chat | isReadOnly + 四层权限链 + Coordinator 剥夺写权限 |
| ReAct | AsyncGenerator + 并行工具执行 + 14 缓存失效向量 |
| Tool Calling | 40+ 工具 + ToolSearch 按需加载 + assembleToolPool 缓存优化 |
| MCP | 内置工具和 MCP 工具在 Agent 眼中无区别 |
| Memory | 文件系统索引 + 200 行上限 + 三重门控写入 + autoDream 整理 |
版本:v1.0 / 2026-05-26 — 基于 Claude Code 泄露源码重构 核心参考:src/memdir/, services/api/claude.ts, Tool.ts, QueryEngine.ts, utils/fingerprint.ts