1. 为什么我会把agent的技能单独抽出来做一套库
1.1 agent开发最容易陷入的泥潭
做agent的朋友应该都有过这种体验:刚开始搭智能体的时候特别兴奋,感觉只要给模型一个系统提示词,再挂一堆工具函数,它就能什么都能干。但真正跑起来之后,第一个星期还算顺利,第二个星期开始就变味了。工具函数越写越多,提示词越改越长,用户提一个“帮我总结这个网页”,你要分辨他到底是想要全文摘要、要点列表、还是提取某几个字段;提一个“把这份文档整理成日报”,你要同时处理文件解析、信息抽取、格式模板、敏感词过滤一大堆逻辑。最后主线代码被各种if else塞满,每加一个新能力就动一次老代码,稍微多一点功能就开始互相打架。
我最初也经历过这个阶段。某个跨平台系统的演示Demo里挂了二十多个函数,从网络请求到本地文件处理全塞在一个入口文件里。模型只要选错一个参数格式,整个流程就崩。后来我把所有函数重新按“技能”的维度整理了一遍,事情才慢慢变得可控。这个项目的代号就叫agent-skills,本质上是一套把agent会做的一件事抽取成独立模块、通过统一注册和调度机制交给大模型选择的技能管理方案。
1.2 技能和工具到底有什么区别
很多人会把skill和tool混着叫,但实际做下来,两者的粒度不一样。工具更多是单一原子操作,比如“读文件”“发请求”“查数据库”,而技能是围绕一个完整目标的组合行为。举个例子,“下载网页正文”是工具,“对网页内容生成一份结构化会议纪要”是技能。技能内部可以调用多个工具,也可以直接调用模型完成推理,还可以套一层后处理逻辑。
抽技能这件事解决的核心问题,是把“模型意图”和“系统实现”之间的缝隙变窄。你不用再让大模型自己去猜测调用流程的每一步,只需要让它在技能列表里选一个合适的技能、给出正确的输入参数。这很像给一个实习生一份更细的岗位说明书:不是告诉他“你来处理这个客户的投诉”,而是告诉他“遇到投诉时,先调客户资料,再读取投诉分类表,最后按对应模板生成回复”。对模型来说,技能就是一套带有输入输出契约和行为描述的最小任务单元。
2. agent-skills的技能格式设计:让每个技能都能被模型读懂
2.1 技能描述的信息结构
在agent-skills里,一个技能不是只写一个函数名就算完。它至少需要包含五类信息:技能名称、适用场景描述、输入参数定义、输出结果定义、执行逻辑。其中“适用场景描述”是给大模型看的关键,模型靠这段描述决定什么时候调用这个技能,所以不能像写接口文档那样只写参数,还要写清楚“在什么情况下用、能达到什么效果、有哪些边界条件”。
我习惯给每个技能准备一个类似这样的定义模板:
name: summarize_url description: > 当用户需要提取网页内容并生成要点总结时使用。 适用于新闻文章、博客、产品说明页等公开网页。 如果网页需要登录或内容为PDF,请先调用下载类技能完成文件获取。 input_schema: url: type: string description: 目标网页的完整链接 required: true max_points: type: integer description: 最多输出几个要点,默认为5,范围1-20 default: 5 output_schema: title: type: string description: 网页标题 points: type: array items: string description: 按优先级排序的核心要点 execution: type: python entry: skills/summarize_url.py这套结构最重要的三个字段,一个是description,一个是input_schema,一个是execution。description决定模型会不会选它,input_schema决定模型能不能把参数传对,execution决定真实运行时能不能稳定执行。三个字段如果有一个偷懒,后面排查问题都得加倍还债。
2.2 参数Schema为什么要用结构化定义
如果你做过function calling类的项目,应该能体会参数Schema的重要性。LLM在生成调用时,会根据字段名和字段描述来填参数。如果你只写“url: str”,模型可能把用户说的话整段填进去,或者把多余的修饰词也带上。但你要是写清楚“目标网页的完整链接,需要包含协议头,例如https://example.com/article,不要包含其他说明文字”,模型出错率会明显下降。
我用的输入描述格式基本脱胎于JSON Schema,但实际落地时可以简化,不必所有字段都搞成那么复杂的嵌套结构。common里我通常分为字符串、整数、布尔、数组、对象五种基础类型。数组里的元素类型要写清楚,比如“要点列表,每个要点为字符串,一句话描述一个事实”。对象类型要给出嵌套字段示例。只要字段描述里把“单位、格式、边界、示例”四件事说清楚,模型基本能稳定输出。
比如说,一个“生成日报”技能,输入参数可能有:
today_events:今日事务清单,数组,每个元素包含time、content、project三个字段template_style:日报风格,字符串,可选值为concise、detail、bulletinclude_metrics:是否包含量化指标,布尔值,默认false
这样模型在调用时就会按这个结构去整理用户输入,不会把“就按简洁一点来”这种口语指令塞进template_style里,而是会把它映射成concise。
2.3 执行逻辑的几种承载方式
执行逻辑是技能真正跑起来的那部分。git的项目里,技能的执行逻辑用了几种不同承载方式,我建议按实际复杂度来选,不必一上来就强行容器化。
第一类是纯模型技能。技能内部不需要额外代码,只需要把输入整理成一段固定格式的提示词,然后交给主模型推理。适合做文本改写、摘要、分类这种认知型任务。第二类是脚本技能。技能内部调用一段Python、Node或者Shell脚本,适合做计算、文件处理、数据清洗。第三类是组合技能。技能内部再调用其他技能,相当于把多个子技能串成一条流水线,适合做跨步骤任务。
每种方式都有自己的适用场景。纯模型技能最容易维护,但没法保证输出稳定性;脚本技能执行速度快、可测试性强,但需要额外处理依赖;组合技能最灵活,但链路变长之后要格外注意中间结果格式,不然一步错步步错。
3. 核心实现:技能注册表、调度器和执行器
3.1 用装饰器实现技能注册表
agent-skills最核心的代码其实不多。一套简单的技能注册表加调度器只有两百行左右,却足够支撑大部分业务场景。注册表负责把技能元数据和执行函数绑定起来;调度器负责接收大模型返回的意图,找到对应技能并执行;执行器负责管理运行环境、超时和异常。
我用Python实现了一个简化版本。先定义一个BaseSkill基类,让开发者按统一格式写技能:
# skill_base.py from typing import Any, Optional from dataclasses import dataclass, field @dataclass class SkillResult: success: bool data: Optional[Any] = None error: Optional[str] = None class BaseSkill: name: str = "" description: str = "" input_schema: dict = field(default_factory=dict) output_schema: dict = field(default_factory=dict) def validate_input(self, params: dict) -> tuple[bool, str]: required_fields = [ key for key, val in self.input_schema.items() if val.get("required", False) ] missing = [key for key in required_fields if key not in params] if missing: return False, f"缺少必填参数: {', '.join(missing)}" return True, "ok" def execute(self, params: dict) -> SkillResult: raise NotImplementedError然后写一个全局注册器,用一个字典把技能名映射到技能对象。为了让注册过程更直观,我支持了两种方式:手动实例化和装饰器自动注册。
# registry.py from typing import Dict, Type from skill_base import BaseSkill SKILL_REGISTRY: Dict[str, BaseSkill] = {} def register_skill(skill_cls: Type[BaseSkill]): instance = skill_cls() if not instance.name: raise ValueError("技能名称不能为空") if instance.name in SKILL_REGISTRY: raise ValueError(f"技能名称冲突: {instance.name}") SKILL_REGISTRY[instance.name] = instance return skill_cls def get_all_skill_descriptions() -> list[dict]: descriptions = [] for skill in SKILL_REGISTRY.values(): descriptions.append({ "name": skill.name, "description": skill.description, "input_schema": skill.input_schema, "output_schema": skill.output_schema, }) return descriptions def get_skill(name: str) -> BaseSkill: if name not in SKILL_REGISTRY: raise KeyError(f"未找到技能: {name}") return SKILL_REGISTRY[name]注册器里做了两层防线:技能名不能为空,技能名不能重复。看起来像是无用功,但实际项目里经常会出现两个同事同时往库里加同名技能的情况,没有这层检查,到时候调度时会指向错误实现,排查半天都找不到原因。
3.2 调度器如何跟大模型协作
调度器的作用,是拿到大模型返回的技能调用请求后,去注册表里找到技能并执行。多数大模型平台都有function calling机制,会把模型结果以结构化JSON返回。agent-skills的调度器会解析这段JSON,拿到skill_name和params,然后做四件事:
- 校验技能名是否存在
- 校验参数是否满足输入Schema
- 调用技能执行函数
- 把执行结果封装成统一格式返回给模型
# dispatcher.py from registry import get_skill, get_all_skill_descriptions from skill_base import SkillResult def dispatch(skill_name: str, params: dict) -> SkillResult: try: skill = get_skill(skill_name) except KeyError as exc: return SkillResult(success=False, error=str(exc)) ok, msg = skill.validate_input(params) if not ok: return SkillResult(success=False, error=msg) try: return skill.execute(params) except Exception as exc: return SkillResult(success=False, error=f"执行异常: {exc}")在与大模型交互时,需要把get_all_skill_descriptions()返回的技能列表拼进系统提示词或者工具定义列表里。我选择的方式是走function calling的tools参数,因为这样模型返回的格式最稳定,不用自己写解析逻辑。
系统提示词里可以加一句原则:
你是技能调度助手。当用户提出任务时,先从技能列表中判断是否存在合适技能;若存在,选择最匹配的一个或多个技能。参数必须严格按照技能输入描述生成,不要擅自增加字段,不要省略必填字段。
这句提示词其实是一个软约束。模型是否遵守,很大程度取决于技能描述写得好不好。如果某个字段的描述含混,它就会开始自由发挥。因此我在每个技能输入描述里都会显式注明“从用户输入中提取关键信息,不要生成原文中没有的内容”。
3.3 技能执行超时和错误码设计
agent技能执行跟普通函数调用最大的区别在于,模型对超时和错误是没耐心的。如果一个技能执行了三十秒还没返回,再次运行或改用其他技能的代价可能比等待更高。因此我给所有外部调用类技能都加了统一的超时控制,一般网络请求类设15秒,本地脚本类设10秒,组合技能设30秒。
同时,错误返回格式要固定。就像HTTP状态码一样,我会把错误信息分成几个类别:
| 错误类型 | 示例 | 处理建议 |
|---|---|---|
| 参数错误 | 缺少必填参数、类型不符 | 让模型重新按Schema提取参数,并返回具体缺失项 |
| 执行超时 | 网络请求超过15秒 | 返回“建议用户稍后重试或换一个输入” |
| 资源缺失 | 文件不存在、目录不可写 | 返回精确路径,提示模型调用其他技能处理 |
| 逻辑异常 | 中间步骤返回格式不符 | 记录日志,返回“技能内部错误,换一个方案” |
这样调度器把错误返回给模型之后,模型就能根据错误内容自行决定下一步是修正参数重试,还是换技能,或者是向用户解释需要补充哪些信息。省去了主流程里大量手写异常分支的功夫。
4. 用agent-skills搭一个多技能工作台
4.1 场景设定与技能拆分
下面用一个实际场景来走通完整流程。我要给某公司内部做一个“日常办公助手”,需要让助手处理四类任务:网页摘要、日报生成、文字润色、会议纪要整理。这是典型的技能化场景,每类任务边界足够清晰,相互之间没有强依赖。
在开始写任何代码之前,先拆技能清单。
| 技能名 | 适用任务 | 执行方式 |
|---|---|---|
| summarize_url | 网页内容摘要 | 脚本+模型 |
| generate_daily_report | 生成日报 | 模型 |
| polish_text | 对文字进行润色改写 | 模型 |
| meeting_minutes | 整理会议记录为结构化纪要 | 模型 |
这四个技能互不重叠,调度器可以根据用户指令准确选择。但实际使用时用户经常会把任务混在一起,例如“帮我看一下这个链接,然后把要点写进今天的日报里”。这种场景靠单一技能没法完成,需要模型依次调用两个技能。function calling天然支持多轮调用,所以调度器会先执行summarize_url拿到结构化结果,再把这个结果传给generate_daily_report。
4.2 实现第一个技能:网页摘要
网页摘要技能比较典型,因为它包含了输入校验、外部资源处理和结果格式化三层逻辑。技能脚本的核心流程是:先获取网页正文,然后用模型提取指定数量的核心要点,最后返回标题和要点列表。
# skills/summarize_url.py from skill_base import BaseSkill, SkillResult class SummarizeUrlSkill(BaseSkill): name = "summarize_url" description = ( "当用户需要提取网页内容并生成要点总结时使用。" "适用于新闻文章、博客、产品说明页等公开网页。" "如果网页需要登录或内容为PDF,请先调用下载类技能完成文件获取。" ) input_schema = { "url": { "type": "string", "description": "目标网页的完整链接,需要包含协议头,例如https://example.com/article", "required": True, }, "max_points": { "type": "integer", "description": "最多输出几个要点,默认为5,范围1-20", "required": False, }, } output_schema = { "title": {"type": "string", "description": "网页标题"}, "points": {"type": "array", "items": "string", "description": "按优先级排序的核心要点"}, } def execute(self, params: dict) -> SkillResult: url = params["url"] max_points = params.get("max_points", 5) # 这里省略实际网页抓取和模型调用细节 normalized = self._normalize_url(url) content = self._fetch_public_page(normalized) points = self._summarize_with_model(content, max_points) return SkillResult(success=True, data={"title": points.get("title"), "points": points.get("points")}) def _normalize_url(self, url: str) -> str: if not url.startswith(("http://", "https://")): raise ValueError("网址必须以http://或https://开头") return url.strip()这里最重要的点是,技能内部不要直接把从网络上拿到的原始数据返回给模型,最好先做一次白名单过滤和去重。不然模型可能会把广告文案、版权声明、页面脚注都当成要点。我在真实项目里加了一个简单规则:每一句话少于五个字且不含数字、不含人名的不作为要点;重复语义的要点合并,只保留信息量更大的那一条。
4.3 注册技能并接入大模型
技能写完之后,在统一入口文件里注册即可。
# main.py from registry import register_skill, get_all_skill_descriptions from dispatcher import dispatch from skills.summarize_url import SummarizeUrlSkill from skills.generate_daily_report import GenerateDailyReportSkill from skills.polish_text import PolishTextSkill from skills.meeting_minutes import MeetingMinutesSkill register_skill(SummarizeUrlSkill) register_skill(GenerateDailyReportSkill) register_skill(PolishTextSkill) register_skill(MeetingMinutesSkill) # 将技能描述传给大模型 skill_descriptions = get_all_skill_descriptions()接入大模型时,我会把技能描述映射成对应的工具定义。结构通常是type、function、parameters三个层级。
{ "type": "function", "function": { "name": "summarize_url", "description": "当用户需要提取网页内容并生成要点总结时使用...", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "目标网页的完整链接..." }, "max_points": { "type": "integer", "description": "最多输出几个要点,默认为5..." } }, "required": ["url"] } } }这里有一个老手才会注意的细节:description里不要包含“可选参数”这类会误导模型的词。直接把required数组写清楚,模型对必填参数的理解会更准确。有些平台还会把工具名称截断或者自动加上前缀,所以技能名尽量控制在30个字符以内,不用复合词做名字,避免模型生成调用时记混。
4.4 一次完整的对话链路
当用户说“帮我把这篇博客整理成五个要点的摘要,然后润色一下摘要文字”,模型会先返回一个技能调用请求,指向summarize_url。调度器执行完网页摘要后,把结果返回给模型,模型再基于这条结果发起第二个技能调用polish_text。
两个技能串联的完整数据流如下:
- 用户输入 → 大模型判断意图
- 模型返回
summarize_url调用,参数url为用户提供的链接,max_points=5 - 调度器执行网页抓取和摘要,返回
title和points - 调度器把结果作为消息回传给模型
- 模型发现还需要润色文字,返回
polish_text调用,参数text为上一步的points - 调度器执行润色,返回最终结果
- 模型把最终结果组织成自然语言回复给用户
整个过程看起来简单,但实际处理和响应时间可能会超过二十秒。所以我建议在界面层面加上“技能执行中”的状态提示,否则用户很容易认为程序卡死了。多个技能串联时,每一步中间结果都要留痕,日志里记录好技能名、入参摘要、出参摘要、耗时,这样出问题的时候直接能定位到是哪一步。
5. 我在实际项目中踩过的常见坑和排查办法
5.1 技能描述写得模棱两可
这个问题出现的频率最高。比如某个技能名叫process_data,描述写的是“处理用户上传的数据”。听起来没毛病,但模型根本不知道“处理”到底指什么,是清洗、转换、统计还是画图?结果就是用户说“帮我把这堆数据处理成表格”,模型可能连续试错好几个技能,或者干脆自己猜测参数。
我后来把描述改成“当用户上传CSV或Excel文件,且希望进行数据清洗、去重、补全空值时使用。若用户希望生成可视化图表,请调用另一个技能”,准确率立刻提上来。所以技能描述必须包含三个要素:使用场景、输入内容、输出结果。不满足其中一个,就说明这个技能设计得还不够清晰。
5.2 参数Schema和实际执行不匹配
开发时最容易出现的情况是,注册表里定义的输入参数和技能执行函数里实际使用的字段不一致。比如Schema里写了file_path,执行函数里却读取的是filename,模型照Schema传参,结果执行器拿不到值。
这类问题的原因通常是多人协作时改过字段名,没有同步更新Schema。解决办法也很粗暴:在测试阶段做一次全量技能自检,遍历注册表里每一个技能,给每个字段生成模拟值,跑一次正常流程。哪怕只是假数据,也能暴露大量低级的字段名错误。
5.3 模型生成技能参数时“过度自信”
有时模型明明没有拿到用户给的某个信息,却还是编了一个值填进参数里。比如用户只提供网页链接,没有提到要点数量,模型却自动填了max_points=8。这不算严重问题,但会导致结果不符合预期。
我在提示词里加了一条禁止性规则:“如果参数在用户输入中不存在且没有默认值,不要猜测,请向用户询问。”同时把可推断的字段都设上合理的默认值,降低模型自由发挥的空间。实在没办法确认的参数,就让模型给用户回一个问题,而不是低头硬跑。
5.4 技能数量膨胀后选择准确率下降
当技能库里超过20个技能时,模型的选择准确率会开始下滑。尤其是描述里用了相似的词组,比如“生成摘要”和“生成总结”,模型可能会混淆。我的做法是设计一个技能分类索引,按领域前缀命名,比如web_summarize_url、office_daily_report、text_polish、meeting_minutes,让命名自带领域信息。
另一个办法是在技能列表里增加一层“技能分组”,把同一类的技能用一个分组描述代表。大模型先选分组,再进组内选技能,相当于把原先的一百选一问题拆成十个十选一问题。这个思路类似数据库索引,短期技能数量不多时不明显,数量上来之后效果很显著。
5.5 执行超时导致模型误判
外部调用类技能最害怕网络请求超时。如果超时后直接抛出异常,模型可能会认为这个技能不可用,从而选择另一个完全不合适的技能硬凑。我的处理方式是,超时后返回一个标准错误结构,同时附上“该问题与环境相关,请建议用户稍后重试或换一个链接”,让模型能够原样传达给用户,而不是自行更换技能掩盖问题。
还有一个容易忽略的点:技能执行期间如果用户又发来一条新消息,容易造成状态错乱。我在调度器里加了请求级唯一ID,把用户消息、中间结果、技能执行记录全部串在这个ID下。后续无论排查日志还是恢复现场,都方便很多。
6. 我把这套方案继续扩展的方向
agent-skills在我这边已经从小项目长成了日常工作台的基础层。我试过把技能库部署成一个独立服务,让多个agent共享同一份技能定义;也试过给技能增加版本号,每次修改都能追踪行为变化;还试过把技能执行日志输出成结构化数据,用来自动标注技能的置信度,后续让调度器优先选择历史上成功率更高的技能。
其中最有价值的是给技能加“capability fingerprint”,也就是能力指纹。每个技能除了描述做什么,还会记录它对输入数据的假设,比如“这个技能假设输入文本长度不超过8000字”“这个技能需要网络环境”“这个技能不处理二进制文件”。这些假设不会直接暴露给模型,但会用于调度时的前置过滤,避免模型选到根本不可能成功的技能。等于在模型之上加了一层规则兜底。
如果你的agent项目也开始出现工具混乱、提示词膨胀、维护吃力的问题,不妨试试把每个能力抽成独立技能,用统一Schema管理起来。先从小场景开始,不要试图一次设计几十个技能,保持每个技能小而明确。等你尝到“改一个技能不影响另一个技能”的甜头,就会明白这套做法的价值了。