任何一步失败(工具不存在、参数不合法、权限拒绝),都会返回is_error: true的错误结果。
这套流水线设计让我想起我们之前做的 API 网关。请求进来也是这么一层层过滤——认证、限流、参数校验、路由、执行、响应封装。每一层职责单一,出问题好定位。
权限系统——用户始终在掌控
权限检查是流水线里最关键的一环。工具按危险程度分了四个等级:
权限配置有个层级优先级:
这意味着公司可以设策略禁止某些操作,用户没法绕过。用户可以设默认偏好,项目可以进一步定制。
每个权限有三种动作:
当权限是 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,
readTime
ate.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:"给我看匹配的内容"返回匹配行和上下文
默认是 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":"
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 /怎么办?