“agent-skills”这个名字第一次见到时,很多人以为它只是个普通的工具函数集合,实际上它代表的是AI Agent开发里最关键的架构命题——把Agent的能力拆成一组可注册、可发现、可调用的技能。我最近在做基于大模型的自动化助手项目时,把Agent的能力层从单体prompt彻底重构为skill化架构,核心就是围绕这样一个agent-skills技能库来组织的。这篇文章直接用项目里的真实设计与代码,把技能注册、描述、调用、测试、排障完整梳理一遍,给正在做Agent应用的工程师一个能直接参考的方案。
适合谁看:想摆脱“一个Agent啥都干但啥都干不好”困境的开发者;打算把业务能力沉淀为可复用技能的产品团队;还有所有被模型选错工具、上下文爆掉折磨过的同学。
1. 为什么一定要给Agent做“技能化”改造
1.1 单体Agent模式的三宗罪
我最早做Agent时,习惯把所有能力塞进一个大prompt里:系统提示词写了两千字,塞了十几个工具的说明,然后指望着模型自己判断什么时候调哪个工具。第一个月还能跑,后面随着业务增加,问题集中爆发:
- 提示词上下文被工具说明占掉一大半,真正给模型思考的空间越来越小,回复质量肉眼可见地下降。
- 工具之间职责重叠,模型经常选错:要查订单的调了物流查询,要做数据分析的调了报表生成。
- 任何一个工具升级或修改,整个prompt都要重新调,回归成本高得吓人。
这个阶段很像一个人同时挂十几个科室的号,每个医生都觉得自己是总负责人,最后没人真正把事情做完。顺着这个思路,我后来把所有能力从prompt里拆了出来,做成独立的技能模块,由Agent在每次任务时动态选择——这个模块集合,就是agent-skills。
1.2 Skills、Tools、Workflow,谁的粒度更合适
做技能化之前,得先理清三个概念,不然拆出来的东西会不伦不类。
- Tool(工具):最小的可执行单元,比如“调用搜索接口”“读一个文件”“发送一封邮件”。它没有业务语义,只负责把动作做完。
- Workflow(工作流):固定顺序的流程编排,比如“先查库存→再算价格→最后下单”。它适合那些步骤明确、不许模型自由发挥的场景。
- Skill(技能):介于两者之间。它是一组相关工具加上执行策略的封装,对外暴露的是一个带意图描述的能力,例如“整理会议纪要”这个技能,内部可能是:转写文本→分段→提取待办→生成模板。模型只需要决定“现在应不应该调用整理会议纪要”,至于内部怎么一步步做,由技能自己保证。
用生活化的说法:Tool是一颗螺丝钉,Workflow是一条流水线,Skill是一个标准作业包。Agent面对任务时,先想的是“这活儿该派给哪个作业包”,而不是“我要拧哪颗螺丝”。
1.3 agent-skills项目的定位
agent-skills在我的设计里不是一个框架,而是一个技能库项目,承载三个职责:
- 技能的注册与统一描述,让Agent知道有什么可用。
- 技能的调用协议与生命周期管理,让Agent用起来不出错。
- 技能的测试与质量保障,让技能长期可维护。
如果拿人体做比喻,大模型是大脑,agent-skills是神经系统,每个技能是肌肉记忆。大脑不需要理解肌肉如何收缩,它只需要知道“抬左手”这个意图对应哪个反射弧。
2. 核心设计:注册表、描述元数据、调用协议
2.1 技能注册表:先解决“有什么可用”的问题
Agent要选择技能,前提是它知道有哪些技能。我的做法是在项目里维护一个全局技能注册表(Registry),所有技能在启动时注册进去,形成一份技能清单,这份清单经过压缩后放进Agent系统提示词里。
注册表的数据结构大致是这样:
from dataclasses import dataclass, field @dataclass class SkillRegistration: name: str description: str input_schema: dict output_schema: dict version: str tags: list[str] = field(default_factory=list)核心字段我解释一下:
- name:技能唯一标识,采用kebab-case写法,比如extract-action-items。名字要短,方便模型在输出中准确引用。
- description:最重要的字段,直接决定模型选得准不准,后面单独讲。
- input_schema / output_schema:用JSON Schema描述入参和出参,模型看到后才知道“调用前要提供哪些信息”。
- version:技能自己的版本号,和Agent主版本解耦。
注册时机我选择启动时静态扫描,而不是运行时动态注册。原因很简单:运行中注册的技能可能没经过测试就上线,一旦出问题,很难追溯是哪一轮会话引入的。静态扫描的好处是每次发版,技能清单是确定的,可以跑一遍全量校验。
2.2 description怎么写,决定模型选得准不准
我踩过最大的坑,就是description写得像API文档,满篇都是“本函数用于获取物流信息并返回实时状态”。模型看到这类描述,经常在多个相似技能之间犹豫,最后选错。
经过反复实验,我发现一个好的技能描述要满足三点:
- 写意图,不写实现。比如“当用户想知道快递到哪里了,使用此技能查询物流轨迹”,而不是“此技能调用物流查询接口,返回轨迹数组”。
- 写清楚触发条件和不触发条件。把“什么时候不要用”写进去,能显著减少误调用。
- 给一个示例输入。模型对示例的理解远比对抽象描述的理解稳定。
我整理的描述模板:
当[用户目标场景]时,使用此技能[完成的事情]。 输入要求: - 必须提供[字段1]。 - 可选提供[字段2],不提供时默认[行为]。 不要使用此技能,当[排除场景]。 示例:用户说"[一句话]",应调用此技能并设置参数[参数示例]。同一个技能,用旧写法和新写法在测试集上对比,选对率从71%提升到93%。这个提升幅度比换一个更大的模型还明显,所以别急着花钱,先把描述写对。
2.3 统一的调用协议:所有技能一个入口
技能对外暴露统一的execute接口,Agent侧不需要知道每个技能内部怎么实现。
from abc import ABC, abstractmethod from dataclasses import dataclass @dataclass class SkillContext: session_id: str user_id: str current_time: str intermediate_data: dict = None @dataclass class SkillResult: status: str # success / failure / partial data: dict error: str | None = None class BaseSkill(ABC): name: str = "" description: str = "" input_schema: dict = {} output_schema: dict = {} @abstractmethod async def execute(self, params: dict, context: SkillContext) -> SkillResult: ...SkillContext里放的是调用时需要的基础信息:会话ID、用户ID、当前时间、已经拿到的中间数据。为什么不能由技能自己从全局拿?因为同一个技能可能被多个会话并发调用,全局状态会串数据。
SkillResult里特意加了一个partial状态,表示“部分成功”。比如整理会议纪实时,转写模块断了,但已有的分段结果可以先用,这种情况返回partial比直接失败强。模型拿到partial后会知道“技能尽力了,结果不完整”,从而决定是继续追问还是用已有结果兜底。
3. 实操落地:从空目录到一个可用的agent-skills
3.1 工程目录怎么组织
一个技能一个目录,每个技能自包含。目录结构:
agent-skills/ ├── registry.py ├── base.py ├── skills/ │ ├── meeting_minutes/ │ │ ├── __init__.py │ │ ├── skill.py │ │ └── templates/ │ ├── extract_action_items/ │ │ ├── __init__.py │ │ └── skill.py │ └── code_review/ │ ├── __init__.py │ └── skill.py └── tests/每个技能目录下有自己的实现和资源,不跨技能共享内部模块。公共的通用能力(比如HTTP客户端、日志、鉴权)放在最外层shared里,技能之间不许直接互相import。这个约束一开始觉得麻烦,后来发现它保证了技能可以独立测试、独立发布、独立废弃,维护成本和单体prompt完全不是一个量级。
3.2 基类与注册装饰器
我提供两个东西给技能作者:BaseSkill基类和一个register装饰器。
# registry.py from typing import Type _REGISTRY: dict[str, Type[BaseSkill]] = {} def register(skill_cls: Type[BaseSkill]) -> Type[BaseSkill]: name = skill_cls.name if not name: raise ValueError(f"技能{skill_cls.__name__}缺少name") if name in _REGISTRY: raise ValueError(f"技能{name}重复注册") _REGISTRY[name] = skill_cls return skill_cls def get_all_skills() -> list[Type[BaseSkill]]: return list(_REGISTRY.values()) def get_skill(name: str) -> Type[BaseSkill]: if name not in _REGISTRY: raise KeyError(f"技能{name}未注册") return _REGISTRY[name]技能作者需要做的,就是继承BaseSkill、填name和description、实现execute,然后加一行@register。因为这个流程足够简单,团队里非核心的同事也能快速贡献新技能,技能库从5个涨到30多个,基本没花额外的培训成本。
3.3 手写一个“会议纪要整理”技能
我拿项目里最常用的“会议纪要整理”技能当例子,完整走一遍。
先定义入参。这个技能要接收两个输入:会议转写文本raw_text和会议主题topic(可选)。
from .base import BaseSkill, SkillContext, SkillResult @register class MeetingMinutesSkill(BaseSkill): name = "meeting-minutes" description = """当用户提供了会议转写文本,需要把杂乱的口语内容整理成结构化纪要和待办事项时,使用此技能。 输入要求: - 必须提供raw_text,即会议的原始转写文字。 - 可选提供topic,不提供时根据内容自动归纳会议主题。 不要使用此技能,当用户只是想了解会议结论而非整理纪要时。 示例:用户说"帮我整理一下刚才那场会的纪要",应将raw_text设为对话记录,topic留空。""" input_schema = { "type": "object", "properties": { "raw_text": {"type": "string", "minLength": 1}, "topic": {"type": "string"} }, "required": ["raw_text"] } output_schema = { "type": "object", "properties": { "summary": {"type": "string"}, "action_items": {"type": "array", "items": {"type": "string"}}, "decisions": {"type": "array", "items": {"type": "string"}} } } async def execute(self, params: dict, context: SkillContext) -> SkillResult: raw_text = params["raw_text"] topic = params.get("topic", "") segments = self._split_into_segments(raw_text) extracted = [] for seg in segments: result = await self._llm_extract_segment(seg, topic) extracted.append(result) merged = self._merge_extracted(extracted) return SkillResult(status="success", data=merged)execute实现分三步:分段、抽取、结构化。我不直接拿整段文本丢给模型,而是先按语义切块,每个块单独送模型处理,最后合并。这样做的原因是,会议转写经常超过模型单次输入上限,而且分段处理让每一块的指令更聚焦,抽取质量明显更高。
这里有个细节:split_into_segments不要太智能,按标点符号和段落硬切就行。我试过用模型做语义分段,效果是好一点,但每场会多花几十秒和不少token,对整体性价比不划算。分段粒度控制在每段200字左右,既能保证上下文充分,又不会让单次调用的token浪费。
3.4 Agent自动选技能的完整循环
技能库建好之后,Agent的运行循环大概是这样的:
- 启动时,把所有技能的描述清单压缩后写入系统提示词。
- 每轮用户输入到来,模型判断该用哪个技能,输出一个结构化调用意图。
- 系统校验参数,调用对应技能的execute。
- 技能结果返回给模型,模型结合结果生成最终回复。
- 如果模型判断需要连续调用多个技能,就循环执行2-4步。
步骤2里,我强烈建议让模型先输出意图,而不是直接输出技能调用结果。也就是说,模型先回答“我要用哪个技能、参数是什么”,系统确认技能存在、参数合法后,才真正执行。这么做的好处是,可以在执行前做一次参数安全校验,拦截掉明显不合理的调用。有一次我在测试时,模型把用户ID填进了会议纪要的raw_text,如果直接执行,后面所有处理都会跑偏。
意图选择的部分,可以让模型输出JSON:
{"skill": "meeting-minutes", "params": {"raw_text": "..."}}解析JSON比解析自然语言可靠得多。我见过有人让模型直接说“我要调用meeting-minutes”,然后用正则去匹配,这种方式在技能名多起来以后完全不可靠。
4. 实操中常见的问题与排查实录
4.1 模型老是选错技能怎么办
排查顺序很重要,我从实际经验里总结出三板斧:
- 先看description是不是写成了实现说明。把所有技能描述打印出来,自己以用户身份读一遍,凡是出现“调用”“返回”“接口”“参数类型”这类词的,大概率要重写。
- 看技能之间重叠度。如果两个技能解决的是同一个用户意图,就合并或者明确分工。我遇到过“总结文档”和“提取要点”两个技能,看起来不同,实际用户意图完全重叠,导致模型五五开乱选。
- 增加否定性描述。在description末尾明确写“当用户只是闲聊时,不要使用此技能”,能大幅降低误触发。
如果三板斧都没用,再考虑是不是技能名太抽象。技能名用动作化的词汇,比如generate-changelog、extract-action-items,比stats、helper这种名字好辨认得多。
4.2 技能描述把上下文撑爆了
技能多起来之后,把所有完整描述塞进系统提示词,很快就爆。我的做法是分两级:启动时只放“技能名+一行摘要”,完整描述等模型选出候选技能后再临时注入。实操下来,token占用少了约七成,选技能的准确率没有明显下降。
还有一个冷门技巧:把用户历史消息里的技能调用记录也放进上下文。模型看到一个技能之前被同类问题选中过,会倾向于继续选它,这相当于给了模型一个隐性的先验。但要注意,这个技巧只对同一用户同一会话有效,跨会话的历史反而会引入噪音。
4.3 技能内部异常导致整轮对话失败
技能内部调LLM,LLM偶尔超时或返回垃圾内容,如果直接抛异常,Agent整轮就废了。我的处理是在execute内部加兜底:
- LLM调用统一走一个带重试的客户端,最多重试2次,间隔按指数退避。
- 重试仍失败,返回status=failure和错误信息,而不是抛异常。
- 对解析模型输出做严格校验,解析失败时,把这个片段标记为unparsed,但整体的result仍然返回success,让模型自己决定怎么解释。
这背后的思路是:技能是给模型用的,模型需要的是“情况说明”而不是“程序异常”。只要技能能把“发生了什么”描述清楚,模型就能做出合理的下一步判断,比如换个方式提问、让用户补充信息、或者直接给出部分结果。
4.4 技能升级后的兼容问题
技能也要管版本,不然会出大事故。我遇到过的情况是:某个文本摘要技能换了底层的模型参数,结果同一份文档的摘要风格大变,用户以为系统坏了。
现在我的做法是:
- 每个技能在描述里带版本号,注册表里记录当前生效版本。
- 升级时先灰度,只对内部测试会话启用新版本。
- 技能的输出schema不能轻易改,要改就加新字段,老字段保留兼容。模型和上层业务都依赖输出结构,结构一变,连锁反应很难查。
4.5 把技能自检做成必备流程
最后分享一个我坚持了很久的习惯:每个技能合入之前,必须配一组测试用例,包括3个正常场景和2个边界场景。正常的场景用来验证“该调的技能能被正确调用”,边界场景用来验证“不该调的时候不误触”。我把这组用例挂在技能模块的tests目录下,每次发版跑一遍,一旦有技能描述改动导致误调用,立刻就能发现。
而且,这类测试不仅测代码,更要测描述。我会把技能的description喂给一个固定的大模型,然后问它“用户说X,你选不选这个技能”,把它当成一个分类模型来验证。这样能在不跑完整Agent流程的情况下,快速迭代description的质量。
5. 常见问题速查表
| 问题现象 | 可能原因 | 快速处理方案 |
|---|---|---|
| 模型在两个技能间反复横跳 | 技能意图重叠 | 合并技能或明确各自触发边界 |
| 用户闲聊时误调技能 | description缺少否定条件 | 补充“不要使用此技能,当……” |
| 上下文token很快打满 | 技能描述太长 | 改为两段式:摘要常驻、完整描述按需注入 |
| 技能返回结构不稳定 | 输出schema约束不足 | 严格定义output_schema并强制校验 |
| 新技能上线后老场景变差 | 注册表顺序或描述冲突 | 跑一遍技能分类测试,检查重叠度 |
| 并发会话数据串了 | 技能内使用全局变量 | 所有中间数据走SkillContext传递 |
6. 最后再聊两句体会
用agent-skills这套结构重构项目之后,最大的改变不是代码变少,而是团队的协作方式变了。以前改一个Agent功能,要动一大段prompt,所有人都小心翼翼的;现在改成加一个技能目录、写一个技能类、跑一遍测试,边界非常清楚。我个人觉得,技能化其实是把不可控的“模型行为”重新拉回“可控的工程”的手段。
如果你正在做Agent,不管你现在的Agent是单体prompt还是已经有工具调用,我都建议你花一个下午试试把能力拆成技能。先从两三个最常用的技能开始,把description按前面说的模板重写一遍,跑几轮真实对话对比一下。做完之后你再回头看,大概率不会再想回到“一个大prompt管所有事”的状态。
最后再补充一句:技能不是越多越好,20个高质量、边界清晰的技能,远胜60个互相重叠的碎片技能。定期清理技能库,和定期重构代码一样重要。这条经验,是我把技能数冲到60多个又被现实教训回30多个之后,才真正理解的。