信任,但要进行基准测试:我们如何让 AI agent 优化 Elasticsearch

作者:来自 Elastic Thomas Veasey, Chris Hegarty

我们分享了如何构建一个测试框架,它能够在 Elasticsearch 代码库中自动识别并实施优化。

测试 Elastic 领先的开箱即用功能。深入了解我们在 Elasticsearch Labs 代码库 中提供的示例笔记本,开始 免费云试用,或者立即在你的 本地机器 上试用 Elastic。

Elasticsearch 执行各种不同的工作负载,包括持续进行大量索引构建以及实时搜索和分析。要在各方面提供出色的性能,就需要扩大覆盖范围,同时深入代码库,以充分了解每种工作负载的优化机会。传统上,人类的注意力一直是这一过程中的瓶颈;对于一个庞大且不断演进的代码范围来说,工程师没有足够的时间去审查每一条热点代码路径,以寻找低效之处。

然而,随着编码 agent 的快速发展, 性能优化 已经成为一项我们可以半自动化处理的任务。与许多软件工程挑战不同,优化代码提供了一个廉价且客观的验证器。如果你要求 AI 模型让代码运行得更快,那么最终会得到一个明确的数字,准确告诉你发生了什么,而分析工具还能够解释为什么会这样。这使得性能优化非常适合自动化,前提是你确实能够信任这些数字。

如果你只是简单地让一个编码 agent 针对某个基准测试进行优化,通常会得到较低的信噪比:优化收益可能落在环境波动范围之内,或者是由温度限制导致的变化,而不是代码真正变得更好。为了捕获真正能够让 Elasticsearch 用户受益的优化,我们必须弥合"原则上可以检查"和"实践中已经检查"之间的差距。我们构建了一个高度可信的测量循环:一个 测试框架,它假设 agent 有相当一部分时间会出错,但能够在它正确时可靠地捕获并证明这一点,然后帮助引导它下一步应该在哪里寻找优化机会。

一旦这套机制就位,结果便会说明一切。通过让这个测试框架在代码库中运行,我们已经开始发现整个技术栈中一些有意义的性能收益。在本文的第 2 部分中,我们将深入介绍它目前发现的一些示例,包括 Elasticsearch 查询语言(ES|QL)中的字符串转换效率问题、对我们 NEON 向量点积实现的改进,以及我们所使用的 gzip 库存在的升级机会。在这一部分中,我们将了解我们所做的设计选择,以及这些选择如何与更广泛的有效测试框架开发这一主题相关。

AI 代码优化流水线 架构

任何软件工程问题的第一步都是确定正确的高层组件。我们做出了一个事实证明对这个问题非常有帮助的架构选择:将理解优化机会存在于哪里,与进行代码修改的循环分离开来。agent 从一个真实工作负载开始,但只使用它来挖掘应该在哪里寻找性能改进的信息。在这一阶段,我们要求它扩大范围,并考虑一系列与性能相关的信号。在找到并分类热点之后,agent 会读取热点周围的代码上下文,以了解优化机会。我们使用一个独立任务,将经过排序的热点列表压缩成循环可以在几分钟内迭代处理的工件:一个微基准测试,我们能够证明它会在真实运行环境下执行热点路径。最后,我们使用提议者-验证器循环实际修改代码库,以改善其在基准测试上的性能。最后将工作交给验证环节,以评估对真实工作负载产生的影响。我们的 CLI(atune)提供了这一流程所需的工具,其余部分主要通过一组针对特定任务的指令实现 自动化 。

作为背景,我们的高层架构如下所示。粉色框代表人类,青绿色框代表 agent。这里有三种任务类型、一个骨架循环,以及一个裁判。

探索任务会对真实工作负载进行性能分析,并生成按优先级排序的优化机会;人类会将其中一个机会提升为利用任务,该任务针对经过批准的微基准测试进行迭代,并为每个被接受的实验提交代码;随后在真实工作负载上运行验证,以在人工审查并创建 PR 之前确保结果可靠。如果没有基准测试覆盖热点路径,则由基准测试任务创建一个,人类批准后将其加入注册表。性能图谱会为每个任务提供信息,并不断积累每个任务学到的内容。

为什么性能优化适合自主 agent

有三个特性使一项任务非常适合自主执行。值得明确说明这些特性,因为它们可以成为你评估自动化候选任务时的一份检查清单。你需要:

  1. 一个客观的结论,这样就可以根据某些标准约束 agent,而不是只听从它自己的判断。

  2. 一个密集的引导信号,让它知道下一步应该去哪里寻找,而不是靠猜测。

  3. 一个有界的影响范围,让犯错的成本处于可承受范围内。

性能优化同时具备这三点。基准测试提供结论,性能分析器提供梯度,而一个被拒绝的补丁付出的代价只是实际运行时间,而不是正确性。修改会被还原,原因也会被记录下来。生活继续。

关于前两点,我们逐渐认为,梯度比结论更加重要。"这个变快了" 是一个二元结论,而性能分析则会暗示下一步可以尝试什么。agent 可以根据丰富的引导信号生成下一个假设,引导信号越丰富越好,而不是机械地逐项尝试一个列表。

信号就是 agent 能够看到的内容

一个有用的思维模型是:CLI 就是 agent 的感知器官。这会改变你设计每条命令的方式。与其暴露某项能力,不如将其设计为针对当前任务中的某个问题返回清晰而简洁的答案,并在相关情况下提供模型可以进行推理的解释,而不是让它面对必须自行解析和理解的原始数据。这些就是 agent 据以采取行动的信号,最终我们为测试框架确定了以下信号:

信号 它回答的问题
按维度分解的宏观性能分析 对于每种查询类型,真实工作负载的时间都花在哪里?
同一次采集中进行分配和锁采样 成本来自 CPU 周期、垃圾回收,还是竞争?
成本构成分类 这是范围内计算、其他产品代码、GC、JIT 开销,还是休眠线程?
输入形状检测 工作负载实际上向这段代码提供了什么?
统计结论 在当前测得的噪声水平下,这项修改是否有帮助?
分配速率比较 新代码最终是否进行了更多分配?
已解释的反汇编 为什么会产生这个结果?
端到端 A/B 防护,以及差异性能分析归因 是否有什么东西出现故障,以及问题是否来自我们?
环境检查 这台机器现在是否适合进行测量?
上游重复问题搜索 是否已经有人报告或修复了这个问题?

其中四种信号值得进一步讨论,因为在每一种情况下,工具都编码了一项原本需要 agent 手动反复做出的判断。

按维度分解就是最明显的例子。混合 CPU 占比会掩盖覆盖范围:如果你对混合查询工作负载进行性能分析,分组哈希表插入和百分位数草图更新都可能只占总运行时间的个位数百分比,看起来似乎差不多。但它们实际上完全不可比较,因为几乎每个聚合查询都要付出哈希表插入的成本,而只有在有人请求百分位数时才需要付出草图更新的成本。因此,性能分析器会让每个命名的查询维度各自进行一次竞赛,而 agent 记录的每个优化机会都带有一个覆盖范围字段(通用、广泛或狭窄),并按照余量 × 可处理性 × 覆盖范围进行排序。一个通用的 3% 优化 胜过 一个狭窄范围的 10% 优化。将排序函数放入工具中,可以避免它在每次运行时都必须重新发现这一规则。

成本构成分类充当一个路由器。我们没有将一个简单的前 N 个栈帧列表交给 agent,而是将每个采样到的调用栈划分到"范围内计算"、"其他产品计算"、"GC"、"JIT 和安全点开销"、"CPU 外等待" 和 "休眠线程" 这些类别中。每个类别都意味着一种不同类型的调查方式。如果 GC 超过约 15%,真正的目标就是分配速率,而 CPU 热点栈帧反而会积极误导你,因为它们显示的是对象在哪里被回收,而不是对象在哪里被创建。如果 JIT 和安全点开销超过约 25%,你看到的就是一个上限,而不是一个优化机会,因为任何范围内的代码修改都无法改变它。如果线程处于休眠状态且核心利用率较低,那么这是一个并发问题,CPU 火焰图完全不是正确的工具。我们将这种映射作为一个表格写入操作手册,因此模型,即使是一个廉价模型,也能像经验丰富的工程师一样读取性能分析结果,而不是直接寻找最顶部的栈帧。不过,分类只是形成假设时的先验信息,而不是证据的替代品,因此 agent 在提出实验时仍然必须引用具体的栈帧。

已解释的反汇编是我们最初没有提供的一项工具,但它绝对值得加入。它有助于回答"为什么"这个问题,而这正是推动下一个假设形成的关键。火焰图告诉你时间花在哪里,但很少能告诉你为什么某项修改让情况变得更糟。因此,atune asm 会让 JIT 打印某个热点方法的汇编代码,然后短暂运行基准测试,分别捕获工作树修改前后的结果,将每一侧都缩减到最终的 C2 编译结果,对地址进行标准化处理,然后进行差异比较。仅仅这个差异结果很可能仍然有 4,000 行 AArch64 汇编代码,因此我们在此之上增加了一层解释:按指令助记符统计差异、净指令数量、实际捕获到的编译层级,以及向量化信号。向量化信号会统计两侧对向量寄存器的引用数量,并在这些引用被消除、减少一半,或者从 高级向量扩展(AVX)宽度缩减为 流式 SIMD 扩展( SSE )宽度时发出警告。随后,操作手册会将助记符模式映射到具体原因:

差异中的模式 可能的原因
b.eq/b.ne 增加,csel 减少 出现新的不可预测分支
围绕栈指针出现成组的 str/ldr 编译器的寄存器耗尽
NEON 加载被标量比较替代 向量路径出现性能退化

在一次实验中,agent 将两个 SIMD 掩码提取操作合并为一个,但基准测试性能下降了 26%。向量化警告在大约 10 秒内就解释了原因。如果没有这个工具,agent 就会陷入死路,并且没有一个关于机器工作方式的有效模型;有了它,agent 就获得了经过修正的模型以及几个新的思路。

第四种信号与其说是一项单独的工具,不如说是一种习惯;这些工具会自我检查。核心利用率通过两种相互独立的方式计算,一种来自采样密度,另一种来自进程采样,因此可以对两者进行比较。如果未分类部分超过规定限额,就会拒绝该分类,因为一个无法解释自身样本的细分结果不应该被用于推理。反汇编捕获工具会在捕获到的编译结果不是稳定状态编译结果时发出警告。上游重复问题搜索仅限于只读命令,并通过一个检查源代码的测试来强制执行这一限制,因此未来的任何修改都无法悄悄重新引入提交任何内容的能力。之所以存在这些机制,是因为一个能够让人确信自己是错的工具,比一个完全不存在的工具更糟糕。

有一个小型设计细节值得作为良好返回实践的具体实例特别强调。atune compare 在性能得到改善时返回 0,没有变化时返回 1,性能退化时返回 2,出错时返回 3,然后循环根据这个返回码进行分支。这样就无需进行解析,也不会对结论是什么产生歧义。此外,也不会浪费令牌去解释文字描述。

如果要从我们的 CLI 设计中带走一个要点,那就是一个更广泛的设计原则。在一般情况下,有趣的并不是我们发现的那些用于理解性能的单个信号,而是 CLI 正在将一位经验丰富的性能工程师的判断捕获并封装到工具中,让工具返回答案,而不是原始数据。以结构化方式将关于 agent 所处理问题的良好判断强制融入流程,可以改善结果。正确的 CLI 与指令本身一样,都是这一过程的重要组成部分。此外,与其返回数据,不如让工具返回决策和摘要,这样可以节省令牌。这意味着返回一个比较结论,而不是原始的 JMH 输出;返回一个分类摘要,而不是 4,000 行的反汇编差异结果。

从 20 秒探测到数小时的验证

构建一个你能够负担得起频繁调用的验证器,与构建一个你可以信任的验证器,是两个不同的问题。这解决的是可负担性问题。或者,如果你喜欢格言式的说法:真实工作负载是真相所在的地方,也是迭代走向终结的地方。一次宏观性能分析需要 45 到 60 分钟,而一次端到端验证运行需要数小时。但一个微基准测试只需要几分钟。这就是为什么先探索再利用的拆分方式有效;你只需要在真实工作负载上进行一次广泛探索,然后将工作交给一个可以在几分钟内反复迭代的微基准测试。

问题在于,只有当微基准测试在真实的运行条件下执行热点路径时,这种交接才是可靠的;也就是说,它必须具有正确的基数和正确的数据分布。如果这一步做错了,你的快速循环虽然运行得很快,却会朝错误的方向不断迭代。在我们最终确定交接流程之前,我们曾遇到过这样的情况:agent 在一个恰好有利于它的关键数据分布的基准测试上接受了修改,而只有端到端运行才发现了问题。

从探索阶段交接到利用阶段

两个阶段之间的交接是结构化的,而不是非正式的。探索任务的主要交付物是一组优化机会记录,每条记录都会包含利用任务获准编辑的范围路径、余量估计、分类(常数因子、结构性、分配或并发),以及它所依赖的基准测试(如果不存在基准测试,则明确标记为"没有基准测试覆盖")。每条记录还会包含一个狭窄的测试模式,以便让正确性检查保持低成本。之所以存在测试模式字段,是因为我们曾经遇到过一个具体问题:一个探索任务遗漏了该字段,导致后续的利用任务在每次实验中都运行一个非常繁重的测试套件。解决办法是修改上游工件,而不是在下游增加一条指令。这是我们反复采用的一种模式:尽可能早地做出决策并记录下来,而不是每次都重新推导。

在新基准测试可以作为任何任务的门槛之前验证它

如果确实缺少基准测试覆盖,则由专门的基准测试任务创建一个,而这个新基准测试必须先通过有效性检查,之后任何东西才能依赖它。其中两项检查是机械性的:基准测试热点自身时间中,有多少比例来自生产环境性能分析中实际出现的栈帧,以及参数是否落在我们测量得到的输入形状范围内。第三项是一份检查清单,agent 必须逐项确认。这是为了避免 JIT 所面对的环境比生产环境简单。

  1. 输入必须重新打乱,而不是固定或排序,这样分支预测器就不会表现得过于理想。

  2. 必须使用结果,否则死代码消除会删除你真正想测量的内容。

  3. 输入不能是编译时常量,否则它们会被折叠掉。

  4. 调用点看到的类型组合必须大致符合产品中的类型组合,因为单态调用点会进行内联,而多态调用点不会。

随后,人类会将它批准并加入一个由哈希固定的注册表。在此之前,它都是无效的,因为任务设置会拒绝任何引用未经批准基准测试的任务。我们认为这一批准是整个系统中最强的门槛,并且它的位置是经过刻意设计的。一个经过批准的基准测试可以在未来的每个任务中决定接受还是拒绝,因此这里是唯一一个我们要求人类在工件上签名,而不是在决策上签名的地方。

验证阶梯

在所有这些机制之下,是一个反馈机制层级,其特点是每一层都比下一层成本更低、能力更弱,而且低成本层级只用于拒绝。

层级 成本 作用
探测 约 20--60 秒 方向是否正确?绝不能据此接受
代码生成捕获 约 4 分钟 为什么会发生这种情况?
屏幕测试 约 5--15 分钟 廉价的统计过滤器
确认 约 20--60 分钟 接受决策
端到端 数小时 回归防护,仅供参考

这里接受与拒绝之间的不对称性发挥了实际作用。探测只是一次成对分支运行,而接受条件则要求进行完整的确认运行,并且必须匹配校准记录。agent 非常擅长讲出令人信服的故事;事实上,它们接受过许多同时由 LLMs 和人类进行评判的任务训练,因此令人信服本身就会得到积极强化。我们不希望 agent 能够通过对某个廉价信号进行有说服力的描述,就将它提升为一个决策。

对于这个任务基准测试而言,实际运行时间是一个需要考虑的真实成本。一次确认运行可能需要一个小时。因此,在选择配置时,需要仔细权衡所有涉及的成本。一个模型通常每四个假设能够成功验证一个,相比一个更便宜但每十个假设才能成功验证一个的模型,在端到端指标上通常更有优势。在一个长时间运行的循环中降低模型规格,是一种完全相反的常规直觉。当你的循环具有不确定的结果,并且除了消耗的令牌之外还存在显著成本时,你很可能也会发现自己处于同样的情况。

如何知道性能改进是真实的?

"它更快了吗?" 是一个统计学问题。因此,我们将接受条件编写成代码,而不是依赖判断,并将其放在 agent 无法绕过的地方。接受决策包含四个理念,在每一种情况下,我们所拒绝的替代方案与最终采用的选择同样具有启发意义。

分支运行是统计单位

每个 JMH 分支运行都会归结为其平均值,而结论来自精确的 双侧 Mann-Whitney U 检验,其中 α = 0.05,并结合每一侧 3 到 5 个分支运行平均值上的固定种子自助法置信区间。原因在于,一个分支运行中的各次迭代共享 JIT 和堆状态,因此存在自相关;将它们视为独立样本,会凭空制造出统计显著性。我们拒绝比较单次运行得分,因为那纯粹是噪声;也拒绝在迭代级别进行 t 检验,因为这种方法可能会自信地得出错误结论。为自助法设置固定种子意味着重新运行时可以完全复现相同的结论,因为 agent 必须能够区分结果发生了变化和结果从未稳定过这两种情况。

在时间上将候选版本与基线配对

屏幕测试(三个分支运行,可选择只覆盖部分参数)只用于淘汰不好的假设,大约 10 分钟即可完成,而不是等待一个小时。接受决策本身来自一次确认运行,通过切换暂存状态,让候选版本和基线版本背靠背进行测量。在不稳定的环境中,只有当两侧在相同条件下运行时,温度变化和后台活动的漂移才能相互抵消,因此一个数小时前测量的基线实际上是另一个不同的实验。

噪声下限是测量出来的,而不是假设出来的

任务能够接受的最小效果幅度必须超过针对特定机器上的特定基准测试,通过 A/A 校准得到的变异系数,而且确认运行和比较操作在没有匹配的校准记录时都会拒绝继续。笔记本电脑上的 1--2% 改进无法与噪声区分,而由于这个下限会随基准测试和 JDK 而变化,因此无论你选择哪个全局常数,在某些地方都会过于宽松,在另一些地方又会过于严格。

接受规则是复合的,并且刻意保持保守

只有同时满足 p < α、效果超过经过校准的下限,以及置信区间不包含零时,一个参数组合才会被视为有所改进。总体接受还要求没有任何地方发生性能退化(既包括主要基准测试,也包括防护测试),并且至少有一个主要参数组合得到改进。最终的核心指标是每个参数组合加速比的 几何平均数,它始终为正,并且可以跨实验进行组合,因此一个任务的累计改进是一个有意义的数字,而不是把无法直接比较的百分比简单相加。

什么绝对不能变慢

防护测试值得单独说明,因为它们回答的问题与主要基准测试不同;不是 "它变快了吗?" ,而是 "在它变快的同时,什么绝对不能变慢?"。任务定义因此会单独列出它们,它们通常是修改目标并非针对的操作和输入运行条件。

如果你正在优化哈希表的插入吞吐量,那么迭代就是一个防护测试,高冲突键分布也是如此。如果一项修改通过削弱哈希函数来改善常见情况,那么在均匀分布的键上看起来会很好,却可能让更加对抗性的输入发生灾难性的性能退化。我们知道这一点,是因为它确实发生过;在我们早期接受的一项实验中,冲突分布缺失于测试矩阵,而现在任务中已经加入注释,提醒未来的读者,对于对哈希质量敏感的范围,绝不能再次删除这一项。虽然防护测试会在每次确认运行中增加实际运行时间,但它们也能发现边缘情况中的性能退化,而省略它们从长远来看可能付出更大的代价。

值得防护的不只有运行时间。确认运行还会捕获两侧经过标准化的分配速率,并标记任何通过增加约 15% 以上额外垃圾来换取速度的变化。这项检查属于提示性质,而不是阻塞性质,因为有时这种权衡是正确的。不过,这类回归是单纯基于时间的接受规则很可能轻易放行的,而一位更了解调用上下文的经验丰富的性能工程师可能会对此提高警惕。

端到端门槛是单侧的,并且默认开放

端到端门槛是一个不同的统计问题:样本量小、噪声高,并且我们有非常强的先验理由应该基于微基准测试结果接受修改。我们最初的设计将接受和拒绝视为对等的,这导致了多个明显虚假的拒绝,因此重新设计后的门槛采用单侧检验并默认开放。

只有当中位数性能退化超过阈值,并且每一次候选版本重复运行都比每一次基线版本重复运行更慢时,一个操作才会被标记。这种完全分离条件是在当前样本量下的非参数单侧检验,并且能够抵抗偶尔会误导我们的单个异常值重复运行结果。随后,还必须通过差异 CPU 火焰图进一步验证这一标记,其中只有任务自身范围内 CPU 占比的上升才会被视为真实问题。

接近但未达到条件的情况会被报告出来以保持透明,但不会触发分类处理,而且任何内容都不会被自动拒绝;一个标记代表的是请求人类关注,而不是最终结论。我们还有最后一道防线,即每天已经针对 Elasticsearch 运行的大型性能测试套件。

单侧门槛的经验可以推广到基准测试之外。在存在充分先验理由接受的情况下,对于噪声较大的门槛,应采用单侧检验并默认开放。对噪声信号设置对称阈值不仅会让你损失真实的性能收益,还会让循环逐渐不再信任自己的测量工具,而这是一种代价高得多的失败。

探索和利用需要不同的权限

探索和利用看起来可能是同一项活动的两个阶段,但它们具有不同的输入(宏观工作负载与固定范围)和不同的输出(按优先级排序的优化机会与代码提交)。它们也有不同的失败模式,这意味着它们需要不同的权限。我们将这种拆分作为任务的一项一级属性,从而能够对其进行强制执行;探索任务实际上无法提交代码。基线、比较、检查点和验证 CLI 都会拒绝探索任务,基准测试只允许进行探测,而且每次探测产生的差异都会始终被还原。广泛的探索性调查是安全的,因为它所做的任何事情都无法修改代码。

一个很好的附带收益是提示词聚焦。每种类型都会完整读取自己的操作手册,同时明确不加载其他类型的操作手册。如果你试图编写一个同时涵盖"找到余量在哪里"和"在这个范围内实现一个经过验证的性能收益"的单一文档,最终得到的东西两方面都做不好,因为良好探索的指令(遵循性能分析结果、扩大搜索范围、优先进行广泛调查)与良好利用的指令(一次只验证一个假设、保持最小差异、绝不扩大范围)几乎正好相反。

AI agent 记忆:日志、知识库和事后复盘

会话是短暂的,但从会话中学到的东西并不是,因此测试框架会积累三种持久资产,以及一个从这些资产派生出来的一次性视图。这些资产包括我们尝试过的代码修改日志、关于代码如何运行及其性能表现的知识库、测试框架失败时的事后复盘,以及由于会话可以停止并恢复而产生的会话摘要。让它们能够协同工作的关键,是一条清晰的所有权规则,用于确定哪一类事实应该放在哪里。

日志记录我们尝试过什么以及测量到了什么。它是只追加的,每个任务一个文件,每次实验一条记录,并且在编辑代码之前写入。拒绝记录会带有一个面向未来的注释,形式类似于"不要因为 Y 而再次尝试 X",这可能是其中价值最高的一行,因为它能够阻止下一次会话重新走向一条死路。记录还会包含环境和驱动模型,这意味着不同模型之间的假设命中率可以进行比较。

知识库记录代码如何工作以及它的性能表现。它是一个按区域划分的摘要索引集合,每份摘要都会标记它所对应的提交版本,而且每份操作手册都会以一个维护步骤结束,将本次运行发现的所有持久性事实追加进去。例如,由于 JDK 的性能特征也会不时发生变化,一个新的 Vector API 可能会在 AArch64 上更高效地实现向量掩码,因此从性能分析数据中得到的发现也会标记其适用的 JVM 版本。

事后复盘记录 agent 过去犯过的错误,并按照症状而不是日期建立索引。当一个数字看起来不对时,会话真正想问的问题是 "我以前见过这种形式的错误吗?",而按时间排列的列表无法回答这个问题。因此,其中的记录会类似于"验证在与你的差异结构上无关的操作上失败",或者"屏幕测试报告没有匹配的参数组合"。

将日志和知识库分开听起来有些吹毛求疵,但实际上并非如此,因为如果没有这条规则,它们最终都会变成一种日记,容易不断膨胀上下文窗口,或者错过上下文中的关键信息。

一次性状态是会话交接,而我们的建议是永远不要手动编写它,并且如果可能,也不要要求模型生成会话摘要。我们的测试框架会根据日志机械地重新生成一页摘要,因此恢复会话时不会浪费时间和令牌去重新构建任务进行到了哪里。因为它是派生出来的,而不是人工编写的,所以不会像手工维护的内容那样逐渐偏离记录。这也是它不属于审计轨迹的一部分的原因;因为它可以很容易地从日志重新生成。

机械化的会话交接指向两个经验,而最终它们其实是同一个经验。每当一个人出现在循环中时,就会产生摩擦,同时也增加了出错的机会。每当你准备调用一个模型时,都应该问问自己,代码是否能够完成同样的工作。这对于一个主要前提就是将工作委托给模型的项目来说,似乎是一个奇怪的主张,但一旦手边有一个模型,这种习惯就很容易形成。无论判断发生在哪里,判断本身都是昂贵的,因此人和模型都必须凭借实际价值证明自己为什么应该参与其中。

对于持久的 agent 记忆,还有另外两点至关重要。第一,知识会腐化,因此必须对其进行检查。Elasticsearch 的主分支每天都在变化,这意味着知识库中的文件引用会逐渐失效,因此有一个检查器会对它们进行检查。另一个检查器会验证 agent 面向文档中引用的每条命令、每个标志和每条路径确实存在,验证每个事后复盘都已从索引中链接,并验证每种任务类型都有对应的操作手册,而且这些检查会作为测试套件的一部分运行。将为 agent 编写的文字视为可测试的工件,是它能够保持准确的原因。第二,加载纪律是记忆的一半。指令要求先加载索引,然后只加载与任务范围匹配的一到两个摘要;绝不能批量加载其余内容。你无法承担读取成本的记忆,就不能算是真正的记忆。

编码 agent 防护机制:隔离、范围和停止条件

Elasticsearch 拥有数百万行代码,而在如此规模的代码库上运行一个没有范围限制的"让它变快"任务,既无法进行审查,也无法证伪。而且它成本很高。反过来说,验证器只能保护它能够看到的内容,因此我们也必须对它能够修改的内容应用同样的边界。最终形成的任务会在任何代码被编辑之前,就确定其目标、允许访问的路径、基准测试、阈值和停止条件,而且在运行过程中,agent 无法修改其中任何一项。

人类负责安全边界,在任何代码被编辑之前定义范围、阈值和停止条件,并负责所有跨越信任边界的交接。agent 会话负责判断:读取性能分析结果、一次形成一个假设、在范围内进行编辑。atune CLI 负责机械化执行:范围检查、正确性检查、统计、校准、停止条件、环境检查以及信号。持久化状态则是记录:固定在基础提交上的工作树、只追加日志以及生成的报告。

隔离分为两层。第一层是粗粒度且与任务无关的;它是一个静态权限文件,允许会话编辑每个任务的工作树(其中包含 Elasticsearch 的本地分支、所有日志条目和 CLI 工件)以及知识库,同时拒绝访问干净的 Elasticsearch 克隆、任务定义、测试框架配置和审计轨迹。第二层则是精确且针对每个任务的:在任何构建之前,对已跟踪和未跟踪文件执行 git 级别的范围检查,因此超出范围的编辑会在进入基准测试或提交之前就被拒绝。

两层隔离带来了一个很好的推论。将第一层扩大到整个工作树不会产生任何成本,因为任何超出范围的内容都无法通过第二层存活下来。粗粒度隔离加上精确范围检查,比试图让单一机制同时完成两项工作更好,而这正是我们最初尝试的方式,结果是每增加一个新任务,就需要编辑一次权限文件。

停止条件是机械化的。系统设置了最大实验次数、最大连续拒绝次数以及累计改进目标,一旦其中任何一个条件触发,就会拒绝提出新的实验。人类可以覆盖这些限制;agent 不可以。这种不对称性使这些检查机制不依赖于究竟是哪个模型在驱动整个过程。

测试只能新增。新的测试文件会随检查点一起提交,而修改或删除现有测试则会被范围检查阻止。这样从机制上关闭了整个问题空间中最容易产生诱惑的捷径,而不是依靠指令来避免它;对于任何原本需要不断提醒 agent 不要采取的捷径,这似乎都是正确的处理方式。

测试框架位于它所优化的代码之外。Elasticsearch 克隆是一个独立的、被 git 忽略的目录,每个任务都会获得一个固定在基础提交上的工作树。这些收益会叠加:目标代码库始终保持干净,并且可以直接与上游合并,不会有任何与测试框架相关的内容泄漏到 PR 中;同时,审计轨迹也独立于每天都在变化的代码库进行版本控制。此外,任务彼此隔离,也与任何开发者的检出目录隔离。要针对更新版本的 Elasticsearch,就需要创建一个新任务,而不是重新指向旧任务,因为该任务的数字结果与它的基础提交绑定。这也意味着测试框架原则上可以重新定位,因为其中与 Elasticsearch 相关的部分是配置、知识库和基准测试注册表,而不是架构本身。

Agent 永远不会做出的决定

我们最终确定的规则是,agent 运行循环,而人类负责所有跨越信任边界的步骤。这包括创建工作、批准测量工具、发布分支或执行代码库之外的操作。这些操作都不在 agent 的允许列表中,而且每一项都有值得说明的原因:

  • 人类必须批准任务,因为被约束的对象不能自行设定自己的限制。

  • 噪声下限的校准需要针对每个基准测试、每台机器执行一次,而且默认情况下应该进行测量,而不是假定一个值。不过,这个过程成本相当高,因此如果人类非常了解运行环境,可以覆盖这一要求。

  • 通过提升某个机会来决定下一步优化什么,是一种判断,也意味着一个新的范围。

  • 由于基准测试之后会成为其他任务的检查条件,我们认为审查这个工件是正确性安全网的一部分。

  • 对于向外部产生影响的操作,例如推送分支或提交问题,在我们对流程充满信心之前,仍然交给人类完成。

  • 我们允许强制执行操作,但覆盖操作的机制必须位于被覆盖对象之外。

如何在工作流中呈现这些人类操作很重要。生成的报告和会话交接都会在这些人类操作当前到期的那一刻,打印出需要执行的操作,而不是让人类从操作手册中自行推断。

我们有意不对这些人工流程需要持续多久作出判断。对于一项新技术来说,适当的监督程度是一个需要通过经验回答的问题,我们更愿意对它进行测量,而不是争论它。我们一开始采用了相对较高程度的监督,因为在这个方向上犯错的成本更低(一个后来发现根本不需要的检查机制,更容易被移除;而一个已经发布的性能退化则没有那么容易处理),同时测试框架也让这个问题变得可以回答。每个检查机制都是一个有名称、有日志记录的状态转换,因此随着时间推移,我们可以看到哪些检查机制曾经真正改变过结果,哪些检查机制只是增加了摩擦。总结来说,先进行测量,然后再逐步改进。

构建测试框架本身也是同一种循环

测试框架的大量设计并不是从最初的设计文档中直接产生的。信号集合、排序函数、任务的结构以及操作手册规则的具体措辞,都来自观察一次运行出错之后的结果。如果这里有一条可以泛化的建议,那就是在它还没有准备好的时候就使用它,并且对自己的失望进行监测。

最清晰的例子是一条我们现在称为_不信任令人意外的结果_的规则。一次验证运行报告称,每个操作都出现了性能退化,其中最严重的达到了 14.8%。但这个结论有两重错误。一个目标操作模式错误地匹配到了完全不同的代码路径,同时,之前一次运行留下的过期输出目录也被与新输出一起读取了。由于这个结果提供了一个看起来合理的解释,agent 接受了这个解释,并回滚了一项实际上是正确的改动。

那次故障事件之后加入操作手册的内容并不是"要小心"。而是在针对一个令人意外的结论采取行动之前执行以下三步检查:

  1. 跟踪代码路径,并确认发生变化的对象确实能够到达你的差异。

  2. 阅读每次重复运行的原始数据,而不是摘要,并手动重新计算一个核心指标。

  3. 将报告的结构与一次已知正常的运行进行比较,因为结构不同的报告说明问题可能出在流水线,而不是代码本身。

与此同时,还有另一条重要规则:如果 agent 得出测试框架存在 bug 的结论,_不得_在运行过程中修复它,因为运行过程中的测试框架变更会使该次运行中的所有结果都无法进行比较。它应该记录证据并停止。

改进测试框架本身也是一个值得描述的循环。让模型审查自己的会话记录和测试框架文档,并让它自己提出规则,通常效果很好。它非常擅长发现自身指令中存在歧义的地方,而这种发现很难通过你自己重新阅读这些指令来复现。让这些输出真正有用的关键,是要有一个承载它们的地方:操作手册中的简洁规则、带日期的事后复盘中的叙述、按症状建立索引的记录,以及一个确保引用保持准确的检查器。

克制最终也成为同一种纪律的一部分。设计文档明确列出了一系列我们有意没有构建的扩展点,因为对它们的需求仍然只是推测。这与我们应用于优化循环的"不要猜测,等待证据"规则相同,只不过这次是把它应用到了我们自己身上。

这如何应用于性能优化之外的领域

其中一些主题并不专属于性能工作或 Elasticsearch。

可验证的工作是当前的前沿方向。这一洞见同样推动了具有可验证奖励的强化学习,也是 AlphaEvolve 所围绕的核心。agent 始终表现良好的任务,往往都带有一个成本低廉的裁判(测试、编译器、基准测试),而真正有意思的方向并不是寻找更多这样的领域,而是为那些缺乏裁判的领域制造裁判。性能优化恰好是一个很有启发性的案例,因为从某些角度来看,它的裁判似乎显而易见,但即便如此,构建这个裁判仍然耗费了大部分工程工作。

裁判模式同样具有普适性。将容易出错的优化器与机械化执行分离,与沙箱执行和策略引擎采用的是相同的结构:让模型负责判断,让代码负责不变量。实际结果是,系统的安全性不依赖于究竟由哪个模型来驱动它。较弱的模型可能会浪费基准测试时间,但它无法破坏代码,也无法接受一个虚假的成功结果。

Goodhart 定律 是任何使用 agent 的优化任务都必须面对的长期对手,而且有一篇值得阅读的形式化研究。agent 会_精确地_优化你告诉它要优化的东西,因此,两层基准测试结构、只能新增的测试、基准测试批准注册表、对抗性防护分布,以及一个让计时构建在机制上无法进行的标记文件,实际上都是同一个设计主题,只是穿着不同的外衣。

工具就是上下文工程。目前的共识正在逐渐远离"暴露一切",转向少量经过良好设计的能够返回摘要的工具:渐进式披露、自描述接口,以及提供结论而不是载荷的原则。

记忆正在成为架构的一部分。规则文件、每个任务的操作手册、持久知识库和只追加日志构成了一个具有不同生命周期、所有者和加载规则的层级体系,而其中真正困难的部分不是存储,而是淘汰和过期;因此才需要检查器。

最后,人类参与式流程是一个可以调节的旋钮,而不是一个开关,因此它应该处于什么位置,应当针对不同领域进行测量,而不是直接断言。

本文第 2 部分有什么内容

测试框架的设计是一组关于自主性能工作所需要条件的假设,而构建这个测试框架的目的,就是为了验证这些假设。第 2 部分就是这项测试:我们使用它提出的前四个 PR、它们带来的性能提升、每一个提升分别需要多少个假设、哪些检查机制真正捕获到了问题,以及测试框架在哪些地方反而妨碍了我们。这其中也包括那些没有通过端到端验证的改动,因为和往常一样,被拒绝的结果同样具有很高的信息价值。

原文:AI code optimization: Proving an agent's performance wins | Elasticsearch Labs

相关推荐
艾莉丝努力练剑4 小时前
【Git:综合复盘】Git 原理与使用
大数据·人工智能·git·elasticsearch·面试
Elasticsearch1 天前
列式存储并不等同于列式数据库。Columnar 模式为 Elasticsearch 带来了什么
elasticsearch
Elastic 中国社区官方博客1 天前
将 Vercel 数据导入 Elastic:无需安装任何东西的无服务器可观测性
大数据·运维·elasticsearch·搜索引擎·云原生·serverless·全文检索
Elasticsearch1 天前
我们如何将 PromQL 构建到 Elasticsearch 中
elasticsearch
Elasticsearch1 天前
你和你的 AI agent 不应该使用 curl:介绍 Elastic CLI 和 Agent Skills
elasticsearch
yunqiz2 天前
ELK Stack生产环境部署指南:Filebeat + Elasticsearch + Logstash + Kibana
elk·elasticsearch
dongsdh3 天前
部署python
大数据·elasticsearch·搜索引擎
听到微笑3 天前
Elasticsearch 如何存储与检索海量向量
数据库·elasticsearch
知福致福3 天前
【技术复盘】Git 分支污染与提交污染事故排查与解法
大数据·git·elasticsearch