【译文】从零开始两天构建一个 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
2
3
4
5
6
core/       — 引擎层,包含 Agent Loop、SSE 客户端、context 管理、compact 逻辑
tools/ — 工具系统,包含所有内置工具的实现和 MCP 客户端
ui/ — 终端渲染层,处理流式文本输出、进度指示、颜色主题
plugins/ — 扩展系统,允许运行时注入工具和 hook
skills/ — 技能库,对应 Claude Code 的 slash command 高级功能
commands/ — 处理 / 前缀命令的解析和分发

关键约束:六层之间的依赖是单向的,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 写法是一个大字符串,在生产环境有两个显著缺陷:

  1. 缓存无法有效命中:每轮对话 system prompt 几乎不变,但以单字符串传入,API 缓存无法按需匹配
  2. 污染动态内容:部分内容(当前目录、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 额度。

每次迭代的流程:

  1. 检查是否需要 compact(上下文压缩)
  2. 构建完整 prompt
  3. 发起流式请求
  4. 实时处理事件流
  5. 检查 stop_reason — tool_use 则执行工具,end_turn 或 max_tokens 则结束循环
  6. 构建 tool_result message,追加到 messages 数组,进入下一轮

七、工具执行管线:六个阶段

工具执行是 Agent Loop 中最复杂的部分,共六个阶段:

1
2
3
4
5
6
1. renderToolCall  — 在终端展示将要执行的工具名和参数
2. permissionCheck — 根据工具类型和参数决定是否需要用户确认
3. preHook — 插件系统的前置拦截点
4. checkpoint — 对于破坏性操作,在执行前快照相关文件状态
5. executeTool — 调用实际工具函数
6. postHook — 插件后置钩子

八、上下文压缩:自动 Compact 机制

在每轮迭代开始时,估算当前 messages 数组的 token 数(总字符数除以 4),如果超过模型上下文限制的 85%,触发压缩:

  1. 发起一次独立的 API 调用生成摘要
  2. [{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_diagnostics section 注入下一轮 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
2
3
4
5
6
7
8
9
主 Agent 调用 TeamCreate 创建团队

SendMessage 分发任务

等待完成

汇总结果

TeamDelete 释放资源

这三个工具构成了一个最小化的多 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 是十行代码的事。只有把工具调用结果正确地反馈给模型、在流式输出中间插入用户交互、处理长任务里的错误恢复——这些才是真正的工程壁垒。


相关资源