一次 grep 花掉 3300 tokens:把 pi 的搜索链路换掉
About 20 min read
从一条推文的争论出发,读 pi 0.84.3 的 grep 实现,找出成本的真实来源,换成 FFF 引擎并把代价一起写清楚。
Contents · 11 sections · jump to a heading
起因是 @kaushikgopal 的一条推文,他在反驳另一位开发者 @chasen_liao 的说法。争论的对象是 pi coding agent 的内置 grep 是不是在往模型上下文里塞垃圾。
Chasen 的原话是「pi 内置的 grep 其实就是 rg --json 套壳,limit 100 + 截断 50KB + 默认带 --hidden,搜一次 TODO 这种高频词,直接 30 多条平铺甩进上下文」。Kaushik 回了三点,第一点是「Pi 内置 grep 默认是关的,模型需要搜索时通常会走 bash 里的 rg」。
两个人都在讲自己机器上的事实,但结论互斥。我想知道我这台机器上是哪种情况,于是去读了源码,顺手把它改了。
所有结论基于这几个版本,在 macOS / Apple M1 Pro 上实测:
- pi coding agent
0.84.3 @ff-labs/pi-fff0.10.5@mobrienv/pi-tidy-tools0.4.1@oh-my-pi/pi-coding-agent17.0.7- ripgrep
15.1.0
引用 fff 源码时用 v0.10.5 tag(commit 459ebcd)而不是 HEAD。写这篇时 GitHub 主干已经比 npm 上的 0.10.5 前进了,行号会错位——我第一次取锚点就取错了,DEFAULT_GREP_LIMIT 在 HEAD 是第 40 行,在 v0.10.5 是第 35 行。
pi 和 fff 的代码块都给出了 commit 锚点:pi 的上游是 earendil-works/pi(v0.84.3 tag,commit 4e58f32),引的是 TypeScript 源码,我逐段核对过它与本机 node_modules 里编译产物的一致性。pi-tidy-tools 和 omp 只以编译后的 npm 包分发,没有对应的公开仓库,那两处引文标了包版本和包内路径——可以在本地 node_modules 里逐行复核,但给不出不可变的外部链接。
token 数按「字节数 / 4」估算,不是 tokenizer 精确值,用于比较量级而非绝对成本。
先固定一个能贯穿全文的对象。Flowise 我本地这份有 2414 个受 git 跟踪的文件,useState 在里面有 1514 处命中,分布在 229 个文件。这是日常开发里非常普通的一次搜索:你想知道某个 hook 在哪些组件里用了。
按 pi 的 grep 实现,这次调用返回前 100 条。我把 rg 的 JSON 输出按 pi 的格式化逻辑重放了一遍:
1matches: 100 files: 23 bytes: 12804 ≈ 3201 tokens
3201 tokens 买到的信息是 229 个文件里的 23 个。分布也偏:前 100 条里 E2eExample.tsx 一个文件独占 18 条。换成 catch 更极端,前 100 条里 utils.ts 占 29 条,pnpm-lock.yaml 占 13 条。
问题不在总量,在于 100 这个额度是平铺的:先命中的文件把额度吃光,后面的文件一条都出不来。
pi 的 grep 工具在 packages/coding-agent/src/core/tools/grep.ts。核心是拼 rg 参数然后流式读 JSON:
1const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];2if (ignoreCase) args.push("--ignore-case");3if (literal) args.push("--fixed-strings");4if (glob) args.push("--glob", glob);5args.push("--", pattern, searchPath);
Chasen 说的「rg --json 套壳 + --hidden」在这里逐字成立。计数逻辑在 rl.on("line") 回调里:
1if (event.type === "match") {2matchCount++;3const filePath = event.data?.path?.text;4const lineNumber = event.data?.line_number;5const lineText = event.data?.lines?.text;6if (filePath && typeof lineNumber === "number")7matches.push({ filePath, lineNumber, lineText });8if (matchCount >= effectiveLimit) {9matchLimitReached = true;10stopChild(true);11}12}
matchCount 是全局单调递增的,effectiveLimit 默认 DEFAULT_LIMIT = 100。没有任何按文件分组的配额,达到 100 就 kill 掉 rg 子进程。额度是先到先得,而 rg 的输出顺序是文件遍历顺序,跟这个文件是否重要无关。
同一份代码里还有 DEFAULT_MAX_BYTES = 50 * 1024 和 GREP_MAX_LINE_LENGTH = 500(都在 packages/coding-agent/src/core/tools/truncate.ts 第 12、13 行),50KB 截断和单行 500 字符截断也都对得上。
Kaushik 说内置 grep 默认关闭。packages/coding-agent/src/core/sdk.ts 里确实这么写:
1const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];2const configuredDefaultToolNames = settingsManager.getDefaultTools();3const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined);4const excludedToolNames = options.excludeTools;5const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined;6const initialActiveToolNames = (7options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? defaultActiveToolNames))8).filter((name) => !excludedToolNameSet?.has(name));
--help 输出一致:grep - Search file contents (read-only, off by default)。我的 ~/.pi/agent/settings.json 里没有 defaultTools 字段,按这个逻辑 grep 应该不可用。
实际跑一下:
1pi -p "必须使用 grep 工具(不要用 bash)搜索 TODO,然后只回答一个词:成功 或 无此工具"2# 成功34pi --no-extensions -p "必须用 grep 工具搜 TODO;只回答 成功 或 无此工具"5# 無此工具
带扩展能用,不带扩展不能用。是某个扩展把它打开了。
翻我装的扩展,是 @mobrienv/pi-tidy-tools。它在 index.ts 里无条件注册了这几个工具:
1const sourceTools: Record<string, SourceToolDefinition> = {2read: createReadTool(cwd), write: createDiffingWriteTool(cwd),3edit: createEditTool(cwd), bash: createBashTool(cwd),4grep: createGrepTool(cwd), find: createFindTool(cwd), ls: createLsTool(cwd),5};6for (const [name, source] of Object.entries(sourceTools)) {7if (startupPlan.skipTidyTools.has(name)) continue;8pi.registerTool(decorate(source));9}
它包的就是 pi 原生的 createGrepTool,limit=100 平铺原样继承。而 pi 的 session 层在 agent-session.ts 里会把新注册的工具自动加进 active 集合:
1} else if (!options?.activeToolNames) {2for (const toolName of this._toolRegistry.keys()) {3if (!previousRegistryNames.has(toolName)) {4nextActiveToolNames.push(toolName);5}6}7}
任何扩展注册一个名字,这个名字就会被激活。defaultTools 只决定初始集合,管不了后续注册。
这解释了两个人为什么都对:Kaushik 说的是 pi 的默认值,Chasen 说的是装了扩展之后的实际行为。这条链路的问题在于它是隐式的——我从来没有主动打开 grep,是一个做终端渲染的扩展顺手带进来的,而且加载顺序一变就可能失效。
建一个能让平铺问题裸露出来的仓库:40 个文件,每个 10 处命中,共 400 处。
1rm -rf /tmp/greplimit && mkdir -p /tmp/greplimit && cd /tmp/greplimit && git init -q .2for f in $(seq 1 40); do3for i in $(seq 1 10); do echo "line $i MARKERXYZ here"; done > file$f.txt4done5# files=40 matches=400
让 pi 用 grep 工具搜一次,不带 limit:
1matches=100 files=10
100 条额度全部被前 10 个文件吃掉,剩下 30 个文件的 300 处命中一条没有返回。上下文里出现的是「10 个文件各 10 条」,而真相是「40 个文件各 10 条」。模型据此做的任何判断都建立在一个有偏样本上。
推文里提到两个方案,我加了一个自己的。
Kaushik 建议的 bash + rg,成本上是最差的一条。同一个 useState 搜索走裸 rg:
11513 行 / 179772 字节 ≈ 44943 tokens
pi 的 bash 工具会把输出截到最后 2000 行或 50KB(packages/coding-agent/src/core/tools/output-accumulator.ts 调的是 truncateTail,保留末尾丢掉开头),截完还剩约 12800 tokens,是走 grep 工具(3201)的四倍。而且保留末尾意味着你拿到的是 rg 文件遍历顺序最后那一段,这个样本连「先命中的文件」这点微弱的相关性都没有。
pi-better-read-edit 是推文第三点推荐的,但它解决的是 read/edit 而非搜索。顺带说,推文里那个链接 404 了,真实位置是单仓多包结构 kaushikgopal/pi-kaush/extensions/pi-better-read-edit。我这边 omp harness 的 read/edit 本来就是 hashline([PATH#TAG] 加行号操作),装了是重复实现,而且它 README 第一条迁移说明就是「先卸掉其他注册 read/edit 的扩展」。跳过。
@ff-labs/pi-fff,推文里提到「ffgrep 默认已经是 20 条加分页」。这条需要澄清版本:SamuelLHuber/pi-fff 那个老仓库里 DEFAULT_GREP_LIMIT = 100,README 顶部已经标注迁走了;迁移后的 dmtrKovalenko/fff monorepo(原名 fff.nvim,已改名)里才是 20。如果看的是老仓库,数字对不上是版本差而不是谁记错了。
装包:
1pi install npm:@ff-labs/pi-fff2# added 6 packages, and removed 319 packages in 22s
「removed 319 packages」这行需要核实,别的扩展可能被拆掉了。查了 settings.json 的 packages 列表仍是完整的 14 条,原生二进制 @ff-labs/fff-bin-darwin-arm64 也已落地,是 npm 的依赖去重而非误删。
装完默认不冲突,因为 fff 注册的是 ffgrep/fffind:
1const FFF_TOOL_NAMES: ToolNames = {2grep: "ffgrep",3find: "fffind",4multiGrep: "fff-multi-grep",5};6const OVERRIDE_TOOL_NAMES: ToolNames = {7grep: "grep",8find: "find",9multiGrep: "multi_grep",10};
这里出现了本次改造最实际的一个判断点。装完之后模型手里同时有 grep(100 平铺)和 ffgrep(20 分页)两个工具,选哪个取决于模型当下的判断。实测一次对照:
1grep: matches=100 files=222ffgrep: matches=20 files=6
两条路都活着,就等于没改。我需要的是单一路径,让 grep 这个名字直接指向 FFF 引擎。
pi 的 settings 里没有 excludeTools 字段(只有 CLI 的 --exclude-tools),defaultTools 又管不住扩展注册。所以只能从 fff 侧走 override 模式,它的模式解析顺序是 flag、env、默认值:
1let currentMode: FffMode =2(pi.getFlag("fff-mode") as FffMode) ??3(process.env.PI_FFF_MODE as FffMode) ??4"tools-and-ui";
第一次尝试直接撞墙:
1Error: Failed to load extension "@ff-labs/pi-fff/src/index.ts":2Tool "grep" conflicts with "@mobrienv/pi-tidy-tools/index.ts"
tidy-tools 已经占了 grep 这个名字。pi 的报错直接点名了冲突方,不用猜。
我先试过 tidy 自己提供的接管路径 /tidy pi-fff setup。它的设计正是为了解决这个撞名:把 fff 的 ffgrep 捕获后以 grep 的名字对外暴露,FFF 负责执行,tidy 负责展示。但在 tidy 0.4.1 × pi 0.84.3 这个组合上 preflight 失败了:
1Error: PIFFF_PREFLIGHT_FAILED: pi-fff adapter inactive:2a required running Pi capability is unavailable.
它要求的十个方法(registerTool、registerCommand、registerProvider、getFlag、events.on/emit 等)我逐个在 pi 的 packages/coding-agent/src/core/extensions/loader.ts 里查过,全部存在。这更像是 tidy 适配器在新版 pi 上的检测问题——它的 profile 表里写的是 @ff-labs/pi-fff verified 0.9.6,而 npm 上已经是 0.10.5。我没有继续深挖第三方适配器的 bug,那不是这次要解决的问题。
换成让 tidy 整体让位。它在 index.ts 里有一个早退:
1if (!tidyState.enabled) return;
关掉之后它不注册任何工具。代价要说清楚:会丢掉 tidy 的终端紧凑输出和 diff 渲染。但它对模型看到的内容没有影响——composeSourceTool 只注入一个 reasoning 参数并在 execute 里原样委托给源工具,不改返回值。这个取舍是「终端好看」换「搜索便宜」,不涉及正确性。
最终写进 ~/.zshrc:
1# pi search: route grep/find to @ff-labs/pi-fff (Rust, in-process, limit 20 + cursor)2# tidy-tools must stay off: its index.ts unconditionally registers grep/find and3# pi rejects the duplicate tool name at extension load.4export PI_FFF_MODE=override5export PI_TIDY_TOOLS=0
同时把 defaultTools 显式写进 ~/.pi/agent/settings.json,不再依赖扩展的副作用:
1"defaultTools": ["read", "bash", "edit", "write", "grep", "find", "ls"]
这一条不改变行为,只是把「碰巧能用」变成「声明式地能用」。改之前备份了 settings.json 和 .zshrc,都带时间戳后缀。
验证过程里我自己踩了一个坑,值得记下来。第一次用 zsh -lc 验证,环境变量是空的:
1PI_FFF_MODE= PI_TIDY_TOOLS=2matches=100 files=10 cursor=无
看起来像配置没生效。实际是 zsh -l(login shell)只读 .zprofile 不读 .zshrc,是验证命令错了,不是配置错了。换成 zsh -ic:
1MODE=override TIDY=02matches=20 files=2 cursor=有
在 Flowise 上:
1matches=25 files=6 cursor=有
25 而不是 20,因为 fff 的 pageSize 是每文件上限,总数可以略微超出。
单次搜索的上下文成本,同一批 pattern 对照(Flowise,2414 文件):
| pattern | 总命中 | pi grep(100 平铺) | fff(20 + cursor) | 降幅 |
|---|---|---|---|---|
useState | 1514 | 3333 tok | 633 tok | 82% |
catch | 1729 | 2003 tok | 446 tok | 78% |
export const | 627 | 2743 tok | 509 tok | 82% |
interface | 566 | 2170 tok | 417 tok | 81% |
降幅数字很好看,但它有一部分是「返回得更少」而不是「返回得更准」。这一点必须讲清楚,否则这篇文章就变成了给一个数字找理由。
fff 的每文件配额和总配额是同一个值:
1const grepResult = picker.grep(query, {2mode,3smartCase,4maxMatchesPerFile: pageSize,5pageSize,6// ...7});
maxMatchesPerFile 直接等于 pageSize,意味着 limit=20 时一个热文件理论上可以吃掉全部 20 条。在 /tmp/greplimit 那个人造仓库上就是这样:matches=20 files=2,40 个文件里只出现了 2 个。平铺问题在 fff 这里并没有从机制上消失,只是因为额度从 100 降到 20、且 FFF 按 frecency(访问频率)排序,实际表现好一些。
真正从机制上解决的做法是给每文件单独配额。omp harness(@oh-my-pi/pi-coding-agent 17.0.7)用的就是这套,dist/types/tools/grep.d.ts 里写得很直白:
1/** Maximum number of distinct files surfaced in a single response. The2* agent paginates further pages via `skip`. */3export declare const DEFAULT_FILE_LIMIT = 20;4/** Per-file match cap for multi-file searches — keeps a single hot file5* from crowding out diverse hits. Applied in JS after grep returns. */6export declare const MULTI_FILE_PER_FILE_MATCHES = 20;7/** Per-file match cap for single-file searches — there's no diversity8* concern when the scope is one file. */9export declare const SINGLE_FILE_MATCHES = 200;
「文件数」和「每文件命中数」是两个独立额度,注释里明确写了动机是防止单个热文件挤掉其他命中。我按这套规则在 Flowise 上重放过:catch 走 20 文件 × 20/文件 是 1307 tokens 覆盖 20 个文件,而 pi 的 100 平铺是 2142 tokens 覆盖 35 个文件。omp 这套在 token 上不如 fff 省,但样本偏差是可控的。
所以现状是三种取舍:pi 原生贵且有偏,fff 便宜但样本窄,omp 中等且样本均匀。我这次选了 fff,因为在 pi 这条链路上它是唯一能一步到位换掉引擎的选项。如果 tidy 的适配器在新版 pi 上能跑通,「FFF 执行加每文件配额」的组合会比现在更好。
另外几件没做的事:fff 自称的「20-50x vs rg」我没有测,只验证了它的 limit 默认值和分页行为;fff-multi-grep 的多模式 OR 搜索没有试;关掉 tidy 之后终端渲染确实变回原生样式,这个代价是真实的。
那条推文的争论,答案是两个人描述的是同一个系统的两个状态。Kaushik 读的是 pi 的默认配置,Chasen 描述的是装了扩展之后的运行时行为,而后者是绝大多数实际用户所处的状态——因为激活 grep 的不是用户的选择,是 agent-session.js 里「新注册的工具自动激活」这行逻辑加上某个扩展的副作用。
比这个争论本身更值得记的是:一个成本问题的现象、机制、和它看起来的原因可以完全对不上。--hidden 是真实存在的、可以指着源码说「就是这行」的事实,但它不是那 3200 tokens 的来源。如果我按 --hidden 去修,会得到一个正确的改动和一个没有变化的账单。