
两个月前我把自己的Agent项目里那一堆“散装工具函数”彻底推翻重构成了独立的技能模块体系。今天把所有设计和踩坑过程整理出来给同样在做agent-skills方向的开发者一个可复现的参考。先说背景。我维护一个内部对话Agent早期实现很简单把业务能力写成函数注册成LLM的工具让模型按需调用。功能只有三五个时很舒服但当技能列表滚到五十个以上问题接踵而至——system prompt越来越长工具描述互相打架模型频繁选错工具加一个新函数就要动路由逻辑。如果你正在经历同样的痛苦这篇文章应该能帮你少走不少弯路。全文不涉及特定框架的绑定核心思路在LangChain、LlamaIndex或者裸OpenAI API上都能落地。1. 为什么我把散装工具函数改造成独立技能模块1.1 从硬编码工具到可插拔技能的转变动因最开始的系统里每个工具就是一个Python函数加上一段描述。天气功能最初只有一个get_weather(city)后来陆续加了get_weather_by_coord、get_weather_history、get_air_quality。函数之间没有边界描述写在同一个列表里系统提示词越攒越长。真正让我下定决心重构的是一次线上事故用户问“上海明天适合跑步吗”模型先调了get_weather_by_coord拿到经纬度查询又调了get_air_quality最后在结果里找不到“适合跑步”的判断直接胡编了一句“空气质量优适合跑步”。而实际上空气数据接口返回的是污染指数系统里根本没有做运动适宜度评估的能力。问题不在模型而在工具的组织方式。所有函数平铺在一起模型只靠一两句描述去猜猜错的概率随数量指数上升。技能模块的核心思路是把“一个函数”升级为“一个完整能力单元”除了执行代码还携带使用条件、参数约束、返回结构、常见错误处理、示例用法。相当于把工具从“散件”变成了“带说明书的组件”。另外一个大因素是复用。项目里另一个Agent要做“根据天气推荐穿搭”它也得查天气。原来我只能复制一份工具代码或者把它暴露成HTTP接口。改成技能模块后同一个技能可以被不同Agent挂载通过注册表统一管理不用再为每个Agent维护一套工具列表。1.2 技能、函数调用、插件、MCP的边界划分很多刚接触这个概念的读者会问skills跟function calling有什么区别跟插件又有什么区别这里我按自己的理解给一个清晰划分你就知道为什么需要技能这一层。Function Calling是模型与外部世界的通信协议解决“模型怎么请求执行”的问题。它不关心业务逻辑只关心参数怎么传、结果怎么回。Tool/Plugin是功能执行体解决“某个动作怎么做”的问题。它比函数调用多了执行逻辑但通常是独立的、无状态的。Skill是能力的包装单元解决“Agent在什么场景下应该用什么能力”的问题。它包含功能执行体、语义描述、参数模式、前置条件、依赖关系、失败策略。一个技能内部可以编排多个Tool调用。MCPModel Context Protocol是传输标准解决“技能和工具如何跨进程被发现和调用”的问题。它是通信层的规范与技能本身是两层东西。用生活化的类比函数调用是“你告诉厨师要一盘宫保鸡丁”工具是“后厨的炒锅”技能则是“一张完整的菜谱”告诉你什么时候做这道菜、需要哪些食材、火候怎么控制、出锅要什么标准。你可能有很多锅但菜谱才是决定出品的核心。Agent Skills的价值恰恰在这个“菜谱”层面。2. 技能模块的骨架设计一份能被Agent稳定调用的技能长什么样2.1 技能描述、参数Schema与执行体三件套我重构后的技能定义核心就三个东西描述、参数Schema、执行体。先看一个我实际在用的数据类定义from dataclasses import dataclass, field from typing import Any, Callable, Optional, List dataclass class Skill: id: str name: str description: str parameters_schema: dict execute: Callable tags: List[str] field(default_factorylist) examples: List[str] field(default_factorylist) timeout_seconds: float 10.0 is_sensitive: bool False version: str 1.0.0 def to_tool_spec(self): return { type: function, function: { name: self.id, description: self.description, parameters: self.parameters_schema, } }这里最容易被低估的是description。它不是写给人类看的而是写给模型看的“路由信号”。我见过太多人写“获取天气信息。”这种描述等于没说。合格的技能描述应该包含四类信息该技能能做什么具体职责越具体越好该技能在什么场景下用触发条件该技能不应该做什么边界声明防止模型越权使用该技能可能的副作用比如“会发送通知”“会修改数据库”举个例子。一个发通知的技能描述我最终写成向指定用户发送站内信或邮件通知。当用户明确要求“提醒我”“发一封邮件”“告诉我”且需要异步触达时使用。此技能仅负责触达不负责生成通知内容内容请在调用前由其他模块生成。发送动作不可撤销请确认收件人和内容后再调用。这段描述里有能力声明、触发场景、边界声明、副作用警告。实际测试下来模型选错的概率比原来用一句“发送消息”低了一个数量级。参数Schema我统一用JSON Schema格式因为它可以直接被OpenAI、Claude等各家模型的tools参数识别。关键是约束要严{ type: object, properties: { recipient_id: { type: string, description: 接收方用户ID必须是平台存在的用户 }, channel: { type: string, enum: [email, inbox], description: 触达渠道 }, title: { type: string, maxLength: 60, description: 标题 }, body: { type: string, maxLength: 2000 } }, required: [recipient_id, channel, body] }我坚持两个原则所有布尔字段禁止出现在技能参数里模型对布尔值判断极不稳定能用enum约束就绝不放任模型自由发挥。比如“channel”只有两种取值直接写成enum模型出错率从15%降到接近0。2.2 自检清单与技能签名约束在把新技能加入技能池之前我总结了一份自检清单每一条都来自真实翻车经历技能ID是否遵循动宾短语命名规范比如send_notification、fetch_user_profile而不是do_stuff。description是否包含“什么时候不该用”如果没有大概率会被模型误调。参数Schema是否每个字段都写了description模型在生成参数时会参考字段描述空字段描述会逼模型猜。必填参数是否真的必填我见过把“备注”字段设为required导致模型为了填它而编造内容。是否有超时控制网络请求类技能必须设超时否则一个慢接口可能拖垮整个对话。执行失败时返回的结构是否统一必须返回结构化的错误码而不是只抛异常这样上层才能做二次决策。签名约束是另一个重要细节。所有技能执行的出参我都强制包一层{ status: success | error, error_code: NULL | TIMEOUT_ERROR | API_ERROR | PARAM_ERROR, data: { ... } }这样模型在技能结果上做推理时能够明确识别“这次调用是不是可靠结果”。否则一个异常导致模型把报错信息当成真的业务数据那才叫灾难。3. 技能注册表让Agent在运行时发现并选择技能3.1 注册表数据结构与技能元信息有了技能模块下一步就是注册表。我用一个简单但够用的注册表来管理所有技能class SkillRegistry: def __init__(self): self._skills: dict[str, Skill] {} self._index_by_tag: dict[str, list[str]] {} def register(self, skill: Skill): if skill.id in self._skills: raise ValueError(f技能冲突: {skill.id} 已存在) self._skills[skill.id] skill for tag in skill.tags: self._index_by_tag.setdefault(tag, []).append(skill.id) def get(self, skill_id: str) - Skill: return self._skills[skill_id] def list_by_tags(self, tags: List[str]) - List[Skill]: result [] for tag in tags: for sid in self._index_by_tag.get(tag, []): if sid not in result: result.append(sid) return [self._skills[sid] for sid in result] def all(self): return list(self._skills.values())注册表的价值不只是存对象它承担三个隐性职能启动时校验冲突如果两个技能ID相同直接启动失败而不是在运行时让模型随机选。冲突可能来自同名不同团队提交的技能。按标签分组让“召回阶段”可以按领域缩小候选集。比如用户问题里提到“报表”先按reporting标签召回技能再交给模型精排。统一审计入口每个技能的版本、所属团队、上线时间、调用次数都挂在注册表上查问题时有据可依。我还会给技能打上“敏感等级”。凡是涉及发送消息、删除数据、修改配置的技能注册时标记is_sensitiveTrue。模型想调用敏感技能时系统额外要用户确认一次。这个机制在项目上线后拦下了好几次误触发送。3.2 技能发现、过滤与路由决策机制注册表建立后核心挑战变成给定一个用户请求如何选出最合适的技能我试过两种极端方案最后选择了折中路线。不太推荐的方案是“把所有技能都塞给LLM让它自己选”。五十个技能就意味着五十段描述塞进上下文token开销大、选择噪音高实测准确率会明显下降。我最终采用的流程分三阶段硬规则过滤先做关键词和正则匹配直接淘汰明确不相关的技能。比如用户问“现在几点”就不会把写报表的技能纳入候选。这阶段几乎不耗token。语义召回用户请求向量化后与技能描述向量做相似度计算取Top K。K我通常设置在10到15之间。如果候选技能数少于一个阈值可以直接选多于阈值才进入第三步。LLM精排只把Top K技能的完整描述和参数Schema交给模型让它从K个候选中决策调用哪一个并以JSON格式返回技能ID和参数。这段伪代码展示了流程骨架async def route_and_execute(user_request: str, registry: SkillRegistry): # 阶段1: 硬规则过滤 candidates [s for s in registry.all() if not reject_by_rules(user_request, s)] if len(candidates) 1: return await candidates[0].execute(param_from_request(user_request, candidates[0])) # 阶段2: 语义召回 if len(candidates) 10: candidates semantic_topk(user_request, candidates, k10) # 阶段3: LLM精排 final_skill, final_params await llm_decide(user_request, candidates) return await final_skill.execute(**final_params)这个流程的好处是“召回是廉价的精排是昂贵的”语义召回哪怕召回错了也只是被LLM精排淘汰不产生实质成本但如果不做召回把五十个技能全给LLM每轮对话的延迟和成本都会肉眼可见地上涨。4. 技能编排与动态加载组合调用和多技能协作4.1 串行、并行与条件分支的编排方式单个技能能解决的问题是有限的。实际场景里“给上周活跃但没下单的用户发优惠券”这种需求需要依次调用“查用户列表”“查订单状态”“生成优惠券”“发送消息”四个技能。我引入了一个非常轻量的编排层核心概念是技能步骤SkillStep。编排层不关心技能内部实现只管步骤间的依赖关系。dataclass class SkillStep: skill_id: str input_mapping: dict # 上游输出字段到本技能参数的映射 depends_on: List[str] condition: Optional[str] None # 如 order_count 0同一步的多技能可以并行执行带依赖关系的必须串行带条件的用LLM或规则判断是否执行分支。比如前面的例子pipeline [ SkillStep(fetch_active_users, input_mapping{}, depends_on[]), SkillStep(query_order_status, input_mapping{user_id: active_users[*].id}, depends_on[fetch_active_users]), SkillStep(generate_coupon_template, input_mapping{}, depends_on[]), SkillStep(send_notification, input_mapping{template: coupon_template, users: filtered_users}, depends_on[query_order_status, generate_coupon_template]), ]这里有一个设计原则技能之间不直接通信所有数据交换通过编排层的上下文对象进行。技能A永远不需要知道技能B的存在。这样每个技能保持独立可以单独测试、单独替换。否则技能之间一旦互相引用技能池很快就变成一团乱麻。4.2 动态加载的两种实现路径与取舍技能数量上去之后有人自然会想到“按需加载”别一启动就把五十个技能全加载到内存。我试过两种路径第一种是进程内懒加载。技能文件放在skills/目录下用importlib在第一次被路由命中时才加载import importlib class LazySkillLoader: def load(self, skill_id: str) - Skill: module_path fskills.{skill_id} module importlib.import_module(module_path) skill module.skill registry.register(skill) return skill好处是实现简单、冷启动快。坏处是第一次调用会有明显的加载延迟而且技能文件有改动时必须重启进程才能生效。第二种是独立进程中通过标准协议调用技能作为独立服务对外暴露。这个方案隔离性最好一个技能进程挂了不影响主进程但要处理网络通信、序列化、权限认证工程复杂度高很多。我的建议大部分内部项目先用第一种配合定时预加载热门前五名技能。只有当技能来自不同团队、需要隔离部署时才考虑第二种。永远不要为了“设计感”引入不必要的复杂度。5. 真实项目里的技能池设计从3个技能扩展到50个技能5.1 技能目录划分与命名规范技能一多目录结构就成了硬需求。我最终的目录结构长这样skills/ core/ # 基础能力当前时间、随机数、通用HTTP请求 data/ # 数据能力查询数据库、读CSV、调用内部数据API comm/ # 触达能力邮件、站内信、Webhook domain/ report/ # 报表业务域 user/ # 用户画像业务域 order/ # 订单业务域每个二级目录代表一个“业务域”不能跨域引用。报表域的技能不允许直接调用用户域的内部函数哪怕技术上可以也必须走编排层保证改动影响可控。命名规范我固定为动词_对象。为什么不用对象_动词因为在日志里定位问题时动词开头更容易一起看出一段“动作序列”。比如fetch_user_profileupdate_user_taggenerate_daily_sales_reportarchive_old_orders我甚至规定技能ID里禁止出现do、handle、process这类毫无信息量的动词。如果你发现自己写下do_thing这种名字说明技能边界还没拆清楚没有资格进注册表。5.2 通用技能与业务技能的接口隔离这是我在扩展到三十多个技能后踩出来的经验。最开始的fetch_user_profile返回的是数据库原始结构字段是created_at、last_login_ip。后来产品上需要给用户打标签又出一个fetch_user_profile_with_tags几乎复制了一个函数。问题的根源是通用数据技能和业务技能没有隔离。通用技能读的是“物理层”数据业务技能应该在通用技能之上做“语义层”包装。我现在强制要求通用技能core和data目录下只返回标准化的中立数据不包含任何业务判断。业务技能负责把中立数据解释成业务语义。fetch_user_profile只返回基础信息compute_user_lifecycle_stage内部调用fetch_user_profile和fetch_user_order_stats自己完成“活跃度、忠诚度”的计算。这样做有一个很实际的好处模型在精排的时候不会被大量雷同字段搞晕。通用技能描述写“返回用户的原始注册信息”业务技能描述写“判断用户生命周期阶段新用户、成长、成熟、流失”两者面向的决策场景完全不同模型选错率显著下降。6. 跑通之后的性能优化与稳定性排障6.1 技能调用的延迟构成与token成本优化技能体系跑通之后紧接着的问题是性能。一次技能调用完整链路包含四段时间LLM路由决策耗时如果走精排技能执行耗时包括内部API调用结果反馈给模型的二次推理耗时最终回复生成耗时实测中最容易被忽视的是“结果反馈给模型的二次推理”。技能返回一大段JSON模型要读完这堆JSON才能生成回复。如果技能返回了五百行的原始数据模型光“阅读理解”就要多花一两秒和几百token。我的优化思路有三个一是限制技能出参大小。所有列表类技能都支持limit参数默认只返回前二十条并在出参里带上总数提示。模型需要更多时反查下一批而不是一次性灌满上下文。二是缓存路由决策。同一种意图模式请求三次以上直接把“意图→技能ID”的映射缓存下来下一次就不再走LLM精排直接执行技能。实测热点问题延迟从两秒降到零点四秒。三是技能描述压缩。把高频技能的描述调整到一百五十字以内只留“场景边界副作用”。千万别在描述里写“这是一个功能强大的综合技能”这种废话每个字都是token而且都在给模型增加选择噪音。具体到token成本我给你算一个真实的账。五十个技能假设每个技能描述平均三百token全量给模型就是一万五千token。而我走“召回Top10”后精排阶段只带进三千token。单次路由决策成本下降了百分之八十。如果一天有十万次调用这个差距对应的成本非常可观。6.2 我踩过的五个典型坑与修复过程坑一技能描述里出现“可能”“或者”这类的模糊词。现象是模型总在相关技能之间犹豫甚至一次调用同时触发两个技能。排查链路先查日志发现模型输出里频繁出现“不确定该调用哪个”的自我质疑再定位到两个相似技能最后发现两个技能的描述里都有“可以用来查询用户信息或订单信息”。修复方案把每个技能的描述改成强约束句式——“只用于查询用户的基础注册资料”“只用于查询用户的订单记录”。从语法上杜绝二义性。这个改动让路由准确率提升了将近十个百分点。坑二技能执行异常时模型把报错信息当真数据继续推理。现象订单查询失败返回了“APIError occurred”模型居然顺着这句话回复用户“您的订单发生了一些错误请重试”。修复方案是统一错误结构{ status: error, error_code: API_ERROR, data: {retryable: True, message: 订单服务暂不可用} }我在编排层加了规则只要statuserror且有retryable标记就让模型尝试备选技能或明确告知用户失败绝不顺着异常内容编造。坑三两个技能语义高度重叠召回阶段经常一起进入候选LLM选错。比如generate_daily_report和export_sales_data一个生成日报一个导出原始销售数据。关键词几乎全部重叠。修复时我直接在两个技能的描述里互相声明边界前者写“不要用它获取原始明细数据”后者写“不要用它生成总结性日报”。在“负例描述”上花力气比调整向量模型效果来得快。坑四技能执行超时会卡死整个对话线程。一个报表技能内部要聚合七天的数据平均耗时三秒某次数据量暴涨耗时升到十五秒聊天接口整个卡住。修复方案每个技能执行都放在独立任务里设置超时上限。超时之后返回结构化错误给模型让它走降级路径比如“数据量过大请缩小范围查询”。坑五动态加载新技能之后没有回滚机制。有一次上线新技能忘了做版本标记第二天模型开始频繁调用它但效果不好想回退时发现旧版本已经被覆盖。修复方案注册表强制记录技能版本和变更时间每次发布前自动比对支持一键回退到上一个版本。技能和代码一样没有版本管理就谈不上稳定。结尾再分享一个实际操作中的工具。我建了一个“技能路由回归集”固定收集了大概两百条历史真实用户问题每条都标注了期望命中的技能ID或正确处理路径。每次改技能描述或者加新技能就跑一遍回归集对比路由准确率和平均token消耗。这个回归集帮我避免了很多次“改了A技能B技能路由变差”的隐性回归。如果你也在搭建agent-skills体系我建议从第一个技能开始就同步建立这个测试集成本极低收益远大于代价。