一次 grep 花掉 3300 tokens:把 pi 的搜索链路换掉

About 20 min read

起因是 @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-fff 0.10.5
  • @mobrienv/pi-tidy-tools 0.4.1
  • @oh-my-pi/pi-coding-agent 17.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 里找 useState

先固定一个能贯穿全文的对象。Flowise 我本地这份有 2414 个受 git 跟踪的文件,useState 在里面有 1514 处命中,分布在 229 个文件。这是日常开发里非常普通的一次搜索:你想知道某个 hook 在哪些组件里用了。

按 pi 的 grep 实现,这次调用返回前 100 条。我把 rg 的 JSON 输出按 pi 的格式化逻辑重放了一遍:

1
matches: 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:

1
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
2
if (ignoreCase) args.push("--ignore-case");
3
if (literal) args.push("--fixed-strings");
4
if (glob) args.push("--glob", glob);
5
args.push("--", pattern, searchPath);

Chasen 说的「rg --json 套壳 + --hidden」在这里逐字成立。计数逻辑在 rl.on("line") 回调里:

1
if (event.type === "match") {
2
matchCount++;
3
const filePath = event.data?.path?.text;
4
const lineNumber = event.data?.line_number;
5
const lineText = event.data?.lines?.text;
6
if (filePath && typeof lineNumber === "number")
7
matches.push({ filePath, lineNumber, lineText });
8
if (matchCount >= effectiveLimit) {
9
matchLimitReached = true;
10
stopChild(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 字符截断也都对得上。

但 --hidden 这条被夸大了

Chasen 把 --hidden 列为病因之一。这条我单独验证了,结论是它几乎不构成成本。

建一个最小仓库:一个普通文件、一个点文件、一个被 .gitignore 排除的 node_modules:

1
cd /tmp/rgtest && git init -q .
2
echo "TODO x" > a.txt
3
echo "TODO hidden" > .secret
4
mkdir -p node_modules && echo "TODO nm" > node_modules/b.txt
5
echo "node_modules" > .gitignore
6
7
rg --json --line-number --color=never --hidden -- TODO .
8
# 命中:a.txt、.secret

--hidden 只让 rg 多看点文件,.gitignore 依然生效,node_modules 依然被排除。.git/ 目录 rg 本来就跳过——用 rg --json --hidden -- "ref:" . 试过,零命中;rg --files --hidden 也只列出三个文件,没有 .git 内容。

所以 --hidden 的实际影响是多扫 .env、.secret 这类点文件。这有安全含义(可能把密钥内容带进上下文),但不是「塞垃圾」的成因。真正的成本来自 limit=100 的平铺,跟 --hidden 无关。

一个诊断落到了正确的现象上,归因到了错误的机制上。这值得单独指出来,因为按 --hidden 去修不会让成本下降。

grep 默认是关的吗:源码说是,实测说不是

Kaushik 说内置 grep 默认关闭。packages/coding-agent/src/core/sdk.ts 里确实这么写:

1
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
2
const configuredDefaultToolNames = settingsManager.getDefaultTools();
3
const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined);
4
const excludedToolNames = options.excludeTools;
5
const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined;
6
const initialActiveToolNames = (
7
options.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 应该不可用。

实际跑一下:

1
pi -p "必须使用 grep 工具(不要用 bash)搜索 TODO,然后只回答一个词:成功 或 无此工具"
2
# 成功
3
4
pi --no-extensions -p "必须用 grep 工具搜 TODO;只回答 成功 或 无此工具"
5
# 無此工具

带扩展能用,不带扩展不能用。是某个扩展把它打开了。

翻我装的扩展,是 @mobrienv/pi-tidy-tools。它在 index.ts 里无条件注册了这几个工具:

1
const sourceTools: Record<string, SourceToolDefinition> = {
2
read: createReadTool(cwd), write: createDiffingWriteTool(cwd),
3
edit: createEditTool(cwd), bash: createBashTool(cwd),
4
grep: createGrepTool(cwd), find: createFindTool(cwd), ls: createLsTool(cwd),
5
};
6
for (const [name, source] of Object.entries(sourceTools)) {
7
if (startupPlan.skipTidyTools.has(name)) continue;
8
pi.registerTool(decorate(source));
9
}

它包的就是 pi 原生的 createGrepTool,limit=100 平铺原样继承。而 pi 的 session 层在 agent-session.ts 里会把新注册的工具自动加进 active 集合:

1
} else if (!options?.activeToolNames) {
2
for (const toolName of this._toolRegistry.keys()) {
3
if (!previousRegistryNames.has(toolName)) {
4
nextActiveToolNames.push(toolName);
5
}
6
}
7
}

任何扩展注册一个名字,这个名字就会被激活。defaultTools 只决定初始集合,管不了后续注册。

这解释了两个人为什么都对:Kaushik 说的是 pi 的默认值,Chasen 说的是装了扩展之后的实际行为。这条链路的问题在于它是隐式的——我从来没有主动打开 grep,是一个做终端渲染的扩展顺手带进来的,而且加载顺序一变就可能失效。

复现那个病症

建一个能让平铺问题裸露出来的仓库:40 个文件,每个 10 处命中,共 400 处。

1
rm -rf /tmp/greplimit && mkdir -p /tmp/greplimit && cd /tmp/greplimit && git init -q .
2
for f in $(seq 1 40); do
3
for i in $(seq 1 10); do echo "line $i MARKERXYZ here"; done > file$f.txt
4
done
5
# files=40 matches=400

让 pi 用 grep 工具搜一次,不带 limit:

1
matches=100 files=10

100 条额度全部被前 10 个文件吃掉,剩下 30 个文件的 300 处命中一条没有返回。上下文里出现的是「10 个文件各 10 条」,而真相是「40 个文件各 10 条」。模型据此做的任何判断都建立在一个有偏样本上。

三条候选路径

推文里提到两个方案,我加了一个自己的。

Kaushik 建议的 bash + rg,成本上是最差的一条。同一个 useState 搜索走裸 rg:

1
1513 行 / 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。如果看的是老仓库,数字对不上是版本差而不是谁记错了。

改造:把 grep 换成 FFF

装包:

1
pi install npm:@ff-labs/pi-fff
2
# 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:

1
const FFF_TOOL_NAMES: ToolNames = {
2
grep: "ffgrep",
3
find: "fffind",
4
multiGrep: "fff-multi-grep",
5
};
6
const OVERRIDE_TOOL_NAMES: ToolNames = {
7
grep: "grep",
8
find: "find",
9
multiGrep: "multi_grep",
10
};

这里出现了本次改造最实际的一个判断点。装完之后模型手里同时有 grep(100 平铺)和 ffgrep(20 分页)两个工具,选哪个取决于模型当下的判断。实测一次对照:

1
grep: matches=100 files=22
2
ffgrep: matches=20 files=6

两条路都活着,就等于没改。我需要的是单一路径,让 grep 这个名字直接指向 FFF 引擎。

pi 的 settings 里没有 excludeTools 字段(只有 CLI 的 --exclude-tools),defaultTools 又管不住扩展注册。所以只能从 fff 侧走 override 模式,它的模式解析顺序是 flag、env、默认值:

1
let currentMode: FffMode =
2
(pi.getFlag("fff-mode") as FffMode) ??
3
(process.env.PI_FFF_MODE as FffMode) ??
4
"tools-and-ui";

第一次尝试直接撞墙:

1
Error: Failed to load extension "@ff-labs/pi-fff/src/index.ts":
2
Tool "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 失败了:

1
Error: PIFFF_PREFLIGHT_FAILED: pi-fff adapter inactive:
2
a 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 里有一个早退:

1
if (!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 and
3
# pi rejects the duplicate tool name at extension load.
4
export PI_FFF_MODE=override
5
export PI_TIDY_TOOLS=0

同时把 defaultTools 显式写进 ~/.pi/agent/settings.json,不再依赖扩展的副作用:

1
"defaultTools": ["read", "bash", "edit", "write", "grep", "find", "ls"]

这一条不改变行为,只是把「碰巧能用」变成「声明式地能用」。改之前备份了 settings.json 和 .zshrc,都带时间戳后缀。

验证

验证过程里我自己踩了一个坑,值得记下来。第一次用 zsh -lc 验证,环境变量是空的:

1
PI_FFF_MODE= PI_TIDY_TOOLS=
2
matches=100 files=10 cursor=无

看起来像配置没生效。实际是 zsh -l(login shell)只读 .zprofile 不读 .zshrc,是验证命令错了,不是配置错了。换成 zsh -ic:

1
MODE=override TIDY=0
2
matches=20 files=2 cursor=有

在 Flowise 上:

1
matches=25 files=6 cursor=有

25 而不是 20,因为 fff 的 pageSize 是每文件上限,总数可以略微超出。

单次搜索的上下文成本,同一批 pattern 对照(Flowise,2414 文件):

pattern总命中pi grep(100 平铺)fff(20 + cursor)降幅
useState15143333 tok633 tok82%
catch17292003 tok446 tok78%
export const6272743 tok509 tok82%
interface5662170 tok417 tok81%

这次改造没有解决的问题

降幅数字很好看,但它有一部分是「返回得更少」而不是「返回得更准」。这一点必须讲清楚,否则这篇文章就变成了给一个数字找理由。

fff 的每文件配额和总配额是同一个值:

1
const grepResult = picker.grep(query, {
2
mode,
3
smartCase,
4
maxMatchesPerFile: pageSize,
5
pageSize,
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. The
2
* agent paginates further pages via `skip`. */
3
export declare const DEFAULT_FILE_LIMIT = 20;
4
/** Per-file match cap for multi-file searches — keeps a single hot file
5
* from crowding out diverse hits. Applied in JS after grep returns. */
6
export declare const MULTI_FILE_PER_FILE_MATCHES = 20;
7
/** Per-file match cap for single-file searches — there's no diversity
8
* concern when the scope is one file. */
9
export 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 去修,会得到一个正确的改动和一个没有变化的账单。

Share this note on Twitter / X · Browse all articles · Feedback via @qianyuhe.

Thanks for reading.

– 千羽鹤