Skip to content

Latest commit

 

History

18 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Agent Engineering Log

学 Agent 开发过程中,自己动手做出来的东西和踩到的坑。不是教程代码的搬运——跟练的部分在 agenticloops-ai/agentic-ai-engineering, 这里只放我自己写的评测工具、重写的实现,以及从中提炼的规则。


核心内容:给一个 RAG 检索系统建评测闭环

顶石项目是一个 codebase navigator(索引本地代码库 → 向量检索 → LLM 回答)。跑通之后我发现 它答错了却看不出来——问 BLOCKED_COMMANDS 的值,它编了一个 6 元素的列表(真实是 10 个), 格式漂亮、语气笃定。

所以先不优化,先做能测准的那一层:60 条查询 + ground truth 的检索层评测集, 只测检索命中,不调 LLM,确定性且免费。然后每次只改一个变量,改一次测一次。

可归因的改进记录(28 条评测集时期)

改动 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

把评测集从 28 条扩到 60 条,三个 100% 消失了

路线里的合格线是「评测集 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 个百分点。

误差是双向的。 小样本不是「数字偏高」或「偏低」,是数字没有意义—— 它同时高估了三个类别、低估了第四个。

当前基线(60 条)

类别 中文 @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% 混合

评测集三次证明了自己的价值

  1. 抓出语料污染——73% 的 chunk 来自一个不该被索引的目录
  2. 抓出数据泄漏——把答案键建在语料目录内,分数从 43% 跳到 64%。看着很合理,所以差点就信了。 实测发现 36 条 ground truth 里 33 条(92%)原样写在被索引的 eval_set.json, 检索命中的是答案键本身。移出语料后真实值是 54%
  3. 拦下一个方向错误的优化——我已经规划好要改切块策略,评测集显示 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_pythonVectorStore 这类查询的关键 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 混合检索自学教程(跑通 → 重写 → 读透)

my_agent.py 修掉的两个洞

跟练的实现里:

  1. stop_reason 只判断了 end_turn。遇到 max_tokens 时会:不满足退出条件 → 找不到 tool_usetool_results 是空列表 → 追加 {"role":"user","content":[]} → 下次调用 400 崩溃。重写版每个枚举值都有分支,外加 else 兜底
  2. 子串黑名单不是护栏。实测:echo format 被拦("rm""format" 的子串,误伤), mv a.txt /dev/null 放行(漏网),python3 -c "..." 一句话绕过全部黑名单。 换成三层独立防御:拒 shell 操作符 → 可执行文件白名单 → shell=False

第 1 层为什么必须在第 2 层之前:白名单回答的是「哪个程序允许运行」, 它隐含前提是字符串里只有一个程序echo $(whoami)$(...) 是命令替换、 在主命令之前执行,你检查第一个词是 echo 就放行了,而 whoami 早跑完了。


指标本身也要修:hit@k → coverage@k

评测集原来有个 schema 缺陷:expect_contains一个字段同时承担了「或」和「与」两种语义

// 「任选其一」—— 两种写法都对
"expect_contains": ["stop_reason == \"end_turn\"", "stop_reason != \"tool_use\""]

// 「全部都要」—— 问的是完整管线经过哪些模块
"expect_contains": ["def chunk_repository(", "def index_chunks(", "def add_chunks("]

不拆开,任何聚合指标都是错的,而且不报错。现在拆成两个字段,由 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 块),所有数字与污染版逐格相同, 证明那个混杂变量是惰性的、结论成立。但这是事后才知道的: 混杂变量惰性与否要靠实验确认,不能靠猜。


混合检索(向量 + BM25):也是负面结果,但负得有价值

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% 的那类。

如果一个臂在所有类别上都被另一个臂支配,融合不可能有增益,只能有稀释。 融合的前提是互补。这个检查五分钟就能做,应该排在实现融合之前。 没有互补的时候,调权重是在给噪声调音量。

为什么 BM25 在代码上不行:缩写

英文查询 目标代码 词集交集
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 之前:先量清楚这是召回问题还是排序问题

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_file13 条的全部期望块都在 top-50 内。 像 q51 [6, 7]、q56 [6, 12]、q57 [12, 20] 这种差一点点就进 top-5, 正是 reranker 最容易拿分的形状;真正的离群点只有 q50 [84, 40, 52, 60]

这个测量没改一行代码,却直接决定了下一步做什么、以及做完之后的上限在哪。


Reranker:@1 涨 16 分,@5 只涨 1 分

上它之前先量了天花板(见上一节):英文 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% 的可用空间。

为什么:通用 reranker 对短块有系统性偏见

先猜是「重排破坏了多样性」,量了一下被证伪(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。

最该记的一点:同一个改动在不同 k 上价值完全不同

@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 判死刑。


第四个杠杆:长上下文 vs RAG —— 先量语料多大

「要不要 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。

About

给一个 RAG 检索系统建评测闭环:28 条查询的 recall@k 评测集、可归因的改进记录,以及 39 条「现象 → 规则」工程笔记

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages