你们有没有发现,现在聊AI应用,几乎绕不开一个词:skills。很多人在群里问,到底什么是skills、怎么让自己的智能体能会干活、能干对活。这个问题的答案,往往就藏在技能设计上。所谓skills,不是简历上写的技能,而是给模型准备的一套可复用的能力模块:查数据、发通知、处理文件、调外部接口。模型就像一个刚入职的聪明新人,知识面很广,但手里得有工具才真正能干成事。技能化的本质,就是把零散的工具封装成有边界、有说明、可复用的能力包,交给模型按需调用。
这篇文章我准备用一个最小可运行的示例框架,把skills从概念、拆分、实现到踩坑完整拆一遍。适合正在做智能体类应用、或者想给自己的AI项目增加可扩展能力的开发者参考。不管你是个人折腾还是团队干活,这套思路都值得你花二十分钟过一遍。
1. 先搞明白:Skills到底是个什么东西
1.1 从"工具调用"到"技能体系"
最简单的理解方式,是把Skills当作函数。但如果你真的只把它当函数,那就浪费了技能化的价值。函数是程序员写的、由代码决定什么时候调;Skills是给模型读说明书之后,由模型自己决定什么时候用。这一步决策权的转移,才是智能体应用的核心变化。
早期一批Agent应用的做法很直接:给模型一个工具列表,每个工具就是一个JSON结构加一个后端函数。模型根据用户问题输出一段"我要调用工具A,参数是B"的结果,后端收到后执行,再把执行结果塞回去,让模型继续往下推理。这个模式到今天依然存在,大家习惯叫Function Calling或者Tool Use。
但一旦工具数量上来,比如几十个、上百个,问题就暴露了。模型面对一个特别长的工具清单,选择准确率很容易掉;每次对话还要把所有工具的说明全量塞进上下文,Token开销也蹭蹭涨。这时候就需要往上包一层组织逻辑,于是Skills出现了。
一个Skill不只是"一个函数",而是一整套完整处理能力:它有清晰的名字和说明,告诉模型什么时候用、什么时候不用;它有严格的参数定义,模型调用之前必须按规则填参;它内部可以封装多个步骤,自行决定调什么底层服务、怎么处理中间结果;它还可以带上默认配置、模板和错误处理策略。我更愿意把Skills理解成一个"能力包",而不是单纯的函数清单。模型需要知道的不只是"有这个函数",还有边界、输出格式、失败之后怎么办。
1.2 技能化解决的真实痛点
很多智能体产品给人感觉是"演示惊艳,一上真实场景就犯蠢"。原因之一就是工具没有做技能化封装,模型决策空间太大,乱调用、乱传参、结果不可控。技能化至少能解决四个实际问题。
第一,收敛决策范围。模型不需要在上百个底层函数里挑,只需要在上层Skill里选。比如"查订单"这个Skill,内部可能整合了数据库查询、缓存读取、结果格式化多个步骤。模型需要知道的只是:当用户想了解订单状态时,调用"查订单"就够了。它完全不需要自己组装SQL、操作缓存结构。
第二,降低Token消耗。每次会话都把全部底层工具定义塞给模型,上下文会越来越贵、越来越慢。技能化之后,可以先给模型一小批高优先级Skill,其余按需加载。本质上这就是两层检索:先根据用户意图粗筛技能,再把选中的技能详细描述喂给模型。
第三,可复用、可测试。同一套Skills可以在多个项目里复用。修bug、加逻辑都在Skill内部完成,不需要每次改完一个智能体就把业务代码翻个底朝天。我自己用同一套技能体系同时支撑过两条产品线,省掉了重复建设的三成工作量,这个收益是实打实的。
第四,权限和安全更好做。每个Skill都是独立边界,可以单独配置是否允许访问外部网络、是否需要身份校验、是否记录审计日志。这比在智能体主流程里到处加权限判断要干净得多。
2. 设计一套Skills库的核心思路
2.1 技能拆分原则
刚开始做技能库的时候,我踩过一个典型的坑:把所有能用代码完成的活都塞进一个"万能执行器",然后指望模型自己理解。结果是模型经常把一个不太相关的技能执行了,或者干脆无视技能,自己编结果。后来我把拆分原则定成了三条,越做越顺。
第一,按用户意图拆分,不按代码函数拆分。一个Skill应该对应一种用户想完成的事情,而不是对应一个内部方法。比如"给用户发送邮件"是一个Skill,"校验邮箱地址格式"是底层逻辑,后者不应该单独暴露给模型。
第二,粒度要能让模型在三步之内做决策。如果用户说一个需求,你的Skill列表里有两百个选项,模型很容易懵。比较稳妥的经验是:常驻技能控制在十五到二十个以内,其余技能按领域分组,在对话开始或意图变化时动态激活。
第三,独立性优先。Skill之间尽量不要互相依赖调用,否则模型要协调的事情就变复杂了。如果有公共逻辑,提取成内部服务;Skill对外只保留"输入"和"输出"两个面。我自己整理了一个拆分自检表,每次新增技能都过一遍:
| 检查项 | 说明 |
|---|---|
| 是否对应用户可见的任务 | 如果不是,拆到内部层 |
| 是否可以在一条消息内完成 | 如果流程太久,拆得更小 |
| 是否与其他技能强耦合 | 如果必须互相调,考虑合并 |
| 是否有明确的失败场景 | 必须定义模型拿到失败结果后怎么继续 |
| 是否值得占用模型注意力 | 边际价值太低就只放内部,不注册 |
2.2 描述文本的写法
这是我眼里最容易被低估的地方。模型的技能选择效果,一半取决于描述写得够不够清楚,而不是取决于后端函数实现有多好。我见过太多团队花大量时间调模型、调参数,却从来不改一句技能描述,结果就是能力明明实现了,模型却"看不见"。
一个Skill描述,至少要把四件事讲清楚:
- 技能是做什么的:一句话讲清能力范围,贴近人话,别堆名词。
- 什么时候该用:给出典型触发场景,或者反过来写哪些情况别用。
- 在什么约束下使用:比如必须要有用户授权、只能查某类数据范围。
- 输出会是什么样:让模型知道拿到结果后,下一步可以怎么处理。
我拿一个实际对比来说明。差一点的描述是这样的:"OrderInfo skill that queries the order database and returns information. Use this when needed."
好一些的描述可以写成:"用于查询订单详情。当用户询问订单状态、物流进度、订单金额时使用。提供订单编号或用户身份,会返回订单基本信息及相关状态。若订单不存在,会返回错误信息,拿到错误后应引导用户核对单号。"
看起来第二段只是多写了几句,但在模型理解上的差距非常大。我实测下来,第二种描述能让技能被正确选中的概率从七成左右提升到九成以上,具体数字和模型版本有关,但方向非常稳定。
另一个小技巧:给每个Skill加别名或关键词。就像给文件加标签,模型在做意图判断的时候,这些关键词能起到不小的辅助作用。
2.3 参数与输入输出的规范
参数定义,我用一句话总结:宁可多写required,也不要让模型去猜。
模型填参数时没有程序员的严谨,它倾向于"尽量填一下试试"。如果你的Skill有两个参数,只有一个标了required,另一个带默认值,模型往往会跳过默认可选参数,把一些推理出来的内容错误塞进去。所以我的做法是:
- 真正业务必需的参数,全部标为required;
- 可选参数必须写清楚默认行为和省略后果;
- 枚举值要列出允许的取值范围,最好再给一两个示例;
- 参数名用语义化命名,别用"data1""info2"这种没人能看懂的字段。
输入之外,输出格式同样要规范。一个比较实用的做法是让所有Skill统一返回"结构化结果 + 自然语言提示"两种信息。结构化结果给后端做后续判断,自然语言提示给模型直接用来回复用户。否则你会经常在日志里看到模型对着一个状态码开始一本正经地胡说八道。
3. 实操:一个最小可用的技能注册与调度框架
3.1 整体结构
我直接给一个能跑的最小实现思路。示例用Python写,你完全可以改成Java、Go或TypeScript,核心逻辑不复杂:注册、描述、调度、执行、结果回传。
整个框架分三层:
- 注册层:负责把Skill对象和它的描述、参数Schema放进内存注册表;
- 调度层:当模型说"我要调X,参数为Y"时,从这里取出对应函数并执行;
- 执行层:真正调用内部服务,做异常兜底和结果包装。
我强烈建议把"Skill的定义"和"Skill的执行"分开写。定义是静态元数据,比如名字、描述、参数Schema;执行是动态逻辑。分开之后,你才能对同一个Skill做不同场景的权限控制、限流和复用。
3.2 注册器实现
先写一个最小的Skill定义结构。它负责保存技能元数据,并提供转成模型可读格式的方法。
from typing import Callable, Any, Dict, List class Skill: def __init__( self, name: str, description: str, parameters: Dict[str, Any], required: List[str], func: Callable[..., Any], ): self.name = name self.description = description self.parameters = parameters self.required = required self.func = func def to_schema(self) -> Dict[str, Any]: # 组装给模型看的函数调用描述 props = {k: v for k, v in self.parameters.items()} schema = { "name": self.name, "description": self.description, "parameters": { "type": "object", "properties": props, "required": self.required, }, } return schema再加一个注册表:
class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def register(self, skill: Skill) -> None: if skill.name in self._skills: raise ValueError(f"Skill {skill.name} already exists") self._skills[skill.name] = skill def get(self, name: str) -> Skill: skill = self._skills.get(name) if skill is None: raise KeyError(f"Skill {name} not found") return skill def all_schemas(self) -> List[Dict[str, Any]]: return [skill.to_schema() for skill in self._skills.values()]这段代码很简单,但有几个点值得展开说。
注册时检查重名,是为了避免两个技能名字太像,导致模型在调用时混淆。如果场景上确实需要相似能力,可以用命名空间区分,比如"orders.query"和"orders.update",而不是都叫"query"。
to_schema()这个方法把Skill定义直接转成模型能读的格式,好处是你加一个新技能时,只需要维护一份元数据,不需要同步更新文档或硬编码工具列表。最后的all_schemas()返回的就是之后塞给模型的那段"可用工具列表"。
3.3 让模型自动选择技能(调度层)
真实Agent应用里,模型这层通常是外部大模型接口。你可以在对话请求里带上all_schemas()的结果,模型会返回类似"我选择调用技能X,参数如下"的结构。不同模型的返回格式不一样,但总思路一致:后端收到选择结果后做分发,执行完再把结果送回模型。
我来写一个调度函数,接收模型选择结果,执行技能并返回包装后的结果:
def dispatch_by_model_call(registry: SkillRegistry, chosen_skill_name: str, arguments: Dict[str, Any]): skill = registry.get(chosen_skill_name) try: result = skill.func(**arguments) return {"success": True, "result": result} except TypeError as e: # 参数不匹配时,回传错误给模型,让它重新组织参数 return {"success": False, "error": f"参数错误: {e}"} except Exception as e: # 业务异常,需要把错误信息回传,模型才能继续对话 return {"success": False, "error": f"执行失败: {e}"}这里的核心思路是:调用失败时不要把异常直接抛给用户,而是把错误信息变成结构化回传,让模型看到后自己决定怎么补救。模型在拿到失败信息后,往往能重新组织语言、向用户追问、或者换一种执行方式。这样做下来,用户感知到的系统错误率会低很多,因为大部分问题被模型在"内部"消化掉了。
实践中我还会加一层前置校验调度:在真正调用Skill函数之前,先对模型的参数和Schema做一次校验。如果必填参数缺失、类型不匹配,直接在调度层拦截,生成一条引导消息,不进业务代码。这样做能挡掉很多因为模型幻觉导致的无效调用,省下大量日志排查时间。
3.4 动态加载与熔断
技能数量少时,注册表放内存没问题。技能一多,内存占用和热点查询就会变成瓶颈。比较务实的方案是:常驻技能全量放内存,非常驻技能按需加载,加载完成后缓存下来。
动态加载的核心就是一个懒加载注册表:
class LazySkillRegistry(SkillRegistry): def __init__(self, loader: Callable[[str], Skill]): super().__init__() self._loader = loader def get(self, name: str) -> Skill: if name not in self._skills: # 只在第一次被调用时加载 self._skills[name] = self._loader(name) return self._skills[name]动态加载对Token控制的收益非常明显。假设你一共有六十个技能,但当前对话只涉及订单和物流,调度层可以先只把这两个技能相关的一组Schema喂给模型,而不是一次把六十个全塞进去。每次请求省掉的中长尾描述,累积起来非常可观。
再说熔断和限流。有一次我给某个技能接入外部接口时,接口偶尔会慢到半分钟才超时。如果不做熔断,模型调用这个技能时会长时间卡住,用户会以为整个智能体挂了。后来我加的处理是:每个技能都设定超时阈值,超过就直接返回"技能执行超时,请稍后重试或换个方式询问",同时把该技能短时间内标记为不可用,不再让模型继续选它。这个简单的熔断处理,让整体可用性提升非常明显,单次接口故障不会拖垮一整条会话。
4. 上线之后:我踩过的坑与排查思路
4.1 模型选错技能的排查
线上最常见的问题是模型选错技能:用户想查账户余额,模型却调了"查交易明细"。排查这类问题,我建议按顺序走这几步。
先看会话日志里模型实际收到的技能Schema是什么。很多时候是因为线上没有更新到最新技能配置,或者缓存还是旧版本。
再看技能描述是否覆盖了当前意图的表达方式。有些模型对"账户余额"的理解偏向"查询资产",而不是"查交易明细",所以描述里要把容易误用的场景也写清楚。比如在查余额的Skill描述中补一句:"当用户询问收支流水时,不要使用本技能。"
再看多个技能之间的描述是否雷同。两个技能如果描述里都反复出现"查询账户信息",模型高频混淆就非常合理。
最后看是否需要缩小常驻技能集合。技能太多,模型注意力被稀释,选错概率会明显上升。
还有一个经验:如果技能A和技能B在业务上确实难区分,不如合并成一个技能,内部用参数分流。我做过一个案例,技能A"发邮件"和技能B"发通知"经常被模型搞混,合并成一个"发送消息通知"技能,加一个渠道类型参数之后,选错问题直接消失了。
4.2 参数格式的怪问题
我遇到过一类很隐蔽的问题:模型明明照着Schema填了参数,后端却一直报类型错误。排查到最后发现,模型返回的参数是"123"这种字符串,而Schema里定义的是integer。不同模型在严格JSON输出上的能力不太一样,有些会在数字前后带上引号,有些会把空值填成"null"字符串。
处理方式一般有两种。
第一种是在调度层做参数宽容化处理:对字符串形式的数字做类型强转,对不能强转的再报错回传。这种方式很稳,但会掩盖一些真实问题,长期用容易把日志搞脏。
第二种是在系统提示词里明确写:"调用技能时,所有参数必须严格按照JSON Schema的类型定义,禁止把字符串当数字使用。"如果模型支持结构化输出,直接开启那个选项,能解决掉九成以上的格式问题。
我平时两种都用,但更推荐第二种优先,因为它从源头杜绝问题,而不是靠后端一直擦屁股。毕竟一个真正稳定的技能体系,应该越少依赖"事后补救"越好。
4.3 超时与资源占用
技能执行时可能要去访问外部API、查数据库、跑一段文件处理,这些都可能很慢。很多人在调试阶段感觉不到,因为本地数据量小、网络快。线上真实流量一来,超时、并发、资源竞争全冒出来了。
我的建议是,技能内部分两层超时:网络调用层设置短超时,比如5秒;整个技能执行设置总超时,比如15秒到20秒。网络层超时能快速止损,总超时防止多个技能串行执行时把整个会话拖死。
技能内部如果并发请求外部服务,一定要控制并发数。很多智能体应用在线用户稍多,技能里直接对第三方服务发起请求却不限制并发,很容易把对方接口打爆。加一个简单的并发信号量就够了,不需要多复杂的熔断框架。
还有个容易被忽视的点是日志。每个技能最好都输出统一的执行日志格式:技能名、参数摘要、耗时、错误码。没有这些日志,排查线上问题就像摸黑修电路。我一般会把技能执行的耗时和结果摘要写入结构化日志,方便按技能名做聚合统计,比如哪个技能最慢、哪个技能错误率最高。
4.4 常见问题速查表
这里整理一份我实际工作里累积的排查速查表,希望能帮你在遇到类似问题时少走弯路:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 模型完全不调用技能 | 描述太泛、常驻技能太少 | 提升描述触发场景的明确度 |
| 模型频繁调用同一个错误技能 | 两个描述太雷同 | 合并技能或加上"何时不用"说明 |
| 参数多次报错 | Schema类型定义不清 | 显式指出类型,开启结构化输出 |
| 技能执行超时 | 外部依赖慢 | 加网络层和技能层双层超时 |
| 用户看到"系统内部错误" | 异常被直接抛给应用层 | 统一异常捕获并回传友好错误 |
| 换一个模型后技能选择明显变差 | 不同模型理解能力有差异 | 按模型能力回归技能集和描述 |
| 常驻技能太多导致响应慢 | Token过多 | 做动态加载和按意图分组 |
这张表内容不算多,但每一条背后都是真实事故。我建议你在搭建技能系统时,先预设好统一的日志和错误格式,否则真到排查问题的时候,你会非常痛苦。
5. 进一步把Skills库做成能持续进化的系统
5.1 版本与缓存
技能不是写一次就不动了。外部接口变化、业务规则调整、模型升级,都需要改动技能。如果版本管理没做好,很容易出现线上用的技能和代码仓库里的不一致。
我推荐一个轻量方案:每个技能定义里加一个version字段,发布时通过CI自动递增。注册表启动时把自己的版本列表上报到监控中心,如果发现某台机器的技能版本和其他机器不一致,就能快速定位是不是灰度发布导致的差异。
缓存方面要分两层看。技能描述本身是静态元数据,做全量缓存没问题。技能内部的外部接口结果,则要根据数据变更频率做短TTL缓存。比如查询订单状态这种数据,缓存十秒足够挡住大量无效请求,但又不会明显影响实时性。加上这个之后,第三方接口的压力会小很多。
5.2 评测闭环
"技能选择是否准确"不能靠感觉。要让Skills持续进化,必须有一个评测闭环。做法是:收集一批真实或近真实的用户问题,标注出每个问题应该调用哪个技能、期望得到什么结果,组成一个离线评测集。
每次改技能描述、增删技能、切换模型,都在这套评测集上跑一遍,记录技能选择准确率和参数填充正确率。我一般会保留最近几周的评测结果,这样能很快看出某次改动让整体变好还是变坏。
这个评测集的价值会随着时间越来越大。你甚至可以做成自动回归:每天筛选新对话里不成功的案例,人工标注后加进评测集,让整个系统的技能质量螺旋式上升。没有评测闭环的技能库,本质上是在靠运气运行。
5.3 给个人或小团队用的小建议
如果你是一个人开发,或者团队规模很小,我不建议一开始就去追求大而全的技能平台。先用注册表加调度层加日志这套最小结构,把二三十个核心技能跑起来,已经能覆盖相当多的真实场景。等发现模型频繁选不对技能、或者技能数量多到不好管理时,再上动态加载和评测闭环,完全来得及。
还有一个很务实的小建议:每新增一个技能,先在真实对话里手动测试十到二十条不同表达方式的问题,确认模型确实能准确选中它。这种方式比单纯写单元测试更贴近实际。因为模型的行为不像纯代码那样确定,真实表达的多样性必须靠人工回归去兜底。
现在很多人做智能体应用,最先想到的是堆模型、调Prompt,却把最容易控制、也最该工程化的技能层放在后面。我反而觉得,把Skills设计和打磨好,是投入产出比非常高的一件事。技能层做扎实了,哪怕模型换一版、场景换一个,整条系统依然能稳定跑起来。