1. agent-skills项目定位:Agent从“会说话”到“会干活”的桥梁
先说清楚这个概念。agent-skills,字面意思是给智能体(Agent)编写和挂载技能。但真正做过Agent项目的同学会有同感:LLM本身再聪明,也只会“想”,不会“做”。你让它去查询数据库、抓取网页、算个账、发个通知,它没法直接操作外部系统——这时候就需要一套机制,把模型能“想象”的动作,翻译成系统真实执行的函数、脚本、API调用。skills就是这套机制里的最小能力单元。
我最早接触这个方向是给一个内部运营系统做自动化工单助手。最开始的做法很粗暴:把所有工具函数写进System Prompt,让模型自己在文本里“描述”要不要调用某个工具,再用正则去解析。结果Prompt越来越长,工具描述越来越像八股文,模型经常选错函数、传错参数,甚至自己编造不存在的函数名。折腾了两个月我才意识到,问题不是提示词工程,而是缺少一套结构化的技能管理系统——这就是我后来自己折腾agent-skills这类项目的原因。
这套体系真正要解决的事情有三件:第一,把“技能”从Prompt文本里剥离出来,变成可注册、可枚举、可校验的独立模块;第二,在模型和真实系统之间建立一个稳定的调用契约,让参数传递不再是纯文本猜谜;第三,让多个技能可以组合、编排、复用,而不是每个Agent项目都从头写一遍工具调用逻辑。适合来看这篇文章的,主要是有过Function Calling或Tool Calling使用经验、但觉得还不够系统化的开发者,以及正在从Demo级Agent转向工程化Agent的团队。下面所有内容都基于我自己落地这类体系时的真实设计取舍。
2. 技能包的结构设计:一份可注册、可校验、可检索的“能力说明书”
2.1 Skill Manifest的字段拆解
我习惯把每个技能定义成一个独立的“技能包”,包的核心是manifest文件——你可以把它理解成技能的说明书加合同。模型或者编排器拿到这份manifest,就知道这个技能叫什么、能干什么、需要什么参数、返回什么结果、有没有副作用、需要哪些权限。
一份典型的manifest包含这些关键字段:
- name:技能唯一标识,建议用
命名空间.动作的格式,比如calendar.create_event,避免不同技能之间的命名冲突。 - description:给LLM看的技能描述,这个字段直接影响模型能不能在多个技能中选对目标。描述要写得“像广告语”——说清楚做什么、在什么场景用、和相邻技能的区别在哪。
- parameters:参数定义,用JSON Schema描述,包括字段名、类型、必填项、枚举值、默认值。
- returns:返回值的结构定义,同样用JSON Schema,帮助上层做结果校验。
- permissions:该技能运行时所需的权限范围,比如只读、只写、网络访问、Shell执行。
- depends_on:依赖的其他技能,用于编排时解析依赖关系。
- metadata:分类标签、成本估算、超时时间、重试策略等。
这里要特别强调parameters和returns用JSON Schema而不是普通文档字符串,因为Schema是可以被程序解析和校验的。你在注册技能时就能提前发现参数定义错误,而不是等到运行时模型传了个诡异类型回来才发现问题。
2.2 输入输出Schema:技能边界的硬约束
很多初版实现会忽略Schema的严格性,觉得“反正模型能理解自然语言描述就行”。我实际用下来完全不是这样——如果不给硬约束,模型就会发挥它的创造力:说好要传整数它传字符串,说好枚举值它给你现编一个,日期格式更是重灾区。
我的做法是参数Schema里必须明确三件事:
- 类型和格式双重校验。比如
date字段写明type: string、format: date,系统在调用前先做一次本地校验,过不了就直接拒掉,不把坏参数传给真实函数。 - 必填项和可选项目列表明确区分。缺了必填参数时,返回给模型的错误信息要能引导它补充,而不是直接抛异常。
- 枚举值和范围限制写全。比如
priority字段只允许low/medium/high三个值,模型一旦越界,你的回调逻辑就要触发重选或纠偏。
有读者可能会问:这样会不会把模型的灵活性给锁死了?我的答案是:技能边界恰恰是可控性的来源。你可以给参数留一个extra_notes之类的自由文本字段作为容错口,让模型在不确定时把原始意图转述出来,但核心参数必须硬校验。这样既保留了自然语言的吞吐空间,又不至于让整个链路失控。
2.3 技能包目录与多技能管理
当技能数量超过十几个之后,管理成本会急剧上升。我建议从一开始就把技能做成目录化组织:
skills/ common/ calendar/ manifest.json handler.py mail/ manifest.json handler.py data/ mysql_query/ manifest.json handler.py每个技能目录就是一个独立单元,里面有manifest、实现代码、测试用例、README。这套结构的好处是:技能可以按目录批量加载,也可以按目录做安全检查;某个技能出问题可以单独下架,不影响其他技能;不同团队可以各自维护自己的技能包,通过类似包管理的方式共享。
首次接入这套体系时,可以先手工维护技能清单,后面再用脚本从目录自动扫描、校验、生成索引。这样维护成本不会随技能数量膨胀失控,也为做技能热更新留了口子。
3. 技能注册与调用的实现路径:加载、匹配、上下文注入
3.1 技能加载与收敛:从文件到内存的注册流程
技能要能被使用,第一步是把磁盘上的技能包注册进运行时的技能注册表(Skill Registry)。这一步听起来简单,但工程上必须处理好几件事:
第一是启动时全量扫描还是按需加载。我刚开始图方便,启动时把所有技能全部加载进内存,结果技能库到四十个以后,每次请求都要扫描一遍,响应时间明显变长。后来改成两层:启动时只加载manifest元数据,不加载handler实现;真正触发调用时才按需import对应模块。这样冷启动快,内存占用也小得多。
第二是注册时的Schema预编译。把所有的parameters和returns在注册阶段就编译成校验器对象,调用时直接用同一套校验器做入参检查和结果校验,避免每次调用都重新解析Schema,能省掉大量重复解析开销。
第三是重复注册和版本冲突。同名技能在注册表里路径不一致时会静默跳过,并输出警告;版本不一致时会按语义化版本规则决定保留哪个版本。如果两个技能声明了相同的name但版本不兼容,整个注册流水线会直接报错——宁可启动失败,也不要运行期才炸。
3.2 LLM调用技能的三种方式对比
技能是准备好了,但Agent怎么知道自己该用哪个技能?这是整个体系里最需要权衡的地方。我试过三种路线,各有取舍:
第一种是依赖平台原生的Tool/Function Calling机制。把每个技能的parameters自动转换成平台的工具描述结构,由模型在对话中自主选择并生成结构化调用请求。这种方式最省力,识别准确率也不错,适合大部分通用场景。缺点是平台的工具调用格式通常偏简单,复杂嵌套的参数结构在互转时容易丢信息。
第二种是纯提示词描述+文本解析。把所有技能描述写进System Prompt,让模型在回答里输出类似[CALL skill_name(param=value)]的标记,再用正则或解析器提取。这种方式极端灵活,也不受平台限制,但误识别率和解析容错率都不高,只适合技能数量少、参数简单的内部场景。
第三种是自己维护一个轻量路由器(Skill Router),预先训练或配置规则来决定某个请求应该交给哪个技能。这一种我放在编排部分展开,这里只提一点:它对“技能选择”的可控性最高,但成本也最高,不适合技能数量少、意图简单的场景。
大多数情况下我建议默认走第一种,因为模型的工具选择能力已经很强。等到技能超过三十个、同领域的技能经常混淆时,再用路由层在前期过滤意图,缩小候选技能集。
3.3 上下文注入的格式:给模型一张“技能菜单”
无论走哪种调用方式,模型都需要在上下文中看到技能列表。这里的关键是“菜单怎么呈现”。最初我直接把全部技能描述平铺进Prompt,长一点的技能库光菜单就占了两千多token,模型的选择准确率反而下降了。后来我把上下文注入分成两级:
第一级是全局可见的“今日菜单”,只放少量高频、无歧义的通用技能;第二级是按需展开的“二级菜单”,只有当路由层或上下文检索判定可能用到某个领域技能时,再展开该领域的全部技能描述和参数信息。实测下来,这种方式在保持技能覆盖率的同时,模型选技能的平均准确率提升了约9个百分点,上下文token也比全量铺开省了一半。
还有个容易被忽略的细节:技能描述不要只列“能做什么”,还要写“不做什么”。比如一个fetch_article技能,如果描述只写“抓取文章正文”,模型可能什么都往里塞。加上一句“仅用于正文抓取,不包含评论抓取和样式转换”后,误用率立刻下降。
4. 技能编排:从单技能调用到多技能协作
4.1 顺序编排与条件分支
单个技能解决的是“单点动作”的问题,但真实业务往往是链条式的。比如“自动生成周报”这个任务,拆开来至少需要:查询项目数据、汇总更新记录、生成摘要、套用模板、发送邮件。这就是技能编排的用武之地。
我比较推荐用图状结构来表达编排。每个节点是一个技能调用,节点之间用边定义依赖关系,根据业务需求写清是顺序执行还是条件分支。顺序场景下,前一个技能的输出自动映射为后一个技能的输入;条件分支则根据上一步返回值里的状态字段决定走向哪个分支。
为什么不用硬编码的if-else去串联?因为你一旦把流程做成代码写死,以后调整流程又得改代码、测回归。把流程描述成结构化配置后,可以做成可视化编排,也可以在运行时动态调整分支,维护成本大大降低。
4.2 技能路由:谁来决定用哪个技能
技能路由的“路由”分两层。一层是上文提到的候选集过滤,另一层是复杂任务里的“决策链”选择。我做过一个比较成功的实践是用两层路由:
- 轻量层:基于关键词规则和少量样本训练的意图分类模型,先把用户输入归到少数几个候选技能域。
- 决策层:把候选技能域的完整描述交给LLM做最终技能选择,再附带对参数的具体值抽取。
这样做的优势是:既享受了LLM语义理解的优势,又给它划定了候选范围,显著减少“十个技能里选错了”的情况。缺点是工程上多了一点维护成本,分类模型要跟着业务做小规模的样本迭代。如果你的技能总数不超过十五个,可以跳过轻量层,让模型直接选,问题不大。
4.3 状态管理:技能协作时的数据流
多技能协作期间最头疼的是状态同步。A技能生成了长文本,B技能要拿到处理后才能入库,C技能又要读取入库结果做二次加工——这中间的中间结果放哪里?
我最终采用的方案是共享会话上下文(Session Context),本质是一个带键约束的JSON对象,在编排执行过程中持续读写。技能之间不直接调用,统一通过上下文读取上游产物。每个技能声明自己要读哪些键、写哪些键,编排器在技能执行前先检查依赖键是否已就绪,执行后再校验产出键是否按预期写入。这套机制能明显提升容错性:中间某个技能失败时,可以重新尝试替代技能,而不必重跑整个流程。
这里特别注意:上下文的键不要全局乱放,一定要按技能或步骤命名空间隔离。我最开始随手用了result、data这类通用键名,结果两个技能写同一个键,互相覆盖数据,排查花了一整天才发现。
5. agent-skills与传统Function Calling的边界:为什么两者不是替代关系
5.1 Function Call是系统接口,Skills是业务能力
不少人问过我:既然各家平台都支持Function Calling,为什么还要单独搞一套skills体系?我的理解是:两者的抽象层次不同。Function Call解决的是“模型如何调用一个函数”的通信协议问题,而skills解决的是“一个Agent业务能力如何被定义、被发现、被组合、被治理”的工程问题。
你可以把Function Call理解成“单根网线”,把Skills理解成“办公室综合布线系统”。单个技能内部可能就是一个Function Call,甚至多个Function Call的组合;但技能之上还有描述、Schema、权限、依赖、生命周期管理,这些都不是Function Calling规范要做的事。
5.2 可组合性、版本管理与权限控制的差异
拿版本管理举例。平台的Function Calling通常是跟着应用代码发布的:你改了函数签名,就得同步改工具描述,然后重新发布整个应用。而技能包的粒度很小,可以单独升级:换一个技能的实现,只要输入输出契约不变,整个Agent流程不用重新发布。
权限控制上差别更大。原生Function Call的权限往往跟着Agent全局走,技能内部要么什么都访问,要么设个粗粒度角色权限。而技能体系支持更细的控制:比如某个技能允许读取数据库,但禁止写库,又要求调用前先经过审批回调。这种技能级权限与函数级权限的组合,在多部门共用的Agent平台里极其重要。
对比下来可以这样区分:如果你的Agent只有三五个工具,且生命周期都在同一个应用里,直接用Function Calling就够了。如果你要在一个共享平台上支撑几十上百个Agent场景,不同团队维护各自的工具和流程,那就必须为这些“能力”建立独立的管理体系,这就是skills要承担的职责。
5.3 什么时候该上Skills体系,什么时候别上
我给自己定了三条判断准则:
- 技能的数量是否会持续增长?如果是,值得上Skills体系,否则容易陷入重复造工具。
- 是否有多条Agent流程共用同一批能力?有共用就有复用需求,有复用需求就该有独立管理单元。
- 是否需要独立升级、独立权限、独立观测某一个能力?需要,就把它做独立技能;不需要,用函数就够了。
另外,如果团队里没有人能投入维护一套技能框架,强行上体系只会增加负担。技能体系的本质是“用结构换可控”,如果结构引入的成本大于可控性带来的收益,不如老老实实把Prompt和工具函数写好。
6. 从零到一:为一个实际场景编写技能包
6.1 场景拆解与技能需求分析
我拿“客服知识库自动问答”这个场景举个例子,因为它的技能拆解非常典型。完整流程是这样:
用户提问输入后,Agent需要把问题规范化(识别意图和关键实体),然后去知识库检索相关条目,再对检索结果做摘要生成;如果知识库里找不到答案,还需要触发一个“转人工”的工单技能。
做技能需求分析时,我列出三个技能:
query.normalize:意图理解与参数抽取,输出规范化后的查询对象。kb.search:检索知识库相关内容,接受规范化查询对象,返回候选文档列表。kb.summarize:根据候选文档生成最终答复,必要时带上置信度。ticket.create:处理无法回答的场景,生成转人工工单。
这四个技能各自独立、可复用。以后换一个场景,比如把“知识库问答”换成“文档自动摘要”,只需要重写kb.search的实现,其他技能完全不动。
6.2 代码示例:技能定义与注册
下面给一个简化但完整可运行的定义示例。我用Python写过类似的技能框架,结构参考它即可。
先定义技能基类和manifest:
# skill_base.py from typing import Any, Dict, Optional import jsonschema class Skill: def __init__(self, manifest: Dict[str, Any]): self.name = manifest["name"] self.description = manifest["description"] self.parameters_schema = manifest.get("parameters", {}) self.returns_schema = manifest.get("returns", {}) self._validator_params = jsonschema.Draft7Validator(self.parameters_schema) self._validator_returns = jsonschema.Draft7Validator(self.returns_schema) def validate_params(self, params: Dict[str, Any]) -> None: self._validator_params.validate(params) def run(self, params: Dict[str, Any], context: Dict[str, Any]) -> Any: raise NotImplementedError然后注册一个具体的搜索技能:
# skills/kb/search.py from skill_base import Skill import json class KbSearchSkill(Skill): def run(self, params, context): # 这里替换成真实的知识库检索逻辑 query_text = params["query"] top_k = params.get("top_k", 3) # 模拟检索结果 return { "candidates": [ {"doc_id": "1", "score": 0.92, "text": "关于退货流程的说明"}, {"doc_id": "2", "score": 0.85, "text": "包裹破损如何处理"}, ], "total": 2, } manifest = { "name": "kb.search", "description": "在知识库中检索与给定查询最相关的内容,返回候选文档列表。" "仅在已有知识库索引时使用,不处理无查询或空查询的情况。", "parameters": { "type": "object", "properties": { "query": {"type": "string", "minLength": 1}, "top_k": {"type": "integer", "minimum": 1, "maximum": 10} }, "required": ["query"] }, "returns": { "type": "object", "properties": { "candidates": {"type": "array", "items": {"type": "object"}} }, "required": ["candidates"] } }注册流程如下:
# registry.py skills = {} def register_skill(skill: Skill): skills[skill.name] = skill register_skill(KbSearchSkill(manifest))这个示例虽然简化了,但核心逻辑都在:参数校验、返回结构约束、技能按名注册。实际工程里还会加入版本判断和依赖解析,但骨架就是这套。
6.3 调用效果与压测数据
完成注册后,我模拟了三百条真实客服咨询记录做效果评测。在没有技能体系、纯靠Prompt让模型直接“编答案”时,正确引用知识库条目的比例只有61%,有相当比例的回答只是看起来正确但内容并非来自真实知识库。接入技能体系后,要求Agent先调用kb.search再加kb.summarize,并强制检索结果必须在回答里体现,正确引用率稳定在88%以上。
性能上也做了压测:单技能调用响应时间中位数为230ms,编排四个技能加上上下文读写后中位数约1.1秒。考虑到真实链路里还有LLM推理的时间,这个调度层本身带来的开销在可接受范围内。对比同样是四步操作但硬编码流程的版本,技能编排版的灵活性和可维护性远好于硬编码,这也是我后来一直坚持用技能化方案的原因。
7. agent-skills落地中踩过的坑
7.1 命名空间冲突与技能覆盖率
我在一次联调时发现mail.send技能同时存在两个版本,一个来自内部工具组,一个来自我自己写的客服通知模块。两边都没报错,但运行时模型偶尔会选到内部工具组那个,参数结构完全不同,导致邮件发送失败。
排查之后引入两条硬规则:所有技能名必须以团队或业务域为前缀,比如crm.notify.send、kb.search;注册时如果检测到同名前缀但非同一版本的技能,直接拒绝启动并打印冲突报告,而不是让系统带病运行。这个约束听起来简单,但真正解决了大规模共享场景下的核心问题。
另外一个经验是技能覆盖率问题:你以为把主要功能都技能化了,但模型还是会遇到“没有技能可调用”的情况。我的处理是在工具菜单最后固定加一个fallback.reason技能,专门用来让模型表达“我看不出来该调用哪个技能,原因是什么”,既防止模型硬凑技能,又能把未知需求收集起来反哺技能库迭代。
7.2 参数校验与幻觉参数
幻觉不只是答案里的幻觉,还有参数层面的。模型有时候会自作主张补充一个并不存在的参数名,比如给kb.search传一个"date_range"字段,而我们的Schema里根本没定义。第一次遇到时,我的校验器直接报Additional properties are not allowed,触发的是整体异常处理,导致整轮对话失败。
后来我把参数校验策略改成分层处理:校验失败的参数先做一次“可纠正性评估”——如果只是多了未知字段,记录警告并从入参里剥离;如果缺少必填字段或者类型错误,再返回错误信息让模型补充或改正。这样既能容忍模型的小失误,又不真正让坏参数落到底层函数上。
7.3 幂等性与重试机制
技能编排过程中,网络抖动可能会让某个技能执行超时,触发重试。但重试不是所有场景都安全的:如果是查询类技能,重试问题不大;如果技能内部已经写了一条工单记录,重试就会产生重复工单。
我的解决方案是把技能分成三类:幂等型(如查询)、可安全重试型(如下载文件后校验MD5)、不可重定型(如创建工单、发邮件)。不可重定型技能在manifest里加retryable: false标记,编排器遇到这类技能失败时不会盲目重试,而是进入人工确认或补偿流程。同时建议所有写操作都允许传入_request_id作为去重键,服务端记录已处理过的ID,从机制上挡住重复执行。
7.4 技能热更新与灰度发布
技能升级是另一种容易踩坑的地方。早期我直接在线上热加载技能文件,结果有一次新版本技能引入了不兼容的参数Schema改动,存量流程立刻大面积报错。后来强制规定:技能发布必须走灰度流程,先在独立的测试Agent环境运行新版本技能,跑一批回归样本,再逐步放开流量。切流量时采用按技能名滑动百分比的方式,配合手工回滚开关,确保问题出现时能在一分钟内下线异常版本。
这一套做下来之后,技能的迭代节奏变得快而稳。内部团队从每周只能发布一次Agent功能,变成随时可以独立评审并发布某个技能包,线上事故数明显下降。
最后再分享一个小体会:Agent技能的工程化,本质上是在“模型的原生创造力”和“系统的确定性要求”之间铺一层缓冲带。你不可能让LLM既完全自由又绝对可靠,技能体系的意义在于,把不可控的部分压缩到最小,把可控的部分用契约和校验锁死。这个东西做得好,Agent才真正有资格从“聊天机器人”进化成“干活系统”。