Skip to content

工具发现重设计:会话内动态 tools 与 prompt cache 的根本矛盾 #879

Open

Description

@tt-a1i

这是 #300 Tool Surface Economics 的一个实测续篇,不是重复开题:#300 当时缺的是「动态工具面在真实长任务上到底付了多少钱」的运行证据,而最近一组 Terminal-Bench 对照跑分把这个问题推到了不得不正面回答的位置。

相关的设计探索已有一份未合并的文档草案(PR #878,仅文档、无运行时改动),本文希望成为它的公开讨论入口。

1. 跑分暴露的问题

在 Terminal-Bench 一组 10 个任务上,用 Harbor 驱动 OpenPI(pi --print --mode json,普通 tool-calling,openai-completions)与 opencode 做同题对照,观察到的现象是:

  • OpenPI 的 prompt cache 重叠命中约 59%,opencode 约 90%;opencode 从第 2 轮起基本稳定在 93–98%,OpenPI 则反复出现「整轮归零 / 腰斩」的 miss 回合,把整段 prompt 算进未缓存。
  • 最干净的单点证据是 kv-store-grpc:在 bg_start 成功之前缓存一直正常(cacheRead 约 5k+),OpenPI 随后动态注入了 bg_status / bg_list / bg_kill / bg_watch 四个 lifecycle 工具,下一轮立刻变为 cacheRead 从约 5504 腰斩到 2432。
  • 另外两个伴随现象:nginx-request-logging 中 OpenPI 护栏改写 transcript 后出现 cacheRead=0 的全量 miss;regex-log / log-summary 这类「大 thinking / 大 tool 输出」之后一轮整段记为未命中、再下一轮恢复高命中。

先排除一个误解:这轮跑分不是 Code Mode。 Harbor 实际拉起的是 pi --print --mode json(mode json 只是 harness 的 JSON 事件流),scriptMode 为 false,系统提示是标准编码 agent(read/bash/edit/write)。所以「缓存低是因为走了 Code Mode」不成立——问题恰恰出在没走、但又在会话中途改 tools 的这条路上。

证据边界(避免过度归因,这点与 PR #878 的处理一致):

  • 上述 59% / 90% 是今回跑分的对照数字,尚未在仓库内以固定 revision / model / 任务集复跑存档;请求按 provider 上报的 cacheRead 统计,且各家 cacheWrite 上报不完整(本轮 cacheWrite 全程为 0 却能 cacheRead),所以它适合作为「现象量级」而非精确价格结论。
  • Issue Runtime tool-surface toggling (context_pivot, background lifecycle tools) breaks the prompt prefix cache on models without mid-conversation tool support #644 曾把失效归因于工具显隐,作者随后复查并撤回了原主张:当时同时变化的还有系统段折回开头的问题。也就是说「工具数组本身」与「系统段变化」两条通道尚未拆开测量。本文的诉求正是把这件事测清楚,而不是预设结论。

2. 抽象问题:这不是 bg 的 bug

bg_* 只是最容易抓现行的一个。同一模式在 OpenPI 里是一整类:

根因是同一个:工具定义位于 prompt 前缀最前面(tools → system → messages),会话内任何一次对模型可见 tools 的增、删、改序,在不支持中途锚定的 provider 上都会使此前已缓存的前缀失效;即使能锚定,OpenPI 工具附带的 promptSnippet / promptGuidelines 变化还可能通过系统段再打一次。 做 K 次动态变更,就付 K 次接近全量的 miss。会话越长、能力用得越全,损失越大。

原来隐含的取舍是「省首轮 schema tokens > 后期缓存收益」。这组跑分说明,在长会话上这个取舍不成立:为了前期不暴露工具付出的成本,后期以数量级更大的方式还回来了。这正是想请社区一起重新判断的地方。

3. 同行共识(供讨论,非定论)

  • Pi(上游):MCP 默认 exposure: "codemode"——工具不声明进前缀,模型在 QuickJS 脚本里用 searchTools() / describeTool() 发现,mcp_servers 摘要变化时追加到对话而非改 tools;另有 deferred + tool_search 路径。Pi 已经有「不改声明集」的发现面,只是 OpenPI 自己的工具几乎都还走经典 schema + setActiveTools 显隐。
  • Anthropic:defer_loading 让延迟工具不进初始 prefix、以 tool_reference 追加;官方明确把改 tools 数组列为打穿 prompt cache 的行为。
  • OpenAI:建议 tools 保持稳定、用 allowed_tools/tool_choice 限制可调用集(mask 而非 remove);tool search 把新发现工具加在上下文末尾。
  • Manus:KV-cache 命中应作为第一指标,工具可用性用 mask 切换、不要从数组里删。
  • MCP 官方:大工具集走「search → inspect → execute」的 progressive discovery,并明确禁止中途增删前缀 tools。

4. 改进方向(按代价从低到高,均待讨论)

  1. P0 — 冻结规则:同一会话内禁止对模型可见 tools 做 Added / Removed / Reorder。先停掉 churn 源:bg_start 之后再注入 lifecycle、context_pivot 的按轮开关、Tasks/Goal 的状态显隐。能力组内改为「组加载时一次装齐 + 执行期检查」。
  2. P1 — 能力组边界定型:每个组只允许在首次加载边界改一次声明集,之后只增不减;「换 pack」视为新会话或显式边界。子代理工具只活在子会话自己的前缀里,主会话只保留固定的 spawn/await 合同。
  3. P2 — 大目录与 MCP 走 Pi 既有发现面:优先 deferred + tool_search 或 Code Mode,使发现结果只追加在后缀上,不动顶层 tools;不再重复实现一套搜索/执行网关。
  4. 失败关闭用冻结集,不用显隐:在不支持中途锚定的 provider 上,退化应是「一次声明、之后冻结」,而不是退回动态显隐。
  5. 测量先行:用 faux provider / 本地回放按「只改 tools / 只改系统段」拆开请求字节(tools 字节 SHA、tool-surface fingerprint、input/cacheRead/cacheWrite),Terminal-Bench 验收同时报告 cache overlap、tools 数组是否恒等、成功率与总成本。

5. 我的思考与待讨论问题

我的倾向:这件事的本质是「工具发现的序列化策略」,不是某个工具要不要常驻。发现(discoverable)、可见(visible)、授权(authorized)必须分开,但「可见」这一层在会话内应当近似只读。省 token 只允许发生在会话开始或能力组首次加载这两个边界上,每个边界最多吃一次 miss。

待讨论:会话边界画在哪?子代理工具面如何与父面解耦?Code Mode 在 DeepSeek 等模型上写脚本调工具的可靠性如何?tool_search 在不支持锚定的模型上是否仍等于改顶层 tools?「常用」应由采用率 × 净收益定义,目前缺数据。

6. 明确不做什么

想请大家表态:(1)「会话内 tools 只允许在会话开始/组首次加载定型」是否成立;(2) P0 三处 churn 源能否先独立停掉;(3) 大目录默认面选 Code Mode 还是 deferred+tool_search,需要什么最小证据。

先讨论,不预设结论;本文也不构成实现授权。

Activity

  1. tt-a1i commented on Oct 17, 2026

    @tt-a1i
    CollaboratorAuthor

    方案草案:单一默认工具集,会话内冻结(讨论稿,不构成实现授权)

    1. 结论

    • 放弃"初始工具集很小、按关键词动态加载"的设计。 它的目标是让大部分用户的体验接近原生 Pi,但实际代价是:每次加载都打穿 prompt cache;能力被藏起来,模型基本不用;还需要一套和 AGENTS.md 冲突的关键词路由。
    • 改为单一默认工具集:删并之后的 OpenPI 工具(约 8–10 个,估计约 3.5k token,实现后实测)对所有会话从第一轮起常驻。不设模式、不设逐组开关,用户不需要做任何选择。
    • 冻结原则(Decision 候选):同一会话内,模型可见的 tools 列表和工具附带的系统提示片段只在会话开始时确定一次。唯一的例外是用户显式进入/退出的边界(/plan、/goal、/openpi-setup):低频、由用户发起,每次进出接受一次缓存失效。模型的动作和运行时状态(bg_start、goal 状态、上下文阈值、提示词内容)一律不得改变 tools;状态不满足时由工具执行时检查并返回明确错误,或者向上下文末尾追加消息。
    • 大目录(MCP 等):交给 Pi 已有的 deferred / tool_search / Code Mode,也就是只往上下文末尾追加的发现面,不自己再造一套。
    • 明确接受的代价:装了 OpenPI 的会话,每次请求多出一份固定的工具定义(约 3.5k,被缓存后约等于 0.35k 正常输入 token 的价钱)。"OpenPI 默认工具面与原生 Pi 完全一致"不再是目标。

    2. 证据

    A. 源码机制(1940c38)

    • 所有显示/隐藏都走 patchOwnedTools → setActiveTools,见 extensions/shared/tool-surface.ts。
    • Pi 的 setActiveToolsByName() 每次都会执行 _rebuildSystemPrompt(activeNames)(pi-coding-agent/dist/core/agent-session.js:1104-1117)。所以 tools 数组和系统段在每次切换时必然一起变,Runtime tool-surface toggling (context_pivot, background lifecycle tools) breaks the prompt prefix cache on models without mid-conversation tool support #644 没能拆开的两条通道在当前 API 下本来就绑在一起。冻结 tools 可以同时消除这两条。
    • 新启用的工具被追加到数组末尾(projectedOwnerTools)。这和 kv-store-grpc 里 cacheRead 是"腰斩"而不是"归零"的现象一致(推断,未单独测量)。
    • 中途改动 tools 的地方:能力组加载(capabilities/index.ts:160-168,按提示词关键词触发);组内第二道门 bg_*(首次 bg_start 后)、tasks_*(每批任务一显一隐)、goal(创建/清除);context_pivot(agent_start 时按 30k 阈值,以及压缩后);plan / setup / interaction 这几个模式。

    B. 本地会话实测(维护者单人数据,0.2.0 之后共 236 个会话,混合多个 provider;只能看量级,不是基准)

    测量 结果
    openpi_load_tools 之后的下一轮缓存大面积失效(前一轮命中 ≥50%,间隔 ≤120s,下一轮命中 <50%) 80–100%(10 组样本,命中率平均下降 0.77–0.92)
    同一口径下的普通工具(read/bash/edit/rg) 0.5–1.7%
    门控对使用率的影响:rg / fd 出现的会话占比 0.2.0 前常驻时 44% / 37% → 门控后 16% / 12%
    0.2.0 后 bg_* / tasks_* 0 次 / 只出现在 1 个会话

    门控除了让缓存失效,还让模型不知道这些能力存在,实际效果接近把工具删掉了。生命周期门(如 bg_start 之后)造成的缓存失效,在本地数据中没有样本,目前只有 Terminal-Bench 那一份证据。

    C. 每个工具的成本:只统计 {name, description, parameters} 的 JSON 加 promptSnippet/promptGuidelines,token 按字符数 ÷ 4 粗估

    • Pi 原生 read/bash/edit/write:合计约 0.9k
    • OpenPI 全部 33 个工具:约 13.1k。其中 configure_my_pi_setup 一个就约 3.9k;除去它约 9.2k
    • 各能力组:search ≈1.6k(fd/rg 0.9k,git_* 0.7k);delegate ≈1.75k;workflow ≈1.0k;background ≈1.05k;session(tasks+goal)≈1.5k;interaction ≈1.1k;browser ≈0.55k;context_pivot ≈0.26k

    D. 其他 harness 怎么做(带 commit 链接和原文引用)

    • DeepSeek deepseek-harness(官方):plan mode 原文 "The tool catalog stays the same across modes for request-cache stability … those tools remain listed to keep the tool catalog unchanged"(packages/bundle/base/cordis.patch.yml)。
    • Claude Code:开启 tool search 时 "keeps the tool list from the conversation's first request for the whole conversation";macOS/Linux 默认不带 Glob/Grep,走 Bash;TodoWrite 默认关闭。
    • Codex CLI:没有专用的搜索/glob 工具;MCP 用 defer_loading + tool_search,目录变化以追加通知的形式告诉模型;prompt_cache_key = session id。
    • OpenCode v2:12 个原生工具 + execute,MCP 放进 CodeMode 目录,目录变化以追加 diff 的形式告诉模型;后台任务是 shell 的 background: true 参数;没有 todo 工具。
    • Manus:"avoid dynamically adding or removing tools mid-iteration",用屏蔽代替删除。
    • 反例:Goose 每次请求重建 tools 列表。

    3. 成本账

    设常驻工具占 T token、缓存读价格约为正常输入价的 r(常见是 0.1):

    • 常驻:首轮 T(Anthropic 写缓存另有溢价);之后每轮约 r·T。T≈3.5k 时每轮约等于 0.35k 正常输入 token。平均上下文 60k 的长会话,总成本多约 5%;平均上下文 15k 左右的小会话,多约 20%。
    • 中途加载:每次约 (1−r)·C(C 是当时的上下文大小),外加一次全量 prefill 的延迟;同时还有"能力被藏起来、模型不用"的隐性损失(见 2B)。

    单一默认可行的前提是先删并、再常驻:33 个工具 / 13k 全部常驻不可取(#20 当时退掉常驻也是这个原因),8–10 个 / 约 3.5k 可以接受。为了防止工具集以后又慢慢涨回去,加一个常驻工具预算检查:测试中实测 OpenPI 常驻工具的总 token 数,超过预算(实测值 + 少量余量)就失败;新增常驻工具必须先在 Issue 里说明它的价值和成本。

    4. 发现面失败关闭

    provider 不支持只往末尾追加的发现方式时,退化行为应该是"会话开始声明一次、之后冻结",不能退回中途显示/隐藏。

    5. 逐个工具的决定

    工具 ~tok 实际使用(0.2.0 后会话占比) 同行先例 决定
    openpi_load_tools + 整套关键词机制 0.12k 5.4%,每次都引起缓存失效 无 整套删除:关键词正则(shared/capability-intent.ts)、按关键词加载能力组和显示网关、隐藏的 skill 指引注入、编辑器里关键词高亮和闪烁的 UI 层、capabilities.discovery 配置,以及能力组 / loadOpenPiCapabilities 这套门控。是否委派、是否用后台任务,由模型根据用户原话判断;能力说明改由 Pi Skills 按需披露
    runtime_snapshot 0.07k 1 次调用 — 删
    fd / rg 0.89k 12% / 16% Codex 没有;Claude Code 在 POSIX 默认不带 删。Pi 原生 grep 底层就是 ripgrep,find 底层就是 fd,功能重复;所有只读白名单(READ_ONLY_AGENT_TOOLS、PLAN_MODE_CHILD_TOOLS、workflow replay)本来就包含 Pi 的 grep/find。损失的只有 file_type/hidden/max_depth/按目录类型过滤这些边角参数,确实需要时可以提给 Pi 上游
    git_show/diff/log 0.68k 1.5–4.5% Codex/Claude Code 用 shell 保留。它们是没有 bash 的只读场景(explorer 等只读子代理、规划子代理、workflow replay)里唯一能查看 git 历史的途径;而且会强制加上 --no-ext-diff --no-textconv,plan mode 的 bash 策略正是依赖这一点才拒绝原始 git diff/show(plan-mode/bash-policy.ts:12-20)。不在父会话常驻,只在 /plan 边界和只读子会话中出现
    subagent_*(6 个) 1.75k 9.4% OpenCode 1 个,Claude Code 是 Agent+SendMessage 保留,合并到约 3–4 个(spawn 带 background 参数、send、wait/check、cancel),常驻
    workflow*(3 个) 0.99k 5.9% — 保留,常驻;实现时评估能否合并 workflow_stop/workflow_status
    bg_*(5 个) 1.05k 0.2.0 后 0 次;0.2.0 前 5 个会话里 bg_status 被轮询 418 次 同行做法是 shell 加后台参数 + 完成时通知 合并成 1–2 个,常驻;完成时发事件通知以减少轮询
    tasks_*(3 个) 0.77k 0.5% OpenCode 没有;Claude Code 默认关闭 删,连同任务面板
    goal(3 个) 0.77k 由 /goal 发起 — 合并成 1 个(模型只需要读取和完成 goal)。由用户 /goal 发起,算作用户显式边界,进出时各改一次 tools;goal 生命周期内不再变化
    plan_ready、ask_user/human_handoff 1.3k 0.5–1% DeepSeek:plan mode 不改工具列表 只在 /plan、/openpi-setup 边界进出,模式内保持不变。现在 plan_ready 会在计划就绪后隐藏,这属于状态驱动的切换,要去掉;重复调用由工具执行时拒绝
    configure_my_pi_setup 3.9k 2% — 保留为 setup 流程专属工具,符合现有 AGENTS.md 约定
    context_pivot 0.26k 1.5%(3 次) Pi 有 compaction 删;如果需要,改成斜杠命令
    openpi_browser 0.55k — — 保持现状:浏览器控制是一项授权,仍然需要用户在 setup 中开启。它已经使用 Pi 的 exposure: "deferred",并在 session_start 时按配置决定

    做完这些之后:

    • 所有会话从第一轮起拥有同一份 OpenPI 工具集(子代理、workflow、后台任务),会话中途不再变化。
    • tools 只会在 /plan、/goal、/openpi-setup 这几个用户显式边界上变化。
    • 关键词路由、能力组门控、网关工具这些代码整体删除。

    需要同步修改的约定

    AGENTS.md "Package configuration contract" 里现在写的是:"expensive or privileged behavior must be opt-in"。建议改为:

    Installation must remain side-effect-safe: no provider/model names hardcoded as defaults, no model calls before explicit configuration, and privileged authority (for example browser control or optional packages) must be opt-in. Exposing a model-facing tool is not itself expensive behavior: the resident OpenPI tool set is one fixed, measured budget for every session, and any model call it enables must be initiated by the model within a user-started turn using the user's configured model.

    同时删除 tool-surface.ts 顶部"Ordinary parent sessions add no resident OpenPI tool"这一设计说明,以及 README / SETUP 里关于能力发现模式的描述。

    迁移注意

    • 删 fd/rg 时,需要同步更新只读白名单、workflow replay 的工具身份、plan mode 的提示词,以及 CHILD_SAFE_PACKAGE_TOOL_NAMES。用户已有的角色文件(例如 ~/.pi/agent/agents/*.md)里如果写了 fd/rg,应该忽略并提示,而不是直接失败。
    • 删 tasks 时,旧会话里持久化的任务记录要能被安全忽略。
    • 用户配置文件里旧的 capabilities.discovery 字段要能被安全忽略。
    • 去掉关键词门控后,过度委派的风险上升。工具描述和 skill 要写清楚什么时候该委派;上线后观察子代理调用率和单会话成本。

    6. 明确不做什么

    • 不设"精简 / 完整"模式,也不设逐组开关。冻结架构下,以后真有需要,加一个会话开始时的过滤即可。
    • 不让常驻工具集无约束地增长(见第 3 节的预算检查)。
    • 不自己再造一套 search/execute 网关或代码执行器。
    • 不用提示词代替运行时检查;状态不满足的调用必须在执行时被拒绝。

    7. 验收证据

    • faux provider 测试:一个会话内经过 bg / goal 状态变化、上下文跨过阈值、模型调用工具等全部非边界事件,断言每次请求的 tools 字节 SHA 和系统段 SHA 都不变;/plan、/goal、/openpi-setup 边界单独断言"进出时各变一次"。
    • 常驻工具预算测试(第 3 节)。
    • Terminal-Bench 用同一份 10 题的 task set 复跑:报告 cache overlap、tools 是否始终不变、成功率、总成本,固定 revision、model、任务和 verifier 的身份。
    • 本地复测脚本:会话工具使用率统计、"某工具调用后下一轮缓存失效率"统计。

    欢迎对第 1 节的原则和第 5 节的决定提出异议;如果没有异议,就按第 5 节拆成实现 Issue。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions