做AI Agent开发的时间一长,你会发现最让人头疼的往往不是模型本身,而是“技能管理”这件事。我指的“技能”就是Agent能调用的那些函数,比如查订单、发邮件、导报表。这些函数一旦超过二十个,代码就开始失控:命名随意、参数时好时坏、不知道哪个Agent用了哪个版本,最糟糕的是调试起来像大海捞针。agent-skills这个项目就是我从这些坑里爬出来后,沉淀出来的一套轻量技能管理框架。它解决的问题很聚焦:如何让技能的注册、发现、调用、观测变得像搭积木一样清晰。这篇文章我会从设计动机、核心模型、实现链路、实战案例到踩坑记录,把整套思路完整讲一遍,希望对正在做Agent工具层的朋友有帮助。
1. Agent技能管理到底难在哪里:为什么我会动手写agent-skills
1.1 从一次智能体开发事故说起
去年夏天我负责一个客服Agent,功能范围不大:查订单、申请退款、改收货地址,外加一个售后留言。一开始非常顺利,每个能力就是一个函数,我把它们以JSON Schema的形式拼进System Prompt,让模型自己选。上线第一周就翻车了。
用户问:“我上周买的那个保温杯退款到哪了?”这本来是一个查询类请求,模型却先调用了查订单函数,拿到了订单号,紧接着又把同一个订单号塞进“申请退款”接口,系统在后台直接生成了一个新退款工单。用户只是想看进度,结果我们真的给他退了一次款。如果不是业务方及时拦截,这就是一次实打实的事故。
排查那天下班后,我打开tools.py,十个函数全挤在一个文件里,里面到处是try/except和临时加的if分支。真正让我后背发凉的,是整个文件没有任何“能力边界”:没有统一的入参规范,没有调用权限控制,也没有任何日志可以告诉我模型在每一轮到底选了哪个函数、传了什么值。那一刻我意识到,这个问题不能在函数堆叠层面解决,必须有更高一层的抽象。
1.2 技能不等同于函数,它应该是带完整契约的能力单元
很多Agent项目把“Agent技能”直接等于“Python函数”,这是我认为最大的误区。函数只描述“怎么执行”,但技能还需要回答另外三个问题:什么时候该被调用?调用需要什么约束?结果如何与Agent里的其他状态衔接?
举个例子。“查订单”这个能力,放在订单查询场景里不需要用户敏感信息,放在退款场景里就需要完整的退款详情。如果只是一个函数,你只能在函数内部加参数去判断“当前是哪个Agent在调用我”,这会让业务代码充满上下文判断。而把它提升为技能对象后,我可以定义两个技能实例:一个permission="read",一个permission="admin",分别注册到不同Agent的注册中心里,代码天然隔离,逻辑清晰。
所以在agent-skills里,技能是一个包含完整契约的数据结构:名称、描述、参数Schema、执行函数、依赖项、超时、权限等级。名称和描述决定模型如何理解“什么时候用我”,参数Schema决定输入约束,依赖项决定前置技能,超时和权限决定运行时边界。这样一来,“技能”就不再是隐性的代码,而是一等公民。
1.3 为什么不直接上LangChain,非要自己维护一套轻量机制
有人会问,LangChain、Semantic Kernel这些框架已经提供了工具注册和调用编排,为什么不直接用?我的答案是:看需求规模。如果你的Agent需要复杂的记忆、向量检索、多LLM提供商适配,那用完整框架是合理的。但如果只是想把一批业务函数接入LLM,重框架反而会成为束缚。
我当时需要的东西非常简单:技能的注册、查找、Schema导出、执行校验、基础编排。这些功能如果自己实现,不过几百行代码,但换成通用框架,我还要学习它的插件机制,处理它的版本升级,绕开它的默认行为。agent-skills不绑定任何LLM服务商,不预设对话管理策略,只做技能这一件事。你可以把它接到OpenAI的Function Calling,也可以接到本地模型的工具调用协议,甚至不用模型直接走规则匹配。这个范围克制是它最大的好处,也是我坚持不膨胀它的原因。
2. agent-skills的整体设计:把技能当成一等公民
2.1 核心数据模型:技能名称、描述、参数与执行体
我先定义了技能的最小数据模型,所有模块都围绕它来工作:
from dataclasses import dataclass, field from typing import Callable, Any, Dict, List @dataclass class Skill: name: str # 唯一技能名,例如 get_order_info description: str # 给模型看的描述,越具体越好 parameters: Dict[str, Any] # JSON Schema,描述入参结构 handler: Callable[..., Any] # 真正执行业务逻辑的函数 dependencies: List[str] = field(default_factory=list) # 前置技能名列表 timeout: float = 5.0 # 执行超时秒数 permission: str = "read" # read / write / admin 权限等级这个模型最巧妙的地方,在于它把“模型看到的描述”和“开发者看到的执行函数”绑在了一起。生成Function Calling协议时,我们直接取name、description、parameters,完全不需要维护第二套Schema。开发者注册一个技能时,只需要思考“我该写一个多么清晰的描述”,而不是去查框架文档。
我还特意让parameters采用JSON Schema标准,因为它已经是很成熟的生态,很多人熟悉。后续不管对接哪个模型厂商的工具调用协议,几乎都能直接用。这个选择帮我避免了很多次“新模型不支持某种格式”的头痛。
2.2 注册中心:技能统一存放与查找的核心
技能对象有了,接下来需要一个地方把它们存起来。我实现了一个SkillRegistry类,内部就是按技能名维护一个字典,并提供注册、查找、导出Schema、权限过滤等接口:
class SkillRegistry: def __init__(self, max_permission: str = "admin"): self._skills: Dict[str, Skill] = {} self._max_permission = max_permission def register(self, skill: Skill) -> None: if self._max_permission == "read" and skill.permission != "read": print(f"跳过技能 {skill.name}: 权限等级过高") return if skill.name in self._skills: raise DuplicateSkillError(skill.name) self._skills[skill.name] = skill def get(self, name: str) -> Skill: if name not in self._skills: raise UnknownSkillError(name) return self._skills[name] def list_skills(self) -> List[Skill]: return list(self._skills.values()) def schemas(self) -> List[Dict[str, Any]]: return [ { "type": "function", "function": { "name": skill.name, "description": skill.description, "parameters": skill.parameters, }, } for skill in self._skills.values() ]这里有两个细节值得说。第一,同名技能直接抛异常,而不是默默覆盖。这个看起来严苛的设计,在后面帮我拦住了一次很隐蔽的生产事故。第二,max_permission参数允许在Agent启动时限定整个注册中心能接受的最大权限等级。比如一个只读分析Agent,初始化时传max_permission="read",那么任何write甚至admin技能都会被静默跳过并打印警告,从源头防止“只读Agent顺手写了库”。
2.3 声明式配置的好处:不写模板,不藏业务逻辑
早期我做Agent技能时,最常犯的错误是把“这个技能是否可用”的逻辑写成if/else埋在各个函数里。比如订单技能,在客服Agent里可用,在后台管理Agent里又要脱敏,结果一个函数里全是“如果当前环境是xx,则yy”。这种代码一旦多起来,根本没法维护。
用声明式配置后,这些问题全部变成数据字段。技能是否可被某个Agent使用,取决于它的permission等级与注册中心的max_permission;技能是否依赖其他技能,写在dependencies里;技能的执行超时和安全等级,也直接作为元数据。业务函数只管业务逻辑,其他横切关注点全部交给框架。
例如“查订单”技能,我可以生成两个不同配置的实例:
read_skill = Skill( name="query_order", description="查询订单基础信息,不含退款详情。", parameters=base_order_params, handler=query_orders_basic, permission="read", ) admin_skill = Skill( name="query_order", description="查询订单完整信息,包含退款详情与用户隐私字段。", parameters=base_order_params, handler=query_orders_full, permission="admin", )一个注册到客服Agent的Registry,一个注册到售后后台Agent的Registry,两边互不干扰。这比传参判断不知道清爽多少。
3. 核心实现:从注册到调用的完整链路
3.1 写一个新技能只需要10行:装饰器封装
为了让团队里没有框架背景的同学也能快速上手,我给Registry封装了一个装饰器。它的作用是:把普通函数包装成Skill对象,并注册到注册中心。这个设计几乎不改变业务函数的写法:
@registry.decorate( name="create_calendar_event", description="创建或更新一个日历日程,传入标题、时间、地点和备注。", parameters={ "type": "object", "properties": { "title": {"type": "string", "description": "日程标题"}, "time": {"type": "string", "description": "ISO格式时间,例如2026-07-01T15:00:00"}, "location": {"type": "string", "description": "地点"}, }, "required": ["title", "time"] }, permission="write", timeout=3.0, ) def create_calendar_event(title: str, time: str, location: str = ""): # 调用日历API,写入日程 return {"status": "created", "event_id": "evt_20260617_001"}装饰器的实现并不复杂,核心逻辑是生成一个Skill实例然后调register。我之所以用装饰器而不是让开发者继承一个SkillBase,是因为继承会强迫他们写一堆抽象方法,装饰器则可以把业务函数原封不动保留。实际项目中,新同学只需照着一个已有技能的例子抄一遍,十分钟就能上手写技能,这是很多重框架给不了的体验。
3.2 参数校验与类型转换:模型传参不靠谱,这一关必须守住
LLM调用技能时,参数是模型自己生成的,格式飘忽不定是常态。我见过模型把时间字段传成“明天下午3点”,把数字字段传成“一二三”。如果不做校验,这些脏数据会直接进入业务层,产生各种奇怪的结果。
所以我给技能注册阶段增加了一个validator可选参数。如果没有自己提供校验器,框架就根据parameters里的JSON Schema自动生成一个校验器。执行handler之前,先跑校验:
def _validate(skill: Skill, raw_args: Dict[str, Any]) -> Dict[str, Any]: validator = skill.validator or build_validator(skill.parameters) try: return validator.validate(raw_args) except ValidationError as e: raise SkillParameterError(skill.name, e.messages)这里的关键是:校验失败时,返回的错误信息必须具体到字段级,并且给出修正建议。比如time字段需要ISO格式,例如2026-07-01T15:00:00。为什么?因为模型会读取这个错误信息来修正下一轮调用。你给的错误越具体,模型修正得越快。一开始我图省事只写了“参数不合法”,结果模型连续四轮生成同样的错误参数;改成具体提示后,一次就改对了。
除了校验,我还做了轻量的类型转换。比如模型传入的top_k: "5",会由泛型校验器转换为int(5);传入全角逗号等符号,也会在转换层修复。这个小设计减少了大量因编码习惯不同导致的小问题。
3.3 技能编排:顺序执行、条件分支与上下文传递
多技能协同是Agent的刚需。用户说“帮我约个明天下午的会议室,并通知参会人”,这需要先查会议室空闲,再创建日程,最后发通知。agent-skills里我实现了一个很轻量的SkillPipeline,它的职责是保证“当模型决定要依次调用这些技能时,执行链路是稳定且可观测的”。
class SkillPipeline: def __init__(self, registry): self.registry = registry async def run(self, skill_names: List[str], initial_context: Dict[str, Any]): context = dict(initial_context) for name in skill_names: skill = self.registry.get(name) result = await self._execute_with_timeout(skill, context) context[skill.name] = result return context async def _execute_with_timeout(self, skill: Skill, context: Dict[str, Any]): try: return await asyncio.wait_for( skill.handler(**self._pick_args(skill, context)), timeout=skill.timeout, ) except asyncio.TimeoutError: raise SkillTimeoutError(skill.name, skill.timeout)_pick_args的作用是从当前context里挑出技能需要的参数。因为前一个技能的结果可能放在context[skill.name]里,后一个技能需要引用它。
如果某个场景需要条件分支,我更倾向于把“分支决策”交给模型:模型根据上下文选择下一批技能名,代码里的pipeline只负责无条件执行给定列表。这样做的原因是,前期的条件分支往往不是唯一解,硬编码会埋没模型的灵活性。P.S. 测试时也更好写,因为pipeline的行为是确定性的。
4. 实战案例:让一个个人助理Agent同时管理日程、天气和文档
4.1 从需求到技能清单:拆解一个助理Agent需要哪些技能
为了验证框架的可用性,我搭了一个个人助理Demo:它能查询天气、创建日程、搜索本地文档。这三个技能看似简单,但背后各有各的复杂——天气要城市名转经纬度,日程要支持时区与重复事件,文档搜索要按相关度排序并返回摘要。
我拆出来的技能清单是这样的:
| 技能名 | 用途 | 关键参数 | 权限 | 依赖 |
|---|---|---|---|---|
| get_weather | 查询指定城市、日期的天气情况 | city, date | read | geocode_city |
| geocode_city | 把城市名转成经纬度 | city | read | 无 |
| create_calendar_event | 新建一条日历日程 | title, time, location | write | 无 |
| search_documents | 按关键词搜索本地文档 | query, top_k | read | 无 |
注意我特意把“城市名转经纬度”拆成独立技能,而不是塞在天气技能内部。因为它太常被复用了:查询天气要用,后续算两地距离要用,地图导航也要用。拆出独立技能后,模型可以在不同场景里自由组合它,而不是每次都要单独写一套转换逻辑。
4.2 把注册表翻译成LLM能懂的Function Calling协议
在Agent主流程里,我只需要把注册表里的Schema导出,传给LLM接口的tools参数:
functions = registry.schemas() # response = await llm.chat(messages, tools=functions)实际运行时,模型会在对话过程中“觉得”需要调用某个技能时,返回一个tool_calls指令。我的Agent层捕获这个指令,解析技能名和参数,调用pipeline执行,再把结果以tool消息喂回给模型。
为了验证“描述优先级高于硬编码”,我在get_weather的description里明确写了:“调用前需要通过geocode_city将城市转为经纬度,并将经纬度结果传入。”结果在测试对话中,模型真的会先在用户问天气时调用geocode_city,拿到经纬度后再调get_weather,非常听话。
不过也遇到过模型跳过前置技能的情况:用户问“北京明天会下雨吗”,模型没有调用geocode_city,而是直接把北京当参数传给天气接口。我预先在天气参数的Schema里设置了pattern,要求纬度必须是-90到90之间的数字,因此在缺失时直接校验失败。随后错误信息“缺失lat/lng参数,请先调用geocode_city”被喂回给模型,模型立刻补了前置调用。这一整套流程跑下来,让我坚信:校验错误信息是引导模型行为的一等工程手段。
4.3 实测中的幻觉:模型越界调用了你没给它定义的东西
跑了一段时间后,我攒了不少“模型幻觉”案例。最典型的是参数幻觉:用户问“查一下明天早上9点的天气”,模型把时间参数传成了tomorrow 9am,不符合ISO格式。因为有了校验层,这个请求被拦截,模型根据错误提示重新生成了2026-07-02T09:00:00,用户毫不知情。如果没这层拦截,10个应用里可能有8个会把tomorrow 9am直接传给天气API,返回“无法识别时间”的报错,而用户会一脸问号。
还有一个更隐蔽的幻觉:多技能协同时的字段误用。模型在搜索文档后,拿到返回的doc_id字段,接着把它当成title传给了创建日程技能。乍一看没啥,但日程系统里多了一条叫“doc_12345”的日程,用户根本不知道那是什么。这个问题的根源是技能描述的字段语义不够清晰。后来我在日程技能的时间字段上加了“ISO格式时间,不是文档ID”,并在title字段的description里写了“用户自定义的标题”,这类误用才基本消失。
5. 我在迭代中踩过的五个坑和对应的解决方案
5.1 重名技能被静默覆盖:给注册表加命名空间
给客服项目加新模块时,我写了一个get_user_info技能,另一个同事在订单模块里也写了一个同名技能,结果后者在注册时把前者静默覆盖了。我没有异常日志,整个系统表现诡异:客服报“查不到订单用户”,后台却正常。
排查近两个小时后,我才发现注册表里同一个名字只保留了一个。这个坑的解法有两个层面:第一,注册时遇到重名直接抛异常;第二,支持带命名空间的技能名,比如user.get_info和order.get_info。从那次以后,我再也没遇到过“不知被谁覆盖”的情况。
5.2 模型返回的JSON参数多了一个未知字段
有一次我惊喜地看到Agent跑通了一个五步复杂流程,但最后一步下游API突然崩了。查日志发现,模型在调用create_calendar_event时,除了我定义的title、time、location之外,还自己加了一个attendees字段。虽然我的handler没用这个字段,但因为我在执行时直接把参数原样透传给了日历API,导致API序列化失败。
解法很简单:在参数Schema中开启additionalProperties: false,未在Schema里声明的字段直接在校验阶段被拒绝。如果你确实想允许额外字段,必须显式声明在Schema里。这样模型再怎么“发挥”,也不会污染下游系统。
5.3 技能调用超时拖垮整个对话
Agent响应速度取决于最慢的技能调用。有一次我的“路况查询”技能内部有个傻循环,遇到某类输入会重复做无用计算,单次响应拖到几十秒。用户端表现为Agent一直转圈,最后超时。
我给装饰器加了timeout参数,执行时用asyncio.wait_for包住handler,超时后返回结构化错误“技能执行超时,请简化问题或稍后再试”。长耗时的技能我建议设计成独立异步任务,不要占用LLM响应链路。这个机制让一个卡住的任务不至于毁掉整个对话体验。
5.4 没日志,查不了“模型为什么这么选”
我在前面踩的坑,大部分靠日志才定位到。给agent-skills加上调用日志之后,我要求每个技能调用都必须记录skill_name,input,output,duration,error五要素。刚开始只是print到控制台,后来直接接到了可观测系统里。
有了日志,你能很清楚地看到模型选择了什么技能、传参是否合理、哪个技能慢了几百毫秒、哪个技能抛了异常。有一次用户投诉“Agent乱说话”,我看日志发现模型连续三次调用了同一个错误技能,根源是该技能的description里有一个错别字,导致模型误解。改个词就解决了。没有日志,这种问题只能靠猜。
5.5 别只测正常路径:单元测试要覆盖“脏调用”
我的很多测试早期只覆盖“合法输入返回预期结果”,结果一上线就被模型的花式脏数据打脸。后来我要求每个技能至少写三个用例:正常输入、缺少必需参数、参数类型错误。同时写了一个“脏调用”测试,直接调用注册中心的校验器,断言它返回普通用户可读的错误结构,而不是Python异常。
这里有一点很关键:断言出错时,异常信息本身也是产品的一环。因为Agent会把异常信息原样反馈给模型,所以对错误信息的措辞要像写文案一样认真。我甚至专门写过几个“引导性错误文案”的测试,确保模型看到后能立刻修正而不继续犯傻。
6. 扩展:从单Agent到多Agent的技能共享
6.1 把技能做成可安装的插件包,复用比复制轻得多
单个Agent的技能管理捋顺后,下一个痛点是多Agent复用。我的客服Agent和运营分析Agent都要查订单数据,早期做法是复制粘贴一份代码过去,结果两边各自修改,几个月后出现了两套行为完全不同的“查订单”。
现在的做法是:把技能做成一个普通的Python包,里面包含register_skills(registry)函数。Agent启动时调用这个函数,把技能注册进自己的Registry。这样技能模块与Agent业务彻底解耦,升级包即可更新所有接入方的能力。新Agent接入技能的时间也从半天缩短到十几分钟。
6.2 给技能配置权限等级:别让只读Agent顺手写库
技能共享之后,权限管理成了不可回避的问题。我在Skill模型里加的permission字段,配合Registry的max_permission,可以让每个Agent只注册它应该拥有的技能。
实际操作方法是:在Agent的配置里声明max_permission="read",注册中心遇到权限高于read的技能就跳过并打印警告日志。一次我在生产环境误把运营侧的配置写成了write权限,日志立刻打印出大量“跳过技能”警告,这才意识到配置弄错了。如果权限机制缺失,那些写操作技能就可能在一个毫无防备的Agent里偷偷跑起来。
6.3 关于未来:一个可组合的技能协议,而不是另一套框架
聊到最后,我想说点更宏观的体会。agent-skills虽然是我自己的一个轻量组件,但它的设计思路其实指向一个更大的可能性:Agent技能可以被标准化描述,进而像一个开源库那样被分享和安装。
现在每家Agent框架都有自己的一套工具定义,互相不兼容。如果有一天,大家都能用统一的JSON Schema加上模型可读的描述来定义技能,那么社区里就会涌现出无数可组合的“技能包”。OpenAPI做过类似的事,但它针对的是HTTP接口,而Agent技能还需要额外地提供“何时使用”、“依赖什么”以及“权限边界”这些维度。
对我个人而言,这个项目最大的收获不是代码量,而是让我看清了“技能描述”的重要性。你为一个技能写的description,不只是给模型看的说明,更是给整个系统的“使用说明书”。描述写得越准确,模型编排就越顺利,踩坑越少。这也是我想对所有做Agent的朋友说的一句话:别急着堆函数,先把技能的契约想清楚。