去年年底在重构一个内部 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, ge=1, le=10, 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(f"Skill {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( name=execute.__name__, description=description, params_model=params_model, execute=execute, category=category, version=version, ) registry.register(skill) return skill return wrapper具体使用如下:
@skill_skill( description="获取指定城市的当前天气,适合用户询问天气、温度、降雨概率时。", category="utility", ) 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(city=city) ...这样调度核心不关心技能内部怎么组织,下游技能只需要知道自己依赖哪个技能名。但我会在技能描述里显式声明依赖,比如"本技能需要依赖 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_ascii=False, indent=2)}\n" f"请严格按照Schema生成参数。" )调用流程简化为:
- 模型阅读技能目录,判断需要哪个技能。
- 模型输出技能名和参数摘要(也可以直接输出空参数)。
- 调度器找到技能详情,拼接到下一轮 prompt。
- 模型输出最终结构化参数。
- 调度器校验并执行技能。
这个流程让模型每次只需要关注一小段信息,准确率提高非常明显。代价是多了一轮交互,但很多场景下值得。后续也可以对高频技能做缓存,根据对话主题直接预加载几个可疑技能,减少试探轮次。
4.3 上下文裁剪:根据对话状态动态过滤技能目录
除了两级索引,动态裁剪也很关键。我的实现里维护了一个context_tags,也就是从当前对话中抽取的场景标签,比如"天气""新闻""SQL"。然后在构建目录时,根据标签过滤掉明显不相关的技能。比如用户问今天天气,就没必要把"数据库备份"这种运维技能展示给模型。
这个能力依赖于对用户意图的初步判断,不一定要很精确,只要能把候选集从二十个降到五六个,模型选择的准确度就能上一个台阶。我建议用一次快速的轻量分类来打标签,而不是让主 Agent 又做意图识别又做技能选择,否则每轮推理成本会高得离谱。
5. 实测:我把这套技术写作助手跑起来之后
5.1 场景设定与技能清单
为了验证 agent-skills 不是玩具,我做了一个相对完整的示例项目:一个技术写作助手。它需要完成资料搜索、网页内容摘要、代码示例获取、稿件素材整理、保存到 Notion 数据库这些任务。
当时注册的技能包括:
| 技能名 | 用途 | 依赖 |
|---|---|---|
| web_search | 搜索最新技术资料 | 无 |
| fetch_webpage | 抓取网页正文并转成纯文本 | 无 |
| extract_code | 从网页正文中提取代码块 | fetch_webpage |
| generate_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,发现没有禁止字符串强转成数字。 - 调整参数模型,增加
strict=True,让多余的类型强迫转换直接失败,反而让模型更容易理解错误信息。 - 再给校验错误设计了一条清晰的错误提示,包括出错字段、期望类型、传入值,要求模型重新生成参数。
这个链路最值得夸的一点是: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_search | 320 | 92% | 1.2s | 2025-01-10 |
| extract_code | 25 | 78% | 3.1s | 2025-01-08 |
定期看这张表,你能发现很多之前没注意的问题。比如extract_code调用次数少但成功率低,说明描述可能太窄,或者依赖的fetch_webpage返回内容不理想。这种数据驱动的迭代,比凭感觉改 prompt 高效得多。
7.3 技能描述也要做 A/B 测试
最后分享一个偏门但有效的经验:对高争议的技能描述做 A/B 测试。同一时间让一半流量看到描述 A,一半看到描述 B,统计技能选择的准确率、任务完成率。
我自己测试过web_search描述里加不加"不要使用"约束,结果加了之后误选率下降了 12%。看似一句话的差别,在几十个技能并存时影响会被放大。如果你没有完整 A/B 平台,至少可以在本地跑一个小样本对比,把两个描述各跑二十条测试用例,看看哪个更稳。
说实话,做到这一步,agent-skills 已经不再是一个简单的工具管理脚本,而是一套关于"如何让模型可靠地使用工具"的方法论。它的价值不在于某个具体的技能实现,而在于你能把每个能力变化都变成可测试、可回滚、可观测的过程。对我个人而言,这套机制最直接的好处是,我再也不怕业务方突然提"再加一个技能"的需求了——无非是写一个函数、配一个描述、跑一遍回归集的事。