电商 Search Agent 的 DSL 生成工业化实践
从 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. 工业化演进过程
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) 训练对,再拿去离线微调小模型。
流程图如下:
第一轮:小批量 2000 条。蒸馏数据喂给 Gemma 4 e4b 做 LoRA 微调后,准确率直接从 <60% 拉到 ~80%。这个跃迁让我们确认了路线正确。
第二轮:把训练集扩到 2 万条。准确率冲到 92%。结论清晰:对小模型微调,训练集规模比模型参数更值钱。这也反过来说明,2k 条时模型只是"学会了格式",2w 条时才真正"学会了语义判断"。
3.4 阶段 3:LoRA 合并 + 8bit 量化
微调后准确率达标了,但延迟还停在 ~2s,仍是生产瓶颈。这一步做的是纯工程优化,分两步:
- LoRA 权重合并:把训练出的 LoRA 适配器 merge 回主权重,消除推理时的旁路计算开销;
- 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 量化 |
三点结论:
- 延迟与准确率的矛盾,本质要靠"知识迁移"解——把大模型的能力蒸馏进小模型权重,而不是在推理时硬扛大模型。
- 数据规模是微调的胜负手:2k → 80%,2w → 92%,量变引起质变。
- 量化是"最后一公里"的性价比之王:4 个点换 4 倍速度,在业务可容忍区间内。
6. 踩坑与经验
- 别裸跑小模型。open-weight 小模型不经过对齐,对我们的 DSL Schema 毫无概念,准确率崩是必然的,不是"参数不够"。
- 大参数救不了对齐问题。Qwen 27B 裸跑依然不达标——对齐缺失时,容量再大也只是"更自信地瞎编"。
- 蒸馏数据要过校验闸。
validate_dsl是数据质量的生命线,脏样本会直接拉低微调上限,宁缺毋滥。 - 评测口径要先钉死。字段级 F1 + 分层测试集 + 对抗样本,是能让我们在四个阶段之间做可信对比的前提。
- 结构化输出关掉随机性。
temperature=0,用json_object约束,能让线上行为稳定、可回归。 - 量化有损,但要算账。92% → 88% 是"贵得起的损耗",500ms 才是能上生产的门票。
本文为工业化实践复盘,文中模型名与响应数据均为团队实测口径,具体数值可随评测集与部署环境浮动。