免费POC, 零成本试错
AI知识库

53AI知识库

学习大模型的前沿技术与行业应用场景


我要投稿

Claude Code 源码揭秘:为什么不造 100 个工具?一个 Bash 打天下的哲学

发布日期:2026-01-12 22:38:12 浏览次数: 1527
作者:与AI同行之路

微信搜一搜,关注“与AI同行之路”

推荐语

Claude Code 用极简工具集实现强大功能,背后是"让模型直接生成Shell命令"的哲学思考。

核心内容:
1. Claude Code 工具系统的极简设计:仅核心工具+Bash
2. 不造专用工具的原因与跨平台适配难题
3. Bash工具的强大实现与七步执行流水线

杨芳贤
53AI创始人/腾讯云(TVP)最具价值专家

 

↑阅读之前记得关注+星标⭐️,😄,每天才能第一时间接收到更新

「Claude Code 源码揭秘」系列的第三篇,上一篇聊了 Claude Code 的消息。但光有消息不够,得有工具才能干活。

今天来拆工具系统。这么能打的 Agent,工具应该很多吧?毕竟功能那么丰富,文件操作、代码搜索、网络请求、进程管理...

结果打开 src/tools/index.ts 一看,核心工具就那么几个:

export function registerAllTools(): void {
  // Bash 工具家族

  toolRegistry.register(new BashTool());
  toolRegistry.register(new KillShellTool());

  // 文件工具三剑客

  toolRegistry.register(new ReadTool());
  toolRegistry.register(new WriteTool());
  toolRegistry.register(new EditTool());
  // ...

}

就这?没有 GitTool、没有 NpmTool、没有 DockerTool... 你想跑 git status?用 Bash。想 npm install?用 Bash。想 grep 搜代码?还是用 Bash。

我一开始觉得这是偷懒。后来想了想,不对,这是聪明。

说到这个我想起之前在金融科技那个项目里,我们也想过造一堆专用工具。当时团队里有个小伙子特别有热情,说咱们搞一个 GitTool 吧,把常用的 git 操作都封装一下,多方便。我说行,你先调研一下。

结果他搞了两周,来找我说搞不下去了。为啥?光是跨平台适配就把他整崩溃了。Windows 上 Git 可能装在不同位置,PATH 配置五花八门。macOS 可能用 Xcode Command Line Tools 的 Git,也可能是 Homebrew 装的。Linux 各发行版又不一样。然后 git 命令的参数组合几百种,你是穷举还是开放?穷举不现实,开放的话还不如直接让用户敲命令。

Claude Code 的做法就是想明白了这一点:不造专用工具,让模型直接生成 Shell 命令。模型见过的 Shell 命令比任何人写的工具都全。它知道 git log --oneline -10 是啥意思,知道 npm install --save-dev 和 npm install -D 等价。你造 100 个工具,也覆盖不了模型脑子里的知识。

当然,不造专用工具不代表 Bash 工具本身简单。src/tools/bash.ts 有 1089 行代码,处理的事情相当多。


工具执行的七步流水线

在深入具体工具之前,先说说每个工具调用是怎么跑的。模型发出 tool_use 请求后,不是直接执行,而是要过一道流水线:

任何一步失败(工具不存在、参数不合法、权限拒绝),都会返回 is_error: true 的错误结果。

这套流水线设计让我想起我们之前做的 API 网关。请求进来也是这么一层层过滤——认证、限流、参数校验、路由、执行、响应封装。每一层职责单一,出问题好定位。


权限系统——用户始终在掌控

权限检查是流水线里最关键的一环。工具按危险程度分了四个等级:

级别
工具示例
风险
read
Read, Glob, Grep
只读,最安全
write
Edit, Write
修改文件
execute
Bash
执行任意命令
network
WebFetch
访问网络

权限配置有个层级优先级:


这意味着公司可以设策略禁止某些操作,用户没法绕过。用户可以设默认偏好,项目可以进一步定制。

每个权限有三种动作:

  • • Allow:直接执行,不问
  • • Ask:弹窗问用户
  • • Deny:直接拒绝

当权限是 Ask 的时候,CLI 会显示提示:

四个选项:单次允许、本次会话都允许、单次拒绝、本次会话都拒绝。用户始终知道模型要干什么,始终有否决权。

这个设计我很认可。我们之前做过一个自动化运维平台,一开始为了"智能",很多操作都自动执行。结果出过一次事故——脚本自动清理了不该清的日志。后来改成关键操作必须人工确认,虽然麻烦了点,但安全了很多。


Bash 工具——原力与约束

Bash 是最强大也最危险的工具。它能做用户 Shell 能做的一切。

{
  "name"
: "Bash",
  "input"
: {
    "command"
: "npm test",
    "description"
: "Run test suite",
    "timeout"
: 60000
  }
}

几个关键设计:

输出截断:测试套件、构建日志可能输出几兆内容。全塞进上下文窗口既浪费又混乱。Bash 工具默认截断到 30000 字符,截断时会提示,模型可以按需获取特定部分。

超时控制:默认 2 分钟,最长 10 分钟。大多数命令很快完成,2 分钟能抓住跑飞的进程。真正需要长时间的操作(比如完整构建)可以显式指定更长超时。

description 参数:强制模型用人话说明要干什么。这个会显示在 UI 里,方便用户理解和审批。也是个"三思而后行"的机制——模型得先想清楚再动手。

后台模式:有些操作要跑很久。run_in_background 参数让命令在后台执行,立即返回任务 ID。模型可以继续干别的,稍后用 TaskOutput 查结果。

跨平台适配这块:

function getPlatformShell(): ShellConfig {
  if
 (IS_WINDOWS) {
    return
 { shell: 'powershell.exe', args: [...], isPowerShell: true }
  }
  return
 { shell: process.env.SHELL || '/bin/bash', ... }
}

Windows 用 PowerShell,WSL 要特殊处理,Unix 系统用用户的默认 Shell。我之前做运维平台的时候也踩过这个坑,Windows 的命令行兼容性问题能把人搞疯。Claude Code 在这块处理得挺细的。


文件工具三剑客

读、写、改分成了三个独立的工具——Read、Write、Edit。

你可能会问,为什么不搞一个 FileTool 什么操作都能干?

答案是:分开才能加约束

这个设计让我想起我们之前做云文档系统的时候,也有类似的争论。当时有人说咱们搞一个统一的 DocumentAPI,增删改查都走这一个入口,多简洁。但后来发现不行,因为你没法在统一接口上加细粒度的权限控制。比如我想要求"修改文档之前必须先获取锁",如果增删改查都在一个接口里,这个约束就很难加。

Read——上下文的基础

{
  "name"
: "Read",
  "input"
: {
    "file_path"
: "/project/src/index.ts",
    "offset"
: 1,
    "limit"
: 100
  }
}

输出用 cat -n 风格,每行带行号:

  1    import { App } from './app';
  2
  3    const app = new App();

行号不只是给人看的。模型后面用 Edit 工具的时候,需要引用准确位置。行号创建了读和改之间的共享坐标系。

limit 参数强制模型有选择性地读取,而不是把整个大文件塞进上下文。offset 支持分页浏览长文件。

Claude 是多模态的。Read 工具可以加载图片、处理 PDF(逐页提取文本和视觉内容)。这让 Claude Code 不仅仅能处理代码。

Edit——精准优于强力

Edit 工具体现了一个关键原则:让安全的事情简单,让危险的事情困难。

{
  "name"
: "Edit",
  "input"
: {
    "file_path"
: "/project/package.json",
    "old_string"
: "\"version\": \"1.0.0\"",
    "new_string"
: "\"version\": \"1.1.0\""
  }
}

早期版本允许基于行号或模式匹配来编辑。问题是:模型读文件和改文件之间,代码可能变了。行号移位了,模式匹配到了错误位置。要求精确、唯一的字符串匹配,文件变了就安全失败,而不是改错地方。

Claude Code 的思路一样。Edit 工具有个硬性前提:必须先读过这个文件才能改

怎么实现的呢?有个全局单例叫 FileReadTracker:

class FileReadTracker {
  private
 readFiles: Map<string, FileReadRecord> = new Map();

  markAsRead
(filePath: string, mtime: number): void {
    this
.readFiles.set(path.resolve(filePath), {
      path
: filePath,
      readTime
: Date.now(),
      mtime  // 记录文件当时的修改时间
    });
  }

  hasBeenRead
(filePath: string): boolean {
    return
 this.readFiles.has(path.resolve(filePath));
  }
}

Read 工具读文件的时候,会调用 markAsRead 记录下来。Edit 工具改文件之前,会检查 hasBeenRead,没读过就直接报错。而且它还记录了文件的修改时间 mtime,如果你读完之后文件被别人改了,Edit 也会拒绝执行,让你重新读一遍。

这套机制防止的是什么?防止模型盲目编辑。你想想,如果模型可以不看文件就直接改,它会根据自己的"记忆"来改。但模型的记忆可能是过时的,可能是训练数据里别的项目的,可能是它自己编的。强制先读再改,模型必须看到文件的真实内容,才能做出正确的修改决策。

replace_all 逃生舱:有时候你真的需要全局重命名变量。replace_all: true 绕过唯一性检查,但必须显式指定。模型必须主动选择更危险的操作。

还有个细节特别有意思,就是 Edit 工具的智能匹配。

你用 Read 工具看文件,输出是带行号的:

  42    foo()
  43    bar()
  44    }

用户可能直接复制这段作为 old_string 去做替换。正常来说,文件里根本没有   42\t 这个前缀,匹配应该失败对吧?

但 Edit 工具会自动检测并剥离行号:

const LINE_NUMBER_PREFIX_PATTERN = /^(\s*\d+)\t/;

function
 stripLineNumberPrefixes(str: string): string {
  return
 str.split('\n').map(line => {
    const
 match = line.match(LINE_NUMBER_PREFIX_PATTERN);
    return
 match ? line.substring(match[0].length) : line;
  }).join('\n');
}

还有智能引号的问题。你从某些文档或网页复制代码,普通引号可能被自动转成弯引号 ""。Edit 工具也会处理这个,把弯引号转回普通引号再匹配。

这些细节用户基本感知不到,但缺了就会很痛苦。我之前做过一个内部的代码评审工具,就没处理这些边界情况,结果用户天天来问"为什么我复制的代码匹配不上"。后来加了类似的容错处理,投诉一下子就少了。

Write——核武器选项

Write 创建文件或完全覆盖现有文件。

{
  "name"
: "Write",
  "input"
: {
    "file_path"
: "/project/src/utils.ts",
    "content"
: "export function add(a: number, b: number) {\n  return a + b;\n}\n"
  }
}

系统提示词指示模型在覆盖前先读文件。这不只是礼貌——确保模型看到了它要销毁的内容。工具本身没法强制这一点(它不知道消息历史里有什么),但指令创建了行为护栏。

Write 工具自动创建目录,不用模型每次都先 mkdir -p。减少正常操作的摩擦。


代码搜索工具

Glob——按名字找文件

知道要什么类型的文件,但不知道在哪?

{
  "name"
: "Glob",
  "input"
: {
    "pattern"
: "src/**/*.tsx",
    "path"
: "/project"
  }
}

结果按修改时间排序,最近编辑的在前面。你问"我刚才改的那个组件",排在前面的文件更可能是答案。这个小优化让模型的猜测更准确。

为什么不用 find 命令?Glob 更快,跨平台。用优化过的库实现,而不是调系统命令。大代码库里这个差别很明显。

Grep——搜文件内容

Grep 搜索文件内部,基于 ripgrep 构建,大代码库也很快。

{
  "name"
: "Grep",
  "input"
: {
    "pattern"
: "TODO:",
    "path"
: "src/",
    "output_mode"
: "content",
    "-C"
: 2
  }
}

三种输出模式:

  • • files_with_matches(默认):"哪些文件包含这个?"只返回路径,最省 token
  • • content:"给我看匹配的内容"返回匹配行和上下文
  • • count:"有多普遍?"返回每个文件的匹配数

默认是 files_with_matches 因为最省 token。模型可以后续针对性地读取。

直接暴露 ripgrep 的参数:-A-B-C 上下文参数,大小写敏感,行号等。开发者本来就熟悉这些,不用发明新词汇,工具说的语言和开发者日常用的一样。

LSP——语义理解

Grep 搜文本,LSP 理解代码结构。

{
  "name"
: "LSP",
  "input"
: {
    "operation"
: "goToDefinition",
    "filePath"
: "/project/src/index.ts",
    "line"
: 10,
    "character"
: 15
  }
}

正则能找文本,但没法回答"这个函数定义在哪?"或"什么调用了这个方法?"。LSP 操作提供语义导航:跳转定义、查找引用、显示类型信息。这就是 IDE 的工作方式,Claude Code 也能接入同样的基础设施。

局限:LSP 需要该文件类型配置了运行中的语言服务器。没有的话工具返回错误。能用的时候很强,但不是普遍可用。


Task——委派给子代理

复杂任务可以委派给专门的子代理。

{
  "name"
: "Task",
  "input"
: {
    "description"
: "Find auth code",
    "prompt"
: "Search for authentication-related code and explain the login flow",
    "subagent_type"
: "Explore"
  }
}

有些任务需要大量探索,会让主对话变得杂乱。子代理在隔离环境里工作,完成后返回摘要。主对话保持专注,子代理在几十个文件里挖掘。

专门的代理类型存在是因为不同任务需要不同方法:

  • • Explore:快速、浅层搜索,适合"找 X 在哪定义"
  • • Plan:深度思考架构,适合"设计怎么实现 Y"
  • • code-reviewer:专注发现问题,适合"审查这个 PR"
  • • security-audit:专门找漏洞,适合"检查安全问题"

resume 参数:子代理可以带着之前的上下文恢复。探索被中断或需要继续,不用从头开始。


Web 工具

WebFetch——把外部世界带进来

有时候答案不在代码库里。

{
  "name"
: "WebFetch",
  "input"
: {
    "url"
: "https://docs.example.com/api",
    "prompt"
: "Extract the authentication endpoints"
  }
}

原始 HTML 冗长,充满导航、广告、样板。prompt 参数告诉一个更小更快的模型提取什么。结果是聚焦的摘要,而不是一堆标签。

15 分钟缓存:重复抓取同一个 URL 浪费时间和带宽。缓存确保模型在一个会话里多次引用同一页面时,只抓一次。

WebSearch——发现

不知道 URL?搜索。

{
  "name"
: "WebSearch",
  "input"
: {
    "query"
: "React useEffect cleanup pattern"
  }
}

域名过滤:allowed_domains 和 blocked_domains 参数让你聚焦可信来源或排除噪音。只想要官方文档的结果?可以强制。


用户交互工具

AskUserQuestion——当模型需要输入时

不是所有事都能自动化。有时候模型需要问。

{
  "name"
: "AskUserQuestion",
  "input"
: {
    "questions"
: [{
      "question"
: "Which database should we use?",
      "header"
: "Database",
      "options"
: [
        { "label": "PostgreSQL (Recommended)", "description": "Full-featured relational DB" },
        { "label": "SQLite", "description": "Lightweight, file-based" }
      ],
      "multiSelect"
: false
    }]
  }
}

选项比打字快(点一下 vs 打字),对模型来说也更容易解释。"Other" 逃生舱始终存在,以防选项都不合适。

header 限制 12 字符,因为 UI 把它显示为紧凑的标签。强制简短让界面可扫描。

TodoWrite——可见的进度

模型可以追踪自己的工作。

{
  "name"
: "TodoWrite",
  "input"
: {
    "todos"
: [
      { "content": "Read existing code", "status": "completed", "activeForm": "Reading code" },
      { "content": "Implement feature", "status": "in_progress", "activeForm": "Implementing" },
      { "content": "Write tests", "status": "pending", "activeForm": "Writing tests" }
    ]
  }
}

为什么同时只能有一个 in_progress?这个约束强制顺序专注。模型必须完成或放弃当前任务才能开始另一个。防止待办列表变成一堆半成品。

activeForm 字段:任务的进行时形式,在进行中时显示("Implementing feature" 而不是 "Implement feature")。小 UX 细节,让状态栏感觉更有生命力。


工具接口

所有工具实现标准接口:

interface Tool {
  name
: string;           // 唯一标识
  description
: string;    // 给模型理解的描述
  inputSchema
: ZodSchema; // 输入校验
  permissionLevel
: "read" | "write" | "execute" | "network";

  call
(input: ToolInput, context: ToolContext): Promise<ToolOutput>;
  mapToolResultToToolResultBlockParam
(result: ToolOutput): string;
}

这个接口确保所有工具的一致性,无论是内置的还是通过 MCP 提供的。

新增一个工具很简单:继承基类,实现业务逻辑,注册到工具表里,完事。


设计原则

纵观所有这些工具,模式浮现出来:

安全失败。Edit 要求唯一字符串。Read 有行数限制。Bash 有超时。出问题时,失败模式是保守的。

Token 效率。Grep 默认返回文件路径而不是内容。Read 有限制。输出会截断。每个设计决策都考虑上下文窗口。

用户可见性。Bash 要求描述。危险操作前会提示权限。用户永远不会对模型的行为感到惊讶。

带护栏的力量。工具确实强大,但每个都有约束防止最坏结果。


翻完这部分代码,我总结一下感受。

能让模型干的事,别自己造轮子。Bash 工具就是这个哲学的体现。分离是为了加约束,Read/Write/Edit 分三个不是功能划分,是为了能在 Edit 上加"先读后改"的约束。多轮容错比一次成功更务实,智能引号、行号剥离这些处理,都是在兜用户的底。

好了,工具能跑命令、能改文件了,但你有没有想过一个问题:如果模型生成了一条 rm -rf / 怎么办?

下一篇聊聊 Claude Code 的安全防线。命令注入检测、沙箱隔离、权限拦截... 给 Agent 装安全气囊这件事,比想象中复杂得多。

本文基于 Claude Code 2.0.76 版本源码分析,主要文件:src/tools/bash.tssrc/tools/file.tssrc/tools/base.ts

最后记得⭐️我,每天都在更新:欢迎点赞转发推荐评论,别忘了关注我

 

53AI,企业落地大模型首选服务商

产品:场景落地咨询+大模型应用平台+行业解决方案

承诺:免费POC验证,效果达标后再合作。零风险落地应用大模型,已交付160+中大型企业

联系我们

售前咨询
186 6662 7370
预约演示
185 8882 0121

微信扫码

添加专属顾问

回到顶部

加载中...

扫码咨询