文章 · 2026-08-16

让 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 能对单条查询做干跑预览,但跑不了整轮离线评测——因为评测服务永远针对当前已发布的策略运行。

后果有两个:

  1. 想扫描字段权重,就得真的发布/回滚几十次,把策略历史和审计流全部污染
  2. 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 = -1strategy_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 = 0Recall@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_breakerscore = 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。这意味着任何小幅结论都需要多次运行。

七、代码

仓库里 experiments/ 下是留出集划分清单、基线、扫描日志与全部实测产物;agent/searchops_agent/eval/ 是统计与门禁;权限边界在 agent/searchops_agent/tools.pysafety.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 可以自由试错——它反复评测候选策略不会污染任何状态,被门禁拦下也没有代价。它拿不到的只有最后那一步:按下发布。

© 2026 Yuxu Ge ·