电商 Search Agent 的 DSL 生成工业化实践

2026-08-25 03:480 条评论60 次阅读

从 20 秒到 500 毫秒:电商 Search Agent 的 DSL 生成工业化实践

一条「先生成 DSL 再召回」的搜索链路,如何从云端大模型一步步下沉到本地 4B 微调量化模型的全过程复盘。

摘要(结论先行)

电商平台的 Search Agent 要做准自然语言商品召回,最可靠的路子是先由模型产出结构化 DSL,再由检索引擎消费 DSL 做召回与排序。这条链路对生成模型的准确率和延迟都极其苛刻:准了才搜得对,快了才敢上生产。

我们用四步把这条链路跑通了工业化:

阶段 方案 响应时间 DSL 准确率 一句话结论
0 GPT-5 Prompt 直出 20s+ 高(teacher 基线) 效果好但延迟不可接受
1a Gemma 4 e4b 原生推理 ~2s < 60% 小模型裸跑准确率崩塌
1b Qwen 3.6 27B 原生推理 ~5s 仍不达标 单纯放大参数救不了
2a Gemma e4b + 2k 蒸馏微调 ~2s ~80% DSPy 蒸馏数据开始见效
2b Gemma e4b + 2w 蒸馏微调 ~2s 92% 训练集规模是关键
3 微调 + LoRA 合并 + 8bit 量化 ~500ms ~88% 用 4 个点换 4 倍速度,投产

核心洞察:把「准确率」从大模型的参数里「蒸馏」到小模型的权重里,是这条链路真正能上生产的唯一路径。而 DSPy 在这里扮演的不是"Prompt 调优器",而是一条数据蒸馏流水线——这是本文最想讲清楚的一点。


1. 背景与问题定义

1.1 为什么要做 Search Agent

我们的电商平台日均搜索 PV 在千万级,传统关键词召回 + 规则排序已经触到天花板。用户越来越习惯用一句自然语言描述需求,而不是拆成「关键词 + 筛选条件」。于是我们立项做 Search Agent:把自然语言意图翻译成检索系统能精确执行的查询。

这里有一个关键取舍:是让 Agent 端到端直接吐商品,还是让它吐结构化的中间产物?

1.2 为什么选择「先生成 DSL」

端到端直出商品,等于把召回、相关性、排序的锅全甩给模型,黑盒、不可控、无法复现、难以线上调试。工程上一旦效果抖动,你连"它为什么召回这排商品"都解释不了。

所以我们选择语义解析路线:Agent 先输出一份结构化 DSL,再由已有的检索引擎(倒排 + 类目树 + 属性索引 + 价格过滤 + 配件关系表)去执行。这样:

  • 可解释:每一份 DSL 都是可读的、可审计的;
  • 可灰度:新旧 DSL 生成器可以逐字段对比;
  • 可复用:DSL 是标准的,换模型只换"生成器",不动检索侧;
  • 可约束:类目、属性枚举都在 DSL Schema 里被校验,不会出现引擎不认识的值。

1.3 DSL 长什么样

DSL 的四个核心块,正好对应电商召回的四类约束:

作用 例子
category 类目(决定走哪个索引分片) 手机配件 > 手机壳
entity_attrs 实体属性(品牌、型号、颜色等) brand=华为color=黑
price_range 价格区间 [200, 500]
part_fitment 零部件适配关系 target=Mate 60 Pro

一个贯穿全文的示例 query:

「200 到 500 块的华为手机壳,黑色,要能适配 Mate 60 Pro」

对应的目标 DSL:

{
  "category": "手机配件/手机壳",
  "entity_attrs": {
    "brand": "华为",
    "color": ["黑色"]
  },
  "price_range": { "min": 200, "max": 500 },
  "part_fitment": {
    "target_model": "Mate 60 Pro",
    "fit_type": "exact"
  }
}

注意 part_fitment 这一块——它是电商搜索(尤其是 3C/汽配)区别于通用 NER 的难点:模型不仅要识别实体,还要理解「适配」「适用于」「专用」这类关系语义。这也是后面小模型裸跑准确率暴跌的重灾区。


2. 先定义清楚「准确率」,否则全是玄学

在讲演进之前,必须先把度量标准钉死。工程上最忌讳的就是「效果很好」「效果不错」这种无法复现的说法。

2.1 测试集

从线上真实 query 日志分层抽样 1000 条,覆盖头部高频、腰部、长尾,以及刻意加进去的对抗样本(歧义 query、口语化 query、缺价格/缺适配的 query)。每一条由资深运营 + 算法双重标注出标准 DSL。

2.2 指标:字段级 F1

DSL 是结构化 JSON,不适合用「整条字符串是否相等」来打分。我们按四个块分别算精确率/召回率,再按业务权重加权:

准确率 = w1·F1(category) + w2·F1(entity_attrs) + w3·F1(price_range) + w4·F1(part_fitment)

其中 part_fitment 权重最高(它是错得最多、也最伤召回质量的一块)。后文所有百分比都基于这套口径,可复现、可对比。


3. 工业化演进过程

阶段0
GPT-5 直出
20s+ 慢

阶段1
开源模型平替
快但不准

阶段2
DSPy 蒸馏 + 微调
2s / 92%

阶段3
合并 + 8bit 量化
500ms / 88%

3.1 阶段 0:GPT-5 Prompt 直出(基线)

最初的实现朴素得不能再朴素:一段精心雕琢的 System Prompt + few-shot 示例,把 query 丢给 GPT-5,让它吐 JSON。

效果:准确率非常好,好到让我们一度觉得"这事已经做完了"。

致命问题:平均响应 20 秒以上。拆开看,延迟来自三块——云端网络往返、长 Prompt(示例 + Schema 定义撑到好几千 token)、以及 JSON 结构化输出本身的自回归生成开销。20s 对搜索这种强交互场景是灾难级的,用户早就走了。

结论:GPT-5 在这里的价值,不是当线上模型,而是当数据工厂(teacher)。这个判断直接决定了后面的技术路线。

3.2 阶段 1:开源模型平替的两次试错

既然云端大模型太慢,那就把它搬回本地。我们连踩了两个坑:

试错 A:Gemma 4 e4b。4B 参数,本地推理 ~2s,速度达标。但准确率 不到 60%,尤其是 part_fitment 和价格区间抽取,基本处于"能猜就猜、猜不对就瞎填"的状态。小模型对「适配关系」这种需要世界知识的语义理解,能力确实不够。

试错 B:Qwen 3.6 27B。直觉是"参数越大越聪明",换 27B 后延迟涨到 ~5s,但准确率依然不理想——提升没有想象中大,反而把响应时间又拖回去了。

这一步的教训非常关键裸跑大参数 ≠ 高准确率。因为 open-weight 模型没有针对我们的 DSL Schema 做过任何对齐,它"聪明"但"不知道你要什么格式、什么口径"。问题不在模型容量,在对齐。这直接把我们导向了微调这条路。

3.3 阶段 2:DSPy 蒸馏 + 微调(核心转折)

决定微调后,遇到的第一个现实问题就是:训练数据从哪来?

手工标注 2 万条 DSL,成本和时间都不现实。这时我们发现了 DSPy 的一个**「不一样」的用法**。

很多人把 DSPy 当 Prompt 调优器(BootstrapFewShot、MIPRO 那套)。但我们的用法是:把 DSPy 的 Signature + 模块化抽象,当一条「数据蒸馏流水线」来用——用它组织 GPT-5 批量生产高质量、格式严格的 (query → DSL) 训练对,再拿去离线微调小模型。

流程图如下:

不合法

合法

线上 query 日志
(百万级)

分层抽样

GPT-5 (teacher)
DSPy Signature 批量生成 DSL

Schema 校验
+ 人工抽检

蒸馏数据集
jsonl

微调 Gemma 4 e4b
(LoRA)

本地部署推理

第一轮:小批量 2000 条。蒸馏数据喂给 Gemma 4 e4b 做 LoRA 微调后,准确率直接从 <60% 拉到 ~80%。这个跃迁让我们确认了路线正确。

第二轮:把训练集扩到 2 万条。准确率冲到 92%。结论清晰:对小模型微调,训练集规模比模型参数更值钱。这也反过来说明,2k 条时模型只是"学会了格式",2w 条时才真正"学会了语义判断"。

3.4 阶段 3:LoRA 合并 + 8bit 量化

微调后准确率达标了,但延迟还停在 ~2s,仍是生产瓶颈。这一步做的是纯工程优化,分两步:

  1. LoRA 权重合并:把训练出的 LoRA 适配器 merge 回主权重,消除推理时的旁路计算开销;
  2. 8bit 量化部署:对合并后的权重做 8bit 量化,显存和带宽开销大幅下降。

结果:响应时间从 ~2s 压到 ~500ms,准确率从 92% 回落到 ~88%——用 4 个百分点的代价换 4 倍速度。

算账式权衡:88% 的字段级 F1 在"高权重块(适配关系、价格)仍保持高精度"的前提下,完全够线上用;而 500ms 已经进入搜索场景的可接受区间。这是典型的用精度换吞吐、且换得划算的取舍。


4. 关键代码

以下是四个环节最核心的代码片段,均为可运行骨架。

4.1 DSL Schema 与 Prompt(阶段 0)

SYSTEM_PROMPT = """你是电商搜索意图解析器。将用户的自然语言 query 解析为严格 JSON 结构的搜索 DSL。
                 .... 此处无法展示"""
                

def generate_dsl_gpt5(query: str) -> dict:
    resp = client.chat.completions.create(
        model="gpt-5",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": query},
        ],
        response_format={"type": "json_object"},   # 强制 JSON
    )
    return json.loads(resp.choices[0].message.content)

4.2 DSPy 蒸馏数据(阶段 2 的核心)

这里就是 DSPy「不一样用法」的关键:用 Signature 约束 teacher 的输出结构,把 GPT-5 变成一条可控的数据生产线。

import dspy

# 1) 用 Signature 严格定义输入输出契约
class SearchDSL(dspy.Signature):
    """将用户自然语言搜索意图解析为结构化搜索 DSL。"""
    query = dspy.InputField(desc="用户自然语言 query")
    dsl   = dspy.OutputField(desc="严格 JSON DSL:category/entity_attrs/price_range/part_fitment")

# 2) teacher 用 GPT-5
lm = dspy.LM("openai/gpt-5", api_key="...")
dspy.configure(lm=lm)

teacher = dspy.Predict(SearchDSL)

# 3) 批量蒸馏 + Schema 校验 + 落盘 jsonl
def distill(queries, out_path):
    with open(out_path, "w", encoding="utf-8") as f:
        for q in queries:
            pred = teacher(query=q)
            if not validate_dsl(pred.dsl):      # 类目/枚举/适配关系校验
                continue                         # 不合法的直接丢弃,保证数据质量
            f.write(json.dumps({
                "instruction": SYSTEM_PROMPT,
                "input": q,
                "output": pred.dsl,
            }, ensure_ascii=False) + "\n")

关键在 validate_dsl 这一环:蒸馏数据的质量上限决定了微调模型的上限。凡是类目不在类目树、价格区间非法、适配关系指向不存在型号的样本,一律丢弃,绝不让脏数据污染训练集。

4.3 LoRA 微调 Gemma 4 e4b(阶段 2)

用 Unsloth + SFTTrainer,4B 模型单卡即可训练:

from unsloth import FastLanguageModel
from trl import SFTTrainer

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="google/gemma-4-e4b",
    max_seq_length=4096,
    load_in_4bit=False,       # 训练用全精度,量化留到部署阶段
)

model = FastLanguageModel.get_peft_model(
    model,
    r=64, lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    bias="none",
)

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=load_dataset("json", data_files="distill_20k.jsonl")["train"],
    args=TrainingArguments(output_dir="./lora_out", per_device_train_batch_size=4,
                           num_train_epochs=3, learning_rate=2e-4, fp16=True),
)
trainer.train()

4.4 LoRA 合并 + 8bit 量化部署(阶段 3)

# 合并 LoRA 回主权重,消除推理旁路开销
model = model.merge_and_unload()
model.save_pretrained_merged("gemma-e4b-dsl-merged", tokenizer, save_method="merged_16bit")

# 转 GGUF 后做 8bit 量化(llama.cpp 工具链)
#   python convert_hf_to_gguf.py gemma-e4b-dsl-merged --outtype f16
#   ./llama-quantize gemma-e4b-dsl-merged/ggml-model-f16.gguf q8_0

部署侧(本地推理服务,500ms 级响应):

from vllm import LLM, SamplingParams

llm = LLM(model="gemma-e4b-dsl-q8", quantization="gptq", dtype="half",
          max_model_len=4096)
sampling = SamplingParams(temperature=0, max_tokens=512)  # 结构化输出,关闭随机性

def generate_dsl(query: str) -> dict:
    out = llm.generate([f"{SYSTEM_PROMPT}\n\n{query}"], sampling)
    return json.loads(out[0].outputs[0].text)

temperature=0 很关键:结构化 DSL 生成不需要多样性,确定性输出既能保证稳定,也方便做线上 diff 与回归。


5. 数据总览

维度 阶段 0 阶段 1a 阶段 1b 阶段 2b 阶段 3
模型 GPT-5 Gemma 4 e4b Qwen 3.6 27B Gemma e4b 微调 微调 + 8bit
训练数据 - - - 2w 蒸馏 2w 蒸馏
响应时间 20s+ ~2s ~5s ~2s ~500ms
DSL 准确率 高(teacher) <60% 不达标 92% ~88%
部署成本 云端 API 本地 4B 本地 27B(显存压力大) 本地 4B 本地 4B 量化

三点结论

  1. 延迟与准确率的矛盾,本质要靠"知识迁移"解——把大模型的能力蒸馏进小模型权重,而不是在推理时硬扛大模型。
  2. 数据规模是微调的胜负手:2k → 80%,2w → 92%,量变引起质变。
  3. 量化是"最后一公里"的性价比之王:4 个点换 4 倍速度,在业务可容忍区间内。

6. 踩坑与经验

  1. 别裸跑小模型。open-weight 小模型不经过对齐,对我们的 DSL Schema 毫无概念,准确率崩是必然的,不是"参数不够"。
  2. 大参数救不了对齐问题。Qwen 27B 裸跑依然不达标——对齐缺失时,容量再大也只是"更自信地瞎编"。
  3. 蒸馏数据要过校验闸validate_dsl 是数据质量的生命线,脏样本会直接拉低微调上限,宁缺毋滥。
  4. 评测口径要先钉死。字段级 F1 + 分层测试集 + 对抗样本,是能让我们在四个阶段之间做可信对比的前提。
  5. 结构化输出关掉随机性temperature=0,用 json_object 约束,能让线上行为稳定、可回归。
  6. 量化有损,但要算账。92% → 88% 是"贵得起的损耗",500ms 才是能上生产的门票。

本文为工业化实践复盘,文中模型名与响应数据均为团队实测口径,具体数值可随评测集与部署环境浮动。