做智能体开发这一年多,我最大的一个感受是:真正拉开项目水平的往往不是模型选得有多新、Prompt写得有多花,而是一堆不起眼的 skills 怎么设计、怎么组织、怎么复用。
第一次接触“技能包”这个概念,是因为一个特别具体的痛点:当时我在做一个信息汇总 Demo,同样一个“从网页里抽正文”的功能,先写进了一个脚本,再塞进了一段 Prompt,最后又单独暴露成了一个工具接口。三个地方各写一份,逻辑大同小异,但换一个输入场景就要改三处,改到最后自己都分不清哪份是新的。
后来参考了主流的 Agent Skills 机制,把这类能力统一封装成独立的技能包,模型调用率、复用性、排查效率都明显上了一个台阶。这篇内容就围绕“skills”展开,聊清楚技能包到底解决什么问题、内部结构长什么样、怎么从零开发一个可用的技能包、以及哪些坑是常规文档里不会写的。适合正在做智能体应用、或者想把自己手里的脚本升级成可复用能力的开发者参考。
1. 内容整体设计与思路拆解
1.1 从脚本到技能包:工程化的分水岭
先聊一个很多人没意识到的问题:普通脚本和技能包,差别到底在哪?
普通脚本解决的是“开发者自己怎么调用”。你把一串逻辑写进文件,命令行跑一次,输出结果,完事。它的问题是:换一个调用方、换一个业务场景、换一种输入格式,这段逻辑就基本报废了。它绑定了“某个具体任务”,而不是“某类能力”。
技能包解决的是“模型怎么理解、怎么自动调度、怎么标准化复用”。它不仅包含执行逻辑,还包含一份给模型看的“说明文档”——什么时候该用这个技能、参数怎么填、返回什么结构。模型读到用户的问题之后,自己去判断“这件事应该调用哪个技能”,然后把参数填好,触发执行。
我用一个生活化类比来解释。以前你带实习生,给他一份事无巨细的文档,他也能把事情做出来,但换一个任务、换一种问法,他就懵了。技能包相当于给实习生发了一套标准的“工作卡”:岗位说明、输入单、操作SOP、所需工具。实习生看到任务,先翻工作卡,匹配上了就照着执行,匹配不上就上报。这套机制稳定的原因,不是因为实习生更聪明,而是因为“该在什么场景做什么、怎么做、做完怎么交付”都被明确定义了。
当时我做那个信息汇总 Demo 的时候,逻辑全堆在一个超长 Prompt 里,模型每次的判断都不稳定。拆成技能包之后,每个功能模块边界清晰,模型只需要做“选择”,不需要做“猜测”,效果自然就稳了。
1.2 技能包的组成结构:四层拆解
一个标准技能包,我习惯拆成四层来看。
第一层是描述层(Skill Description)。这一层是写给模型看的,不是写给开发者看的。它回答三个问题:这个技能是什么、在什么场景下使用、典型输入长什么样。很多人的技能包不被调用,80% 的问题出在这一层写得不够清楚。
第二层是参数层(Input Schema)。这一层定义模型调用技能时需要填哪些字段。用 JSON Schema 来描述,比如字段类型、是否必填、可选项枚举、默认值。参数层设计得好不好,直接决定模型填参数的时候会不会出错。
第三层是执行层(Execution Logic)。这一层是真正干活的代码。它接收模型填好的参数,执行实际操作,然后把结果按约定格式返回。执行层的重点是稳定性,不是炫技。
第四层是资源层(Dependencies & Permissions)。这一层包括技能运行所需的依赖包、外部服务访问权限、超时配置、安全策略等。资源层很容易被忽略,但生产环境出问题,大半都出在这里。
用一张表对比一下“普通函数”“API”“插件”“技能包”这四者的区别:
| 类型 | 服务对象 | 核心关注点 | 典型问题 |
|---|---|---|---|
| 普通函数 | 开发者 | 代码复用、逻辑封装 | 换场景就要重写 |
| API | 应用与系统 | 通信协议、鉴权、稳定性 | 模型不知道怎么调 |
| 插件 | 应用平台 | 功能扩展、界面集成 | 太重,不适合细粒度调度 |
| 技能包 | 模型与智能体 | 可描述、可调度、可复用 | 描述和参数设计难 |
这个表格里最关键的一行是最后一行:技能包的服务对象是模型。你和模型之间没有口头沟通的机会,唯一的沟通渠道就是那层描述文本。很多人把技能包当成普通函数来写,结果模型理解不了、触发不了,问题就出在“服务对象搞错了”。
1.3 设计取舍:不是所有场景都该上技能包
技能包虽好,但不要过度设计。我自己见过一些项目,明明是一个固定流程,非要拆成五个技能包,结果模型在技能之间来回跳,上下文被撑爆,调用链路复杂到没法排查。
什么情况下才值得做技能包?我一般用四个条件来判断:
- 功能边界是否清晰?能不能用一句话说清楚“这个技能负责什么”。
- 输入输出能否结构化?能不能用 JSON Schema 描述清楚参数和返回结果。
- 是否有跨场景复用需求?同一个功能是否会被多个任务、多个智能体用到。
- 是否需要模型自主调度?用户的问题是否会动态变化,需要模型自己决定是否调用。
如果四个条件都满足,才值得动手做技能包。如果只是固定流程、固定输入,用 Prompt + 脚本反而更直接。技能包的本质是一种工程化抽象,抽象是有成本的——开发成本、调试成本、维护成本。判断什么时候该抽象,比抽象本身更重要。
2. 核心细节解析与实操要点
2.1 描述层:写给模型的使用手册
描述层是整个技能包里最关键、也最容易被敷衍的部分。很多开发者写 description 就写一句话,比如“Extract web content”。这在模型眼里等于什么都没说。模型看到一个技能,它需要判断“这个问题是不是这个技能能解决的”,判断依据就是这段描述。描述越模糊,模型就越不敢调用。
我总结了一个描述层的“三段式写法”。
第一段:这个技能是什么,擅长做什么。要具体,不要用空泛的动词。 第二段:在什么场景下应该触发调用。这是最关键的一段的,要写“用户提出哪类问题时,你可以使用本技能”。 第三段:典型输入示例。给一两个真实输入示例,帮助模型理解参数格式。
拿“网页正文抽取”举例,差描述是:
Extract web content.好描述是:
Extract the main text content of a web page and convert it to clean Markdown format. Use this skill when the user provides a URL and asks to summarize, collect, save, or quote the page content. Typical input: "https://example.com/article?id=1", with optional output format 'markdown'.注意“Use this skill when...”这个句式,它是在主动告诉模型触发条件。这比单纯描述功能要有效得多,因为模型的判断过程本质上是“问题特征匹配技能描述”,你把触发条件写清楚了,匹配成功率会大幅提升。
2.2 参数协议:JSON Schema 不是细枝末节
参数层是第二个容易出问题的地方。模型不是人类,它填参数的时候不会“灵活变通”,它只会严格按照它理解到的 Schema 来填。Schema 设计得不好,模型就会填错、漏填、编造字段。
我常用的一套基础参数设计原则:
- 参数要扁平,不要搞多层嵌套对象。嵌套层级越多,模型出错率越高。
- 必要的时候用枚举(enum),给模型指路。比如输出格式,就写成 markdown、json、text 三个枚举值,模型大概率会选对。
- 能设默认值的就设默认值。比如超时时间、输出语言、返回数量,这些参数设了默认值,模型少填一个参数,就少一次出错机会。
- required 字段要精简。我见过一个技能包,required 里列了八个字段,模型每次调用都要编两个理由说明为什么填不出来。把真正必需的字段控制在三四个以内,效果会好很多。
给一个实际的 JSON Schema 参考:
{ "name": "extract_web_page", "description": "Extract main text content from a web page and convert it to Markdown or JSON. Use when the user provides a URL and asks to summarize, save, or quote page content.", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "The full URL of the web page, starting with http:// or https://" }, "output_format": { "type": "string", "enum": ["markdown", "json", "text"], "default": "markdown", "description": "The expected output format" }, "max_length": { "type": "integer", "default": 3000, "description": "Maximum length of extracted text in characters" } }, "required": ["url"] } }这个 Schema 只有 url 是必填的,output_format 和 max_length 都有默认值。模型只需要填一个 URL 就能调用成功,出错率就低很多。
2.3 执行层:稳定性比功能炫技更重要
执行层是真正跑代码的地方。很多人把执行层当成写业务逻辑的地方,花大量时间优化算法,结果忽略了最基础的稳定性问题——超时、异常、返回格式不统一。
我在实际项目中遇到的执行层问题,按频率排序是:
- 网络请求超时。第三方接口响应超过 30 秒,模型那边等不到结果,整个链路就断了。
- 异常没有兜底。解析出错、字段缺失、编码异常,直接抛 Exception,返回给模型一堆堆栈信息,模型也看不懂。
- 返回结果格式不统一。有时返回字符串,有时返回数组,有时返回 JSON,模型下游处理逻辑只能靠猜。
执行层的一个好习惯是:不管成功还是失败,都返回结构化的结果。成功时返回规定好的数据结构;失败时返回一个包含错误类型的 JSON,而不是裸抛异常。
def extract_web_page(url: str, output_format: str = "markdown", max_length: int = 3000): try: # 这里是具体的抓取和解析逻辑 result = fetch_and_parse(url, output_format, max_length) return { "success": True, "data": result } except TimeoutError as exc: return { "success": False, "error_type": "timeout", "reason": f"request timeout after 30s: {url}", "retryable": True } except InvalidUrlError as exc: return { "success": False, "error_type": "invalid_url", "reason": f"URL is malformed: {url}", "retryable": False }这个模式的好处是:模型拿到结果后,可以通过 success 字段明确知道成功还是失败,通过 error_type 判断是否可以重试或需要换参数。这种把“失败也变成可处理的信息”的思路,才是稳定的技能包该有的执行层。
3. 实操过程与核心环节实现
3.1 实战:从零开发一个“竞品页面结构化抽取”技能包
理论讲了那么多,我们来走一遍完整实操。这个场景来自一个市场调研智能体项目:智能体需要根据用户提供的链接,把竞品官网页面抽取成结构化信息——产品名称、核心卖点、定价信息、页面更新时间。
第一步,先拆需求。
输入参数有两个:页面 URL、需要抽取的字段列表(fields)。可选参数有一个:超时时间。输出是一个结构化 JSON,结构如下:
{ "product_name": "", "selling_points": [], "pricing": {}, "last_updated": "", "raw_url": "" }第二步,定义执行函数。核心逻辑不复杂,请求页面、解析 HTML、提取指定字段:
import requests from bs4 import BeautifulSoup def extract_product_page(url: str, fields: list, timeout: int = 20): try: resp = requests.get(url, timeout=timeout, headers={ "User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)" }) resp.raise_for_status() except requests.Timeout: return {"success": False, "error_type": "timeout", "reason": f"timeout {timeout}s"} except requests.HTTPError as exc: return {"success": False, "error_type": "http_error", "reason": str(exc)} soup = BeautifulSoup(resp.text, "html.parser") result = {} if "product_name" in fields: h1 = soup.find("h1") result["product_name"] = h1.get_text(strip=True) if h1 else "" if "selling_points" in fields: items = soup.select(".selling-point, .feature-item, [data-feature]") result["selling_points"] = [i.get_text(strip=True) for i in items][:10] # pricing 和 last_updated 的解析逻辑可以按页面结构扩展 result["last_updated"] = find_meta_date(soup) result["raw_url"] = url return {"success": True, "data": result}第三步,编写描述层和参数层。描述层沿用前面的“三段式”,参数层设计成:
- url:字符串,必填。
- fields:字符串数组,枚举可选值,默认给全部字段。
- timeout:整数,默认 20,一般不传给模型。
给模型的描述是:“Extract structured product information from a competitor product page. Use when the user provides a product URL and wants to know product name, selling points, pricing, or last updated time. Typical input: https://competitor.example.com/product/12345”
第四步,做本地验证。在接入模型之前,先用一条固定 URL 直接调用函数,确认执行层能跑通、返回结构符合预期。这一步很多人跳过,结果是模型那边调了半天才发现是执行层本身的 bug。
3.2 注册与加载:让模型真正“看到”新技能
技能包开发完了,下一步是让模型能够发现并调用它。以常见的智能体框架为例,技能包通常以“一个目录一个技能”的方式组织:
skills/ extract_product_page/ SKILL.md main.py requirements.txtSKILL.md 就是前面说的描述层和参数层,main.py 是执行层,requirements.txt 声明依赖。框架在启动时扫描 skills 目录,读取每个 SKILL.md,把技能列表注册给模型。模型每次收到用户消息时,会先看到这个技能列表,再决定调用哪一个。
这个环节最常见的困惑是“为什么我加了技能,模型好像没反应”。我建议养成一个好习惯:把模型真正“看到”的上下文打印出来。
- 确认模型收到的技能列表里确实包含新技能名。
- 确认描述文本没有被截断。
- 确认技能名和描述里的触发词,和用户问题的表达方式对得上。
比如用户说“帮我看看这个页面有什么卖点”,你的技能描述里写的是“Use when the user provides a product URL and wants to know product name, selling points...”,这个匹配度就没问题。如果你写的是“A function to parse HTML pages”,模型大概率不会选它。
调试期可以先把其他技能临时注释掉,只留一个技能,看模型是否能稳定触发。能触发,再逐步把它加回技能列表里。逐个验证,比一次全上然后猜问题要高效得多。
3.3 多技能组合编排:日历查询与会议纪要素材整理
单个技能能跑通不算完,生产场景里更常遇到的是多个技能组合起来完成一个完整任务。我做过一个日程管理智能体,需要组合“查询日程”和“生成会议纪要”两个技能。
流程是这样的:
- 用户说“我今天下午的会议,帮我整理一下待办”。
- 模型先调用“查询日程”技能,拿到下午的会议列表。
- 拿到会议列表后,模型发现有一个会议有录音链接,于是调用“生成会议纪要”技能,把录音转成文字并提取待办事项。
- 最后模型把待办事项汇总输出给用户。
这个流程里的关键点有两个。
第一个关键点是中间结果的上下文传递。模型把第一个技能返回的 JSON 结果放在上下文里,再决定要不要调用第二个技能。所以每个技能的输出结构必须清晰。如果“查询日程”返回的是一大段 Markdown 表格,模型解析起来就费劲,后续步骤就容易断。第一代版本我让“查询日程”返回原始 JSON,模型反而容易提取字段信息。
第二个关键点是编排顺序不能写死。模型需要有一定的自由裁量权,它可以根据上下文判断先调用哪个、跳过哪个。所以技能描述里不要写“必须先调用 A 再调用 B”,而是让模型基于当前上下文自行判断。实测下来,给模型自由裁量权,比强制编排一个固定 DAG 的容错性高很多。因为真实用户输入千变万化,固定编排很容易在某个环节卡住。
4. 常见问题与排查技巧实录
4.1 技能存在,但模型一直不调用
这是最让人头疼的问题。你辛辛苦苦写了技能包,注册也成功了,日志里能看到模型收到的技能列表里有它,但模型就是不用。我排查过很多次,最后归纳为三个主要原因。
第一个原因是描述层触发条件写得模糊。模型的判断依据是描述文本和用户问题的语义匹配度。描述里写“Extract web content”,用户说“帮我总结一下这个链接里讲了什么”,虽然语义上相关,但模型可能认为“总结”和“抽取”不是一件事。改成“Use when the user provides a URL and asks to summarize, collect, or save the page content”之后,触发率明显上升。
| 现象 | 首要排查点 | 次要排查点 |
|---|---|---|
| 技能列表里有,模型不调用 | 描述里有“应当触发”的条件 | 与其他技能描述高度重合 |
| 调用率低,时灵时不灵 | 描述中的触发词覆盖不全 | 模型上下文太长,技能列表被截断 |
| 调用报错,参数明显不合理 | 参数 Schema 有歧义 | 描述和参数没有示例 |
第二个原因是多个技能之间的描述重叠。我一开始在系统里同时挂了一个“网页正文抽取”技能和一个“网页结构化抽取”技能,两个描述里都有“URL”“提取”等关键词。模型每次选哪个基本靠猜。解决方法是明确划定边界:正文抽取侧重“总结、引用全文”,结构化抽取侧重“提取产品名、价格等信息”。边界清晰之后,模型的选择就稳定了。
第三个原因是技能数量太多。当技能列表超过二十个,模型在有限的上下文窗口里需要处理大量不相关的描述信息,决策质量会下降,甚至可能忘记后面那些技能的存在。解决方法是分组管理,先让模型通过一个“路由器”技能决定去哪一组技能里找。这一步是后面要讲的技能库设计的基础。
4.2 参数解析错乱:Expected object but got string
模型在调用技能时,偶尔会把参数格式搞错。最常见的一个报错是“Expected object but got string”。日志显示模型传了一个 JSON 字符串,而不是 JSON 对象。这种情况通常是因为模型把参数用 Markdown 代码块包裹起来了,或者把整个参数体当成了字符串字段。
应对这个问题,我从两个方向解决。
第一个方向是给模型更多示例。在参数 Schema 的 description 字段里,我并不只写字段含义,而是加上“示例值”。例如:
"url": { "type": "string", "description": "Full URL of the web page. Example: https://example.com/article?id=123" }模型看到示例,往往会更规范地填参数。
第二个方向是写一个容错解析器。在框架层统一处理模型传参时的常见问题:检查参数是不是被 Markdown 代码块包裹,有的话先剥掉;检查参数是字符串还是对象,是字符串就尝试二次 JSON.parse;遇到字段名不一致,就做一层映射。这个容错层不复杂,但能实打实减少不少线上报错。
4.3 运行环境与安全边界
技能包本质上是让模型触发一段代码执行,这就意味着外部输入会直接进入你的执行环境。我在这方面吃过一次亏:当时写了一个“下载并解析文件”的技能,没有限制参数里的 URL 域,结果模型根据用户输入请求了一个内网地址,幸好当时环境隔离做得好,没有造成实际影响。从那之后,我严格遵守几条安全底线。
第一,所有外部输入参数都要做校验。URL 只允许 http/https 协议,路径参数必须规范化,禁止包含“..”等特殊符号。第二,技能包运行环境要隔离,不要直接在主进程里跑未知代码或抓取逻辑。第三,日志不要打印完整的外部参数,尤其是 URL、Token、密钥这些信息,脱敏之后再记录。第四,给技能设置超时和重试次数,防止一个慢请求拖垮整个智能体。
5. 技能包的扩展方向与工程化沉淀
5.1 从单个技能到技能库
单个技能能稳定运行之后,下一步是把它放进一个可维护的技能库体系里。这个阶段要考虑的不再是“怎么写好一个技能”,而是“怎么管好一百个技能”。
我用的管理方法有三个原则。
第一是命名规范。每个技能包的命名要能看出功能边界,比如 extract_web_page、query_calendar、generate_meeting_notes,避免用 ambiguous 的名称比如 utils、helper。第二是版本管理。技能包也要有版本号,改动后要更新描述、记录变更点。模型调用某个技能时,日志里要能追溯到具体版本。第三是白名单机制。不同的智能体应用加载不同的技能集合。比如面向市场调研的智能体只加载竞品分析和行业信息相关技能,不加载会议纪要技能。这样可以减少模型的选择负担,从源头避免技能间冲突。
5.2 回归评估与观测
技能包上线之后,没有观测就等于摸黑运行。我建议维护一份标准回归样本集,每个技能准备三到五条典型的输入问题,比如“抽取这个链接的产品卖点”“总结这个页面内容”。每次改完代码后,跑一遍回归集,对比调用成功率、返回结果是否符合预期。
观测指标方面,我重点关注四个:
- 调用成功率:模型成功触发技能并拿到 success 返回的比例。
- 参数校验失败率:模型传参不合法导致执行失败的比例。
- 单次调用耗时:从模型发起调用到返回结果的时长。
- 返回数据质量:抽取结果的完整性、字段非空率。
这些指标不一定要做得很复杂,先记录到一个日志表里,每周扫一眼,就能发现问题。比如某个技能的调用成功率突然从 90% 掉到 60%,大概率是最近改过描述或参数 Schema 导致的回归,回滚到上一个版本就行。
5.3 多技能协作中的数据流设计
最后聊一个进阶话题:多个技能协作时,数据怎么在技能之间流动。
最基础的做法是让模型当“数据搬运工”。模型把技能 A 的返回结果读进上下文,再填进技能 B 的调用参数里。这个方式简单,但有两个问题:一是模型搬运长文本容易出错,二是中间结果占用大量上下文窗口,影响模型整体表现。
更工程化的做法是定义一个统一的中间数据格式。比如会议纪要技能的输出,统一成一个“事件 + 时间 + 负责人 + 待办事项”的结构;日历查询技能的输出也统一成同样的结构。两个技能只要都遵守同一个结构,模型在中间环节只需要做字段映射,不需要理解两种完全不同的输出结构。我做日程管理智能体时,把会议纪要的待办和日历查询的事件格式统一之后,整个编排流程一下子清爽了很多。
再进一步,还可以给技能加“降级策略”。比如“网页正文抽取”技能超时了,就自动降级到“链接摘要生成”技能,用模型直接读取链接内容生成摘要。用户无感知,但任务没断。这种降级逻辑需要事先定义好主备关系,并且备选技能的返回格式要和主技能保持一致。
我自己踩过几次坑之后的体会是:技能包设计不要一上来就追求“大而全”。先把一个边界清晰的小功能做成标准技能包,跑通一轮完整的调用链路,再慢慢往外扩展。这个过程中最有价值的部分,不是代码本身,而是你对自己功能边界的理解。每分出一个技能包,其实都是在逼自己回答一个问题:这件事最核心、最稳定、最值得复用的部分到底是什么。
后续还可以往更有意思的方向探索,比如给技能包增加缓存层、支持结果复用、用 DAG 编排代替模型自由调度。不过那些就是另一个话题了。先把手里这一个技能包做好、调顺、沉淀下来,你会发现在智能体工程化这条路上,这一步迈得比想象中更关键。