学 Agent 开发过程中,自己动手做出来的东西和踩到的坑。不是教程代码的搬运——跟练的部分在 agenticloops-ai/agentic-ai-engineering, 这里只放我自己写的评测工具、重写的实现,以及从中提炼的规则。
顶石项目是一个 codebase navigator(索引本地代码库 → 向量检索 → LLM 回答)。跑通之后我发现
它答错了却看不出来——问 BLOCKED_COMMANDS 的值,它编了一个 6 元素的列表(真实是 10 个),
格式漂亮、语气笃定。
所以先不优化,先做能测准的那一层:60 条查询 + ground truth 的检索层评测集, 只测检索命中,不调 LLM,确定性且免费。然后每次只改一个变量,改一次测一次。
| 改动 | chunk 数 | @5 (中文查询) | @5 (英文查询) |
|---|---|---|---|
| 基线 | 539 | 43% | 未测 |
① SKIP_DIRS 排除 repos/、data/ |
149 | 54% | 86% |
| ② 顶层常量单独成块 | 213 | 57% | 93% |
⚠️ 这三行是在 28 条评测集上、用 hit@k 口径测的。评测集后来扩到了 60 条, 这些数字在新评测集上不可复现,前两行的索引也已经不存在。保留它们是作为 改动可归因的历史记录,不是当前水平——当前基线见下。
改动 ① 是一行配置:索引时把 clone 进来的第三方仓库和向量库自身的持久化目录扫了进去, 539 个 chunk 里 391 个(73%)是垃圾。
改动 ② 见 rag-eval/patches/chunker.patch:原来只按 class/def
切块,模块级常量全被塞进同一个 chunk。一个 384 维向量要同时代表 docstring + import + 4 个常量 +
一份 tool schema,离哪件都不够近。受控实测(同一查询,只改被比较的文本长度):
| 查询 | vs 整个 chunk(1094 字符) | vs 只有那几行常量 |
|---|---|---|
SHELL_OPERATORS 包含哪些操作符 |
0.2670 | 0.5724 |
ALLOWED_COMMANDS 白名单里有哪些命令 |
0.2169 | 0.3969 |
路线里的合格线是「评测集 50–100 条起 + 明确基线 + 说得出对应什么场景」。补齐条数之后, 检索系统一个字都没改——语料、索引、检索代码、指标定义全部不动,只有查询集变了:
| 类别 (英文 @5) | 28 条 | 60 条 | n 变化 |
|---|---|---|---|
exact_identifier |
100% | 100% | 11 → 15 |
semantic |
100% | 81% | 8 → 16 |
value_lookup |
100% | 79% | 6 → 14 |
cross_file |
33% | 46% | 3 → 15 |
| 整体 | 93% | 76% | 28 → 60 |
那三个 100% 是小样本假象:n=8 和 n=6 时,「全对」只需要连续 8 次和 6 次不出错。
而 cross_file 的 33% 是被低估的——n=3 时一条查询就值 33 个百分点。
误差是双向的。 小样本不是「数字偏高」或「偏低」,是数字没有意义—— 它同时高估了三个类别、低估了第四个。
| 类别 | 中文 @5 | 英文 @5 | 指标 |
|---|---|---|---|
exact_identifier (n=15) |
93% | 100% | hit |
semantic (n=16) |
19% | 81% | hit |
value_lookup (n=14) |
36% | 79% | hit |
cross_file (n=15) |
4% | 46% | coverage |
| 整体 | 38% | 76% | 混合 |
- 抓出语料污染——73% 的 chunk 来自一个不该被索引的目录
- 抓出数据泄漏——把答案键建在语料目录内,分数从 43% 跳到 64%。看着很合理,所以差点就信了。
实测发现 36 条 ground truth 里 33 条(92%)原样写在被索引的
eval_set.json里, 检索命中的是答案键本身。移出语料后真实值是 54% - 拦下一个方向错误的优化——我已经规划好要改切块策略,评测集显示
semantic类的瓶颈 根本不是切块,是查询语言
all-MiniLM-L6-v2 是英文模型,语料是英文代码。同一语料、同一 ground truth,
只把 60 条查询从中文换成英文:
| 类别 (@5) | 中文查询 | 英文查询 | Δ |
|---|---|---|---|
exact_identifier |
93% | 100% | +7 |
semantic |
19% | 81% | +62 |
value_lookup |
36% | 79% | +43 |
cross_file |
4% | 46% | +41 |
| 整体 | 38% | 76% | +39 |
exact_identifier 是对照组:chunk_python、VectorStore 这类查询的关键 token
中英文本来就一样,几乎没有跨语言损失,所以它的差距(+7)远小于其余三类(+41 到 +62)。
这条对照证实了机制是查询语言,不是别的东西。
这个效应在 28 条评测集上首次测到(当时是 54% vs 86%),扩到 60 条后方向和量级都复现, 而且对照组依然成立——这是少数几个没有被样本量翻盘的结论。
修法是换多语言 embedding 或加查询改写,我选择不修——这条线的价值在于已经拿到的数字。
| 路径 | 是什么 |
|---|---|
rag-eval/ |
检索评测集与脚本(60 条查询,分 4 类) |
rag-eval/hybrid.py |
BM25 + RRF 混合检索(实测负面,保留作对照) |
rag-eval/rerank.py |
cross-encoder 重排(@1 +16,@5 +2) |
rag-eval/patches/chunker.patch |
对上游切块器的两处改动 |
agent-loop/my_agent.py |
关掉源文件凭理解重写的 agent loop |
NOTES.md |
60 条学习笔记,格式是「现象 → 规则」成对 |
learn/hybrid-retrieval.md |
混合检索自学教程(跑通 → 重写 → 读透) |
跟练的实现里:
stop_reason只判断了end_turn。遇到max_tokens时会:不满足退出条件 → 找不到tool_use→tool_results是空列表 → 追加{"role":"user","content":[]}→ 下次调用 400 崩溃。重写版每个枚举值都有分支,外加else兜底- 子串黑名单不是护栏。实测:
echo format被拦("rm"是"format"的子串,误伤),mv a.txt /dev/null放行(漏网),python3 -c "..."一句话绕过全部黑名单。 换成三层独立防御:拒 shell 操作符 → 可执行文件白名单 →shell=False
第 1 层为什么必须在第 2 层之前:白名单回答的是「哪个程序允许运行」,
它隐含前提是字符串里只有一个程序。echo $(whoami) 里 $(...) 是命令替换、
在主命令之前执行,你检查第一个词是 echo 就放行了,而 whoami 早跑完了。
评测集原来有个 schema 缺陷:expect_contains 这一个字段同时承担了「或」和「与」两种语义。
不拆开,任何聚合指标都是错的,而且不报错。现在拆成两个字段,由 schema 决定用哪个指标
(不是由 category 决定——类别是给人看的分组,不该拿来做判定):
| 字段 | 含义 | 指标 |
|---|---|---|
expect_any |
备选写法,命中任一即算答上(45 条) | hit@k |
expect_all |
答案分散在多处,全部必需(15 条) | coverage@k = 命中数 / 应命中数 |
| 要素 | 预测 | 实测 | |
|---|---|---|---|
| 方向 | 只降不升(逐条 coverage ≤ hit,数学约束) | 成立 | ✅ |
| 幅度(中文) | @5 持平 0%,@10 降到约 11% | @5 = 0%,@10 = 11% | ✅ |
| 幅度(英文) | @5 从 33% 降到 11~17% | 33%,没动 | ❌ |
| 作用范围 | 只有 cross_file 会动,其余三类一格不许变 |
12 个数字逐格未变 | ✅ |
英文那条为什么没降:唯一命中的 q27 本来就是 2/2 全覆盖,没有水分可挤。 而中文 q28 只捞回 3 个期望块里的 1 个,所以从 33% 掉到 11%。
「这个指标有缺陷」和「这个数字受了缺陷影响」是两件事。 高估的上限是数学决定的,高估了多少完全取决于数据。 修完测量方法数字不动不代表白改——改之前那个 33% 是「碰巧对」,改之后是「可信地对」。
「作用范围」那一项是这次最有用的自检:其余三类逐格未变,排除了「把公共代码路径改坏了」。 四要素里它最容易漏。
「给每个 chunk 加上它的文件路径,让向量带上模块身份」——这个假设我留了很久,
理由听起来很顺:cross_file 问的是模块层面的问题,而 chunk 的向量里根本没有模块信息。
做了,失败了。 改动是给每块前面加一行 # file: <相对路径>:
| 英文查询 @5 | 加前缀前 | 加前缀后 | Δ |
|---|---|---|---|
exact_identifier |
100% | 91% | -9 |
semantic |
100% | 88% | -12 |
value_lookup |
100% | 100% | ±0 |
cross_file (coverage) |
33% | 17% | -16 |
| 整体 | 93% | 84% | -9 |
它要救的那一类反而更差了,而且英文全线退步。唯一的收益在中文侧:
value_lookup 50% → 83%,中文整体 57% → 64%。
前缀是加在每一块上的固定成本,净效果取决于前缀比原内容更好匹配还是更差:
- 原内容已经能被精确匹配(英文查
CHUNK_SIZE = 50)→ 前缀只是噪声,稀释 - 原内容几乎没有可匹配文本(中文查一个 14 字符的常量块)→ 前缀是净新增信号
受影响最大的确实是短块,但方向不固定。同一批 chunk、同一个改动,
中文 value_lookup +33、英文 ±0——唯一的变量是查询语言。
评测集扩到 60 条后,我发现上面这个实验有个可疑之处:28 条里的 value_lookup
全是 BLOCKED_COMMANDS / MAX_STEPS 这类常量名本身就和查询词重合的查询,
根本不需要路径做桥梁——那个评测集测不出路径前缀的价值。
新增的查询里就有反例:MODEL_NAME = "all-MiniLM-L6-v2" 是个 31 字符的小块,
里面有 MODEL_NAME 和模型名,却没有 "embedding" 这个词,
查询 "which embedding model is used" 直接 MISS——而它所在的文件叫 indexer/embedder.py,
桥梁恰恰在路径里。
所以我用 60 条重跑了完全相同的改动,并预先写下一条可证伪的断言:
三条桥梁词齐全的英文
value_lookup查询,至少有两条应该从 MISS 变成命中。
| id | 前 | 后 | |
|---|---|---|---|
| q34 which embedding model is used | MISS | rank 4 | 救回 |
| q37 where are cloned repositories stored | MISS | rank 10 | @10 算,@5 不算 |
| q40 how many results does vector search return | MISS | MISS | 没救回 |
按 @5 口径 1/3,断言不成立。 而整体结果(英文 @5,n=60):
| 类别 | 基线 | 加前缀 | Δ |
|---|---|---|---|
exact_identifier |
100% | 87% | -13 |
semantic |
81% | 75% | -6 |
value_lookup |
79% | 71% | -8 |
cross_file |
46% | 43% | -3 |
| 整体 | 76% | 69% | -7 |
否决维持,而且比当初更硬(n=60 上 -7,n=28 上 -9)。
q34 的 top-1 和 top-2 都来自 indexer/embedder.py:路径桥梁确实把对的文件捞了上来,
只是正确的那一块排在同文件另外两块后面。桥梁增益是真的,但它换来的是每一块都变钝一点,
失去的比捞回来的多。
「这个改动在我指定的几条查询上按机制生效」和「这个改动值得做」是两件事。 前者验证的是因果,后者要看它在全部查询上的净效果。前者成立完全不蕴含后者。
我还顺手否掉了自己的第二个猜测——「前缀会把检索粒度从块拉向文件」。 量了 top-5 里的平均不同文件数:基线 3.267 → 加前缀 3.150,只差 0.12。 方向对,但幅度太小,不足以当解释。q34 那个「同文件两块霸榜」的例子是真的, 但它是个案。一个漂亮的个案不能升级成机制。
改动已回滚,基线八个数字逐格恢复。补丁留档在
rag-eval/patches/path-prefix-rejected.patch。
第一版实现把逻辑写成新的顶层函数 add_path_prefix,重建索引后 chunk 数
从 213 变成 214——加前缀不该改变块数。
原因:chunker.py 自己就在被索引的语料里,新增一个顶层 def 就等于给语料新增一个 chunk。
实测那个新块 6 次挤进 top-10,而英文退步的正是那几类查询——
当时无法区分是前缀导致的退步,还是新增竞争块导致的。
把逻辑内联进已有函数重跑(213 块),所有数字与污染版逐格相同, 证明那个混杂变量是惰性的、结论成立。但这是事后才知道的: 混杂变量惰性与否要靠实验确认,不能靠猜。
RAG 的四个杠杆之二。自己实现了 BM25(含代码感知分词:FILE_TOOLS 同时产出
file_tools/file/tools)和 RRF 融合,代码在
rag-eval/hybrid.py,用 RETRIEVER=hybrid 切换。
九组参数,没有一组打得过纯向量:
| cand_k | w_bm25 | exact | semantic | value | cross | 整体(英文 @5) |
|---|---|---|---|---|---|---|
| — | 纯向量 | 100% | 81% | 79% | 46% | 76% |
| 10 | 0.3 / 1.0 | 93% | 81% / 75% | 57% / 50% | 36% / 38% | 67% / 64% |
| 20 | 0.3 / 1.0 | 100% | 81% | 57% / 50% | 26% / 30% | 66% |
| 50 | 0.3 / 1.0 | 100% | 75% / 81% | 64% / 43% | 32% / 30% | 68% / 64% |
我直接实现了融合就跑,value_lookup 一个类别掉了 36 个百分点,花了一整轮排查。
回头做了本该最先做的事——把两个臂分开测:
| 类别(英文 @5) | 向量单独 | BM25 单独 |
|---|---|---|
exact_identifier |
100% | 93% |
semantic |
81% | 56% |
value_lookup |
79% | 36% |
cross_file |
46% | 27% |
BM25 在每一个类别上都输给向量,唯一接近的 exact_identifier 恰好是向量已经 100% 的那类。
如果一个臂在所有类别上都被另一个臂支配,融合不可能有增益,只能有稀释。 融合的前提是互补。这个检查五分钟就能做,应该排在实现融合之前。 没有互补的时候,调权重是在给噪声调音量。
| 英文查询 | 目标代码 | 词集交集 |
|---|---|---|
| what is the maximum number of agent loop steps | MAX_STEPS = 10 |
{steps} |
| where are cloned repositories stored | REPOS_DIR = Path |
空集 |
| how many results does vector search return | n_results: int = 5 |
{results} |
自然语言用完整词,代码用缩写。 BM25 没有词形归一化就跨不过去,
而 embedding 恰好能——它训练时学过 max ≈ maximum。
这不是「混合检索没用」,而是它的收益取决于查询和语料的词形是否对齐: 查询里直接写标识符时 BM25 rank 1、和向量并列;查询是自然语言时基本失效。 要在代码上真正用起来,得先补 stemming / 缩写词典 / 标识符展开—— 没补这一步,测到的是「没做词形归一化的 BM25」,不是「BM25」。
第一版 CANDIDATE_K=50,而语料只有 213 个块——候选列表覆盖 23% 的语料。
RRF 的分数是 Σ 1/(k+rank),同时出现在两个列表里的文档白拿一份加分,
于是平庸文档靠「两边都沾边」把向量的第一名挤了下去:
| 查询 | 向量 rank | BM25 rank | 融合后 |
|---|---|---|---|
| what is the maximum number of agent loop steps | 1 | 44 | 12 |
| what is the default model identifier | 2 | 无 | 22 |
教科书上的 RRF 是在百万级语料取 top-10,两个列表几乎不重叠;在 213 个块里取 top-50,
重叠是常态,机制就反过来了。CANDIDATE_K 要相对语料规模来设。
降权到 w_bm25=0.3 能把 value_lookup 从 43% 拉回 64%——参数确实占了那 36 个点的一大半,
但参数对了也救不回一个没有互补性的方案。先分清「调参问题」和「方向问题」。
实现保留在仓库里(默认 RETRIEVER=vector,行为不变),作为一个可复现的负面结果。
reranker 只能重排已经召回的东西——它修排序,不修召回。 所以上它之前,先花 20 分钟量一件事:那些没进 top-5 的期望块, 是根本没被捞出来,还是捞出来了但排在后面?
把 cross_file 的 36 个期望块在全量 213 名里的真实排名列出来:
| 排名区间 | 英文 | 中文 |
|---|---|---|
| 1–5(已在 top-5) | 42% | 6% |
| 6–50 | 47% | 14% |
| 51–100 | 11% | 28% |
| 101–213 | 0% | 50% |
完美 reranker 的天花板(假设能把候选池里的正确块全部提到前 5):
| 候选池深度 N | 英文 | 中文 |
|---|---|---|
| 5(现状) | 46% | 4% |
| 20 | 74% | 11% |
| 50 | 93% | 20% |
| 100 | 100% | 48% |
同一个系统给出了两个相反的答案。 英文是排序问题 —— 89% 的期望块在 top-50 内,一个都没有完全捞不到,+47pp 空间。 中文是召回问题 —— 一半排在 100 名开外,候选池开到 50 天花板也才 20%。 同一个 reranker,对英文价值巨大,对中文几乎没用。
顺带量到另外两类的天花板:semantic 81% → 100%、value_lookup 79% → 100%,
而且 N=20 就够,不必像 cross_file 那样开到 50。
不同类别的最优候选深度不一样,一刀切会浪费重排预算。
逐条看,15 条 cross_file 里 13 条的全部期望块都在 top-50 内。
像 q51 [6, 7]、q56 [6, 12]、q57 [12, 20] 这种差一点点就进 top-5,
正是 reranker 最容易拿分的形状;真正的离群点只有 q50 [84, 40, 52, 60]。
这个测量没改一行代码,却直接决定了下一步做什么、以及做完之后的上限在哪。
上它之前先量了天花板(见上一节):英文 89% 的期望块在 top-50 内,是排序问题。
用本地 cross-encoder ms-marco-MiniLM-L-6-v2,候选池 N=50,英文 @5:
| 类别 | 基线 | 重排后 | Δ |
|---|---|---|---|
exact_identifier |
100% | 100% | ±0 |
semantic |
81% | 88% | +7 |
value_lookup |
79% | 71% | -8 |
cross_file |
46% | 47% | +1 |
| 整体 | 76% | 77% | +1 |
天花板 93%,实际只吃到 2% 的可用空间。
先猜是「重排破坏了多样性」,量了一下被证伪(top-5 不同文件数 3.27 → 3.33)。
真正的模式在丢失的条目里——q35(66 字符)、q36(75 字符)掉了,
q40(3350 字符)反而涨了。聚合验证:
top-5 里 <200 字符的小块: 基线 36 个 → 重排后 14 个 (丢掉 61%)
top-5 chunk 字符数中位数: 657 → 1092 (+66%)
MS MARCO 是网页 passage。一个 66 字符的
DEFAULT_MEMORY_PATH = Path(...)长得完全不像模型见过的东西,于是被系统性打低分。 受害程度和块长度完全对应:value_lookup全是极短常量块,-8;semantic块中等偏大,+7。
| 配置 | exact | semantic | value | cross | 整体 |
|---|---|---|---|---|---|
| 基线 | 100% | 81% | 79% | 46% | 76% |
| N=10 | 100% | 81% | 71% | 50% | 76% |
| N=20 | 100% | 88% | 79% | 47% | 78% |
| N=50 | 100% | 88% | 71% | 47% | 77% |
池子越深天花板越高,但 reranker 犯错的机会也越多。净效果取决于它在你领域的准确率。 天花板测量告诉你上限在哪,不告诉你能吃到多少。默认已设为 N=20。
| @1 | @3 | @5 | @10 | |
|---|---|---|---|---|
| 基线 | 36% | 59% | 76% | 81% |
| 重排后 | 52% | 70% | 77% | 84% |
| Δ | +16 | +11 | +1 | +3 |
reranker 擅长把最好的那一个顶到最前,不擅长填满 k 个格子。
- 只展示一条答案 / 喂给 LLM 一个片段 → 看 @1,+16,非常值
- 要凑多块拼一个答案(
cross_file)→ 看 coverage@5,+1,不值
「这个改动有没有用」是个没有意义的问题,除非先说清楚用哪个指标。 我差点只看 @5 就把 reranker 判死刑。
「要不要 RAG」在我这里不是能力问题,是成本问题。 因为量过:
整个语料 52,480 tokens ← 完全塞得进 200k 窗口
检索 top-5 1,231 tokens ← 只送 2.3%
单轮成本(Sonnet 输入 $3/M):全塞 $0.157,检索 $0.004,43 倍。
但单轮差价会骗人。 API 无状态、历史每轮全部重发,成本是 O(N²):
| 10 轮对话 | 累计 tokens | 成本 |
|---|---|---|
| 全塞 | 2,886,400 | $8.66 |
| 检索 | 67,705 | $0.20 |
讲长上下文成本,不要说「长上下文贵」,要说 「它的成本随轮数平方增长,而 RAG 把那个平方项的常数压小了 43 倍」。
| 维度 | 长上下文赢 | RAG 赢 |
|---|---|---|
| 语料规模 | 稳定且 < 窗口 | 超窗口或持续增长 |
| 调用频率 | 单次、低频 | 高频 / 多轮 |
| 更新频率 | 无索引可过期,永远最新 | 改了要重建索引 |
| 答案分布 | 需要全局理解 | 答案集中在少数几处 |
第三行最容易漏:这个项目每改一次切块器就必须跑 reindex.py——
索引是一份会过期的副本,长上下文没有这个负担。
支持长上下文:cross_file 英文 coverage@5 只有 46%——
「完整流程涉及哪些模块」这类问题需要全局视野,RAG 天生只给局部。
支持 RAG:exact_identifier 已经 100%——
花 52k tokens 回答「execute_grep 在哪定义」是纯浪费。
所以答案是按查询类型路由,不是二选一。 而做这个判断的前提是能分辨查询类型——没有分类评测集, 你看到的只是一个 76% 的总分,看不出哪一类该走哪条路。
| 杠杆 | 结论 | 关键数字 |
|---|---|---|
| 切块 | 有效 | 清污染 + 常量单独成块,43% → 57%(28 条口径) |
| 混合检索 | 负面 | 九组参数无一胜过纯向量;代码缩写跨不过 BM25 的词形鸿沟 |
| reranker | 看 k | @5 只 +2,@1 +16;通用模型对短块有系统性偏见 |
| 长上下文取舍 | 路由 | 语料 52k 塞得下;10 轮对话成本差 43 倍 |
三个杠杆里两个的结论是「别做」或「看情况」。 这不是失败——能说清楚为什么不做,比做了一堆说不清效果的改动更接近真实工程。
诚实记录,没修:
cross_file仍是最弱的一类:英文 coverage@5 = 46%,中文 4%- 中文查询 38% vs 英文 76%(@5),已定位未修复
- 评测集 n=60,每类 14–16 条。边界附近单条排名的抖动仍是噪声,只看类别级别的系统性移动
- 只评了检索层。端到端答案质量需要 LLM judge,贵、慢、有随机性—— 先把能测准的层测准
见 rag-eval/README.md。检索层评测不需要任何 API key。