省掉 91% 的工具提示词:给 Pi Agent 用户和扩展作者的操作指南
最近我在给 Pi Agent 写扩展的时候算了一笔账:如果按传统方式,每个工具动辄要写八十到一百行提示词,从 JSON Schema 到自然语言描述,再到 few-shot 示例,一套下来累得够呛。而换了 Pi Agent 的自动描述机制之后,单个工具的平均提示词从 3000 多字符降到了 270 字符左右,算下来正好省掉 91% 的内容。这篇文章就把这套"怎么写工具提示词才能省力"的方法论整理出来,重点面向两类人:一是 Pi Agent 的重度用户,日常想少打几句话就让 agent 干活;二是打算写扩展的作者,想让自己的工具被用户"开箱即用",而不是附赠一篇万字说明书。
先说结论:Pi Agent 会从函数的签名、类型注解、docstring 里自动提取工具描述,不需要作者额外维护一大段提示词。但前提是代码得"自带描述能力",命名、注解、文档字符串三者配合到位,否则自动提取照样翻车。下文会给出可复现的写法、迁移步骤和避坑清单,你照着改就能把扩展的提示词负担降下来。
1. 工具提示词膨胀:为什么扩展作者总在无声加班
1.1 一个三万字符的教训:我手写过最大的工具描述
上个月我给 Pi Agent 写了一个视频信息查询扩展,功能不复杂:输入文件路径,返回分辨率、编码格式、时长、音轨信息。按我过去的习惯,会在扩展的 manifest 里手写工具定义,差不多是这个画风:
{ "name": "get_video_info", "description": "获取视频文件的元数据信息。当用户想知道视频的分辨率、时长、编码格式、帧率或音轨情况时使用此工具。如果传入路径不存在,返回错误码 1001;如果文件不是视频格式,返回错误码 1002。注意:本工具只读取元数据,不进行转码。对于远程 URL 不生效,需要先下载到本地。", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "视频文件的绝对路径,或相对当前工作目录的路径。目录路径无效。" }, "detail_level": { "type": "string", "enum": ["basic", "full"], "description": "basic 只返回分辨率、时长、编码;full 额外返回音轨语言、比特率、创建时间。默认 basic。" } }, "required": ["path"] } }单看这一个工具不觉得多,但我那个扩展一共挂了 12 个工具。为了每个工具的边界情况、返回格式示例,manifest 文件一路膨胀到了接近 3 万字符。更难受的是维护成本:我改过一次参数结构,JSON Schema 忘同步,结果 agent 按旧参数调用,连着报了好几天错才被用户发现。
1.2 膨胀从哪来:一段正常提示词的构成拆解
传统工具提示词之所以厚,主要不是名字本身,而是五类附加内容的叠加:
- 自然语言功能描述:为了让模型理解"什么时候该用这个工具",通常要写几十到几百字的场景说明。
- 参数的逐字段说明:每个字段的类型、取值范围、默认值、与其它字段的约束关系,都要展开写。
- 边界条件和错误码:文件不存在怎么办、权限不足怎么办、格式不支持怎么办,模型需要提前知道。
- few-shot 示例:有时候还得塞一两个完整调用示例,教模型怎么组合参数,这一塞又是几百字符。
- 冗余安全话术:防止模型误用,比如"不要在用户没有要求时自动调用",这类话来回来去写。
这几部分合计下来,一个中等复杂度的工具没有两千字符打不住。你要是去翻一些成熟 agent 项目的工具定义文件,动辄上万字符的 manifest 真不少见。但问题在于:工具的实现本身就在代码里,提示词里的描述不过是对代码的"二道翻译",翻译错了、漏了、过期了,全是新的坑。
1.3 91% 到底是怎么算出来的:一个可复现的对比口径
我按自己的扩展做了一个对照实验。同一个"视频信息读取"工具,传统手写法写满自然语言描述和 JSON Schema,一共 3120 字符;Pi Agent 模式下,我只需要写函数定义、类型注解和两行 docstring:
from pi_agent import tool from typing import Literal @tool def get_video_info( path: str, detail_level: Literal["basic", "full"] = "basic" ) -> dict: """读取视频文件的元数据,返回分辨率、编码、时长等核心信息。""" ...函数签名部分 147 字符,注解部分 58 字符,docstring 65 字符,加起来 270 字符。3120 到 270,减少了约 91.3%。这中间省掉的部分不是"偷工减料",而是 Pi Agent 自己根据类型注解和命名推导出来的:Literal["basic", "full"]自动变成枚举,path: str自动映射为字符串参数,函数名里的get_video_info配合 docstring 的第一句话会自动生成场景描述。
所以"省掉 91%"不是玄学,是换了一套描述生成协议。下面两节我会把机制拆开讲。
2. 自动描述生成的内幕:函数签名如何变成模型读得懂的工具说明
2.1 从类型注解到工具参数模型的映射关系
Pi Agent 的扩展系统有一套内置的类型映射协议,简单说就是把你写在函数签名里的 Python 类型,直接转成模型侧使用的 JSON Schema。协议对应关系大致如下:
| Python 类型 | 生成的 JSON Schema 片段 | 备注 |
|---|---|---|
str | {"type": "string"} | 最常用 |
int | {"type": "integer"} | |
float | {"type": "number"} | |
bool | {"type": "boolean"} | |
Literal["a", "b"] | {"type": "string", "enum": ["a", "b"]} | 自动生成枚举 |
list[str] | {"type": "array", "items": {"type": "string"}} | 只支持单一泛型 |
dict | {"type": "object"} | 无内部结构时只声明对象 |
TypedDict | {"type": "object", "properties": {...}} | 适合结构化入参 |
None / NoneType | 参数可空或返回空 | 用于可选返回值 |
Path | {"type": "string", "format": "path"} | 路径特化处理 |
不需要手动写"parameters": {...},这些全部由函数签名自动映射。使用这一层机制,扩展作者要维护的内容就变成了"函数签名本身",提示词和代码实现天然同步,不会出现改了参数忘了改描述的问题。
2.2 它到底读懂了什么:命名、参数名和 docstring 的配合逻辑
类型映射只是骨架,真正让工具描述"像人话"的是三个元素的配合。
第一个是函数名。Pi Agent 的提取器会把函数名按 snake_case 拆词,get_video_info拆成get / video / info,再在生成的工具描述里合成一个简短的功能头:"获取视频信息"。这意味着你给函数起名时得用强动词 + 明确宾语,别用process_data这种谁都看不懂的名字。
第二个是参数名。path、detail_level这种自解释的名字会被原样保留进描述;如果你写p、dl,提取器生成的参数描述就只剩下类型信息,模型就只能靠猜。参数默认值也会被读取,detail_level="basic"会被解析成"该参数选填,默认值为 basic",模型调用时就能省掉这个字段。
第三个是 docstring。第一行会被当作工具的简短描述,紧跟函数名合成的头部,共同组成完整的场景说明。所以 docstring 千万别写"这个函数用来干嘛的"这种废话,直接把"什么时候该用、什么时候不该用"压缩成一句话,效果远比三行空话好。
2.3 为什么这是"对扩展作者解放"而非"压缩"的设计
很多人以为省提示词只是省存储空间,其实真正的价值是解除了维护负担。传统模式下,工具描述和工具实现是两个独立事实来源,改一个必须同步另一个,否则就会出现"说明书与实物不符"的翻车现场。Pi Agent 的自动描述机制把实现本身变成了唯一事实来源,描述不过是实现的一个投影视图。
对照一下两种模式的心智负担:
| 维护动作 | 传统手写 JSON 描述 | Pi Agent 自动描述 |
|---|---|---|
| 新增一个参数 | 改函数 + 改 JSON Schema + 改自然语言描述 | 改函数签名即可 |
| 调整参数取值范围 | 改枚举 + 改描述文本 | 改Literal注解 |
| 更新工具行为 | 改描述里的边界条件说明 | 改函数实现 + docstring 第一句 |
| 排查 agent 误用 | 对比描述与实现是否一致 | 直接看函数签名,没有二义性 |
一句话总结:自动描述把"写提示词"变成了"把代码写清楚"。代码本身就是文档,文档就是代码的一部分。对单打独斗的扩展作者来说,维护成本能降一个量级。
3. 扩展作者实操:写出"零提示词负担"的工具函数
3.1 一个完整示例:同样的视频读取工具,两种写法
先看最终的 Pi Agent 扩展代码长什么样:
from pi_agent import tool from typing import Literal, TypedDict class VideoMeta(TypedDict): width: int height: int codec: str duration_seconds: float audio_tracks: list[str] @tool def get_video_info( path: str, detail_level: Literal["basic", "full"] = "basic" ) -> VideoMeta: """读取视频文件的元数据,用于回答分辨率、编码格式、时长、音轨相关问题。""" # 实际实现可以调用 ffprobe 或自研解析器 ...这段代码里没有任何"提示词",但 Pi Agent 会自动生成下面这份等价描述:
{ "tool_name": "get_video_info", "description": "读取视频文件的元数据,用于回答分辨率、编码格式、时长、音轨相关问题。", "parameters": { "type": "object", "properties": { "path": { "type": "string", "format": "path", "description": "参数 path 的类型为 string,表示文件路径。" }, "detail_level": { "type": "string", "enum": ["basic", "full"], "default": "basic", "description": "参数 detail_level 的类型为 string,可选值为 basic 或 full,默认 basic。" } }, "required": ["path"] }, "returns": { "type": "object", "properties": { "width": {"type": "integer"}, "height": {"type": "integer"}, "codec": {"type": "string"}, "duration_seconds": {"type": "number"}, "audio_tracks": {"type": "array", "items": {"type": "string"}} } } }对比第一节的手写 JSON,这里没有一个字是在"额外维护",全部来源于代码本身。你只需要保证代码正确,工具描述就正确。
3.2 三个技术要点:命名、类型注解与 docstring 的最佳实践
想让自动描述发挥最大效果,得把代码写成"提取器友好"的形态。我总结了三条硬规则。
规则一:函数名要能"望文生义"。get_video_info没问题,但handle_event、do_task、process这类名字就是在浪费自动提取的机会。好的函数名由强动词 + 明确宾语构成:list_workspace_files、search_by_keyword、convert_encoding。不要用缩写到无法读懂的短名,get_vid_meta这种名字虽然省了几个字符,但会直接影响模型判断工具用途。
规则二:参数一定要写类型注解,能用Literal绝不用裸str。裸str意味着模型不知道参数的可选范围,它会可着劲猜。Literal["basic", "full"]一写,枚举直接生成,模型就只在范围内取值。结构化参数用TypedDict定义,比dict这种笼统类型安全得多。另外,可选参数记得给默认值,默认值会被解析进描述,模型调用时就不会非传不可。
规则三:docstring 第一句写"何时使用"而非"如何使用"。拿上面的例子说,docstring 写"读取视频文件的元数据,用于回答分辨率、编码格式、时长、音轨相关问题",重点在"什么场景下用户会需要这个工具"。如果写成"传入路径参数,返回元数据字典",等于把类型注解已经表达的信息再重复一遍,白白浪费提取器留给你的宝贵描述空间。
3.3 常见反模式:这些写法会让自动提取器直接摆烂
光知道怎么对还不够,还得知道哪些写法会让工具描述变得不可用。我踩过的坑按严重程度排了个序:
- 没有任何类型注解的参数。提取器对无注解参数只能写一个笼统的
"type": "string",模型把目录当文件传、把数组当字符串拼,都是这么来的。 **kwargs兜底接一切。提取器压根不知道你接受哪些字段,结果就是模型传什么你都收,错误要到运行时才炸。- 函数名和实际行为不匹配。比如函数叫
get_video_info,实际还顺带做了转码,docstring 也没写清楚,模型就会误以为它具备转换能力。 - docstring 写成小作文。Pi Agent 只取第一句作为工具描述,后面全是冗余。把边界条件、错误码、示例都堆进 docstring,提取器只会截断,不会帮你拆结构。
- 返回类型写
dict而不是TypedDict。返回结构不明确,模型就不知道工具返回后该怎么解读结果,尤其容易把{"width": 1920}错当字符串处理。
可以用下面这个对照表快速自查你的工具函数:
| 维度 | 推荐写法 | 容易翻车的写法 |
|---|---|---|
| 函数名 | list_workspace_files | do_thing/process |
| 参数类型 | path: str | def f(path) |
| 枚举取值 | Literal["basic", "full"] | 裸字符串加注释 |
| 结构化入参 | TypedDict类 | **kwargs |
| docstring | 一句话讲场景 | 三段式流水账 |
| 返回类型 | TypedDict/list[T] | Any/dict |
4. 用户侧玩法:不写一行扩展代码,日常也能省掉九成提示词
4.1 内置工具本来就"免描述":你的提示词里根本不用写工具细节
作为 Pi Agent 的普通用户,你可能永远不会写扩展,但同样能享受到提示词减少的红利。因为 Pi Agent 内置的文件读写、代码搜索、终端执行、网络请求等工具,全部走自动描述协议。这意味着你在对话里不需要教它"应该用哪个工具、参数怎么传、返回结果怎么看",它自己就能完成工具匹配。
举个实际例子。以前用别的 agent,想让它统计项目里的代码行数,我得说:"请先用 list_files 列出 src 目录下所有文件,然后对每个文件计算行数,注意排除 node_modules,再汇总。"这种话本质上是在手动替 agent 做工具选择。换成 Pi Agent 的自动描述工具,我只需要说:"统计 src 目录下所有 Python 文件的总行数,排除测试文件。"剩下的路径怎么传、用哪几个工具,自动描述机制会替模型完成选择。
4.2 使用官方与第三方扩展包:装完即用,对话只需提需求
Pi Agent 的扩展市场里已经有不少第三方扩展,比如各类媒体处理、数据分析、图表生成工具。这些扩展如果按自动描述协议写好,用户装完之后不需要学任何"新话术"——直接在对话里提需求就行。
安装一个视频元数据扩展后,你可以直接问:"帮我看看 downloads 目录里那个 demo.mp4 编码是什么、有没有多音轨?"扩展里的get_video_info工具会自动匹配上,参数path会被填成绝对路径,detail_level会默认取 basic。你全程没有提"请调用工具"这类元指令,因为工具描述已经从函数签名里生成了,模型看到的是"当前环境有一个能读取视频元数据的工具,接受路径参数",它自然会去用。
我自己测过很多次,这种玩法下,对话里真正需要用户表达的只有"要做什么",而不是"怎么做"。提示词省下来的不光是字符数,更是思考负担。我想让 Pi Agent 干活时,不再像在写 API 文档。
4.3 用户与扩展作者协作时的注意点:更新、缓存与版本
自动描述机制也不是说完全不用维护。扩展作者更新了函数签名之后,用户的本地描述缓存如果不刷新,你暂时还是会看到旧的工具描述。按我目前的经验,Pi Agent 会在扩展加载时校验函数签名哈希,但是缓存策略比较保守,高频使用的扩展未必每次都触发重载。
遇到这种情况,一个靠谱的排查方式是查看当前会话实际生效的工具描述。Pi Agent 的会话里可以直接询问"你现在有哪些工具可用",它会列出自动生成的描述。如果发现列表里的参数还是旧版,就手动卸载重装对应扩展,或者在设置里清空扩展缓存,强制重建一次工具索引。
这一条也提醒扩展作者:升级扩展时尽量保持函数签名兼容,把Literal枚举往大改没问题,但把已发布的参数名删掉或者改类型,会让用户侧的缓存描述与实际实现短期错位,体验很差。
5. 避坑实录:自动描述机制的边界与调试方法
5.1 自动提取失败与变形的三种典型情况
自动描述不是万能的,我在实际使用中遇到过三种翻车场景,写出来帮你避雷。
情况一:函数参数没有类型注解。有一阵子我图省事,把get_video_info的path参数写成无注解形式,自动描述直接退化成一个光秃秃的string参数。模型拿到这种描述完全不知道路径格式,传入C:/Users/...还是/mnt/data/...全靠碰运气。解决办法很直接:补上str注解,后面如果涉及路径语义,再进一步标注成Path类型。
情况二:docstring 第一句是空话套话。我写过"""获取信息。"""这种毫无信息量的 docstring,结果自动生成描述里工具说明就只剩"获取信息"四个字。模型面对"获取什么信息、什么场景用"完全没有判断依据,只要有一丁点沾边就乱调。改成"读取视频文件的元数据,用于回答时长、编码、分辨率相关问题"之后,匹配准确率立竿见影。
情况三:返回类型写成了Any。返回结构对模型判断"工具结果怎么用"至关重要。Any会让模型把结果当成未知结构处理,后续推理经常出错。我在做一个分子式解析工具时,返回一个 dict,忘记定义TypedDict,结果模型每次都要猜返回里有什么字段。补上返回结构定义之后,后续的推理步骤明显稳了。
5.2 局部重写描述:自动提取结果不够用时的兜底方案
自动描述虽然省力,但总有一些工具的行为没法靠命名和注解表达清楚。比如某个工具需要传入的参数之间互相依赖(mode为remote时必须传url),这类逻辑注解表达不了,只能靠自然语言说明。Pi Agent 给我们留了一个describe参数来做局部覆盖:
from pi_agent import tool from typing import Literal @tool(describe="""获取视频元数据。当 mode 为 remote 时需同时传入 url;为 local 时用 path 指向本地文件。""") def get_media_info( path: str | None = None, url: str | None = None, mode: Literal["local", "remote"] = "local" ) -> dict: ...注意这里加的是局部补充,不是推翻整个自动描述机制。参数名、类型映射、枚举值仍然从签名里自动来,describe只负责补充那些"签名表达不了"的约束信息。这种混合写法的维护成本仍然远低于手写全套 JSON Schema,而且不会出现两处不一致的问题。
5.3 实测心得:把扩展调到稳定可复用的三个坑位
最后说三个我反复踩、反复修的实战细节,都是文档里不会写的。
第一,默认值尽量别给"空字符串"。我早期写的工具里有参数lang: str = "",提取器生成的描述是"默认空字符串",模型看到后经常传一个空字符串过去,运行时判断逻辑还得额外处理。改成lang: Literal["auto", "zh", "en"] = "auto"之后,模型只会选三个枚举值,行为清晰得多。
第二,返回结构里不要混Optional和None之外的值。TypedDict里某个字段写成str | None,生成后模型会理解为"可能是字符串也可能为空",它推理时会丢字段。如果确实可能没有,建议在 docstring 第二句补充说明"未取到时分秒字段返回 null"这类信息,帮助模型正确处理。
第三,工具名里别带版本号或序号。get_video_info_v2这种名字会被拆词解析成get / video / info / v2,"v2"没有语义含义,既干扰生成描述,也容易让模型困惑和旧版本的区别。版本语义交给扩展包版本管理,不要塞进函数名。
至于扩展分发的时候,我会额外看一眼生成的描述预览再发布。Pi Agent 的开发者工具里能看到自动提取器对每个函数生成的结果,我会把生成描述通读一遍,确认跟函数行为一致。这一步就是整个流程里唯一需要人工点评的地方,花不了两分钟,却能把 91% 的提示词成本省得明明白白。