前几天同事跑来找我,说他的文件解析 Skill 上线两天,接口一条请求都没收到。他在测试台手动传参调了十几遍,全部正常,可一到大模型对话里就彻底"隐身"。我把他的 Skill 名称和描述调出来看了一眼,又对比了平台上另一个功能相近的内置能力,问题一下就清楚了:当用户说"帮我看看这个 PDF 里写了什么"时,模型在工具清单里同时看到了他的 Skill 和另一个描述更直观的能力,最终选了后者,而他的 Skill 从头到尾都没被"看见"。
这个场景在接入大模型能力的开发里太常见了。很多人以为 Skill 不被调用就是执行代码有 bug,实际上十次有八次,问题出在更靠前的位置——模型拿到手的那份"工具清单"就已经决定了它会不会选你。这篇文章会把一次 Skill 调用从注册到执行的完整链路拆开,然后把我们团队在多个项目中总结出来的 9 条最佳实践逐一展开。内容覆盖命名、描述、参数、路由、缓存、日志和超时这些环节,无论你是刚接触 Skill 机制的新手,还是已经被线上 0 调用折磨过的老手,都能从里面找到可以直接落地的排查方法。
1. 拆解触发链路:你的 Skill 是在哪个环节被拦下的?
很多人在排查"Skill 不被调用"时,第一反应是看执行日志和接口监控。这没错,但如果问题根本没走到执行这一步,你看到的自然是一片空白。所以先搞清楚 Skill 从定义到发生调用的完整链路,比什么都重要。
1.1 一条 Skill 从注册到执行的四个环节
一个 Skill 要走完"被调用"这条路,通常要经过四个环节:
- 注册:你在平台或配置文件里定义 Skill 的元信息,包括唯一标识、名称、描述、参数声明、执行端点和权限。这一步的产出是一份结构化清单,相当于把"我有哪些能力"告诉给了平台。
- 注入:每次会话请求开始前,平台会把当前可用的 Skill 清单拼进发给大模型的上下文里,通常是 system prompt 或工具列表。这里有一个非常容易被忽略的限制:清单不是无限长的,模型能同时看到的工具数量受上下文窗口限制。
- 路由:模型根据用户当前这句话与 Skill 名称、描述的匹配程度,决定"要不要调用、调用哪一个"。即使你的 Skill 出现在清单里,模型也可能认为"另一个更合适"或"这次根本不需要工具"。
- 执行:路由命中后,平台把用户请求中的关键信息抽取成参数,交给你的接口去跑,再把返回结果拿回来交给模型组织回答。
这里面最容易出问题的就是"注入"和"路由",因为它们的结果看不见、摸不着,不像接口报错那样直观。想定位问题,就要先判断失败发生在哪一环。
1.2 四类失败分型:不可见、不匹配、被拒、被弃
根据我排查过的线上问题,Skill 不被调用基本可以分成四种类型。判断的依据很简单:看加载日志、命中日志、校验日志和执行日志四段里,你的 Skill 消失在哪一段。
| 失败类型 | 典型现象 | 常见原因 | 定位手段 |
|---|---|---|---|
| 不可见 | 模型完全没提过该功能,命中日志无记录 | 未注册成功、被下线、清单被截断、被平台过滤 | 打印真实请求的注入内容,搜 Skill 名 |
| 不匹配 | 模型调用了另一个 Skill,或回复"没有这个功能" | 名称/描述与用户意图匹配度不够,功能重叠 | 用真实用户原话做命中测试,看路由结果 |
| 被拒 | 路由命中但接口没收到请求,或参数校验报错 | 必填参数缺失、类型不对、枚举值不合法 | 看校验阶段日志,复现一次带参数的调用 |
| 被弃 | 有请求日志,但执行超时或返回结构不合规 | 接口响应太慢、返回值与声明不一致 | 看执行阶段耗时和响应体 |
大多数"0 调用"的问题都集中在第一类和第二类。换句话说,你的 Skill 要么根本没出现在模型眼前,要么出现了但模型不认为它该被调用。
1.3 把模型看到的清单想象成菜单
我经常跟同事打一个比方:模型就像一个顾客,Skill 清单就是餐厅菜单。菜单上没这道菜,顾客自然点不了;菜单上有这道菜但描述写得含糊不清,旁边又有好几道差不多的菜,顾客大概率会随便选一个或者干脆不点。你写的名称、描述、参数说明,就是这份菜单上唯一能打动顾客的文字。
所以后面讲的 9 条最佳实践,本质上是围绕一件事:让模型在任何可能用到你的场景里,都能快速、准确地认出你,并且知道怎么把参数填对。
2. 声明阶段的三件小事:名称、描述、参数 Schema 写不好,后面全白搭
Skill 声明是后面所有环节的数据源头。我见过太多人随便写个名称和一句话描述就发布了,结果线上数据一片惨淡。多花十分钟把这三样打磨好,比事后排查节省一整天。
2.1 名称:唯一、短、用"动词+宾语"
模型在做路由选择时,会同时看名称和描述做语义匹配。名称如果和用户表达高度一致,会显著提高被选中的概率;如果起得太抽象,或者与其他 Skill 重名,平台可能只保留一个,路由时还会随机。
我自己习惯用的格式是"动词+宾语",语义直接、一眼可懂。不要带版本号,比如 parse_file_v2 这种,版本变更不会因为名称变化获得更好的路由,反而增加混淆感。
| 名称写法 | 评价 | 理由 |
|---|---|---|
| do_stuff | 差 | 完全看不出功能,模型无法匹配意图 |
| fileprocessing | 一般 | 语义泛,用户不会用"处理文件"来触发 |
| parse_uploaded_file | 推荐 | 动词 parse + 宾语 uploaded_file,清晰直接 |
平台一般有技能命名唯一性约束,如果重名往往会导致其中一个不可见。所以发布前搜一遍现有 Skill 列表,确认没有同名,是最基本的动作。
2.2 描述:写"什么情况下调用",别写"功能说明"
这是九个实践里性价比最高的一条,但也是最容易被忽视的。很多人写的描述是"解析用户上传的文件并返回内容",这本质上是一句功能说明书,它告诉模型"我能干什么",但没告诉模型"用户怎么说的时候该叫我"。
模型的匹配逻辑是基于语义相似度打分的。你越贴近用户的真实表达习惯,分数越高。正确写法应该是:先写触发场景,再给 2-3 个典型表达,最后把与其他 Skill 的边界说清楚。
错误示例:
解析用户上传的文件并返回内容。正确示例:
当用户上传或提到一份文档、图片、表格并希望读取或提取其中内容时调用, 例如"帮我看看这个PDF"、"提取Excel里的数据"、"这个图片里的文字是什么"。 注意:生成总结或摘要请调用另一个Skill summarize_content,本Skill只负责提取原文。关键点有三个:写触发场景、给典型表达、明确边界。描述也不是越长越好,太长会占用模型上下文;控制在 100-200 字内,把高频场景和互斥说明放在最前面。你要不要在描述里写清"不要做什么",答案是:一定要写,因为模型在犹豫选哪个时,边界说明就是它做决定的关键依据。
2.3 参数 Schema:必填、枚举、示例值一个都不能少
路由命中之后,模型要根据参数声明去"猜"该怎么传参。如果必填字段没标清楚,模型可能直接跳过;如果字段格式没有示例值,模型可能填入一些你无法解析的内容。
我看过一个真实的报错:日期字段在 schema 里声明成 string,没写格式,模型在对话里听到用户说"明天",就把"明天"传了过去,接口解析直接失败。这种情况完全可以通过 schema 优化避免。
推荐写成这样:
{ "type": "object", "properties": { "file_url": { "type": "string", "description": "用户提供或上传文件的访问地址,必须以 https:// 开头。如果用户只提到文件名,请先调用文件查询接口获取 URL。", "format": "uri" }, "extract_type": { "type": "string", "enum": ["text", "table", "image"], "description": "要提取的内容类型,默认 text", "default": "text" } }, "required": ["file_url"] }注意几个细节:每个字段 description 要写清楚"如果用户没有直接给全,该怎么补";有默认值的字段给 default,模型就不用纠结要不要传;能用枚举的不要用自由字符串,模型填空的能力没有你想象的那么强。Schema 是给模型看的 API 文档,文档写得不清楚,模型的"调用请求"自然不准确。
3. 路由层那几个看不见的规则:重叠、遮蔽、截断
就算名称、描述、参数都过关了,路由层还有几道隐形的坎。这几个坎有一个共同点:它们不会直接报错,只会悄悄降低你的命中率,甚至让你的 Skill 完全消失。
3.1 功能重叠等于互相稀释
当一个场景同时有两个 Skill 在竞争时,模型的选择会变得不稳定。比如一个叫"文本总结",一个叫"会议纪要提炼",当用户说"帮我把这段会议记录总结一下"时,模型可能在两者之间随机选择,也可能反复切换,最终导致两个 Skill 的命中率都很低。
解决办法是职责拆分要彻底:功能重叠时要么合并,要么在描述中明确互斥。一个简单自检方法:把两个 Skill 的描述并排放在一起,遮住名称后如果分不清谁是谁,那模型也会困惑。合并之后,即使逻辑内部区分场景,也要保证对外只有一个入口。
3.2 同名与同功能遮蔽:内置能力优先
很多平台自带一批内置能力,比如提醒、计时、搜索。内置能力在路由优先级上往往高于自定义 Skill。如果你再定义一个"定时提醒"Skill,用户说"提醒我三点开会"时,模型大概率会选内置能力,而不是你的自定义实现。
我不建议硬碰硬去覆盖内置能力,除非你的 Skill 有明确的差异化价值。比如内置提醒只支持单次提醒,你的 Skill 支持按周循环、按节假日跳过,这就能成为被选择的理由。但前提是,你要把这种差异写进描述的前两行,让模型一眼就看出"这个自定义版本有内置能力给不了的东西"。
3.3 工具列表截断:模型一次只看得到前 N 个
上下文 token 是有限的,平台通常只把 Skill 清单的前 N 条注入给模型。如果你的 Skill 排在最后,而总数量超过上限,它根本不会出现在模型眼前——这不是匹配度的问题,是彻底的"不可见"。
要验证这一条,最直接的办法是打印一次真实请求的完整提示词或工具列表,在原文里搜你的 Skill 名。如果搜不到,说明它被截断了。应对方法是在不影响语义的前提下精简描述,让高优先级的 Skill 排到前面。Skill 特别多的时候,也可以考虑两级路由:设计一个"调度类"Skill,让模型先调用调度器,再由调度器根据参数决定实际执行哪个子模块。调度器本身只做路由,不承载业务逻辑,描述里讲清楚"这个 Skill 负责判断并转给合适的处理模块"就够了。
4. 上线后真正的敌人:缓存、日志、超时和返回结构
很多人以为 Skill 发布上线就万事大吉,实际上线上问题往往出在你看不见的环节:缓存让模型拿着旧描述跑了半天,日志缺失让你无从下手,超时让 Skill 在关键时刻被弃用。这一章讲的四个坑,每一个我都踩过。
4.1 发布新版本后,模型拿到的还是旧描述
Skill 清单在构建时会被打包进一个产物,这个产物经过 CDN 或缓存层分发。你改了描述并发布,但如果缓存没有失效,模型拿到的还是上一次打包的旧内容。最典型的表现是:你在后台看到新描述已经生效,测试台调用也正常,但线上真实请求里依然是旧版本。
所以发布后的第一件事,不是写代码,而是确认模型实际拿到的清单版本。具体办法是调取任意一个真实会话的注入日志,检查 Skill 名称、描述、参数声明是否与最新版一致,同时确认构建时间和版本号变化。必要时,在构建流水线里给产物加上 hash 作为缓存标识,从根上避免"改了没生效"的问题。
4.2 日志要分成四段来打
只打执行日志,是排查"不被调用"时最大的痛点。如果路由阶段就没命中,你连一条执行日志都看不到,等于什么都没有。
我建议按四个阶段分别埋点:
- 加载日志:记录请求开始时模型拿到的 Skill 清单,至少包含数量和名称列表。
- 路由日志:记录模型最终选择了哪个 Skill。如果没有选中任何 Skill,记录模型的原始回复文本。
- 校验日志:记录参数解析结果,失败的请求要存下原始参数和校验报错。
- 执行日志:记录调用耗时、状态码、返回体,以及返回给模型之前做过的后处理。
四段日志配合,才能准确判断失败属于"不可见""不匹配""被拒""被弃"中的哪一类。日志不用打得很详细,但关键字段必须有。比如路由日志这一行,要能区分"模型没看到这个 Skill"和"模型看到了但没选它"。
4.3 超时、并发和返回结构
Skill 执行一般有超时上限,常见是 3-5 秒。一旦超时,平台会判定调用失败并终止该 Skill 的后续调用,模型可能直接告诉用户"这个功能暂时不可用"。所以外部接口一定要套一层超时控制,宁可快速失败也不要拖到整体超时。快速失败的好处是,模型拿到错误后还可以重试一次,或者给出友好的兜底回复。
返回给模型的结构也要稳定。模型需要从返回结果中提取信息来组织最终回答,如果你返回纯文本、大段 HTML 或一个结构不明的 JSON,模型解析效率会很低。推荐固定成这样的结构:
{ "status": "success", "summary": "共提取到 3 个段落,其中表格 1 个", "data": { "paragraphs": [...], "table_count": 1 } }summary 字段给模型一个快速了解结果的口径,data 字段放结构化的详细内容。返回结构一旦和参数声明呼应起来,模型的回答质量和稳定性都会有明显提升。
5. 一次真实排查:从"0 调用"到定位并修复的全过程
前面讲的是方法论,这一章给你看一个完完整整的排查案例。这个案例发生在我们的模拟项目 X 里,过程很典型,你可以对照着自己的情况复现排查思路。
5.1 现象:接口一条请求都没有
同事 A 开发了一个"会议纪要要素提取"Skill,功能是提取会议记录中的待办事项、负责人和截止时间。上线两天,接口监控显示请求量为 0。他在测试台手动传参,功能完全正常,所以第一时间排除了执行代码的问题。从现象看,这属于典型的"不匹配"甚至"不可见",直接进入中间链路排查。
5.2 三步定位:加载、命中、校验
第一步,确认 Skill 在不在模型可见的清单里。我调取了一个真实会话的注入日志,搜 Skill 名称,发现它在清单里,字段完整。这一步排除了"不可见"。
第二步,模拟真实用户原话做路由测试。我们拿"帮我把这段会议记录里的待办事项提取出来"这句话反复测试,发现模型总是倾向于调用另一个"文本总结"Skill,而不是他的"要素提取"。把两个 Skill 的描述并排对比后发现问题:同事 A 的描述只写了一行"提取会议记录中的结构化信息",既没有典型用户表达,也没有和"文本总结"做边界说明。模型看到"总结"和"提取"两个词同时出现时,选了描述更泛、措辞更接近用户原话的那个。
第三步,看参数校验。同事 A 在手动测试时总是传 full_url 和 extract_type,但真实对话里模型经常只抽取到 full_url,把 extract_type 当成无关字段丢掉了。由于参数声明里没有 default,模型也默认它可以不传,导致接口侧拿不到预期的输入,一次请求都发不出去。
5.3 根因与修复
根因有两个:一是描述信息不足,没有触发场景和边界说明,导致模型不愿意选它;二是参数声明里 extract_type 缺少默认值,模型认为这个字段可省,接口却要求一定要传。
修复方案也很直接:
- 在描述里补上触发场景和 2-3 个典型表达,比如"把这段记录里的待办列出来""这个会议有哪些负责人"。
- 在描述末尾加上"注意:生成摘要请调用 summarize_content,本 Skill 只负责提取结构化要素",把边界讲清楚。
- 在参数 schema 的 extract_type 字段里加上 default 值,并声明枚举范围。
- 重新发布后,用同一条用户原话再做命中测试,确认路由结果已经指向正确的 Skill。
5.4 这个 case 教会我的事
排查过程走完,最大的感受是:Skill 不被调用,往往不是代码的问题,而是你写给模型的那份"菜单"出了问题。描述是给模型看的 API 文档,schema 是给模型看的参数使用说明,这两样东西写得不清楚,执行代码写得再好也白搭。另外一定要用真实用户的表达方式去测试,别总是用自己设计好的标准语句,那会掩盖掉大量真实场景里会出现的措辞问题。
我自己现在的习惯是:每次发布新 Skill 之前,把它想象成一个第一次进店、对菜品一无所知的顾客,用三句话向他介绍这道菜什么时候该点、点了之后服务员会不会把菜上对。能说清楚的,上线之后的命中率一般都不会差。要是你们团队也遇到过类似的诡异问题,不妨按这条思路走一遍,大概率会在描述或者参数声明里找到答案。