做Agent开发快两年了,我最大的体会是:Agent能不能真正落地,很多时候不取决于模型多聪明,而取决于你给它准备的“技能”靠不靠谱。今天想聊的这个项目agent-skills,就是一套把Agent能力拆成可复用技能、按需注册与调用的实战框架。它适合正在搞AI应用、想让大模型真正干活的开发者,也适合想把自己的工具集沉淀成产品化能力的团队。我不打算只讲概念,而是把整个项目的设计思路、核心代码和踩坑过程都摊开来说,希望能给你一个可以直接抄作业的参考。
1. 项目概述:为什么Agent需要一份“技能库”
1.1 agent-skills到底在解决什么问题
先说背景。我在做Agent项目时,最初的做法很简单:把所有工具函数塞进System Prompt,让模型自己看着办。结果就是Prompt越来越长,模型越来越糊涂,经常该调工具的时候不调,不该调的时候乱调。后来我把项目拆成了agent-skills的形态——每个能力都封装成一个独立的、带元数据的技能,由Agent按需调度。
这里的关键词是“按需”。模型不是看到一长串工具列表再现场理解,而是通过一套结构化的技能清单,先判断“我现在需要哪个能力”,再调用对应的技能执行。这个思路的本质,是把大模型的推理能力和外部工具的执行能力解耦。推理归推理,执行归执行,中间用一份清晰的技能注册表来衔接。
这个项目能解决的问题主要有三个:
- 解决Prompt膨胀问题:工具描述不再全塞进System Prompt,技能清单可以动态维护、按需注入。
- 解决能力复用问题:一个技能可以被多个Agent、多个场景共用,不用每个项目重写一遍。
- 解决调试困难问题:技能调用有独立的输入输出结构,出了问题可以精确地定位到是哪个环节挂了。
1.2 技能层抽象比直接堆Prompt强在哪
很多人会说,我不搞什么技能框架,直接把函数扔给LLM,用Function Calling不就行了?确实可以,但只适合两三个工具的场景。一旦工具数量超过十个,直接函数列表的方式就会暴露很多问题。
我用一个生活化的类比来解释。你去餐厅吃饭,菜单就是Agent的技能清单。如果餐厅把食材仓库的进货单也贴在菜单上,你还能快速点菜吗?不能。同样道理,LLM需要看到的是“菜品”(技能)的简短描述和适用场景,而不是每个函数背后的实现细节。agent-skills做的,就是给Agent一份精简的、有结构的“点菜菜单”,而不是把整个仓库清单丢给它。
从工程角度看,技能层抽象带来的好处很明显:
- 职责边界清晰:每个技能只做一件事,输入输出都被明确定义,方便单测和迭代。
- 调度逻辑统一:所有技能走同一个注册、发现、调用、返回的链路,新增能力不需要改主流程代码。
- 可观测性强:技能被调用一次,就能记录调用参数、耗时、返回结果,后续做分析、审计、优化都有数据支撑。
1.3 适合谁用这套方案
如果你属于下面任一类人,这个项目值得花时间研究:
- 正在开发AI客服、AI助手、自动化工作流,需要让模型调用外部API或内部服务的开发者。
- 已经用过Function Calling,但发现工具一多就开始乱、不好维护的团队。
- 想把个人或团队沉淀的工具包做成可复用、可分享的能力库的人。
这套方案对模型本身没有特殊要求,OpenAI系的Function Calling可以用,开源模型走JSON模式再加约束也可以用。后面我会讲到具体怎么适配。
2. 技能体系的三大核心设计
2.1 技能的定义:一份让模型和人都不迷惑的元数据
agent-skills项目的第一个核心,就是定义技能的数据结构。这不是随便写个函数名字就完事,而是要建立一套统一的元数据规范。我目前用的核心字段包括技能名称、用途描述、参数Schema、执行函数、标签和版本。
from pydantic import BaseModel, Field from typing import Callable, Any, Optional class Skill(BaseModel): name: str = Field(..., description="技能的唯一标识,使用英文小写加下划线") description: str = Field(..., description="对模型展示的技能用途说明,必须包含能力和适用场景") parameters: dict = Field(..., description="参数Schema,遵循JSON Schema规范") function: Callable[..., Any] = Field(..., description="实际执行的Python函数") version: str = Field("1.0.0", description="技能版本号") tags: list[str] = Field([], description="技能标签,用于分类检索")这里面最容易被忽视的字段是description。很多人会随手写一句“获取天气”,然后发现模型根本不知道怎么用。我的经验是,description至少要包含三层信息:技能是做什么的、什么情况下应该调用它、调用时需要注意什么边界条件。比如“获取指定城市的实时天气信息,当用户询问天气、气温、降雨概率时使用,城市名必须传中文全称而非缩写”。
2.2 技能注册与按需加载:别把家底全亮出来
第二个核心设计是技能注册表。所有技能不是散落在代码里,而是统一登记到一个注册中心,由这个中心负责技能的发现和分发。这样Agent在对话过程中,可以动态决定往上下文里注入哪些技能。
class SkillRegistry: def __init__(self): self._skills: dict[str, Skill] = {} def register(self, skill: Skill) -> None: if skill.name in self._skills: raise ValueError(f"技能 {skill.name} 已存在") self._skills[skill.name] = skill def get_skill(self, name: str) -> Optional[Skill]: return self._skills.get(name) def list_skills(self, tags: Optional[list[str]] = None) -> list[Skill]: if not tags: return list(self._skills.values()) return [s for s in self._skills.values() if set(tags) & set(s.tags)]按需加载的意思是,不要把所有技能的描述一次性全部塞给模型。比如你有二十个技能,其中十五个跟数据分析相关,五个跟信息检索相关,那在用户问“帮我查一下天气”时,就只需要给模型看天气相关的技能描述,而不是把二十个技能全部铺开。这样做的好处是既节省Token,又能减少模型选择技能时的注意力分散。
我实际的做法是,在对话的第一轮先通过关键词做一次粗筛,选出候选技能列表,然后将候选技能的元数据拼接到消息里。如果某轮对话没有命中任何技能,就用默认的空结果。这相当于给Agent加了一层“路由感知”,而不是让每次请求都在全量技能集里做选择。
2.3 技能选择与参数映射:让模型“正确动手”
第三个核心设计,是技能选择和参数映射。当模型决定调用某个技能后,它回传的不只是技能名称,还包括一份符合参数Schema的JSON对象。这个环节处理得好不好,直接影响整个Agent系统的稳定性和准确性。
我的做法是,在调用模型接口时,把所有候选技能的元数据直接映射为模型的tools参数。以OpenAI兼容接口为例,每个技能都会转换成一个tool定义。关键在于parameters字段要与模型接口期望的JSON Schema格式保持一致,尤其是required、type、enum这些字段,缺一个都可能导致模型生成的参数不合法。
{ "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的实时天气信息", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,使用中文全称" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位" } }, "required": ["city"] } } }这里有个细节容易踩坑:模型返回参数时,未必严格遵循Schema。比如枚举值它可能输成“C”而不是“celsius”,整数字段可能输出成字符串“25”。所以我在执行技能之前,会加一层参数校验和类型转换,用Pydantic模型直接把原始输出解析成规范的参数对象,解析不过就返回一条清晰的结构化错误,让模型重新尝试。
3. 从零搭建可复用的agent-skills实战
3.1 基础架构与依赖准备
这一节我会给出一个可以直接运行的完整架构,技术栈选择相对通用,方便大家迁移到自己的项目里。
依赖很简单:Python 3.10以上,openai库(或者其他兼容OpenAI接口的SDK)、pydantic、fastapi和uvicorn用于起服务。如果只是想验证核心逻辑,甚至连FastAPI都可以省掉,直接跑命令行脚本看效果。
项目目录我建议这样组织:
agent-skills/ ├── app.py # 入口,编排主流程 ├── skills/ │ ├── __init__.py # 技能模块集合 │ ├── registry.py # 技能注册表 │ ├── time_skill.py # 时间查询技能 │ └── web_skill.py # 网页抓取技能 ├── core/ │ ├── llm.py # 模型调用封装 │ └── types.py # 技能元数据定义 └── config.py # 配置文件3.2 第一个技能:时间查询与上下文注入
我们从最简单的技能开始。这个技能的作用是获取当前时间,初看没什么价值,但它是验证整个调用链路最好的“最小可执行样本”。我把时间和时区都作为参数,这样能测试模型对非必填参数的默认值处理能力。
from datetime import datetime from zoneinfo import ZoneInfo def get_current_time(timezone: str = "Asia/Shanghai") -> dict: """返回指定时区的当前时间,当用户询问时间、日期、今天是几号等场景时使用。""" now = datetime.now(ZoneInfo(timezone)) return { "timezone": timezone, "date": now.strftime("%Y-%m-%d"), "time": now.strftime("%H:%M:%S"), "weekday": now.strftime("%A") }把函数注册到注册表:
time_skill = Skill( name="get_current_time", description="获取指定时区的当前日期和时间,当用户询问时间、日期或星期几时调用", parameters={ "type": "object", "properties": { "timezone": { "type": "string", "default": "Asia/Shanghai", "description": "IANA时区名称,如Asia/Shanghai、America/New_York" } }, "required": [] }, function=get_current_time, ) registry.register(time_skill)这里有个经验:required不要随便加。如果一个参数的默认值对大多数场景都适用,就把它设为非必填,给模型足够的容错空间。我见过不少项目把每个参数都设为required,结果模型为了填一个冷门参数反复出错。
3.3 第二个技能:网页内容抓取与总结
实战中更有代表性的技能是网页抓取类,因为这类技能能充分体现“Agent先决策、后执行、再总结”的价值。用户可能说“帮我看一下这个页面里讲什么”,模型需要判断应该调用什么技能获取URL内容,然后结合LLM的能力生成总结。
抓取网页的核心实现:
import httpx from bs4 import BeautifulSoup def fetch_webpage(url: str, max_chars: int = 5000) -> dict: """抓取指定网页的正文文本,当用户需要了解网页内容、页面信息时使用。 注意事项:仅抓取静态页面,动态渲染页面可能只能拿到部分内容。 """ headers = {"User-Agent": "Mozilla/5.0 (agent-skills demo)"} with httpx.Client(timeout=10, follow_redirects=True) as client: resp = client.get(url, headers=headers) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") for tag in soup(["script", "style", "nav", "footer"]): tag.decompose() text = " ".join(soup.stripped_strings) return {"url": url, "content": text[:max_chars], "length": len(text)}注意我在返回结果里加了length字段,这是为了方便后续控制上下文长度。真实项目里,网页内容动辄几万字符,直接全量扔给模型会撑爆上下文窗口。我会在return前先截取前max_chars个字符,并在描述里明确告诉模型这个截断逻辑,避免它误以为页面只有这么长。
3.4 技能编排器:一次请求怎么走完调度链路
现在我们把上面的技能串起来,写一个精简但完整的编排器。这个是agent-skills的核心环节,也是大家最需要理解的部分。
import json from openai import OpenAI class AgentOrchestrator: def __init__(self, registry: SkillRegistry, client: OpenAI): self.registry = registry self.client = client def build_tools(self, skills: list[Skill]) -> list[dict]: tools = [] for skill in skills: tools.append({ "type": "function", "function": { "name": skill.name, "description": skill.description, "parameters": skill.parameters, } }) return tools def _execute_skill(self, name: str, args: dict) -> dict: skill = self.registry.get_skill(name) if skill is None: return {"error": f"技能 {name} 不存在"} try: result = skill.function(**args) return {"name": name, "result": result} except Exception as exc: return {"name": name, "error": str(exc)} def run(self, user_input: str): # 简化处理:全量注入技能描述 skills = self.registry.list_skills() messages = [ {"role": "system", "content": "你是智能助手,根据用户问题选择合适的技能并调用。"}, {"role": "user", "content": user_input} ] # 第一轮:让模型判断是否需要调用技能 response = self.client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=self.build_tools(skills), tool_choice="auto", ) msg = response.choices[0].message messages.append(msg) # 如果模型决定调用技能,执行并将结果回填给模型 if msg.tool_calls: for tool_call in msg.tool_calls: name = tool_call.function.name args = json.loads(tool_call.function.arguments) result = self._execute_skill(name, args) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) # 第二轮:让模型基于技能结果组织最终回复 final_response = self.client.chat.completions.create( model="gpt-4o-mini", messages=messages, ) return final_response.choices[0].message.content return msg.content这里我来解释几个关键设计。
第一轮调用models接口的tool_choice我设置为auto,让模型自主决定要不要调用技能。这是最灵活的方案,但如果你明确知道用户一定会调用某个技能,也可以强制指定。实战中我倾向于设置auto,因为强迫模型调用不合适的技能反而会降低回答质量。
技能执行结果我以role=tool的身份回传,并带上tool_call_id。这个字段是对应关系的关键,它告诉模型哪次调用产生这个结果。很多初学者漏掉tool_call_id,结果模型根本分不清结果对应的是哪个技能。
第二轮调用时我不再传tools参数。这样做的原因是,模型这时候的任务已经从“做选择”变成“做回答”,继续传tools会让它陷入一种又要选工具又要回答的混乱状态。从我测过的多个模型来看,这种“先决策、后总结”的两段式设计,比一次性完成的效果更稳定。
4. 真实踩坑记录与排查策略
4.1 模型就是死活不调技能?先查描述
这个坑我踩得最深。有一阵子,模型每当遇到数学计算就直接心算,明明我提供了精确计算技能,它就是不调用。一开始我以为是模型能力问题,后来反复排查才意识到是技能的description写得有问题。
我当时写的是“执行数学计算,当需要计算时使用”。听着没毛病,但模型认为自己的内置算术能力已经够用了。问题的根本在于,我没有说清楚技能的优势和适用边界。后来改为“执行高精度数学运算,支持大数、小数、复杂表达式,当结果可能需要精确到多位或涉及大数乘除时优先调用,不要依赖心算”,模型才开始稳定地调用它。
所以如果你发现模型不调用技能,第一个该看的不是代码,而是描述。描述写得像白开水,模型就把它当可有可无的选项。要明确写出“什么场景用、为什么用它、别用什么方式替代”,相当于给模型一个决策理由。
4.2 参数解析不稳定?Schema要“死板”一点
模型生成参数时,偶尔会出现“类型漂移”。比如定义了一个integer类型的字段,模型却传了"25"这样的字符串。还有一种情况是缺字段,明明required标记了city,模型生成的参数里就是没有city。
我的应对策略是三层防护:
- 第一层:在Schema里把枚举值、格式约束写死,不给模型自由发挥的空间。
- 第二层:拿到模型输出后,用Pydantic模型做严格校验和类型转换,解析失败就捕获异常。
- 第三层:解析失败时,不直接把错误堆栈抛给模型,而是返回类似“参数city不能为空,请重新调用技能”的简洁消息,让模型在下一轮自行纠正。
这三层下来,参数错误导致的失败率大概能从之前的百分之十几降到百分之一左右。这里有个心理准备要提前做好:不管你怎么设计,总会有极小比例的调度是错误的,系统要做好“让模型重新试一次”的兜底逻辑。
4.3 技能返回内容撑爆上下文怎么办
网页抓取技能最容易遇到这个问题。用户丢一个超长URL,页面文本抓回来可能有几万字,如果直接把它回传给模型做总结,上下文窗口会快速被占满,而且成本也会飙升。
我在处理这个问题时,采用了“两级供给”的策略。第一级是技能本身先做截断,只返回前5000个字符。第二级是如果模型需要更多信息,可以在总结时使用另一个“获取网页片段”的技能,按需取后面的内容。
这个思路本质上是分页思想。技能不负责一次性把数据全倒给模型,而是提供一种“按需取数”的能力。我还给技能结果加了长度标记,让模型知道当前内容是完整还是截断的,避免它基于不完整信息得出错误结论。
4.4 多技能协作时的意图漂移问题
多技能场景下的问题比单技能复杂得多。比如用户说“帮我查一下北京明天会不会下雨,顺便把这篇文章的核心观点总结一下”,这涉及天气和网页抓取两个技能,模型很容易只调用了头一个,却把第二个忘了。
我的处理方案是引入一个“意图规划”步骤。在真正调用技能之前,先让模型输出一份简短的任务拆解,然后按顺序依次调用技能。每完成一个技能的执行,都把结果追加到对话上下文,再做下一步。这就类似于给Agent一个待办清单,不至于做着做着就忘了后面还有事。
对于需要多个结果交叉分析的场景,我还会让模型最后输出一个汇总表或者结构化JSON。这种做法一方面方便前端展示,另一方面也逼着模型把不同来源的信息做真正的整合,而不是简单堆砌。
5. 我的经验体会与扩展方向
项目做到后面,我越来越觉得agent-skills这类框架的核心不在代码量,而在“技能设计”这件事本身。每新增一个技能,我都会问自己几个问题:这个技能和现有技能边界是否清晰?描述是否足够明确?参数是否容易让模型理解?返回结构是否便于后续处理?这些问题比写代码本身更能决定一个Agent项目的质量。
现在我手里的新项目已经全面切换到这套模式,所有能力都收拢成技能包,不再写一次性脚本。新增一个接口时,开发流程变成了写执行函数、填元数据、注册进表、跑一遍用例,几乎没有额外成本。团队协作时大家维护各自的技能模块,互不干扰,代码评审也轻松了很多。
如果你也想在这条路上继续深入,我建议下一步可以研究这几个方向:一是给技能加权限控制,把用户会话维度引入调度逻辑;二是做一个技能调用监控面板,记录每个技能的调用次数、耗时和成功率;三是探索自动生成技能描述,让模型根据函数的docstring自动编写元数据。这条路走通之后,Agent的能力边界就不再受限于Prompt技巧,而会变成一个可持续生长、可度量的工程体系。