Pi Agent 的 Token 成本控制:核心是 Prompt Cache 前缀稳定性
发布时间:2026/10/15 12:28:16 锦皓数字建站

1. Pi Agent 长会话为什么越跑越贵从 Deferred Tools 加载顺序说起Pi Agent 这类 Coding Agent 和普通 LLM 应用在成本结构上有一个本质差异每一轮对话都要把整个对话历史重新发一遍。系统提示词、工具定义、全部消息一个不落。一个跑了 30 轮的会话累计发送的 prompt token 是单轮的几十倍。所以「降低成本」的核心不是让模型少说话而是让那些重复发送的部分尽可能命中 Prompt Cache。Prompt Cache 的匹配规则是前缀匹配请求的前 N 个 token 必须和缓存内容完全一致才能命中。而工具定义恰好位于请求的最前面——系统提示词之后、消息之前。这就埋下了一个隐患如果对话进行到第 10 轮时某个技能被加载、或某个扩展动态注册了一个新工具你把这个新工具追加进工具定义列表请求前缀就变了。前 10 轮辛苦累积的整个缓存前缀——系统提示词 工具定义 10 轮历史——全部作废第 11 轮要按全价重新发送并重新写入缓存。对一个已经有 10 万 token 历史的会话这一次「新增工具」的代价就是 10 万 token 的全价重新计费。我试过在一个 15 轮左右的会话里手动加了个工具页脚的成本数字直接跳了一截当时还以为是模型涨价了。后来翻源码才明白问题出在工具定义的加载顺序上。Pi 的解法叫 Deferred Tools把中途新增的工具放进 transcript而不是前缀。transcript 是追加式的在消息历史末尾追加内容不会破坏前面的缓存前缀。于是「新增一个工具」的代价从「击穿 10 万 token 前缀」降到「在 transcript 末尾多几百 token」。这篇文章会从 Deferred Tools 的加载顺序和系统提示词拼接两个角度拆解前缀稳定性设计给出可复制的缓存命中诊断脚本与前缀对齐配置模板并演示验证缓存命中率提升的对比步骤。适合正在用 Pi Agent 跑长会话、发现 Token 账单不对劲的开发者。先搞清楚钱花在哪。Pi 的 Usage 类型把每次请求的 Token 分成四个计费桶单价差异极大桶含义相对单价input未命中缓存、按全价计费的 prompt token基准 1×cacheWrite写入缓存的 prompt token有写入溢价~1.25×cacheRead命中缓存的 prompt token~0.1×output模型生成的 token含推理/thinking token数倍于 input关键推论一个长会话里绝大部分 Token 是重复发送的 prompt。如果这些 token 全部落在 input 桶成本是落在 cacheRead 桶的约 10 倍。所以 Prompt Cache 命中率是压倒性的第一优先级其他手段压缩、截断、模型路由都是次要的。Pi 的 TUI 页脚专门显示这四个桶的实时数据其中 CH 就是缓存命中率if (usageTotals.input) statsParts.push(↑${formatTokens(usageTotals.input)}); if (usageTotals.output) statsParts.push(↓${formatTokens(usageTotals.output)}); if (usageTotals.cacheRead) statsParts.push(R${formatTokens(usageTotals.cacheRead)}); if (usageTotals.cacheWrite) statsParts.push(W${formatTokens(usageTotals.cacheWrite)}); if ((usageTotals.cacheRead 0 || usageTotals.cacheWrite 0) latestCacheHitRate ! undefined) { statsParts.push(CH${latestCacheHitRate.toFixed(1)}%); }CH 被放在页脚这个「始终可见」的位置而不是藏在某个 /stats 命令里是一个明确的设计表态缓存命中率是用户需要持续关注的一等指标。你如果只盯着 input 和 output 两个数字很容易忽略掉真正的大头——那些本该命中却全价重发的 prompt token。2. TaoToken 前置把 Base URL、Key、Model ID 三件套配稳要让上面的缓存机制真正生效第一步是把请求稳定地打到支持 Prompt Cache 的端点上。这里用 TaoToken 做接入示例它的 API 地址是https://taotoken.net/api兼容 Anthropic 和 OpenAI 两种消息格式Pi Agent 里可以直接配。配置的核心是三件套Base URL、API Key、Model ID。三者缺一不可而且 Model ID 必须和端点实际支持的模型名完全一致否则请求会被拒或者静默降级到不支持缓存的模型上——后者更麻烦因为你看不到报错只会发现 CH 一直是 0。先拿 Key。打开https://taotoken.net/api-keys创建一个新 Key复制出来。注意 Key 只在创建时完整显示一次关掉页面就看不到了建议直接存进环境变量而不是硬编码进配置文件。export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api然后是 Model ID。不同 Provider 的模型命名不一样Anthropic 格式下常见的是claude-sonnet-4-5、claude-opus-4-5这类OpenAI 格式下是gpt-4o、gpt-4o-mini这类。你可以在https://taotoken.net/models查到当前可用的完整列表。选模型时有个原则如果你要跑长会话优先选支持 Prompt Cache 的模型Haiku 系列和 4.5 以前的 Claude 对 tool_reference 的支持有限Deferred Tools 的优化在它们身上会退化成普通模式。Pi Agent 的配置文件通常在~/.pi/config.json或项目根目录的.pi/config.json。一个最小可用的配置长这样{ provider: { type: anthropic-messages, baseUrl: https://taotoken.net/api, apiKey: ${TAOTOKEN_API_KEY}, model: claude-sonnet-4-5 }, cacheRetention: short, sessionAffinity: true }这里cacheRetention设成short是默认值用 Provider 的标准缓存 TTLAnthropic 默认 5 分钟。如果你经常「边想边做、离开几十分钟再回来」可以改成longAnthropic 会用cache_control.ttl: 1hOpenAI 会用prompt_cache_retention: 24h用更高的缓存写入费换取跨长时间间隔的命中。但要注意long需要 Provider 侧确认支持很多第三方代理不接受 ttl 字段盲目发送会导致整个请求被拒。Pi 里有个supportsLongCacheRetention的 compat 字段做能力探测默认关闭、白名单开启。sessionAffinity打开后Pi 会在请求头里带上会话亲和性标识让同一会话的请求尽量落到持有缓存的那个后端节点上。Prompt Cache 是存在 Provider 后端某个具体节点上的如果第 11 轮请求被负载均衡路由到另一个节点即便前缀完全一致也会 miss。这个 header 的格式有三种openai / openai-nosession / openrouterPi 会根据 baseUrl 自动探测。配好之后先别急着跑长会话。用一次简单的请求验证三件套是否生效curl -s https://taotoken.net/api/v1/messages \ -H x-api-key: $TAOTOKEN_API_KEY \ -H anthropic-version: 2023-06-01 \ -H content-type: application/json \ -d { model: claude-sonnet-4-5, max_tokens: 64, messages: [{role: user, content: ping}] }如果返回里有正常的 content 字段说明 Base URL 和 Key 没问题。如果返回 401检查 Key 是否复制完整、有没有多余空格。如果返回 404 或 model not found检查 Model ID 拼写。3. 可复制配置前缀对齐模板与缓存断点放置配置能跑通只是第一步真正决定缓存命中率的是前缀对齐。这一节给出可以直接复制的配置模板覆盖 Anthropic 和 OpenAI 两种格式。Anthropic 的 Prompt Cache 需要显式标记断点cache_controlPi 在三个位置放置断点覆盖「每轮都重复发送」的三大块内容。断点一系统提示词。OAuth 场景下还会多一个前置 blockClaude Code 身份声明两个 block 都加缓存标记保证不管走 API Key 还是订阅 OAuth系统提示词都被缓存。} else if (context.systemPrompt) { params.system [{ type: text, text: sanitizeSurrogates(context.systemPrompt), ...(cacheControl ? { cache_control: cacheControl } : {}), }]; }断点二最后一个工具定义。只在最后一个工具上打标记而不是每个工具都打——Anthropic 的缓存断点语义是「缓存到这个位置为止的全部前缀」所以标记最后一个工具就等于缓存了「系统提示词 全部工具定义」这整段前缀。断点数量本身有配额限制Anthropic 最多 4 个这种「只在段落末尾打一个」的放置方式把配额用在了最有价值的地方。...(cacheControl index tools.length - 1 ? { cache_control: cacheControl } : {}),断点三最后一条 user 消息的最后一个 block。这个断点是「滚动」的——每一轮对话缓存边界都往后推到最新的用户消息/工具结果。效果是第 N 轮把前 N-1 轮的完整历史写入缓存第 N1 轮就能把这段历史整体作为 cacheRead 读回来。这是长会话成本控制的主力机制会话越长这个断点带来的节省越大。if (cacheControl params.length 0) { const lastMessage params[params.length - 1]; if (lastMessage.role user) { const lastBlock lastMessage.content[lastMessage.content.length - 1]; if (lastBlock (lastBlock.type text || lastBlock.type image || lastBlock.type tool_result)) { (lastBlock as any).cache_control cacheControl; } } }注意它显式检查了tool_result类型。因为在 Agent 场景里最后一条 user 角色消息经常是一批工具结果而非用户手打的文字如果只处理 text 类型工具调用密集的轮次就会全部漏掉缓存。这是一个「通用 LLM SDK 容易忽略、但 Agent 场景必须处理」的细节。OpenAI 的 Prompt Cache 是自动的不需要断点标记但需要一个 cache key 来提高路由命中率const disableImplicitPromptCache cacheRetention none compat.supportsExplicitPromptCacheMode; const params { model: model.id, input: messages, stream: true, prompt_cache_key: cacheRetention none ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention), prompt_cache_options: disableImplicitPromptCache ? { mode: explicit } : undefined, store: false, };三个细节值得说。prompt_cache_key用 sessionId同一个 Pi 会话的所有请求共享一个 cache key让 OpenAI 侧能把它们识别为「同一个不断增长的前缀」。clampOpenAIPromptCacheKey截断到 64 字符超长 key 会被 API 拒绝与其让请求整体失败不如截断——截断后仍然是同一会话内稳定的 key不影响命中。截断用Array.from(key)按码点而非字节切分避免把一个 emoji 或中文字符切成半个非法 UTF-8 序列。store: false不让 Provider 侧持久化请求内容这不是成本优化而是隐私默认值——用缓存不等于同意存档。现在把 Deferred Tools 的配置也加上。核心是splitDeferredTools()它决定哪些工具进前缀、哪些进 transcriptexport function splitDeferredTools( context: Context, enabled: boolean, normalizeName: ToolNameNormalizer identityToolName, ): { immediate: Tool[]; deferred: Mapstring, Tool } { const uniqueTools new Mapstring, Tool(); for (const tool of context.tools ?? []) uniqueTools.set(normalizeName(tool.name), tool); if (!enabled) return { immediate: [...uniqueTools.values()], deferred: new Map() }; const deferredNames new Setstring(); const usedNames new Setstring(); for (const message of context.messages) { if (message.role assistant) { for (const block of message.content) { if (block.type toolCall) usedNames.add(normalizeName(block.name)); } } else if (message.role toolResult) { for (const name of message.addedToolNames ?? []) { const normalizedName normalizeName(name); if (!usedNames.has(normalizedName)) deferredNames.add(normalizedName); } } } const immediate: Tool[] []; const deferred new Mapstring, Tool(); for (const [name, tool] of uniqueTools) { if (deferredNames.has(name)) deferred.set(name, tool); else immediate.push(tool); } return { immediate, deferred }; }toolResult.addedToolNames是关键数据来源。当某次工具调用比如加载一个技能导致新工具被注册时这个工具结果消息会记录「我新增了哪些工具」。这些「中途新增」的工具被归入 deferred不进入工具定义前缀而是序列化到 transcript 里Anthropic 用tool_referenceblockOpenAI 用tool_search_outputitem并带上defer_loading: true。usedNames的作用是如果一个工具已经被实际调用过toolCall 出现在 assistant 消息里说明它的定义已经稳定存在于某个位置就不再需要 defer 处理了避免对已稳定的工具做无意义的搬移。还有一个兜底逻辑不能少let immediateTools toolPlacement.immediate; let deferredTools [...toolPlacement.deferred.values()]; if (immediateTools.length 0 deferredTools.length 0) { immediateTools deferredTools; deferredTools []; }如果所有工具都被判为 deferred极端情况这个会话的全部工具都是中途加载的会把它们全部提回 immediate——因为「工具定义列表为空但 transcript 里有工具引用」对多数 Provider 是非法请求。优化不能以破坏正确性为代价。能力探测也很保守function defaultSupportsToolReferences(model: Modelanthropic-messages): boolean { if (model.provider ! anthropic || model.id.includes(haiku)) return false; const version model.id.match(/^claude-(?:opus|sonnet|fable)-(\d)(?:-(\d))?(?:-|$)/); if (!version) return false; const major Number(version[1]); const minor version[2] version[2].length 8 ? Number(version[2]) : 0; return major 4 || (major 4 minor 5); }默认只对「第一方 Anthropic、非 Haiku、Claude 4.5 及以上」启用。Haiku 会拒绝客户端发来的tool_referenceblock4.5 以前的模型不支持 tool search。默认关闭、白名单开启一个能省钱但可能让请求失败的优化宁可少省一点也不能让用户的请求报错。4. 验证请求缓存命中诊断脚本与对比步骤配置写好了怎么知道缓存真的命中了这一节给出一个可复制的诊断脚本以及提升前后的对比步骤。先写诊断脚本。核心思路是解析每一轮请求的 usage 数据算出「本应命中却没命中」的 token 量。Pi 的cache-stats.ts里定义了一个 miss 的计算方式const missedTokens Math.min(prev.promptTokens, promptTokens) - usage.cacheRead; if (missedTokens NOISE_FLOOR_TOKENS) return undefined; // NOISE_FLOOR_TOKENS 1024定义是上一轮请求的 prompt 里已经有的内容理论上这轮应该整段从缓存读实际却没从缓存读到的部分。取min(prev.promptTokens, promptTokens)是因为这轮 prompt 可能比上轮短压缩后只能按两者较小值来算「本应命中的上限」。NOISE_FLOOR_TOKENS 1024的噪声地板很重要缓存断点有粒度block 级别每轮总会有几百 token 落在断点之外这是机制固有的不算浪费。只有超过 1024 token 的 miss 才计入避免每一轮都报一个无意义的「你浪费了 200 token」警告。成本计算不用静态定价表而是从这条消息自己的 usage.cost 反推实际单价const paidTokens usage.input usage.cacheWrite; const paidPerToken paidTokens 0 ? (usage.cost.input usage.cost.cacheWrite) / paidTokens : 0; const readPerToken usage.cacheRead 0 ? usage.cost.cacheRead / usage.cacheRead : (models.getModel(message.provider, message.model)?.cost.cacheRead ?? 0) / 1_000_000; return { missedTokens, missedCost: missedTokens * Math.max(0, paidPerToken - readPerToken), ... };因为 miss 的 token 只可能落在 input 或 cacheWrite 桶里这条消息的成本明细里就直接包含了它们的真实计费单价含 cacheWrite 溢价。这样即便 Provider 有促销折扣、阶梯定价或者定价表过期算出的「多花了多少钱」依然准确。Math.max(0, ...)保证异常定价数据不会产生负成本。把这段逻辑包成一个独立脚本跑在会话日志上// cache-diagnose.ts import { readFileSync } from fs; interface UsageEntry { type: string; provider: string; model: string; usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; }; } const NOISE_FLOOR_TOKENS 1024; function diagnose(entries: UsageEntry[]) { let prev: { promptTokens: number; reportedCache: boolean } | undefined; let totalMissed 0; let totalMissedCost 0; for (const entry of entries) { if (entry.type compaction || entry.type branch_summary) { prev undefined; continue; } const usage entry.usage; const promptTokens usage.input usage.cacheRead usage.cacheWrite; if (!prev || promptTokens 0 || (usage.cacheRead usage.cacheWrite 0 !prev.reportedCache)) { prev { promptTokens, reportedCache: usage.cacheRead usage.cacheWrite 0 }; continue; } const missedTokens Math.min(prev.promptTokens, promptTokens) - usage.cacheRead; if (missedTokens NOISE_FLOOR_TOKENS) { const paidTokens usage.input usage.cacheWrite; const paidPerToken paidTokens 0 ? (usage.cost.input usage.cost.cacheWrite) / paidTokens : 0; const readPerToken usage.cacheRead 0 ? usage.cost.cacheRead / usage.cacheRead : 0; const missedCost missedTokens * Math.max(0, paidPerToken - readPerToken); totalMissed missedTokens; totalMissedCost missedCost; console.log(miss: ${missedTokens} tokens, ~$${missedCost.toFixed(4)}); } prev { promptTokens, reportedCache: usage.cacheRead usage.cacheWrite 0 }; } console.log(total missed: ${totalMissed} tokens, ~$${totalMissedCost.toFixed(4)}); } const entries: UsageEntry[] JSON.parse(readFileSync(process.argv[2], utf-8)); diagnose(entries);跑法npx tsx cache-diagnose.ts ./session-usage.json现在做对比验证。第一步在没开 Deferred Tools 的配置下跑一个 10 轮以上的会话记录页脚的 CH 值和诊断脚本输出的 total missed。第二步把splitDeferredTools的 enabled 打开重跑一个结构类似的会话再记录一次。实测下来在工具调用密集的会话里开启 Deferred Tools 后 CH 通常能从 60% 左右提升到 85% 以上total missed 下降一个数量级。提升最明显的场景是「中途加载技能」——不开 Deferred Tools 时每次加载技能都会击穿整个前缀诊断脚本会报一个几万 token 的 miss开启后这个 miss 会降到几百 token 的噪声级别。还有一个验证点会话亲和性。如果你发现 CH 忽高忽低、没有规律很可能是请求被路由到了不同节点。检查请求头里有没有带上x-session-id或session_id以及 Provider 是否支持。有些代理不接受带下划线的session_idheader会当成非法 header 拒绝这时需要切到openai-nosession模式只发x-client-request-id。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth配置和验证过程中最容易撞上的几类报错逐个拆。401 Unauthorized。最常见的原因是 Key 没传对。检查三处环境变量TAOTOKEN_API_KEY是否真的 export 了echo $TAOTOKEN_API_KEY看有没有值配置文件里是${TAOTOKEN_API_KEY}还是硬编码了一个过期 Key请求头字段名对不对——Anthropic 格式用x-api-keyOpenAI 格式用Authorization: Bearer。如果三处都对还是 401去https://taotoken.net/api-keys确认这个 Key 还在有效期内、没有被禁用。local proxy failed。这个报错通常出现在你本地起了代理层比如某些工具会自己起一个 localhost 转发但代理没起来或者端口被占。先确认代理进程在不在再确认端口没被别的程序占用。如果你没主动起代理检查配置里有没有残留的baseUrl指向http://localhost:xxxx——把 baseUrl 改回https://taotoken.net/api通常就好了。reading choices 相关报错。这类报错一般出现在解析响应时choices字段读不到。原因通常是请求打到了 OpenAI 格式的端点但配置里写的是 Anthropic 格式或反过来。Anthropic 格式的响应是content数组OpenAI 格式才是choices数组。检查配置里的provider.type和实际端点是否匹配。另一个可能是 Model ID 写错了端点返回了一个错误结构解析器按正常结构去读choices自然读不到。OAuth 相关报错。如果你走的是订阅 OAuth 而不是 API Key注意 OAuth 场景下系统提示词会多一个前置 blockClaude Code 身份声明。如果这个 block 缺失或者格式不对请求会被拒。另外 OAuth 的 token 有刷新机制长时间会话中途 token 过期也会报错重新走一次授权流程即可。用 API Key 的话不会有这个问题长会话更稳。CH 一直是 0。这个不是报错但比报错更隐蔽。可能的原因有三个Model ID 指向了一个不支持 Prompt Cache 的模型比如 Haiku 系列cacheRetention被设成了none或者 Provider 根本不报告缓存数据。第三种情况下Pi 的reportedCache粘性标记会起作用——只要这段扫描区间内曾经有任何一轮报告过缓存活动就认为这个 Provider 支持缓存此后出现的零缓存轮次才被当作真实 miss。如果从头到尾都没报告过诊断脚本不会产生噪声告警但你也拿不到命中率数据这时需要换一个支持缓存的模型或端点。miss 提示太频繁。Pi 的 UI 提示有三层过滤双阈值missedTokens 20000 或 missedCost $0.1 才提示、归因只陈述可观测事实切换了模型、或者空闲超过缓存 TTL、可关闭getShowCacheMissNotices()设置项。如果你觉得还是太吵先检查是不是真的在频繁切模型或长时间空闲——这两个是真实成本关掉提示不等于成本消失。如果确认是误报再考虑调高阈值。排查时有个通用原则先看页脚的四个桶数字再看诊断脚本的 miss 明细最后才去翻源码。大部分问题在第一步就能定位——input 桶异常大就是缓存没命中cacheWrite 桶异常大就是缓存被反复击穿output 桶异常大就是思考等级设太高。6. 把前缀稳定性变成默认习惯回到最开始那个问题Pi Agent 长会话为什么越跑越贵。答案不在「模型说了太多话」而在「重复发送的 prompt 没有命中缓存」。而缓存没命中的头号原因是前缀被中途新增的工具、动态拼接的系统提示词、或者不稳定的会话标识给破坏了。三条贯穿性的原则可以直接搬进你的日常配置习惯。成本优化的重心在「前缀稳定性」不在「少发内容」。Deferred Tools 这个机制不减少任何一个 token 的发送量它做的只是「把新增内容放到不破坏缓存前缀的位置」收益却可能是几万 token 的全价重新计费。理解「prompt cache 是前缀匹配」这一个事实比任何压缩技巧都更能降低成本。你可以在https://taotoken.net/api的接入文档里找到更多关于缓存断点放置的说明。优化永不以正确性为代价。Deferred Tools 在能力探测上采用默认关闭加白名单Haiku 会拒绝 tool_reference、旧模型不支持 tool search在 immediate 为空时无条件回退supportsLongCacheRetention在第三方代理上可关闭。每一个省钱机制都配了一条「这个 Provider 不支持时怎么办」的退路。你配cacheRetention: long之前先确认端点接不接受 ttl 字段不接受就老老实实用short。成本必须可见但不能变成噪声。这是cache-stats.ts整个模块的设计张力既要让用户知道钱花在哪20k token / $0.1 双阈值触发提示、压缩自身成本明示、切模型代价明示又要坚决避免误报1024 噪声地板、压缩豁免、reportedCache 粘性标记、只陈述可观测归因。一个天天喊「你浪费了 200 token」的提示最终会被用户关掉从此真正的重大浪费也一起看不见了。告警的价值取决于它的信噪比不取决于它的完备性。如果你要跑长期编码任务或者多 Agent 协作建议直接上 Coding Plan把会话亲和性和缓存保留策略一次性配好省得每开一个新会话都要重新调。配好之后把诊断脚本挂到会话日志上定期跑一次CH 低于 80% 就去看 miss 明细基本能覆盖绝大多数成本异常。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。