AI 定价引擎如何应对噪声数据:6 层防护架构
在无清洁数据的情况下构建可信定价系统的工程实践——6 层防护机制均不信任模型输出。高度可借鉴的 AI 应用架构设计。
在无清洁数据的情况下构建可信定价系统的工程实践——6 层防护机制均不信任模型输出。高度可借鉴的 AI 应用架构设计。
有人问我:当没有干净的数据集可依赖时,我是如何处理搜索结果中的噪声的。下面是完整答案:六层防护,没有任何一层会盲目信任模型。
在我写完那篇关于如何为一个没有数据的市场构建汽车定价引擎的文章后,@topstar_ai 在评论区提出了一个值得专门写篇文章回答的问题:
LLM 能够在收到请求时,根据实时搜索结果综合出价格,这一点让我印象深刻。它彻底颠覆了传统的监督学习方法。数据集不再是前提,而是系统运行过程中产生的“尾气”,这对于在数据匮乏的市场中构建 AI 产品来说,是一次颠覆性的变化。我做过一些类似的项目。当时为了克服数据稀缺问题,我们不得不依赖代理数据源和巧妙的特征工程;但 LLM 方法看起来提供了一种扩展性更强、也更灵活的解决方案。你是如何处理搜索结果中的数据质量和噪声问题的?又实施了哪些领域防护机制,来确保模型输出可靠且值得信任?
这里面其实有两个问题,而且恰好是最关键的两个。如果你的 ground truth 来自实时搜索结果,而不是经过整理的数据集,那么数据质量问题并不会消失,只是转移了位置。它变成了一个运行时问题,需要你在每一次请求中重新解决。
下面是我在 AutoValue 中实际采用的方法,我会逐层说明。
在不了解具体失效模式之前,泛泛而谈“过滤低质量来源”没有任何意义。以我搜索尼日利亚二手车价格为例,会遇到以下问题:
标价,而不是成交价。 Jiji 上的每一个挂牌价格都只是谈判的起点。尼日利亚买家通常会把价格谈到比标价低 10%~20%。如果模型直接报告挂牌数字,就会系统性地高估市场价格。
货币贬值前发布、之后却从未下架的车源。 奈拉兑美元汇率从大约 ₦460 跌到了约 ₦1,650。2022 年发布的车源现在仍然会被搜索引擎索引,而且显示的还是 2022 年价格。严格来说,这些价格并没有错,只是已经成了考古遗迹。
配置污染。 搜索“Toyota Land Cruiser Prado price Nigeria”时,大部分结果都是顶配 VX,因为经销商主要宣传的就是这种配置。如果把这些结果当成标准配置的基础价格,那么此后每一辆 Prado 的估价都会永远偏高。
币种混杂。 出口网站和拍卖网站使用美元报价,尼日利亚本地车源网站使用奈拉报价,但两者会同时出现在同一组搜索结果里。
诈骗诱饵。 一辆售价 ₦2.5 million 的 Range Rover 不是一个数据点,而是一个诱饵。但它又是一个格式完全正确的数字,任何天真的解析器都会高高兴兴地把它纳入平均值。
注意,只有最后一种属于通常意义上的“坏数据”。其余数据本身都是正确的,只是对于我的目标而言不适用。这个区别决定了整个系统的设计。
成本最低的过滤器,就是从一开始便阻止噪声被检索出来。
const query = `${year} ${make} ${model} price Nigeria site:jiji.ng OR site:cars45.com`;
const searchRes = await fetch("https://google.serper.dev/search", {
method: "POST",
headers: { "X-API-KEY": serperKey, "Content-Type": "application/json" },
body: JSON.stringify({ q: query, gl: "ng", hl: "en", num: 10 }),
});
通过 site: 将范围限制在真正重要的两个交易平台上,再通过 gl: "ng" 把搜索的地理位置设为尼日利亚。后者的重要性远超表面所见。如果没有这个设置,你会搜到同款汽车在美国市场的美元报价,而那个市场的进口经济结构完全不同。
当 Google 的 answer box 和 knowledge panel 存在时,我也会把它们的优先级放在自然搜索结果之前,因为 Google 已经对多个来源做过一轮综合处理:
const priorityLines: string[] = [];
if (answerBox?.snippet || answerBox?.answer) {
priorityLines.push(`GOOGLE SUMMARY: ${answerBox.title ?? ""}\n${answerBox.snippet ?? ""}`);
}
const organicLines = organic.slice(0, 7).map(r => `${r.title ?? ""}\n${r.snippet ?? ""}`);
const allLines = [...priorityLines, ...organicLines];
这是其他所有设计赖以成立的结构性决策,也是我最希望读者能够记住的一点。
这里实际上存在两项完全不同的工作。第一项是:阅读这些混乱的搜索摘要,然后告诉我这款车目前的实际成交价。第二项是:在拿到基础价格后,根据这辆具体汽车的里程、车况、配置和所在地进行调整。最诱人的做法,是用一个 prompt 同时完成两项工作。不要这么做。
在我的 pipeline 中,它们是两次独立的 Claude 调用,原因在于可审计性。在单次调用的设计中,如果最终价格出错,你无法判断究竟是模型误读了某条摘要,还是错误应用了某项调整规则。把两者拆开后,每一个错误输出都能定位到具体阶段。这项改动为我节省的调试时间,比任何 prompt 优化都多。
提取阶段使用 Haiku,给它的是一套操作流程,而不是一句请求:
Extract the REALISTIC TRANSACTION PRICE RANGE, what this car actually sells
for, not what sellers hope to get.
- Naira devalued sharply: ₦460/$ in 2022 to ₦1,650/$ in 2026. Car prices are
now 3 to 4x higher than pre-2023.
- Jiji and Cars45 show ASKING prices. Nigerian buyers negotiate 10 to 20%
below the listed ask.
- STEP 1: any price more than 60% below the highest-priced cluster member is a
pre-devaluation or junk listing. SKIP IT.
- STEP 2: SKIP listings marked "5+ years on Jiji". ENTERPRISE is a dealer
badge, do NOT skip those, dealers are valid sellers.
- STEP 3: prices in $ multiply by ₦1,650.
- STEP 4: from remaining valid prices take the MIDDLE cluster. Ignore the
bottom 15% and anything above 2x the cluster median.
- price_low = 25th percentile. price_high = 75th percentile.
- If only 1 to 2 valid prices remain: low = price × 0.88, high = price × 1.08.
其中每一条规则,都是系统踩过坑后留下的伤疤。STEP 2 之所以专门为 ENTERPRISE 设置例外,是因为我第一版“跳过带徽章的车源”规则悄悄删除了所有经销商库存,而这些库存恰恰是网站上最可靠的定价信号。
这一点值得单独拿出来讲,因为它只改动了一行,却产生了极其显著的效果。
最终输出是剩余价格集群的第 25 百分位数和第 75 百分位数,而不是平均值,也不是最小值与最大值。一条价格为 ₦2.5M 的诈骗车源足以摧毁平均值,也足以摧毁最小值,却几乎不会影响第 25 百分位数。只要选择正确的统计量,就能免费获得对离群值的鲁棒性;无论做多少 prompt engineering,都无法替代这个选择。
数据稀少的情况也要显式处理。如果最终只剩下一两个有效价格,百分位数就失去了意义,因此规则会退化为围绕单个观测值计算固定区间。你必须明确告诉模型数据不足时应该怎么做,否则模型就会自行编造一种处理方式。
配置污染是这份列表中最隐蔽的失效模式,而它的解决方案也远不只适用于定价系统。
我可以要求提取模型:“如果搜索结果看起来来自高配车型,就向下调整价格。”但这样做相当于让模型把一次不可见、不可审计、不可复现的调整悄悄埋进一个数字里。我的做法是让模型将观察到的配置作为独立字段报告,再由代码完成计算,这样我就能清楚地查看整个过程:
const TRIM_FACTOR: Record<string, number> = {
full_option: 1.12,
limited: 1.10,
sport: 1.08,
standard: 1.00,
unknown: 1.00,
};
const trimFactor = TRIM_FACTOR[searched.detected_trim] ?? 1.00;
const normalizedLow = Math.round(searched.price_low / trimFactor / 50_000) * 50_000;
const normalizedHigh = Math.round(searched.price_high / trimFactor / 50_000) * 50_000;
这样一来,存储的每个价格锚点都是基础配置的价格。之后重新加入配置溢价时,它会作为卖家可以看到的明确条目出现;而且我可以在不修改 prompt 的情况下调整乘数。
让模型提供观察结果,让代码执行计算。如果模型的判断会改变某个数字,就让它通过一种能够被打印出来的机制影响这个数字。
调整模型会收到明确的百分比规则:内饰极佳加 2%;对于车龄为 4~7 年的汽车,里程每比预期少 10,000km,价格增加 0.3%;等等。
但它并不总会遵守自己收到的规则。线上验证时,我发现它曾为一辆车应用 7.8% 的里程加成,而根据它自己陈述的规则,结果本应是 0.3%。这并不是人们通常想象的那种戏剧性 hallucination,而只是模型在做近似计算,最后得出了一个看起来似乎合理的数字。
所以,模型响应返回后,代码还会执行一道限制:
const CONDITION_UPLIFT_KEYS = ["condition", "interior", "engine", "mileage"] as const;
function applyAgeConditionCeiling(estimate, base, carAge) {
// Younger car, smaller allowed uplift: "excellent" is the expected norm for a
// 2-year-old car and already priced into the anchor, but a genuine rarity on
// a 10-year-old one.
const capPct = carAge <= 3 ? 0.03 : carAge <= 7 ? 0.06 : 0.10;
const baseMedian = (base.price_low + base.price_high) / 2;
const upliftNGN = CONDITION_UPLIFT_KEYS.reduce(
(sum, key) => sum + Math.max(0, estimate.adjustments?.[key] ?? 0), 0
);
const upliftPct = upliftNGN / baseMedian;
if (upliftPct <= capPct) return { estimate, cappedPct: 0 };
// Subtract only the excess above the ceiling, proportionally.
const excessPct = upliftPct - capPct;
...
}
这里的领域推理和代码本身同样重要。两年车龄汽车的搜索价格锚点,本来就默认车况接近极佳,因为市面上的两年车龄汽车通常就是这种状态。如果在这个锚点上继续叠加车身极佳、内饰极佳、发动机极佳和低里程奖励,最终就会把一辆车况良好的二手车定价成新车。对于数据稀少的车型,这种问题最严重,因为唯一能搜到的结果可能是新车经销商报价,而此时价格锚点本身就已经处于上限。
配置和地区带来的价格增幅被有意排除在这个上限之外,因为它们属于结构性因素,而不是车况因素。配置溢价已经在第 4 层中被除掉,现在只是合理地重新加回来。
永远不要相信模型能够保证某个算术不变量。如果一种关系必须成立,就在模型完成响应后通过代码强制执行。
这一点可能是我最容易与其他 LLM 产品开发者争论的地方。
当搜索没有返回任何可用结果时,endpoint 不会退回到模型自身的知识,而是直接返回 503:
if (!searched) {
return NextResponse.json({
success: false,
error: "pricing_unavailable",
message: "We couldn't find live market data for this car right now. Please try again in a few minutes.",
}, { status: 503 });
}
大多数 LLM 产品都会选择开放式失败。Retrieval 返回空结果后,模型仍然会根据训练数据给出答案,而用户无法判断这个答案究竟有事实依据,还是只是一种猜测。对于聊天机器人来说,这种情况只是略显糟糕;但对于一个核心价值主张是“这个数字值得信任”的产品来说,这将是致命问题,因为恰恰在失败造成最大伤害的时候,用户完全看不到它。
还有一个相关问题,我在上一篇文章里做过更深入的讨论:模型生成的价格曾经会被缓存为价格锚点,然后在下一次请求中重新注入系统,仿佛它们是真实市场数据。此后的修复已经从运行时绕过升级为类型层面的限制。存储锚点的 source 只能是 manual、serper_search 或 google_cse,读取查询也会明确地只筛选这三种来源:
.in("source", ["serper_search", "google_cse", "manual"])
现在,这个系统已经从能力层面杜绝了将模型自身输出当成证据的可能性。
接下来坦诚说说目前仍然存在的漏洞——毕竟这通常是此类文章会跳过的部分。
汇率目前还是一个硬编码常量:USD_TO_NGN = 1650。我曾专门写过一整篇文章,讨论陈旧的汇率假设如何摧毁我的定价,结果转头就在自己的代码里放入了一个会逐渐过时的汇率假设。它需要替换为实时查询,而且以后一定会这么做。
百分位数的截断点、60% 的陈旧数据阈值以及配置乘数,都是根据我自己的市场知识校准出来的,而不是基于结果数据拟合得到的。一旦有足够多的车源通过平台成交,真实成交价格就会成为训练信号,取代我现在的这些猜测。到那时,系统产生的“尾气”才终于真正变成数据集。
了解你所面对的具体噪声。我的大多数坏数据并不是虚假数据,而是本身正确、却不适用于当前问题的数据。
将提取与推理分开。两次调用、两个失效域,而且可以调试。
使用鲁棒统计方法。选择百分位数,而不是平均值。一条诈骗车源不应该改变你的答案。
模型负责报告,代码负责计算。任何会改变数字的调整都应该可见,并且接受版本管理。
在模型响应后,通过代码强制执行不变量。写进规则里的要求,并不等于一定会被遵守。
采用封闭式失败。当信任本身就是产品时,没有数据比自信的猜测更好。
我以公开透明的方式构建 AutoValue。如果你也在一个数据源如此混乱的市场中从事以 Retrieval 为基础的工作,我很想知道你还会增加哪一层防护。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。