【译文】从零开始两天构建一个 Claude Code:AI CLI 每一层的技术拆解
来源:@icebearminer(龙猫/小八)Twitter/X 帖子
原文链接:https://x.com/icebearminer/status/2037888800341610684
产出:46 个文件,一万行 TypeScript,零框架依赖,唯一外部依赖 fast-glob
背景动机
前两天突发奇想:一个生产级的 agentic CLI 到底需要哪些组件?每一层的具体怎么实现?
- SSE 缓冲区怎么管理?
- System prompt 怎么分段?
- 工具权限怎么拦截?
- 上下文满了怎么压缩?
这些问题靠读文档回答不了,靠逆向混淆代码效率极低。所以选择了另一条路:以 Claude Code 为参照系,从零重建一个功能等价的实现。
一、核心架构:最小循环
在开始写任何代码之前,先要在脑子里跑通一个最小循环:
1 | 用户输入 → 组装请求 → API 调用(SSE)→ 解析响应事件流 → 根据 stop_reason 决定分支 |
stop_reason 分支逻辑:
end_turn— 输出文本,结束本轮tool_use— 执行工具调用 → 将 tool_result 追加到 messages → 反复迭代
二、目录结构:六层单向依赖
1 | core/ — 引擎层,包含 Agent Loop、SSE 客户端、context 管理、compact 逻辑 |
关键约束:六层之间的依赖是单向的,core/ 不依赖 ui/,tools/ 不依赖 skills/。
三、技术选型:Node.js 22 原生能力
| 需求 | 解决方案 |
|---|---|
| HTTP 客户端 | fetch(原生) |
| 流式响应 | ReadableStream(原生) |
| UTF-8 字节流 | TextDecoder(原生) |
| 二进制数据 | Buffer(原生) |
| Shell 执行 | child_process.exec(原生) |
| 文件匹配 | fast-glob(唯一外部依赖) |
fast-glob 唯一的外部依赖,原因:原生 glob 在跨平台路径处理上有已知缺陷,且 fast-glob 在处理 .gitignore 规则和大型目录的性能上比原生实现好一个数量级。
四、多模型兼容:格式适配层
问题:部分本地模型(如 Ollama、LM Studio)暴露 OpenAI 兼容接口,但事件格式与其他格式不同。
解决方案:在 SSE 客户端初始化时传入 format: 'openai' 参数,在事件解析层做格式适配,将 OpenAI 的 delta 结构翻译成统一的内部事件类型。Agent Loop 层完全不感知 API 格式差异。
五、System Prompt 缓存优化(核心设计)
5.1 问题:单字符串方式的缺陷
最直觉的 system prompt 写法是一个大字符串,在生产环境有两个显著缺陷:
- 缓存无法有效命中:每轮对话 system prompt 几乎不变,但以单字符串传入,API 缓存无法按需匹配
- 污染动态内容:部分内容(当前目录、Git 状态、CLAUDE.md)每轮都变化,与静态内容混在一起会污染缓存
5.2 解决:block 数组 + cache_control
将 system 参数设为 block 数组,每个 block 可以独立设置 cache_control,按可变性分为两类:
静态段(打上 cache_control,首次写入缓存,后续命中):
- 身份声明(参考龙虾的 User、Soul)
- 工具使用规范(何时用 Bash vs 读文件、何时拒绝执行)
- 编码风格(规范、注释原则)
- 安全执行规则(禁止执行的命令类型)
💡 这就是官方订阅为什么调用 API 能省不少钱——缓存命中率高。
动态段(不带 cache_control,每轮重新计算):
- 当前工作目录和系统环境(每次启动可能不同)
- Git 仓库状态(git status 输出,每轮可能变化)
- CLAUDE.md 内容(用户可随时修改)
- MCP 服务器的自定义指令(运行时发现)
block 数组顺序固定:身份 → 工具指南 → 编码规范 → 安全规则 → 风格指南 → 环境信息 → Git 上下文 → CLAUDE.md → MCP 指令。
Anthropic 的 prompt caching 按照 block 数组的前缀匹配来识别缓存,排在前面的静态内容越稳定,缓存命中率越高。
六、Agent Loop:有上限的迭代循环
骨架:一个有上限的 while 循环,最大迭代次数 25 次。
25 轮足够完成大多数真实任务(读文件、分析、修改、验证通常在 10 轮内完成),同时防止 runaway loop 耗尽 API 额度。
每次迭代的流程:
- 检查是否需要 compact(上下文压缩)
- 构建完整 prompt
- 发起流式请求
- 实时处理事件流
- 检查 stop_reason — tool_use 则执行工具,end_turn 或 max_tokens 则结束循环
- 构建 tool_result message,追加到 messages 数组,进入下一轮
七、工具执行管线:六个阶段
工具执行是 Agent Loop 中最复杂的部分,共六个阶段:
1 | 1. renderToolCall — 在终端展示将要执行的工具名和参数 |
八、上下文压缩:自动 Compact 机制
在每轮迭代开始时,估算当前 messages 数组的 token 数(总字符数除以 4),如果超过模型上下文限制的 85%,触发压缩:
- 发起一次独立的 API 调用生成摘要
- 用
[{role: 'user', content: summary}, {role: 'assistant', content: 'Understood.'}]替换原来的 messages 数组
这点跟 Claude Code 是一样的。
九、Prompt Caching 的三个施力点
| 层级 | 内容 | 缓存策略 |
|---|---|---|
| System Prompt Blocks | 静态段(身份、工具规范等) | 静态块走缓存 |
| Tools 数组 | 工具定义 | 最后一个 tool definition 打上 cache_control |
| 工具结果 | 最后一条 tool_result message | 作为缓存断点 |
三层叠加的实际效果:每轮 API 调用只有少量 tokens 是真正的输入计费,大部分走缓存价格(约为正常输入价格的 10%)。
中转站的缓存命中率一般都不会很高,但这个三层设计本身是值得借鉴的。
十、工具系统:JSON Schema 定义 + 单一分发入口
入口:TOOL_DEFINITIONS,一个 JSON Schema 数组,描述每个工具的名称、用途和参数结构。模型通过这个数组”知道”有哪些工具可以调用以及如何调用。
JSON Schema 能保证输出格式,LLM 的 JSON 输出也更容易阅读。
执行入口:单一的 executeTool 函数,内部用 switch 按工具名分发。MCP 工具通过 mcp__ 前缀识别,走独立的调用路径。
10.1 核心内置工具
| 工具 | 实现要点 |
|---|---|
| Read | fs.readFile 读取后加行号前缀,支持 offset 和 limit 分页 |
| Write | 写入前 fs.mkdir({ recursive: true }) 确保父目录存在 |
| Edit | 精确字符串替换,old_string 必须在文件中唯一出现,多次出现则报错 |
| Bash | child_process.exec 执行,120 秒超时,输出超 500 行时截断(保留前 200 + 后 100) |
| Grep | 自实现 regex 引擎,支持三种输出模式、上下文行、跨行匹配,不依赖系统 grep |
| WebFetch / WebSearch | fetch + HTML 剥离 + 截断,搜索走 DuckDuckGo |
10.2 Deferred Tools:性能优化
低频工具(如 NotebookRead、TodoWrite)不放入每次请求的 tools 数组,而是标记为 deferred——模型需要时通过 ToolSearch 工具按关键词查询获取完整 schema,然后在下一轮调用。
效果:将 tools 数组的固定开销降低了约 40%。
十一、权限系统:安全与体验的平衡
11.1 三种运行模式
| 模式 | 行为 |
|---|---|
| default | safe 类工具自动执行,dangerous 和 write 类需要用户确认 |
| auto | 绕过所有交互式提示,适合 CI——但 deny rules 仍然生效,底线不可越过 |
| plan | 只读沙箱,safe 工具放行,dangerous 和 write 工具被静默拒绝,可先看执行计划 |
11.2 工具分类(注册时静态声明)
- safe — Read, Glob, Grep, WebFetch, WebSearch 等
- dangerous — Bash, Agent(副作用范围不可预测)
- write — Write, Edit(文件修改是最常见的需要审计的操作)
- bypass — PlanMode 切换,始终无需确认
11.3 两阶段分类器(增强 auto 模式安全性)
Stage 1 — 纯模式匹配(零延迟,覆盖 90%+ 情况):维护已知安全命令和已知危险命令的规则表,命中即返回。
Stage 2 — Haiku 模型判断:处理未覆盖的模糊操作,将命令字符串发给 Haiku 模型,要求返回 allow / deny / ask_user 三种之一。Haiku 延迟约 300-500ms,相比模型主循环几乎可以忽略。
十二、MCP(Model Context Protocol):标准化工具发现
MCP 本质上是一个标准化的工具发现协议。传统做法是把工具硬编码在 CLI 里,MCP 让工具变成可以独立部署的进程,通过统一接口被任何兼容客户端发现和调用。
协议层实现:
- 传输:JSON-RPC 2.0 over stdio,启动 MCP server 进程,通过 stdin/stdout 交换 JSON-RPC 消息
- 启动序列固定:
initialize握手 →tools/list获取工具定义数组 - 格式兼容:工具定义包含 JSON Schema 格式的 inputSchema,与 Anthropic API 的工具格式直接兼容
MCP 管理:
- McpManager:管理多个 server 的生命周期
- 命名空间隔离:工具名加上
mcp__server_name__前缀
十三、LSP 集成:实时代码诊断
LSP 解决的问题:不是扩展工具,而是给模型提供实时的代码诊断信息。
实现方式:
- LspClient:实现 Language Server Protocol 客户端侧,使用 Content-Length 帧协议通信
- LspManager:维护文件扩展名到语言服务器的路由表
- 自动通知:Write/Edit 执行后自动通知对应语言服务器,诊断结果作为
lsp_diagnosticssection 注入下一轮 system prompt
这个设计让模型在修改代码后能立即看到编译器反馈,而不需要单独的”检查错误”工具调用,缩短了发现问题到修复问题的路径。
十四、插件系统:manifest 驱动的扩展
每个插件是一个目录,包含 plugin.json,声明六类扩展点:
| 扩展点 | 说明 |
|---|---|
| skills | 注入可调用的技能 |
| agents | 注入自定义 Agent 定义 |
| hooks | 前置/后置钩子 |
| commands | 自定义斜杠命令 |
| mcpServers | 注入 MCP 服务器,启动时自动连接 |
| lspServers | 注入 LSP 服务器 |
Skill 查找优先级:内置 → 插件 → 项目 .clio/skills/
项目级 skill 可以覆盖插件级,但不能覆盖内置,防止安全敏感的内置 skill 被意外替换。
十五、Skills 技能库:轻量复用单元
Skill 是比自定义 Agent 更轻量的复用单元——本质上是参数化的 prompt 模板。
8 个内置 Skill
| Skill | 功能 |
|---|---|
| commit / pr / review | Git 操作类,读取 diff 后生成规范化消息或 PR 描述 |
| init | 扫描项目结构,生成配置文件 |
| simplify | 审查变更代码,查找重复/低效逻辑,直接修复 |
| loop / schedule | 自动化类,持续执行或定时触发 |
| update-config | 修改 CLI 自身配置 |
十六、多 Agent 协作:团队协调协议
子 Agent(Sub-Agent)
通过 executeSubAgent() 运行,是主 agent loop 的简化版本:
- 排除团队管理工具(防止无限嵌套)
- 最多 15 轮迭代
- 隔离模式(可选):
isolation: "worktree"创建 Git worktree
后台 Agent
通过 run_in_background: true 标记,Promise 存入 Map,完成时以 tool_result 形式通知主 Agent。
完整协作流程
1 | 主 Agent 调用 TeamCreate 创建团队 |
这三个工具构成了一个最小化的多 Agent 协调协议,没有消息队列,没有共享状态,只有显式的消息传递。
十七、Auto Memory:用文件系统模拟持久记忆
问题:LLM 本身是无状态的。每次对话从空白上下文开始,之前建立的偏好、项目约定、用户反馈全部消失。
解决:Auto Memory 用文件系统模拟持久记忆,不引入数据库,不设计新的存储格式。
记忆文件类型
| 类型 | 内容 |
|---|---|
| user | 用户偏好(角色、习惯、专长) |
| feedback | 行为纠正(用户确认或否定的做法) |
| project | 项目约定(不可从代码/git 推导的上下文) |
| reference | 外部资源指针(URL、看板、文档链接) |
MEMORY.md 是索引文件,每次对话开始时注入 system prompt,模型按需通过 Read 工具读取具体内容。
生命周期:首次运行创建索引 → 后续对话自动加载 → 过时记忆由模型主动更新或删除。
记忆的写入完全复用现有工具链(Write/Edit/Read),零新增代码路径,工具权限模型自动适用。
十八、总结:Harness Engineering 是核心难点
整个项目验证了一个认知:Claude Code 的工程质量确实高于平均水平。
分段 prompt 的三层缓存设计——静态系统提示缓存、工具定义缓存、动态内容不缓存——这个粒度的 cache 意识在大多数 LLM 应用里是缺失的。
核心结论:想构建 agent 工具/产品,最重要的一条结论是:核心难点在于 Harness Engineering。
毕竟调用 API 是十行代码的事。只有把工具调用结果正确地反馈给模型、在流式输出中间插入用户交互、处理长任务里的错误恢复——这些才是真正的工程壁垒。