
去年年底在重构一个内部 AI 助手时我反复遇到同一个尴尬模型本身很聪明但它总是用错工具、漏传参数甚至在不需要查询时硬去搜索。后来我把所有工具调用收拢成一套独立管理机制也就是现在这个名为 agent-skills 的技能注册与调度体系才真正把会对话的模型变成会办事的模型。这篇文章不聊空理论只讲我实际怎么设计、怎么落地以及中间踩过的那些坑给准备做 Agent 技能编排、工具库管理、Function Calling 架构的同学一个可参考的样本。1. 为什么我会专门抽一层技能库来管 Agent 的能力1.1 之前把所有工具写死在代码里问题有多严重最初做 Agent 原型时我的写法非常直接在系统提示词里把所有工具描述写进去然后代码里放一堆 if-else 或者 match-case把模型返回的函数名映射到实际函数。这个方案在只有两三个工具时很好用但一旦超过十个麻烦就接踵而至。首先是提示词越来越长每次调用都要携带全部工具描述token 消耗大而且模型对后边的工具描述记忆明显减弱。其次是新增一个工具要改多处代码——注册、描述、参数校验、异常处理漏一处就出 Bug。第三是工具之间会有交叉依赖比如搜索和抓取网页经常要组合使用但在硬编码结构里这种组合逻辑完全没法复用。最后逼我动手重构的导火索是一次演示翻车模型明明应该调用查数据库技能却因为描述里有个模糊词误选了搜索文件。那时候我意识到不能把工具当散兵游勇得把它们变成一个结构化的技能资产来治理。1.2 agent-skills 的设计目标我需要的不是一个框架而是一套轻量级的规范。当时列了几个硬性要求每个技能有独立、自描述的结构包含名称、用途、参数校验规则、执行体。技能注册是声明式的新增技能不需要改动调度主逻辑。模型看到的技能目录是动态生成的可以根据对话上下文裁剪而不是一股脑全塞进去。技能之间可以有显式的依赖关系允许一个技能内部调用另一个技能。这套规范我起名叫 agent-skills后面就是按这个思路一步步实装的。它的本质是把模型可以调用什么这件事从代码中解耦出来变成可注册、可发现、可度量、可淘汰的资源。1.3 和 Function Calling、插件体系的关系提一句容易混淆的概念。OpenAI 的 Function Calling 是模型输出结构化调用指令的能力LangChain 的 Tool 则是将函数包装成模型可用形式的抽象。agent-skills 更接近一个技能管理层它位于模型和实际工具函数之间负责技能的登记、索引、描述生成、参数校验和调用编排。你可以理解成 Function Calling 是通信协议Tool 是单个接口而 agent-skills 是管理这些接口的注册中心和调度器。这样分层之后换一个底层模型、换一种 Function Calling 实现技能定义不用大改上层业务代码也不受影响。2. 技能的标准结构一个可被模型读懂和执行的单元2.1 核心组成身份、Schema、执行体在 agent-skills 里我定义了一个技能的最小单元它必须有四个部分name机器可读的技能标识用 snake_case比如 web_search。description给模型看的人类可读说明必须包含什么时候用、什么时候不要用。parameters参数结构定义使用 Pydantic 模型自动生成 JSON Schema。execute异步执行函数负责完成具体动作并返回结构化结果。这个结构参考了 OpenAPI 规范和 Anthropic 的 tool use 格式但为敏捷开发做了一些简化。下面是实际代码里 Skill 类的核心片段。from typing import Any, Callable, Optional, Type from pydantic import BaseModel, create_model import json class Skill: def __init__( self, name: str, description: str, params_model: Type[BaseModel], execute: Callable[..., Any], category: str general, version: str 1.0.0, ): self.name name self.description description self.params_model params_model self.execute execute self.category category self.version version property def param_schema(self) - dict: # 由 Pydantic 模型直接生成 JSON Schema供模型侧使用 return self.params_model.model_json_schema() async def run(self, **kwargs) - Any: # 入口处统一做参数校验失败时给出可读错误信息 validated self.params_model(**kwargs) return await self.execute(**validated.model_dump())这里最容易被忽略的一点是参数校验不能放到执行函数内部做必须在技能入口统一做。因为模型返回的参数经常有缺漏、类型错误如果每个技能里各写各的校验很快就会出现同一个错误在不同技能上报错格式不一致的情况。统一在 run 里做校验后续做日志审计、指标收集都会方便很多。2.2 为什么要用 Pydantic 自动生成 Schema而不是手写 JSON我见过不少项目直接在代码里手写 JSON Schema比如{ type: object, properties: { query: {type: string, description: 搜索关键词} }, required: [query] }第一次写没问题但技能有二十个以后字段一改手写的 JSON 经常忘记同步。模型拿到的 Schema 和实际执行函数对不上后果就是调用时报参数错误甚至是更隐蔽的漏参。用 Pydantic 之后参数模型就是唯一事实来源。比如我定义搜索技能from pydantic import BaseModel, Field class WebSearchParams(BaseModel): query: str Field(description搜索关键词尽量精确) max_results: int Field(3, ge1, le10, description返回结果数量) region: str Field(zh-CN, description搜索区域) async def web_search(query: str, max_results: int, region: str) - list[dict]: # 实际调用搜索 API ...param_schema会直接生成{ properties: { query: {description: 搜索关键词尽量精确, title: Query, type: string}, max_results: {default: 3, description: 返回结果数量, maximum: 10, minimum: 1, type: integer}, region: {default: zh-CN, description: 搜索区域, type: string} }, required: [query], title: WebSearchParams, type: object }这样写的好处不仅是少改一份文件更重要的是Pydantic 的 Field 约束ge、le、枚举等会直接变成模型可读的约束信息模型生成参数时会更少越界。实测下来参数非法导致的重试次数下降了约 40%。2.3 技能描述怎么写模型才听得懂这是整个技能库里最软但也最关键的部分。我发现很多团队把描述写成一句话简介比如执行搜索模型根本选不准。我的经验是描述里必须写清使用场景和不要使用的场景而且要给出正反例。下面是我常用的一段描述使用场景当用户需要查询实时信息、获取最新新闻、查找某个机构/人物/产品的当前情况时。 不要使用如果用户只是问概念解释、历史知识且不要求最新信息请使用 knowledge_base 技能。这段描述直接把搜索技能和知识库技能区分开了。模型对什么时候不要用特别敏感因为大量误选都发生在两个技能边界模糊时。后面我还会专门讲怎么靠边界描述来提升技能选择的准确率。3. 注册中心与动态发现让新建技能像插线板一样简单3.1 基于装饰器的注册机制有了技能定义下一步是把它们收集到一个注册中心里。我用的方式是在模块加载时通过装饰器自动注册。先定义全局注册表from typing import Dict from dataclasses import dataclass, field class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] {} def register(self, skill: Skill) - Skill: if skill.name in self._skills: raise ValueError(fSkill {skill.name} already registered) self._skills[skill.name] skill return skill def get(self, name: str) - Skill: return self._skills[name] def all(self) - list[Skill]: return list(self._skills.values()) registry SkillRegistry() def skill_skill( description: str, category: str general, version: str 1.0.0, ): def wrapper(params_model: Type[BaseModel], execute: Callable): skill Skill( nameexecute.__name__, descriptiondescription, params_modelparams_model, executeexecute, categorycategory, versionversion, ) registry.register(skill) return skill return wrapper具体使用如下skill_skill( description获取指定城市的当前天气适合用户询问天气、温度、降雨概率时。, categoryutility, ) class GetWeatherParams(BaseModel): city: str Field(description城市中文名) async def get_weather(city: str) - dict: ...装饰器把函数名作为技能名参数类作为 Schema 定义整个流程非常轻。新加一个技能时只有模块被 import 进来技能就会自动进入注册表主调度逻辑完全不用改。这基本就是插件架构的低配版但已经能很好地满足需求。3.2 为什么不用硬编码列表很多人会问项目不大直接在列表里写清楚不就行了我早期也这么做过但吃了几次亏之后决定改用注册中心。一次是多人协作时同事加了一个技能但导入了模块却忘记在列表里追加结果模型看不到这个技能。还有一次是写测试时需要隔离技能集合硬编码列表让测试非常别扭。改用注册中心后测试时可以轻松构造一个临时 Registry 并注入 mock 技能再也不用担心污染公共列表。另外自动扫描还有利于做按需加载。大项目里技能可能涉及重依赖比如 PDF 解析技能要导入一堆库。我可以在技能模块里做懒加载注册阶段只存描述和 Schema执行时才真正 import 重依赖库。这样应用启动速度和内存占用都更可控。3.3 技能间依赖从重复实现到组合调用技能库管理工具多了以后第二个高频需求就是技能复用。比如天气查询和穿衣建议两个技能后者应该组合前者而不是重新实现一遍天气逻辑。我采用的方式是在 Skill 执行体里可以直接拿到注册表实例async def dressing_advice(city: str, temp: float) - str: weather_skill registry.get(get_weather) weather await weather_skill.run(citycity) ...这样调度核心不关心技能内部怎么组织下游技能只需要知道自己依赖哪个技能名。但我会在技能描述里显式声明依赖比如本技能需要依赖 get_weather 获取实时温度这样模型选择组合型技能时会更清楚它背后的成本。不过这里也要提醒一句技能间调用会增加一次模型调度延迟如果可以尽量在同一个执行函数内并行调用多个基础技能而不是串行依赖。我后面会专门讲性能优化。4. 让模型知道用什么技能技能选择提示词的组装策略4.1 全量技能都塞进 Prompt 是最蠢的做法一开始我天真地把所有技能的名字、描述、参数规则全部塞进 system prompt。技能数量只有五个的时候还行到十五个以后模型的选择准确率明显下降token 消耗也让人肉疼。后来我统计了一次完整对话的平均 token 消耗系统提示词里技能描述占了 60% 以上而实际单轮对话中模型通常只需要两三个技能。也就是说绝大部分信息是冗余的反而干扰了模型的注意力。4.2 两级索引先选技能再补详情我的解法是两级索引策略。第一级维护一个精简的技能目录每个技能只保留 name、一句话简介、使用场景、参数约束摘要。这个目录尽量控制在模型能一屏看完的规模目标是让模型快速定位候选技能。第二级当模型在回复中表示需要调用技能 X时调度器再把技能 X 的完整参数 Schema、详细描述、示例注入到下一轮上下文里让模型严格按 Schema 生成参数。具体实现上我在 system prompt 里放这样的模板可用技能目录 {skills_catalog} 如果你需要完成某个操作请先输出要使用的技能名称以及对应的参数 JSON。def build_skills_catalog(skills: list[Skill]) - str: lines [] for s in skills: lines.append( f- {s.name}: {s.description.split(使用场景)[0].strip()} ) return \n.join(lines) def build_skill_detail(skill: Skill) - str: return ( f技能名称: {skill.name}\n f完整描述: {skill.description}\n f参数Schema: {json.dumps(skill.param_schema, ensure_asciiFalse, indent2)}\n f请严格按照Schema生成参数。 )调用流程简化为模型阅读技能目录判断需要哪个技能。模型输出技能名和参数摘要也可以直接输出空参数。调度器找到技能详情拼接到下一轮 prompt。模型输出最终结构化参数。调度器校验并执行技能。这个流程让模型每次只需要关注一小段信息准确率提高非常明显。代价是多了一轮交互但很多场景下值得。后续也可以对高频技能做缓存根据对话主题直接预加载几个可疑技能减少试探轮次。4.3 上下文裁剪根据对话状态动态过滤技能目录除了两级索引动态裁剪也很关键。我的实现里维护了一个context_tags也就是从当前对话中抽取的场景标签比如天气新闻SQL。然后在构建目录时根据标签过滤掉明显不相关的技能。比如用户问今天天气就没必要把数据库备份这种运维技能展示给模型。这个能力依赖于对用户意图的初步判断不一定要很精确只要能把候选集从二十个降到五六个模型选择的准确度就能上一个台阶。我建议用一次快速的轻量分类来打标签而不是让主 Agent 又做意图识别又做技能选择否则每轮推理成本会高得离谱。5. 实测我把这套技术写作助手跑起来之后5.1 场景设定与技能清单为了验证 agent-skills 不是玩具我做了一个相对完整的示例项目一个技术写作助手。它需要完成资料搜索、网页内容摘要、代码示例获取、稿件素材整理、保存到 Notion 数据库这些任务。当时注册的技能包括技能名用途依赖web_search搜索最新技术资料无fetch_webpage抓取网页正文并转成纯文本无extract_code从网页正文中提取代码块fetch_webpagegenerate_summary调用大模型对文本生成摘要无save_to_notion将整理好的内容保存到数据库无5.2 效果对比技能选择准确率从 68% 提到 94%我准备了一百条真实用户问句作为测试集覆盖搜索、摘要、保存、组合任务等类型。在没做技能库管理、所有工具硬编码、描述也很简陋的情况下模型正确选择技能的比例只有 68%也就是三成的情况下选出了错误的工具。经过技能结构标准化、两级索引、动态裁剪和描述优化之后同样的一百条样本技能选择准确率提升到了 94%。误选主要发生在fetch_webpage和extract_code之间的调用顺序上后来靠强化示例才压下去。token 消耗方面也有明显改善。没优化前每轮对话平均在系统提示词上花费约 1800 token优化后因为只注入目录和必要的技能详情平均降到 700 token 左右整体对话成本下降了约 60%。当然这个数据跟具体技能数量和模型上下文能力有关系但方向是通用的。5.3 一个让我意外的发现技能执行结果也需要结构化回填做到一半我发现技能执行完返回的数据不能原样丢给模型。直接返回一长串网页全文不仅浪费 token而且模型难以提取重点。后来我给技能加了一层format_result让每个技能返回结构化且精炼的结果摘要。比如搜索技能返回的不是完整结果列表而是每个结果的标题、URL、时间、一句话摘要。这个改动让后续对话的上下文变得更干净模型在引用资料时也更准确。我建议给每个技能准备一个result_summary方法或者至少对返回内容做一个 token 上限截断。这比在主提示词里写请忽略无关内容有效得多。6. 最容易翻车的三个细节与我的完整排查链路6.1 现象模型总把参数类型搞错第一次上线时模型调用web_search时把max_results传成了字符串 5。Pydantic 其实会自动做类型转换但如果是字符串 abc 就会直接报错。本来我以为校验失败会让模型自己重试但发现模型报错后经常不知道该改成什么。排查链路我先在日志里打印每次技能执行的validated参数确认错误来源是类型强制转换失败。然后检查 Pydantic 的model_config发现没有禁止字符串强转成数字。调整参数模型增加strictTrue让多余的类型强迫转换直接失败反而让模型更容易理解错误信息。再给校验错误设计了一条清晰的错误提示包括出错字段、期望类型、传入值要求模型重新生成参数。这个链路最值得夸的一点是Pydantic 的严格模式一开始就要开。如果不严格很多隐性类型问题会在技能执行阶段才炸出来而且定位成本更高。6.2 现象两个技能描述太像模型反复选错有一次模型在查天气和查空气质量之间反复横跳几乎没什么规律。我把两个技能的完整描述拿出来逐字对比发现都写着用于查询城市的当前环境信息。这显然不行。排查链路我写了一个小脚本对所有技能描述做两两相似度计算用简单的关键词重叠度发现get_weather和get_air_quality的相似度最高。为每个技能重写了描述补充明确的边界场景。比如空气质量技能必须提到AQI、PM2.5、污染天气技能必须提到温度、湿度、降雨。在测试集上加了两条容易混淆的用例比如今天出门要不要戴口罩应该选空气质量今天会不会下雨应该选天气。之后我把技能描述相似度检查加入了 CI。每次提交代码时自动跑一遍如果发现两个技能描述相似度过高就报警提示人工review。这个工具对团队协作特别有用。6.3 现象上下文里技能太多模型瞎选有段时间我的技能数量增加到二十多个即便做了目录精简模型还是时不时选出一个跟当前话题八竿子打不着的技能。排查链路我记录了模型每次选择的 log发现误选大多发生在对话较长的中后段。进一步检查发现前置对话把模型注意力带偏了尤其是之前提到过某个技能模型容易惯性选择。于是在构建新一轮技能目录时我显式把当前用户问题放在目录之前让模型先明确问题再看技能。另外把技能目录按类别折叠默认只展示用户当前场景可能相关的类别收起无关类别。改完以后长对话中的误选率明显下降。这说明技能选择不是纯靠模型理解能力输入的结构化程度对结果影响很大。7. 技能治理版本、评估与淘汰机制技能库不是一劳永逸的。加了新技能可能挤压旧技能的选择空间改了一个技能的描述可能影响其他技能的边界。我后来慢慢把这套东西当成一个需要治理的代码库来对待。7.1 每个技能都要有版本和负责人我在 Skill 结构里增加了author和version字段。虽然听起来不重要但在多人协作时版本标签能让日志里的调用记录对应到一个明确的代码版本。线上出问题后git blame加version能快速定位是谁改过、什么时候改的。7.2 建立技能选择回归集我强烈建议项目里至少准备 50 到 100 条标注好的意图 - 技能 - 参数测试用例。每次修改技能定义后跑一遍回归集统计技能选择准确率和参数生成准确率。再进一步可以给每个技能单独维护一条技能热度和技能错误率。如果一个技能连续半个月没被调用或者调用后频频报错就该考虑下线或重写描述。我用一张简单的表来跟踪技能名调用次数成功率平均耗时最后调用时间web_search32092%1.2s2025-01-10extract_code2578%3.1s2025-01-08定期看这张表你能发现很多之前没注意的问题。比如extract_code调用次数少但成功率低说明描述可能太窄或者依赖的fetch_webpage返回内容不理想。这种数据驱动的迭代比凭感觉改 prompt 高效得多。7.3 技能描述也要做 A/B 测试最后分享一个偏门但有效的经验对高争议的技能描述做 A/B 测试。同一时间让一半流量看到描述 A一半看到描述 B统计技能选择的准确率、任务完成率。我自己测试过web_search描述里加不加不要使用约束结果加了之后误选率下降了 12%。看似一句话的差别在几十个技能并存时影响会被放大。如果你没有完整 A/B 平台至少可以在本地跑一个小样本对比把两个描述各跑二十条测试用例看看哪个更稳。说实话做到这一步agent-skills 已经不再是一个简单的工具管理脚本而是一套关于如何让模型可靠地使用工具的方法论。它的价值不在于某个具体的技能实现而在于你能把每个能力变化都变成可测试、可回滚、可观测的过程。对我个人而言这套机制最直接的好处是我再也不怕业务方突然提再加一个技能的需求了——无非是写一个函数、配一个描述、跑一遍回归集的事。