让 Agent 去调搜索:一个可验证边界的系统设计
代码:https://github.com/geyuxu/searchops-agent-lab
一、要回答的问题
很多 Agent 项目卡在同一处:能提案,但没人敢让它执行。
原因通常不是模型不够聪明。是缺少让人敢放手的机制——提示词里写「请谨慎操作」不构成保障,模型自称的 confidence 也不能当作放行依据。于是 Agent 停在 demo 阶段:它给的建议看起来都对,但没有人愿意把发布按钮交给它。
搜索运营是检验这件事的好场景。它高频、依赖专业判断、后果可逆但有风险:发现劣质查询、诊断成因、调整同义词和字段权重、灰度发布、必要时回滚。这条闭环我在电商平台做了很多年,全是人工。
于是我搭了一个系统来回答:LLM Agent 能不能安全地承担搜索运营?
答案的形状不是「能」或「不能」,而是两条替换:
- 用可验证的评测门禁,替代对模型的信任
- 用结构性权限边界,替代提示词约束
数据用公开的 Amazon Shopping Queries 数据集(ESCI,Apache-2.0):商品标题、品牌、描述、搜索词和人工相关性标注都是真实公开数据;价格、库存、用户、订单是由商品 ID 哈希派生的确定性模拟数据,不是亚马逊交易数据。
先看看要处理的查询长什么样
抽象地说「改善检索质量」没有意义。下面这些是数据集里的真实查询,原样照抄:
!awnmower tires without rims 前导标点,而且 lawnmower 少了首字母
# 2 pencils not sharpened 编号写法 + 否定
1 1/2 leather belts without buckle 分数尺寸 + 否定
03 durango front calipers without pads 车型年份缩写 + 否定
#1 rated resveratrol supplement without tea leaves
两个特征贯穿始终:噪声(前导标点、拼写错误、缩写)和否定(without / not / no)。
否定尤其危险,因为它是最容易被「优化」弄坏的东西——把 fence without holes 里的 without 丢掉,会召回一堆带孔的围栏。任何改写方案都得先证明自己不会犯这个错。
二、概要设计
四层,自上而下:
| 层 | 职责 |
|---|---|
| Agent | 提案者。读诊断证据,产出候选策略 |
| 工具网关 | 按安全等级暴露能力。审批与发布不在它能看见的集合里 |
| 检索服务 | 策略编译 → Elasticsearch BM25;可选查询改写与候选集重排 |
| 评测与门禁 | 逐查询指标 → 配对统计 → 失败关闭的晋级判定 |
三条贯穿全局的设计原则,每一条都是用机制替换承诺:
Agent 无法发布。 不是「要求它不要调用」,而是这些能力压根不在它的工具注册表里。
降级是有类型的,不是静默的。 模型不可用、超时、返回非法时,搜索仍然基于 BM25 成功返回——但响应里带着为什么。
重排不会弄丢商品。 合并逻辑作用在候选下标上,结果可证明是输入的一个排列。
三、详细设计
3.1 权限边界:让越权在构造期就不可能
安全等级不是文档约定,而是随每个客户端方法一起登记的元数据:
class SafetyClass(enum.Enum):
READ = 1 # 只读查询,无副作用
DRY_RUN = 2 # 计算并返回对比,但不写入状态
GOVERNED_WRITE = 3 # 写状态,要求 actor / request_id / 幂等键
PRIVILEGED_WRITE = 4 # 授予审批权,产生 approval_token
TOKEN_GATED_WRITE = 5 # 改变线上生效策略,额外要求有效令牌
#: 允许自动化提案者持有的最高等级
MAX_AUTOMATED = SafetyClass.GOVERNED_WRITE
关键在这个默认值——未登记等级的方法一律视为最高危:
def safety_class_of(fn) -> SafetyClass:
"""取出登记的等级;未登记视为最高危,失败关闭而不是默认放行。"""
return getattr(fn, "safety_class", SafetyClass.TOKEN_GATED_WRITE)
工具注册表用白名单 + 等级双重过滤构建,任何一重不通过都在构造期抛异常,不留到运行期:
def build_registry(client) -> dict[str, Tool]:
registry = {}
for name in ALLOWED: # 白名单:新增方法默认不可见
if name in FORBIDDEN:
raise GovernanceViolation(f"{name} 同时出现在白名单与禁止名单中")
level = safety_class_of(getattr(type(client), name))
if level.value > MAX_AUTOMATED.value: # 等级:越界即拒
raise GovernanceViolation(
f"{name} 的安全等级 {level.name} 高于自动化上限 {MAX_AUTOMATED.name}")
registry[name] = Tool(name=name, fn=getattr(client, name), safety_class=level)
return registry
白名单和禁止名单是冗余的——这是故意的,为了让越界在测试里立刻显形:
def test_registry_rejects_privileged_method_added_to_allowlist(monkeypatch):
monkeypatch.setattr("searchops_agent.tools.ALLOWED", ALLOWED + ("publish",))
with pytest.raises(GovernanceViolation):
build_registry(SearchOpsClient(base_url="http://127.0.0.1:9"))
这条测试保护的是一个会随时间失效的性质:有人往白名单里加一个方法,很容易;加进去恰好是危险方法,也很容易。 让它变成 CI 里的红灯,比写进文档有用。
3.2 提案闭环:Agent 必须能自证
这里有个容易被忽略的缺口。最初的实现里,Agent 能对单条查询做干跑预览,但跑不了整轮离线评测——因为评测服务永远针对当前已发布的策略运行。
后果有两个:
- 想扫描字段权重,就得真的发布/回滚几十次,把策略历史和审计流全部污染
- Agent 无法自证。它只能说「我觉得这个改动更好」,拿不出证据
所以评测请求增加了一个可选的候选配置字段。传了它,就用这份配置编译查询、跑完整评测,但强制不落库、不读写任何策略版本:
public record EvaluationRequest(
List<EvaluationQuery> queries,
int k,
boolean persist,
@JsonProperty("use_ai") Boolean useAi,
// 候选策略配置(可选)。传了就用它编译检索查询,评测一个尚未发布的配置;
// 缺省(null)时行为与新增此字段之前逐字节一致——评测当前已发布策略。
@JsonProperty("strategy_config") @Valid StrategyConfig strategyConfig) {
一个踩过的坑:这个字段最初想用 primitive
boolean。Jackson 3 把FAIL_ON_NULL_FOR_PRIMITIVES的默认值从 false 翻转成了 true,而 Spring Boot 4.1 不恢复 Jackson 2 语义。结果是既有的评测脚本(payload 里没有这个键)直接 400,整条基线复现链路当场断掉。更阴的是运行中的旧镜像看不出问题,一重建才炸。教训:新增可选字段一律用包装类型。
响应用 strategy_version = -1 和 strategy_source = "candidate" 双标记标识这是一次候选评测。实测验证的方式是:用候选配置复刻已发布策略的权重,应当得到逐位相同的指标——如果不同,说明编译路径不同源。
已发布策略 v7 v= 7 NDCG@10 = 0.3524
候选 = 复刻 v7 权重 v=-1 NDCG@10 = 0.3524 ← 逐位一致
候选 = 标题降权 v=-1 NDCG@10 = 0.1544 ← 确实生效
3.3 降级状态机:把「AI 没效果」和「AI 没跑」分开
早期实现里,ai_applied 是唯一的可观测信号。而「AI 被关闭」「请求没要求用 AI」「调用超时」「适配器返回了看不懂的内容」四种情况全部塌缩成同一个 false。
这直接导致一类无法排查的故障:真实模型接入后指标毫无变化,看起来像「AI 没有收益」,实际是它一次都没跑成。
我确实撞上了这个。Java 客户端里有一行遗留断言:
// 改前:任何非 mock 的 provider 都会抛异常,被下面的 catch 静默降级成 BM25
if (response == null || !"mock".equals(response.path("provider").asText()))
throw new IllegalStateException("AI adapter returned an invalid provider response");
而交接文档白纸黑字承诺「替换成真实 AI 服务时,不需要修改搜索后台代码」。这句话因为这一行而是假的,而且失败是静默的:HTTP 200、结果正常、只是 AI 从未生效。
修复分两步。准入条件从「provider 叫什么名字」改成「响应里有没有可用的改写结果」:
var rewritten = response == null ? "" : response.path("rewritten_query").asText("");
if (rewritten.isBlank()) {
throw new InvalidAiResponseException("AI adapter response is missing a usable rewritten_query");
}
然后引入有类型的状态:
public enum AiRewriteStatus {
APPLIED, // 调用成功,且改写后的查询与原始查询确实不同
NO_CHANGE, // 调用成功,但适配器把查询原样返回
NOT_REQUESTED, // 请求本身没有要求 AI。不是故障
DISABLED, // 全局开关关闭。不是故障
TIMEOUT, // 连接或读取超时
TRANSPORT_ERROR, // 其它网络故障,或 4xx/5xx
INVALID_RESPONSE; // 响应体缺失、非法 JSON、或缺少可用结果
public boolean fallback() { return this != APPLIED && this != NO_CHANGE; }
public boolean failure() { return this == TIMEOUT || this == TRANSPORT_ERROR
|| this == INVALID_RESPONSE; }
}
顺带修正了 ai_applied 的语义:现在它表示「查询确实被改写了」,而不是「调用没抛异常」。旧语义下 mock provider 没命中任何规则时也返回 true,这个字段因此无法用来判断 AI 到底有没有影响检索。
重排后来用了独立的状态机,而不是复用这一个。 理由是同一次请求可以「改写成功 + 重排超时」,塞进一个字段就分不出是哪一段出了问题——正是当初逼出这套设计的同一种塌缩。
七个状态全部在真实链路上取过证:指向关闭端口得到 TRANSPORT_ERROR、上游返回 500 得到 TRANSPORT_ERROR、返回 200 但缺字段得到 INVALID_RESPONSE、慢响应得到 TIMEOUT。
有意思的是,
TRANSPORT_ERROR一开始触发不了:停掉容器走的是 Docker 网络丢包 → 连接超时 → 判成TIMEOUT。把应用改成跑在本机进程后,指向一个关闭的端口会立刻收到ECONNREFUSED,这条路径才第一次可测。
3.4 重排的不变式:绝不弄丢商品
重排让模型对 BM25 取回的 N 条候选重新排序。模型可能少返、多返、返回不存在的 ID、重复返回同一个 ID。
合并逻辑因此作用在下标而非 ID 上:建立「下标队列」,模型每点名一次弹出一个;集外 ID 与重复点名忽略;第二趟把未点名的下标按原序补齐。每个下标最多被取走一次,所以末尾一句长度相等的检查就足以证明结果是输入的排列。
这条为什么不可妥协——如果重排能让文档消失,模型的一次幻觉就变成 Recall 下降,而故障形状与「BM25 没召回」完全一致,排查会落到索引和查询编译上,永远查不出来。
违反时抛 IllegalStateException,并被单独归类为 INTERNAL_ERROR 而不是混进 INVALID_RESPONSE——把自己的缺陷记成「模型返回非法」,会把排查引到错误的方向。
模型输出的也不是 product ID 而是候选编号。三个理由:ASIN 约 5–7 个 token,编号只要 1–2 个;集外检测从字符串比对降为区间检查;而且这是一道结构性的注入防线——商品标题是外部不可信数据,本协议下模型唯一能产出的就是一串被校验成 1..N 排列的编号,一次成功的注入最坏也只能换来一个不同的排序。
3.5 门禁:均值提升不足以放行
统计部分刻意写得保守。逐查询指标让配对检验成为可能——同一批查询在两个策略下的表现是配对样本,不是独立样本:
def paired_bootstrap(baseline, candidate, *, iterations=10_000, seed=20260816):
"""对配对差值做 bootstrap。重采样的是"查询"这一单位,保留配对关系。"""
diff = candidate - baseline
rng = np.random.default_rng(seed)
idx = rng.integers(0, diff.size, size=(iterations, diff.size))
means = diff[idx].mean(axis=1)
return float(diff.mean()), float(np.percentile(means, 2.5)), float(np.percentile(means, 97.5))
def permutation_test(baseline, candidate, *, iterations=10_000, seed=20260816):
"""配对置换检验:随机翻转每个查询上差值的符号,构造零分布。"""
diff = candidate - baseline
observed = abs(diff.mean())
rng = np.random.default_rng(seed)
signs = rng.choice((-1.0, 1.0), size=(iterations, diff.size))
null = np.abs((diff * signs).mean(axis=1))
# +1 平滑:避免在有限次数下报出 p=0 这种不可能的精度
return float((np.count_nonzero(null >= observed) + 1) / (iterations + 1))
门禁本身失败关闭。均值更高远远不够:
if policy.require_significant:
if primary.delta <= 0:
promote = False
elif not survives[primary.metric]: # BH 校正后仍显著
promote = False
除了主指标,还要求护栏指标不显著劣化、zero-result 率不上升,以及灾难性跌幅的查询占比有上限——因为均值可以掩盖长尾崩塌。
工具本身也有一条零假设自检:同一份数据与自己比较,四个指标必须全部判为不显著。这条防的是「检验有系统性偏向」这种最难发现的错误。
四、演示:一次完整的治理流程
上面讲的都是机制。下面是这套机制在真实系统里跑起来的样子——查询固定用 laptop stand。
运营者看到什么

左边是实时指标,右边是当前生效的策略(版本、状态、完整配置 JSON)。下方三栏分别是热门查询、零结果队列和低 NDCG 队列——低 NDCG 那一栏里就是真实的 ESCI 查询:!awnmower tires without rims 0.000、# 2 pencils not sharpened 0.382。Agent 的诊断证据就是从这里来的。
基线:v7

top-3 是投影仪支架、笔记本背包、办公贴纸。说实话不算好——这正是要改进的地方。
发布一个有害的变更
把 title 权重从 4.0 压到 0.2、description 抬到 5.0,走完 草稿 → 提交 → 审批 → 发布:

第 2 位变成了手办娃娃(iGREATWALL Action Figure Doll)。这就是一个坏策略上线的样子——而且它已经立刻作用于所有用户。
回滚
用发布时拿到的审批令牌回滚到 v7:

结果与基线那张逐条一致。这是回滚存在的全部理由。
审计台账

每一次状态转移都记着 actor、request_id、动作、幂等键、前后版本和结果。
值得单独指出的是 16、17 两行:
ID 17 searchops-agent STRATEGY_SUBMIT 7 → 8
ID 16 searchops-agent STRATEGY_CREATE 7 → 8
Agent 在整本台账里只出现过这两次,动作只有 CREATE 和 SUBMIT。 所有 APPROVE / PUBLISH / ROLLBACK 都属于人类角色。前面讲的"结构性权限边界",最终就落成审计日志里的这个事实——不是因为提示词要求它克制,而是因为那些能力从来不在它的工具注册表里。
演示中途还撞到一个正面的例子:我试图对一个已发布的策略再次审批以取得回滚令牌,服务返回 409 Conflict。状态机拒绝了非法转移——这不是障碍,这正是它该做的事。
五、结果
先做诊断,再决定投入方向。
对基线里所有 NDCG@10 = 0 且 Recall@10 = 0 的查询,逐条回溯相关商品的真实排名:
11–50 位 30.3% ← 重排能救
51–200 位 27.3% ← 更深的重排能救
200 位之外/未召回 42.4% ← 只有改写能救
57.6% 的彻底失败是排序问题,不是召回问题。 具体是这样的:
查询 相关商品实际排名
1 1/2 leather belts without buckle 第 11 位
1 ml medical grade syringes without needle 第 11 位
08 chevy tailgate without emblem 第 12 位
#15 charm 第 14 位
03 durango front calipers without pads 第 16 位
商品就在那儿,只是差几名进不了前十。这类问题改写救不了——换个查询词只会换一批候选,而正确答案本来就已经被召回了。
据此把 AI 投入从改写转向重排。
同时用 218 组配置扫描字段权重,确认传统调参的头部空间已经用尽(留出集上 +0.0035,p=0.1202,不显著)——这一步是为了排除「你调调参数就有了」这个解释。
扫描顺带挖出两件事,都不是调参能解决的:
category 字段在信息论意义上是死的。 18 档权重(含把字段整个删掉)指标逐位不变。直接查索引才明白:category.keyword 的 terms 聚合只有一个桶 Other,doc_count=20000——覆盖全部文档,文档频率 100%,BM25 的 IDF≈0。这个字段一直带着权重 1.2 参与打分,收益恒为零。
等比放大权重是空操作。 title 从 4.0 提到 8.0,指标十位小数不变;降到 0.1 才掉到 0.2123。原因是 multi_match/best_fields 未设 tie_breaker 时 score = max_f(w_f · bm25_f),整个权重向量乘任意正数只缩放分数、不改排序。发现这一点之前,搜索空间里有一大片配置是互为重复的。
留出集是固定种子的哈希确定性划分,train 1400 / holdout 600,规模由实测功效分析确定。最终结果:
| 深度 | NDCG@10 | Δ | 95% CI | p | 门禁 |
|---|---|---|---|---|---|
| 基线 BM25 | 0.4720 | — | — | — | — |
| 重排 N=20 | 0.5687 | +0.0968 | [+0.0796, +0.1144] | 0.0001 | PROMOTE |
| 重排 N=50 | 0.5926 | +0.1207 | [+0.1006, +0.1416] | 0.0001 | PROMOTE |
| 重排 N=100 | 0.6068 | +0.1349 | [+0.1133, +0.1570] | 0.0001 | PROMOTE |
NDCG@10 相对提升 25.6%,四项指标三档深度全部显著,多重比较校正后存活。
对照组是同一个模型、同一套评测、同一批查询下的查询改写:Δ+0.0025,p=0.5942,不显著,门禁 BLOCK。
这个对照比那个数字更重要。 它说明的不是「用了大模型所以变好」,而是「用对了地方才变好」——而用在哪里,是诊断指出来的,不是猜的。
具体发生了什么
重排把已标注相关的商品从后面拉进前十(抽查,深度 50,括号内是标注相关商品的排名):
#1 rated resveratrol supplement without tea leaves [2,4,9] → [1,2,3]
$13 bb guns without a yellow tube [50] → [5]
+dark chocolate peanuts covered not milk [1,8,9,10,28] → [4,5,12,13,14] ← 变差了
第三条是反例,而且是预料之中的那一类:模型对 not milk 的解读过于激进,把两条已标注相关的商品挤出了前十。收益不是单调的。
改写那一组的赢和输,是同一种行为的两面——模型在纠正品牌拼写时赢,在把不认识的词猜成一个真实品牌时输:
赢 rockshocks front fork → rockshox front fork NDCG@10 +0.826
troybuilt → troy bilt +0.569
zenphone 6 → asus zenphone 6 +0.485
输 k cliffs ... backpack → 猜成了一个真实存在的无关品牌 显著下降
覆盖率也解释了改写为什么整体测不出效果:600 条里只有 152 条真的被改写(25.3%),而最终只有 64 条的 NDCG@10 发生了变化(37 涨 27 跌)。剩下 536 条的差值恒为零,把那 64 条的效应稀释掉了。
深度效应还有个细节:20→50 拿到 +0.0239,50→100 只有 +0.0142。诊断说 11–50 位和 51–200 位的相关商品占比接近,若收益只取决于覆盖范围,两段应当差不多。实测不是——更深的候选里模型更难识别出相关商品,长尾候选与查询的字面重合度更低。
六、限制
这些必须和结果一起说,否则那个数字是误导的。
评测集标注极稀疏。 top-10 里只有约 12% 的位置有人工标注,每条查询平均 1.68 条相关标注。未标注的文档一律记 0 分——一个真正把好商品捞上来但那商品没被标注的改进,会被判为退步。
这也解释了改写为什么难看:它的 Recall@10 微升而 NDCG@10 微降,正是「换进来的商品没标注」的典型指纹。所以 −0.0061 不能读成「改写让用户看到更差的结果」。
统计功效有限。 600 条留出集上,NDCG@10 的最小可检出差约 0.016。小于这个量级的真实效应测不出来。
降级使效应偏保守。 重排在 600 条里有 10 条降级(超时或传输失败),全部退回基线值,所以 +0.1207 若有偏差是偏低。
托管模型无法精确复现。 模型钉死在带日期的快照(qwen3.7-flash-2026-07-15)并随每次运行记录,但厂商更新权重后历史结果不保证可复现。
temperature=0 下仍有非确定性。 完整跑两遍,200 条里有 6 条结果不同,重跑噪声带约 ±0.005。这意味着任何小幅结论都需要多次运行。
七、代码
- GitHub:https://github.com/geyuxu/searchops-agent-lab
- 数据集:amazon-science/esci-data(Apache-2.0)
仓库里 experiments/ 下是留出集划分清单、基线、扫描日志与全部实测产物;agent/searchops_agent/eval/ 是统计与门禁;权限边界在 agent/searchops_agent/tools.py 与 safety.py。
一条命令起完整环境:
cd platform && make doctor && make bootstrap && make data && make up && make seed && make evaluate
开发时基础设施跑容器、应用跑本机进程:
make infra-up # 只起 postgres / redis / elasticsearch
make dev-info # 打印各应用的本地启动方式
八、回到最初的问题
Agent 能不能安全承担搜索运营?
这个系统给的答案是:能,但前提是它的能力边界由代码结构保证、它的产出由统计检验裁决。 两者都不依赖对模型的信任,也都不依赖提示词。
这套边界之下,Agent 可以自由试错——它反复评测候选策略不会污染任何状态,被门禁拦下也没有代价。它拿不到的只有最后那一步:按下发布。