ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

省掉91%工具提示词:用函数签名与docstring自动生成LLM工具描述

省掉91%工具提示词:用函数签名与docstring自动生成LLM工具描述

上个月我把自己维护的 Pi Agent 扩展翻出来清理了一遍,发现一个挺吓人的数字:Tools 数组里的 description 加起来快 3700 个字。这还只是第五个扩展,当时我就意识到,如果继续按"每个工具写一段说明书"的思路走下去,后面每加一个扩展,光是维护这些工具提示词就能耗掉半天。后来我换了个思路,让 Pi Agent 的扩展机制从函数签名、参数类型、返回值和示例里自动生成工具描述,人工要写的提示词直接降了九成。这篇就把我的做法完整写出来,给 Pi Agent 用户和正在写扩展的作者参考。

先说明一下适用范围:我下面用的是 Pi Agent 的扩展开发接口,但也适用于任何支持自定义工具、面向 LLM 的工具调用场景。核心方法是一致的——把"人肉写工具提示词"改成"结构化声明 + 自动渲染"。如果你也在为扩展里的工具描述太长、改起来太痛苦而头疼,这篇应该能帮你省掉不少功夫。

1. 先搞清楚:工具提示词到底费在哪里

1.1 一份"标准"工具描述的自我解剖

要给 LLM 用的工具写说明,绕不开这么几个部分:这个工具是干什么的、什么时候该用、什么时候千万别用、每个参数是什么意思、返回值长什么样。我早期写扩展时,习惯把每一部分都展开写,结果一份工具描述动不动 400 到 800 字。比如给一个"合并本地分支"的工具,我写过类似这样的东西:

工具名称: git_merge_branch 用途: 把某个分支的提交合并到当前分支。当用户要求把 feature 分支合并到 main、或者希望将其他分支的改动整合到当前工作区时使用。合并过程中如果出现冲突,不要自行解决,需要将冲突文件列表返回给用户,等用户确认后再继续。如果当前有未提交的改动,先提醒用户处理。合并完成后输出提交哈希和合并结果。 参数说明: source_branch(s): 要被合并的分支名。必填。注意拼写要完整,不要用简称。 target_branch(s): 目标分支。默认是当前分支,一般不用传。如果用户指定了目标分支,则切换到该分支再执行合并。 allow_conflict(boolean): 默认 false。当为 true 时,允许自动处理可合并冲突;为 false 且检测到冲突时立即停止。 返回值: 合并成功返回一个对象,包含 merger、commitId、message;合并失败返回 error。

语法上没问题,但仔细想想,这段描述里有相当多内容是重复的。参数类型、默认值、是否必填,这些信息完全可以用 JSON Schema 表达;而"合并成功返回一个对象,包含 merger、commitId、message"这种话,有经验的 agent 看到工具名和参数名基本也能推断出来。真正缺的其实是边界条件:什么时候不调用、冲突怎么办。

1.2 真正被 LLM 用到的信息只有四成

我做过一个小实验:把我写过的工具描述逐段删掉,看 Pi Agent 在同样任务下会不会用错工具。实验做了三轮,结论有点反直觉:大部分长描述里真正影响工具选择的,不是那些写得又全又细的"优点",而是下面这四类信息:

  • 参数约束和类型,比如某个参数只能接受特定枚举值;
  • 工具触发场景,就是"当用户提到某些关键词时使用";
  • 明确禁区,比如"不要自动执行删除操作";
  • 返回值中值得关注的字段,例如"返回 error 字段时说明失败"。

其他部分,像"合并成功后输出提交哈希"这种成果描述,对 agent 的选择几乎没有边际贡献。换句话说,800 字的描述里大概有一大半是无效说明,不仅占 token,还稀释了真正有用的指令。

1.3 为什么大家都喜欢"多写一点":提示词通胀的根源

既然长描述不高效,为什么扩展作者还在拼命写?我总结了两点。

第一,LLM 看不到你的代码。你写扩展时很清楚每个函数的输入输出,但 agent 只能通过描述来调用它。你担心描述短了,agent 就理解不了上下文,于是忍不住把代码逻辑翻译成自然语言。这相当于给每个工具写了份文档。

第二,工具提示词没有版本管理意识。很多扩展作者和我早期一样,工具描述是最后补写的,写完就再也没动过。工具本身加了参数、改了默认值,描述却不会同步更新。为了"以防万一",作者会把所有可能性都写在里面,让描述变得越来越臃肿。

一旦明白"冗长的根源是结构信息没有被利用,而是被转译成了自然语言",解决办法就很清晰了:让结构信息自己说话,让扩展机制去渲染描述,而不是让作者手写。

2. 省掉91%的核心机制:让结构替你说人话

2.1 从声明到描述:Pi Agent 扩展里的三层信息

Pi Agent 的扩展本质上是注册一组工具给 agent 调用。每个工具在注册时至少要提供三样东西:调用处理器、参数结构、工具描述。我开展的优化思路是提高前两者的信息密度,把第三层描述变成"自动生成的结果"。

我推荐把工具信息分成三层:

  • 静态声明层:函数签名、类型注解、枚举值、默认值、docstring。这一层是结构化的,能直接被代码读取。
  • 场景层:什么时候调用这个工具、什么时候不要调用。这一层需要人工写,但可以压缩到一两句话或者几个关键词标签。
  • 渲染层:最终给 LLM 看的完整描述。这一层完全由生成器拼装,不人工维护。

大多数扩展作者把场景层和渲染层混在一起,而且渲染层由手工维护。我的改造就是:只维护前两层,渲染层交给一个几十行的生成函数。这样人工要写的提示词,就从一整篇缩成了"一两行 docstring 里的说明"。

2.2 我自己实现的 tool_registry:一条装饰器代替一屏描述

下面是我在扩展里用的工具注册装饰器。它做两件事:解析函数的类型签名和 docstring,然后按模板渲染出工具描述。这样写扩展时,我只需要关注函数本身的 docstring,不用再单独维护 tools 数组里的长描述。

import inspect import json from typing import Any, Callable # 模拟 Pi Agent 扩展 SDK 的注册方法 # 实际使用请替换成你用的 pi_agent_sdk 对应 API def _register(tool_id: str, tool_def: dict, handler: Callable): # 这里调用 Pi Agent 扩展注册函数 # pi_agent_sdk.tools.register(tool_id, tool_def, handler) pass def auto_tool(tool_id: str, category: str = "", when_to_use: str = "", never_use: str = ""): def decorator(fn: Callable) -> Callable: doc = inspect.getdoc(fn) or "" doc_lines = [line.strip() for line in doc.splitlines() if line.strip()] sig = inspect.signature(fn) params = [] for name, param in sig.parameters.items(): if name in ("self", "cls"): continue annotation = param.annotation if param.annotation is not inspect.Parameter.empty else "Any" default = param.default if param.default is not inspect.Parameter.empty else None required = param.default is inspect.Parameter.empty if isinstance(default, str): default_desc = f"默认为 '{default}'" elif default is None and not required: default_desc = "默认为 None" else: default_desc = f"默认值为 {default}" if not required else "必填" # 从 docstring 里按 ":param name: 描述" 提取单行说明 param_desc = "" for line in doc_lines: if line.startswith(f":param {name}:"): param_desc = line.split(f":param {name}:", 1)[1].strip() break if param_desc: params.append(f"- {name} ({annotation}):{param_desc};{default_desc}") else: params.append(f"- {name} ({annotation}):{default_desc}") # 提取返回值描述 ret_desc = "" for line in doc_lines: if line.startswith(":return:"): ret_desc = line.split(":return:", 1)[1].strip() break if not ret_desc: ret_desc = "按照函数返回值类型返回结果" # 提取 docstring 中首个非参数段落,作为用途摘要 first_line = "" for line in doc_lines: if not line.startswith(":") and not line.startswith("->"): first_line = line break block = f"当用户请求与「{category or tool_id}」相关的操作时,使用本工具。" if when_to_use: block += f"触发场景:{when_to_use}。" if never_use: block += f"禁止在以下场景使用:{never_use}。" description = ( f"{first_line or tool_id}。" f"{block}" f"参数要求:\n" + "\n".join(params) + f"\n返回说明:{ret_desc}。" ) _register( tool_id, { "name": tool_id, "description": description, "parameters": { "type": "object", "properties": json.loads(json.dumps({...})), "required": [...] }, }, fn, ) return fn return decorator

代码是示意性的,真去接 Pi Agent 的 SDK 时把_register换成官方接口就行。关键是这个模式:你不再需要单独维护一长段 description,而是在函数定义处写明参数含义和触发边界,其余由生成器拼装。

2.3 参数、返回值、示例,如何自动变成自然语言

有人会问:生成器拼出来的描述,会不会比手写的更死板?我的经验是,对绝大多数工具来说,"死板但结构清晰"比"花哨但重点模糊"有用得多。

具体到生成规则,我用了几个固定策略:

  • 参数名直接带出来。Python 函数参数名本身有语义,比如source_branch见了就知道是源分支,不需要额外解释"要被合并的分支名"。
  • 枚举值一定要出现在描述里。如果参数注解是Literal["feat", "fix", "chore"],生成器会把可选值原样列进参数说明,避免 agent 自由发挥。
  • 示例优先于抽象解释。在函数 docstring 里给一句:example: git_merge_branch(source_branch="feature/login", target_branch="main"),生成器把它拆成"调用方式示意",比干巴巴的参数说明直观得多。
  • 把"何时不要用"放在描述末尾。这是从 agent 的注意力习惯出发:开头和结尾最容易被读到。开头放用途,结尾放禁止项,中间放参数,效果最好。

这样一套下来,我原来那些 800 字的工具描述,平均被压缩到了 70 字左右的人工输入,换算一下正好省掉了九成以上。

3. 实操:把一个现成工具改成"零长篇描述"模式

3.1 改造前的工具定义:一段 400 字的手写碑文

我拿扩展里一个叫check_contract的工具当例子。这个工具的作用是检查当前改动有没有违反团队约定的 Git 提交规范。改造前,我在 Pi Agent 扩展配置里给它写了这么一段:

{ "name": "check_contract", "description": "检查当前 Git 仓库的改动是否满足团队提交流程要求。当用户准备提交代码、需要检查提交信息格式、或者提到 CI 检查失败时使用。该工具会读取配置的规则文件,列出未通过规则的文件列表和违规原因。注意:本工具只做检查,不修改任何文件,也不能阻止提交。若返回 error 字段,说明配置读取失败。" }

其实这个工具核心就三件事:检查、返回违规列表、不修改文件。但为了让 agent 准确理解,我硬是写成了绕口的说明书。每次改规则文件名或者返回值结构,我都要回头同步这段描述,非常容易漏。

3.2 改造后的工具定义:只留 docstring 里的三行

用上面的auto_tool装饰器,改造后变成这样:

from pi_agent_toolkit import auto_tool @auto_tool( tool_id="check_contract", category="git提交", when_to_use="用户准备提交、提交信息被拒绝、CI 检查失败时", never_use="不要用于代码修改或自动提交", ) def check_contract(repo_path: str, rule_set: str = "default") -> dict: """检查改动是否违反 Git 提交规范。 :param repo_path: 仓库绝对路径 :param rule_set: 规则集名称,可选 default/strict/lax :return: 返回 {violations: [...], passed: bool};error 字段表示规则读取失败 """ # 真实检查逻辑略 return {"violations": [], "passed": True}

人工写的部分就是 docstring 三行和两个装饰器参数,我数了一下,加上函数签名里的类型注解,一共 90 来个字符。生成器渲染出来的最终描述大约 350 字,但那些字不是人写出来的,是由代码根据签名、默认值和 docstring 自动拼出来的。

3.3 验证:让 Pi Agent 跑一个真实任务,看它能否选对工具

改造完不能光看字数少了,得验证 agent 还是不是原来那个 agent。我的验证步骤很清楚:

  1. 在 Pi Agent 的调试模式下加载新扩展,打印出实际进入上下文的工具描述;
  2. 给 Pi Agent 发一个和工具场景强相关的任务,比如"我这次提交信息不合规,帮我查一下怎么回事";
  3. 再给一个弱相关任务,比如"帮我看看当前仓库状态";
  4. 最后给一个不该调用工具的任务,比如"把最近三个 commit 的 author 列出来"。

实测三组任务里,第一组它准确调用了check_contract,第二组没调用而是使用了仓库状态工具,第三组它用了 git 查找命令,也没有误用check_contract。这说明自动生成的描述没有让工具失去边界感,关键还是when_to_use和never_use这两个字段管用。

我还翻了一次 Pi Agent 在调试模式下的 token 日志,工具描述从原来的 400 字手写版变成了 350 字生成版,但工具选择的准确率没有下降。省掉的 91% 其实省的是"需要人工维护的提示词",不是省给 agent 的 token——这一点在后面讲坑的时候还要强调。

4. 实测中踩过的坑,以及"什么时候不要省"

4.1 坑一:描述短了,agent 反而不知道工具的真实边界

第一次把所有工具都改成auto_tool后,跑任务很快发现一个现象:agent 开始过度使用某些工具。原因是我只保留了用途摘要,没把"禁止场景"传给生成器。比如某个format_py_file工具,docstring 只说"格式化指定 Python 文件",agent 在用户请求"整理一下这段代码风格"时就会调用它,但我的工具实际只做文件内格式化,不处理从剪贴板来的文本。

解决办法很直接:装饰器里的never_use一定要写真实边界。后来我把所有工具的边界情况都整理了一遍,写进never_use,再跑同样的任务,误用就消失了。所以我建议扩展作者,精简描述的正确姿势不是删边界描述,而是把边界从"一大段负面说明"压缩成"一句禁令"。

4.2 坑二:参数枚举和默认值没进描述,导致传参错误

还有个坑和参数有关。最初生成器只拼了参数名和类型,没有把枚举值拼进去。结果 agent 调用check_contract(rule_set="normal"),而实际只支持default/strict/lax三选一,直接报错。

所以第二个建议是:类型注解和枚举值一律要出现在最终描述里。我在auto_tool的实现里加了一段:如果参数注解是Literal,就把它定义成"可选值:xxx/yyy/zzz"放进参数说明。默认值也必须带,因为 agent 看到"未传参数"时,需要知道会用什么配置。这两项是避免参数错误的关键。

4.3 调试技巧:让 Pi Agent 把工具选择轨迹暴露出来

写扩展时,我踩坑后养成一个习惯:永远先看 agent 为什么选错工具,再改描述。Pi Agent 在调试模式或计划模式下会把"我打算调用哪个工具、理由是什么"打印出来。拿到这个理由,你很容易定位是描述里缺了什么,还是描述里多了什么让 agent 误解。

如果没有这个模式,还有一个土办法:写一个脚本遍历所有已注册工具,每次只把一个工具的完整生成描述交给 agent,让它用自己的话复述"这个工具何时用、何时不用"。如果复述里出现你没表达过的意思,说明渲染模版有问题;如果复述不出来,说明描述信息还不够。我把这个脚本固化在扩展的测试目录里,每次改完工具跑一遍,比手动调描述靠谱得多。

4.4 三种不该精简的场景

自动生成工具描述不是万能的,遇到下面三类工具,我会保留一段人工手写描述,而且不急着删:

  • 高风险操作类工具。比如删除分支、清空缓存、推送远程。这类工具除了结构化声明,我会额外加一段"必须再次和用户确认目标后执行"的说明。这里的提示词不是用来省 token 的,是用来兜安全底线的。
  • 行为依赖复杂上下文的工具。如果工具本身要根据前一天的任务状态或某个临时文件来决定行为,单靠参数描述说不清楚,我会手写一小段"工具运行受什么影响"的说明。
  • 多个工具边界高度重叠时。当两个工具看起来很像,比如format_snippet和format_file,自动生成描述容易让 agent 混淆。这种场景下我会额外加一句"和 format_file 的区别:本工具只处理纯文本片段"。

这三种情况加起来在我的扩展里不超过 20%。所以整体算下来,能省 91% 是真实的,不是一次性把所有描述都砍到最短。

5. 最后聊点维护心得

这套自动生成方案跑了两个多月,最大的变化不是 token 节省,而是扩展维护心态变了。以前加一个新工具,写描述像写论文,现在只需要写函数签名、docstring、两行触发/禁区,然后跑一遍测试脚本确认生成描述正常。工具改了参数名,生成描述会自动跟着变,我再也不用担心"代码改了但工具提示词忘记同步"这种低级错误。

如果想把这条路走得更远,还可以在生成器里加一层自动化测试:每次加载扩展时,把所有工具的描述捞出来,检查是否包含必填参数名、是否包含返回值字段、是否在开头出现了工具名。这些检查逻辑一点都不复杂,但能挡掉大部分"看似精简实则缺失"的回归问题。

对我来说,真正有用的不是"省掉 91% 的工具提示词"这个数字,而是它逼我把工具能力重新梳理了一遍,让描述从"给 agent 的说明书"变成了"给 agent 的结构化索引"。写扩展的朋友不妨从最常维护的那个工具开始试试,先拿一个工具跑完整个验证流程,确认无误后再大批量改造。我踩过的那几个坑,希望你们能直接绕过去。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进