
1. 为什么我们要让 AI agent 来碰 Elasticsearch 的查询优化Elasticsearch 的性能调优这件事做过的人都知道它属于那种看起来有章可循实际上处处是坑的活。官方文档给了一堆参数什么refresh_interval、translog.durability、indexing_pressure、search.thread_pool但真正落到一个具体的业务查询上到底该调哪个、调到多少往往靠的是老司机的直觉加反复试错。我在过去几年里接手过不少查询慢得离谱的集群排查下来十有八九不是硬件不够而是查询 DSL 写得有问题——要么是wildcard前缀通配把倒排索引当成了全文扫描器要么是聚合层级嵌套太深导致global_ordinals爆内存要么是分片数拍脑袋定了个 50结果每个分片就那么点数据协调节点光做 merge 就累死了。问题在于人工调优的迭代速度太慢了。你改一个参数重启或者等_settings生效跑一轮压测看_nodes/stats和_search的 profile 输出分析瓶颈再改下一个。一轮下来半小时没了一天能试的方案也就十来个。而 Elasticsearch 的调优空间是组合爆炸的——光是查询层面的bool子句顺序、filter与must的取舍、track_total_hits的开关、size与from的分页策略再加上索引层面的 mapping 设计、分片路由、段合并策略组合起来轻松上千种。人工试试到明年也试不完。所以让 AI agent 来干这件事逻辑上非常顺agent 可以 7x24 小时不间断地生成查询变体、跑基准测试、读指标、根据反馈调整下一轮方案。它不会累不会因为连续失败十次就心态崩了也不会因为我觉得这个参数应该行而产生确认偏误。但这里有个致命的前提——你必须给它一套可信的基准测试框架。否则 agent 会陷入一种很危险的状态它优化的是它自己以为的指标而不是你真正关心的延迟和吞吐。这就是标题里那句话的由来信任但要进行基准测试。信任 agent 的探索能力但绝不信任它未经基准验证的结论。这篇文章适合两类人看一类是正在被 Elasticsearch 查询性能折磨、想引入自动化手段的后端或搜索工程师另一类是对 AI agent 落地感兴趣、想看看 agent 在真实基础设施优化场景里到底怎么搭、怎么用、会踩什么坑的开发者。我会把整个项目的设计思路、基准测试框架的搭建、agent 的循环逻辑、以及我们实际跑下来遇到的坑全部摊开讲。代码和配置能给全的我都给全你可以直接抄。2. 整体架构设计agent 负责探索基准测试负责裁决2.1 核心设计原则把生成和评判彻底分开这个项目最重要的一条设计原则就是生成查询方案的 agent 和评判方案好坏的基准测试必须是两套独立的东西。听起来像废话但很多人在做 agent 优化时会不自觉地让 agent 自己感觉哪个方案好——比如让 LLM 读一下 profile 输出然后说嗯这个看起来更快。这是灾难的开始。LLM 对数字的敏感度远不如它对文本流畅度的敏感度它很容易被一段看起来合理的解释带偏。我们的做法是agent 只负责提出假设和生成候选方案所有的裁决权交给一个确定性的基准测试 harness。这个 harness 做的事情很纯粹——给定一个查询 DSL 和一组参数在固定的数据集上跑 N 轮收集 P50、P95、P99 延迟、吞吐量、CPU 使用率、堆内存峰值、段文件数量变化然后输出一个结构化的 JSON 报告。agent 拿到这个 JSON才能决定下一步怎么走。提示基准测试 harness 必须是确定性的。同样的输入跑十次结果应该高度一致。如果你的测试环境有噪声比如共享 CPU、后台有别的任务在跑那 agent 的优化方向会被噪声带偏最后优化出来的方案在你的生产环境上可能完全不 work。2.2 为什么选 CLI 而不是 Web 界面或 SDK热词里出现了codex cli、claude cli、trae cli、deveco cli这些说明大家现在对 CLI 形态的 agent 工具接受度很高。我们这个项目也是 CLI 优先的原因有几个。第一Elasticsearch 的运维和调优本身就是命令行驱动的。你要看集群状态curl -XGET localhost:9200/_cluster/health?pretty要看慢查询日志tail -f logs/elasticsearch_index_search_slowlog.log要跑压测esrally或者自己写的wrk脚本。整个工作流天然在终端里。如果 agent 是个 Web 应用你还得在中间做一层命令封装和结果解析多此一举。第二CLI agent 更容易被脚本化和编排。你可以用cron定时跑可以用systemd管起来可以塞进 CI/CD 流水线里做回归测试。我们实际跑的时候就是让 agent 在一个tmuxsession 里持续跑我该干别的干别的偶尔tmux attach看一眼进度。第三CLI 的输入输出是纯文本对 LLM 极其友好。agent 读_search的 profile 输出、读_nodes/stats的 JSON、读基准测试的报告全是文本不需要处理什么图形界面状态。这一点在 agent 开发里很关键——输入输出的模态越简单agent 的可靠性越高。2.3 整体数据流从查询模板到优化报告整个系统的数据流大概是这样你提供一个查询模板带占位符的 DSL和一个参数空间定义哪些参数可以调、取值范围是多少。agent 根据当前轮次的反馈从参数空间里采样出一组具体参数填入模板生成一个完整的查询 DSL。基准测试 harness 接收这个 DSL在预置的数据集上跑压测收集指标输出 JSON 报告。agent 读取报告对比历史最优决定是接受这个方案、还是回退、还是继续探索邻近参数。重复 2-4直到达到停止条件比如连续 20 轮没有改进或者总轮次达到上限。输出最终的最优参数组合和完整的优化轨迹。这个循环里第 3 步是基石。如果基准测试本身不可信后面全是空中楼阁。所以下一节我会花很大篇幅讲基准测试怎么搭。3. 基准测试框架搭建让每一次测量都可信3.1 数据集准备别拿生产数据直接测第一件事准备一个固定的、有代表性的、大小适中的数据集。我们当时用的是某个日志场景的样本大概 500 万条文档每条文档有 20 来个字段包括时间戳、服务名、日志级别、消息体、几个数值型指标。这个规模的好处是单机就能跑一轮压测几分钟出结果同时又能暴露出分片、段合并、缓存这些层面的问题。太小了比如几万条测不出问题太大了几亿条迭代速度跟不上。数据集准备好之后冻结它。不要一边测一边往里面写新数据那样你的基线一直在变根本没法比较。我们的做法是建好索引之后把index.refresh_interval设成-1number_of_replicas设成 0然后_forcemerge到每个分片一个段之后就不再写入。这样每次压测面对的都是完全相同的段结构测量结果才有可比性。注意如果你要测的是写入性能优化那数据集策略完全不同——你需要一个持续写入的负载生成器而且要控制写入速率恒定。我们这个项目主要针对查询优化所以用的是只读数据集。这一点在项目开始前就要想清楚别做到一半发现测的方向不对。3.2 压测工具选型为什么最后用了自研的轻量 harness压测工具我们试过几个。esrally是官方出的功能全但太重了——它自带一堆 track 定义配置起来很繁琐而且它的报告格式对 agent 不够友好解析起来费劲。wrk和hey这类 HTTP 压测工具倒是轻但它们不理解 Elasticsearch 的查询语义你只能测一个固定的 URL没法方便地参数化 DSL。最后我们写了一个大概 300 行的 Python harness核心逻辑很简单import time import json import statistics import requests from concurrent.futures import ThreadPoolExecutor def run_single_query(es_url, index, dsl, warmupFalse): start time.perf_counter() resp requests.post( f{es_url}/{index}/_search, jsondsl, headers{Content-Type: application/json} ) elapsed (time.perf_counter() - start) * 1000 # ms resp.raise_for_status() return elapsed, resp.json() def benchmark(es_url, index, dsl, rounds50, concurrency4, warmup_rounds10): # 预热让文件系统缓存和 ES 的 query cache 进入稳定状态 for _ in range(warmup_rounds): run_single_query(es_url, index, dsl, warmupTrue) latencies [] with ThreadPoolExecutor(max_workersconcurrency) as executor: futures [ executor.submit(run_single_query, es_url, index, dsl) for _ in range(rounds) ] for f in futures: elapsed, _ f.result() latencies.append(elapsed) latencies.sort() return { p50_ms: statistics.median(latencies), p95_ms: latencies[int(len(latencies) * 0.95)], p99_ms: latencies[int(len(latencies) * 0.99)], mean_ms: statistics.mean(latencies), min_ms: min(latencies), max_ms: max(latencies), rounds: rounds, concurrency: concurrency, }这个 harness 有几个关键设计点值得说。预热轮次是必须的因为 Elasticsearch 的查询缓存、文件系统缓存、JIT 编译都需要时间进入稳态你不预热直接测前几轮的延迟会高得离谱把 P99 拉爆。并发度要固定因为不同并发下的最优参数可能不同你如果这一轮用 4 并发、下一轮用 8 并发那比较就没有意义了。百分位数比平均值重要得多用户感知的是 P99不是 mean。3.3 指标采集光看延迟是不够的延迟只是表象要理解为什么快为什么慢必须采集更细的指标。我们在每轮压测前后各采一次_nodes/stats重点看这几个指标路径含义关注点indices.search.query_total查询总次数确认压测确实打进去了indices.search.query_time_in_millis查询总耗时算平均单次耗时indices.search.fetch_totalfetch 阶段次数fetch 多说明返回文档多indices.query_cache.hit_count查询缓存命中命中率高说明查询可缓存jvm.mem.heap_used_percent堆使用率超过 75% 要警惕 GCindices.segments.count段数量段太多影响查询thread_pool.search.queue搜索队列积压有积压说明并发不够这些指标和延迟一起打包成 JSON交给 agent。agent 看到延迟高 query_cache 命中率低就知道该往缓存友好的方向调看到延迟高 堆使用率高 GC 频繁就知道该减少聚合的内存占用。给 agent 的上下文越丰富它的优化方向就越准。3.4 基线建立没有基线就没有优化在让 agent 跑之前你必须先手工跑出一个基线。这个基线就是你当前生产环境在用的那套查询和参数。所有 agent 提出的方案都要和这个基线比。我们当时定的接受标准是P95 延迟降低 15% 以上且 P99 不劣化超过 5%才算有效改进。为什么 P99 要单独卡因为有些优化是牺牲尾部换平均——比如加大size让单次返回更多、减少请求次数平均延迟降了但单次请求变重P99 反而涨了。这种方案在生产上是要出事的。提示基线不是测一次就完事。每次你改了测试环境比如重启了 ES、换了 JVM 参数、系统更新了都要重新测基线。我们吃过这个亏——有一次系统自动更新了内核文件系统缓存行为变了基线整体偏移了 20%结果 agent 之前优化出来的方案全都不准了白跑了两天。4. Agent 的循环逻辑怎么让它越跑越聪明4.1 Agent 的输入输出契约Agent 每一轮接收的输入是一个结构化的 JSON包含当前轮次和历史最优方案的参数最近 K 轮我们用的 K10的完整报告包括参数和指标当前基线指标参数空间的约束哪些参数可以动、范围是多少Agent 输出的也是一个 JSON包含下一轮要试的参数组合它对这组参数的假设为什么觉得这组会更好如果这组参数验证有效它建议的下一步探索方向这个假设字段很重要。它逼着 agent 做有根据的探索而不是随机乱试。而且当优化失败时这个假设能帮你复盘——是假设本身错了还是假设对但参数没调到位。4.2 探索策略从粗到细从单变量到组合Agent 的探索策略我们设计成三个阶段。第一阶段单变量扫描。对每个可调参数在它的取值范围内均匀取 5-7 个点其他参数固定在基线值逐个测。这一阶段的目的不是找到最优而是搞清楚每个参数的大致影响方向和敏感度。比如我们发现track_total_hits从true改成10000P95 直接降了 30%那这个参数就是高优先级。而preference参数怎么调都没啥变化那就先放一边。第二阶段局部搜索。在第一阶段找到的每个参数的较优区间内做更细的采样。同时开始尝试两两组合。这里 agent 会用到第一阶段学到的参数敏感度来决定组合的优先级——敏感度高的参数优先组合。第三阶段贝叶斯式微调。到这一阶段agent 已经积累了几十轮的数据它会用一个简单的代理模型我们用的是高斯过程也可以用随机森林来预测哪些参数组合可能更好然后优先测这些预测值高的点。这一步能显著加快收敛。4.3 反馈信号的设计别让 agent 只看一个数如果只给 agent 一个延迟数字它会陷入局部最优——比如把所有能减少返回数据量的参数都拉满延迟是降了但查询结果的完整性没了。所以我们的反馈信号是一个加权评分score 0.5 * (baseline_p95 / current_p95) 0.3 * (baseline_p99 / current_p99) 0.2 * (baseline_throughput / current_throughput) - penalty_for_correctness_loss其中penalty_for_correctness_loss是关键。我们在 harness 里加了一个正确性校验每轮压测时除了跑性能测试还会跑一次结果校验——用优化后的查询和基线查询分别查同一批样本对比返回的文档 ID 集合和聚合结果是否一致。如果不一致直接判负不管延迟多低。这一条卡死了很多作弊式优化比如偷偷把minimum_should_match调低、把fuzziness关掉之类的。注意正确性校验的样本要覆盖各种边界情况——空结果、单结果、大量结果、聚合为空、聚合有大量桶。我们一开始只测了有结果的情况结果 agent 优化出一个方案在空结果时直接报错差点漏过去。4.4 停止条件与回滚机制Agent 不能无限跑下去。我们设了三个停止条件满足任一就停连续 20 轮没有产生新的最优方案。总轮次达到 200 轮。单轮压测耗时超过 10 分钟说明环境出问题了。停止之后agent 输出最终方案和完整的优化轨迹。但最终方案不会自动上线而是进入一个人工审核环节。审核的内容包括参数是否在合理范围内、正确性校验是否全部通过、有没有引入新的风险比如把refresh_interval设得过大导致数据可见性延迟。审核通过后才通过配置中心推送到生产。回滚机制也很简单所有参数变更都走配置中心配置中心保留版本历史出问题一键回滚到上一个版本。我们实际跑的时候有一次 agent 优化出一个方案在测试环境 P95 降了 40%但上线后发现在真实流量下查询模式比测试集复杂得多P99 反而涨了。还好有回滚五分钟就恢复了。5. 实操过程从零搭起这套系统5.1 环境准备单机 ES 加 Python 就够了你不需要一个庞大的集群来跑这套东西。我们整个项目就是在一台 16 核 64G 的机器上跑的ES 单节点堆内存给了 16G剩下的留给文件系统缓存。Python 环境就是标准的 3.10依赖只有requests、numpy、scikit-learn用于第三阶段的代理模型和openai或者你用的任何 LLM 的 SDK。ES 的安装这里不展开网上教程很多。关键配置就几条# elasticsearch.yml node.name: bench-node path.data: /data/es-bench path.logs: /var/log/es-bench bootstrap.memory_lock: true discovery.type: single-node# jvm.options -Xms16g -Xmx16gbootstrap.memory_lock: true这条很重要它防止 ES 的堆内存被换到磁盘上否则压测时延迟会莫名其妙地抖动。对应的你需要在系统层面给 ES 用户解锁内存锁定的权限在limits.conf里加elasticsearch - memlock unlimited。5.2 查询模板与参数空间定义假设我们要优化的查询是按服务名过滤 时间范围过滤 按日志级别聚合 返回最近 N 条。模板大概长这样{ query: { bool: { filter: [ {term: {service: {{service}}}}, {range: {timestamp: {gte: {{start_time}}, lte: {{end_time}}}}} ], must: [ {match: {message: {{keyword}}}} ] } }, aggs: { by_level: { terms: { field: level, size: {{agg_size}} } } }, size: {{page_size}}, track_total_hits: {{track_total_hits}} }参数空间定义{ track_total_hits: [true, 10000, 1000, false], page_size: [10, 20, 50, 100], agg_size: [5, 10, 20, 50], query_rewrite: [constant_score, scoring_boolean, top_terms_boost_1024] }注意query_rewrite这个参数它对应的是match查询的rewrite选项对性能影响很大。constant_score会跳过打分最快top_terms_boost_1024会保留打分但做优化。这个参数很多人不知道但在我们的测试里它带来的差异能到 20% 以上。5.3 Agent 主循环的实现主循环的骨架大概是这样def agent_loop(es_url, index, template, param_space, baseline, max_rounds200): history [] best {params: baseline, score: 1.0} no_improve_count 0 for round_num in range(max_rounds): # 1. 让 agent 决定下一组参数 next_params agent_propose(history, best, param_space) # 2. 渲染查询 dsl render_template(template, next_params) # 3. 正确性校验 if not correctness_check(es_url, index, dsl, baseline_dsl): history.append({params: next_params, score: -1, reason: correctness_failed}) continue # 4. 跑基准测试 report benchmark(es_url, index, dsl) # 5. 计算评分 score compute_score(report, baseline) # 6. 更新历史 history.append({params: next_params, score: score, report: report}) # 7. 更新最优 if score best[score]: best {params: next_params, score: score} no_improve_count 0 else: no_improve_count 1 # 8. 检查停止条件 if no_improve_count 20: break return best, historyagent_propose这个函数是核心它把历史数据整理成 prompt发给 LLM解析返回的 JSON。这里有个工程细节LLM 返回的 JSON 经常不合法比如多一个逗号、少一个引号、或者把数字写成字符串。我们加了一个重试机制解析失败就重新请求最多重试 3 次还失败就回退到随机采样。这个兜底很重要否则 agent 会因为一次解析失败就卡住。5.4 实际跑一轮的记录我们实际跑的时候第一轮 agent 就提出了一个有意思的方案把track_total_hits从true改成10000同时把page_size从 20 降到 10。它的假设是减少返回数据量和总数统计开销。结果 P95 从 340ms 降到了 210ms降幅 38%。这个方案我们之前人工调优时也想到过但没敢改因为担心业务方需要精确的总数。后来和业务方确认他们其实只需要知道有没有超过 1 万条不需要精确值这个优化就顺利落地了。第二轮 agent 尝试了query_rewrite: constant_scoreP95 又降了 15ms。但正确性校验发现改成constant_score后match查询的打分变了导致返回结果的排序和基线不一致。虽然文档 ID 集合一样但顺序不同。这个方案被我们判为部分有效——如果业务不关心排序可以用如果关心就不能用。最后我们没采用这个参数。到第 30 轮左右agent 找到了一个组合方案track_total_hits: 10000page_size: 10agg_size: 10query_rewrite: top_terms_boost_1024P95 稳定在 180ms 左右比基线降了 47%。这个方案通过了所有正确性校验最终上线。6. 常见问题与排查技巧实录6.1 压测结果抖动大怎么办这是最常见的问题。表现是同一组参数跑两次P95 能差 30%。原因通常有几个文件系统缓存没预热够、后台有别的进程在抢 CPU、ES 在做段合并、JVM 在 GC。排查顺序是先看_nodes/stats里的jvm.gc.collectors计数如果压测期间 GC 次数明显增加那就是堆内存不够或者查询太吃内存再看indices.segments.count如果段数量在变化说明有后台合并等合并完再测最后看系统top确认没有别的进程在抢资源。我们的经验是预热轮次至少 10 轮压测轮次至少 50 轮而且压测期间不要做任何其他操作。如果还是抖就把并发降到 1先测单线程延迟排除并发干扰。6.2 Agent 陷入局部最优怎么破Agent 跑着跑着连续很多轮都在一个小范围内微调分数上不去。这时候需要给它踹一脚。我们的做法是当连续 10 轮没有改进时强制 agent 做一次随机重启——从参数空间里完全随机采一组不管历史。这一组大概率很差但它能把 agent 带出当前的局部区域。实测下来随机重启之后agent 往往能在接下来的 20 轮里找到新的改进方向。另一个技巧是扩大参数空间。有时候不是 agent 不行是你能调的参数太少了。我们一开始只调了 4 个参数agent 很快就摸到了天花板。后来把refresh_interval、preference、batched_reduce_size这些也加进去搜索空间一下子大了很多agent 又找到了新的优化点。6.3 正确性校验误报怎么处理正确性校验有时候会误报。比如浮点数的聚合结果由于计算顺序不同最后一位小数可能有差异。我们的处理方式是对于数值型聚合允许 0.1% 的相对误差对于文档 ID 集合要求完全一致对于排序如果业务不关心可以放宽。这些规则要提前和业务方对齐别自己拍脑袋定。还有一个坑样本选择偏差。如果你只用有结果的查询做校验agent 可能会优化出一个在空结果时行为异常的方案。我们的做法是校验样本里必须包含至少 20% 的空结果查询、10% 的单结果查询、10% 的超大结果查询超过 1 万条。6.4 常见问题速查表现象可能原因排查方法解决压测结果抖动大缓存未预热/GC/段合并看 GC 计数、段数量、系统负载增加预热轮次等合并完再测Agent 连续无改进局部最优/参数空间太小看历史参数分布随机重启扩大参数空间正确性校验失败查询语义变了对比返回结果差异调整校验规则或放弃该方案延迟突然飙升环境变化/资源竞争看系统指标重新测基线隔离环境Agent 输出 JSON 解析失败LLM 输出不稳定看原始输出加重试和兜底逻辑6.5 几个我踩过的坑第一个坑别在压测机上跑 agent 的 LLM 调用。LLM 调用是网络 IO虽然不占 CPU但它的 Python 进程会占内存而且网络抖动可能影响压测的稳定性。我们后来把 agent 和压测 harness 分到两台机器上agent 通过 HTTP 调用压测机的 API稳定多了。第二个坑ES 的查询缓存会骗你。同一个查询跑第二遍因为缓存命中延迟会低很多。如果你不控制这一点agent 会倾向于生成可缓存的查询而不是本身快的查询。我们的做法是每轮压测前清一次查询缓存POST /index/_cache/clear?query_cachetrue确保测的是冷查询性能。当然如果你的生产场景就是热查询为主那不清缓存也是合理的关键是要和实际场景一致。第三个坑别忽略写入放大。有些查询优化方案会改变索引的 mapping 或者 doc_values 配置这些改动可能需要重建索引。重建索引期间集群的写入压力会很大可能影响线上服务。我们的做法是任何涉及 mapping 变更的方案都必须在独立的索引上验证确认没问题后再走重建流程而且重建要在业务低峰期做。7. 这套系统还能怎么扩展跑完这个项目之后我觉得这套agent 探索 基准测试裁决的框架其实不局限于 Elasticsearch 查询优化。任何有明确指标、有参数空间、有可重复测试环境的优化问题都可以套这个模式。比如 JVM 参数调优、数据库索引设计、甚至前端构建配置优化。扩展方向有几个。一是多目标优化现在我们的评分是加权求和其实可以用帕累托前沿的方式同时优化延迟、吞吐、资源占用让业务方自己选。二是在线学习把生产环境的真实指标也反馈给 agent让它不仅优化测试环境还能感知生产环境的变化。三是跨集群迁移把在一个集群上学到的最优参数作为另一个集群的初始搜索起点加速收敛。不过这些都是后话。眼下最重要的还是那句话信任 agent 的探索能力但永远用基准测试来裁决。Agent 可以帮你把搜索空间从一千种方案压缩到十种值得人工看的方案但它不能替你做最终决策。最终上线的参数必须经过正确性校验、人工审核、灰度发布这一整套流程。这不是对 agent 的不信任而是对生产环境的敬畏。