多货币报价在 AI 眼里串了行:priceCurrency 与汇率切换的 60 天引用对照
适用读者:外贸独立站、跨境电商的技术负责人与前端工程师,负责结构化数据(Structured Data)维护的 SEO 同学,以及正在做生成式引擎优化(Generative Engine Optimization,GEO)、被 AI 回答误引报价的人。
一条客户对话记录:报的是欧元价,挂的是美元符号
「你们页面上写 1280 美元一套卡钳,AI 助手给客户的答复是 1280 欧元,客户以为我们坐地起价。」某汽配出海企业的外贸经理把这段对话截图甩进群里,后面跟着终端客户的原话。

人工打开页面核对,USD 1280,没问题。AI 回答里 EUR 1280,数字一位不差,货币单位换了。数值没漂,价格单位(price currency)串了行。
同一批 14 个 SKU 抽检里,9 个出现过数值与货币单位不匹配,AI 回答中的报价引用错误率 61%。这不是渲染 bug,浏览器里一切正常,坏在机器读到的那一层。
问题切片:三个层面各自错了什么
把这次排查拆开看,错误不是单点,是三层叠在一起。第一层是路由形态,第二层是结构化数据(Structured Data)的字段一致性,第三层是汇率快照的时点。三层单独看都不致命,合起来就变成 AI 眼里的乱报价。
层面一:货币切换只活在 URL 参数里
站点用 ?currency=EUR 切换货币。参数切换对用户友好,对抓取器(crawler)不友好:查询串(query string)在抓取调度里常被当作同一个 URL 的变体,规范化 URL(Canonical URL)指向美元版,那么欧元版页面大概率不被单独收录,也不被单独抓取。
结果是一个物理页面拖着三种货币状态,抓取器抓到哪一次算哪一次。
层面二:price 变了,priceCurrency 没跟着变
前端模板把价格数字替换成了换算后的值,但 JSON-LD 里的 priceCurrency 是硬编码的 "USD"。机器读到 price: 1280, priceCurrency: "USD",再叠上 DOM 里渲染出的 € 符号,两条证据互相打架。
层面三:汇率写入时机与抓取时点错位
汇率每天凌晨由定时任务写入缓存,抓取发生在白天。缓存里是 0.92 的换算系数,页面表格里展示的是实时接口拉到的 0.94。同一产品在结构化数据、DOM 文本、AI 回答三处出现三个价格。
| 层面 | 表象 | 机器实际读到 | 修复动作 |
|---|---|---|---|
| 路由 | ?currency=EUR 参数切换 |
欧版页面不收录,抓美元版 | 改为 /eur/ 子路径(Subpath Routing)+ 独立规范化 URL |
| 结构化数据 | 数字换算了,货币没换 | price 与 priceCurrency 不匹配 |
价格与货币同源生成,一次输出 |
| 汇率 | 日更缓存对实时接口 | 三处价格三个值 | 汇率快照(FX Snapshot)落库,全站同读一份 |
原理与机制剖析:AI 引擎怎么把「数字 + 货币」抽出来
抽取路径:结构化数据优先,DOM 兜底
主流引擎在抽取商品价格时,先看页面里的 JSON-LD(JavaScript Object Notation for Linked Data)。Offer 类型的 price 与 priceCurrency 是两个平级字段,抽取器按字段名取值,不会去读页面上的货币符号做二次判断。换句话说,priceCurrency 写的是什么,机器就认定是什么,页面上那个 € 号在结构化数据阶段不参与投票。
生成式回答(AI Overview 一类)在组织答案时,会把结构化数据抽到的数值与 DOM 文本里的展示值做一次弱校验。两者不一致时,常见处理是保留数字、另行补全单位,于是出现「数字来自美元版、单位来自欧元版」的拼接错误。这次事故的完整形状就是这样。
flowchart TD
A[产品页 HTML] --> B{是否存在 JSON-LD Offer}
B -->|存在| C[抽取 price 与 priceCurrency]
B -->|不存在| D[降级到 DOM 文本正则抽取]
C --> E[得到数值与货币单位]
D --> F[从符号推断单位]
E --> G{结构化数值与 DOM 展示值是否一致}
F --> G
G -->|一致| H[回答引用正确]
G -->|不一致| I[保留数字 另行补单位 出现串货币]
URL 参数对抓取视角的影响
抓取调度以 URL 为单位。带查询串的变体在去重阶段容易被折叠进规范化 URL 指向的那一版,欧元版因此长期不被抓取;即便被抓到,缓存里存的也常常是最早那次的结果。
子路径路由(/eur/product/...)在调度眼里是另一个 URL,有独立的抓取预算、独立的规范化声明、独立的缓存条目。这一条改动是后面所有一致性工作的前提。
flowchart LR
subgraph BEFORE[改造前 参数切换]
B1["/product/brake-caliper?currency=EUR"] --> B2[规范化 URL 指向美元版]
B2 --> B3[欧版不收录 AI 只见过 USD]
end
subgraph AFTER[改造后 子路径]
A1["/eur/product/brake-caliper"] --> A2[独立规范化 URL]
A2 --> A3[JSON-LD 输出 EUR]
A3 --> A4[AI 按路径取对应货币]
end
priceSpecification 的一致性契约
priceSpecification 是 Offer 下的复合字段,用 PriceSpecification 描述「多少钱、什么币、含不含税、有没有区间」。它和简写字段 price / priceCurrency 说的是同一件事,两处必须同源。
三条约束:一是 priceCurrency 用 ISO 4217 三字母代码,不要用符号;二是 valueAddedTaxIncluded 显式声明是否含税,B2B 报价尤其不能省;三是同一产品在任一时刻只允许一套生效的 priceSpecification,历史价走另一个字段或另开节点,不要并列。
改造方案:显式声明 + 子路径路由 + 一致性校验
第一步:Offer 与货币同源生成
价格对象由同一个工厂函数产出,price、priceCurrency、priceSpecification 从入参一次推导,不允许模板里二次改写。下面是构建脚本。
依赖与环境:Node.js 18+ 或 Next.js 14 App Router,无第三方依赖,输出直接嵌入页面 <script type="application/ld+json">。
// 依赖与环境:Node.js 18+ / Next.js 14 App Router,无第三方依赖
// 汇率来自全站共用的 FX 快照,禁止在此处另行请求实时接口
// 目标:price、priceCurrency、priceSpecification 三处同源一次产出
// 基准价统一以最小货币单位(分)存储,避免浮点累加误差
function buildOffer(product, currency, fxSnapshot) {
// 取该货币在快照中的换算系数与小数位规则
const rule = fxSnapshot.rules[currency];
// 基准价换算后按该货币的展示精度取整
const amountMinor = Math.round(product.priceMinorUSD * rule.rate);
// 最小单位还原为带小数点的展示值,EUR 与 USD 都是 2 位
const price = (amountMinor / 100).toFixed(rule.scale);
// 价格与货币来自同一次计算,不存在模板二次改写的机会
return {
"@type": "Offer",
// 货币用 ISO 4217 三字母代码,不写符号
priceCurrency: currency,
// 数值与上面的货币严格配对
price: price,
// 库存与履约字段保持与页面一致,避免另一类不一致
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
// 复合字段复述同一组数值,两处必须同源
priceSpecification: {
"@type": "PriceSpecification",
// 与外层 priceCurrency 取同一个变量
priceCurrency: currency,
// 与外层 price 取同一个变量
price: price,
// B2B 场景显式声明含税口径,缺省会让机器自行猜测
valueAddedTaxIncluded: product.taxIncluded,
},
// 子路径版 URL,货币信息体现在路径而非查询串
url: `https://example.com/${currency.toLowerCase()}/product/${product.slug}`,
};
}
// 汇率快照结构示例:rate 为相对基准货币的系数,scale 为小数位
const fxSnapshot = {
asOf: "2026-09-19T00:00:00Z",
rules: { USD: { rate: 1, scale: 2 }, EUR: { rate: 0.92, scale: 2 } },
};
// 同一个产品分别产出两套 Offer,各自携带自己的货币
const offerUSD = buildOffer(brakeCaliper, "USD", fxSnapshot);
const offerEUR = buildOffer(brakeCaliper, "EUR", fxSnapshot);
渲染后的 JSON-LD 片段如下,两个路径各出一份,互不复用:
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Brake Caliper 4-Piston Kit",
"sku": "BC-4P-1280",
"offers": {
"@type": "Offer",
"priceCurrency": "EUR",
"price": "1177.60",
"availability": "https://schema.org/InStock",
"url": "https://example.com/eur/product/brake-caliper-4p",
"priceSpecification": {
"@type": "PriceSpecification",
"priceCurrency": "EUR",
"price": "1177.60",
"valueAddedTaxIncluded": false
}
}
}
第二步:子路径路由中间件
依赖与环境:Next.js 14 App Router 的 middleware.ts,或任意支持边缘中间件的框架,逻辑可直接移植到 Nginx 的 location 规则。
// 依赖与环境:Next.js 14 App Router middleware.ts,边缘运行时
// 职责:把货币写进路径,并为每个货币路径给出独立的规范化 URL
import { NextRequest, NextResponse } from "next/server";
// 站点支持的货币白名单,未列入的一律回落默认货币
const SUPPORTED = ["usd", "eur", "gbp"];
// 默认货币,用于兜底与重定向
const DEFAULT_CCY = "usd";
export function middleware(req: NextRequest) {
const url = req.nextUrl;
// 旧链接携带查询参数,做一次 301 迁到子路径,保留原有路径与查询
const legacy = url.searchParams.get("currency");
if (legacy) {
// 参数值统一小写并对齐白名单
const ccy = legacy.toLowerCase();
// 清理查询串里的货币参数,避免两套机制并存
url.searchParams.delete("currency");
// 拼出新的子路径,携带其余查询参数
const target = new URL(`/${SUPPORTED.includes(ccy) ? ccy : DEFAULT_CCY}${url.pathname}`, url.origin);
target.search = url.searchParams.toString();
// 301 让抓取器把权重迁到新地址
return NextResponse.redirect(target, 301);
}
// 取出路径首段作为货币标识
const seg = url.pathname.split("/")[1] || "";
// 首段不在白名单,说明是裸路径,补上默认货币前缀
if (!SUPPORTED.includes(seg.toLowerCase())) {
const target = new URL(`/${DEFAULT_CCY}${url.pathname}`, url.origin);
target.search = url.searchParams.toString();
return NextResponse.redirect(target, 302);
}
// 命中货币子路径,把货币写进请求头供渲染层读取
const headers = new Headers(req.headers);
// 渲染层与 JSON-LD 构建函数都从这里取值,保证只有一个来源
headers.set("x-currency", seg.toLowerCase());
return NextResponse.next({ request: { headers } });
}
// matcher 排除静态资源,减少无谓的边缘计算
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
第三步:汇率快照与一致性校验
依赖与环境:Python 3.10+,requests 可选,脚本挂在每日定时任务后执行,产出快照并做一次全站一致性扫描。
# 依赖与环境:Python 3.10+,requests 可选,挂定时任务执行
# 职责:生成当日汇率快照,并校验线上结构化数据与快照是否一致
import json
import datetime
import urllib.request
# 基准货币与需要产出的目标货币
TARGETS = ["USD", "EUR", "GBP"]
# 每种货币的展示小数位
SCALE = {"USD": 2, "EUR": 2, "GBP": 2}
# 汇率接口换成自己采购的那一家
FX_ENDPOINT = "https://api.example.com/fx/latest?base=USD"
# 一致性阈值:线上值与快照换算值相差超过 2 分即判定为串货币
TOLERANCE = 0.02
# 扫描结果落盘路径,供看板读取趋势
REPORT_PATH = "./reports/offer_consistency.json"
def build_snapshot():
# 拉取一次接口,全站共用这一份结果
with urllib.request.urlopen(FX_ENDPOINT, timeout=10) as resp:
raw = json.load(resp)
# 快照里记下时间戳,页面可展示“汇率截至”
snap = {"asOf": datetime.datetime.utcnow().isoformat() + "Z", "rules": {}}
# 基准货币系数恒为 1
snap["rules"]["USD"] = {"rate": 1.0, "scale": SCALE["USD"]}
# 其余货币按接口返回的中间价写入
for ccy in TARGETS:
if ccy == "USD":
continue
snap["rules"][ccy] = {"rate": float(raw["rates"][ccy]), "scale": SCALE[ccy]}
return snap
def check_offer(offer, snap, expect_ccy):
# 校验点一:外层货币必须是路径对应的货币
if offer.get("priceCurrency") != expect_ccy:
return f"priceCurrency 不匹配: {offer.get('priceCurrency')} != {expect_ccy}"
# 校验点二:复合字段的货币必须与外层一致
spec = offer.get("priceSpecification", {})
if spec.get("priceCurrency") != expect_ccy:
return "priceSpecification.priceCurrency 与外层不一致"
# 校验点三:两个数值必须字符串相等,避免浮点格式化差异
if str(offer.get("price")) != str(spec.get("price")):
return f"price 与 priceSpecification.price 不一致: {offer.get('price')} / {spec.get('price')}"
# 校验点四:数值必须由当前快照换算得出,浮动超过阈值即报警
# 阈值与上面的 TOLERANCE 共用,避免两处口径不一致
expected = round_expected(offer, snap, expect_ccy)
if abs(float(offer["price"]) - expected) > TOLERANCE:
return f"价格偏离快照换算值: {offer['price']} vs {expected}"
return None
def round_expected(offer, snap, ccy):
# 以基准价乘以快照系数,按展示精度取整
base = float(offer["_baseMinorUSD"]) / 100
rate = snap["rules"][ccy]["rate"]
return round(base * rate, SCALE[ccy])
60 天对照:某汽配出海企业的引用正确率
改造分两批上线:第 0 天完成子路径路由与 301 迁移,第 6 天完成 JSON-LD 同源改造与快照校验,之后进入 60 天观测。观测口径是每天固定 20 组提问,覆盖 14 个 SKU,记录 AI 回答里出现的数值与货币是否与对应路径页面的结构化数据一致。
| 观测指标 | 改造前(前 30 天均值) | 改造后(后 30 天均值) | 变化 |
|---|---|---|---|
| 报价引用正确率 | 39% | 92% | +53 个百分点 |
| 数值正确但货币错误占比 | 34% | 3% | 下降 31 个百分点 |
| 数值与货币全错占比 | 27% | 5% | 下降 22 个百分点 |
| 欧版页面被单独抓取的次数/周 | 2 | 41 | 提升约 20 倍 |
| 同一 SKU 出现三个价格的次数 | 58 | 4 | 下降 93% |
分错误类型看得更清楚。参数路由时期的主要错误是「数值对、单位错」,因为机器只见过美元版的 priceCurrency,却从欧元路径的文本里补了单位。子路径上线后欧版有了独立抓取条目,这一类错误在两周内基本消失。剩下 5% 里的多数是汇率快照更新当天抓取到的旧缓存,属于缓存失效延迟,靠缩短缓存 TTL(Time To Live)继续压。
| 错误类型 | 改造前占比 | 改造后占比 | 主要成因 |
|---|---|---|---|
| 数值对、货币错 | 34% | 3% | 参数变体未被抓取,priceCurrency 停在默认货币 |
| 数值错、货币对 | 19% | 2% | 实时接口与日更缓存并存 |
| 数值与货币全错 | 27% | 5% | 快照更新当天的旧缓存未失效 |
| 未引用任何价格 | 20% | 8% | 结构化数据字段缺失导致降级抽取 |
误区澄清与趋势预判
一个常见误区是以为多货币站点只要在页面上把符号换掉就够了。机器读的是字段不是像素,符号换掉不影响 JSON-LD,也不影响抓取器抓哪一版。另一个误区是把 priceCurrency 写成动态变量却从两个来源取值,页面渲染读一个、结构化数据读另一个,等价于没改。
汇率精度也容易踩。不同货币的小数位规则不同,日元这类零小数位货币按两位输出会出现 1280.00 这种机器能读但明显不对的值,展示精度应当随货币走。
往后看,多货币报价的机器可读性会越来越依赖路径层面的显式信号。查询串驱动的视图层状态,对生成式引擎(Generative Engine)来说是几乎不可见的;把货币、语言、地区这类会影响数值语义的维度写进路径,等于给抓取器一张稳定的地图。结构化数据这一侧的收敛方向也很明确:简写字段与复合字段同源,含税口径显式声明,汇率快照可追溯。
具体到落地,我建议的起手顺序是先改路由,再改结构化数据,最后上校验。路由不改,后面两件事做完了也只覆盖到美元版;校验脚本不上线,改造完一个月就会因为某次汇率接口变动悄悄退化。你们站点在多货币这块踩过哪一类坑,评论区聊聊,尤其是缓存失效和抓取时点那些难复现的。
参考与延伸
- schema.org priceCurrency 定义
- schema.org PriceSpecification 类型
- Google Search Central 产品结构化数据指南
- Google Search Central 重复网址合并与规范化
多货币报价 · priceCurrency · JSON-LD Offer · 子路径路由 · 结构化数据 · GEO · AI优化AIO