返回顶部
热门问答 更多热门问答
技术文章 更多技术文章

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

[复制链接]
链载Ai 显示全部楼层 发表于 3 天前 |阅读模式 打印 上一主题 下一主题

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

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

exportfunctionregisterAllTools():void{
// Bash 工具家族
toolRegistry.register(newBashTool());
toolRegistry.register(newKillShellTool());

// 文件工具三剑客
toolRegistry.register(newReadTool());
toolRegistry.register(newWriteTool());
toolRegistry.register(newEditTool());
// ...
}

就这?没有 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-devnpm 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 查结果。

跨平台适配这块:

functiongetPlatformShell():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:

classFileReadTracker{
privatereadFiles:Map<string,FileReadRecord> =newMap();

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

hasBeenRead(filePath:string):boolean{
returnthis.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 工具会自动检测并剥离行号:

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

functionstripLineNumberPrefixes(str:string):string{
returnstr.split('\n').map(line=>{
constmatch = line.match(LINE_NUMBER_PREFIX_PATTERN);
returnmatch ? 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_domainsblocked_domains参数让你聚焦可信来源或排除噪音。只想要官方文档的结果?可以强制。


用户交互工具

AskUserQuestion——当模型需要输入时

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

{
"name":"AskUserQuestion",
"input":{
"questions":[{
"question":"Which database should we use?",
"header":"Database",
"options":[
{"label":"ostgreSQL (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 细节,让状态栏感觉更有生命力。


工具接口

所有工具实现标准接口:

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

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

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

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


设计原则

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

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

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

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

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


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

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

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


回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

链载AI是专业的生成式人工智能教程平台。提供Stable Diffusion、Midjourney AI绘画教程,Suno AI音乐生成指南,以及Runway、Pika等AI视频制作与动画生成实战案例。从提示词编写到参数调整,手把手助您从入门到精通。
  • 官方手机版

  • 微信公众号

  • 商务合作

  • Powered by Discuz! X3.5 | Copyright © 2025-2025. | 链载Ai
  • 桂ICP备2024021734号 | 营业执照 | |广西笔趣文化传媒有限公司|| QQ