1. 为什么没有好的 tools 和元数据,仅靠指令是不够的
需要先承认一个不太令人愉快的事实:模型看不到你的代码。它不知道你的 Next.js 里有哪些控制器、TypeScript 里有哪些函数,也不知道你在推荐服务里堆叠了哪些神奇的启发式。
它通过以下几个接口“看见”你的 App:
- System‑prompt(角色契约)。
- 工具描述:名称、description、inputSchema、outputSchema、注解等。
- 应用本身的元数据:名称、图标、短/长描述、类别、conversation starters 等等。
在处理请求时,模型会查看对话上下文和这些元数据,以决定:
- 是否应该推荐某个 App;
- 如果应该——在可用的 App 中该选哪一个;
- 如果已选定 App——该 App 的哪个工具最适合当前请求。
在模块 5 的前一部分里,我们处理的是可以“用语言告诉”模型的内容——system‑prompt 与 UX 指令。现在转向它除了文本之外还能看到的东西:tools 与元数据。
所以模块 5 的任务其实是双重的。你先在 system‑prompt 中表述“这个 App 应该做什么、如何表现”,然后通过 tools 与元数据的设计,把这些要求包装成模型能够真正使用的形式——包括用于 discovery 与路由。
可以这样给自己一个比喻:system‑prompt 是宪法,而 tools 与元数据就是法律与周边的行政体系:表单、数据库模式等等。只停留在宪法层面,是走不远的。
2. 分解:“一项任务—一个 tool”,但要理性
先谈最让人头疼的问题:到底要做多少个工具,如何划分。
直觉上的原则:一个工具——一个清晰的任务。这会大幅降低模型的选择难度:它面对的不是一个巨无霸函数 do_everything,而是若干个命名良好的小动作。
针对 GiftGenius,我们可以有如下基础工具:
- profile_to_segments——把收礼人的自由描述(年龄、兴趣、关系、上下文)转成规范化的分段,比如 "tech"、"fitness"、"gamer"。
- recommend_gifts——基于分段、预算、地区/语言/本地化和场合推荐礼物 id 列表。
- get_gift——根据 id 获取选定礼物的完整卡片(描述、媒体、SKU/变体)。
- (可选)similar_gifts——基于某个已选礼物,再给出 3–5 个相似选项。
理论上可以合成一个 gift_tool,用 mode: "profile_to_segments" | "recommend" | "details" | "similar" 来切换,但这会给你和模型都徒增复杂度:描述会拉成长篇大论,inputSchema 膨胀,而在选择工具时模型缺少清晰锚点。
反模式:God Tool
想象这样的方案:
server.registerTool(
"gift_tool",
{
description: "与礼物相关的多种操作。",
inputSchema: { /* 50 个字段和标志 */ },
},
async ({ input }) => { /* 根据 mode 的超大 switch */ }
);
在模型“脑海里”,这就像是“有个抽象的礼物工具,至于怎么用再说”。这会降低选择的准确度,妨碍 discovery,也让你的维护更困难。
但走向另一个极端——为每个小动作都做一个微型工具,堆到 50 个——也不好。每增加一个工具,它就会进入上下文,占用模型注意力,提高路由出错的风险。文档明确提醒:太多细碎工具会降低质量,尤其在描述互相重叠时。
一个实用的规则:
- 凡是用户在流程里感知为一个“步骤”的(例如基于画像的首次礼物推荐),都适合做成单独的 tool;
- 凡是严格在这个步骤内部完成、没有独立意义的(例如计算评分或记录卡片浏览日志),最好留在工具实现内部。
假设你遵循该原则,把场景切分到了 2–4 个工具。接下来一个关键问题是——如何描述这些 tools 的输入,让模型无需猜测就能使用。我们从这里开始。
3. 将 use‑cases 投影到 Input Schema
现在拿一个具体的 use case,老老实实看看这个工具实际上需要哪些数据。
场景:送礼人赶在最后期限内,需要为 25 岁、喜欢足球和桌游的朋友挑 5–7 个主意,预算不超过 $50。
从 jobs‑to‑be‑done 的角度,GiftGenius 的推荐内核任务是把选择范围缩小到一个短列表,并降低“选错礼物”的焦虑。在聊天层面,助手需要:
- 收礼人的基本信息(年龄、性别、与送礼人的关系);
- 兴趣/爱好;
- 预算与货币;
- 场合(生日、周年、新年等);
- 可选——国家/城市,用于配送筛选。
在 GiftGenius 的架构中,这被拆成两个步骤:
- profile_to_segments(input) 接受“原始”数据(年龄、兴趣、文本描述),并把它们转换为规范化的分段,便于后续处理。
- recommend_gifts(segments, budget, locale, occasion) 根据分段与预算,从目录中挑选具体的礼物 id。
从 ChatGPT ↔ MCP 的契约角度,我们需要重点描述第二步——也就是 recommend_gifts 的 schema,因为这个工具会在大多数选礼场景中被调用。
同时不必一开始就向用户索要全部信息:模型可以通过追问补齐(“大概预算是多少?”)。因此画像中的部分字段可以是可选的;但等到要调用 recommend_gifts 时,就应该有规范化的参数集了。
示例:TypeScript + JSON Schema(针对 recommend_gifts)
在 TypeScript 的 MCP 服务器中,可以这样写:
// apps/mcp/server.ts
import { McpServer } from "@openai/mcp-server";
const server = new McpServer();
server.registerTool(
"recommend_gifts",
{
title: "礼物推荐",
description:
"当需要基于收礼人分段、预算、地区/语言/本地化和场合来推荐礼物时,使用此工具。",
inputSchema: {
type: "object",
properties: {
segments: {
type: "array",
description:
"收礼人的分段列表,例如 ['tech', 'football_fan']。通常来自 profile_to_segments。",
items: { type: "string" },
minItems: 1
},
budget: {
type: "object",
description:
"用户货币下的礼物预算区间(最小/最大)。",
properties: {
min: {
type: "number",
minimum: 0,
description: "用户愿意支付的最低金额。"
},
max: {
type: "number",
minimum: 0,
description: "用户愿意支付的最高金额。"
},
currency: {
type: "string",
minLength: 3,
maxLength: 3,
description: "三字母货币代码(例如,USD、EUR、RUB)。"
}
},
required: ["min", "max", "currency"]
},
locale: {
type: "string",
description:
"BCP‑47 格式的用户本地化(例如 'ru-RU' 或 'en-US')。"
},
occasion: {
type: "string",
description:
"送礼场合,例如 'birthday'、'new_year'、'anniversary'。"
}
},
required: ["segments", "budget", "locale", "occasion"]
}
},
async ({ input }) => {
// 这里先不做复杂逻辑,返回一个占位结果
return {
content: [
{
type: "text",
text: `正在根据分段 ${input.segments?.join(
", "
)} 在预算 ${input.budget?.min}–${input.budget?.max} ${input.budget?.currency} 内挑选礼物...`
}
],
structuredContent: {}
};
}
);
注意以下几点。
第一,我们积极使用类似 enum 的约束与清晰的描述。即使形式上只是字符串,description 也会向模型暗示期望的取值,这显著提升它正确填写参数的概率。与其放一个模糊的 "场合": "类似生日的东西",不如给出干净的 occasion: "birthday"。
第二,字段描述不是写给“团队的人”看的,而应当直给模型:这个字段是什么、典型取值是什么、能否给个示例。Apps SDK 文档的作者明确建议为每个参数提供清晰的人类可读描述与示例。
输入 schema 中不该出现的内容
常见的“寄生字段”,很多人会忍不住塞进去:
- 内部标识(tenantId、internalSegment),这些完全可以在服务器端自行填充;
- 模型不可能知道的东西(例如 deploymentRegion)——这是你的职责范围;
- 重复对话历史的字段(例如 userPrompt):模型已经能看到原始消息,不要强迫它复制粘贴。
Input Schema 只应包含模型需要决定并填写的内容,而不是一个“装一切”的大口袋。
4. Output Schema:不仅是数据,也是语义
在 Apps SDK 中,工具的结果会以 role: tool 的消息形式回到对话里。之后如何处理交给模型:如何组织答案、要不要追问、是否打开小部件等。因此,输出 schema 的设计和输入一样重要。
有两种做法。
“原始数据”变体如下:
{
"items": [
{ "id": "GIFT_1" },
{ "id": "GIFT_2" }
]
}
模型只能看到一串 id,不知道这些选项为何出现、总共多少候选、哪些是更好的。它可能会“编造”点什么,但怪异的概率更高。
“语义丰富”的变体:
{
"items": [
{
"id": "GIFT_1",
"score": 0.92,
"reason": "与 'football_fan' 分段高度匹配,且在预算内。"
},
{
"id": "GIFT_2",
"score": 0.81,
"reason": "适合桌游爱好者,价格略接近预算上限。"
}
],
"meta": {
"totalCandidates": 27,
"returned": 5,
"segmentsUsed": ["football_fan", "board_games"],
"budget": { "min": 20, "max": 50, "currency": "USD" },
"advice": "建议先从 score 较高且理由清晰的选项开始。"
}
}
这样模型就能如实解释为什么是这些礼物,并据此组织追问:“我找到了 27 个候选,展示其中 5 个最佳。这是它们入选的原因。”
示例:为 recommend_gifts 描述 Output Schema
把结果 schema 加进工具描述中(即使技术上不是必填项,也最好写上——它是和模型的契约的一部分):
const recommendGiftsOutputSchema = {
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", description: "目录中的礼物 ID。" },
score: {
type: "number",
description: "与画像的匹配度评分(0..1)。"
},
reason: {
type: "string",
description:
"简短解释为什么该礼物合适(可由后端生成)。"
}
},
required: ["id", "score"]
},
description: "带有匹配度评分的推荐礼物列表。"
},
meta: {
type: "object",
properties: {
totalCandidates: {
type: "integer",
description: "目录中找到的候选总数。"
},
returned: {
type: "integer",
description: "本次调用返回了多少个礼物。"
},
advice: {
type: "string",
description:
"总体建议:例如建议从哪类礼物开始。"
}
}
}
},
required: ["items"]
};
并在实现中使用该 schema:
server.registerTool(
"recommend_gifts",
{
title: "礼物推荐",
description:
"当需要基于分段与预算挑选 3–7 个礼物时使用。返回礼物 id 和匹配度评分;若需查看详情请调用 get_gift。",
inputSchema: /* 如上 */,
// 并非总是要求显式声明 outputSchema,但作为文档非常有用:
// outputSchema: recommendGiftsOutputSchema
},
async ({ input }) => {
const recommendations = await recommendFromCatalog(input); // 我们的业务逻辑
return {
content: [
{
type: "text",
text: `我找到了 ${recommendations.items.length} 个合适的主意。现在展示最佳选项。`
}
],
structuredContent: {
items: recommendations.items,
meta: {
totalCandidates: recommendations.meta.totalCandidates,
returned: recommendations.items.length,
advice: recommendations.meta.advice
}
}
};
}
);
我们做了两件事:给模型提供最小化的用户可见文本,同时附上语义化的 JSON,便于模型继续推进对话与追问。
与此同时,get_gift 会基于 id 拉取完整卡片(名称、媒体、SKU 等),GiftGenius 的小部件将其渲染成礼物卡片。
5. 工具的命名与描述:discovery 的基础
接下来是最“香”的部分:工具的名称与描述如何影响模型是否会调用它们。
元数据的文档与最佳实践建议:
- 使用以行动为导向的命名:profile_to_segments、recommend_gifts、get_gift、similar_gifts,而不是 tool1、search、do_stuff;
- 以 “Use this when…”/“当……时使用该工具” 的句式开头,描述触发场景与限制(“不要用于……”)。
这和你的 golden prompt set 直接相关。描述中的措辞应与真实的用户请求有交集。如果在描述中写着“当用户按预算与收礼人兴趣请求推荐礼物时使用”,而在 golden prompt 里有“给爱打游戏的朋友推荐不超过 $50 的礼物”,模型就更容易把请求和该工具匹配起来。
一个好的工具描述示例
再看一个 GiftGenius 的附加工具——similar_gifts。它在用户选定某个礼物后,基于该礼物扩展出相似的主意:
server.registerTool(
"similar_gifts",
{
title: "相似礼物",
description:
"当用户已选定某个礼物并希望再看一些相似选项时使用此工具。不要用于从零开始的首次挑选——那请使用 recommend_gifts。",
inputSchema: {
type: "object",
properties: {
giftId: {
type: "string",
description:
"来自上一次候选集的礼物标识,需要基于它查找相似选项。"
},
limit: {
type: "integer",
description:
"返回多少个相似礼物(默认 3–5)。",
minimum: 1,
default: 5
}
},
required: ["giftId"]
}
},
async () => {
/* ... */
}
);
要点:
- 明确指出何时应使用该工具、何时不应使用。
- 描述中包含“相似选项”“已选定礼物”等词,这些词会高频出现在真实用户请求中。
- 避免与 recommend_gifts 的适用范围重叠——这能降低工具之间在选择时的竞争。
一个不好的描述
description: "处理礼物。"
模型几乎无法从这样的描述中获取任何信息。它只有在“走投无路”时才可能“盲抽”这个工具。
6. 注解与 hints:如何提示模型动作的严肃性
工具不仅有名称与 schema,还有注解,用于提示 ChatGPT 该动作的危险性/重要性,以及是否需要征得用户确认。在 Apps SDK 规范中有多种 hint,比如 readOnlyHint、destructiveHint、openWorldHint 等。
- readOnlyHint: true 表示工具只读不改状态。助手可略过多余确认,更自由地调用它。
- destructiveHint: true 表示工具可能删除或不可逆修改内容,需要向用户显示明确的“你确定吗?”。
- openWorldHint: true 表示动作会影响外部世界(发帖到社交媒体、在账号外创建记录等),同样需要提醒。
最低级别——无需确认
如果你有 public readonly tools,就很有必要标注 readOnlyHint: true。例如:
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"openWorldHint": false
}
这样的工具可以在 GPT 侧无需额外对话式确认即可调用。
一次确认
若你的工具会在服务器上产生变更,建议标注为 readOnlyHint: false:
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"openWorldHint": false
}
模型看到这样的工具,通常会向用户请求一次确认(一般是 ChatGPT UI 中的模态对话框)。
危险操作
如果你的工具会在服务器上执行删除等操作,请标注为 destructiveHint: true:
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"openWorldHint": false
}
模型会非常谨慎地调用该工具,并进行两次确认:
- 先在文本中向用户索取确认,
- 然后平台再弹出标准确认对话框。
在本模块中,我们暂不实现电商相关工具,但可以草拟将来的 create_gift_order:
server.registerTool(
"create_gift_order",
{
title: "创建礼物订单",
description:
"只有在用户明确同意购买所选礼物后才使用。该工具会在系统中创建订单并返回状态。",
inputSchema: {
type: "object",
properties: {
giftId: {
type: "string",
description: "用户已选礼物的 ID。"
},
deliveryEmail: {
type: "string",
description: "接收数字礼物的邮箱。"
}
},
required: ["giftId", "deliveryEmail"]
},
annotations: {
destructiveHint: true,
openWorldHint: true
}
},
async () => {
/* ... */
}
);
注解并不能替代你在服务器端的权限校验;它们只是帮助 ChatGPT 设计合适的 UX:询问确认、显示警告,避免“悄悄”执行这类工具。
7. App 元数据与两个层级的 discovery
工具只是故事的一半。另一半是用户如何发现并启动你的 App。
在 ChatGPT 生态里,有两个关键的 discovery 层级。
其一是对话内的 in‑conversation discovery。当用户在聊天里输入内容(即使没有显式提到 App),模型会查看:
- 消息文本与对话历史;
- 可用应用及其工具的描述;
- 品牌提及、主题与关键短语。
据此模型决定是否要推荐某个 App,如果要,推荐哪一个、并以哪个场景切入。在这里,工具与 App 的描述尤为重要。如果其中包含“礼物推荐”“礼物创意”“礼物预算”等“触发词”,模型选择你的 App 的机会会显著提升。
其二是全局 discovery:目录与 launcher。在这一层,起作用的是人:他会根据名称、图标、短描述与标签来选择 App。此时,你需要清晰诚实地说明应用做什么、面向谁、核心价值是什么。
可以总结成一张小表:
| 层 | 模型/用户看到什么 | 元数据中重要的是什么 |
|---|---|---|
| In‑conversation | 对话文本、tools 与 App 的描述 | 触发性表述、以行动为导向的命名、限制条件 |
| 目录/launcher | 名称、图标、short/long description、标签 | 清晰定位、明确的价值主张 |
以 GiftGenius 为例,可以这样表述:
- 名称:GiftGenius — 60 秒内完成礼物推荐。
- 短描述:收集收礼人画像,并给出 5–7 个礼物主意,可在 ChatGPT 内一键购买。
- 对话内描述:当用户请求帮忙选礼物、不知道送什么、提到预算、收礼人兴趣或场合时,请使用此应用。
这些表述最好与 system‑prompt 和 recommend_gifts 的描述保持一致。这样模型看到的就是一幅完整一致的图景,而不是互相矛盾的文本碎片。
8. 路由在 ChatGPT“脑中”是如何工作的
把一切串起来,看看一个典型请求的路径——不深入 MCP 协议,相关内容在后续模块展开。
假设用户写道:
“帮我给弟弟想个礼物,他超爱足球和桌游,预算不超过 50 美元。”
高度简化的流程:
- 模型分析消息与历史,识别出“礼物”“弟弟”“足球”“桌游”“预算 50”等信号。
- 把这些与可用 App 及其工具的描述进行对比。对于 GiftGenius,描述中明确包含“按兴趣与预算推荐礼物”,因此相关性较高。
- 如果该 App 尚未在会话中激活,模型会给出一条“预告”回复:“我可以打开 GiftGenius 应用,按你的参数帮你选礼物。要打开吗?”——这在我们的 UX 指令中已预先编写。
- 在用户同意后,模型会在 App 内选择 recommend_gifts,因为它的名称、description 与 inputSchema 与当前意图最吻合。
- 模型基于请求填写工具参数:必要时先调用 profile_to_segments,把“弟弟、爱足球和桌游”的文本转为分段 ["football_fan", "board_games"];随后用 recommend_gifts 并提供 segments、budget: {min: 0, max: 50, currency: "USD"}、locale、occasion: "birthday"。
- MCP 服务器执行该工具,生成包含 items 与 meta 的结构化输出并返回。
- 模型读取你在 outputSchema 中定义的 JSON,组织回复:说明找到了什么、为什么是这些礼物,并提出追问(“要按类别缩小范围吗?”“要看这个礼物的相似选项吗?”或者“要直接购买这个礼物吗?”)。
下图是该流程的简易流程图:
flowchart TD A[User: 礼物相关的请求] --> B[ChatGPT 分析上下文] B --> C[与 App 与 tools 的元数据进行匹配] C -->|相关| D[GiftGenius 预告] D -->|用户同意| E["调用 recommend_gifts(+ profile_to_segments)"] E --> F[GiftGenius 的 MCP 服务器] F --> G[包含 items/meta 的 JSON 结果] G --> H[模型组织回复并提出 follow-up]
你对工具与 use case 的描述越好,这里的“随机性”就越少,路由就越稳定。
洞见:Tool Call SEO
在 Apps 生态中,你很快将面临的不仅是目录里对用户注意力的竞争,更是对模型注意力的竞争。面对同一条用户请求,ChatGPT 可能会考虑调用十来个应用;选择不会发生在谁的展示页更漂亮,而是在模型“脑中”的“搜索结果”里。这个看不见的层越来越像 SEO,只不过页面换成了 tools 和 MCP 服务器。
本质上,模型会对候选进行排序:先是 App 层,再是单个工具层。它会查看名称、descriptions、schema、注解,并把它们与用户请求的表述进行匹配。如果 recommend_gifts 的描述里有“按预算与收礼人兴趣推荐礼物”,而用户请求写着“给爱玩游戏的朋友推荐不超过 $50 的礼物”,这个工具就更有机会“排进前列”,而不是一个描述为“处理礼物”的抽象 search。
由此产生了一个实践概念:Tool Call SEO——把名称、description、enum 值与元数据当作关键词与摘要来设计。你不是只给开发者写契约,而是在为来自 golden prompt set 的真实请求流做优化。过于笼统的表述、多个工具的适用范围重叠、没有明确“领域”的 God 工具——都会降低你的 App 在模型“脑中”的“点击率”。
9. 一个小练习
不妨在脑中(或你的仓库里)做如下练习。
先选一个 GiftGenius 的关键场景——例如“在预算受限的前提下,为同事挑选礼物”。
为它进行如下设计:
- 针对这个场景需要哪个单独的工具:是纯粹的 recommend_gifts,还是需要一个更偏 B2B 的专用工具,或者说在 recommend_gifts 之后用 similar_gifts 做扩展就足够?
- recommend_gifts 的输入 schema 里,哪些字段是确实必要的。哪些可以通过追问(follow‑up)向用户补齐,而不是强迫模型“猜”。
- outputSchema 应该长什么样,才能让模型既能如实解释选择依据,又能提出下一步建议(例如切到 B2B 模式、只显示数字礼物、按价格区间再收窄)。
然后回看上一讲中你的 golden prompt set,检查:
- 每条基准请求是否都对应一个显而易见的工具(recommend_gifts、get_gift、similar_gifts 等);
- 是否出现两个工具“同样适合”某一请求的情况(overlapping tools);
- 是否需要加强描述或重命名某个 tool,以减少模型的混淆。
这正是你在每次对 prompt、schema 或逻辑做重大调整前要重复的流程——本质上是一次针对 discovery/路由质量的迷你评测(mini‑eval)。
把上述要点化成一个清单,此阶段你需要:
- 把场景诚实地切成 2–4 个有意义的工具;
- 细致描述 inputSchema/outputSchema,辅以示例与 enum;
- 整理命名、description 与注解;
- 与 system‑prompt 与 App 元数据保持同步。
在接下来的模块中,我们会看看这些东西如何通过 MCP 运转,以及如何诊断 discovery/路由的异常行为。
10. 设计 tools 与元数据时的常见错误
错误 #1:“我们都写在 system‑prompt 里了,工具自己会搞定”。
即使你把 App 的角色、职责边界与 UX 行为写得很好,但如果工具仍叫 tool1、search、do_stuff,schema 也没有描述,模型就无法把漂亮的文字与真实调用对应起来。对 ChatGPT 而言,工具才是主接口;没有良好的元数据,再强的 system‑prompt 也救不了场。
错误 #2:什么都做的 God Tool。
“优化”为一个带 mode 的函数可以理解,但它会导致臃肿的 JSON schema、混乱的描述、糟糕的路由。模型要猜用哪个模式,而你要维护服务器端一个巨大的 switch。不如用几个面向具体步骤的清晰工具,胜过一个“包打天下”的工具。
错误 #3:输入 schema 填满“以防万一”的字段。
很多开发者试图把可能用得上的所有参数都一次性塞进 inputSchema,再加上几个内部字段。结果模型被迫去猜不可能知道的东西(例如 tenantId),然后你对奇怪的取值一脸疑惑。Input Schema 只应包含模型能从对话里得出或通过提问补齐的内容。内部细节请在服务器端添加。
错误 #4:没有元信息的“哑输出”。
只返回一个对象数组很诱人。但这会剥夺模型理解结果为什么出现的能力。缺少 score、reason、searchCriteria、totalCandidates 等字段,模型更难组织诚实的解释与追问。添加一个小型的 meta 包装,包含检索条件与建议,往往能显著提升回答质量。
错误 #5:描述像墙板一样千篇一律:“处理礼物”“搜索课程”“数据处理”。
这类描述的问题在于既不给模型触发器,也不给边界。它不知道“何时”调用、适用“哪个领域”。一个好的描述从“当……时使用该工具”开始,包含具体场景与“不要用于……”的限制。理想情况下,这些表述应和你的 golden prompt set 中的“金句”相互呼应。
错误 #6:忽视注解,混合只读与变更动作。
如果你不区分仅读取数据的工具(readOnlyHint)和会执行动作的工具(destructiveHint、openWorldHint),模型就无法设计正确的确认 UX。结果可能是每一步都弹“你确定吗?”,或者相反,默默地完成购买/修改。注解是廉价且高效的方式,向模型传达操作的重要性。
错误 #7:目录用的 App 元数据与对话内的元数据“各说各话”。
有时目录里的短描述由市场同学撰写(“颠覆人生的革命性 AI 助手”),而工具的 description 与 system‑prompt 则由开发者撰写(“按预算推荐礼物”)。结果在目录中看不出 App 究竟做什么,而模型在对话里也难以把“这是什么服务?”的提问与 App 的真实能力对应起来。把元数据当成一份统一的规范来写,而不是两篇互不相关的营销文案。
GO TO FULL VERSION