下面是三个工具的调用结果草稿,每个都有问题。指出问题出在哪一类工具身上(读/写/执行/搜索/调用 API),说明为什么这样设计不合适,并给出你会怎么改。
Level 1:给工具挑返回值search_code返回:{ "content": "<50 个文件的完整源码拼在一起,共 8000 行>" }write_file返回:{ "success": true }(不带任何 diff 或旧内容信息)send_email失败时返回:{ "error": "failed" }
学习目标:
- 按后果大小把常见工具分成五类,并说出每类的典型签名
- 解释为什么执行命令类工具的风险和其他四类不在同一量级
- 说明搜索类工具为什么返回命中片段而不是整个文件
前置要求:完成第 2 课,理解一次工具调用的往返结构 | 上一课 第 2 课 << | 下一课 第 4 课 >>
这张表按什么排序?不是字母顺序,是「一次调用能造成多大范围的伤害」,也就是这次调用的爆炸半径(blast radius):只读工具的爆炸半径几乎为零——读错文件顶多是这一轮对话跑偏;写文件能覆盖已有内容;执行命令能对整个系统做任意事情。往下逐类展开,你会发现每一类除了「能干什么」之外,还各自带着一个只有这一类才会踩的坑。
读文件工具的签名通常长这样:
返回值就是文件内容本身,通常还带上行号方便模型后续引用:
读文件不改变任何状态,模型读错了、读多了,最坏结果就是这一轮对话里多了些不相关的内容,模型自己会发现读错然后重新读。这是它被称为「最安全」的原因——不是说它没有风险,而是说风险出不了这次对话的范围。
真正的风险在于读到了不该读的文件。如果 Agent 有权限读取 ~/.ssh/id_rsa 或者项目里的 .env,一次看似无害的「帮我看看这个目录下有什么」就可能把密钥内容原样搬进对话上下文——之后只要这段上下文被模型输出、被记录日志,或者被后续某个「调用外部 API」的工具带出去,泄露就已经发生了。这也是为什么读文件工具几乎总要配合路径白名单或沙箱使用,而不是「反正是只读,随便给权限」。第 5 课会具体讲这类边界怎么设。
写文件工具比读文件多一个参数、少一份安全感:
返回值通常很简单,一个状态而已:
问题不在返回值,在这次调用本身。读文件出错了,你重新读一次就好,状态没变。写文件出错了——比如模型把 path 填错,或者 content 里少复制了一半——原来那个文件的内容已经被覆盖,撤销不了,除非有版本控制或者备份。这就是「只读工具与写工具后果不对称」:两类工具的调用形状几乎一样(都是一个 path 加几个参数),但一类可以随便重试,另一类每次调用都在赌一把。
所以负责任的写工具设计会加一层保护,比如要求「编辑前必须先读过这个文件」(防止模型凭记忆瞎改),或者返回旧内容和新内容的 diff 而不只是「成功」两个字,让调用方(也就是宿主应用)有机会在真正落盘前展示改动。这些不是本课的重点,第 4 课讲接口设计时会展开。
执行命令类工具的签名看起来最朴素:
一个字符串进去,stdout、stderr、退出码出来:
问题是这个 command 字段本质上是一个开放式入口——它不是「删除某个文件」或「读取某一行」这种被 schema 约束住的具体操作,而是任意一段 shell 脚本。rm -rf、curl 把数据发到外部服务器、npm install 装一个被投毒的包,全都能装进这一个字符串里。其余四类工具(读、写、搜索、调用 API)无论签名怎么设计,能做的事都被参数结构限定死了;执行命令工具的能力边界等于整个操作系统的能力边界。这就是它在五类里独一档的原因:不是「风险更高一点」,是风险的量级完全不同。
正因为如此,官方文档才会专门为这一类工具设计操作系统级的隔离:文件系统访问和网络访问是两个独立的沙箱层,即使模型被提示注入操纵、执意要跑一条危险命令,操作系统边界也照样拦住,不依赖模型「愿不愿意」配合1。这种设计动机说得很直接——目标就是保证即便提示注入成功了,破坏也出不了沙箱2。第 5 课会具体讲这层隔离怎么配置,这里先记住一点:凡是「执行命令」这个签名出现的地方,默认把它当成五类里最需要额外约束的一类去对待。
搜索工具(比如按关键词或正则在代码库里找)的签名往往带着「限制返回量」的参数:
返回值不是文件本身,是「在哪找到的、上下文长什么样」:
如果这个工具直接把命中文件的全部内容塞回来,会出两个问题。第一个是省 token 的问题:一次搜索命中 50 个文件,每个文件几百行,全部塞进上下文,这一轮对话的输入就被这一个工具调用吃光了,后面模型还要不要继续干活3?写工具描述、控制输入输出的边界,本身就是让工具好用的基本要求之一4。第二个问题更关键:搜索的本意不是「把所有可能相关的内容都读一遍」,而是「帮模型定位它接下来该去哪继续看」。返回命中位置加一小段上下文片段,模型看完这些片段,自己判断「这几个结果里,第 2 条像是我要找的,我再单独读一下那个文件的完整内容」——这就是搜索工具和读文件工具配合工作的方式:搜索负责缩小范围,读文件负责拿到细节。返回「命中位置」而不是「整个文件」,给的正是这个可以继续跟进的线索,而不是一次性倾倒所有可能有用的内容。
前四类工具基本都在本地系统里打转,调用外部 API 这一类不一样——它要跨网络,跨一个你不掌控的服务:
正常返回是这样:
但外部服务会限流、会超时、会因为权限不够拒绝请求、会在你调用的间隙修改了自己的接口。这些不是「意外情况」,是这一类工具运行时的常态。真正决定这个工具好不好用的,不是「正常情况下返回什么」,是「失败的时候返回什么」:
这条错误信息不是给你看的,是给模型看的——模型下一步该重试还是该换个策略,取决于它能不能读懂这个 error 字段。MCP 的官方规范专门把这点写进了协议:客户端应当把工具执行过程中产生的错误反馈给模型,让模型有机会自我纠正、重新尝试5。也就是说,一个把 429 状态码直接吞掉、只返回一句「调用失败」的工具,是在剥夺模型自我修正的机会;一个把 retry_after 这种具体信息带回来的工具,才是把「失败」当成正常工作流程的一部分在设计。
调用外部 API 这一类还牵涉到另一层风险:如果这个 Agent 同时能读到私有数据、又暴露在不可信内容(比如用户粘贴的一段网页文字)、又有对外发消息或发请求的能力,这三者凑在一起,就是安全研究里常说的「致命三要素」(lethal trifecta)——攻击者不需要攻破你的系统,只需要在你会读到的内容里藏一句指令,让 Agent 自己把私有数据带出去6。这个话题第 5 课会专门展开,这里先知道:调用外部 API 是这条链路上最后也是最关键的一环,因为它是数据真正离开你系统的出口。
command 参数是开放字符串,不像其他四类那样被 schema 结构限定住能做的事12下一课,我们把这五类工具的「签名」拆开来看:一个好的工具名字、description、参数 schema、返回值该怎么写,才能让模型第一次就调对。
Configure the sandboxed Bash tool - Claude Code Docs — https://code.claude.com/docs/en/sandboxing ↩ ↩2
Making Claude Code more secure and autonomous with sandboxing - Anthropic Engineering — https://www.anthropic.com/engineering/claude-code-sandboxing ↩ ↩2
Introducing advanced tool use on the Claude Developer Platform | Anthropic Engineering — https://www.anthropic.com/engineering/advanced-tool-use ↩ ↩2
Writing effective tools for AI agents—using AI agents | Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents ↩
Tools - Model Context Protocol — https://modelcontextprotocol.io/docs/concepts/tools ↩ ↩2
The lethal trifecta for AI agents - Simon Willison's Weblog — https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/ ↩
search_code 返回:{ "content": "<50 个文件的完整源码拼在一起,共 8000 行>" }write_file 返回:{ "success": true }(不带任何 diff 或旧内容信息)send_email 失败时返回:{ "error": "failed" }这个任务里其实包含了不止一类工具。写出:
input_schema,至少包含运单号参数(接口设计的系统讲法在下一课,这里照着本课出现过的签名格式模仿即可)记下想法、痛点、没懂的地方。只写进这门课的附录,正课文件不动。
{
"name": "read_file",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "文件的绝对路径" },
"offset": { "type": "integer", "description": "从第几行开始读(可选)" },
"limit": { "type": "integer", "description": "最多读多少行(可选)" }
},
"required": ["path"]
}
}
1 export function add(a, b) {2 return a + b;3 }{
"name": "write_file",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
}
{ "success": true, "bytesWritten": 842 }
{
"name": "bash",
"input_schema": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "要执行的 shell 命令" }
},
"required": ["command"]
}
}
{
"stdout": "3 files changed, 12 insertions(+)\n",
"stderr": "",
"exit_code": 0
}
{
"name": "search_code",
"input_schema": {
"type": "object",
"properties": {
"pattern": { "type": "string" },
"path": { "type": "string", "description": "限定搜索目录(可选)" },
"max_results": { "type": "integer", "default": 20 }
},
"required": ["pattern"]
}
}
{
"matches": [
{ "file": "src/auth/login.ts", "line": 42, "snippet": " if (!user.verified) {" },
{ "file": "src/auth/session.ts", "line": 17, "snippet": "export function verifyToken(token) {" }
],
"total_matches": 2
}
{
"name": "send_slack_message",
"input_schema": {
"type": "object",
"properties": {
"channel": { "type": "string" },
"text": { "type": "string" }
},
"required": ["channel", "text"]
}
}
{ "ok": true, "ts": "1735689600.000200" }
{ "ok": false, "error": "rate_limited", "retry_after": 30 }