商品明明有货 AI 却说缺货:inventoryLevel 库存实态的结构化改造实战

2026-10-04 01:18:50 1 次浏览
GEOAI搜索Schema.orgJSON-LDinventoryLevel电商库存

适用读者:负责连锁零售电商站点的后端工程师、做商品详情页 SEO/GEO 优化的技术同学,以及正被「AI 搜索回答说没货导致客诉」困扰的电商团队负责人。文章假设你至少写过一门后端语言,见过商品结构化数据,但未必深入碰过 schema.org 的 Offer 字段。

我们是一家连锁便利品牌的电商技术团队,三十多家门店,线上线下库存各自为政。今年年中开始,客诉系统里反复出现一类工单:用户在豆包、DeepSeek 或者百度 AI 搜索里问「某某牌低糖酸奶附近门店有没有货」,AI 回答缺货,用户跑到门店发现货架满满的。截图发给客服,客服来问技术,技术去看商品详情页——页面明明白白显示「现货」。问题出在哪?页面给人看的是对的,给 AI 看的库存字段是错的。

问题定位:AI 读到的库存和我们以为的不是同一份

先把现象拆开。商品详情页右上角的「现货 / 缺货」标签,读的是我们前端自己的库存接口,实时查 Redis。但页面底部还有一段 JSON-LD 结构化数据,里面的 Offer 节点长这样:

库存实态结构化改造

{
  "@type": "Offer",
  "price": "12.90",
  "priceCurrency": "CNY",
  "availability": "https://schema.org/OutOfStock"
}

这段 JSON-LD 是三年前上线商品系统时模板里写死的,availability 硬编码为 OutOfStock 的逻辑反了不说,inventoryLevel(库存数量)压根没有输出。AI 引擎在做生成式引擎优化(Generative Engine Optimization, GEO)抓取和索引时,恰恰更信任这类机器可读的结构化数据,而不是页面上那个展示用的角标——后者对爬虫来说只是一段普通文本,语义弱得多。

抓包验证了这个判断。用 User-Agent 模拟主流 AI 爬虫请求商品页,返回的 HTML 里 availability 大量是 OutOfStock,哪怕这家门店当天卖出去两百单。AI 引擎拿到旧快照后,回答「有没有货」自然照本宣科:缺货。

机制剖析:AI 抽取管线为什么高权重消费 JSON-LD

结论一句话:页面上的库存状态和结构化数据里的库存状态是两条完全脱节的数据链路,AI 只能看到后者,而后者三年没维护。

整条数据链路的问题用一张图看得更清楚:

flowchart LR
    POS[POS 收银] -->|实时回写| ERP[(ERP 库存)]
    ERP -->|同步快照| FE[前端库存接口]
    FE -->|实时查询| PAGE[商品页角标]
    OLD[三年前的模板] -->|硬编码 OutOfStock| PAGE
    PAGE -->|抓取 HTML| CRAWLER[AI 爬虫]
    CRAWLER -->|解析 JSON-LD| INDEX[AI 索引]
    USER[用户提问 有没有货] --> INDEX --> ANSWER[回答:缺货]

改造前,POS 和 ERP 到页面角标这条链路是活的,但 ERP 到 JSON-LD 这条链路根本不存在,AI 爬虫只能读到模板里的旧枚举。改造做的事就是把断掉的那条虚线补上。

这里得讲清楚底层机制,AI 引擎为什么宁信结构化数据、不信页面文案。AI 搜索类产品的信息抽取管线通常分两步:先用爬虫抓取页面,解析出正文与元数据;再从 HTML 里提取带语义标注的片段——JSON-LD、Microdata、RDFa 都属于这一类——作为高置信度的实体属性来源。原因很实际:自然语言文案需要模型二次理解,「现货」两个字在不同上下文里可能指预售、可能指门店自提,而 https://schema.org/InStock 是一个枚举值,没有歧义,抽取成本几乎为零。当生成模型被问到「有没有货」这类事实性问题时,检索层命中结构化字段的权重天然更高。这也解释了为什么改造方向是修数据,而不是想办法「让 AI 读懂页面」。

改造方案:POS/ERP 库存定时任务生成 JSON-LD

门店真实库存的可信源是 ERP,POS 每笔交易实时回写,线上下单扣的是 ERP 同步过来的库存快照。改造的核心动作是:新增一个 .NET 8 后台定时服务,定时从 ERP 拉取各门店在售商品库存,生成带 inventoryLevel 和 availability 的 JSON-LD,写入缓存,商品详情页渲染时从缓存取片段直接拼进 HTML。

依赖与环境:.NET 8(net8.0),只用 BCL,不引第三方包;ERP 侧提供一个只读查询接口 GET /api/stock?storeCode={code}&page={page},返回分页库存明细。为什么选 IHostedService 加 PeriodicTimer 而不是 Quartz 或者 Hangfire?因为任务就一个循环、无复杂调度需求,标准库够用,少一个依赖就少一个升级负担。

// InventoryJsonLdWorker.cs —— .NET 8 后台定时服务
// 环境:net8.0,无第三方 NuGet 依赖,宿主为 ASP.NET Core Web 应用
// 职责:定时从 ERP 拉库存,生成 JSON-LD 片段写入缓存
public sealed class InventoryJsonLdWorker : BackgroundService
{
    // 结构化日志,排障全靠它
    // 上线首周靠这行日志定位到 ERP 分页超时
    private readonly ILogger<InventoryJsonLdWorker> _logger;
    // ERP 只读库存客户端,项目内自行封装的 HttpClient
    // 超时设 8 秒,避免 ERP 抖动拖垮整个定时轮次
    private readonly IStockApiClient _stockApi;
    // 缓存封装,底层是 Redis,键按 门店:SKU 维度隔离
    // 键里必须带门店码,跨门店求和无业务意义
    private readonly IJsonLdCache _cache;

    public InventoryJsonLdWorker(IStockApiClient stockApi, IJsonLdCache cache,
        ILogger<InventoryJsonLdWorker> logger)
    {
        // 通过 DI 注入 ERP 客户端与缓存封装
        _stockApi = stockApi;
        _cache = cache;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // PeriodicTimer 是 .NET 6+ 提供的轻量定时器,天然防重入
        // 周期 5 分钟:ERP 压力与库存新鲜度之间的平衡点
        // 比起 Timer 回调,不会出现上一轮没跑完又起新一轮
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // 每轮全量刷新 34 家门店的在售 SKU 库存
                await SyncAllStoresAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // 停机信号,直接退出循环
                break;
            }
            catch (Exception ex)
            {
                // 单轮失败不拖垮服务,记录后等下一轮
                _logger.LogError(ex, "库存 JSON-LD 同步轮次失败,等待下个周期");
            }
        }
    }

    private async Task SyncAllStoresAsync(CancellationToken ct)
    {
        // 门店列表每小时变动极小,客户端内部有内存缓存
        foreach (var store in await _stockApi.ListStoresAsync(ct))
        {
            int page = 1;
            while (true)
            {
                // ERP 接口按门店分页拉取,每页 500 条
                // page 参数从 1 开始,读空页视为本门店结束
                var stocks = await _stockApi.GetStockPageAsync(store.Code, page, ct);
                // 空页说明该门店已读完,跳出分页循环
                if (stocks.Count == 0) break;
                foreach (var s in stocks)
                {
                    // 库存低于安全线 5 件时归入 LimitedAvailability
                    // 三档映射必须与页面角标展示逻辑保持一致
                    var availability = s.Qty <= 0
                        ? "https://schema.org/OutOfStock"
                        : s.Qty < 5
                            ? "https://schema.org/LimitedAvailability"
                            : "https://schema.org/InStock";
                    // 生成该 SKU 的 Offer 节点 JSON-LD 片段
                    // BuildOfferJsonLd 内部用 System.Text.Json 序列化
                    var jsonLd = BuildOfferJsonLd(s, availability);
                    // 写缓存,键含门店码,TTL 10 分钟兜底
                    // TTL 比任务周期长一倍,单轮失败仍可渲染
                    _cache.Set($"ld:{store.Code}:{s.Sku}", jsonLd, TimeSpan.FromMinutes(10));
                }
                // 翻到下一页继续
                // 全量一轮约 4 分钟,留出 1 分钟余量
                page++;
            }
        }
    }
}

BuildOfferJsonLd 的产物就是我们替换掉旧模板的那段 JSON-LD,节点上新增了两个字段:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "sku": "YB-20240711",
  "name": "低糖酸奶 200g",
  "offers": {
    "@type": "Offer",
    "price": "12.90",
    "priceCurrency": "CNY",
    "availability": "https://schema.org/InStock",
    "inventoryLevel": {
      "@type": "QuantitativeValue",
      "value": 186
    }
  }
}

inventoryLevel 的取值用 QuantitativeValue 直接放数量,是官方文档推荐的三种表达之一(也可以用 Integer 或者 BusinessHours 语义的文本,我们选数值是为了让 AI 引擎能做数量比较)。availability 沿用 ItemAvailability 枚举,InStock、LimitedAvailability、OutOfStock 三档足够覆盖便利店场景,不建议自己造枚举值——AI 引擎对未知枚举的处理是直接丢弃。

缓存、防抖与几个踩过的坑

定时任务每五分钟一轮,但真正决定 AI 看到什么的是抓取时机,AI 爬虫不会卡着你的刷新周期来。所以缓存层做了两个关键设计,参数取舍如下:

设计项 取值 理由
任务刷新周期 5 分钟 ERP 压力可控,库存新鲜度对便利店足够
缓存 TTL 10 分钟 比周期长一倍,单轮任务失败仍有数据可渲染
防抖合并窗口 3 秒 剧烈跳动场景下同 SKU 只写一次
LimitedAvailability 阈值 < 5 件 与门店安全库存线对齐

第一是缓存 TTL 与刷新周期错位:任务周期 5 分钟,缓存 TTL 10 分钟,意思是即便某轮任务整个失败,缓存里还有上一轮的完整数据可以撑,页面不会因为后端抖动就渲染出空的 JSON-LD 节点——结构化数据缺失比数据旧五分钟对 GEO 的伤害大得多。

第二是防抖:大促或者补货到店时,ERP 库存会在几分钟内剧烈跳动,如果每次变动都触发缓存写和页面渲染失效,Redis 和页面缓存会被打爆。我们的做法是库存变动先入一个内存合并队列,三秒窗口内同一 SKU 只取最后一次值,再批量刷缓存。上线第一周没做这个,双十二预热当晚 Redis 写入 QPS 冲到平日的八倍,紧急补的。

门店维度还有个容易漏的点:同一 SKU 不同门店库存不同,JSON-LD 必须按「门店 + SKU」维度输出,页面 URL 也带门店参数。有同事最初把三十多家门店的库存做了求和,结果 AI 回答「有货」,用户去了那家实际为零的门店——求和是无意义的,AI 和用户都只关心具体那家店。

改造后的整体数据流如下,定时任务成为 JSON-LD 的单一写入口,页面渲染只读缓存,两边彻底解耦:

flowchart TB
    ERP[(ERP 库存)] -->|每 5 分钟分页拉取| WORKER[InventoryJsonLdWorker]
    WORKER -->|3 秒防抖窗口合并| QUEUE[内存合并队列]
    QUEUE -->|批量写入 TTL 10 分钟| REDIS[(Redis 缓存)]
    REDIS -->|渲染时读取| PAGE[商品详情页 JSON-LD]
    PAGE -->|抓取| AI[AI 爬虫索引]
    WORKER -->|异常单轮跳过| LOG[日志告警]

写了个 Python 校验脚本挂在 CI 里,每次模板改动自动检查输出格式,避免结构化数据悄悄回退:

# check_jsonld.py —— 校验商品页 JSON-LD 的库存字段
# 依赖:仅标准库(urllib.request、json、re),Python 3.10+
# 用法:python check_jsonld.py https://shop.example.com/p/xxx ...
# 目标:模板或渲染逻辑改动后自动发现库存字段回退
import json, re, sys, urllib.request

def check(url: str) -> list[str]:
    # 请求页面并提取所有 application/ld+json 脚本块
    html = urllib.request.urlopen(url, timeout=10).read().decode("utf-8")
    # 非贪婪匹配,兼容页面里存在多个 JSON-LD 块的情况
    blocks = re.findall(
        r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
    problems = []
    for raw in blocks:
        # 解析失败的块直接计入问题,不静默吞掉
        data = json.loads(raw)
        offers = data.get("offers", {})
        # availability 必须是合法 ItemAvailability 枚举
        avail = offers.get("availability", "")
        if not avail.endswith(("/InStock", "/OutOfStock", "/LimitedAvailability")):
            # 枚举值非法意味着 AI 引擎会丢弃整个字段
            problems.append(f"{url} 非法 availability: {avail}")
        # inventoryLevel 必须存在且为非负数值
        level = offers.get("inventoryLevel", {})
        if not isinstance(level.get("value"), (int, float)) or level["value"] < 0:
            # 缺字段比数值旧更严重,直接判失败
            problems.append(f"{url} inventoryLevel 缺失或非法")
    return problems

# 命令行传入商品页 URL 列表,任一失败退出码 1
if __name__ == "__main__":
    # CI 里非零退出码会阻断流水线,防止模板回退上线
    bad = [p for url in sys.argv[1:] for p in check(url)]
    # 逐条打印问题 URL 与原因,方便定位是哪次提交引入的
    for p in bad:
        print(p)
    sys.exit(1 if bad else 0)

上线效果:30 天对照

改造分两批上线,7 月 8 日先上华东 18 家门店,7 月 22 日覆盖全部 34 家。我们拉了上线前后各 30 天的客诉工单,按「用户引用 AI 回答称缺货、实际门店有货」口径统计,同时用抓取工具记录各 AI 引擎看到的结构化数据命中率。数字是内部系统导出的,口径写着上面,供参考不是行业结论。

指标 改造前 30 天 改造后 30 天
AI 回答缺货相关客诉(单/周均值) 14.5 2.3
客诉升级到门店经理层级的工单 9 1
商品页 JSON-LD 库存字段输出正确率 61% 99.6%
门店库存与线上展示不一致工单 22 5

人工抽查了改造后客诉里剩下的那两三单,原因基本集中在两类:一是门店当天临时盘点锁库存,ERP 停写导致的旧值,这个我们加了盘点期间的显式 PreOrder 标记;二是用户问的是门店自营小程序外的第三方平台货,库存源不同,属于业务边界问题,不归这次改造管。

顺便验证了一个此前只有猜测的事:JSON-LD 修好之后,AI 引擎在回答里引用我们商品页的频率明显上升。豆包和 DeepSeek 对几个测试 SKU 的回答从「可能缺货,建议电话咨询」变成直接给出库存状态和门店地址,百度 AI 搜索则开始在答案来源里挂我们的详情页链接。这就是做 GEO 的实际收益——你把数据喂得越结构化、越新鲜,AI 在生成答案时越愿意把你当一手来源。

还可以往前走一步的地方

库存实态只是 Offer 节点的一角,schema.org 的 inventoryLevel 文档 里还有交付时效、适用区域等表达方式我们还没用。下一步计划把门店营业时间(OpeningHoursSpecification)和「线上下单门店自提」的 pickup 语义补进同一份 JSON-LD,让 AI 在回答「几点去拿」这类问题时也有据可依。另外盘点锁库存那类临时状态,ERP 侧目前是停写,计划改成写入一个 status 字段,让定时任务能区分「真缺货」和「暂不可售」,避免又一轮语义模糊。有做连锁零售结构化数据的同学,欢迎评论区交流各家 AI 引擎对 inventoryLevel 的实际读取表现,我们持续在攒对比数据。

参考与延伸

inventoryLevel · ItemAvailability · JSON-LD · Schema.org · 电商库存 · GEO · AI搜索

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。