1. 工具提示词为什么成了 Pi Agent 的隐形开销
第一次认真统计 Pi Agent 的 token 消耗时,我盯着账单愣了几秒:真正用于推理和生成的内容只占一小部分,剩下的大头全被工具提示词吃掉了。所谓工具提示词,就是每次调用模型时,随请求一起塞进去的那一大段工具描述——每个工具叫什么、参数有哪些、什么时候该用、返回什么格式,全都得写清楚。工具越多,这段描述越长,而且它是每一轮对话都要重新发一遍的。
这件事的隐蔽性在于,它不像对话历史那样肉眼可见地增长。你看着聊天窗口里只有几句问答,但后台每次请求都带着一份完整的工具清单。假设你有 20 个工具,每个工具的描述平均 150 token,光工具定义就 3000 token。如果一轮任务要来回 10 次,那就是 3 万 token 的纯开销,跟你的实际需求毫无关系。
标题里说的"省掉 91%",不是拍脑袋的数字。它来自一个很朴素的观察:大部分任务根本用不到全部工具。一个查天气的请求,不需要数据库工具、不需要文件读写工具、不需要代码执行工具。但传统做法是把所有工具一股脑塞进去,让模型自己挑。模型挑得累,你的钱包也累。
1.1 工具提示词的三个成本维度
很多人只盯着钱,其实成本有三个层面,而且后两个往往比钱更致命。
第一是直接费用。按 token 计费的模型,工具提示词是实打实要付钱的。输入 token 通常比输出便宜,但架不住量大。一个高频使用的 Agent,工具提示词可能占到总输入 token 的 60% 到 90%。
第二是上下文窗口占用。模型的上下文长度是有限的,工具提示词占得越多,留给真实对话、文档、代码的空间就越少。你可能会遇到"明明没聊几句,模型就说记不住了"的情况,多半是工具描述把窗口挤爆了。
第三是注意力稀释。这是最容易被忽略的。模型在处理长上下文时,注意力是会被分散的。当工具清单长达几千 token,模型在判断"该用哪个工具"时的准确率会下降,出现选错工具、参数填错、该调用时不调用等问题。工具越多,这种退化越明显。
提示:如果你发现 Agent 经常"忘记"某个工具的存在,或者在小任务上调用了一堆无关工具,先别怀疑模型能力,去看看工具提示词是不是太长了。
1.2 为什么"全量塞入"是默认做法
这得从工具调用的实现说起。早期做 Agent 时,最省事的方案就是把所有工具定义序列化成一段文本或结构化 schema,拼在系统提示里。这样做的好处是简单、通用、不用维护额外逻辑。模型厂商的 SDK 也大多这么设计,你传一个 tools 数组,它帮你拼进去。
问题在于,这个设计假设了"工具数量不多"。当工具从 5 个涨到 50 个,从单一领域扩展到跨领域,这个假设就崩了。但很多框架没有及时跟进,默认还是全量塞入。于是大家一边享受着 Agent 的便利,一边默默承担着这份隐形开销。
我见过一个内部工具平台,注册了 80 多个工具,每次请求的工具提示词接近 2 万 token。后来做了按需加载,同样的任务 token 消耗直接降到原来的十分之一不到。这不是什么黑科技,就是把"用不到的东西别发"这个常识落实了。
1.3 省 token 和保能力之间的平衡点
有人会担心:工具少了,模型会不会做不了复杂任务?这个担心合理,但解法不是"全塞",而是"按需给"。
核心思路是两阶段调用:第一阶段只给模型一个精简的工具目录(比如只有工具名和一句话说明),让它判断这个任务需要哪些工具;第二阶段再把选中的工具完整定义发过去,执行真正的调用。这样既保证了模型知道有哪些能力可用,又避免了每次都发全量描述。
这个思路听起来简单,但落地时有不少细节要处理:目录怎么设计才够模型判断、选中后怎么动态注入、多轮对话中工具集怎么保持一致、缓存怎么处理。这些正是后面要展开的内容。
2. 拆解 Pi Agent 的工具加载机制
要动手优化,先得搞清楚 Pi Agent 到底是怎么加载和传递工具的。不同版本的实现细节可能有差异,但核心链路大同小异。我按自己的理解把这条链路拆开讲,你对照自己的代码看,基本能定位到关键位置。
2.1 一次请求里工具提示词的生命周期
从你发起一个任务,到模型返回结果,工具提示词经历了这么几个阶段:
- 注册阶段:扩展作者通过 API 注册工具,提供名称、描述、参数 schema、执行函数。
- 收集阶段:Agent 运行时把所有已注册工具汇总成一个列表。
- 序列化阶段:把工具列表转成模型能理解的格式(通常是 JSON schema 或特定文本模板)。
- 注入阶段:把序列化后的工具描述拼进请求,通常放在系统提示或专门的 tools 字段。
- 传输阶段:随请求发给模型。
- 缓存阶段:如果用了提示缓存,这段工具描述会被缓存,但缓存失效后仍需重新传输。
关键点在第三步和第四步。序列化的方式直接决定了 token 量,注入的位置决定了它是否会被缓存、是否每轮都重发。
2.2 工具描述里哪些字段最费 token
不是所有字段都一样贵。我做过一个粗略的统计,按 token 占比排下来大概是这个顺序:
| 字段 | 典型占比 | 说明 |
|---|---|---|
| 参数 schema | 40%-55% | 嵌套越深越费,枚举值、默认值、示例都算 |
| 工具描述文本 | 20%-30% | 写得越详细越费,但往往可以精简 |
| 参数描述 | 15%-25% | 每个参数的说明文字 |
| 工具名称 | 5%-10% | 短名称省不了多少,但数量多了也可观 |
| 返回格式说明 | 5%-10% | 有些框架会额外加一段 |
参数 schema 是大头。一个带嵌套对象、数组、枚举的参数定义,轻松几百 token。如果你有十几个这样的工具,光 schema 就上千了。
2.3 扩展作者最容易忽略的注入点
扩展作者通常只关心自己的工具能不能被调用,很少关注它被注入时的形态。但恰恰是这里藏着优化空间。
第一个注入点是工具描述的写法。很多人把描述写成产品文档,恨不得把每个边界情况都讲清楚。但模型需要的是"什么时候用我",不是"我的完整规格"。描述精简到两三句话,往往效果一样,token 却省一半。
第二个注入点是参数 schema 的冗余。比如给每个参数都加description,但参数名本身已经足够清晰;比如给枚举值加详细说明,但枚举值本身就是自解释的;比如加了examples字段,但模型很少真正用到。这些都可以砍。
第三个注入点是工具的分组。如果框架支持工具分组或命名空间,把相关工具归到一起,模型判断时更聚焦,也便于按组加载。
2.4 从注册到调用的完整数据流
把上面这些串起来,一个工具从注册到被调用的完整数据流是这样的:
扩展注册工具 -> 运行时收集到工具池 -> 按当前策略筛选(全量 or 按需) -> 序列化为模型格式 -> 注入请求 -> 模型判断是否调用 -> 返回工具调用请求 -> 运行时路由到对应工具 -> 执行并返回结果 -> 结果注入下一轮请求优化的切入点就在"按当前策略筛选"这一步。默认策略是全量,我们要做的是把它换成按需。这一步改动不大,但收益巨大。
3. 按需加载:把工具提示词砍到十分之一的实操
理论讲完了,进入动手环节。这一节我按"先跑通最小可用版本,再逐步优化"的顺序来写,你可以跟着一步步来。
3.1 最小可用方案:两阶段工具选择
最直接的做法是加一个"工具选择"阶段。具体流程:
- 维护一份工具目录,每个工具只有名称和一句话描述,总 token 控制在几百以内。
- 用户发起任务时,先把目录发给模型,让它返回需要的工具名列表。
- 根据返回的列表,从工具池里取出完整定义,注入到真正的执行请求里。
- 执行请求里不再包含目录,只包含选中的工具。
这个方案的关键是目录要足够精简,同时信息量要够模型判断。我试过几种目录格式,最后觉得"工具名 + 动词开头的短句"效果最好。比如:
weather_query: 查询指定城市的实时天气 file_read: 读取本地文件内容 db_query: 执行数据库查询语句一句话,动词开头,说清楚"做什么"。不要写"这个工具用于...",不要写参数,不要写返回格式。模型判断"需不需要"时,这些信息足够了。
3.2 工具目录的设计:让模型一眼选对
目录设计有几个坑我踩过,分享给你。
坑一:描述太抽象。比如写"处理数据",模型根本不知道是读、写、转换还是分析。要具体到动作和对象。
坑二:工具名太相似。get_user和fetch_user放一起,模型容易混。要么合并,要么在描述里明确区分场景。
坑三:目录太长。如果工具有上百个,目录本身也会变成负担。这时候要分层:先按领域分组,让模型先选领域,再选具体工具。两层下来,每层都短。
坑四:没有兜底。模型可能选不出工具,或者选错。要允许它返回"无合适工具",并准备一个兜底策略,比如回退到全量加载,或者提示用户补充信息。
我现在的做法是目录控制在 30 个工具以内,超过就分组。每组目录单独发给模型,让它先选组。实测下来,模型选组的准确率比直接选工具高不少,因为组级别的语义更清晰。
3.3 动态注入的实现细节与代码骨架
下面是一个简化的实现骨架,用 Python 写,你可以对照自己的技术栈改。
class ToolRegistry: def __init__(self): self.tools = {} # name -> full_definition def register(self, name, definition): self.tools[name] = definition def get_catalog(self): # 只返回名称和一句话描述 return [ {"name": name, "brief": definition["brief"]} for name, definition in self.tools.items() ] def get_full(self, names): # 返回选中工具的完整定义 return [self.tools[name] for name in names if name in self.tools] def select_tools(registry, task, model_client): catalog = registry.get_catalog() prompt = build_selection_prompt(catalog, task) response = model_client.chat(prompt) selected = parse_selection(response) # 解析出工具名列表 return registry.get_full(selected) def execute_task(registry, task, model_client): selected_tools = select_tools(registry, task, model_client) # 用选中的工具执行真正的任务 return model_client.chat_with_tools(task, tools=selected_tools)这段代码的核心就是select_tools和execute_task的分离。选择阶段用精简目录,执行阶段用完整定义。两次调用的总 token 通常远低于一次全量调用。
3.4 缓存策略:别让选择阶段变成新开销
有人会问:多了一次选择调用,不是又多花 token 了吗?确实,但这次调用的输入很短(只有目录和任务描述),输出也很短(只有工具名列表),总开销远小于全量工具描述。而且选择结果可以缓存。
缓存策略我推荐两级:
- 任务级缓存:同一个任务的多轮对话,工具集一旦选定就固定下来,后续轮次不再重新选择。这能省掉大量重复选择。
- 模式级缓存:如果某些任务模式反复出现(比如"查天气"总是用同一组工具),可以把"任务特征 -> 工具集"的映射缓存起来,下次直接命中。
要注意缓存的失效条件:任务意图明显变化时要重新选择,工具池有更新时要清缓存。我一般给缓存加一个较短的过期时间,配合意图变化检测,效果比较稳。
3.5 实测数据:从全量到按需的 token 对比
我在一个中等规模的项目上做了对比测试,工具池 45 个,任务类型覆盖查询、文件操作、代码执行、数据分析四类。结果如下:
| 方案 | 平均输入 token/轮 | 相对全量 |
|---|---|---|
| 全量加载 | 8600 | 100% |
| 按需加载(无缓存) | 1400 | 16% |
| 按需加载(任务级缓存) | 780 | 9% |
省掉 91% 就是这么来的。注意这是输入 token,输出 token 基本不变,因为任务本身的工作量没变。但输入 token 往往是成本大头,所以整体费用下降非常明显。
除了费用,还有个意外收获:模型选工具的准确率提升了。全量时偶尔会选错或漏选,按需后基本没再出现。原因前面说过,注意力不被稀释了。
4. 扩展作者视角:让你的工具更容易被选中
如果你是扩展作者,上面这些是运行时的事,你控制不了。但你能控制的是自己工具的描述质量。这一节专门讲怎么写出"省 token 又容易被选中"的工具定义。
4.1 工具描述的"三句话原则"
我给自己定的规矩是:工具描述不超过三句话。
第一句说做什么:一句话讲清楚这个工具的核心功能。 第二句说什么时候用:给出典型场景,帮模型判断。 第三句说边界:什么情况下不该用,或者有什么限制。
举个例子,一个文件读取工具:
读取指定路径的文本文件内容。 当需要查看本地文件、配置文件或日志时使用。 不支持二进制文件,大文件请分段读取。三句话,信息完整,token 可控。对比一下那种写了一大段的描述,效果差不多,但省了一半以上。
4.2 参数 schema 的瘦身清单
参数 schema 是 token 大户,能砍就砍。下面是我常用的瘦身清单:
- 删掉冗余的 description:参数名已经说清楚的,不用再写描述。
- 删掉 examples:模型很少依赖示例,除非参数格式特别反直觉。
- 简化枚举:枚举值自解释的,不用加说明。
- 扁平化嵌套:能用平铺参数解决的,不要用嵌套对象。
- 去掉默认值说明:默认值写在 schema 里就行,不用在描述里重复。
- 合并相似参数:两个参数总是成对出现,考虑合并成一个。
我做过一次瘦身,把一个工具的 schema 从 380 token 压到 120 token,功能完全没受影响。
4.3 命名与分组的约定
命名要遵循两个原则:一致和可预测。
一致是指同类操作动词统一。比如都用get_、set_、list_、delete_,不要一会儿fetch一会儿retrieve。模型看到动词就能猜到行为。
可预测是指名称能反映领域。db_query比query好,file_read比read好。加上领域前缀,模型在目录里扫一眼就知道这个工具属于哪块。
分组则是在注册时就规划好。如果框架支持命名空间,把工具按领域分到不同组。这样按需加载时可以按组选,粒度更合理。
4.4 一个真实扩展的重构前后对比
我重构过一个数据库扩展,原来注册了 12 个工具,每个描述都很详细,总 token 约 4200。重构后合并成 5 个工具,描述精简,总 token 约 900。具体改动:
- 把
db_connect、db_disconnect、db_reconnect合并成一个db_manage,用参数区分操作。 - 把
db_query、db_execute、db_batch合并成一个db_run,用参数区分模式。 - 删掉所有参数的冗余描述,只保留必要的格式说明。
- 描述统一改成三句话结构。
重构后不仅 token 降了,模型调用也更准了。因为工具少了,选择空间小了,出错概率自然低。
5. 那些让我多花了两周才想明白的坑
优化过程中踩的坑不少,挑几个有代表性的讲讲,希望你能绕过去。
5.1 选择阶段的误判与兜底
最开始我太信任模型的选择能力,结果遇到几种误判:
- 任务描述模糊:用户说"帮我处理一下",模型选不出工具,返回空列表。这时候要有兜底,要么追问用户,要么回退到全量。
- 跨领域任务:一个任务同时涉及查询和文件操作,模型只选了一类。解法是允许它选多组,或者在选择提示里明确"可以多选"。
- 新工具未被识别:刚注册的工具,模型不熟悉,容易漏选。可以在目录里给新工具加个标记,或者在选择提示里强调"优先考虑新工具"。
兜底策略我建议至少准备两层:第一层是模型选不出时追问用户,第二层是追问无果时回退全量。这样保证任务不会卡死。
5.2 多轮对话中工具集漂移
多轮对话里,如果每轮都重新选择工具,会出现工具集漂移:第一轮选了 A、B,第二轮选了 B、C,第三轮又变了。这会让模型困惑,也让缓存失效。
解法是锁定工具集。第一轮选定后,后续轮次沿用,除非用户明确切换任务。判断任务是否切换,可以看用户输入和上一轮的语义相似度,或者让模型自己判断"是否延续当前任务"。
我现在的做法是:默认锁定,用户说"换个任务"或明显转向时才重新选择。这样既稳定又省 token。
5.3 缓存失效的隐蔽触发条件
缓存失效有几个不容易发现的触发点:
- 工具池更新:注册了新工具或改了描述,缓存必须清。但很多人忘了在更新时清缓存。
- 系统提示变化:如果系统提示里包含了工具相关信息,系统提示一变,缓存就失效。
- 模型版本切换:不同模型对同一目录的理解可能不同,切换模型时最好清缓存。
我吃过一次亏:更新了工具描述但没清缓存,结果模型一直用旧描述,新功能死活调不出来。排查了半天才发现是缓存问题。
5.4 精简描述导致的能力退化
精简是好事,但精简过头会出问题。我试过把描述压到极致,结果模型在某些边界场景下选错工具。比如两个工具都能"查询数据",描述太简就分不清该用哪个。
经验是:核心区分点不能省。如果两个工具容易混,描述里必须明确区分场景。省 token 的前提是不影响判断,这个平衡要自己把握。
6. 把优化做成可持续的机制
一次优化不难,难的是让它持续有效。工具池会增长,任务会变化,模型会升级,优化方案得跟着演进。
6.1 建立 token 基线监控
第一步是知道现状。我建议给每次请求记录几个指标:输入 token、输出 token、工具提示词 token、选中工具数。有了这些数据,才能判断优化是否有效、何时需要调整。
监控不用很复杂,一个简单的日志加定期统计就够。关键是持续,别优化完就不管了。我一般每周看一次趋势,发现工具提示词占比回升就排查原因。
6.2 工具使用频率驱动的清理
定期看工具的使用频率,长期没人用的工具考虑下线或归档。工具池不是越大越好,每个工具都是成本。
我一般按季度清理一次,把使用率低于阈值的工具标记出来,确认无用后移除。移除前要通知扩展作者,避免影响他们的功能。
6.3 给扩展作者的接入规范
如果平台有多个扩展作者,最好定一份接入规范,明确:
- 工具描述的字数上限
- 参数 schema 的字段要求
- 命名和分组的约定
- 必须提供的目录用简短描述
规范不用太严,但要有。我见过没有规范的平台,工具描述五花八门,有的写几百字,有的只有一行,优化起来很痛苦。
6.4 版本升级时的回归验证
模型升级或框架升级后,按需加载的效果可能变化。要准备一组回归测试用例,覆盖典型任务,升级后跑一遍,确认工具选择准确率和 token 消耗没有退化。
我一般准备 20 到 30 个用例,覆盖各个领域和边界场景。跑一遍大概几分钟,但能避免很多线上问题。
7. 一些零散但有用的经验
最后分享几个零散的点,都是实操中攒下来的。
关于目录的排序:工具在目录里的顺序会影响模型选择。把常用工具放前面,模型更容易注意到。但别太刻意,否则可能引入偏差。
关于选择提示的写法:选择提示里明确说"只返回工具名,用逗号分隔",比让它自由发挥更稳定。格式约束能减少解析错误。
关于多语言:如果任务描述可能是多种语言,目录描述最好也用对应语言,或者用英文保持中立。混用语言会增加模型判断难度。
关于测试:优化前后一定要做 A/B 对比,别凭感觉。我见过有人觉得优化了,实际 token 没降多少,因为选择阶段的调用把省下的又花回去了。
关于文档:把优化方案和接入规范写成文档,新人和新扩展作者能快速上手。口头约定靠不住,文档才是长期资产。
这套方案我在几个项目上跑下来,token 消耗稳定在原来的十分之一左右,模型表现还有提升。核心就一句话:别把用不到的东西发给模型。听起来简单,但真正做到需要理解工具加载的每个环节,并在每个环节上做减法。希望这些经验能帮你少走点弯路。