做AI Agent开发越久,越发现真正拉开项目差距的,往往不是模型选得多强,而是你有没有一套能把模型能力落地成“可执行动作”的技能体系。我常跟朋友说,Agent就像一个新员工,模型是它的脑力,agent-skills就是它的工作手册——没有手册,再聪明也不知道先干什么、怎么干。这篇文章不聊空泛的架构,只讲我在实际项目中设计、落地一套agent-skills的完整思路和踩过的坑,适合正准备给智能体做能力沉淀、或者已经做完却总感觉调用混乱的开发者。
我最初接触这个名词是在一个内部工具型Agent项目里,当时所有能力都写成一堆if-else,模型只会根据输入关键词硬匹配,结果就是稍微换一种问法就失灵,代码里还堆了一大堆分支。后来我把它彻底重构,按照“技能”的方式组织能力,效果立竿见影:调用准确率上去了,代码也清爽了。这套方法论不依赖特定框架,也不绑定单一模型,更像是一种设计规范。下面我会从设计思路、核心字段、实操代码、排查经验四个层面展开,尽量做到让一个刚入门的开发者也能照着重现。
1. 先从“能力池”说起:Agent到底需要什么样的技能?
1.1 为什么不能把技能直接写死在代码里
很多人做Agent时第一反应是“我先定义几个函数,让模型去选”。这本质上没错,但问题出在“函数”这个概念太编程化了——它天生缺少弹性,写死了入参类型、返回结构,模型只能在你预设的缝隙里做事。比如你做了一个天气查询,函数签名是get_weather(city: str),模型从用户的话里抽出一个城市名填进去,看起来很顺,但一旦用户问“今天杭州上海哪里更热”,模型就得连续调用两次再比较,而你的函数根本没有反馈“比较”这个动作的入口,于是只能靠模型自己搞,最后往往搞错。
把技能写死在代码里的另一层坏处是耦合。技能一旦和主流程纠缠在一起,改一个就牵一发动全身,加一个新能力要动老逻辑,两个人的代码还容易冲突。我在第一个版本就是这么干的,用一个超大的字典存储了所有动作和对应函数,越写越臃肿,到后面连我自己都分不清哪个分支会有副作用。
agent-skills的思路是把每一项能力都当成独立、可描述、可组合的“技能”。技能不是函数,而是“一个带有标准接口和自描述信息的功能单元”。它告诉模型:我叫什么、我擅长解决什么问题、我需要什么参数、我会返回什么结果。模型只看描述就能决定要不要调用,调用后还能根据结果决定下一步。这种设计把“能力”和“记忆”分离,逻辑上非常接近人类的工作方式——你不需要把每个步骤都写在流程图上,只要知道“会修水管的人可以解决漏水”就够了。
1.2 一套技能的三个层次
我习惯把agent-skills里的技能拆成三个层次,对应不同的复用粒度:
第一层是原子技能,也就是不可再拆的单一动作。比如“按城市查天气”“获取当前时间”“计算两个日期之间的天数”“调用某接口发送通知”。它们只做一件事,输入明确,输出简单,通常对应一个函数或一次API调用。原子技能是整个体系的砖块,必须保证质量稳定、边界清晰。
第二层是组合技能,它把多个原子技能串成一条流程。比如“安排一场会议”会涉及“查看参会人空闲时间”“选择一个时间窗”“创建日历事件”“发送通知”这四个步骤。组合技能内部可以有自己的状态和编排逻辑,但对上层模型来说,它仍然是一个整体,描述时写成“根据参会人档期自动安排会议并通知”。
第三层是策略技能,它不是直接执行动作,而是指导Agent“该怎么想”。策略技能可以是一条决策规则、一套优先级标准,甚至是一段Prompt模板。比如“当用户要求推荐方案时,优先考虑成本最低的方案”,或者“如果查询结果为空,自动放宽条件再试一次”。这类技能不产生直接的物理动作,却能显著影响Agent的行为质量。
三个层次不一定要分开部署,但设计时要心里有数。我以前只做第一层,结果流程逻辑全散在模型对话里,每次都要靠模型临场拼装,稍微复杂一点就断链。后来加了组合技能和策略技能,Agent的稳定性和可解释性明显提高。简单说:原子技能负责“能做什么”,组合技能负责“怎么串”,策略技能负责“怎么选”。
1.3 设计原则:高内聚、低耦合、可观测
技能设计跟写代码一样,得讲设计原则。我踩过无数坑后总结出三条最管用的:
高内聚:一个技能只解决一类问题,不要试图做一个万能技能。我见过有人把“查询”和“修改”写在一个技能里,理由是“都跟数据库有关”,结果模型选技能时经常误调用,把读取操作误判成写入。技能的内聚性直接决定了模型选择的准确率,内聚越低,描述就越模糊,越容易出错。
低耦合:技能之间不直接互相调用,统一通过接口或者编排层交互。我维护过一个“发票识别”技能,它内部直接调用了“OCR解析”另一个技能的函数,后来OCR改了内部实现,发票识别差点崩了。改成技能与技能之间只通过输入输出传数据后,再也没出过这种事故。
可观测:每个技能要能记录自己什么时候被调用、参数是什么、返回结果是什么、耗时多少。别小看这条,没有可观测性,后面排查问题简直是大海捞针。我第一次上线时没做日志,模型连续三次调用失败,我花了整整一天才意识到是某个技能返回了空列表,而代码里根本没有处理空列表的分支。
这三条原则听起来简单,但真正做到需要你在每个技能设计时都停下来问一句:这个技能的边界在哪?它和别的技能有没有隐性依赖?如果出了错我能不能通过日志快速定位?我的做法是写了一个技能模板,把记录日志的逻辑内置进去,强制所有新技能都带上基础的可观测能力。
2. 核心细节解析:给技能画一张“标准身份证”
2.1 技能描述字段怎么定
模型不会阅读你的源码,它只能看到一个技能的文字说明。这个说明的质量,比函数写得多优雅重要一百倍。我把每个技能的标准字段定为七个:name、description、parameters、required_parameters、returns、errors、tags。用一个JSON对象来表示,加载后可以直接传给模型或用于本地匹配。
name要短、稳、语义明显,比如check_weather,不要用get_weather_data_async_v2这种带版本号的丑名字,模型容易字符串匹配错误。description是最关键的一行,开头先写“这个技能用来做什么”,再写“适合在什么场景下调用”,最后补一句“不适合做什么”。比如:
描述分子技能时,我还会刻意加入同义词和边界词。比如查询天气的技能,描述里可以写“支持今天、明天、一周预报,也支持‘早上冷不冷’这类模糊问题”,后面加一句“如果要对比两个城市的温度,请分别调用后再由模型自行比较”。这样模型就知道这个技能的边界在哪,不会强行让它去干不属于它的活。
parameters用JSON Schema描述,required_parameters单独列出必填项。returns写清返回结构,比如“返回一个对象,包含date、temp_max、temp_min、condition,无数据时返回空列表”。errors说明可能会抛出的错误码或错误信息,给模型一个处理预期。tags是我的小习惯,给技能打上“工具”“查询”“系统”这类标签,方便做预筛选。
2.2 参数声明与校验
模型天生会“编参数”,尤其是当它不确定你期望什么格式时。比如你说要查天气,它可能给你传一个{"location": "杭州,天气怎么样?"},把用户原话整个塞进去。为了防这种情况,参数声明必须具体到类型和格式。JSON Schema是真的好用,能声明string、number、integer、array、object,还能限定枚举和正则。
比如城市名,我用type: string, pattern: "^[\u4e00-\u9fa5]{2,10}$"来限制只能传入2到10个中文字符。日期字段用format: date,如果模型传了“后天”这种相对时间,我会在技能层先做一次“时间表达标准化”,把它换算成具体的YYYY-MM-DD。这一步非常关键,因为很多模型对日期相对表达处理不稳定,与其信任它的换算,不如写一个时间解析小模块统一处理。
校验通过后还有一个细节:对参数做归一化。比如城市名去空格、日期转成统一格式、数字转成float或int。不要直接拿原始参数去查询,否则你会遇到“Chrome 杭州”这种带尾巴的脏数据。我在实际项目中就吃过亏,有个城市名参数带了用户浏览器插件拼接的字符串,查询接口直接报错,后来加了严格的schema校验和正则才解决。
2.3 错误处理与超时设计
技能调用不可能每次都成功,网络超时、返回空数据、权限不足,都可能发生。我的经验是给技能定义三类错误:可重试错误(比如超时、5xx)、不可重试错误(比如参数校验失败、认证失败)、自定义业务错误(比如数据源返回空数据)。分类后,Agent才能在第一时间决定是重试、换技能还是直接告诉用户失败原因。
超时是最常见的坑。外部接口没有超时控制,技能就可能挂起,连带着整个Agent卡住。我的做法是为每个技能设置两个超时值:内部超时和外部超时。内部超时指从Agent开始调用计算到技能返回的时间上限,外部超时指技能内部最耗时的子操作的时间上限。如果外部超时到了,技能会返回一个“超时但可能有部分数据”的状态,给Agent一个机会做部分结果处理,而不是全盘失败。
重试策略要根据错误类型分开。可重试错误我用指数退避,第一次等0.5秒,第二次1秒,第三次2秒,最多三次。不可重试错误绝不重试,直接返回错误原因,让模型换思路。这里有个容易忽略的点:模型可能因为收到错误信息而反复调用同一个技能,形成“调用死循环”。我设计了一个调用计数,同一技能在一个任务里最多调用5次,超过就强制切换到别的方式,避免无限递归。
3. 实操过程:从零搭一个技能库
3.1 目录结构与存放约定
一个可维护的技能库,文件组织要有约定。我目前用的是这个结构:
skills/ ├── weather/ │ ├── skill.py │ ├── schema.json │ └── prompt.md ├── calendar/ │ ├── skill.py │ ├── schema.json │ └── prompt.md └── common/ └── utils.py每个技能一个文件夹,skill.py包含技能逻辑入口,schema.json包含技能描述和参数声明,prompt.md存放技能详细说明和给模型看的调用示例。common/存放跨技能共用的工具函数,比如时间解析、日志上报、HTTP请求封装。
我之所以把描述拆到独立JSON里,是为了让“技能元数据”和“技能实现”分离。这样有几个好处:一是我可以写一个通用加载器,遍历所有文件夹读取schema,自动生成系统提示词中技能清单;二是不需要import每个技能就能提前校验描述有没有语法错误;三是有新同事加入时不用读源码,看schema就能知道这个技能能干什么。
3.2 注册与发现机制
技能库要有“注册”和“发现”机制。我实现了一个最简易的注册装饰器,用来把函数挂到全局技能表上:
SKILL_REGISTRY = {} def skill(name, schema_path): def decorator(func): with open(schema_path, "r", encoding="utf-8") as f: schema = json.load(f) SKILL_REGISTRY[name] = { "func": func, "schema": schema, } return func return decorator @skill("check_weather", "skills/weather/schema.json") def check_weather(city: str, date: str) -> dict: # 实际查询逻辑 return {"city": city, "date": date, "temp_max": 30.0}这样每次新增技能只需要两步:写一个schema文件,在函数头上加装饰器。不需要手动注册到任何列表,运行时会自动收集。
发现机制我做了两层:第一层是做一次“预筛选”,根据用户请求的关键词和技能标签做粗糙匹配,筛掉明显无关的技能。比如用户问“今天杭州天气”,只有weather标签的技能才会进入候选列表。第二层是交给模型决策,把候选技能的完整描述塞进系统提示词,由模型决定调用哪个。这样既保留了模型的灵活性,又减少了模型在几百个技能里“大海捞针”的概率。
3.3 让模型学会调用技能:系统提示词里的技能清单
系统提示词不是把JSON schema往里面一扔就行,模型很可能看不懂一堆嵌套结构。我会把每个技能描述压缩成一行“技能说明书”,放在提示词里:
可用技能: - 查天气:检查指定城市未来几天的天气情况。参数:城市名、日期可选。使用场景:用户询问天气、气温、降水。 - 设提醒:创建一个时间提醒。参数:提醒内容、日期时间。使用场景:用户让你“记得”某件事。 ...这份说明书不能太长,我控制在每个技能一两句话,最多不超过两百个技能。如果技能超过这个量,就必须依赖预筛选来缩小候选范围。
让模型学会调用的另一个技巧是给少量示例。我在每个schema的prompt.md里写了三个示例对话,展示什么时候调用、参数怎么填、得到结果后怎么答复用户。示例不追求多,但一定要覆盖“边界情况”,比如“查询城市不存在时怎么回复”“结果为空时怎么委婉说明”。我见过很多Agent连最简单的示例都没给,模型全靠猜,效果自然不稳定。
调用完技能后,返回结果要有一个统一包装。我规定技能必须返回一个字典,至少包含status(success或error)、data(结构化数据或错误信息)、raw(原始数据,用于排查)。模型读到status后就知道要不要继续下一步,不用再自己猜测。这里还需要注意,技能返回给模型的内容也要做“压缩”,太长的原始数据会占用上下文窗口,我用一个summarize函数先取关键字段。
4. 常见问题与排查技巧实录
4.1 技能召不准怎么办
这是所有Agent开发者的头号痛点。你明明有很多技能,模型就是不调用,或者调用了错误的那个。我遇到过的典型案例是:用户说“帮我查一下杭州明天的最高气温”,模型却没有选天气技能。排查下来发现是语言表达差异——用户用了“气温”而技能描述里只写了“天气”和“温度”,模型匹配不上。
解决办法有三个:第一,扩充description中的同义词和典型问题表述,把“气温”“冷不冷”“热不热”“穿什么”都写进去。第二,给技能加更多示例,让模型熟悉表达方式。第三,改造预筛选逻辑,用更全面的关键词表做粗匹配,甚至可以直接用文本embedding算相似度,再把最接近的几个技能送给模型。第三种效果最好,但会增加一些算力开销,我在处理几千个技能时才用。
还要注意技能的竞争问题。比如你有“查天气”和“查空气质量”两个技能,它们高度相关,模型经常选错。我的处理方式是在描述里显式写明“本技能不包含空气质量数据,如需空气质量请调用另一个技能”,再提供一个组合技能“查询出行建议”,把两者都包装进去。这样模型更容易选到正确目标。
4.2 参数反复出错,模型被“逼疯”
我见过一个很搞笑的日志:模型调用某技能失败了八次,每次都传相同的错误参数,因为技能返回的错误信息只是“参数错误”,模型不知道自己错在哪。最后Agent抛异常,用户只好手动操作。这个问题的根源是错误信息太模糊。
我在错误信息里加入了“正确参数格式示例”,比如:
错误:参数city不符合要求。正确示例:{"city": "杭州", "date": "2025-03-01"}模型一看到正确示例,马上就会修正自己的输出。还有一个办法是“自动纠偏”:在技能层直接处理常见偏差,比如把“后天”自动解析成具体日期,把“北京、上海”自动分割成两个城市名。纠偏逻辑值得多写,因为它能让Agent少犯几次蠢错。
参数校验失败时,我还建议做一个“失败回传”机制:把失败原因和当前参数一起回传给模型,让模型判断是自己表达错了还是用户需求变了。这个机制能显著减少机械重复。
4.3 技能多了之后调用速度慢
技能库从几十个膨胀到几百个后,系统提示词被塞得越来越长,每次请求的token消耗也跟着涨,响应时间越来越慢。最开始的方法是直接缩减技能清单,但效果不理想,因为有些技能确实会被用到,只是频率低。
我的最终方案分三步:第一,按标签分组,比如工具类、数据类、系统类,先用规则找到用户意图可能涉及的组,忽略其他组。第二,组内技能按“最近使用频率”排序,高频技能优先出现在提示词里。第三,对低频技能,不在提示词里完整展示,而是只提名字,等模型表示需要时再动态加载。这样既保住了长尾能力,又把常规请求的上下文压缩了三分之二。
还有一个小技巧:对组合技能做好“预编译”。有些组合技能其实可以一次性完成,不需要模型一步步拆解。比如“天气播报技能”内部把查天气、格式化播报文案、语音合成串在一起,模型只要调一次就好。组合技能执行时间也远低于模型多次调用。
4.4 并发与资源冲突
Agent技能不只是在单用户对话里用,我这个项目里还同时跑了好几个并发任务,技能之间会互相踩踏。最常见的坑是技能内部用了共享变量,一个任务改了状态,另一个任务读到脏数据。排查这类问题特别头痛,出问题的任务不一样,复现还随缘。
解决方法是给技能增加“运行上下文”概念。每次调用技能,都显式传递一个context_id,技能内部所有临时数据和状态都装在以context_id为键的字典里,任务结束后自动清理。另外,对会修改外部资源的技能(比如写入数据库、修改文件),我用一个全局锁和信号量来控制并发上限,避免多个Agent同时写同一个资源。
如果技能需要用外部API,还要考虑API的限流策略。我在技能封装里加了一个简单的令牌桶,每个API的调用频率被限制在每秒N次,超出后返回“请稍后再试”而不是直接崩溃。这个设计让我在流量高峰时保住了一个关键接口,没有引发连环故障。
最后分享一个小经验:别等到技能出问题再补设计,一开始就为每个技能写好schema、错误码和示例。我最初图省事,几个技能直接写死函数,后来为了加一个字段改了整整三个文件,还带出一个隐藏bug。Agent的技能体系跟代码库一样,前期的结构质量决定了中后期的维护体验。如果你现在正在做Agent相关项目,不妨先把你能想到的所有“动作”列一张清单,给它们命名、写描述、划边界,这就是你agent-skills的第一版草稿。有了这个草稿,后面的路会顺很多。