
你被“模型选错工具”折磨过吗我遇到过。项目初期我把每个小操作都注册成function calling工具搜索、打开网页、解析PDF、发邮件、写文件全都平铺在工具列表里。结果用户说“帮我查一下XX公司最近动态”模型打开了邮件发送窗口。后来我把散落的工具重构成一套可插拔的能力单元仓库代号就叫 agent-skills。它解决的问题很简单让LLM智能体在面对几十个操作入口时能像人一样先判断“这是哪类任务”再按一套标准作业流程执行而不是在工具海洋里瞎猜。如果你也在做Agent或自动化应用或者正被工具描述词穷搞到头大这篇内容值得你看完。1. 为什么需要“技能层”工具函数与技能的差别在哪里1.1 工具函数“散装”模式的崩溃点在做 agent-skills 之前我经历了一段非常典型的“工具爆炸”阶段。当时Agent要支持的能力包括搜索公开网页、抽取网页正文、解析PDF、识别图片里的大段文字、读取Excel、执行一段Python代码、发邮件、写本地文件、调用内部API。为了赶进度我把这些能力全部注册成一个个独立的function直接传给模型。一开始只有十个左右效果勉强能接受等数量加到几十个问题开始集中爆发。最明显的就是模型经常选错工具。用户说“帮我把这个PDF里的结论整理到表格里”模型可能先调用“打开PDF”再去调“读取Excel”最后也没把结论整理出来。还有更常见的模型能选对工具但参数填得乱七八糟时间范围写成“最近三天”搜索API实际要求的是7d这种格式文件路径给了相对路径技能里处理路径的代码却是按绝对路径写的。当时我以为是模型能力不行反复换模型、调温度结果都没用。后来我看明白了问题不在模型在我的设计。把几十个零散工具直接暴露给模型相当于让一个实习生站在堆满零件的工作台前不给他任何流程手册却指望他每次都准确挑出合适的零件并把设备装好。模型天然不擅长在超长列表里做高密度选择。于是我决定把“工具”升级为“技能”把描述粒度从“单个动作”拉高到“一个完整的能力场景”。1.2 技能层的核心设计哲学给模型一份SOP那工具和技能的核心差别到底是什么工具描述的是“我能做什么动作”技能描述的是“什么场景下该用我、怎么用、出了问题怎么办”。一个人知道扳手怎么握不代表他能换轮胎但给一个人一份“换轮胎作业指导书”里面写了顶车位置、螺丝拆卸顺序、扭矩要求、滑丝处理方法他就有机会独立完成整个任务。技能层就是给模型的那份作业指导书。在 agent-skills 里每个技能不再只是一段Python函数和一句description而是一个完整的独立单元它有自己的元信息声明、参数校验、实现代码、依赖文件、测试用例甚至权限声明。模型看到的是收敛后的“技能清单”选中某一个技能后运行器才会把该技能的详细操作步骤注入上下文。这样既避免了把大量内部逻辑塞给模型也让每个技能可以单独打磨、单独测试、单独隔离权限。用表格对比一下这种差异方便你在自己项目里做判断对比维度工具函数Function Calling技能Skill粒度单个原子动作完整能力场景可含多个步骤描述重点参数和功能适用场景、触发条件、边界、错误恢复模型负担每次都要在长列表里单选先选技能再按技能内说明执行维护方式散落在各段业务代码中独立目录依赖、测试、权限内聚权限控制粗粒度所有工具同权限按技能声明所需权限便于沙箱隔离错误处理常常直接抛异常内建恢复路径给Agent可读的错误反馈我得强调一句这不代表 function calling 没有价值。它依然是技能层底下最核心的协议技能最终还是要通过函数的形态暴露给模型。只是你不该让模型直接面对那一堆零碎函数而是先暴露技能清单把实现细节藏在后面。2. agent-skills 的目录设计先把骨架搭稳2.1 每个技能一个目录天然隔离边界我最终采用的目录结构是这样agent-skills/ ├── runner/ # 技能运行器 注册逻辑 │ ├── loader.py │ ├── schema_validator.py │ └── sandbox.py ├── skills/ │ ├── web_search/ # 一个技能一个目录 │ │ ├── SKILL.md # 技能声明 │ │ ├── main.py # 技能实现 │ │ ├── requirements.txt # 独立依赖 │ │ └── tests/ │ │ └── test_search.py │ ├── pdf_extract/ │ │ ├── SKILL.md │ │ ├── main.py │ │ └── ... │ └── code_executor/ │ ├── SKILL.md │ ├── main.py │ └── ... ├── agents/ │ └── demo_agent.py # 接入了技能注册中心的最小Agent └── pyproject.toml为什么不让所有技能代码平铺在一个包里三个原因。第一依赖隔离。 web_search 要用 httpxpdf_extract 要装 pdfplumbercode_executor 可能要起一个临时子进程环境。技能之间不应该共享同一套依赖否则装一个技能就可能导致另一个技能跑不起来。第二权限边界清晰。沙箱执行时可以按目录判定技能声明的权限比如 code_executor 需要高风险权限web_search 只需要网络权限。给单个技能单独开沙箱比给整个Agent进程开权限要安全得多。第三版本管理方便。每个技能可以单独发版甚至用 Git submodule 组织成共享技能库多团队各自维护技能互不阻塞。2.2 SKILL.md技能的门面也是模型的说明书每个技能目录里最重要的文件是 SKILL.md。它的结构是 YAML frontmatter 加自由正文有点类似静态站点的 Markdown 文档。frontmatter 是给运行器和模型做“选择决策”用的正文是技能被选中后给模型看的具体执行说明。--- name: web_search version: 1.3.0 description: 在互联网上搜索公开网页内容返回标题、链接和摘要。适合查找事实、新闻、产品资料不适合访问需要登录的页面也不能读取本地文件。 permissions: network: true filesystem: none subprocess: false timeout: 30 inputs: - name: query type: string description: 搜索关键词尽量用完整短语不用URL编码 required: true - name: max_results type: integer description: 最多返回多少条结果默认5最大10 default: 5 outputs: - result: type: array description: 搜索结果列表每项包含title, url, snippet --- # web_search 在调用本技能前先确认用户意图是否满足“搜索公开网页”这一条件。 如果用户需要的是当前日期、天气、股票行情这类动态数据这个技能同样适用。 但如果是查询数据库或内部文档系统请改用 database_query 技能。 ## 使用步骤 1. 从参数中拿到 query。 2. 调用搜索引擎接口。 3. 对结果做去重排序。 4. 如果结果为空返回错误码 EMPTY_RESULTS。 ## 边界条件 - 不访问需要登录的页面。 - 不下载二进制文件。 - 只返回前 max_results 条结果。这份文件同时服务人和模型。模型主要通过 description 字段做技能选择自由正文则是当模型选中技能后由Agent执行器注入到上下文里的操作指引。所以 description 要克制正文可以放开写。很多项目的教训是正文写太短模型等于拿着残缺流程干活description 写太长模型还没选技能就已经被超大上下文弄晕了。2.3 搭骨架时踩过的坑第一坑技能名用了英文空格和大小写混拼比如Web Search、PDF_Extract_File。模型真的会记错名字而且不同模型对驼峰和下划线的处理不完全一致。后来我统一改成全小写加下划线snake_case并在加载时做了别名映射老调用还能兼容。第二坑description 写得像功能宣传册塞进了“支持并发、支持自定义超时、支持重试”这类实现细节。模型面对这种描述无法判断它该什么时候用只觉得这工具很全能于是什么都想让它做。描述应该写“什么时候用、什么时候千万别用”而不是“我有多强”。第三坑requirements.txt 不锁版本。demo时没事换台机器跑依赖大版本升级技能直接崩。踩了一次之后就老老实实锁版本并在CI里加“干净环境安装冒烟测试”的步骤。凡是能自动化验证的尽量不要靠人肉记忆。3. 让模型真正“会用”技能从描述到参数再到输出3.1 description 是路由信号不是宣传文案技能注册给模型时模型要在一次推理里决定从所有技能中选哪个。这个决策几乎完全依赖技能名和description。我的经验是description 最好采用“一句话触发条件 一句话反例 一两个核心关键词补充”的结构总长度控制在300个汉字以内。不要堆形容词不要写实现细节。举个例子差web_search 技能通过各大搜索引擎获取搜索结果支持高级检索语法可以自定义时间范围支持批量查询内置结果解析性能可靠。好搜索公开网页返回标题链接摘要。适合找事实、新闻、产品资料。不适合读本地文件、不适合查内部系统。第一版描述的问题是模型会高估能力范围。比如用户说“帮我把这份PDF里的结论搜出来”模型可能会误以为 web_search 能直接读PDF。正确描述要主动划清边界。不要怕在description里写“不擅长什么”这些否定信息才是真正帮助模型做路由决策的。3.2 参数Schema给模型看的注释比类型重要很多人在注册工具时只写一个JSON Schema雏形字段类型、required 标一下就完事。但我后来发现模型对参数的理解主要来自字段名和description当字段名有歧义时参数就会填错。比如搜索时间范围有人写time_range类型是字符串模型根本不知道格式是7d、1m还是2024-01-01~2024-02-01。正确的做法是在 description 里给一个具体格式并且配合examples{ type: object, properties: { query: { type: string, description: 搜索关键词完整短语不要URL编码, examples: [大模型 Agent 开源工具] }, time_range: { type: string, description: 时间范围可选值1d/7d/30d/1y/all, enum: [1d, 7d, 30d, 1y, all], default: all } }, required: [query] }如果参数值只能有几个固定选项务必用 enum如果格式有严格要求在 description 里写正则或直接给示例。这不算对模型的“溺爱”而是降低自由度的有效手段。Agent应用里参数合法率直接决定任务成功率一个填错的路径或者时间格式可能让后面的所有逻辑全部白跑。3.3 输出结构让Agent在下一步能接着用技能执行完不能只丢一段文本。比如 web_search 如果返回一段带HTML标签的文本模型还要自己解析如果技能返回 JSON模型就能直接提取字段去进行下一步。我统一用一套输出结构{ success: true, result: [...], error_code: null, message: 共找到6条结果 }错误时{ success: false, result: null, error_code: EMPTY_RESULTS, message: 没有搜索结果可以尝试更换关键词或扩大时间范围 }这里的 message 字段是写给模型看的补救建议不是单纯报错。模型拿到EMPTY_RESULTS之后自然会生成“那我换个关键词再搜一次”的下一步动作。如果你的技能只是返回一个空列表模型往往会愣住然后开始编造一个不存在的搜索结果这是Agent应用里非常危险的幻觉来源。所以不要指望Agent去猜失败原因直接把恢复路径写进错误消息。3.4 技能内错误恢复的落地方案在技能实现里我会在统一入口包一层 try/except把异常转成上文的错误结构。比如调用搜索接口超时如果是可重试的5xx错误技能内部自动重试两次如果重试还失败返回错误码SEARCH_API_ERRORmessage 里写“接口暂不可用建议稍后再试或缩小搜索范围”。这样模型不需要自己学会写重试逻辑因为技能层已经处理掉了。另外技能的幂等性也值得考虑。一个技能如果可能被Agent连续调用两次它不应该在第二次产生额外副作用。搜索、PDF抽取这类操作天然安全文件写入、发邮件、创建订单这类技能就要非常小心。我在 SKILL.md 里增加了一个side_effect字段标注该技能是否会产生外部副作用Agent执行前会据此做二次确认。这个字段看起来不起眼但上线后救过我好几次。4. 调试与评测技能库不是能跑通demo就够了4.1 每项技能都要有最小验证样本加入一个新技能时我会同时写一个tests/test_sanity.py用最小输入跑一遍断言输出结构是合法的。比如 web_search 的 sanity test 就是传一个简单的 query断言返回结果是数组数组里的每一项都有 title、url、snippet。CI 里对所有技能并行跑 sanity test确保技能之间不互相污染。这一步成本不高但能拦住大量低级问题。更关键的是这个 sanity test 要在干净的虚拟环境里跑。技能目录里的 requirements.txt 就是为这个准备的。很多项目里的技能在开发机上跑得好好的部署到新环境就崩绝大多数原因都是依赖没锁好。把“干净环境安装冒烟测试”加进CI之后这个问题基本绝迹。4.2 记录调用轨迹做成回归测试调Agent类应用时我最后悔的事就是没有从第一天开始记录调用轨迹。所谓调用轨迹就是每一次“用户请求 → 模型选择了哪个技能 → 参数是什么 → 技能返回什么 → 用户怎么反馈”的完整记录。后来我把这些记录转成回归测试集固定输入再跑一遍看技能选择有没有偏移。回归里最典型的一个案例我把某技能的 description 增加了一个“不适合做XX”的反例结果原本正确的选择反而飘了模型开始频繁选另一个技能。原因可能是总体上下文变长模型注意力被稀释。这类问题非常容易出现在“描述越写越长”之后。所以我的规则是每次改描述必须跑一遍回归集不能只看单条效果。4.3 我用来量化的几个指标指标含义我的达标线技能选择准确率正确技能是否被选中不低于95%参数合法率参数通过JSON Schema校验不低于98%任务成功率端到端用户目标达成不低于80%平均调用轮数完成任务所需模型调用次数越低越好技能返回可解析率输出能被下一步直接消费不低于99%参数合法率这个指标我单独看因为很多“Agent效果差”最后都能归因到参数填错时间格式错、路径写错、JSON里套了多余引号。技能消费的对象是模型而不是人所以参数校验要严格一点一旦非法就直接让模型重填不要硬着头皮执行。4.4 本地评测时我常用的套路本地评测我习惯用一个 fake LLM 来模拟“会按脚本调用技能的 Agent”而不是每次都烧真实模型。先用少量真实调用记录生成样本再在 pytest 里构造一个最小 agent runner把技能选择器配置成手工指定然后跑一遍技能逻辑。这样能快速发现技能实现本身的bug而不是把模型判断和技能实现混在一起排查。排查顺序也有讲究先怀疑技能实现再怀疑描述最后才怀疑模型。因为数据和代码的bug是确定的描述和模型是概率性的。我见过太多人一遇到Agent效果不稳就怪模型查下来却是技能输出结构不统一模型被迫去猜“这个结果到底成功了没”那它只能乱说。5. 把 agent-skills 接到真实 Agent 里加载、沙箱与生产细节5.1 动态加载与注册技能运行器最核心的一件事扫描 skills 目录解析 SKILL.md把技能注册成模型可调用的工具列表。简化版 loader 长这样import pathlib import yaml import importlib.util SKILLS_ROOT pathlib.Path(skills) def load_skills(): tools [] for skill_dir in SKILLS_ROOT.iterdir(): manifest_path skill_dir / SKILL.md if not manifest_path.exists(): continue raw manifest_path.read_text(encodingutf-8) frontmatter parse_frontmatter(raw) # 从 --- 之间取 YAML module load_skill_module(skill_dir, frontmatter[name]) tools.append({ type: function, function: { name: frontmatter[name], description: frontmatter[description], parameters: frontmatter[inputs], }, handler: module.run, }) return tools这里有个关键点模型看到的 parameters 一定是从 SKILL.md 里解析出来的 JSON Schema而不是实现函数的 Python 类型。我见过有人直接拿inspect.signature生成工具 schema结果 Python 的类型系统跟模型期待的不是一回事字段说明和描述全丢模型自然填错参数。宁可维护 SKILL.md 里的 schema也不要靠自动推断。5.2 沙箱隔离与权限声明权限是技能库上线前必须想清楚的事。web_search 只需要出网PDF抽取只需要读文件code_executor 可能要执行任意代码。这些技能绝不能跑在同一个无限制进程里。我在 manifest 里用 permissions 字段声明runner 拿到权限后决定用普通 subprocess 还是容器环境permissions: network: true filesystem: read_temp subprocess: false实际落地时我用的是一个带超时控制的 subprocess runner每个技能跑在独立进程里超过 timeout 直接杀掉返回 TIMEOUT。对于需要更强隔离的技能可以放到容器里通过标准输入输出传递参数和结果。技能的输出只允许走 JSON 通道不能有别的旁路这样沙箱边界才清晰。5.3 超时、并发与缓存LLM 应用里模型调用本身就有延迟技能执行再拖几秒用户体验会很差。我给技能设置了默认30秒超时代码执行类技能可以放宽到120秒但必须给模型提示“这个操作可能需要更久”。并发方面用 semaphore 控制同时运行的技能数量尤其是搜外部 API 时防止自己的 key 被打爆。结果缓存也是容易忽视的点。同一个技能对于相同参数结果往往是一样的。我在 runner 层做了“参数哈希 → 结果”缓存TTL 按技能类型配置web_search 可以设5分钟PDF 抽取结果不变就设24小时。这里要注意有副作用的技能不能开缓存。side_effect 字段这时候又派上用场了。5.4 多技能协作的路径选择最开始我把所有技能平铺给模型让模型自由选。技能少还行到20个以上时模型的选择开始漂。我的方案是对技能做分组路由。比如“信息获取类”“文件操作类”“代码执行类”先用一个轻量分类或 embedding 相似度把候选集缩到5个以内再让模型在这些候选项里选。这样模型的选择压力小很多准确率也会明显上来。如果你的 Agent 已经有比较固定的工作流也可以直接用 workflow 把技能串起来。比如“搜索资料 → 抽取正文 → 总结成 Markdown”这三个技能用一段 DSL 编排模型只在关键分叉点做选择。这个思路比让模型完全自由发挥可靠得多尤其适合面向外部用户的 Agent。6. 当前局限、设计取舍和下一步打算6.1 先说说这套设计“不擅长”的事agent-skills 把每个技能做成了独立目录在中小规模场景下非常舒服但当技能数量膨胀后会遇到三个问题。第一个是选择问题四五十个技能如果全部注入到模型上下文token 消耗会明显上升而且模型在长列表里选技能的错误率也会上升如果只注入一部分又可能漏掉真正需要的技能。第二个是协作问题技能是孤立的彼此之间不知道对方产生什么数据模型只能靠上下文把前一个技能的输出传给下一个这个链路易断。第三个是版本问题技能升级后老的 Agent 还在用旧版本如果不做版本锁定功能变化会直接影响线上效果。这些都不是 agent-skills 目前能完全解决的。我现在的处理方式很务实技能列表超过一定规模就对技能做优先级分级高频技能进主列表低频技能进“候选池”靠额外的检索步骤召回。6.2 我在取舍上坚持的两个原则第一个原则技能描述是核心资产不是附属品。在设计 agent-skills 时我默认 SKILL.md 里的描述需要跟代码一起评审、一起测试甚至比实现代码更值得打磨。因为代码只影响执行正确性描述还影响模型会不会选到这个技能、选到之后能不能正确执行。第二个原则不要把技能做成“万能瑞士军刀”。如果一个技能什么都能干它的描述一定会变得模棱两可模型会高频误选。宁可把“搜索网页”和“搜索数据库”拆成两个技能也不要写一个“万能搜索”然后在内部做一堆分支判断。技能边界越清晰模型路由越容易。6.3 我接下来的三个演进方向我会继续把技能库往“可路由、可组合、可评测”这三个方向补。可路由就是给 SKILL.md 增加向量索引字段做语义预筛降低模型面对长技能列表的选择压力。可组合就是增加requires_skills、produces_data这类显式依赖让运行器能做数据流拼接而不是全靠模型用上下文传参。可评测就是把每个技能的成功标准写进测试集每次技能更新先跑评测过了才允许发布。这个路线可能不会让 agent-skills 变成一个庞大的框架但至少能让“技能”这个单元越来越接近工程化。对一个要长期迭代的Agent项目来说单元稳定比框架花哨重要得多。6.4 一个实用的起步建议如果你也想在自己项目里沉淀一套技能库不用急着照搬全部设计。先挑一个你每天都会手写的操作比如“网页正文提取”或“PDF转Markdown”把它按 SKILL.md main.py tests 三件套拆出来跑两个真实场景。跑顺以后把第二个、第三个技能加进来你会发现自己的Agent行为开始变得有规律调试效率也比散装工具时代高很多。这也是我整理 agent-skills 时最大的收获——先形成最小闭环再谈规模。技能库从三个能打的核心技能开始永远比一开始就堆五十个没人维护的“花架子”技能更有实用价值。