先说个前阵子踩的坑。我用 Pi Agent 做跨模块重构,会话跑到一半,模型开始频繁丢上下文,回答越来越敷衍。一开始我以为是长会话的老毛病,后来把会话的 token 明细拉出来一看,问题清楚得吓人:系统提示词里光工具定义就占了 12800 多 token,而真正被调用的只有六个工具。剩下三十多个,躺在输入序列里一动不动,每轮请求都在白吃预算。
这一周我系统做了一次减法,把会话里的工具提示词从 12800 token 压到 1152 token,省掉了整整 91%。这篇文章就是对这次优化的完整操作复盘,适合两类读者:一类是 Pi Agent 的日常用户,想知道怎么通过配置和会话习惯给上下文腾位置;另一类是扩展作者,想从源头设计出不费 token 的工具清单。我会先把机制讲清楚,再给可直接照抄的操作步骤,最后说哪些提示词坚决不能省。
1. 工具提示词是怎么悄悄吞掉上下文预算的
1.1 先定义清楚:我们说的"工具提示词"是哪个东西
工具提示词,严格来说不是用户敲进去的那段 prompt,而是 Pi Agent 框架根据注册好的工具清单自动生成的一段模型输入。每注册一个工具,它就会被渲染成"工具名 + 用途描述 + 参数 Schema"三件套,拼进系统提示词里。模型靠这段文字知道"我现在有哪些东西可以用、每个东西怎么用、参数怎么传"。
举个例子,一个简单的代码搜索工具在系统提示词里大概长这样:
{ "name": "search_code", "description": "在代码库中执行正则搜索,返回匹配文件和行号。定位符号定义、调用点、TODO 标记时使用。", "parameters": { "type": "object", "properties": { "pattern": { "type": "string", "description": "要搜索的正则表达式" }, "path": { "type": "string", "description": "可选,限定搜索目录,默认项目根目录" } }, "required": ["pattern"] } }就这么一个最小化的工具,渲染进提示词也要一百多个 token。你可能觉得一百多没什么,但它不是只出现一次——它跟着系统提示词出现在每一轮请求里,从第一轮到最后一轮,一次都不会少。
1.2 算一笔账:40 个工具意味着 12.8k token 的固定开销
上下文窗口就像一张工位:工具提示词是固定摆着的一排健身器材,聊天记录是桌上堆的文件,代码是手里正在焊的架子。器材越多,干活的空间越小。我先按真实使用情况把账算给你看:
| 场景 | 工具数量 | 单工具平均 token | 工具提示词合计 |
|---|---|---|---|
| 只用核心工具 | 12 | 300 | 3,600 |
| 装 2 个常用扩展 | 25 | 310 | 7,750 |
| 装 5 个扩展(我优化前的状态) | 40 | 320 | 12,800 |
12.8k token 是什么概念?在常见的 200k 上下文窗口里看着占比不大,但实际会话里还要叠加上系统规则、历史消息、读进来的代码文件,长任务跑到中段,可用余量就非常紧张了。再算一笔账:假设这个会话有 300 轮请求,工具提示词每轮都完整发送一次,300 轮下来光是"描述工具"就消耗了约 3.8M token。这还只是输入侧的开销,不包含输出。一句话:工具提示词是全会话里最贵、又最容易被忽视的固定成本。
1.3 四类冗余:这笔钱是怎么白白花掉的
我后来逐个工具拆开看,发现这 12.8k token 里有四类明显的浪费,理解了这四类,后面所有操作都会变得很有针对性。
第一类是描述冗余。很多工具的描述写得像文档摘要,把接口细节、返回格式、示例都塞进去。模型其实只需要知道"这个工具是干嘛的、什么时候该用"。
第二类是Schema 冗余。每个参数都写了 title、description、默认值说明,字段名本身已经能说明意思的,还要再解释一遍。参数越多,浪费越严重。
第三类是加载冗余。这是最大的头。40 个工具里,大部分扩展工具跟当前任务毫无关系——写代码的会话挂着一套数据库工具,做数据清洗的会话却还挂着六个 Git 操作工具。工具全量常驻,是提示词膨胀的根本原因。
第四类是跨会话冗余。每个新会话都要把同一套工具定义重新加载一遍。如果不同的会话类型(编码、运维、数据分析)共用同一份全量配置,那每个会话都在重复交这笔学费。
2. 动手之前先搞懂 Pi Agent 的加载规则
2.1 三层工具集:核心、扩展与按需加载
很多人拿到 Pi Agent 的第一反应是"把所有扩展都装上",这恰恰是提示词膨胀的起点。以我当前用的版本为例,它的工具加载分三层。
第一层是核心工具集,包括 shell、文件读写、搜索、patch 这类基础能力,随 Agent 启动常驻,一般 10 到 15 个。第二层是扩展工具集,来自你安装的各种扩展包——GitHub 操作、Docker、数据库、云平台部署等等。第三层是按需工具,只在特定条件下被临时加载,用完即弃。
这里的关键点在于:扩展工具默认并不一定全量进入提示词。Pi Agent 会先做一个轻量的任务预判,再决定把哪一组扩展工具真正加载进当前会话。这个预判的依据,恰恰是扩展清单里的名称和描述。所以扩展作者怎么写名字、怎么写描述,直接决定它能不能在需要时被准确唤醒,也决定它会不会在不需要时白白占位。
2.2 扩展清单是如何变成提示词的
扩展包的根目录里通常有一个 manifest 文件,声明这个扩展提供哪些工具。我贴一个简化版给你看:
name: git-helper version: 1.3.0 description: Git 常用操作封装 tools: - name: git_status description: 查看工作区状态,包括暂存区、未跟踪文件和分支信息。 input: type: object properties: short: type: boolean description: 使用短格式输出,默认 false。 output: text - name: git_log description: 查看提交历史,支持按作者、时间过滤。 input: type: object properties: author: type: string description: 只显示该作者的提交。 since: type: string description: 起始日期,格式 YYYY-MM-DD。 output: textPi Agent 在会话初始化时,会把所有被判定为"需要加载"的工具逐条读取,然后按照统一的模板渲染成一段连续文本拼进系统提示词。扩展装的越多、每个工具的描述和 Schema 越臃肿,这段文本就越长。理解了这个渲染链路,你就应该明白:在 manifest 阶段做减法,比在会话阶段做减法更彻底。
2.3 用户手里有四张牌:禁用、别名、模板与按需开关
用户侧不是只能被动接受。我实际可操作的入口,大致是四个:第一,项目级配置文件里可以手动禁用指定工具;第二,可以给常用工具设置短别名,名字短了,工具提示词和模型输出里的工具名都会变短;第三,创建会话时可以选不同的任务模板,模板会决定挂载哪些扩展;第四,有一个开关控制是否启用"按需加载",核心机制就是我前面说的任务预判。
这四张牌组合起来,就是后面第三节的具体操作。做优化之前,我强烈建议你先看一眼自己的配置文件长什么样,不同版本的路径可能略有差异,但逻辑基本一致:能拿到工具清单的地方,就能做减法。
3. 用户端实操:四个动作把提示词压到 9%
3.1 第一步:盘点当前会话到底加载了什么
优化永远从测量开始。你可以直接在会话里输入/tools或打开工具面板,看当前会话实际加载了哪些工具。我当时的清单里有 40 个工具,其中 14 个是核心工具,26 个来自 5 个扩展包。让我意外的不是"装了很多",而是里面有 12 个工具我近一个月一次都没用过:网页截图工具、翻译工具、时区转换工具、两个功能高度重叠的代码搜索工具……
我建议你做一个最简单的记录表,列三列:工具名、上次使用时间、当前会话是否用到。如果一行工具既在"上次使用时间"里查不到近期的记录,又在"当前会话是否用到"里填了否,那它就是第一批被砍掉的对象。
3.2 第二步:砍掉这些工具,没有任何损失
拿我手头这个版本举例,项目根目录下的配置文件长这样:
tools: disabled: - web_fetch - browser_snapshot - translate_text - timezone_convert aliases: github_pr_create: pr_create loads: on_demand: truedisabled列表里的工具会直接被排除在渲染之外,不会再生成对应提示词。我一次性禁掉了 8 个工具,工具提示词立刻从 12800 掉到 9500 左右,少了四分之一。这一步几乎没有风险,因为砍掉的都是低频或与当前项目无关的能力。你担心的"万一以后要用怎么办"完全多余——配置文件里随时可以加回来,而且 Pi Agent 的按需加载机制会在你真用到的时候通过预判把它带进来。
3.3 第三步:用会话模板做职责隔离
砍完低频工具,大头还在后面:那 40 个工具里的"高频工具",也只是对某个特定场景高频。写 Go 服务的时候,Docker 部署工具一次都用不上;查数据库的时候,Git 操作工具基本闲置。让一个会话背上全部高配工具,等于每天通勤都开卡车。
我的做法是建三套会话模板,你可以直接抄:
| 模板名 | 挂载的扩展 | 适用场景 |
|---|---|---|
| coding | 核心工具 + Git 扩展 | 日常写代码、重构、代码审查 |
| data | 核心工具 + 数据库/数据处理扩展 | 数据分析、脚本调试 |
| ops | 核心工具 + Docker/K8s/云平台扩展 | 部署、运维、排障 |
这样每个会话的工具数量从 40 降到 15 左右,提示词规模自然跟着砍半。一开始你会觉得"切来切去麻烦",但配合模板一键创建,实际多花的时间不到五秒钟,换来的却是每一轮请求都更轻快。
3.4 第四步:把"按需加载"真正打开
如果你确认自己的配置里没有开启on_demand,请一定把它打开。按需加载的意义在于:即使某个扩展在模板里被挂载了,如果本次任务完全没触发它的关键词,它的工具提示词也不会被渲染进系统提示词。这相当于给工具集又加了一道动态闸门。
实测下来,打开按需加载之后,我那个 coding 模板的会话,工具提示词又掉了一截,最终稳定在 1152 token 左右。四步全部做完,从 12800 到 1152,刚好省了 91%。这中间没有任何一步是伤筋动骨的,全是配置级的调整,属于"做了就赚"的优化。
4. 扩展作者的设计守则:让工具从源头就省 token
前三节是用户侧的打法,但工具提示词的膨胀,真正的源头在扩展作者这边。一个写得臃肿的扩展,会被几百个用户加载、渲染、浪费。下面这五条守则是我自己写扩展时定下的硬规矩,每条都对应可量化的 token 节省。
4.1 名字:短而唯一
工具名是模型识别工具的标识,也是提示词里必须出现的字符串。好的名字控制在 6 到 12 个字符,用命名空间前缀避免冲突。比如gh_pr_create就比create_pull_request短,也比create_pull_request_with_title这类描述式命名干净得多。模型在调用工具时必须输出完整名字,名字越长,每轮调用都跟着多花 token。这不是一次性成本,是叠加成本。
反面典型我也见过:一个请求历史数据的工具叫get_historical_telemetry_data_for_device_with_range,42 个字符。改成device_telemetry_query,十六个字符,意思一点没丢。
4.2 描述:一句话说清"做什么、何时用"
描述是工具提示词里弹性最大的部分。很多人习惯写长描述,生怕模型不懂。但模型比你想象的更擅长抓重点,你要做的不是解释,是触发。
我自己的模板是:动作 + 对象 + 触发条件,不超过 15 个词。
常规写法(约 70 token):
从远程仓库拉取一条 Pull Request 的完整详细信息,包括标题、正文、提交历史、文件变更列表、审查意见、CI 检查状态以及合并状态。适用于需要了解 PR 全貌的场景。
精简写法(约 20 token):
读取 PR 详情,用于查看变更和检查状态。
前者包含的很多信息,模型反正看不到返回值的实际结构就无法用,写了等于白写。后者把"做什么"和"何时用"讲清楚了,剩下的交给函数实现。我统计过,单这一条守则就能让描述成本降低 60% 到 70%。
4.3 参数 Schema:删掉所有可以被默认值替代的字段
Schema 是工具提示词里最容易失控的部分。写 Schema 的人有一种心理惯性:把所有可配项都暴露出来,显得工具很强大。但模型其实只需要知道必须传什么、可传什么、默认给什么。
这是我实际压缩过的一个例子。压缩前:
{ "type": "object", "properties": { "owner": { "type": "string", "description": "仓库所属的组织或个人用户名,必填。" }, "repo": { "type": "string", "description": "仓库名称,必填。" }, "pull_number": { "type": "integer", "description": "PR 编号,必填。" }, "include_reviews": { "type": "boolean", "description": "可选,是否包含审查意见,默认 false。" }, "include_ci": { "type": "boolean", "description": "可选,是否包含 CI 状态,默认 false。" } }, "required": ["owner", "repo", "pull_number"] }压缩后:
{ "type": "object", "properties": { "owner": {"type": "string"}, "repo": {"type": "string"}, "pull_number": {"type": "integer"}, "include_reviews": {"type": "boolean", "default": false}, "include_ci": {"type": "boolean", "default": false} }, "required": ["owner", "repo", "pull_number"] }差别就在于:删掉了所有重复描述的字段说明,把可选参数用default兜底。字段名owner、repo本身已经自解释,描述说明纯属画蛇添足。这个工具的 Schema 部分直接从原来的 230 token 砍到 90 token,效果立竿见影。
4.4 公共 Schema 抽取:一处定义,处处引用
如果你一个扩展里有一组工具都要用到分页参数、认证信息、时间过滤条件,千万别在每个工具的 Schema 里各写一遍。把这些公共对象提取出来,用引用语法统一指向。渲染的时候,提示词里只出现一次定义,各工具按需引用。这一招在工具数量多的时候尤其有效——10 个工具共享同一个分页结构,能省下数千 token。
4.5 聚合工具:用 action 枚举替代一打相似工具
这是我自己最推崇的一招。很多扩展作者喜欢一个操作一个工具,结果 GitHub 扩展一写就是 20 个工具。更好的做法是聚合:一个工具、一个 action 枚举、内部去分发。
比如仓库管理,与其注册repo_list、repo_get、repo_create、repo_update、repo_delete五个工具,不如注册一个:
{ "name": "github_repo_ops", "description": "GitHub 仓库操作。action 指定 list/get/create/update/delete。", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get", "create", "update", "delete"] }, "repo": {"type": "string"}, "payload": {"type": "object"} }, "required": ["action"] } }五个工具的提示词合并成一个,描述成本直接省掉 60% 以上。模型侧的理解负担也小得多——它不需要在五个名字里做选择题,只需要在一个工具的参数里选一个枚举值。这个模式唯一的代价是描述需要稍微写清楚聚合规则,但这几十个 token 的投入,比维护五个工具省得多。
5. 实测记录:同样的任务,12806 token 到 1152 token
5.1 测量方法:用 tokenizer 说话
优化这种事,不能靠"感觉变快了",要有数字。我用的测量脚本很简单,核心逻辑是把工具清单渲染成和系统提示词一样的文本,然后用 tokenizer 编码数长度:
import json import tiktoken enc = tiktoken.get_encoding("cl100k_base") def tools_to_prompt(tools: list[dict]) -> str: lines = [] for t in tools: lines.append(f"## {t['name']}") lines.append(t.get("description", "")) lines.append(json.dumps(t.get("parameters", {}), indent=0, ensure_ascii=False)) return "\n".join(lines) def tool_prompt_tokens(tools: list[dict]) -> int: return len(enc.encode(tools_to_prompt(tools))) # 优化前:从配置和扩展清单里读出的全部工具 before_tools = load_all_tools() # 40 个 # 优化后:模板加载 + 按需预判之后的工具 after_tools = load_effective_tools() # 15 个 print("before:", tool_prompt_tokens(before_tools)) print("after:", tool_prompt_tokens(after_tools))这里有个细节值得注意:load_effective_tools()不是模拟出来的,是我在会话里通过/tools拿到真实渲染结果。测量要测"模型实际看到的",不是"配置文件里写的",否则数字会虚高。
5.2 优化前后对比
我那次优化的完整数据贴在这里,你可以当成一个基准参考:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 会话加载工具数 | 40 | 15 | -62.5% |
| 工具提示词 token | 12,806 | 1,152 | -91.0% |
| 单工具平均 token | 320 | 77 | -76.0% |
| 首轮响应耗时(同任务) | 约 2.1s | 约 1.4s | -33% |
| 长任务中途丢上下文的概率 | 高 | 未再出现 | - |
首轮响应耗时的下降很直观:模型要处理的输入短了,预填充时间自然变短。但更重要的收益是长任务稳定性——以前跑到 200 轮左右开始丢细节,优化后同样的任务跑到 350 轮,关键上下文依然完整。这就是省出上下文预算带来的实际红利。
5.3 回归验证:压缩会不会破坏效果
省了 91%,心里肯定会打鼓:工具描述这么短,模型还能不能正确调用?我做了两轮回归。
第一轮,把过去两周跑过的 12 个典型任务用优化后的配置重跑一遍,包括代码重构、多文件搜索、Git 操作、数据库查询。结果是 11 个任务输出完全符合预期,1 个任务第一次调用参数传错,但模型自己通过工具返回的错误信息纠正了。第二轮,专门测边界场景:让模型在"必须从两个相似工具里选一个"的情况下做判断,这个我放在下一节细讲。
结论是:对于绝大多数日常场景,91% 的压缩不会带来可感知的能力下降。但如果你的用例涉及危险操作、易混淆工具或复杂参数约束,那就得看最后一节的边界清单。
6. 不能省的提示词:压缩的边界与反面案例
任何一刀切的优化都会留下隐患。下面这四类提示词,是我在实践中发现必须保留甚至主动加厚的地方。
6.1 涉及危险操作的安全说明
删除分支、清空目录、推送强覆盖这类工具,描述里必须保留明确的安全警告。模型需要知道"这个操作不可逆",才能在用户意图含糊时停下来确认。我在一个工具集里做过实验:把删除操作的安全说明从 80 token 压到 10 token,结果模型在任务中直接把删除分支和创建分支搞混过一次。虽然最终没有造成事故,但这说明安全信息不是冗余,是护栏。生产环境的扩展,护栏不能省。
6.2 易混淆工具的区分信息
如果你的扩展里有两个工具长得特别像,比如read_file和read_binary,或者parse_log和search_log,描述里必须给出明确的区分线索。压缩描述时,我通常保留一句话:"文件较大或二进制内容用前者,纯文本检索用后者"。这类区分信息一般也就二三十个 token,但它决定了模型能不能选对工具。省了这几十个 token,换来一次错误的工具调用,光重试的 token 成本就翻倍了。
6.3 带复杂约束的参数
正则表达式、时间范围格式、枚举白名单、速率限制边界——这些约束必须留在 Schema 里。模型不擅长猜约束,你删掉pattern或format限制,它就会传一个格式错误的值,然后被工具报错,再重试。一次报错重试的成本,轻松超过你省下的那几十个 token。我见过最离谱的例子:一个工具的参数要求 ISO 8601 格式,作者把格式说明删了,模型连续传错三次。后来加回一行"format": "date-time",问题立刻消失。
6.4 什么时候应该主动"加回去"
压缩不是终点,是起点。我现在的做法是:先按守则压缩,然后跑一轮典型任务,哪个工具调用不稳,就给哪个工具单独加厚描述,而不是整体回滚。通常加 30 到 50 个 token 的描述就够解决问题,整体依然维持在 90% 的压缩率附近。这种"按需回补"的迭代方式,比一次性写满再慢慢删要高效得多。
我现在已经把这套守则写进了自己扩展仓库的贡献指南,每个新工具的 description 字数上限设为 20 词,Schema 里不出现与字段名重复的说明,ray 公共类型统一抽取。新写的扩展从合并那一刻起就自动达标。如果你维护着自己的工具集,建议也把这条底线定下来——它不耽误功能,却能让你和所有使用你扩展的人,每一轮请求都少付一点代价。