
做AI应用落地这段时间我最大的感触是模型能力再强不会用工具也是白搭。我自己折腾了大半年的一个项目“skills”就是为了解决这个痛点——把模型需要的外部能力全部封装成标准化、可复用、可编排的“技能单元”。今天这篇就把这个项目的完整思路、设计方案、核心实现和踩坑记录都翻出来给同样在做Agent、做工具调用的朋友一个参考。先说一下这东西到底是什么。skills本质上是一套给大语言模型用的“技能框架”核心就一句话统一工具的定义方式、加载方式和调用方式。让模型像“学会一项新技能”一样去理解和调用你给它注册的能力而不是每次都在系统提示词里堆一坨JSON Schema了事。如果你是做LLM应用开发的或者自己搭过Function Calling、搞过Agent系统应该会有同感工具一多管理就乱描述写不好模型就乱点兵参数格式不统一解析就报错。skills这套框架就是奔着这些具体问题去的后面对应的实现也都围绕这些展开。1. 内容整体设计与思路拆解1.1 先想清楚要解决什么问题在动手写skills之前我自己已经被工具管理这件事折磨过好几轮。最早做的那个Agent十几个函数全塞在OpenAI的tools参数里每次请求光工具定义就要占掉两千多个token。后来工具涨到三十几个模型开始频繁调用错误的工具——不是它不聪明是因为工具描述太像了它在JSON里分不清“日程查询”和“日程列表”到底有什么区别。再往后又踩了另一个坑每个人的工具定义风格都不一样。有人写description就一句话有人写一长串小作文参数格式也五花八门有的用snake_case有的用camelCase。每接入一个新工具都要花半天时间对齐格式、写适配层、调schema。这时候我就意识到问题的根子不在模型而在工具的定义和加载方式缺乏一套统一规范。skills这个项目的定位就是做一层“技能中间件”把工具能力按统一格式描述按统一机制注册按统一策略路由。你不用再关心底层调的是HTTP接口还是本地函数是OpenAI的function calling还是Claude的tool use你只需要关心“这个技能应该长什么样”。1.2 技能系统的三层架构设计我设计的skills系统分三层每层解决一类问题。最底层是“能力适配层”负责把外部能力——不管是REST API、Python函数、还是数据库查询——包装成标准接口。这个接口长这样一个名字、一段描述、一个参数Schema、一个执行函数。中间层是“技能注册与加载层”负责技能的管理。技能可以来自本地目录、远程仓库也可以是运行时动态注册的。系统启动时扫描所有技能定义校验格式合法性然后构建出一个“技能索引表”供模型路由使用。最上层是“编排与路由层”这是技能系统的核心。它接收模型的选择结果做参数映射、校验、执行、输出解析然后在多个技能之间做组合编排。比如“查询天气然后设置日程提醒”这种跨技能操作就需要这层来协调。这个分层思路在实操中带来一个很大的好处可插拔。想加一个新技能只需要按规范写一个目录丢进去不用改任何上层代码。我后期接入外部API的时候基本就是写技能描述文件 写执行函数加起来不到一百行。1.3 为什么选择“技能”这个抽象而不是直接用工具“工具”和“技能”这两个词在LLM应用领域经常混用但在skills项目里我刻意做了区分。工具是偏底层的概念强调“能干什么”技能是偏上层的概念强调“在什么场景下、怎么干、干完以后输出什么”。举个例子。你有一个查天气的API它本身是一个工具。但把它封装成技能的时候你需要额外描述什么时候应该用这个技能用户提到天气、温度、降雨概率时、接收什么参数城市名还是经纬度、以及如果用户没说清楚城市时该怎么追问。这些信息对模型的正确调用很重要但它们不属于工具本身而是属于“使用这个工具的知识”。我把这些知识写进技能定义形成“工具使用知识”的组合体。模型看到的不再是一行干巴巴的工具描述而是一个完整的、带使用场景的“技能卡片”。实测下来路由准确率比直接塞工具定义高了不少尤其是在技能数量超过20个以后效果差异非常明显。2. 核心细节解析与实操要点2.1 技能定义文件的六个关键字段每个技能在skills里都是一个独立的定义文件我用JSON或YAML格式存储。六个字段缺一不可写的时候每个都有讲究。第一个是name必须是全局唯一的短横线命名比如weather-query不要用空格和驼峰。模型的注意力在长文本里很容易被奇怪的名字干扰越短越清晰越好。第二个是description这是整个定义文件里最重要的字段。模型完全靠这段文本来决定要不要调用这个技能所以描述必须写清楚“做什么什么时候用”。我之前写“查询天气信息”这种描述模型就经常在用户问“明天要不要带伞”的时候去调用一个不相干的技能。改成“当用户询问天气、温度、降水概率、是否需要带伞等场景时使用通过城市名称获取实时天气数据”之后正确率明显上来了。第三个是parameters格式上我直接复用JSON Schema标准。这里有个容易忽略的点参数定义不仅要写类型还要写清楚的描述。比如city这个参数光写string不够要写“城市名称如北京、上海若用户只提供了模糊地点则需要追问具体城市”模型才能正确提取参数。第四个是execute指向实际的执行逻辑。在Python实现里可以是一个函数名的字符串也可以是一个可调用对象。我用的是前者这样做的好处是技能定义文件可以用JSON存储不会把代码写进配置里。第五个是permissions声明这个技能需要哪些权限。比如读文件、访问网络、修改数据库系统会做静态检查没有权限的技能直接拒绝加载。这个字段后期让我少操了很多心起码没出现过某个技能偷偷越权调用系统API的情况。第六个是metadata存一些辅助信息比如技能版本号、作者、依赖的其他技能、超时时间等。这些信息虽然不进模型上下文但在技能编排和运维排障时很关键。2.2 描述文件怎么写模型才爱看关于description的写法我试过很多种风格最后总结出一套在实测中效果最好的模板。描述结构分成三层触发场景、具体行为、边界说明。触发场景回答“什么时候用”具体行为回答“做什么”边界说明回答“什么时候别用”。触发场景的部分要写得口语化、贴近真实用户的表达。不要写“本技能用于获取经百度地图API接口返回的地理位置相关信息”这类描述模型不关心你的API是什么。你要写的是“当用户询问从哪里出发、某某位置在哪里、两个地点之间的距离时”模型一看就懂。边界说明很多人都忽略但它其实特别有用。比如一个点外卖的技能边界说明可以写“本技能仅支持餐饮类外卖不支持生鲜、药品、商超等品类用户询问药品时引导其使用药品配送技能”。这个“什么时候别用”的说明能有效抑制模型乱调用尤其是当多个技能确实存在业务范围重叠的时候。参数描述也要用心。我在系统里写过一个正则表达式清洗函数用来在技能加载时给每个参数描述做预处理——把全角符号转半角、去掉多余换行、压缩连续空格。别小看这个步骤有时候模型选择参数错误就是因为描述里的特殊符号导致embedding或tokenization时候的异常。2.3 技能格式校验在加载时把问题挡在门外技能定义文件是给人写的人一定会犯错。我在加载器里加了一套三层校验逻辑。第一层是结构校验检查必填字段是否齐全、类型是否正确。第二层是语义校验检查description是否为空、参数描述是否缺失、技能名称是否符合命名规则。第三层是执行函数校验确认引用的处理函数真实存在、签名匹配、返回类型可解析。这套校验机制单独看没什么技术含量但它大大减少了运行时问题。以前工具调用出错往往是到了执行阶段才发现参数不对、函数不存在现在加载阶段就跑完了全套检查报错信息还带具体行号和字段名排障时间从小时级降到了分钟级。第三层校验还有一个隐藏功能自动生成运行时的参数强制转换。JSON Schema里声明了参数类型是integer但模型传了一个字符串的42系统会自动做类型转换并记录一条warning日志。这个日志在测试阶段很有价值能帮你发现模型对参数类型的理解偏差及时修正描述。3. 实操过程与核心环节实现3.1 技能目录怎么组织我习惯把技能按业务域拆分放在独立目录里。目录结构大概是这样的每个技能一个文件夹里面放skill.json定义文件、executor.py执行逻辑、tests/测试目录和一份README.md使用说明。skills/ ├── core/ │ ├── skill_loader.py │ ├── skill_registry.py │ └── schema_validator.py ├── builtin/ │ ├── weather_query/ │ │ ├── skill.json │ │ ├── executor.py │ │ └── tests/ │ ├── reminder_set/ │ │ ├── skill.json │ │ ├── executor.py │ │ └── tests/ │ └── web_search/ │ ├── skill.json │ ├── executor.py │ └── tests/ └── custom/ └── (用户自定义技能目录)这么组织的好处是内置技能和自定义技能互不干扰升级框架时不会覆盖掉用户自己的技能目录结构同时也是加载器的扫描路径新增技能只需要放进对应目录重启或热加载即可识别。3.2 定义第一个技能完整的skill.json示例拿一个“设置提醒”的技能来举例。这是我在项目里用的真实定义字段都磨过好几轮。{ name: reminder_set, description: 当用户要求设置提醒、定时任务、闹钟或日程通知时使用。支持一次性提醒和每日重复提醒。若用户未明确提醒时间需要通过追问获取具体时间和提醒内容。, parameters: { type: object, properties: { content: { type: string, description: 提醒的具体内容如实记录用户原话不要做任何改写 }, time: { type: string, description: 提醒触发时间格式为YYYY-MM-DD HH:MM时区由系统配置决定 }, repeat: { type: string, enum: [none, daily, weekly, monthly], description: 重复频率默认为none即一次性提醒 } }, required: [content, time] }, execute: executor.py::handle_reminder_set, permissions: [notification:write, storage:write], metadata: { version: 1.0.0, timeout: 5, tags: [reminder, schedule, notification] } }这里有几个细节值得展开。content参数为什么要求“如实记录用户原话”因为模型在提取摘要信息时容易自作主张地改写内容把“明天早上八点提醒我带身份证”压缩成“提醒带证件”这就丢失了关键信息。所以我在描述里强制要求不润色、不缩写。time参数为什么不做默认值因为提醒类技能最怕的是时间不明确就擅自执行。让系统在时间缺失时追问比生成一个错误的默认时间要好得多。这也是我反复踩坑之后的结论——宁可多一次对话轮次不要执行一个错的时间。3.3 执行器怎么写从JSON定义到Python函数executor.py是技能的执行端我把它设计成一个非常薄的处理层。函数从注册中心拿到已解析的参数做一次运行时前置校验然后调用实际业务逻辑。下面是简化版的实现。from skills.registry import SkillContext from skills.utils import validate_required, parse_datetime def handle_reminder_set(ctx: SkillContext): params ctx.params # 运行时校验双保险 validate_required(params, [content, time]) # 解析时间并检查是否合法不能在过去 remind_time parse_datetime(params[time]) if remind_time datetime.now(): return {status: error, message: 提醒时间必须晚于当前时间} repeat params.get(repeat, none) reminder_id create_reminder( contentparams[content], remind_atremind_time, repeatrepeat, timezonectx.config[timezone] ) return { status: success, reminder_id: reminder_id, message: f已设置提醒{params[content]}, display_hint: f将在 {remind_time.strftime(%m月%d日 %H:%M)} 提醒你 }这里有个经验值得分享返回值里我特意加了display_hint字段。模型的输出往往需要再转换成自然语言但如果执行器直接返回一段半成品话术模型就不用重新组织语言了减少一层幻觉风险。这个设计在跟模型对话的时候效果很好直接拿走就能说。另外返回结构里不要混入技术细节。很多次模型去渲染一个“执行成功”的提示结果把调用链里的临时文件路径或者数据库主键ID给带出来了用户在对话中突然看到一行record_id10293体验非常割裂。所以我在返回结构里做了分层message是给模型看的摘要metadata是给系统排查用的详细数据两者严格分离。3.4 技能注册中心的实现机制注册中心是技能的“大脑”负责维护一个全局索引表。每个技能加载后会构建一个SkillEntry对象包含技能定义、执行函数引用、权限集和统计信息。所有技能统一存进一个注入有序的字典结构。class SkillRegistry: def __init__(self): self._skills {} self._index_by_tag {} def register(self, entry: SkillEntry): if entry.name in self._skills: raise DuplicateSkillError(f技能 {entry.name} 已存在) self._skills[entry.name] entry for tag in entry.metadata.get(tags, []): self._index_by_tag.setdefault(tag, set()).add(entry.name) def resolve(self, name: str) - SkillEntry: entry self._skills.get(name) if entry is None: raise SkillNotFoundError(f未找到技能: {name}) return entry我重点说一下tag索引这个设计。当模型路由不确定该用哪个技能时系统可以基于用户输入的意图做一次预过滤。比如用户的输入里提到“提醒”我可以通过tags索引快速定位候选技能集合再把候选集合的描述拼进本次请求的提示词里而不是把所有技能描述都塞进去。这个过程看似绕了一圈实际上缩短了路由路径也减少了token消耗。注册中心还必须维护技能的健康状态。我给每个技能加了“失败计数器”连续失败超过阈值就自动摘除不再参与后续路由。等执行器修复或者网络恢复后通过一次成功调用自动恢复状态。这个机制对在线技能升级非常关键——不用重启服务修复了直接恢复调用。4. 技能编排的高级玩法4.1 让模型学会“组合技能”而不是只调一个单技能调用只是第一步实际业务里经常需要把多个技能串起来用。skills系统里我主要通过“子技能依赖声明”实现组合。定义一个复合技能的时候可以在metadata.dependencies里声明需要调用的子技能列表框架会自动把子技能的入参需求合并到父技能的参数里。举个例子我有一个travel_plan复合技能它需要调用三个子技能flight_query查航班、hotel_search找酒店、weather_query看目的地的天气。父技能的输入参数是“出发城市、目的城市、日期”框架把这三个参数透传给子技能。{ name: travel_plan, description: 当用户要求规划旅行行程、查询机票酒店组合方案时使用, parameters: { type: object, properties: { from_city: {type: string, description: 出发城市名称}, to_city: {type: string, description: 目的城市名称}, depart_date: {type: string, description: 出发日期YYYY-MM-DD} }, required: [from_city, to_city, depart_date] }, execute: executor.py::handle_travel_plan, metadata: { dependencies: [flight_query, hotel_search, weather_query], timeout: 30 } }复合技能的核心逻辑在编排器里拿到父技能的输出再按子技能声明逐个调用、汇总结果。这个过程看起来简单真正实现好的关键在于“上下文传递”——子技能的执行结果要进入下一个子技能的提示上下文还要把中间态传递给最终的自然语言生成模块。4.2 路由冲突与消解策略技能多了以后模型经常会遇到“多个技能看起来都能做同一件事”的窘境。我的做法是给技能加一个suitability字段表示该技能对特定意图的适配程度。路由时优先选择suitability高的技能如果两个候选技能的适配度差值小于阈值就向用户发起确认性提问。比如用户说“帮我查一下明天北京的天气”weather_query和air_quality_query看起来都满足这时系统生成一句引导“你更关心空气质量还是整体天气情况”一次追问就能把意图偏差消除掉。有些团队嫌追问烦用算术方式直接硬选但实测下来用户对一次精准的追问接受度很高反而觉得系统“灵活”。4.3 技能执行超时与降级方案任何外部服务都不是100%可靠的。我给所有技能设置了默认超时时间——内置技能5秒复合技能30秒。如果技能执行超时系统会触发降级逻辑先做一次重试重试仍失败就把这次请求切换到“无工具模式”让模型仅凭自身知识回答。这里值得特别注意模型在技能超时后容易“编造”工具输出。它可能自己生成一个假的天气结果来维持对话流畅。解决方法是在降级时显式告知模型“工具调用超时不得编造工具返回结果请向用户说明当前服务不可用。”这句调度提示在大多数模型上都很管用能把幻觉概率压住。5. 常见问题与排查技巧实录5.1 模型总是选错技能怎么办这个问题几乎每个接入skills的人都会遇到排名第一的根因不是模型不行而是技能描述写得不行。我处理过的案例里大概有七成靠改description就能解决。我曾经有个技能叫note_take描述写“当用户要求记录事情时使用”结果用户一句“帮我记一下明天的会议”模型去调用了calendar_create。两个技能描述高度重叠但又各管一摊最终解决办法是明确给它们做边界切割calendar_create的处理逻辑包含时间冲突检测note_take侧重纯文本记录。两个描述都补充了“若涉及具体时间应使用哪个技能”的明确指向问题立解。还有一类问题是技能名太像。我有过send_email和email_reply两个技能模型经常张冠李戴。最后我给send_email加了一条参数约束——必须有收件人地址给email_reply加了一条边界说明——必须基于现有邮件线程。模型的错误率马上就降下来了。如果你调完描述还是错再去想路由策略的事不要一上来就怀疑模型能力。5.2 参数明明必填模型就是不传这种情况多发生在参数描述和用户表达差距过大的场景。模型不是看不到required标记而是它觉得用户没说它就不填。比如一个city字段标了必填用户原话是“帮我看看明天用不用穿秋裤”没有直接出现城市名模型就犹豫了。解决方案是在加载器里加一个“实体脱漏检测”。如果必填参数缺失系统自动为本次调用注入一个location上下文参数——提前从会话历史里提取用户提到过的地理位置。这个功能在对话型应用里非常有用。实现上不复杂就是维护一个上下文缓存按实体类型存储最近几轮出现的实体值然后在调用技能时尝试自动补齐。5.3 技能执行的返回结果模型不好好看执行器返回了一个结构化JSON但模型在最终回答时只挑了其中一部分信息来讲甚至漏掉了最关键的字段。这个问题的根源在于返回结构太复杂模型被无关字段干扰了。我改用“摘要优先”原则重写了返回格式。核心结论字段放最前面详细数据全部塞到details子对象里。然后在技能描述文件里加了一段使用说明“调用本技能后应优先使用message字段的信息回应用户其余字段为备选扩展信息仅在用户询问时提供。”模型的回答完整度提升明显。5.4 技能调试的利器调用追踪与回放最后分享一个排障工具。我在系统里做了一个“技能调用追踪”功能每次调用都会记录完整的活动链路模型选了哪个技能、参数解析结果、执行耗时、返回状态、降级是否触发。这些记录存成JSON文件支持回放。排查问题的时候先看“模型选技能”这一步再对比“参数解析”这一步两个环节基本能定位八成以上问题。我强烈建议你在集成技能系统的时候至少把调用日志和参数快照做出来。没有追踪能力等你技能数量过50个出了问题就是大海捞针。6. 迭代过程中的几点真实体会技术和方案讲得差不多了最后分享几句这段时间反复验证下来的体会。技能描述文件的维护要像维护代码一样认真。很多团队上线了技能系统以后就把技能定义文档扔在那不管了。等到模型换版、业务调整技能描述和真实能力已经悄悄脱节。我现在的做法是每次大版本升级后用一批固定测试用例跑一遍技能路由看准确率有没有波动。这个习惯帮我发现了至少三次因模型升级导致的技能路由退化。热加载能力建议早做。我开始的时候每次改技能定义都要重启服务后来实在受不了了才加了一套文件监听和动态注册机制。现在改完技能JSON秒级生效调试效率提升非常大。如果你现在还在手动重启第一优先级就是把热加载做上。技能数量控制在质不在量。我见过一些人拼命往系统里塞技能几十个、上百个都要结果模型路由准确率越来越差。技能描述有重叠是常态但重叠度过高就会把路由决策变成一场赌博。我倾向于“删冗余、并同类”把功能相近的技能合并成一个再用参数区分细节。一个五十个技能的库经过合并精简到三十个以内路由效果反而更好。再延伸一嘴这套技能框架不仅可以藏私有技能库还可以挂第三方共享仓库。我留了一个远端加载器按git协议拉取其他开发者发布的技能包。这个方向我觉得还有很大的玩头相当于给Agent开了一个“技能市场”每个人都能写技能、发布技能、复用技能。当前版本还在完善安全校验和沙箱机制等稳定了再单独开一篇聊聊。项目做到现在最大的收获不是技术有多复杂而是真正理解了“给模型设计工具”和“给模型设计技能”之间的差别。工具是死的技能是活的——好的技能定义能让模型事半功倍差的设计会让模型南辕北辙。希望我这篇记录能帮你少走几个弯路。如果你也在做同类项目欢迎交流。