
最近在折腾AI Agent相关的东西时遇到一个特别有意思的话题agent-skills也就是给智能体配置“技能”。很多刚接触Agent开发的朋友都会困惑——我的Agent已经能对话了也能调用几个工具了为什么做出来的东西总感觉不像那么回事问题往往就出在“技能”这个层面上。你看到很多成熟框架里动辄几十个技能模块看起来很复杂其实拆开了看核心就是一套把能力抽象、注册、路由、执行串起来的机制。这篇文章我想结合自己的实际项目经验从零开始聊聊agent-skills这套体系的设计思路、落地过程以及我在踩坑之后总结的一些经验。内容偏实操适合正在做Agent开发的工程师或者想搞清楚Agent技能系统原理的技术爱好者。1. agent-skills到底是什么从一个真实需求说起1.1 没有技能体系的Agent就像一个只会背台词的话务员先说个真实场景。早前我做了一个客服类Agent最初的设计很简单把公司的FAQ文档、产品手册全部塞进prompt再挂几个查订单、查物流的API接口然后就直接上线用了。结果问题马上暴露出来。用户问“我上周买的那个蓝色耳机什么时候发货”Agent确实能识别出这是要查物流但它不知道调用哪个API、传什么参数、接口返回的数据该怎么解析。虽然我在prompt里写了“如果需要查询物流请调用getShippingStatus”但Agent时不时就会自己编一个接口名或者把用户的收货地址当成订单号传进去。那时候我才意识到靠prompt里的描述约束Agent本质上是在赌LLM的临场发挥这太不可控了。后来我开始研究agent-skills这套模式才慢慢理解为什么成熟的Agent框架都要做技能层。所谓“技能”本质上是把Agent需要执行的一个个原子能力——查物流、算价格、写文案、做翻译——封装成带有明确接口定义、入参规则、执行逻辑和返回格式的独立模块。1.2 技能体系解决的核心痛点我在实践里体会到一套好的skills机制核心要解决三个问题。第一个是“怎么让Agent知道有什么技能能用”。这对应技能的注册与发现机制。每个Agent启动时需要能从技能仓库里加载可用的技能清单而不是依赖开发者在prompt里手写一堆说明。第二个是“怎么让Agent知道该用哪个技能”。这对应技能的路由与选择逻辑。Agent收到用户请求后需要根据意图匹配到最合适的技能。有时候一个请求可能涉及多个技能比如“帮我查一下订单顺便把退货流程说一下”这时候还要有技能的编排能力。第三个是“怎么让技能执行得稳定可靠”。这对应技能的执行与校验机制。技能内部封装了具体的处理逻辑比如调外部API、读数据库、操作文件系统执行完还要把结果规范成Agent能理解的格式方便它继续生成回复。等我把这三层都理顺之后整个Agent的稳定性明显上了一个台阶。后面我把这套东西抽象成了一个独立的模块名字就叫agent-skills并且在好几个项目里复用和迭代。这篇文章分享的内容基本就是这套体系从0到1再到规范化的全过程。2. Skill体系的核心设计思路2.1 Skill的抽象层级与边界在动手写代码之前我先把“技能”这个概念做了拆分。我建议把Agent的“能力”分成三个层级否则很容易写成一锅粥。最底层是“工具”Tool指的是原子的、无状态的单一操作比如查询天气、调用一次OCR接口、执行一段Python脚本。工具不关心业务上下文只负责执行一个具体动作并返回结果。中间层才是“技能”Skill技能是面向场景能力的组合封装。一个技能可以内部串起多个工具调用也可以包含一些简单的业务判断逻辑。举个例子“查订单”这个技能内部可能要依次调用用户认证工具、订单查询工具、物流信息工具最后还要做数据格式化。对Agent来说它只需要知道有一个叫“查询订单状态”的技能传一个订单号进去就能拿到结构化结果。最上层是“流程”Workflow或者叫“方案”对应的是多技能之间的编排。比如“处理售后”这个流程可能需要先调用“查询订单”技能再根据订单状态决定调“申请退款”技能还是“发起换货”技能。我见过很多项目把这三层混在一起结果就是技能模块越写越臃肿Agent的路由逻辑也变得越来越难维护。我的经验是agent-skills这套体系至少要覆盖“工具”和“技能”这两层至于“流程”层可以根据业务复杂度决定要不要引入。2.2 注册中心与元数据设计技能体系里最容易被忽视、但最重要的部分是元数据设计。简单说你要让Agent“知道”这个技能是干什么的、什么时候用、怎么用靠的就是一段规范化的技能描述。我早期犯过的错误是把技能描述写得太简单比如只写“order: 查询订单”。结果Agent面对复杂的用户表述时根本无法判断该不该用这个技能。后来我参考了不少成熟的Skill协议把技能的元数据扩展成了这样几个字段name技能的唯一标识必须用英文小写加下划线比如query_order_statusdescription一段自然语言描述说明这个技能是做什么的、在什么场景下使用描述里还可以附带一些关键的同义词和典型例句parameters参数定义每个参数需要说明类型、是否必填、默认值、取值范围、示例值returns返回结果的格式说明最好给出一个结构化的示例timeout和retry策略执行超时和重试相关配置这里特别想强调description这个字段。我发现Agent在选择技能时对description的依赖程度远高于对技能名的依赖。也就是说描述写得好不好直接决定了技能被正确触发的概率。我之前做过一次对比实验同一个技能描述从一句话扩写成包含场景、例句、边界说明的完整段落之后Agent选择准确率从68%提升到了91%。2.3 为什么不能把所有功能都塞进system prompt我见过不少团队在Agent刚起步时习惯把技能说明直接写进system prompt让LLM看一眼就“学会”了。这种方式在小规模Demo阶段确实很快但一上生产就暴露问题。第一个问题是上下文窗口的压力。每多一个技能prompt里就要多几十甚至几百个字的描述。技能多了以后光技能清单就能吃掉几千token真正留给对话历史的内容就少了。用户多聊几轮系统就只能被迫截断早期上下文Agent就像得了“失忆症”。第二个问题是技能描述的稳定输出。直接在prompt里写技能说明意味着每次构造请求时都要原样拼接一遍。如果某个技能改了参数你得记得同步改prompt模板漏掉一处线上就出问题。而用独立的技能注册中心技能的定义只有一份所有Agent共享引用改一处全局生效。第三个问题是Agent的自主决策空间。把技能定义放在外部Agent需要在每次绑定调用前通过工具查询可用技能清单并选择最合适的那个。这个过程看似多了一步但实际上给了Agent一个“思考”的间隙让它先理解用户意图再匹配技能而不是在生成回复时硬凑一个调用。所以我在agent-skills项目里坚持一个原则prompt里只放Agent的角色设定和交互规则所有技能能力都走外部注册中心。这样不仅逻辑清晰后续做技能权限管理、灰度发布也都更方便。3. 实操从零搭建一个可用的skills模块3.1 目录结构与基础接口定义我实际搭建agent-skills模块时用的是Python整体目录结构大概是这样的agent_skills/ ├── registry.py # 技能注册中心 ├── base.py # 技能基类与接口定义 ├── loader.py # 技能动态加载器 ├── executor.py # 技能执行器 ├── skills/ │ ├── query_order/ │ │ ├── __init__.py │ │ ├── skill.py # 技能实现 │ │ └── schema.json # 技能元数据 │ ├── get_weather/ │ └── ... └── errors.py # 异常定义基础接口我用的是抽象类的方式让每个技能继承统一的基类。核心接口只需要三个方法from abc import ABC, abstractmethod from typing import Any, Dict, Optional class BaseSkill(ABC): 所有技能必须继承的基类 name: str description: str version: str 1.0.0 abstractmethod def execute(self, params: Dict[str, Any], context: Optional[Dict[str, Any]] None) - Dict[str, Any]: 执行技能的逻辑 params: 从LLM抽取并校验过的参数 context: 可选的上下文信息如用户ID、会话ID等 返回: 需要规范化的结果字典 pass abstractmethod def validate_params(self, params: Dict[str, Any]) - None: 参数校验逻辑不合法时抛出异常 这一步至关重要能避免脏数据进入执行阶段 pass def get_metadata(self) - Dict[str, Any]: 返回技能的元数据注册时会用到 return { name: self.name, description: self.description, version: self.version, parameters: self.get_param_schema(), } def get_param_schema(self) - Dict[str, Any]: 返回参数定义的JSON Schema return {}3.2 参数定义与校验LLM给参数不可信这里我要专门说一下参数校验环节。LLM在决定调用技能时会从用户输入里抽取参数但抽取出来的参数经常不靠谱。我遇到过的情况包括用户没提订单号LLM就自己捏造一个用户说时间是“下周三”LLM不知道具体日期用户连着说了两个数字LLM分不清哪个是数量哪个是价格。所以我在每个技能里都写了validate_params方法用JSON Schema做严格校验。比如订单查询技能的参数定义{ type: object, properties: { order_id: { type: string, pattern: ^[A-Z0-9]{8,20}$, description: 订单号通常以字母开头长度为8到20位 }, phone_last_four: { type: string, pattern: ^\\d{4}$, description: 手机号后四位用于身份验证 } }, required: [order_id, phone_last_four] }校验的好处是双重的。一方面它能在执行前就拦掉一批明显非法的请求省得技能内部再写一堆防御逻辑另一方面当校验失败时我可以把错误信息返回给LLM让它根据错误提示重新向用户索要信息而不是硬着头皮继续执行。这一步非常关键相当于给Agent加了一个“不会不懂装懂”的保险杠。3.3 与LLM的function calling对接参数定义好了接下来就要把技能暴露给LLM。我用的是OpenAI风格的function calling协议但底层也兼容其他主流模型框架。做法很简单把注册中心里的技能元数据转成function schema列表每次对话请求时一起传给模型。def build_functions_from_registry(registry) - list[dict]: functions [] for skill in registry.list_skills(): schema skill.get_param_schema() functions.append({ type: function, function: { name: skill.name, description: skill.description[:1000], parameters: schema, strict: True, } }) return functions这一步有一个重点不是把所有技能一股脑全传给LLM而是要做一次预筛选。技能数量少没问题一旦超过二三十个全部传进去会很消耗token而且LLM在大量相似技能之间容易选错。我在项目里加了关键词匹配和embedding语义匹配两种预筛策略先粗筛出5到10个候选技能再把它们的schema传给LLM准确率和成本都有明显改善。3.4 技能执行器的实现细节技能执行器是负责接收LLM返回的function_call然后真正去执行技能并返回结果给LLM的模块。它的逻辑并不复杂但有几个细节需要处理。第一是超时控制。外部API或者数据库查询都可能因为网络问题变慢我的做法是给每个技能配一个独立的超时时间用asyncio.wait_for来包一层超时就返回一个标准化的错误结果而不是让整个Agent等死。第二是重试策略。针对不同的错误类型要有不同的处理逻辑。比如限流类错误429可以等待后重试参数非法类错误400重试没有意义应该直接反馈给LLM让它修正。第三是执行上下文的传递。有些技能需要知道当前用户是谁、有没有权限、当前会话的上下文是什么。我的做法是在Executor里维护一个context对象技能执行时传入技能内部通过context.user_id这类字段做权限校验和数据隔离。我贴一个简化版的执行器核心代码import asyncio from typing import Any, Dict, Optional from .errors import SkillTimeoutError, SkillValidationError class SkillExecutor: def __init__(self, registry, default_timeout: float 15.0): self.registry registry self.default_timeout default_timeout async def execute(self, skill_name: str, params: Dict[str, Any], context: Optional[Dict[str, Any]] None) - Dict[str, Any]: skill self.registry.get_skill(skill_name) if skill is None: return {status: error, error_code: SKILL_NOT_FOUND, message: f技能 {skill_name} 不存在} # 参数校验 try: skill.validate_params(params) except SkillValidationError as e: return {status: error, error_code: INVALID_PARAMS, message: str(e)} # 超时控制 timeout getattr(skill, timeout, self.default_timeout) try: result await asyncio.wait_for( skill.execute(params, context), timeouttimeout ) except asyncio.TimeoutError: return {status: error, error_code: TIMEOUT, message: f技能 {skill_name} 执行超时} except Exception as e: return {status: error, error_code: INTERNAL_ERROR, message: str(e)} # 规范化返回 return {status: success, result: result}4. Skill生命周期管理调试、测试与迭代4.1 离线测试的几种方式技能做出来之后不能直接扔给Agent用先离线测一轮是必须的。我这里说的离线测试指的是不经过LLM直接构造参数调用技能并检查结果。我在项目里用pytest搭了一套测试框架每个技能目录下都有一个test_skill.py。测试的核心是覆盖正常流程和异常流程两条线。正常流程就是要确认技能在合法参数下能返回符合预期的结构化结果。异常流程要覆盖参数缺失、参数越界、外部依赖不可用等情况确认技能能返回规范化的错误信息而不是直接抛出一个让LLM不知所措的异常。还有一类测试我称之为“语义回归测试”专门验证技能description的表述是否准确。做法是把一批历史真实用户问题作为测试集跑一遍技能的预筛和选择逻辑看每个问题能否命中预期技能。如果某个技能经常被选中错大概率是description里少了关键场景或者包含太多误导性的词语。4.2 版本管理与回滚策略我在这上面吃过亏。早期没有版本概念某次我更新了一个高频技能的参数逻辑结果跟线上正在跑的对话流程不兼容用户提问得不到正确答案排查了半天才发现是新旧逻辑混用了。后来我给技能加上了version字段并且要求所有技能定义只追加不修改重大变更必须升级版本号。注册中心内部保存了所有版本的技能快照Agent侧会记录当前绑定的技能版本。线上发布时先小流量灰度一批用户去用新版本观测无误后再全量切换。一旦发现问题回滚只需要把Agent的技能版本号指回旧版本即可完全不用重新发布代码。这套机制在多人协作的场景下尤其有用。团队里不同开发者维护不同技能频繁发布也不会互相踩踏因为版本隔离了彼此的影响面。4.3 性能与成本监控Agent的技能调用肯定要花钱花时间所以我们得盯着这两个指标。我在agent-skills模块里埋了统一的可观测埋点每次技能执行都会记录执行时长、token消耗、成功失败状态。最后聚合成两个核心指标技能平均响应时间和单次Agent会话的技能token占比。关于token占比这个指标我解释一下。每次触发技能时技能描述、参数结果都会被塞进上下文。如果某个技能返回的结果特别冗长或者技能描述写得特别啰嗦token消耗就会直线上涨。我在调优时发现把某个技能的结果从返回500字的JSON改成只返回必要的50字摘要整个会话的token消耗降低了近四成。这种优化不需要改模型不需要换算法只需要在技能的结果格式上下功夫性价比非常高。5. 常见问题与排查技巧实录5.1 问题速查表我把实际运行中遇到的典型问题整理成了一张速查表方便大家直接对照排查。问题现象可能原因排查思路与解决方案Agent明明有某技能但就是不调用技能description缺少触发场景检查description是否包含典型用户说法补充场景词和同义词每次对话都调用好多个技能技能边界模糊多个技能都能命中同一意图细化技能职责边界在description里明确排除场景LLM传的参数经常不对参数schema缺少示例值或者description不清在参数的description里加上示例比如“订单号例如A123456789”技能执行正确但Agent回复还是错返回结果格式不够明确检查结果是否包含了Agent生成回复所需的全部关键信息必要时增加summary字段技能数量超过30个后选择准确率骤降函数列表太长LLM注意力分散增加预筛机制先按语义粗筛候选技能再把候选列表传给LLM技能定义改了线上不生效版本缓存未刷新检查注册中心的缓存策略确认技能版本号递增且刷新机制正常5.2 我踩过的那些坑接着说几个印象比较深的坑。第一个坑是关于LLM“强行调用”技能的问题。在刚把技能体系接进来时我发现Agent在用户表达模糊的情况下也会硬选一个技能去调用因为“不调用任何技能”这个选项并没在候选动作里。结果就是用户只是随口问一句“你们东西质量怎么样”Agent直接触发了查询订单技能把用户当成了已下单客户。解决办法是在系统提示里显式声明当且仅当用户意图与技能高度匹配时才调用技能否则保持正常对话。这个约束看似多余实际上能把误触发率压低很多。第二个坑是技能的并发和上下文覆盖问题。早期执行器对每次调用的隔离做得不好个别技能执行时把状态写到了全局变量里导致并发场景下A用户的请求污染了B用户的上下文。后来严格规定技能内部不允许使用全局状态所有中间结果必须存放在context对象里并且每个请求的context是独立实例。这一点在写自定义技能时特别容易违反建议在代码评审时专门检查。第三个坑是外部依赖的账号过期问题。有些技能调用的第三方API需要定期刷新token但token过期时并没有立即触发错误而是等到技能执行完才报一个模糊的权限错误浪费了很多时间。后来我在技能基类里加了一个内置的依赖健康检查钩子每个技能执行前先检查它的外部依赖是否可用不可用就直接返回明确错误并提示Agent告知用户“该服务暂时不可用”。5.3 如何设计一个“高辨识度”的技能描述最后分享一个我摸索出来的小方法关于怎么写技能的description才能让Agent更容易选对。我的模板是这样的先说技能的目标动作用动词开头比如“查询”“计算”“生成”然后说明触发场景列举两到三个最典型的用户问题接着说明边界条件明确这个技能不处理什么最后补充一个示例调用。举个例子“生成商品描述”这个技能我最初只写了“根据商品信息生成营销文案”后来改成了完整的描述块根据商品信息生成适用于电商平台的营销文案。当用户说“帮我写个商品描述”“这个产品怎么宣传”“给我的宝贝写段介绍”等问题时触发此技能。本技能处理单个商品的文案生成不处理批量商品文案生成也不处理广告投放策略。示例用法用户提供商品名称、核心卖点、目标人群技能返回标题、卖点列表和详情文案。这段描述看起来比最初长了很多但它覆盖了英文表述的多变性Agent在各种问法下都能稳定触发这个技能。实测下来重写描述后这个技能的命中率从72%提升到95%左右而token只多花了几十个非常划算。这个写法的核心逻辑是LLM在做技能匹配时其实是在做一次模糊的文本匹配你给它的信息越贴近真实用户表达习惯匹配就越准。与其让Agent在运行时去“意会”一个比你想象中短得多的描述不如把边界和场景一次性写清楚。我在agent-skills项目的持续迭代中最深的体会是技能体系的建设没有一个“一步到位”的方案它更像是在和LLM的能力边界不断磨合。你需要通过测试去观察Agent在什么场景下会选错技能、在什么场景下会传错参数然后反推是改description、改参数schema还是重新划分技能边界。这确实是个精细活但一旦跑顺了你会发现原本不可控的Agent行为会变得可靠得多。如果你也在做Agent开发不妨从一两个核心技能开始试试这套模式把注册、校验、执行、监控这几个环节搭起来再慢慢扩展。真踩到坑了欢迎带着具体问题来讨论很多细节光靠看文档确实是体会不到的。