Skip to content

第 4 章 工具设计原则

工具是 Agent 和世界交互的接口。工具设计得好不好,直接决定 Agent 听不听话。这一章讲我在工具上栽过的三个跟头。

开篇:Agent 学会了存记忆,却不会改记忆

我做的那个 Agent 有个记忆功能。它能记住群里聊过的事——谁说了什么、约了什么活动、花了多少钱。存的时候没问题,检索的时候也没问题,但有一个奇怪的现象:它只会存新记忆,不会更新已有的记忆。

比如群里先约了"周六去香山",后来改主意了说"改成周日吧"。按我的设计,Agent 应该用 supersedes_id 参数——新建一条"改成周日"的记忆,指向原来"周六"那条,表示替代关系。但 Agent 压根不用这个参数。每次都是直接新建一条全新的记忆,不管之前有没有相关的。结果记忆库里"周六去香山"和"周日去香山"并存着,检索的时候两条都出来,Agent 自己都搞不清以哪条为准。

我一开始以为是提示词没写清楚,于是在系统提示里加规则:"修改或取消已有信息时,用 supersedes_id 创建新记忆指向原记忆。"写了规则,它还是不用。

折腾了一阵子没什么进展,我跟自己说了句实话:这方面我没有经验,也没有方法论。

一个契机:让 CC 替我去查资料

换作以前,遇到这种情况我会自己开浏览器,搜"LLM tool design best practices"之类的关键词,翻几篇英文博客,自己消化总结。但那天凌晨一点多,我换了个做法——我直接对 Claude Code 说:我需要你帮我多调研一些相关的理论和工程实践,有说法把这个叫 harness,驾驭工程。

十来分钟后,结果就回来了。CC 派了子智能体出去,发了十几次联网搜索请求,关键词直奔 Anthropic、OpenAI 的工程博客——"Anthropic Claude tool use guidelines""agent error handling self-correction"之类的。搜回来一大堆资料,消化整理成了一份结构化的研究报告。

我翻那份报告的时候,有一段话直接击中了我。是 Anthropic 的一篇叫《Writing Tools for Agents》的文章里的实测数据:在工具定义里加上具体的用法示例,模型的调用准确率能从 72% 提升到 90%。

紧接着那句话还有一个判断,原文大意是——工具描述不是文档,是 prompt 的一部分。

看到这句话,我脑子里那个困扰了我好几天的问题,突然有了线索。

回到代码:supersedes_id 到底卡在哪

报告里那句"工具描述是 prompt 的一部分"让我回去重新看自己的代码。我仔细看了 supersedes_id 这个参数的描述,发现了一个之前一直忽略的问题。

描述里写的是:"要更新/替代的记忆 ID"

就这几个字。问题是——模型从哪知道记忆的 ID 是多少?

记忆 ID 只在 memory_search 的返回结果里才有。但 memory_search 的工具描述里,压根没提"返回结果会带编号"。supersedes_id 的描述也没写"从 memory_search 结果里取编号"。两个工具的描述各写各的,中间那条隐含的依赖关系——"想更新记忆,得先搜索拿到编号,再用编号做替代"——一个字都没提。

模型又不傻,它会看描述。描述里没说 supersedes_id 的值从哪来,它自然不知道该怎么填这个参数。它不是"不想用",是"不知道怎么用"。

这就是 CC 调研回来后,报告里那张"理论 × 我的代码"对照表帮我看到的东西。问题不在模型,在我的工具描述里漏掉了关键的上下文。

我把 supersedes_id 的描述改成了:"要更新/替代的记忆 ID(从 memory_search 结果中的 #编号)。例: supersedes_id=3"。同时在 memory_search 的返回结果末尾加了一句提示:"如需修改某条记忆,用 memory_save 的 supersedes_id 参数指定其编号(如 supersedes_id=1)。"——两个工具的描述两头对齐,把那条隐含的依赖关系显式写了出来。

改完再跑,Agent 开始用 supersedes_id 了。改期、取消、纠正费用,都走替代逻辑,记忆库里不再并存矛盾的信息了。

那个 72% 到 90% 的数据不是别人家的故事,它真真切切地发生在我的代码里。补上几句描述和示例,解决了一个提示词怎么调都没搞定的问题。

这个发现改变了我对工具描述的看法。以前我觉得工具描述是"写给开发者看的文档",写清楚这个工具干嘛就行。现在我知道了——工具描述是写给模型看的 prompt 的一部分。你写给开发者的文档可以惜字如金,因为开发者会去看代码、去读上下文。但模型只能看到你给它的那段文字,这段文字就是它全部的信息来源。漏掉一句"编号从哪来",对开发者来说不算事,对模型来说就是一道跨不过去的坎。

说句题外话:左脚踩右脚

那天凌晨的经历,后来成了我固定的一个工作习惯。再遇到不懂的领域、拿不准的问题,我就让 CC 去做联网调研——搜业界怎么做的、整理成报告、映射到我的代码上。我自己管这叫程序员的"左脚踩右脚上天大法"。

什么意思呢?以前你遇到新技术、新模式,得自己开浏览器 Google,翻十几篇英文资料,自己提炼总结,这个过程又慢又累。现在你把这个"检索 + 总结"的步骤外包给了 CC。CC 帮你把资料找齐、消化好、甚至直接对照你的代码指出问题在哪。你相当于踩着 CC 这个"左脚",让自己这个"右脚"够到了本来够不到的地方。

说白了就是:遇到问题,我不会,但我可以让会查的 CC 帮我学会。 这跟以前程序员遇到问题去 Google 没有本质区别,只不过那一步从"我自己来"变成了"CC 替我来",效率高了不止一个量级。

这个方法一旦跑通,我就离不开了。后来每次遇到新话题,我都跟 CC 说"还是老规矩,帮我调研一下"。这个"老规矩",就是那天凌晨一点多养成的。

原则一

工具描述就是 prompt engineering。每个工具都要有:一句话说明做什么,加上至少一个具体的用法示例。像写提示词一样写工具描述,别像写 API 文档那样写。

错误信息要"递出下一把钥匙"

第二个跟头栽在错误信息上。这也是 CC 那次调研带给我的收获之一。

我的 Agent 有一堆工具——读文件、跑命令、发请求之类的。工具调用失败是常有的事,比如文件路径写错了、命令超时了。问题在于,失败的时候我返回给模型的错误信息长什么样呢?

一句话:

参数错误。

就这三个字。模型看到"参数错误",能怎么办?它不知道哪个参数错了,不知道正确格式是什么,也不知道该怎么改。它只能做两件事:原样重试(大概率还是错),或者放弃。

我后来翻代码才发现这个问题。满屏幕的"参数错误",十几处,所有工具失败都返回这一句。这是我早期图省事写的,反正错误信息嘛,写一句占个位就行。

CC 调研回来的那份报告里,专门有一节讲错误信息。大意是:好的错误信息能让模型自我修正,差的错误信息只能让模型重试或放弃

这句话点醒了我。错误信息不是写给人看的栈追踪,是写给模型看的下一步指令。模型读完错误信息之后,应该知道"哦,我哪错了,我该怎么改"。

我花了半天时间,把所有"参数错误"重写了一遍。原则是三条:说清原因,给出当前输入或可用选项,指明下一步动作

举几个改完之后的例子:

  • 文件不存在:文件不存在: "xxx.txt"。请确认路径是否正确,可以运行 ls 查看目录内容。
  • 文件太大:文件过大(30000 字节,上限 8000)。建议用 head 或 tail 读取部分内容。
  • 命令超时:命令执行超时(30 秒限制)。建议简化命令或分步执行。
  • 项目不存在:项目不存在(编号: 5)。请先用 project_query 查看当前项目列表。

你发现规律了吗?每一条错误信息都在"递出下一把钥匙"。找不到文件,告诉你去 ls 看看;文件太大,告诉你用 head 或 tail;项目不存在,告诉你去查列表。模型拿到这种错误信息,立刻就知道下一步该干什么,不用瞎猜。

改完之后,Agent 的一个明显变化是:它很少在同一个错误上卡死了。以前碰到"参数错误"会反复重试,现在碰到"文件不存在"会主动去 ls 一下、换条路径再试。它学会了从错误里自我修正。

这件事教会我一个原则:

原则二

错误信息必须可操作。每一条错误信息都要包含:原因 + 当前输入或可用选项 + 下一步动作建议。判断标准很简单——模型读完这条错误,能不能立刻知道下一步干什么。

别替模型做决定:截断的故事

第三个跟头比较隐蔽,我甚至一度没意识到它是问题。

我的 Agent 工具会返回各种结果——文件内容、命令输出、搜索结果。这些结果有长有短,有些特别长。早期我没想太多,给所有工具结果加了一个截断:超过 200 字符的,砍掉,只留前 200 个字

这个截断是怎么来的呢?我当时写了一个工具函数,既用来打印日志,也用来把结果塞回给模型。同一个函数,截断逻辑也只有一处:超过 200 就截断。我觉得 200 字符打印日志够了,那么给模型看也够了吧?

这是个想当然的判断,而且错了很久才发现。

问题是怎么暴露的呢?有一天我调一个比较复杂的 bug——Agent 调了工具,工具返回了一个错误,但错误信息比较长,超过 200 字符被截断了。Agent 看到的是被砍掉一半的错误信息,完全看不懂,自然没法自我修正,就在那儿反复重试同一个注定失败的操作。

我去查为什么,翻到工具返回值,才看到完整的错误信息其实写得很清楚——"原因 + 建议"都有。但模型永远看不到这后半段,因为被我的截断砍掉了。

我截掉的不是字符,是模型自我修正的能力。

这件事让我重新想了想截断这件事。截断本身没错——你不能让一个工具结果无限制地撑爆上下文。但截断的策略有问题:

  • 错误信息不应该被截断。错误信息往往包含模型修正行为所必需的关键信息,砍掉就等于把模型关在门外。
  • 正常结果可以截断,但要告诉模型截断了。"这是前 8000 字符,完整结果共 30000 字符"——模型知道了,可以想办法分段读。
  • 关键信息(比如记忆编号、错误原因)永远不能被截断

我后来把截断阈值从 200 提到了 8000,而且让正常结果在截断时带上"截断了多少"的提示。错误信息则完全不走截断那条路。

但说实话,最让我印象深刻的不是这些具体改动,而是这件事背后的那个认知——harness 里的每一个"硬编码兜底",都在替模型做一个决定。我截断到 200,本质上是替模型决定了"200 字符够你看的了"。但模型需不需要更多信息,应该由它根据具体情况判断,不是我提前替它定死。

原则三

harness 里的每个硬编码参数(截断长度、循环次数、超时时间),都在替模型做一个假设。设这些值之前想清楚:我是不是在替模型做一个本该它自己做的决定?如果是,能不能把这个决定交还给它?

三条原则背后的共性

这三条原则——描述加示例、错误要可操作、别替模型做决定——看起来讲的是不同的事,但它们背后有一个共同的认知:

工具不是给人用的 API,是给模型用的接口。

这个认知转变,对传统程序员来说特别重要。我们习惯了"API 是给人调用的"这套思维——文档写得简略点没关系,开发者会去看源码;错误码抽象点没关系,开发者会去查文档;返回值精简点没关系,开发者会自己去拼装。这些假设在 Agent 时代全部失效了,因为调你工具的不是人,是模型。模型看不懂你的源码,查不了你的文档,它只有你给它的那段描述和那次返回值。

所以工具设计的每一条原则,本质上都是在回答同一个问题:怎么让模型在只拿到有限信息的情况下,做出正确的决策。描述加示例,是让它正确理解工具的用途;错误要可操作,是让它在失败时能自我修正;别替它做决定,是让它有足够的信息空间。

这一章的工具:工具设计检查清单

🔧 工具设计检查清单

设计或评审一个 Agent 工具时,逐条过一遍:

描述

  • [ ] 描述里有没有具体的用法示例?(不是抽象规则,是真实输入输出)
  • [ ] 描述里说清楚了"什么时候用"吗?有没有说"什么时候不用"?
  • [ ] 参数级别的描述够不够精确?("URL" vs "完整 URL,必须包含 https://")

返回值

  • [ ] 返回值是给模型看的,还是给开发者看的?(返回 UUID 就是给开发者看的,返回自然语言摘要才是给模型看的)
  • [ ] 返回值里有没有模型下一步决策需要的信息?
  • [ ] 返回值会不会太长?太长的话有没有截断提示?

错误处理

  • [ ] 错误信息是不是只有一句"出错了"或"参数错误"?
  • [ ] 错误信息有没有"递出下一把钥匙"?(原因 + 选项 + 下一步)
  • [ ] 错误信息会不会被截断逻辑砍掉?

硬编码参数

  • [ ] 截断阈值设了多少?为什么是这个值?
  • [ ] 错误信息走不走截断?(不应该走)
  • [ ] 有没有哪些值是"替模型做了决定"的?能不能交还给它?

一句话标准 闭上眼睛想一下:如果模型只能看到这个工具的描述和返回值,它能正确使用这个工具吗?如果不能,缺什么?

小结

这一章的三条原则,是我在工具设计上踩出来的。每一条背后都有一个具体的 bug——"今天天气不错"被存记忆、模型对着"参数错误"反复重试、截断把错误信息砍了一半。

如果只能记住一句话,记这句:工具是给模型用的,不是给人用的。你写工具描述的时候,脑子里想象的是一个看不见你源码、查不了你文档、只能靠你给它的文字来理解世界的模型。你给它的每一段文字、每一个返回值、每一条错误信息,都是它决策的全部依据。

下一章,我们把视角从单个工具拉远到整个执行循环——工具准备好了,Agent 该按什么流程去调用它们。

下一章

第 5 章 · 循环设计 —— 保持循环简单,复杂度下沉。为什么所有成熟 Agent 的核心循环都出奇地简单。