agent-skills最近在圈子里讨论度挺高但很多人还是把它当成普通的功能函数库来用。我花了两周时间把一个内部的多智能体项目重构成了以skills为核心的架构今天把这套从设计到落地的完整思路整理出来包含目录结构、元信息设计、参数schema、运行链路和组合编排希望能帮正在做Agent应用的朋友少走一些弯路。1. agent skills到底在解决什么问题1.1 从function calling到技能化的演进大语言模型刚接入业务系统的时候大家最先接触的是function calling。你给模型一堆JSON格式的函数定义模型根据用户意图选择调用哪个。早期的Agent应用基本都长这样十几个函数塞在一个文件里模型每次都要从所有函数定义中做选择。这套模式在函数数量少的时候还好用一旦超过二十个问题就暴露了。模型的选择准确性直线下降经常调错函数或者混淆参数。更麻烦的是代码复用变得很别扭——这个项目里写好的函数换一个项目要复制粘贴再改一遍函数定义、参数校验、错误处理全部散落在各处。agent-skills的思路完全不同。它把一个能力封装成一个完备的技能包这个技能包不仅包含函数的调用签名还包括技能本身的使用说明、适用场景、参数语义、返回值约定甚至还有配套的系统提示词片段。模型看到的不是一个孤立函数而是一个完整的能力说明。1.2 技能化的三个核心价值第一个价值是可复用性。一个封装好的技能就是一块乐高积木项目A里写好的内容安全审核技能项目B直接拷贝过去就能用。我实测过技能包的迁移成本基本为零因为技能内部已经包含了所有必要的说明和校验逻辑。第二个价值是可控性。每个技能都是独立的命名空间参数校验、运行时错误、日志记录都在技能内部闭环。出了问题只需要检查对应的技能目录排查成本大幅降低。更重要的是你可以在技能层做统一的访问控制和审计这对企业场景来说几乎是刚需。第三个价值是演进能力。技能可以独立迭代版本新版本技能的逻辑不影响其他技能。你可以对同一个技能保留多个版本做灰度对比看看哪个版本的描述文件能让模型更准确地触发调用。这种粒度的实验能力函数堆积架构很难做到。1.3 什么时候不该用skills说句实话skills不是银弹。如果你的Agent只需要调用三五个内部API老老实实写function calling就好不要过度设计。我见过有人硬把两个没有任何复用价值的功能也拆成技能包结果管理成本比收益还大。另外一个重要的判断标准是团队的维护能力。技能化要求你花精力写高质量的描述文件和参数定义这部分工作需要技术文档能力。如果团队本身就没什么文档沉淀强行技能化只会把代码仓库变成一个混乱的技能坟墓。2. 技能目录与元信息设计2.1 一个技能在磁盘上的样子先看一个标准技能包的目录结构这是我经过几个项目迭代后觉得比较合理的组织方式。skills/ └── web_search/ ├── SKILL.md ├── meta.yaml ├── schema.json ├── __init__.py └── run.pySKILL.md是这个技能的核心说明书用自然语言描述技能的能力边界和适用场景。meta.yaml记录技能的机器可读元数据比如技能名称、版本、作者、依赖关系。schema.json定义参数格式run.py是技能的实际执行逻辑。我强烈建议把技能描述和代码分离这个设计在后期调试时特别有用。模型读取的是SKILL.md和meta.yaml真正执行的是run.py。你想调整模型的调用行为只需要改描述文件不需要碰代码逻辑。2.2 meta信息描述怎么写才不会被模型误解这是整个skills架构里最关键也最容易被忽视的部分。模型对技能的理解完全取决于描述文本的质量描述写得模糊模型的调用准确率一定低。我总结了一个描述要素清单写SKILL.md的时候逐项确认技能的目标和职责范围明确说清楚这个技能管什么、不管什么前置条件什么情况下才应该调用这个技能输入参数的语义解释每个参数在实际业务里代表什么典型的调用场景示例最好给一两个具体的用户问题作为正例技能的局限性比如数据更新截止时间、适用的数据范围等我踩过一个很典型的坑。有一个天气查询技能最初的描述只写了查询天气信息。结果模型在用户问明天该穿什么衣服的时候也触发了这个技能因为描述里完全没有写技能的边界。后来我把描述改成根据城市名称和日期查询历史或预报天气数据不包含穿衣建议等衍生判断误用率直接降到了零。一个更用心的SKILL.md里还会加入正例和反例。正例是用户说什么话时应该调用反例是用户说什么话时不应该调用。模型对正反例的敏感度非常高这种示例比抽象描述管用得多。2.3 技能命名与版本管理命名规则看起来是个小事实际影响非常大。我建议使用领域_动作的格式比如content_safety_review、weather_daily_query。这种命名方式让模型在多个技能之间选择时更容易通过语义匹配找到目标技能。版本管理方面每个技能的meta.yaml里维护version字段技能的迭代记录写在CHANGELOG里。我采用语义化版本主版本号变更意味着不兼容的改动次版本号变更意味着新增能力补丁号是bug修复。技能之间的依赖关系一定要声明清楚。A技能依赖B工具库这个依赖关系必须显式声明在meta.yaml的dependencies字段里。技能管理器检查依赖的时候能避免部署时缺依赖的尴尬情况。3. 从定义到运行技能调用的完整链路3.1 技能的发现与注册机制Agent运行环境启动时需要做一次技能扫描。最简单的实现就是遍历skills目录解析每个子目录下的meta.yaml把技能元信息注册到一个内存索引里。这个索引的数据结构大概是这样的# skill_registry.py dataclass class SkillInfo: name: str version: str description: str parameters_schema: dict entry_module: str class SkillRegistry: def __init__(self): self._skills {} def scan_directory(self, base_path: str): for skill_dir in Path(base_path).iterdir(): if not skill_dir.is_dir(): continue meta_path skill_dir / meta.yaml if not meta_path.exists(): continue meta yaml.safe_load(meta_path.read_text(encodingutf-8)) skill SkillInfo( namemeta[name], versionmeta[version], descriptionmeta[description], parameters_schemameta.get(parameters, {}), entry_modulefskills.{skill_dir.name}.run, ) self._skills[skill.name] skill def get_skill(self, name: str) - SkillInfo: return self._skills.get(name)扫描一次性完成运行时不需要反复读盘。这里有个性能细节值得注意模型请求的prompt里需要塞入技能描述信息如果你有上百个技能全量塞入会造成token浪费。我的做法是只把技能的名称和一句话摘要放进系统提示词模型决定调用之后再通过工具调用把完整的SKILL.md注入上下文。3.2 参数schema与运行时校验参数定义用JSON Schema格式这个格式模型理解得最好。一个典型的参数定义长这样{ type: object, properties: { city: { type: string, description: 城市中文名如北京、上海, enum: [北京, 上海, 广州, 深圳] }, date: { type: string, description: 查询日期格式YYYY-MM-DD不传则默认今天, pattern: ^\\d{4}-\\d{2}-\\d{2}$ } }, required: [city] }参数描述里一定要写清楚格式要求和取值约束。模型本身不具备格式感知能力它只能根据描述文本推断参数格式。如果描述里不明确说日期格式模型可能给你传各种千奇百怪的东西。我在参数校验上吃过不少亏比如模型给日期参数传了明天、最近几天这类相对时间表达。后来我在描述里强制约束只接受具体日期字符串格式YYYY-MM-DD不接受相对时间表达这个问题就基本解决了。3.3 让技能返回值成为经验一个很容易被忽略的设计点是技能的返回结构。返回内容不能只是一段裸文本应该具备结构化特征最好返回数据部分和元信息部分。数据部分给模型消费元信息部分给开发者做监控和诊断。def run(input_data: dict) - dict: # 核心业务逻辑 result query_weather(input_data[city], input_data.get(date)) return { data: result, meta: { source: openweathermap, fetched_at: datetime.now().isoformat(), cache_hit: False, } }模型拿到返回值后对meta部分的处理逻辑其实很简单它主要关心data部分。但meta信息对你排查问题非常关键比如你可以知道数据是不是从缓存读的、数据源是哪个、是什么时候抓取的。这个习惯一旦养成以后的调试效率会高很多。4. 实战搭建一个可扩展的skill runner4.1 runner的整体结构技能定义好了需要一个运行时管理器把技能串联起来。我不建议引入特别庞大的Agent框架很多时候一个轻量的runner就够用。整个runner分为三层调度层负责接收模型技能调用请求、参数解析和分发执行层负责加载技能模块、校验参数、调用技能逻辑汇总层负责收集执行结果并格式化成模型友好的回复。调用流程在下层接口上有一个核心的dispatch函数模型侧通过工具调用协议触发# skill_runner.py class SkillRunner: def __init__(self, registry: SkillRegistry): self.registry registry self.execution_log [] def execute(self, skill_name: str, raw_input: dict) - dict: start_time time.time() skill self.registry.get_skill(skill_name) if skill is None: raise SkillNotFoundError(fskill {skill_name} not found) # 参数校验 errors validate_against_schema(skill.parameters_schema, raw_input) if errors: return { status: parameter_error, errors: errors, suggestion: 请根据参数要求重新提供输入 } # 导入并执行 module importlib.import_module(skill.entry_module) result module.run(raw_input) self.execution_log.append({ skill: skill_name, input: raw_input, output: result, duration_ms: (time.time() - start_time) * 1000, }) return { status: success, skill: skill_name, data: result, duration_ms: (time.time() - start_time) * 1000, }4.2 调用失败时的优雅降级模型调用技能失败是常态不是异常。你的runner不能一出错就抛异常要在调度层就完成失败兜底。我整理了三种常见的失败场景并给出对应的返回格式。参数校验失败的时候不要直接报错而是把校验失败的原因以明确的形式返回给模型让模型知道自己错在哪然后请求它重新提供参数。注意这里返回的失败原因主要给模型看不是给用户看所以要用模型容易理解的表述。技能执行过程中的业务错误也要捕获。比如数据源返回了接口异常码技能内部应该把这个情况结构化地返回而不是让一个裸异常直接冒泡到调用方。真正的未知异常才需要抛给上层。但在抛之前起码记录下完整的技能名称、输入参数、堆栈信息方便事后排查。# 执行入口封装 def execute_with_fallback(runner: SkillRunner, skill_name: str, args: dict) - dict: try: return runner.execute(skill_name, args) except SkillNotFoundError: return { status: skill_not_found, skill: skill_name, available_skills: runner.registry.list_all_skill_names() } except Exception as e: logger.exception(skill execution failed) return { status: execution_error, skill: skill_name, error_message: str(e) }4.3 可观测性是性能的放大镜技能调用如果不做日志记录那就是裸奔。我会给每个技能执行加上耗时、输入输出摘要的埋点。这些数据一方面可以做调用量趋势分析看哪些技能是热点技能另一方面也能发现哪些技能经常性超时或出错。我习惯在runner层统一记录执行日志而不是让每个技能自己打日志这样日志格式就统一了。技能内部只需要专注业务逻辑日志和监控交给runner去处理。5. 技能的组合与编排5.1 任务拆解成多个技能的原则单技能解决不了复杂任务。我看到很多Agent做复杂任务时流程通常是直接在一个大prompt里塞了所有上下文让它一口气处理效果往往不好。合理的思路是把任务拆成步骤每个步骤对应一个技能调用。拆解的原则我是这么把控的每个技能只负责一件事技能的输入输出有明确对接关系步骤之间有清晰的先后次序。比如做一个竞品分析报告的任务可以拆成搜索竞品信息、整理信息摘要、生成结构化报告这么三个技能。粗粒度原则是我在拆解时特别注重的。技能粒度太细模型需要多次调用来完成一个原子操作粒度太粗技能内部逻辑就会膨胀变成一个微服务。判断标准很简单技能描述能不能用两句话说明白如果能粒度就是合适的。5.2 串行、并行、条件分支的选择实际Agent任务里技能的执行关系无外乎串行、并行、条件分支这三种。串行最直观上一个技能输出作为下一个技能输入。并行适合多个独立技能同时执行能显著降低整体耗时。条件分支根据中间结果决定走哪条技能链。我这里有一个简单的执行顺序描述方法用结构化配置来控制技能流程{ task: 竞品分析, steps: [ { skill: web_search, args: {query_template: {product_name} 竞品分析}, output_var: search_results }, { skill: content_summarize, args: {content: {search_results}}, output_var: summary }, { skill: report_generate, args: {summary: {summary}}, output_var: final_report } ] }这种声明式的流程配置比硬编码的if-else更好维护。它把流程决策从代码里剥离出来你可以随时调整流程顺序不用重新部署代码。5.3 编排时容易踩的组合坑变量传递是不可忽视的环节。步骤与步骤之间的数据通过模板变量引用比如{search_results}、{summary}。这个机制看起来简单实际使用中经常出问题。模型在生成参数时可能会引用不存在的变量名或者变量名拼写不一致。我建议在runner里做模板渲染时加入缺失变量检测一旦发现引用了不存在的变量就报错而不是静默替换成空字符串。我说一个实际调试中遇到的典型问题。串行流程中后一个技能的输入依赖前一个技能的输出但模型前一步生成的参数名是summary_text后一步的模板里写的是{summary}结果一运行就发现变量找不到。后来我在模板渲染函数里加了变量检测逻辑任何未定义的变量直接抛错问题立刻暴露定位也快很多。6. 常见问题与排查技巧6.1 模型死活不调用技能这是最让人头疼的问题。模型面对一个用户消息选择了直接回答而不是调用你辛辛苦苦写好的技能。排查步骤我从优先级高到低列一下先检查系统提示词里是否明确列出了可用的技能和调用时机。模型不会自动去找技能你得告诉它什么时候该用哪个技能。最有效的办法是在系统提示词里写明比如当用户询问天气时你必须调用weather_daily_query技能获取实时数据。再看SKILL.md里的描述是否足够触发模型的调用意图。如果描述写得太抽象模型会认为直接回答也能完成。最后看是不是因为技能列表过载导致模型选择困难。技能数量超过30个模型的准确率会明显下滑。优先考虑分类分组或者只把与当前会话可能相关的技能注入。6.2 技能参数的幻觉与误用模型生成参数时经常出现幻觉特别是枚举值不在范围内、日期格式错误这种情况。我用的防幻觉手段是组合拳技能描述里写清楚参数取值范围、JSON Schema里用enum约束、运行时强制校验兜底。还有一类误用的情况是技能职责边界不清导致模型选错技能。比如你有query_sales_data和analyze_sales_report两个技能描述相近模型就容易混淆。解决方法是描述里明确加上反例说明什么样的请求不该用这个技能。6.3 长上下文下的技能检索当技能数量庞大全量注入不现实时需要引入检索机制。我做过一个尝试把技能描述向量化存入内存索引每次请求来临时根据用户消息语义检索最相关的几个技能动态注入。效果比全量注入好得多响应速度也快不少。# 动态技能选择 def select_skills(query: str, all_skills: list[SkillInfo], top_k: int 5) - list[SkillInfo]: query_embedding embed(query) scored [] for skill in all_skills: skill_embedding embed(skill.description) score cosine_similarity(query_embedding, skill_embedding) scored.append((score, skill)) scored.sort(reverseTrue, keylambda x: x[0]) return [skill for _, skill in scored[:top_k]]有一个细节要注意检索召回时会漏掉一些需要被调用的技能。我建议把高危技能做强制常驻比如涉及内容安全的技能不管检索结果如何都注入系统提示词确保不会被漏掉。常规技能动态检索高危技能固定常驻这个策略兼顾了效果和安全性。写在最后的一点点心得我这两周重构下来最深的感触是skills架构真正的价值不在于它有多先进而在于它让Agent应用工程化的边界变清晰了。函数堆积时代Agent的能力边界藏在代码里不可见、不可管理。技能化以后能力边界被显式地描述出来可复用、可测试、可运营。如果你也在做Agent应用我的建议是从一个最小技能包开始试水比如挑一个最稳定的功能做技能化封装跑通整个流程之后再逐步迁移。不要一上来就想把整个系统重构那样大概率翻车。另外一个我觉得很值得做的事情是定期复盘技能的调用日志看看哪些技能经常被调用、哪些技能永远没有触发。调用频率极低的技能要么删掉要么重写描述。技能库需要持续运营不是写完就完事了。