做了大半年Agent相关的东西前前后后攒了十几个能跑通的自动化流程踩过最大的坑不是模型选型不是向量库调优而是“给Agent写的工具它根本不会用”。工具函数写了一堆Prompt里也描述了但Agent要么视而不见要么把我精心设计的参数传得乱七八糟。这个问题困扰了我很久直到我开始把思路从“写工具”转成“建技能”把所有可复用能力以agent-skills的形式统一管理、统一描述、统一评估局面才彻底改观。这篇东西就把这套方法完整拆开讲一遍包括技能库怎么设计、技能描述怎么写才能让模型真正理解、怎么验证技能真的可用以及我在实际迭代中踩过的那些坑。这个话题适合谁看如果你在用大模型开发智能体写了不少Function Calling或工具调用但总感觉模型用不好如果你团队里已经积累了几十个工具文档和代码开始脱节新来的同事根本不知道有哪些能力或者你只是想把Agent做成一个能持续进化的系统而不是每次加需求就硬塞Prompt——那这篇文章应该能给你一个相对完整的参考。1. 为什么要做 agent-skills从“多一个工具”到“多一项技能”1.1 工具和技能的本质差异为什么工具写了十个Agent只会用一个我先说个真实场景。早期我做了一个知识库问答机器人为了让它能干更多事我给它接了十几个工具查天气、查数据库、发邮件、写周报、OCR识别、网页抓取……工具列表洋洋洒洒Prompt里也写了每个工具是干嘛的。结果实测下来Agent永远只调用前三个它最熟悉的工具后面的工具形同虚设。后来我仔细看了日志发现问题不在模型笨而在我给的“工具说明”太烂了。我给每个工具写的是这种描述“get_weather(city: str) - str根据城市名获取天气信息。”这种描述是给人看的不是给模型看的。模型根本不知道这个工具应该在什么场景下用、什么时候不该用、传参的时候有哪些隐含约束。我那时候才意识到一个关键区别工具是一个“函数”它只看输入输出而技能是一个“能力单元”它必须包含什么时候用、怎么用、边界在哪、失败了怎么办。工具是死的技能是活的。agent-skills的核心就是完成这层转化。1.2 技能化的三个层次封装、解释、自我修正我自己总结下来一个真正能用的技能体系至少要覆盖三个层次缺一个都会在实际跑的时候出问题。第一层是封装。这个最简单就是把一段可复用逻辑包成函数或者API有明确的输入输出。封装解决的是“能力存在不存在”的问题。第二层是解释。这是最容易被忽略的一层。所谓解释是你在代码之外给模型写清楚这个技能在什么任务里用、适用什么样的上下文、参数有哪些隐藏规则、返回值怎么解读。我用过的最有效的方式是把解释分成三个字段技能名称、触发场景、使用约束三个字段单独写不混在一起。模型对结构化文本的理解比对长段落的理解好得多。第三层是自我修正也就是要有反馈闭环。Agent调用技能后如果发现输出不符合预期技能本身最好能暴露一些诊断信息比如返回一个状态码或者在意料之外的输入下给出一个明确的Error信息。这样编排层才能根据错误信息做重试或者切换策略。你想想如果是人干活干错了至少能说一句“我哪里不会”技能也一样失败码就是技能的“反馈信号”。这三层都做到位工具才真正变成了技能。agent-skills这个项目名本质上就是那套把这三种工作固化成流程和代码库的基础设施。2. 设计一个技能库步骤拆解与命名规范2.1 先拆任务还是先写代码一个文档拉齐需求的实操方法很多团队做技能库上来就让工程师把现有函数改改加上描述就算完事。这个做法看着快实际会埋雷你现在以为的技能划分只不过是把历史代码的边界照搬了一遍跟大模型实际要处理的任务往往是错位的。我的做法恰好相反先不要碰代码先去拆任务。具体流程是这样的把Agent要完成的业务场景全部列出来比如“整理会议纪要”“监控数据异常”“自动回复客户邮件”然后为每个场景写一个“任务卡”。任务卡上只写三样东西输入信息是什么比如会议原始录音转写文本、期望输出是什么结构化会议纪要包括决定事项和待办、整个处理过程需要哪几个步骤摘要→提取待办→指定责任人→发送相关人。做这一步的目的是把“技能”的边界从“函数边界”改成“任务边界”。为什么这么做因为大模型是按照任务去理解世界的不是按照代码库理解世界的。你把任务边界划清楚了再回头看哪些函数该被合并成一个技能、哪些技能需要拆开就清晰了。实践中我建议用共享文档飞书文档、Confluence、Notion都行把任务卡拉齐让所有相关角色都看一眼因为技能库的消费者不只是写代码的人上游配置Prompt的人、下游测试的人都要对着这批定义干活一旦命名和拆分口径不统一后面全是沟通成本。2.2 技能描述怎么写Agent才能真正看懂这是整套体系里最值得花时间的地方。同一个技能描述写得好不好效果差距可能是天壤之别。我总结了三条铁律。铁律一不要概括要展开。 “处理文本”这种描述等于没写模型根本判断不了什么时候该用它。正确的是类似“当用户提供一段会议录音转写文本需要提取所有明确的责任人、截止时间、行动事项并整理为Markdown清单时使用。” 这句话信息密度极高既说明了触发场景会议录音转写文本又说明了技能会做什么提取责任人、截止时间、行动事项还说明了输出格式Markdown清单。铁律二写清楚“什么时候不用”。 很多技能描述只写了适合场景没写禁用场景于是模型会把不相关的东西硬塞进来。比如上面那个会议纪要技能一定要加一句“如果输入内容不是会议相关或者用户只要求翻译/改写不要调用本技能。”这几行字能帮你过滤掉大量误调用。铁律三参数说明比函数签名重要得多。 模型读参数名有时候绕不过去。比如参数名是date_range模型不一定知道该传“2025-01-01~2025-01-31”这种格式还是“本周”“最近一个月”。所以每个参数都要给格式示例不然你会看到模型把时间描述原样传进来然后你的解析器直接报错。我常用的技能描述模板长这样name技能短名全小写加下划线description一句话说明技能的用途与核心价值when_to_use什么场景下调用写出具体条件when_not_to_use明确禁止调用的场景input_params参数名、类型、必填与否、格式示例return_value返回结构说明包括成功和失败两种情况error_codes可能出现的错误码以及各自触发条件写完这些之后我通常会拿去问团队里另一名工程师——“你看了这段描述知道什么时候该调它吗”如果对方模棱两可说明描述还不够具体继续改直到一个没写过这段代码的人也能正确判断使用场景才算是合格。2.3 参数设计最容易被忽略的约束条件参数设计有一个特别关键的误区很多人直接沿用老接口的参数觉得没必要改。但技能的参数是给大模型填的不是给人填的。人的习惯和大模型的习惯差异很大。举几个真实出现过的反面案例第一个案例时间类参数。 老接口用的是Unix时间戳人用得习惯机器也准确但大模型算时间戳可容易翻车了。你把“明天上午9点”这五个字丢给模型它会很纠结到底该用哪个时区的零点去换算。我的做法是技能参数全部用ISO 8601字符串2025-03-01T09:00:0008:00并且描述里直接给一个当日示例模型抄作业也会抄得更准。第二个案例筛选条件过多。 如果一个技能有七八个可选参数模型在判断哪些该填、哪些留空的时候表现会明显下降。为什么因为参数越多可选组合空间越大模型“猜”的可能性就越大。应对办法是收敛如果某两个参数经常一起出现就合并成一个结构化对象如果某个参数在80%的场景下都是固定值就把它做成技能配置而不是技能参数。第三个案例输出格式偏好。 这个我踩过一次印象很深的坑。当时技能返回JSON里带了一个status字段值域是0和10表示正常1表示异常。模型每次拿到结果后都会判断“status0表示没有异常那用户的请求应该是成功了”但它不会直接把0翻译成“成功”给用户看因为在它看来0看起来像个失败状态。后来我把status改成单词success和failed误判率立刻降了一个数量级。从那以后我设计返回值一律用可读文本绝不用隐晦的数字枚举。3. 从技能定义到可用代码完整落地一门技能3.1 技能骨架定义、实现、注册表三段式的总体结构理论讲清楚了就该动手。我这里给出一套已验证的工程实现方案结构上分为三层技能定义文件JSON或YAML、技能实现代码Python函数或API Call、技能注册表Registry。这三层就是紧密协作的关系定义文件负责描述实现代码负责干活注册表负责汇总和索引。技能定义文件是一个字典记录技能的元数据包括名称、描述、参数定义、返回值定义、错误码等。前面已经说过这些字段的作用是让大模型理解技能。技能实现代码就是普通函数但有一个约束入口函数要返回一个统一的包装结构比如错误就抛异常成功就返回带数据的对象。技能注册表则扫描技能目录把所有技能定义加载到一个全局字典里供Prompt组装和Function Calling逻辑取用。为什么要分成三段因为三个部分的更新频率和关注点是不同的。定义文件跟Prompt策略强相关可能是每周都要微调实现代码相对稳定有Bug才动注册表基本不怎么改只在增删技能时变动。三层分离后你就可以在不动代码的前提下随时调节模型对技能的认知——这对快速迭代太重要了。3.2 实操记录以“会议纪要整理”技能为例完整走一遍下面拿“整理会议纪要”这门技能开刀完整走一遍从定义文件到代码实现的全过程你会看到实际工程里长什么样。先创建技能定义文件meeting_minutes.json{ name: meeting_minutes, description: 将会议录音转写文本整理为结构化的会议纪要。, when_to_use: 当用户提供会议录音转写文本并要求提取会议主题、参与人、决定事项、待办事项时使用。输入可以是直接粘贴的文本也可以是录音转写文件路径。, when_not_to_use: 当用户只是要求翻译、改写、概括普通文章或者输入内容不含会议相关对话时不要调用本技能。, input_params: { transcript: { type: string, required: true, description: 会议录音转写文本, example: 张三我们先对齐一下Q2的目标。李四同意重点是提升留存率…… }, language: { type: string, required: false, description: 输出语言可选zh或en默认zh, example: zh } }, return_value: { type: object, properties: { summary: 会议内容概述, decisions: 会议决定事项列表, action_items: 待办事项列表每项包含task、owner、deadline } }, error_codes: { EMPTY_TRANSCRIPT: 输入的转写文本为空, NO_MEETING_CONTENT: 文本内容不像会议记录无法提取会议要素, LANGUAGE_NOT_SUPPORTED: 指定的输出语言暂不支持 } }然后是实现代码meeting_minutes.pyimport json from typing import Any class SkillError(Exception): def __init__(self, code: str, message: str): self.code code self.message message def run(transcript: str, language: str zh) - dict[str, Any]: # 1. 输入校验 if not transcript or not transcript.strip(): raise SkillError(EMPTY_TRANSCRIPT, transcript不能为空) # 2. 调LLM抽取结构化信息为了方便展示这里用模拟逻辑 # 实际项目中可以调用自有大模型或外部接口 content_preview transcript[:200] if 会议 not in content_preview and 讨论 not in content_preview: raise SkillError(NO_MEETING_CONTENT, 输入内容不像是会议记录) # 3. 假设模型成功抽取了以下结构 result { summary: 会议讨论了Q2目标与留存率提升策略。, decisions: [Q2核心目标是提升留存率], action_items: [ {task: 输出留存实验方案, owner: 李四, deadline: 2025-03-15} ] } return result最后是注册表逻辑registry.pyimport importlib.util import json import os from typing import Any SKILLS_DIR ./skills def load_skill_definitions() - dict[str, dict[str, Any]]: 扫描 skills 目录下的所有 JSON 定义文件构建技能元数据字典 registry {} for fname in os.listdir(SKILLS_DIR): if not fname.endswith(.json): continue with open(os.path.join(SKILLS_DIR, fname), r, encodingutf-8) as f: skill_def json.load(f) registry[skill_def[name]] skill_def return registry这里有个工程细节必须提一下技能的description和when_to_use字段是可以直接拼进System Prompt的。我用的时候会在每次请求时把所有注册技能的name和when_to_use整理成一个缩略版技能清单塞进Prompt让模型在“可选技能”的层面有全局认知。而完整版的参数定义等模型确认要调用某个技能后再传给Function Calling框架。这样做的好处是可以显著减小Prompt的体积同时模型选择技能时也没那么迷茫。3.3 技能注册表一个自动生成技能清单的小脚本技能多了之后手工维护Prompt里的技能清单会疯的。我写了段小脚本每次构建请求前自动把所有技能的name和when_to_use拼成一段系统提示词。贴上这段核心逻辑def build_skill_prompt(registry: dict[str, dict[str, Any]]) - str: lines [可用技能如下请根据用户请求自行判断是否调用] for name, meta in registry.items(): lines.append(f- {name}: {meta[when_to_use]}) return \n.join(lines)这个脚本的收益非常直观。新增一个技能只需要把定义文件放进skills/目录重启服务后Prompt自动带上新技能完全不用手工改Prompt。如果哪天发现某个技能模型总是误调用我可以临时在注册表里加一个enabled: false字段过滤逻辑跳过它实现灰度下线技能。这个“开关技能”的能力在线上调试时实在太有用了。4. 技能评估与迭代怎么知道Agent真的“会”了4.1 没有评测就没有迭代一套轻量级技能测试台技能写好了也接入Agent了但你凭什么说这个技能“好用”凭感觉肯定不行。我在项目里搭了一套轻量级的技能评测台专门用来量化每个技能的调用质量。评测台的核心是一批带标注的测试样本每个样本包含一个用户请求文本、期望调用的技能名、期望的参数取值、期望的输出结构。评测时系统把请求文本发给Agent记录它实际调用了哪个技能、传入什么参数、得到什么输出然后跟期望值做比对算出三个核心指标技能调用准确率正确调用的次数 / 总测试次数。这里的“正确”指技能名完全匹配。参数正确率在调用成功基础上参数中所有必填项是否都填了格式是否符合要求。任务完成率最终输出结构是不是符合预期有没有出现缺字段的情况。说实话第一轮跑下来结果挺打击人的能拿到60%以上调用准确率的技能都算不错参数正确率很多不到一半。但这其实是好事因为你现在有了一个可量化的基线之后每次改进描述、调整参数都能立刻看到分数的变化。没有评测就去调技能就像闭着眼睛修车改了半天也不知道有没有修好。4.2 从失败样本反推技能问题三个典型信号评测跑完会吐出一堆失败样本怎么从里面提取有效信息是有方法可循的。我把常见的失败模式编码成了三类典型信号每看一个失败样本先对号入座。第一类信号技能选对了参数填错了。 这个最常见说明技能描述中对参数的说明不够明确。修复办法就是去补充参数描述加示例、加格式说明、加边界条件。改完后重跑评测看参数正确率有没有提升。第二类信号应该调A技能结果调了B技能。 这说明两个技能的when_to_use描述边界没有划清楚模型产生了混淆。修复办法是把两条描述放在一起看找出它们对模型来说太相似的部分。比如“生成周报”和“生成日报”这两个技能描述如果都是“根据工作日志生成报告”模型当然会分不清。你要在描述里明确区分“周报覆盖周一到周五”“日报只覆盖当天”。第三类信号模型压根没调任何技能直接硬答。 这种情况往往是技能清单在Prompt里被淹没了。你塞给模型的上下文太多技能清单只占很小一块模型根本注意不到有技能可用。修复办法是把技能清单放到System Prompt靠前的位置并且主动提示“当前任务需要调用技能时必须从以下列表中选择”。如果你用的是比较强的模型也可以试试System Prompt里加一句话“在回答中注明是否使用了工具如果使用了写出技能名”这能强制模型对工具使用做出明确决策。4.3 技能迭代的版本管理别让Agent用上昨天的坏技能技能是会持续迭代的但你改了一版描述线上Agent如果还在用旧版本那评估等于白做。所以我专门给技能目录按版本管理用了一个非常简单的方案技能定义文件里加一个version字段每次修改递增小版本号技能实现文件和定义文件放在同一目录下修改后一起发布。真正的诀窍是不要让技能目录“原地更新”而是让每次发布产生一个新目录的历史快照。我实际操作中用了Git tag每次技能集稳定通过评估后就打一个类似skills-2025-03-release-01的标签。线上服务只加载指定tag路径下的技能目录这样你就永远不会出现“代码跑的是新逻辑Prompt里还是旧描述”的错乱。这一点对于多人协作的团队尤其重要。一旦技能库上了Git任何人对技能描述的修改都能追溯出问题也能回滚。别等到线上Agent行为突变了才想起来不知道自己改了什么。5. 常见问题与排查技巧实录5.1 技能“隐身”为什么Agent完全不调用某个技能症状技能已经注册在列表里测试时模型就是不调用它仿佛这个技能不存在一样。第一步先查注册表确认技能真的被加载了。很多时候是因为技能目录路径写错了或者JSON解析失败被静默跳过了。我的建议是注册表加载后打一条INFO级别的日志把技能数量打印出来多少技能成功加载、多少失败一目了然。第二步查Prompt。技能清单如果太长模型很可能会忽略后半段的技能。你可以试试把清单顺序调整一下或者采用我前面提到的“简要清单按需取参数”的模式只把每个技能的when_to_use放进去而不是把整段完整描述都塞进Prompt。实测下来Prompt精简之后长尾技能的调用率有明显提升。第三步查冲突。如果你的系统同时存在一套老的Function Calling配置和一套新的技能注册表模型可能优先走了老配置根本没看到新技能。检查一下系统里是不是有一套旧工具定义还活着。5.2 参数错乱大模型幻觉和类型转换症状模型调用了技能但把参数传成了完全离谱的值比如把城市名“北京市”传成了“北京是中国的首都”或者把时间描述原样塞进时间字段。这种问题八成出在参数说明不够明确。前面说过每个参数必须给格式示例这里不再重复。但我要补充一个更实际的技巧在你传给Function Calling框架的参数Schema里面description字段写得越具体模型表现越好。比如{ name: city, type: string, description: 城市名使用标准的城市中文名称例如“北京市”“上海市”不需要携带省份或国家信息。 }如果你试了还是出现幻觉参数可以在实现代码里做严格校验校验不通过就返回明确错误码。这样至少不会让错误参数继续污染下游逻辑同时错误码本身会成为评估和调试的重要信号。5.3 环境冲突多技能同时运行时状态污染症状技能A单独测没问题技能B单独测也没问题但把两个技能放到同一个Agent里技能A偶尔返回技能B的输出。这个问题我第一次遇到的时候排查了很久后来定位到是全局变量导致的。比如某个技能内部用了模块级别的变量来缓存中间状态两个技能同时被调用时缓存互相覆盖。解决办法很简单技能实现代码尽量不要用模块级可变状态所有中间数据都放在函数内部。如果必须用缓存请使用带命名空间的键比如cache_key f{skill_name}:{param_hash}这样不同技能之间的缓存绝对不会互相污染。5.4 排查工具箱调试三件套真心建议每个做Agent开发的同学都配齐这三个调试工具能省下大量“靠猜”的时间。第一个是日志面板。不管你是用LangSmith还是自己搭一定要能实时看到每次调用的完整链路模型输入了什么、模型决定调用哪个技能、传了什么参数、技能执行结果是什么、错误信息是什么。没有这个排查问题基本靠缘分。第二个是回放工具。把线上请求的完整输入输出保存下来能够随时重放同一个请求。因为LLM有随机性重放可能复现不了完全一致的错误但是大概率能看到同一类问题这个能力对定位Bug极其重要。第三个是Diff工具。记录技能描述每次修改前后的内容专门用来观察“这次改动到底是帮了模型还是害了模型”。配合评测台你可以做A/B对照同一批测试样本旧描述跑一遍新描述跑一遍看指标变化用数据说话而不是靠感觉拍板。6. 写在最后给初入这个方向的人几点建议如果你正在做Agent开发我强烈建议你认真考虑引入agent-skills这种技能化的工作方式。不要觉得手头只有五六个工具没必要这套方法论越早建立越省事。等工具量到了几十个再回头来梳理边界成本会高很多而且很可能已经导致线上出现过莫名其妙的误调用了。我个人实操下来最大的体会是技能库的核心不是代码而是描述。一个技能能不能被大模型用对80%取决于描述怎么写代码反而是次要的。所以每写一个技能我建议你把一半时间花在写描述上写出场景、写出边界、写出示例。这事没有捷径就是反复迭代用评测数据说话。另外一个很重要的心得是不要试图一口气把所有功能都技能化。先挑两三个最高频、价值最大的能力做试点跑通这一整套流程——定义、实现、评估、发布。等团队里所有人都尝到了甜头看到了评测分数的提升再逐步扩展技能库的规模减少沟通阻力。最后再分享一个小技巧。当你写技能描述写得没灵感的时候可以去翻Agent历史对话里那些调用失败的日志。失败的调用是最佳的描述素材库一次误调用、一个错误参数就是一段没写清楚的描述。把这些真实案例翻译成描述里的条件句技能质量会以肉眼可见的速度提升。
