工具描述就是给模型的API契约——Agent工具设计的原则:fail-closed默认值、结果预算与工具提示词

上次研究完 Agent Loop,手痒照着思路用 Python 写了个小 agent 练手,给它接了几个查文档、读文件的工具。

循环很快就跑起来了,结果问题全出在工具上。描述写得随意,模型就乱传参数;有个工具报错被我 try/except 吞掉,返回了一个空字符串,模型就一本正经地编了个结果出来。

这让我意识到,工具才是 agent 里最容易被轻视、又最容易出事的地方。于是回头去看 Claude Code 是怎么设计工具的。

一、工具是模型的双手

模型本身只会输出文本。它想读一个文件、跑一条命令,只能"说"出来,由工具去真正执行,再把结果翻译回文本交给模型。

简单的说,工具负责把模型在文本空间里的意图,翻译成真实世界的副作用。

这里有一个很关键的视角转变。我们写 Java 接口时,接口文档是写给调用方看的,调用方读完文档,知道怎么传参、会返回什么、什么情况会抛异常。

工具也一样,只不过调用方换成了模型。模型对工具的全部认知,就是你写的名字、描述和参数 schema。所以说,工具描述就是给模型的 API 契约。描述写得含糊,就跟接口文档写得含糊一样,调用方只能靠猜。

Claude Code 内置了 40 多个工具,每一个都遵循同一套契约。我把其中最值得借鉴的设计归成了三块:安全的默认值、结果的预算、工具的提示词。

二、默认值要 fail-closed

Claude Code 里所有工具都通过一个 buildTool() 来创建,它会先铺一层默认值,再用工具自己的定义覆盖:

// 示意:默认值是最保守的那一档
const TOOL_DEFAULTS = {
  isConcurrencySafe: () => false, // 默认不能并发
  isReadOnly: () => false, // 默认会写入
  // ……
}

export function buildTool(def) {
  return { ...TOOL_DEFAULTS, ...def }
}

可以看到,一个新工具如果什么都不声明,就会被当成"会写入、不能并发、需要权限"的工具。

这就是 fail-closed:安全属性从"选择加入"变成了"选择退出"。工具作者忘了声明,结果是这个工具跑得慢一点、多弹一次确认,而不是一个危险的写操作被当成只读放过去了。即使犯错,也是一个安全的错误。

还有一个细节,这些属性不是写死的布尔值,而是根据具体输入来判断:

// 示意:Bash 只有在命令是只读的时候,才允许并发
isConcurrencySafe(input) {
  return this.isReadOnly(input)
},
isReadOnly(input) {
  return checkReadOnlyConstraints(input.command).behavior === 'allow'
},

同一个 Bash 工具,git status 可以和别的命令一起并发跑,git push 就不行。用一个全局的布尔值来描述"Bash 安不安全",本身就是错的。

有了这些属性,执行层就可以做调度了:模型一次发出好几个工具调用时,相邻的、并发安全的调用合成一批并行执行,遇到不安全的就单独成批串行。并发判断如果解析失败或者抛了异常,一律按不安全处理。

三、结果要有预算

工具返回的东西是要原样塞进上下文的。一个 cat 打出几万行日志,或者一次搜索命中几千个文件,上下文就直接被撑爆了。

Claude Code 给工具结果设了两层预算:

  1. 单个工具的上限:每个工具声明自己最多返回多少字符,超出了就把完整内容写到磁盘,只给模型返回一段预览,加上文件路径和总大小;
  2. 单条消息的上限:一轮里好几个工具并行跑,结果加起来也有一个总上限,超了就从最大的那个结果开始落盘。

单工具上限防的是单点失控,单消息上限防的是并行调用一起把上下文撑爆。

有意思的是,各个工具的上限并不一样。比如搜索工具 Grep 的上限反而比 Bash 小。原因是,搜索结果太多,说明搜索范围太宽了,这时候与其把所有结果塞给模型,不如只给一段预览,逼模型去调整搜索策略。

再回头看我那个返回空字符串的 bug。Claude Code 里有一条专门的规则:工具结果为空时,要填一段占位文本,比如 (Bash completed with no output)。因为空的工具结果会被一些模型误以为是回合结束的信号,接着就开始胡说八道。

我那个工具错就错在两处:一是把异常吞掉了,模型根本不知道出了错;二是返回了空,模型就只能自己脑补。正确的做法是,失败了就明明白白地告诉模型失败了,以及为什么失败。

四、工具提示词是一个微型驾驭器

前面说的是工具的"代码",这一节说工具的"文案"。

Claude Code 里每个工具的描述都写得很长,远不止一句功能说明,里面有正面引导、负面禁令、条件分支,甚至有格式模板。系统提示词定下全局的人格和规则,工具提示词塑造每一个局部动作,可以把它看作一个微型驾驭器(micro-harness)。

我从里面挑了几条最有用的原则。

1. 流量导向,双向闭环

Bash 是一个万能工具,什么都能干,模型很容易用它去 grep、cat、find。但专用工具有结构化的输出和更细的权限控制,而 Bash 命令只是一个不透明的字符串。

所以 Bash 的描述一开头就写着:不要用这个工具去跑 find、grep、cat,然后给出一张"用 X,不要用 y"的映射表。

光这样还不够。Grep 那边也写了一句:搜索永远用 Grep,不要在 Bash 里调用 grep 或 rg。

一边说"别用我做这件事",另一边说"这件事归我",两边都约束到了,这才叫闭环。只写一边,就会留下漏洞。

2. 先讲理由,再下禁令

上面 Grep 那句禁令后面跟着理由:Grep 已经处理好了权限、忽略规则和版本控制目录的排除。

只下禁令不讲理由,模型在遇到规则没覆盖的情况时,就不知道该怎么判断。讲清楚了为什么,它才能举一反三。

3. 前置条件:声明加强制

编辑文件的工具描述里写着:编辑之前必须先用 Read 读过这个文件,否则这个工具会报错。

而运行时也真的会检查,没读过就报错。

提示词里声明,是为了让模型一开始就按规矩来,少浪费一次调用;运行时强制,是为了兜底,防止模型基于幻觉中的文件内容去改代码。提示词层防止浪费,运行时层防止出错,两层缺一不可。

4. 安全的默认值,加一个逃生舱口

搜索工具有一个 head_limit 参数,默认只返回前 250 条,传 0 表示不限制,后面再补一句"谨慎使用"。

默认值保守,保证大多数情况下不会撑爆上下文;同时留一个显式的口子,真需要全量结果时模型也有办法拿到。

5. 能力声明要和运行时一致

如果当前环境不支持读 PDF,那么描述里关于 PDF 的整段说明就压根不出现。

承诺了运行时兑现不了的能力,模型就会反复尝试、反复失败。

五、回头改我的 demo

按照上面这些原则,我把那个查文档的工具改了一遍。改之前是这样的:

@tool
def search_docs(q: str) -> str:
    """搜索文档"""
    try:
        return "\n".join(doc_index.search(q))
    except Exception:
        return ""

改之后:

@tool
def search_docs(query: str, limit: int = 20) -> str:
    """在内部文档库中按关键词搜索,返回匹配的标题和片段。

    - 查找文档内容时总是使用本工具,不要用 read_file 逐个打开文件去找。
    - query 用 2~5 个关键词,不要传整句话,整句话的召回效果很差。
    - 默认最多返回 20 条;确实需要更多时可以调大 limit,但结果过多通常说明关键词太宽泛,应该先换关键词。
    """
    try:
        hits = doc_index.search(query, limit=limit)
    except IndexNotReadyError as e:
        # 失败要明确告诉模型,并说明原因和下一步
        return f"搜索失败:文档索引尚未就绪({e}),请稍后重试,不要自行编造结果。"
    if not hits:
        # 空结果也要给占位文本
        return f"没有找到与「{query}」相关的文档,可以尝试更换关键词。"
    return "\n".join(hits)

可以看到,改动主要有三处:

  1. 描述里写清楚了什么时候用、参数怎么传,并且和 read_file 划清了边界;
  2. 给 limit 一个保守的默认值,同时说明了什么时候该调大、调大之前先想想什么;
  3. 失败和空结果都返回了明确的文本,而不是吞掉异常、返回空。

改完再跑,乱传参和编结果的情况基本就没再出现了。

总结

  1. 工具描述就是给模型的 API 契约,模型对工具的全部认知都来自你写的描述和 schema;
  2. 安全相关的属性默认取最保守的值,并且按具体输入来判断,而不是一个全局布尔值;
  3. 工具结果要有单工具和单消息两层预算,超出时落盘并给预览,空结果也要填占位文本;
  4. 工具提示词要双向闭环、先讲理由再下禁令,前置条件既在提示词里声明,也在运行时强制;
  5. 失败要明明白白地告诉模型,不要吞掉异常,否则模型只能自己脑补。

正好最近公司也开始琢磨 Agent 相关的东西了,这些原则应该很快就能派上用场。

相关资料