客服电话写对了 AI 还是打不通:ContactPoint 与 contactType 的字段规范清单
读的时候最好手边开着你们首页的 JSON-LD 源码,可以跟着一起改。
telephone 字段没写错,AI 还是把用户推给了三年前停用的 400 号。这话不是我编的,是上个月一个做连锁家政的客户在工单里转述给我的原话——他们在 LocalBusiness 里规规矩矩写了 telephone,Google 的结构化数据校验一片绿,可用户在 AI 助手里问「你们客服电话多少」,得到的回答是页脚那个早就停机的旧号。
这类问题属于生成式引擎优化(Generative Engine Optimization, GEO)里最容易被忽略的一块:结构化数据不是写对了就算数,还得让机器能判断「这个号是干嘛的、什么时候能打、服务哪些人」。telephone 只是个字符串,ContactPoint 才带语义。
号码没错,AI 念的却是停用的那个
先说现场。这家公司有 37 家门店,站点是 Next.js 14 做的,每个门店一个落地页。出事的那个页面叫「城南店」,第 9 周上线,第 12 周客服部在工单系统里连续收到 4 条投诉:客户说打过去是空号。

我把三个数据源拉出来对了一遍:
| 数据来源 | 号码 | 状态 |
|---|---|---|
| 页面 JSON-LD 的 LocalBusiness.telephone | 4001234567 | 2019 年停用,模板里硬编码没改 |
| 联系页正文的 tel 链接 | 0755-88886666 | 在用,门店坐席 |
| 地图平台认领的门店电话 | 138****6666 | 在用,区域经理手机 |
三个号三个来源,JSON-LD 里那个偏偏是最旧的。更要命的是,这份 JSON-LD 里根本没有 contactPoint 节点,AI 助手拿到的是一串没有角色标注的数字,它没法判断这是客服号、销售号还是门店前台。于是它选了最「正式」的那个——停用 400。
这事儿白费劲的地方在于:很多人第一反应是去改 JSON-LD 里的号码,改完发现过两天又被覆盖回去了,因为页脚模板里还有一份。真正的病灶是两个:号码没有单一数据源,号码没有语义标注。
AI 挑号码的底层机制
要理解为什么写了也会被念错,得先看 AI 搜索是怎么消费结构化数据的。很多同学以为搜索引擎拿到 JSON-LD 会原样存起来,用户问什么就吐什么,其实中间还有几跳。
flowchart TD
A[抓取门店页 HTML] --> B{页面里有没有 JSON-LD}
B -- 没有 --> C[退化为解析正文里的 tel 链接和页脚文本]
B -- 有 --> D[解析 JSON-LD 图节点]
D --> E{有没有 contactPoint 子节点}
E -- 有 --> F[按 contactType 匹配用户意图]
E -- 没有 --> G[退回顶层 telephone 字符串]
F --> H[再按 areaServed 与 hoursAvailable 过滤]
G --> I[直接引用顶层字符串不做角色判断]
H --> J[生成答案并附号码]
I --> J
关键在 E 和 H 这两跳。有 contactPoint 的时候,引擎是先匹配意图再选号;没有 contactPoint 的时候,它只能拿顶层那个字符串,不做角色判断,也不做时段过滤。所以你写了 telephone 和没写 contactPoint,等于把选择权交给了抓取顺序和文本位置——谁在 HTML 里靠前就可能被选中。
结构化数据到答案的三跳
第一跳是抽取。爬虫取到的是一张图(graph),不是你眼睛看到的那个 JSON 缩进。节点之间靠 @id 关联,@type 决定它按什么规则解释。
第二跳是消歧。同一家店在页脚、地图平台、点评站上可能有三个号,引擎会给它们算一个置信度。带 contactType 且和正文一致的号,置信度明显高;孤零零一个字符串,容易被判成「历史遗留」。
第三跳是生成。大模型在组织答案时会挑置信度最高的那个节点展开。这就是为什么你把号改对了还不够——只要旧号在别的地方还活着,它就还在竞争。
把这三跳画成时序,用户侧的一次提问大概是这样走的:
sequenceDiagram
participant U as 用户
participant E as AI 搜索引擎
participant G as 结构化数据图谱
U->>E: 城南店客服电话是多少
E->>G: 查询城南店节点
G-->>E: 返回 contactPoint 数组
E->>E: 用 contactType 匹配客服意图
E->>E: 用 hoursAvailable 过滤已下班号码
alt 命中客服号
E-->>U: 报出在用客服热线
else 只有顶层 telephone
E-->>U: 直接引用字符串可能是停用号
end
结论:contactType 不是可选装饰,它是让引擎区分「售前咨询」和「售后报修」的核心语义入口。少了它,AI 只能瞎猜。
ContactPoint 上能挂哪些字段
ContactPoint 是 Schema.org 里挂在 Organization、LocalBusiness、Person 下面的通用类型。下面这几个字段在本地服务业场景里最常用,逐个说清楚。
| 字段 | 期望类型 | 该填什么 | 缺了会怎样 |
|---|---|---|---|
| telephone | Text | E.164 风格,带国家码,如 +86-755-88886666 | 境外引擎拼错区号,外呼直接失败 |
| contactType | Text | customer service、sales、technical support、reservations、billing | 引擎分不出售前售后,把销售号当客服号念出去 |
| contactOption | ContactPointOption | TollFree 或 HearingImpairedSupported | 免费号与付费号混排,用户被莫名收费 |
| availableLanguage | Text 或 Language | BCP 47 标签,zh-Hans、yue、en | 粤语用户被推给只讲普通话的坐席 |
| areaServed | AdministrativeArea、GeoShape、Place、Text | 城市名或行政区 | 深圳用户拿到只服务东莞的门店号 |
| hoursAvailable | OpeningHoursSpecification | 坐班时段 | 深夜提问被报一个没人接的号 |
几个容易踩坑的细节:
telephone一定要加引号写成字符串。写成 JSON 数字,前导 0 和加号会丢,+86直接变成 86。contactType在 Schema.org 里是 Text,不是枚举,理论上可以写中文「客服」;但主流引擎的训练语料里英文取值命中率更高, bilingual 站点建议写英文,中文放到 alternateName 里。这一步做了,AI 搜索语境下的匹配会稳很多。availableLanguage要用 BCP 47 标签,写「中文」或「Chinese」都不规范,写zh-Hans才被认。hoursAvailable可以和 OpeningHoursSpecification 复用同一套 dayOfWeek 写法,别另起炉灶。
错误写法和正确写法摆一起看
下面是出事那家店改造前的 JSON-LD,我删掉了无关字段,只留联系方式相关的部分。环境是 Next.js 14 App Router,JSON-LD 由服务端组件内联输出。
// 改造前的写法:号码是纯数字、没有国家码、没有 contactPoint
// 结果:引擎只能拿到一个无角色标注的字符串
{
// 上下文固定写 schema.org,不要写成 http 版本
"@context": "https://schema.org",
// 家政维修类用 HomeAndConstructionBusiness,它是 LocalBusiness 的子类型
"@type": "HomeAndConstructionBusiness",
"name": "某某家政服务(城南店)",
// 这里写成了 JSON 数字,前导符号会丢
"telephone": 4001234567,
// 两个号硬塞进一个字符串,引擎无法拆分
"faxNumber": "0755-88886666, 0755-88886667",
// 邮箱没有对应到具体角色,等于没有语义
"email": "cs@example.com",
"address": {
// 地址节点类型不能省,省了 PostalAddress 会被当成纯文本
"@type": "PostalAddress",
// 街道地址写到门牌号,别用简称
"streetAddress": "城南大道 88 号 1 层",
// 城市名不带省市后缀更利于匹配
"addressLocality": "深圳市",
"addressRegion": "广东省",
// 国家码用 ISO 3166-1 alpha-2,中国是 CN
"addressCountry": "CN"
}
}
问题有三处,从上往下数:telephone 是数字类型;contactPoint 整个缺失;faxNumber 里塞了两个号用逗号分隔,这种写法 Schema.org 不认,等于告诉引擎「这串东西你看着办」。
改造后的版本长这样:
// 改造后的写法:顶层只保留门店主号,角色号全部下沉到 contactPoint
// 环境同上,由服务端组件内联输出,号码统一取自门店配置表的单个字段
{
"@context": "https://schema.org",
"@type": "HomeAndConstructionBusiness",
// 稳定 @id,改造后重抓时引擎能识别为同一实体
"@id": "https://example.com/store/cheng-nan#store",
"name": "某某家政服务(城南店)",
// 顶层 telephone 与客服号保持一致,避免两处打架
"telephone": "+86-400-123-4567",
"email": "cs@example.com",
"contactPoint": [
{
"@type": "ContactPoint",
// 客服热线:放在第一位,用户最常问的就是它
"contactType": "customer service",
"telephone": "+86-400-123-4567",
// 标注免费电话,引擎会优先推荐给用户
"contactOption": "TollFree",
// 两种语言,普通话与粤语
"availableLanguage": ["zh-Hans", "yue"],
// 服务范围限定本市,别让外地用户拿到这个号
"areaServed": {"@type": "AdministrativeArea", "name": "深圳市"},
"hoursAvailable": {
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
"opens": "08:00",
"closes": "22:00"
}
},
{
"@type": "ContactPoint",
// 技术支持:空调维修、水电抢修走这条线
"contactType": "technical support",
// 门店固话,抢修类问题打这条
"telephone": "+86-755-88886666",
// 只支持普通话
"availableLanguage": ["zh-Hans"],
"areaServed": {"@type": "AdministrativeArea", "name": "深圳市"},
"hoursAvailable": {
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
"opens": "00:00",
"closes": "23:59"
}
},
{
"@type": "ContactPoint",
// 销售加盟:只服务工作日,别在夜里被报出去
"contactType": "sales",
// 加盟咨询专线,工作日才有人
"telephone": "+86-755-88886688",
"availableLanguage": ["zh-Hans", "en"],
"areaServed": {"@type": "AdministrativeArea", "name": "广东省"},
"hoursAvailable": {
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "09:30",
"closes": "18:30"
}
}
]
}
对照着看,改动其实就四件事:号码统一加国家码写成字符串、顶层号与客服号对齐、三条 contactPoint 各带 contactType、每条都补 hoursAvailable 和 areaServed。
多个 ContactPoint 的排序问题
很多人问我,数组顺序是不是代表优先级。在 Schema.org 的规范里,数组顺序不携带优先级语义,引擎应该按 contactType 匹配用户意图。但实际抓取里,排在前面的节点更容易被先解析到,所以习惯上把最高频的那条放第一位——客服号放第一,销售号往后。
去重规则也简单:同一个 contactType 在同一个 LocalBusiness 下只保留一条。我们扫过一家有 9 个门店页的站点,其中一个页面上挂了 4 条 customer service,两个是旧号、两个是新号,引擎自己也不知道该信谁。清理办法是先按 contactType 分组,同组里留 hoursAvailable 覆盖时段最宽且与页脚一致的那条。
还有个容易忽略的点:@id。给每个 ContactPoint 加稳定 @id,改造后重新提交的时候,引擎更容易判断这是同一个实体的更新而不是新冒出来的号。
400 和手机号混在一个字段里
本地服务业特别爱干这事——400 是全国热线,门店还有自己的固话,区域经理再留个手机,三个全往 telephone 里塞,用顿号或斜杠隔开。
这在 Schema.org 里是无效写法。telephone 的期望类型是单个 Text,塞多个号等于给了一个引擎无法解析的复合值。正确做法是拆成三条 contactPoint,靠 contactType 和 areaServed 区分:
- 400 走
customer service+contactOption: TollFree,areaServed 写全国。 - 门店固话走
customer service之外的technical support,areaServed 写本市。 - 手机号原则上不进结构化数据。它变动频繁,一旦换人就是一批失效数据,而且私人号码进公开图谱也不合适。
另外提醒一句,contactOption 只有两个合法取值:TollFree 和 HearingImpairedSupported。见过有人写 Free、Toll Free(中间有空格)甚至写中文「免费」,这些都不在枚举里,写了跟没写一样。
全站号码一致性怎么批量查
改完单个页面不算完,得确认全站模板里没有第二份旧号。我们内部用一段脚本做这件事:把仓库里所有模板和构建产物扫一遍,抽出所有疑似号码,和门店配置表里的号做比对。
# 环境:Python 3.11,仅用标准库,无第三方依赖
# 用途:扫描站点源码与构建产物,找出与配置表不一致的电话号码
# 运行方式:python scripts/check_phone_consistency.py
# 退出码约定:脚本只打印,交由人工判断是否清理,避免误删
import re
import json
import pathlib
# 门店配置表:号码的权威数据源,改造时以它为准
STORE_JSON = pathlib.Path("data/stores.json")
# 需要扫描的目录:模板源码 + 构建产物
SCAN_DIRS = ["templates", "pages", "public", ".next/server"]
# 匹配中国区号固话、手机号、400 号码,允许分隔符或连写
PHONE_RE = re.compile(r"(?:\+?86[- ]?)?(4\d{2}|1[3-9]\d{2}|0\d{2,3})[- ]?\d{3,4}[- ]?\d{3,4}")
def load_official_numbers():
# 读取门店配置,返回官方号码集合
stores = json.loads(STORE_JSON.read_text(encoding="utf-8"))
# 用集合去重,同一号码在多家门店出现只算一次
return {normalize(s["telephone"]) for s in stores}
def normalize(raw):
# 归一化:去掉所有非数字字符,只保留数字部分用于比对
digits = re.sub(r"\D", "", raw)
# 去掉国际前缀,统一成国内直拨格式再比对
for prefix in ("0086", "86"):
if digits.startswith(prefix):
digits = digits[len(prefix):]
return digits
def scan():
# 遍历目录,收集每个号码出现的次数与位置
hits = {}
for d in SCAN_DIRS:
for p in pathlib.Path(d).rglob("*"):
# 只处理文本文件,跳过图片和二进制
if not p.is_file() or p.suffix not in {".html", ".tsx", ".jsx", ".js", ".json", ".md"}:
continue
text = p.read_text(encoding="utf-8", errors="ignore")
for m in PHONE_RE.findall(text):
# 记录归一化后的号码出现在哪些文件里
hits.setdefault(normalize(m[0]), set()).add(str(p))
return hits
official = load_official_numbers()
for number, files in scan().items():
# 不在官方集合里的号码,就是需要清理的遗留数据
if number and number not in official:
print("疑似遗留号码:", number)
for f in sorted(files)[:5]:
print(" 出现在:", f)
跑一遍大概十几秒,输出里每个号后面跟着它出现的文件路径。我们第一次跑的时候,扫出 23 个号码,其中 11 个是停用的老号,散落在 6 个页脚模板里。清掉这 11 个,才是真正的收尾。
不想写脚本的话,命令行也能粗筛:
# 环境:Git Bash / macOS / Linux,ripgrep 14.1
# 目的:把源码与构建产物里出现过的电话号码全捞出来做频次统计
# 说明:-o 只输出命中片段,--no-filename 去掉文件名便于聚合
# 管道第二段:sed 去掉分隔符,让同一号码的不同写法归一
# 管道第三段:uniq -c 统计出现次数,再按次数倒序排列
# 扫描目录按需增删,静态导出站点只看 out/ 或 dist/ 即可
rg -o --no-filename '\+?86[- ]?[0-9]{3,4}[- ]?[0-9]{7,8}' templates/ public/ .next/server/ \
| sed 's/[-+ ]//g' \
| sort | uniq -c | sort -nr
频次排在最前面却不在配置表里的那个,基本就是出事的号。
官方校验工具怎么跑
改完一定要过一遍校验,别凭感觉。两个工具配合着用:
第一个是 Schema.org 官方的标记校验器,地址是 https://validator.schema.org/ 。它只管语法和类型,不管 Google 认不认。把页面 URL 或 JSON-LD 原文贴进去,重点看 ContactPoint 节点有没有被识别成独立类型,以及 contactOption 有没有报「不在枚举范围内」。
第二个是 Google 的富媒体结果测试,地址是 https://search.google.com/test/rich-results 。它管的是 Google 侧的展示资格。本地商家类目下,它主要看 name、address、telephone、openingHoursSpecification 这些,对 contactPoint 的 contactType 不做强校验——所以这边不报错,不代表 AI 那边能答对,两个工具看到的东西不一样。
跑的时候注意两点:一是用线上 URL 而不是本地地址,二是改完之后等抓取周期,别改完五分钟就去问 AI 助手,那时候索引里还是旧的。我们那次是改完第 3 天再抽查的。
改造前后,差异在哪
第 12 周做的改造,第 15 周回头抽了一次样,用的是 20 组固定提问,比如「城南店客服电话」「空调坏了打哪个号」「你们加盟找谁」。结果如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 电话号码来源 | 页脚硬编码,含 2019 年停用的 400 号 | 单一配置字段,与联系页同一变量 |
| contactType 标注 | 无 | 客服、技术支持、销售三条 |
| 20 组提问命中率 | 6 组给出停用号 | 20 组全部给出在用号 |
| 号码格式 | 4001234567(纯数字,无国家码) | +86-400-123-4567 |
| 夜间提问(22 点后) | 报坐席固话,无人接听 | 报 24 小时值班号 |
| 全站遗留号码数量 | 11 个停用号散落 6 个模板 | 0 |
变化最明显的是夜间那个场景。改造前用户在晚上十一点问,AI 照样把白天的坐席固话报出去;加上 hoursAvailable 之后,引擎会跳过已下班的号,改推覆盖全时段的那条。这一条是纯粹的字段功夫,没动任何页面文案。
容易想歪的几个点
误区一:以为写了 telephone 就够了。telephone 只是个字符串,AI 搜索语境下它承载不了「这是客服还是销售」的信息,contactType 才承载。
误区二:以为校验器全绿就等于 AI 会答对。校验器验证的是语法合规,不验证语义合理。一个语法完美但写着停用号码的 JSON-LD,照样全绿。
误区三:以为改一处就行。号码在模板、联系页、地图平台、点评站上各有一份,改了 JSON-LD 不改页脚,过几天又被覆盖回去。做 GEO 的时候,号码一致性比字段写法更基础。
往后看,本地生活服务这类站点在 AI 搜索里的竞争,会越来越集中在「实体信息的准确度」而不是「关键词密度」。门店号、营业时间、服务范围这些字段,谁的数据干净谁就容易被引用。把 ContactPoint 写规范,成本就是几个字段的事,收益是用户打过来的电话能接通。
你们站点上有没有类似的遗留号码问题,评论区聊聊排查思路。
参考与延伸
- ContactPoint 官方类型定义:https://schema.org/ContactPoint
- LocalBusiness 官方类型定义:https://schema.org/LocalBusiness
- Schema.org 标记校验器:https://validator.schema.org/
- Google 本地商家结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/local-business
关键词:GEO、AI优化AIO、AI 推荐门店、LocalBusiness、ContactPoint、Schema.org、结构化数据、本地生活服务