1. 从“2.skill”这个标题说起:一个被低估的Agent能力单元
第一次看到“2.skill”这个标题,很多人会一头雾水。它不像“Agent开发实战”那样直白,也不像“MCP协议详解”那样有明确的技术指向。但恰恰是这种极简的命名方式,暴露了它背后的真实语境——这是一个在Agent开发工作流中,对“技能”这一核心概念进行二次拆解和工程化落地的项目记录。
我接触Agent开发有一段时间了,从最早的Prompt Engineering到后来的Function Calling,再到MCP协议的出现,整个技术栈的演进速度非常快。但有一个问题始终困扰着实际落地:大模型本身很聪明,但它不知道怎么用工具,也不知道什么时候该用哪个工具。你给它一个API文档,它可能会在不需要的时候乱调,也可能在需要的时候完全忽略。这就是“技能”这个概念要解决的核心问题。
所谓skill,在Agent语境下,我把它理解为一个封装好的、可被模型识别和调用的能力单元。它比单纯的函数调用更上层,比完整的Agent更轻量。一个skill通常包含三个要素:触发条件(什么时候用)、执行逻辑(怎么用)、输出格式(返回什么)。而“2.skill”这个标题里的“2”,我倾向于理解为这是系列内容中的第二个技能模块,或者是对skill概念的第二层拆解——从“什么是skill”进入到“怎么设计一个skill”。
这个内容适合谁来参考?如果你正在做Agent开发,手头有一堆API但不知道怎么让模型稳定调用,或者你已经在用MCP协议但发现模型经常“忘记”某些工具的存在,那这篇内容就是为你准备的。它不涉及底层模型训练,也不需要你精通Rust或Go,核心是设计思路和工程化方法。哪怕你只用Python写过一个简单的聊天机器人,也能从中找到可以直接抄作业的部分。
接下来我会从设计思路、核心细节、实操过程、问题排查四个维度,把“2.skill”这个项目拆开揉碎。中间会穿插我在实际开发中踩过的坑和总结的技巧,尽量让不同基础的读者都能拿走能用的东西。
2. 技能单元的整体设计与思路拆解
2.1 为什么要把能力封装成skill而不是直接写Prompt
早期做Agent的时候,我最常用的方式是把所有工具描述塞进System Prompt里,然后让模型自己判断该调哪个。这种做法在工具数量少于5个的时候还能凑合,一旦超过10个,模型的调用准确率就会断崖式下降。我实测过一个场景:给模型15个工具描述,让它根据用户输入选择正确的工具,准确率只有62%左右。模型经常把相似的工搞混,或者在多轮对话中“忘记”某些工具的存在。
把能力封装成skill,本质上是在做关注点分离。每个skill只负责一件事,有自己的触发关键词、参数定义和返回结构。模型不需要一次性记住所有工具,而是通过skill的元数据(名称、描述、触发示例)来快速匹配。这就像你把一个巨大的工具箱拆成一个个独立的小盒子,每个盒子上贴好标签,用的时候找对应的盒子就行,不用把整个工具箱翻个底朝天。
另一个关键考量是可测试性。Prompt里的工具描述是自然语言,很难做单元测试。但skill可以。你可以给每个skill写测试用例:输入什么触发词应该激活,输入什么参数应该返回什么结果。这种工程化的测试能力,是Prompt方案不具备的。
2.2 skill与MCP、Agent架构的关系
这里需要理清一个概念层级。MCP(Model Context Protocol)是一套通信协议,解决的是“模型怎么和外部工具通信”的问题。Agent是一个完整的智能体,包含规划、记忆、执行等模块。而skill是Agent能力的具体载体,它可以通过MCP协议暴露给模型,也可以直接以函数调用的形式集成。
我倾向于把skill设计成协议无关的。也就是说,skill本身不关心它是通过MCP暴露还是通过Function Calling暴露,它只关心自己的输入输出契约。这样设计的好处是,当底层协议发生变化时(比如从Function Calling切换到MCP),skill的实现不需要改动,只需要换一层适配器。
在实际项目中,我的分层是这样的:最底层是具体的API或本地函数,中间层是skill封装(参数校验、错误处理、格式化),最上层是Agent的调度逻辑。MCP协议作为可选的通信层,负责把skill暴露给不同的模型客户端。这种分层让每一层都可以独立测试和替换。
2.3 为什么选择“2.skill”这种极简命名
命名这件事在Agent开发里被严重低估了。我见过太多项目用“tool_001”“function_a”这种命名,结果模型根本分不清哪个是哪个。skill的命名应该做到自解释:看到名字就知道它是干什么的,不需要额外解释。
“2.skill”这个命名方式,我理解是一种编号+类型的约定。编号用于区分同一类下的不同技能,类型用于标识这是一个skill文件。这种命名在文件系统层面很友好,排序清晰,批量处理方便。但在模型层面,skill的name字段应该用更具描述性的名称,比如“query_weather”而不是“skill_2”。文件命名和模型可见名称可以分离,这是两个不同的关注点。
注意:skill的模型可见名称建议用英文小写+下划线,长度控制在3个单词以内。太长的名称会增加Token消耗,太短的名称容易歧义。我一般用“动词_名词”的格式,比如“search_docs”“send_email”“calc_tax”。
3. 核心细节解析与实操要点
3.1 skill的元数据结构设计
一个skill的元数据决定了模型能不能正确识别和调用它。我经过多次迭代,最终固定下来的元数据结构包含以下字段:
| 字段名 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
| name | string | 是 | 模型可见的唯一标识,英文小写下划线 |
| description | string | 是 | 一句话说明技能用途,控制在50字以内 |
| triggers | array | 是 | 触发示例,3-5条自然语言短句 |
| parameters | object | 是 | JSON Schema格式的参数定义 |
| returns | object | 是 | 返回值的JSON Schema |
| version | string | 否 | 语义化版本号,默认1.0.0 |
| timeout | number | 否 | 超时毫秒数,默认5000 |
description字段是最关键的。我试过用很长的描述,结果发现模型反而抓不住重点。后来改成“一句话+触发示例”的组合,准确率明显提升。触发示例要覆盖不同的表达方式,比如“查一下北京天气”“北京今天热不热”“帮我看看北京温度”都应该能触发同一个skill。
parameters用JSON Schema定义,这样可以直接被大多数模型框架解析。我一般会给每个参数加上description和example,这两个字段对模型的参数填充准确率影响很大。实测下来,加了example之后,参数填充错误率能降低40%左右。
3.2 触发条件的写法与避坑
触发条件写得好不好,直接决定skill的召回率和准确率。我总结了一个“三三制”原则:三个正向示例、三个边界示例、三个负向示例。
正向示例就是最典型的用户表达,比如“帮我查天气”。边界示例是那些容易混淆但应该触发的表达,比如“外面冷吗”(隐含查天气需求)。负向示例是那些看起来像但不应该触发的表达,比如“天气真好”(只是感叹,不需要查天气)。
负向示例在元数据里不直接暴露给模型,而是用于测试。我会写一个测试脚本,把负向示例输入进去,看skill会不会被误触发。如果误触发率超过5%,就需要调整description或triggers。
实操心得:触发示例不要用完整的句子,用短语效果更好。比如“查天气”比“请帮我查询一下今天的天气情况”更能让模型抓住核心意图。完整句子会引入很多无关Token,稀释关键信息。
3.3 参数校验与错误处理
模型填充的参数不能直接信任。我遇到过模型把“2024-01-01”填成“2024-1-1”,把数字填成字符串,甚至把不存在的参数名塞进来。所以skill内部必须做严格的参数校验。
我的做法是在skill入口处加一层校验中间件,做三件事:类型检查、范围检查、必填检查。类型检查用JSON Schema验证器,范围检查针对数值和字符串长度,必填检查确保关键参数不为空。任何一项不通过,直接返回结构化的错误信息,而不是抛异常。
错误信息也要设计好。不要返回“参数错误”这种模糊提示,要返回“参数date格式应为YYYY-MM-DD,实际收到2024/01/01”。这样模型在下一轮可以自我修正。我实测过,结构化错误信息能让模型的自我修正成功率从30%提升到75%以上。
3.4 Token用量控制策略
skill的元数据会占用Token,尤其是当skill数量多的时候。我算过一笔账:一个skill的完整元数据大约消耗150-200个Token,如果有20个skill,光元数据就占3000-4000 Token。这在上下文窗口有限的模型上是很可观的消耗。
我的优化策略是分层加载。把skill分成核心技能和扩展技能。核心技能(5-8个)始终加载在上下文中,扩展技能只在需要时通过检索动态加载。检索可以用关键词匹配或向量相似度,我一般用关键词匹配就够了,因为skill的triggers本身就是很好的关键词来源。
另一个技巧是压缩description。把“这个技能用于查询指定城市的天气情况”压缩成“查询城市天气”,Token消耗减少60%,效果几乎不变。模型对简洁描述的识别能力反而更强。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
我用的技术栈是Python 3.11 + Pydantic 2.x + FastAPI。Pydantic用于参数校验和Schema生成,FastAPI用于把skill暴露成HTTP接口(方便后续接入MCP)。如果你只用Function Calling,可以不用FastAPI,直接写Python函数就行。
pip install pydantic fastapi uvicorn httpx目录结构我习惯这样组织:
skills/ __init__.py base.py # skill基类和校验中间件 registry.py # skill注册中心 weather/ __init__.py skill.py # weather skill实现 schema.json # 元数据 search/ ...base.py里定义Skill基类,包含name、description、parameters等属性,以及一个execute方法。所有具体skill继承这个基类,实现execute方法。registry.py负责扫描skills目录,自动注册所有skill。
4.2 编写第一个skill:以天气查询为例
先定义元数据schema.json:
{ "name": "query_weather", "description": "查询指定城市的当前天气", "triggers": ["查天气", "城市温度", "外面冷吗", "会下雨吗"], "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称", "example": "北京" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius", "description": "温度单位" } }, "required": ["city"] }, "returns": { "type": "object", "properties": { "city": {"type": "string"}, "temperature": {"type": "number"}, "condition": {"type": "string"}, "humidity": {"type": "number"} } }, "timeout": 5000 }然后实现skill.py:
from skills.base import Skill import httpx class WeatherSkill(Skill): name = "query_weather" async def execute(self, params: dict) -> dict: city = params["city"] unit = params.get("unit", "celsius") # 调用天气API(这里用示例URL) async with httpx.AsyncClient() as client: resp = await client.get( "https://api.example.com/weather", params={"city": city, "unit": unit}, timeout=5.0 ) resp.raise_for_status() data = resp.json() return { "city": city, "temperature": data["temp"], "condition": data["condition"], "humidity": data["humidity"] }关键点在于execute方法的输入输出都是纯字典,不依赖任何框架特定的类型。这样这个skill可以在任何Agent框架里复用。
4.3 注册与调度逻辑实现
registry.py的核心逻辑是扫描目录、加载schema、实例化skill类:
import json import importlib from pathlib import Path from skills.base import Skill class SkillRegistry: def __init__(self, skills_dir: str = "skills"): self.skills = {} self._load_all(Path(skills_dir)) def _load_all(self, root: Path): for schema_file in root.rglob("schema.json"): with open(schema_file) as f: meta = json.load(f) skill_dir = schema_file.parent module_path = str(skill_dir).replace("/", ".") + ".skill" module = importlib.import_module(module_path) # 找到Skill的子类 for attr_name in dir(module): attr = getattr(module, attr_name) if isinstance(attr, type) and issubclass(attr, Skill) and attr is not Skill: instance = attr() instance.meta = meta self.skills[meta["name"]] = instance break def get_skill(self, name: str) -> Skill: return self.skills.get(name) def list_skills(self) -> list: return [ { "name": s.meta["name"], "description": s.meta["description"], "parameters": s.meta["parameters"] } for s in self.skills.values() ]调度逻辑我一般放在Agent主循环里。每一轮对话,先把list_skills()的结果注入System Prompt,然后模型返回要调用的skill名称和参数,registry.get_skill()拿到实例后执行execute,把结果返回给模型。
4.4 接入MCP协议的适配层
如果要把skill通过MCP暴露,需要写一个适配层。MCP的核心是JSON-RPC over stdio或HTTP。我用FastAPI写了一个简单的HTTP适配:
from fastapi import FastAPI, HTTPException from pydantic import BaseModel from skills.registry import SkillRegistry app = FastAPI() registry = SkillRegistry() class CallRequest(BaseModel): skill: str params: dict @app.get("/skills") def list_skills(): return registry.list_skills() @app.post("/call") async def call_skill(req: CallRequest): skill = registry.get_skill(req.skill) if not skill: raise HTTPException(404, f"Skill {req.skill} not found") # 参数校验 errors = skill.validate(req.params) if errors: raise HTTPException(400, {"errors": errors}) try: result = await skill.execute(req.params) return {"result": result} except Exception as e: raise HTTPException(500, {"error": str(e)})这个适配层很薄,核心逻辑都在skill内部。这样设计的好处是,如果以后要换协议(比如从HTTP换成gRPC),只需要改适配层,skill代码不动。
5. 常见问题与排查技巧实录
5.1 模型不调用skill或调用错误skill
这是最常见的问题。排查思路分三步走:
第一步,检查skill的description和triggers是否足够清晰。我遇到过一个案例,skill的description写的是“处理用户请求”,模型完全不知道这个skill是干什么的。改成“查询快递物流信息”之后,调用准确率立刻上来了。
第二步,检查System Prompt里skill列表的格式。如果格式混乱,模型很难解析。我一般用固定的JSON格式,每个skill一行,包含name、description、parameters三个字段。
第三步,检查是否有相似skill互相干扰。如果有两个skill的description很接近,模型容易混淆。解决办法是在description里明确区分场景,比如“查询国内快递”和“查询国际快递”。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 完全不调用 | description太模糊 | 人工阅读description | 改成具体动作+对象 |
| 调用错误skill | 多个skill描述相似 | 对比相似skill的description | 增加区分性关键词 |
| 参数填充错误 | 参数缺少example | 检查parameters定义 | 补充example字段 |
| 多轮后忘记skill | 上下文Token超限 | 统计Token用量 | 分层加载skill |
5.2 Token失效与权限问题的处理
在Agent开发中,Token失效是绕不开的问题。这里的Token指的是访问外部API的凭证,不是模型的Token。我遇到过几种典型情况:Token过期、权限不足、频率限制。
处理策略是在skill内部做统一封装。不要在每次调用时手动检查Token,而是写一个TokenManager,负责自动刷新和重试。我的实现是:TokenManager维护一个Token池,每次调用前检查是否过期,过期则自动刷新。如果刷新失败,返回结构化的错误信息给模型,让模型决定是重试还是告知用户。
注意:Token刷新逻辑要加锁,避免并发刷新导致重复请求。我用asyncio.Lock实现,实测下来很稳。
另一个坑是权限范围。有些API的Token只能访问特定资源,如果skill请求了超出权限的资源,会返回403。这种错误不要直接抛给模型,而是转换成“当前凭证无权访问该资源,请检查权限配置”这样的提示。模型看到这种提示后,通常会选择其他skill或告知用户。
5.3 skill执行超时与重试策略
外部API不稳定是常态。我统计过,在生产环境中,大约3%的skill调用会因为网络问题超时。如果不处理,用户体验会很差。
我的策略是分级超时+指数退避重试。每个skill的timeout字段定义基础超时时间,默认5秒。第一次超时后,等待1秒重试;第二次超时后,等待2秒重试;第三次超时后,放弃并返回错误。重试次数最多3次。
但不是所有skill都适合重试。查询类skill可以重试,写入类skill(比如发邮件、下单)不能重试,否则可能产生重复操作。我在skill元数据里加了一个idempotent字段,标记是否幂等。只有幂等的skill才启用重试。
async def execute_with_retry(skill, params, max_retries=3): for attempt in range(max_retries): try: return await asyncio.wait_for( skill.execute(params), timeout=skill.meta.get("timeout", 5000) / 1000 ) except asyncio.TimeoutError: if attempt == max_retries - 1: raise if not skill.meta.get("idempotent", False): raise await asyncio.sleep(2 ** attempt)5.4 常见问题速查表
| 问题 | 现象 | 根因 | 解决 |
|---|---|---|---|
| skill不触发 | 模型直接回答不调用 | description模糊 | 改具体描述+加触发词 |
| 参数缺失 | 模型只填部分参数 | required未标注 | 检查JSON Schema |
| 返回格式错 | 模型解析失败 | returns未定义 | 补充returns Schema |
| 并发冲突 | 偶发数据错乱 | 共享状态未隔离 | 每个skill独立实例 |
| 内存泄漏 | 运行久了变慢 | 连接未关闭 | 用async with管理资源 |
| 日志缺失 | 出问题无法排查 | 没打关键日志 | 在execute入口出口打日志 |
实操心得:skill的日志要打三个点——入口参数、出口结果、异常堆栈。但注意不要打敏感信息,比如Token、密码。我一般用脱敏函数处理后再打日志。
6. 技能设计的进阶思路与扩展方向
6.1 技能组合与编排
单个skill能力有限,真正强大的是skill的组合。比如“订机票”这个需求,可能需要“查询航班”“比较价格”“选择座位”“支付”四个skill协作完成。我目前的实现方式是让模型自己编排,但模型在多步编排时容易出错。
更稳的方式是预定义工作流。把常见的组合场景写成workflow,每个workflow是一组skill的调用序列。模型只需要选择workflow,不需要自己编排每一步。这种方式牺牲了一些灵活性,但换来了稳定性。我实测下来,预定义工作流的任务完成率比模型自由编排高30%以上。
workflow的定义我放在单独的YAML文件里:
name: book_flight steps: - skill: search_flights params: from: "{{user.from}}" to: "{{user.to}}" date: "{{user.date}}" - skill: compare_prices params: flights: "{{steps[0].result}}" - skill: select_seat params: flight: "{{steps[1].result.best}}" - skill: pay params: order: "{{steps[2].result.order}}"这种模板化的编排方式,既保留了灵活性(参数可以动态填充),又保证了执行顺序的确定性。
6.2 技能的安全边界设计
skill能调用外部API,这就带来了安全风险。我见过最危险的情况是skill直接执行用户传入的SQL或Shell命令。这种设计等于把整个系统暴露给模型,模型一旦被诱导,后果不堪设想。
我的安全原则是最小权限+输入白名单。每个skill只申请它需要的最小权限,比如查询天气的skill只能访问天气API,不能访问文件系统。输入参数做白名单校验,比如城市名称只允许字母、数字和常见汉字,拒绝任何特殊字符。
另一个原则是输出脱敏。skill返回的结果里如果包含敏感信息(比如用户ID、内部IP),要在返回前脱敏。我写了一个通用的脱敏函数,用正则匹配常见敏感模式,替换成占位符。
注意:不要信任模型传来的任何参数。模型可能被Prompt注入攻击,传入恶意参数。所有参数必须经过校验才能使用。
6.3 技能版本管理与灰度发布
skill是会迭代的。今天写的查询逻辑,明天可能因为API变更需要调整。如果没有版本管理,更新skill时很容易影响正在运行的任务。
我的做法是语义化版本+灰度发布。每个skill的元数据里有version字段,遵循major.minor.patch格式。不兼容的变更升major,兼容的功能新增升minor,bug修复升patch。registry支持同时加载多个版本的skill,模型调用时默认用最新版,但可以通过参数指定版本。
灰度发布是通过配置实现的。新版本skill先只对10%的请求生效,观察一段时间没问题后再全量。这个比例可以在配置中心动态调整,不需要重启服务。
6.4 从skill到Agent的演进路径
skill是Agent的基础能力单元,但Agent还需要规划、记忆、反思等能力。我的演进路径是:先有skill,再有workflow,最后有Agent。
skill阶段解决“能不能做”的问题,workflow阶段解决“按什么顺序做”的问题,Agent阶段解决“做什么”的问题。很多团队一上来就做Agent,结果发现基础能力都不稳定,Agent的规划再强也没用。我的建议是先把skill做扎实,再往上叠。
具体来说,skill的测试覆盖率要达到80%以上,workflow的完成率要达到90%以上,再考虑引入Agent的自主规划。这个顺序不能反。
7. 我在实际项目中的几点体会
做Agent开发这两年,最大的体会是不要过度设计。一开始我总想把skill设计得很通用,结果每个skill都变得很复杂,模型反而不知道怎么用。后来我改成“一个skill只做一件事”,代码量少了,效果反而好了。
另一个体会是测试比实现更重要。skill的实现可能就几十行代码,但测试用例我写了上百行。这些测试用例覆盖了各种边界情况,让我在迭代时很有信心。没有测试的skill,我根本不敢改。
还有一点是日志要打够。Agent的运行链路很长,模型决策、skill调用、API请求,任何一环出问题都可能导致任务失败。我在每个环节都打了详细的日志,出问题时能快速定位。日志格式我统一用JSON,方便后续做分析和告警。
最后分享一个小技巧:skill的description可以定期用模型自己来优化。把skill的实际调用记录喂给模型,让它根据误触发和漏触发的案例,重新生成更准确的description。我试过这个方法,经过两轮优化,skill的调用准确率从78%提升到了91%。这个技巧不需要额外标注数据,成本很低,效果很明显。