如何构建一个生产级 AI Agent CLI ------ 以 Claude Code 架构探索
📂 基于 Claude Code v2.1.88 源码分析(1884 文件 / 51 万行 TypeScript)
一、背景:Agent CLI 为什么值得研究
2026 年是 AI Coding Agent 的爆发年。这个赛道上出现了三种产品形态:IDE 插件(Cursor、Copilot)、Web 平台(Devin、OpenHands)、以及终端 CLI(Claude Code、Aider)。
其中 CLI 形态最值得架构师关注------因为它是三种形态里工程约束最苛刻的:
- 没有浏览器的富交互,只有终端的字符流
- 没有 WebSocket 的天然双向通道,要自己处理 SSE
- 没有 IDE 的文件系统抽象,要直接操作磁盘
- 用户对延迟极度敏感------终端里的 5 秒空白比网页上的 5 秒长得多
- 权限模型比 IDE 插件复杂------终端里一个
rm -rf是真的会执行
Claude Code 是 Anthropic 在这个约束集下交出的生产级答卷 。npm 包 @anthropic-ai/claude-code,v2.1.88 版本含 1884 个源文件、约 51 万行 TypeScript、40+ 内置工具、完整的 MCP 协议集成、三层上下文压缩、多 Agent 协作和 React/Ink 终端 UI。
本文的目标不是源码解读,而是从它的架构设计中提取可复用的方法论------如果你是下一个想做 Agent CLI 的人,这篇文章告诉你要解决哪些问题,以及每种问题有哪些解法。
二、整体思路:终端里的分布式系统
大多数人对 Agent CLI 的第一直觉是"一个 chat 循环 + 几个 tool 调用"。200 行代码就能跑。
但 Claude Code 的 51 万行告诉我们:一个生产级 Agent CLI 本质上是一个跑在终端里的分布式系统。它至少需要协调六个子系统:
┌───────────────────────────────────────────────────────┐
│ 入口 & UI 层 │
│ 多模式入口 (REPL/headless/print/SDK) │
│ React + Ink 组件树 (140+ 组件, 30+ 分组) │
│ 80+ 斜杠命令 │
└─────────────────────────┬─────────────────────────────┘
│
┌─────────────────────────▼─────────────────────────────┐
│ Agent 主循环 │
│ AsyncGenerator 全链路流式 │
│ while(true) { think → act → observe } │
│ 7 个不可变状态快照 + 9 种退出路径 │
└───┬──────────────┬──────────────┬─────────────────────┘
│ │ │
┌───▼───┐ ┌─────▼─────┐ ┌───▼──────────┐
│ Tool │ │ Context │ │ MCP 协议 │
│ System│ │ 压缩层 │ │ 集成层 │
│ │ │ │ │ │
│40+工具│ │3层压缩 │ │5种传输方式 │
│5步鉴权│ │compact_ │ │动态工具发现 │
│并发安全│ │boundary │ │鉴权透传 │
└───┬───┘ └─────┬─────┘ └───┬──────────┘
│ │ │
┌───▼──────────────▼──────────────▼─────────────────────┐
│ 状态 & 持久化层 │
│ AppState 全局状态树 │
│ JSONL 追加日志 (用户消息阻塞写, Assistant 异步写) │
│ --continue / --resume / --fork-session │
└───────────────────────────────────────────────────────┘
这六个子系统不是堆叠关系,而是一组彼此咬合的齿轮:
- Agent 主循环负责"推理的节奏"------每一步该不该调用 Tool、什么时候退出
- Tool System 负责"能力的边界"------能做什么、谁允许你做、并发安全吗
- Context 压缩负责"记忆的容量"------窗口满了怎么办、旧的该记住还是忘记
- MCP 负责"生态的入口"------外部工具如何进来、鉴权如何统一
- State 持久化负责"时间的连续性"------崩溃重启后能继续吗、能回到过去吗
- UI 负责"人机交互的体验"------REPL 流式渲染、权限弹窗、进度条
任何一环的缺失,都会在最坏的时候暴露出来。
三、竞品对比:三条路线,同一个问题
Agent CLI 领域目前有三条路线。它们之间的差异,本质上是对同一个问题的不同回答:
"自主性和可控性的边界画在哪里?"
3.1 IDE 插件路线:Cursor / GitHub Copilot
┌────────────────────────────────────┐
│ 人在回路,AI 辅助 │
│ │
│ User → 写代码 → AI 建议 → User 确认 │
│ │
│ 工具: 代码补全、内联建议、chat 面板 │
│ 自主性: 低(需要人工触发) │
│ 安全: 高(人拍板) │
│ 上下文: IDE 自动收集 │
└────────────────────────────────────┘
IDE 插件把边界画在**"AI 只建议,人做决策"**。优势是安全可控,代价是自主性低------AI 不能主动执行多步操作,每次 Tool 调用都需要用户确认。这让它在"修改一个函数"时体验流畅,但在"重构一个模块"时显得笨拙。
3.2 轻量开源路线:Aider
┌────────────────────────────────────┐
│ 最小内核,LLM 驱动 │
│ │
│ User → 描述需求 → LLM 输出 diff │
│ → 自动 apply → User review │
│ │
│ 工具: 文件读写、git、shell │
│ 自主性: 中(自动 apply + 人工 review)│
│ 安全: 中(git 可回滚) │
│ 上下文: 手动管理 │
└────────────────────────────────────┘
Aider 比 IDE 插件更自主------LLM 生成的 diff 自动 apply,用户事后 review。核心代码量小(几千行 Python),架构清晰透明。但它不做上下文压缩、不支持多 Agent、没有 MCP 生态、没有会话持久化。不是做不到,而是这些都不是"最小可用"的范畴。
3.3 全栈 CLI 路线:Claude Code
┌────────────────────────────────────┐
│ 自治 Agent + 可扩展生态 │
│ │
│ User → 描述目标 → Agent 自主规划 │
│ → 多轮 Tool 调用 → 完成 │
│ │
│ 工具: 40+ 内置 + MCP 外部 + 子Agent│
│ 自主性: 高(Agent 自主决策多步) │
│ 安全: 分层(5步鉴权链 + 权限模式) │
│ 上下文: 3层自动压缩 │
└────────────────────────────────────┘
Claude Code 把边界画在**"Agent 可以自主执行,但每类操作有独立的权限策略"**。它不是"全信任"或"全拒绝",而是给每一步 Tool 调用都套上可配置的决策链。代价是 51 万行代码的工程复杂度------但这不是过度设计,而是"自主性每提高一级,安全性就要多一层"的必然结果。
3.4 三条路线的本质
自主性
▲
│ ● Claude Code
│ (分层鉴权,生态扩展)
│
│ ● Aider
│ (自动apply,git回滚)
│
│ ● Cursor/Copilot
│ (人拍板)
│
└──────────────────────────────▶ 可控性
三条路线不是在比"谁更好",而是在比"谁愿意为更高的自主性付出更多的工程代价"。Aider 用 git 回滚做安全网,因此可以自动 apply。Claude Code 用 5 步鉴权链做安全网,因此可以让 Agent 自主规划执行。
核心结论:Agent CLI 的工程复杂度 = 自主性 × 安全性。提高任何一个维度,另一个维度都要同步加固。
四、展开:五个关键子系统的设计决策
4.1 Agent 主循环:AsyncGenerator + 不可变状态
Agent CLI 最核心的代码就是主循环。Claude Code 的主循环签名为 query(): AsyncGenerator ------ 每一步推理都是一个 yield,全链路流式。
┌─────────────────────────────────────────────┐
│ Agent 主循环 │
│ │
│ IDLE │
│ │ │
│ ▼ │
│ SETUP (初始化上下文、工具、权限) │
│ │ │
│ ▼ │
│ while(true) { │
│ ┌─ PRE-API (构建 message) │
│ ├─ API Call (SSE streaming) │
│ ├─ Tool Execution (到达即执行) │
│ ├─ contextCompact (超阈值自动压缩) │
│ └─ Continue or Exit (9种退出路径) │
│ state = { ...state, newFields } ◀ 不可变 │
│ } │
└─────────────────────────────────────────────┘
为什么是 AsyncGenerator 而不是回调? 因为 Agent 的每一步都可能涉及异步操作------LLM API 调用是异步的、MCP 工具调用是异步的、子 Agent 执行是异步的。回调会导致"回调地狱",事件会导致状态管理混乱。AsyncGenerator 让主循环的每一轮都是一个 await + yield 的干净交接。
为什么用不可变状态? 主循环有 7 个 continue 点,每个点都可能因为异步操作而交错执行。如果状态用可变字段 this.state.field = x,两个异步分支可能互相覆盖。不可变 state = { ...state, field: x } 保证了每个分支读到的都是自己的快照。
为什么要 9 种退出路径? 因为 Agent 的结束不只有"任务完成"一种:
| 退出原因 | 触发条件 | 处理方式 |
|---|---|---|
| completed | LLM 输出 stop_reason | 返回最终结果 |
| hook_stopped | PreToolUse Hook 返回 false | 停止执行 |
| blocking_limit | 权限被用户拒绝 | 中断并告知 |
| max_turns | 达到最大轮次 | 优雅降级 |
| prompt_too_long | 上下文超限 | 压缩后重试 |
| model_error | API 返回错误 | 重试或退出 |
| aborted | 用户中断 | 保存状态 |
设计启示:Agent 循环的复杂度不在"正常路径",而在"异常路径"。200 行 Demo 和 50 万行产品的差距,80% 在异常处理上。
4.2 工具系统:注册 != 安全,权限需要一条决策链
工具系统的设计思路可以分成两层:能力层 (注册什么工具)和安全层(谁可以调用、什么时候可以调用)。
┌────────────────────────────────────────────────────────┐
│ 工具权限决策链 │
│ │
│ ① validateInput(input) ── 输入格式校验 │
│ │ │
│ ▼ │
│ ② PreToolUse Hooks ── 前置钩子(可拦截) │
│ │ │
│ ▼ │
│ ③ Permission Rules ── 规则匹配 │
│ alwaysDeny > alwaysAllow > alwaysAsk │
│ │ │
│ ▼ │
│ ④ Interactive Prompt ── 交互式确认 │
│ │ │
│ ▼ │
│ ⑤ checkPermissions ── 最终检查 │
│ · 路径沙箱检查 │
│ · Agent 黑名单检查 │
│ · MCP 工具鉴权透传 │
└────────────────────────────────────────────────────────┘
为什么需要 5 步而不是 1 步? 因为不同场景需要不同的安全级别:
- Plan 模式:只读工具自动放行(FileRead、Grep),写入工具需确认
- Auto 模式:让 LLM 自己判断危险等级并请求确认
- Bypass 模式:跳过所有检查(仅限受信任的自动化脚本)
并发安全是工具自己的属性。 每个 Tool 声明 isConcurrencySafe() ------ FileRead 可以 10 个并发执行(读操作安全),FileEdit 必须串行(防止写冲突)。执行器根据这个标记决定调度策略。
设计启示:权限不是二进制的"允许/拒绝"。它是一个可配置的决策链------每多一层配置就多一层灵活性,但同时也多一层代码复杂度。你需要的是"刚好够用的决策链",既要防止意外的 rm -rf,又要避免每次 grep 都弹窗。
4.3 上下文压缩:不是"能不能记住",而是"该记住什么"
Agent CLI 的最大工程挑战不是 LLM 调用,而是上下文窗口管理。每轮对话都在消耗 token,一旦超出模型限制,Agent 就会"失忆"。
Claude Code 用了三层压缩策略:
┌───────────────────────────────────────────────┐
│ 上下文压缩三层策略 │
│ │
│ ① autoCompact ── 主动压缩 │
│ 触发: 当前 token 数 > (窗口 - 13K buffer) │
│ 动作: 调用 Claude 总结旧消息, │
│ 插入 compact_boundary 标记 │
│ │
│ ② snipCompact ── 清理过期标记 │
│ 触发: autoCompact 之前 │
│ 动作: 删除旧的 compact_boundary, │
│ 只保留最新的一条 │
│ │
│ ③ contextCollapse ── 被动重组 │
│ 触发: API 返回 413 (prompt_too_long) │
│ 动作: 将部分上下文持久化到 collapse store, │
│ 不在 REPL 中显示 │
└───────────────────────────────────────────────┘
compact_boundary 是三层策略的"书签"。 它是一条特殊的系统消息,标记"此处发生过压缩"。只有 boundary 之后的消息发给 API;boundary 之前的被摘要替代。如果发生多次压缩,旧的 boundary 会被 snipCompact 清理。
为什么压缩前后有一层 13K buffer? 因为压缩本身消耗 token------你需要给 LLM 足够的空间来"读旧消息 + 写摘要"。不留 buffer 的结果是:刚要压缩就超限,API 返回 413,被动重试,用户体验断崖式下跌。
设计启示:上下文管理占 Claude Code 工程复杂度的 30% 以上。这不是 LLM 的问题,而是"人机对话天然会变长"的问题。你的 Agent 如果不做压缩,迟早会遇到 413------不是在开发时,而是在用户用到第 100 轮的时候。
4.4 多 Agent 协作:隔离是安全的前提
Claude Code 支持多 Agent,但不是"让多个 Agent 共享一套状态"------而是三种隔离级别,越危险越隔离。
┌──────────────────────────────────────────────────────┐
│ 子 Agent 三种隔离模式 │
│ │
│ fork (子进程) │ Agent B 有独立进程 │
│ · 独立消息历史 │ · 共享文件磁盘缓存 │
│ · 权限降级 │ · 无 AppState 写入 │
│ │ │
│ remote (Bridge) │ Agent B 在远端执行 │
│ · 完全隔离 │ · 通过网络通信 │
│ · 独立权限模型 │ · 独立文件系统 │
│ │ │
│ in-process (ALS) │ Agent B 同进程不同上下文 │
│ · AsyncLocalStorage │ · setAppState 改为 no-op │
│ · 工具池过滤 │ · 禁止危险工具 │
└──────────────────────────────────────────────────────┘
子 Agent 的两个安全机制:
-
权限降级 :子 Agent 的
setAppState是一个 no-op。它不能修改主 Agent 的全局状态。只有setAppStateForTasks能写入根 Store------且这个函数受额外的权限检查。 -
工具池过滤 :子 Agent 拿到的工具列表是主 Agent 的子集。危险工具(如
rm -rf、sudo)被移除。
Swarm 团队模式 更进一步:Leader Agent + 多个 Teammate,共享一个 Task Board。Task Board 是 Agent 之间唯一的通信媒介------没有直接的 sendMessage(agentB, "hello"),只能通过 Board 认领任务和提交结果。这种"通过共享数据结构通信"的模式比"直接对话"更容易审计和回滚。
设计启示:多 Agent 协作的难点不是通信协议,而是隔离。增加的每一个 Agent 都是一个"不可信的外部实体"------即使它跑在同一个进程里。权限降级和工具池过滤不是在过度设计,而是在防止"子 Agent 污染主会话"。
4.5 状态持久化:不是存下来就行,是怎么存才能不丢也不慢
Agent CLI 的会话可能持续数小时、数十轮对话。如果崩溃后一切归零,用户体验会从"好用的工具"变成"不敢用的工具"。
Claude Code 的持久化方案是 JSONL 追加日志 + 差异写入策略:
┌──────────────────────────────────────────────────────┐
│ JSONL 会话持久化 │
│ │
│ 存储路径: ~/.claude/projects/<hash>/ │
│ sessions/<session-id>.jsonl │
│ │
│ 格式: 每行一个 JSON 对象 │
│ {"type":"user","message":{"role":"user",...}} │
│ {"type":"assistant","message":{"role":"assistant"...│
│ {"type":"system","subtype":"compact_boundary",...} │
│ │
│ 写入策略 (差异化的): │
│ · user 消息: 同步写入 (block, 防崩溃丢数据) │
│ · assistant: 异步写入 (fire-and-forget, 性能) │
│ · progress: 内联去重 (不追加重复行) │
└──────────────────────────────────────────────────────┘
为什么 JSONL 而不是 SQLite? 因为 Agent 的会话是"追加为主"的日志------每轮对话追加一行,几乎不需要 UPDATE 或 DELETE。JSONL 的追加性能是 O(1),SQLite 的 B-tree 写是 O(log n)。对于"追加"这个操作,JSONL 是最快的。
为什么用差异写入策略? 因为不同类型的消息对可靠性和延迟的要求不同:
- 用户消息丢失 = 用户白打了,必须可靠(同步写入)
- Assistant 消息丢失 = 下次 --resume 时重跑一轮,可以容忍(异步写入)
- Progress 消息重复 = 界面抖动,需要去重(内联合并)
三种会话恢复模式:
| 命令 | 效果 |
|---|---|
--continue |
恢复最近的会话 |
--resume <id> |
恢复指定会话 |
--fork-session |
克隆会话历史,新 ID 继续 |
设计启示:持久化的复杂度不在"存什么",而在"怎么存才能同时满足可靠性和性能"。同步 + 异步 + 去重的三层策略,是对不同类型消息的"差异 QoS 保障"。
五、总结:生产级 Agent CLI 的五个必要条件
从 Claude Code 的架构往回看,一个生产级 Agent CLI 至少要解决五个问题。不是"最好有",而是"必须有"。
① 流式全链路
API SSE → AsyncGenerator → Tool Executor → Ink UI
│ │ │ │
└────────────┴───────────────┴──────────────┘
每个环节都在流式传递,Tool 到达即执行
如果 API 是流式的但 Tool 执行是批量的,你只省了"等 LLM 输出"的时间,没省"等 Tool 执行"的时间。Claude Code 的 Tool 是到达即执行------LLM 输出第一个 Tool call 的瞬间就开始执行,不需要等所有 Tool call 都输出完。
② 安全分层,可配置不绑架
"要么全信任,要么全拒绝"不是安全模型,是偷懒。Agent 的危险程度是连续的------grep 比 rm -rf 安全得多,应该有不同的确认策略。5 步鉴权链的价值不在于"5 步",而在于每一步都可以独立配置。
③ 上下文管理是核心,不是附加功能
不要等到窗口满了再想怎么办。autoCompact + snipCompact + contextCollapse 三层策略是"预防 + 治疗 + 抢救"。13K token buffer 是"手术安全空间"------不让压缩本身消耗的 token 撑爆窗口。
④ 状态可恢复,崩溃无感
--continue 不是锦上添花,是用户的信任锚点。如果一次崩溃就回到原点,没人敢在重要项目上用 Agent。JSONL + 差异写入策略,用极低的工程成本换取了极高的用户信任。
⑤ 可扩展但不失控
MCP 协议让外部工具进入 Agent 的工具池。但每进入一个新工具,就多一个安全风险。Claude Code 的做法是:MCP 工具走同一套鉴权链,子 Agent 权限自动降级。扩展性和安全性不是矛盾的------只要在架构设计阶段就考虑二者的咬合关系。
最后
Claude Code 的 51 万行代码不是一次性堆出来的。它经历了从"一个 chat 循环"到"一个终端里的分布式系统"的演化。这个演化过程中,每一步的驱动力都是同一个问题:
"用户会怎么用错这个功能?"
正是因为每次都先问这个问题,才催生了 5 步鉴权链、三层上下文压缩、不可变状态快照、子 Agent 权限降级、差异写入策略------这些在 Demo 阶段看起来"过度设计"的东西,在生产阶段恰好是"保底设计"。
生产级不是"功能更多"。生产级是"每个功能被用错的可能性,都有一条防御路径"。