AI 搜索优化漏了服务渠道:本地商家 availableChannel 与 ServiceChannel 写法解读
适用读者:给本地服务类站点(到店预约、上门服务、线上咨询)做结构化数据的后端与前端工程师,以及负责 GEO 落地的技术运营。
前几天帮一个做上门开锁的客户排查问题,场景挺典型。用某个带联网检索的助手问「附近这家开锁店怎么联系、能不能上门」,助手答得挺顺:营业时间、大概位置、服务范围都有,偏偏没给电话,也没说能不能在线预约。翻他们页面源码,<head> 里老老实实塞了 LocalBusiness,telephone、address、openingHours 一个不缺,缺的是另一层东西——Service 和它下面的 availableChannel。
这个缺口不是个例。做本地服务站的团队,十个里有七八个只标了「店是谁」,没标「这家店提供什么服务、这个服务怎么约」。前者是 LocalBusiness,后者是 Service 加 ServiceChannel。传统 SEO 时代这个缺口不太疼,用户点进页面自己看得见预约按钮;到了生成式引擎优化(Generative Engine Optimization, GEO)时代,AI 是照着实体属性拼答案的,属性没写,答案里那一格就空着,用户也就看不到。
被漏掉的那一层:Service 和它的渠道
先把概念摆正。LocalBusiness 描述的是一个「地点/商户」,Service 描述的是「一项可以被人获取的服务」,两者是并列又互补的实体,不是二选一的关系。

Service 上挂了一堆属性,其中对「怎么约、怎么联系」这类问题最关键的,是 availableChannel。它的值不是一段文本,而是一个 ServiceChannel(服务渠道)对象——也就是「这条服务可以通过哪些渠道被触达」。
ServiceChannel 自己又带 serviceUrl、servicePhone、availableLanguage、processingTime、serviceLocation 这些属性。说白了,availableChannel 是挂载点,ServiceChannel 是渠道卡片本体,两者是引用关系:Service 通过 availableChannel 指向一个或多个 ServiceChannel 节点。
一段服务页的结构化数据,理想情况长这样:LocalBusiness 负责说明商家身份,Service 负责说明服务内容,ServiceChannel 负责说明触达方式,再用 provider、areaServed、serviceType 把三者串成一张图。AI 引擎抓到的不是一段文字,而是一张有边的图,回答问题时顺着边把槽位填上。
判断标准很粗暴:把页面上的按钮、电话、表单全隐藏,只把结构化数据丢给一个只会读 JSON 的程序,它能不能说出「这家提供什么服务、约的方式是这个电话或这个链接」。答不上来,就说明渠道层没标。
底层机制:AI 引擎怎么从 Service 实体里抠出「服务渠道」
这一节讲机制,理解了它才知道字段为什么这么设计。搜索引擎和 AI 引擎处理结构化数据,底层走的是「实体—属性—值」三元组那一套,JSON-LD、RDFa 只是它的载体。抓取之后引擎会做实体对齐:把页面上的 LocalBusiness、Service、ServiceChannel 落成图数据库里的节点和边,同一个 @id 的节点会被合并成一条。
用户抛出一个意图类问题时,比如「这家店怎么预约」,引擎会把问题拆成「主体 + 槽位」两部分。主体是那家店,槽位是「预约渠道 / 联系方式」这类语义位置。拆完就进入槽位填充环节。
填充动作是这样走的:引擎在实体图上先定位主体对应的 Service 节点,再看它有没有指向 ServiceChannel 的 availableChannel 边。有边,就沿着边走,读 serviceUrl、servicePhone 把值填回槽位;没有边,这个槽位就是空的,生成答案时要么跳过,要么含糊地说一句「建议到店咨询」。用户看到的就是「这店在哪、几点开门」,但没有「怎么约」。
这里有个反直觉的点:servicePhone 和 telephone 不是一回事。telephone 挂在商户节点上,语义是「这个商户的电话」;servicePhone 挂在服务渠道节点上,语义是「这条服务的预约/咨询电话」,而且它的值是个 ContactPoint(联系方式点)结构,可以带 contactType、availableLanguage。多店多服务的情形下,总机号码和某个具体服务的预约号码经常不一样,AI 一旦读错就把用户引到了错误的号码。把渠道电话显式写出来,等于替引擎做了消歧。
processingTime 常被小看。它描述的是「从这个渠道发起请求到被处理,大概要多久」,用 ISO 8601 时长格式写,比如 PT30M 表示三十分钟。写清楚能帮引擎判断这个渠道适不适合即时咨询。serviceUrl 则决定引擎能不能给出一条可点击的预约入口——很多 AI 答案是纯文本,没有链接,用户还得自己回站里找,体验就断在最后一公里。把 serviceUrl 给全,等于给答案补上落点。
引擎不做阅读理解,它做的是槽位填充。你缺哪个槽位,答案里就少哪一块。
三个实体怎么串成一张图
建模时把关系先画出来,字段就不容易漏。下面这张关系图是本地服务场景的常规骨架,可以照着它的边逐个检查自己的数据。
graph LR
LB[LocalBusiness 商户] -->|provider| S[Service 服务]
S -->|availableChannel| C1[ServiceChannel 电话渠道]
S -->|availableChannel| C2[ServiceChannel 表单渠道]
C1 -->|servicePhone| P[ContactPoint 联系方式点]
C2 -->|serviceUrl| U[预约落地页]
S -->|areaServed| A[City 服务范围]
S -->|serviceType| T[服务类型文本]
图里有两条边最容易被省:availableChannel 和 provider。省掉 provider,引擎连不上「服务属于谁」;省掉 availableChannel,渠道信息整个缺失。这两条边是本地服务站点在 AI 问答里「被引用」的地基。
字段怎么填:一张对照表
各个属性挂在哪一层、值该长什么样,经常被写串。下面按实体分组列一遍,值是工程口径的示例。
| 属性 | 所属实体 | 值类型 | 是否关键 | 填法要点 |
|---|---|---|---|---|
| provider | Service | 引用 | 高 | 指向商户的 @id,别内联整段商户对象 |
| availableChannel | Service | 数组 | 高 | 一个服务挂多条渠道,电话与表单各一条 |
| areaServed | Service | City/Text | 中 | 划服务地理范围,回答覆盖判定 |
| serviceType | Service | 文本 | 中 | 用业务方看得懂的服务名,别写内部编码 |
| serviceUrl | ServiceChannel | URL | 高 | 可点击的预约/咨询落地页,尽量带参数区分渠道 |
| servicePhone | ServiceChannel | ContactPoint | 高 | 预约专线,不是商户总机 |
| availableLanguage | ServiceChannel | 数组 | 中 | 该渠道支持的语言,多语言站点要给全 |
| processingTime | ServiceChannel | ISO 8601 | 低 | 从发起到被处理的时长,如 PT30M |
| contactType | ContactPoint | 文本 | 中 | 写「预约」「咨询」等业务词 |
| telephone | ContactPoint | 文本 | 高 | 带国际区号,避免纯本地号码 |
表里标「高」的几个属性,缺一个 AI 回答里就少一块。标「中」的影响覆盖面判断和语言匹配,标「低」的在多数场景里可以后补。
完整 JSON-LD 示例(服务页)
下面这段是电话预约加在线表单双渠道的完整示例。注意以 // 开头的行是讲解注释,JSON-LD 规范本身不认注释,落到线上前要删掉;文末的 Python 生成器输出的就是不带注释的纯 JSON。
// ===== 服务页 JSON-LD:电话预约 + 在线表单双渠道 =====
// 下面以 // 开头的行是讲解注释,上线前必须删除
// 顶层用 @graph 把商户与服务放进同一张图,靠 @id 互相引用
{
// @context 声明用 schema.org 词表,引擎靠它解析属性语义
"@context": "https://schema.org",
"@graph": [
{
"@type": "LocalBusiness",
// @id 全站要稳定,跨页面才能对齐成同一个商户节点
"@id": "https://example.com/#business",
// name 用对外展示的商户名,跟工商全称可以不同
"name": "示例开锁服务部",
// telephone 是商户总机,跟下面的服务专线不是一回事
"telephone": "+86-000-0000000",
// address 用 PostalAddress,省市必填,街道可省
"address": {
"@type": "PostalAddress",
"addressLocality": "某市",
"addressRegion": "某省",
// addressCountry 用两位国家码,中国写 CN
"addressCountry": "CN"
}
},
{
"@type": "Service",
"@id": "https://example.com/service/lock#service",
// serviceType 写业务方能读懂的服务名
"serviceType": "门锁开锁与上门换锁",
// provider 指回商户,形成 服务 -> 商户 的边
"provider": { "@id": "https://example.com/#business" },
// areaServed 划服务地理范围,回答覆盖判定靠它
"areaServed": { "@type": "City", "name": "某市" },
// availableChannel 是数组,一个服务挂多条渠道
"availableChannel": [
{
"@type": "ServiceChannel",
// 电话渠道:servicePhone 用 ContactPoint 结构
"servicePhone": {
"@type": "ContactPoint",
"telephone": "+86-000-0000001",
// contactType 用业务语义词,别写内部枚举
"contactType": "预约",
// availableLanguage 声明这条渠道支持的语言
"availableLanguage": ["zh-CN"]
},
// processingTime 用 ISO 8601 时长,PT30M 表示 30 分钟
"processingTime": "PT30M"
},
{
"@type": "ServiceChannel",
// 表单渠道:serviceUrl 是要给用户点的预约落地页
"serviceUrl": "https://example.com/booking/new",
// 表单比电话快,处理时长写短一点
"processingTime": "PT5M",
"availableLanguage": ["zh-CN", "en"]
}
]
}
]
}
这份数据的三个要点:Service 与 LocalBusiness 用 @id 互指而不内联,渠道用数组而不是单对象,电话挂在渠道的 servicePhone 上而不是商户的 telephone 上。改完这三处,问答里「怎么约」这一格才填得上。
用 Python 批量产出结构化数据
手写 JSON-LD 在单页站点还行,多门店多服务就得程序生成。下面这个脚本用标准库就能跑,输入商户与服务参数,输出合规的 JSON-LD。
# -*- coding: utf-8 -*-
# 依赖:Python 3.10+,只用标准库 json,不需要三方包
# 作用:把 商户 + 服务 + 渠道 三元组渲染成 JSON-LD
import json
# 电话渠道:一个服务可以挂多条渠道,这里先封装电话预约
def phone_channel(tel: str, lang: str = "zh-CN") -> dict:
# servicePhone 用 ContactPoint 结构,contactType 写「预约」
return {
"@type": "ServiceChannel",
"servicePhone": {
"@type": "ContactPoint",
"telephone": tel,
"contactType": "预约",
# availableLanguage 声明语言,多语言站点传数组
"availableLanguage": [lang],
},
# processingTime 用 ISO 8601 时长,PT30M 表示 30 分钟
"processingTime": "PT30M",
}
# 线上表单渠道:核心是给出可点击的 serviceUrl
def web_channel(url: str) -> dict:
# serviceUrl 决定答案里能不能带一条可点入口
return {
"@type": "ServiceChannel",
"serviceUrl": url,
# 表单提交一般比电话快,处理时长写短一点
"processingTime": "PT5M",
}
# 组装 Service 实体:provider 指商户,areaServed 划范围
def build_service(biz_id: str, channels: list) -> dict:
# serviceType 用业务方一眼能懂的说法
return {
"@type": "Service",
"serviceType": "门锁开锁与上门换锁",
# provider 只放引用,别内联整段商户对象
"provider": {"@id": biz_id},
# areaServed 划服务范围,决定覆盖判定
"areaServed": {"@type": "City", "name": "某市"},
# 把渠道数组挂到 availableChannel
"availableChannel": channels,
}
# 入口:命令行传入电话与表单地址,输出纯 JSON(无注释)
if __name__ == "__main__":
biz = "https://example.com/#business"
chans = [phone_channel("+86-000-0000001"), web_channel("https://example.com/booking/new")]
doc = {
"@context": "https://schema.org",
"@graph": [
{"@type": "LocalBusiness", "@id": biz, "name": "示例开锁服务部"},
build_service(biz, chans),
],
}
# ensure_ascii=False 保证中文按原文输出,便于人工核对
print(json.dumps(doc, ensure_ascii=False, indent=2))
批量场景下把 biz 和 chans 换成配置表里的一行,循环一遍就能出一批服务页的 JSON-LD,人工只需抽查几个。
.NET 8 里怎么拼 JSON-LD
后端是 ASP.NET Core 的话,可以用最小 API 直接吐 application/ld+json。下面这段依赖 .NET 8 与内置的 System.Text.Json,不引三方库。
// 依赖:.NET 8 / ASP.NET Core 8,System.Text.Json 为框架内置
// 思路:用最小 API 端点把 JSON-LD 拼好,按 application/ld+json 返回
using System.Text.Json.Nodes;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// 映射「服务详情」页对应的结构化数据端点
app.MapGet("/service/lock/jsonld", (IConfiguration cfg) =>
{
// 从配置读商户 id 与专线电话,避免把号码硬编码进代码
var bizId = cfg["Seo:BusinessId"]!;
var booking = cfg["Seo:BookingHotline"]!;
// 电话渠道:servicePhone 挂在 ServiceChannel 上,不是商户上
var phoneChannel = new JsonObject
{
["@type"] = "ServiceChannel",
// ContactPoint 的 contactType 用业务语义词,不要写内部枚举
["servicePhone"] = new JsonObject
{
["@type"] = "ContactPoint",
["telephone"] = booking,
["contactType"] = "预约",
// 多语言站点这里给数组
["availableLanguage"] = new JsonArray("zh-CN")
},
// 时长用 ISO 8601,PT30M 表示 30 分钟
["processingTime"] = "PT30M"
};
// 表单渠道:serviceUrl 是要给用户点的预约落地页
var webChannel = new JsonObject
{
["@type"] = "ServiceChannel",
["serviceUrl"] = "https://example.com/booking/new",
// 表单没有语言限制之外的额外字段,时长同样用 ISO 8601
["processingTime"] = "PT5M"
};
// Service 实体:provider 指商户,areaServed 划服务范围
var service = new JsonObject
{
["@type"] = "Service",
["serviceType"] = "门锁开锁与上门换锁",
["provider"] = new JsonObject { ["@id"] = bizId },
["areaServed"] = new JsonObject { ["@type"] = "City", ["name"] = "某市" },
// 渠道数组挂到 availableChannel
["availableChannel"] = new JsonArray(phoneChannel, webChannel)
};
// 顶层用 @graph 把商户与服务放进同一张图
var doc = new JsonObject
{
["@context"] = "https://schema.org",
["@graph"] = new JsonArray(
new JsonObject { ["@type"] = "LocalBusiness", ["@id"] = bizId, ["name"] = "示例开锁服务部" },
service)
};
// 返回 application/ld+json,便于爬虫直接识别
return Results.Text(doc.ToJsonString(), "application/ld+json");
});
app.Run();
号码、商户 id 这些从配置读,别写死在代码里。多门店上线时按配置逐店生成,改一处配置就能全量重发。
提交前的结构自检
字段拼完先自检一遍再交,能省掉大量返工。下面这组命令依赖 curl、python3、jq,用来自查线上返回的 JSON-LD 是否合法、渠道是否真的存在。
# 依赖:curl / python3 / jq
# 拉取服务页的结构化数据端点
curl -sS "https://example.com/service/lock/jsonld" -o /tmp/ld.json
# 先确认是合法 JSON(若含 // 注释行,需在此步之前删掉)
python3 -c "import json; json.load(open('/tmp/ld.json')); print('JSON OK')"
# 用 jq 按图遍历,确认 availableChannel 存在且非空
jq -e '[.["@graph"][] | select(.["@type"]=="Service") | .availableChannel | length] | add > 0' /tmp/ld.json
# 再确认每个渠道至少带 serviceUrl 或 servicePhone 之一
jq -e '[.["@graph"][] | select(.["@type"]=="Service") | .availableChannel[] | select(.serviceUrl // .servicePhone)] | length' /tmp/ld.json
jq -e 在条件为假时返回非零退出码,接进 CI 就能当门禁用:渠道缺失直接让构建失败,比上线后靠人工发现靠谱。
改造前后:AI 回答对照
回到开头那个客户。给他们补上 Service 与 availableChannel 前后,同一批问题在助手里的回答差别挺明显。
| 用户问题 | 改造前 AI 回答 | 改造后 AI 回答 | 生效的属性 |
|---|---|---|---|
| 这家怎么预约 | 只给了营业时间,让自行到店咨询 | 给出预约专线与在线预约入口 | availableChannel、servicePhone、serviceUrl |
| 能不能上门 | 说不确定,建议电话确认 | 说明支持上门并给出服务范围 | serviceType、areaServed |
| 留个电话 | 拿总机号码,常转到非预约部门 | 直接给预约专线号码 | servicePhone.telephone |
| 支持哪些语言 | 无信息 | 说明该渠道支持中文与英文 | availableLanguage |
| 多久能回 | 无信息 | 说明电话约 30 分钟、在线约 5 分钟 | processingTime |
数据是客户站点自测口径,问题集固定了二十条,只统计「回答里是否出现可执行的预约方式」这一项。补渠道属性之前,命中大概三成;补齐之后涨到八成上下。数字不算惊艳,但足以说明槽位缺失的影响。
改造前后的流程差异,用一张时序图看得更清楚。
sequenceDiagram
participant U as 用户
participant AI as AI 引擎
participant D as 站点结构化数据
U->>AI: 这家怎么预约
AI->>D: 读取 Service 实体
D-->>AI: 只有 LocalBusiness 信息
AI-->>U: 给了地址和营业时间,没给预约方式
Note over D: 补上 availableChannel 之后
AI->>D: 读取 ServiceChannel
D-->>AI: servicePhone 与 serviceUrl
AI-->>U: 给出预约专线号码和在线预约入口
校验与踩坑
字段拼对了不代表引擎就认。踩过的坑大致这么几类,列出来省得你重蹈。
坑一:@id 不稳定
同一家店在服务页写 #business、在门店页写 #shop,两个 @id 指向同一主体,引擎却当成两个节点,图直接散了。全站商户 @id 用同一套规则生成,别手写。
坑二:把渠道电话写成总机
servicePhone 填了前台总机,用户打过去被转三四个部门。渠道电话就填该服务的专线,多服务多专线。
坑三:serviceUrl 指向首页
serviceUrl 给首页,等于没给。要给到能直接提交预约的那一页,最好带上渠道参数,方便后端区分来源。
坑四:注释行忘了删
上面示例里的 // 行是给人看的,直接上线会让 JSON 解析失败。生成脚本输出的走纯 JSON,手写的一定要过一遍解析校验。
抽取链路全景
下面这张流程图把引擎从抓取到出答案的整条链路画了出来,标注了渠道缺失时断在哪一环。
flowchart TD
A[爬虫抓取页面] --> B[解析 JSON-LD 生成三元组]
B --> C[实体对齐 合并同 @id 节点]
C --> D{问题里的目标槽位}
D -->|怎么预约| E[查 Service.availableChannel]
D -->|怎么联系| F[查 ServiceChannel.servicePhone]
E --> G[填充槽位]
F --> G
G --> H[生成自然语言答案]
C -.渠道缺失.-> I[槽位为空 答案里没有预约方式]
和传统 SEO 的关系
有人会问,这套东西跟传统 SEO 是不是重叠。重叠的部分是「页面要被抓到」,不重叠的部分是「抓到之后能不能被引用」。传统 SEO 关心排名和点击,页面本身是终点;GEO 关心的是页面能不能被拆成实体、填进答案,页面反而成了语料。
所以 LocalBusiness、Service、ServiceChannel 这些结构化数据,在传统 SEO 里是锦上添花,在 GEO 里是入场券。排名再高,属性缺失,AI 也拼不出「怎么预约」这句回答。两者不冲突,只是重心不一样:一个把人引到页面,一个让内容被直接引用。
误区澄清
几个挺常见的误解,顺手澄清一下。
误解一:标了 LocalBusiness 就够了。 商户实体只说「这家店存在」,不说「怎么触达它的服务」。渠道信息在 Service 那一层,不在商户层。
误解二:渠道信息写在正文里,AI 能读到。 能读到,但读到的是一段非结构化文本,需要额外抽取,抽取有损耗和不确定性。写成 ServiceChannel 是把它变成引擎可直接取用的结构化槽位,命中更稳。
误解三:字段越多越好。 不是。属性之间自相矛盾比缺失更糟,比如 areaServed 写全市、serviceLocation 又限定在某区,引擎判断覆盖范围时会犹豫。宁可少写,也别写冲突的。
误解四:改一次就一劳永逸。 引擎的抽取策略在变,ServiceChannel 这类相对冷门的类型,各家支持度也不一样。定期用真实问题回归一遍,看哪些槽位又被漏掉了。
往后看,AI 搜索对「可执行信息」的胃口只会更大。用户不再满足于知道「有这家店」,还要知道「怎么立刻联系上」。把服务渠道做成结构化实体,是本地商家在 AI 回答里被准确引用的低成本动作,值得尽早补上。
参考与延伸
- schema.org 的 ServiceChannel 类型定义:https://schema.org/ServiceChannel
- schema.org 的 availableChannel 属性说明:https://schema.org/availableChannel
- schema.org 的 Service 类型定义:https://schema.org/Service
- Google Search Central 本地商家结构化数据指南:https://developers.google.com/search/docs/appearance/structured-data/local-business
AI 搜索优化、GEO、AI优化AIO、本地商家被 AI 推荐、ServiceChannel、availableChannel、Schema.org、结构化数据