1. 这不是一个工具,而是一套组织Agent能力的思维方式
这半年只要你在搞LLM应用,就绕不开一个词:Agent。但真正上手做过的朋友应该都有体会,做个能聊天的Demo容易,做一只能“干活”的Agent难。难在哪儿?难在怎么让模型稳定地调用工具、难在怎么把一次长任务拆成多个步骤、难在怎么让不同Agent之间共享能力而不重复造轮子。最近我在梳理项目代码的时候,重新盘了一遍自己维护的agent-skills体系,越想越觉得这个方向值得单独写一篇东西好好聊聊。
agent-skills说白了,就是把Agent能够执行的每一个原子能力——查天气、读文件、调API、写数据库、发邮件、跑Python——全部封装成独立的、可描述、可注册、可复用的“技能单元”。它不是一个具体的Agent产品,而是介于大模型和业务逻辑之间的一层“能力中间件”。跑通这套机制之后,你会发现让Agent去做“联网搜索并把结果整理进表格”这种组合任务,不再需要在代码里堆一堆if-else,而是像拼乐高一样把技能卡插上去就行。
这篇博文主要适合三种人:正在做Agent类产品的开发者、想把现有工具链暴露给大模型调用的后端工程师、以及被function calling的prompt调教折磨到崩溃的LLM应用新手。我会把我在实际项目中沉淀下来的技能定义规范、注册机制、执行引擎设计、安全边界控制这些东西一点点拆开讲,最后附上一套可以直接抄的代码骨架。看完你至少能回答三个问题:Agent的技能到底该怎么定义、怎么管理、怎么让它被模型用得又稳又准。
2. 整体设计与思路拆解:为什么Agent的能力必须“体系化组织”
2.1 场景痛点:直接调function calling,为什么总是翻车
很多从OpenAI function calling开始接触Agent的朋友,最开始的写法都是这样:在请求参数里塞一个functions数组,里面定义好函数名、描述、参数JSON Schema,然后让模型自己决定调用哪个。单看一两个工具,效果确实还不错,模型能准确识别“今天北京天气怎么样”里隐藏的get_weather("beijing", "today")。但一旦工具数量超过十个、参数结构变复杂、工具之间存在调用依赖,问题就全冒出来了。
我踩过的坑大概可以列一长串:第一个是描述冲突,两个工具的功能边界模糊,模型经常选错;第二个是参数混乱,同一个概念在不同函数里叫法不一样,模型不知道user_id和userId是一个东西;第三个是上下文爆炸,十个工具的定义加起来有几千个token,还没开始干活预算就没了一半;第四个是最要命的——技能之间完全没有协作关系,get_weather拿到数据之后,模型如果要把它存到数据库,那就必须同时把get_weather和save_record两个函数的全部细节硬塞进上下文里,模型要在一次回复里完成两次调用,出错概率成倍上升。
这套打法的本质问题在于:工具定义是“平铺”的,而真实任务往往是“分层”的。你希望Agent做的事情,从来不是“调用一个函数”,而是“完成一个目标”。目标需要拆解成步骤,步骤需要协调多个工具,工具之间还有数据流转。平铺式的function calling把这一切都压扁了,等于让模型自己去做流程编排——不是说不行,但是极其消耗token,而且极其不稳定。
2.2 方案演进:从“工具列表”到“技能系统”
在做agent-skills之前,我经历过三个阶段的演进,每个阶段都是为了解决上一阶段的遗留问题。
第一阶段是“函数堆叠”。所有能力都写成普通函数,用一个大的tools数组传给模型。这个阶段的问题前面已经说过了,规模一上来就崩。
第二阶段是“分组分类”。把工具按功能域拆成几组,比如搜索组、文件组、数据库组,然后根据用户意图先用一个分类器判断该激活哪组工具,再把那一组的定义注入上下文。这个方案解决了部分冲突问题,但离“技能可复用”还很远——分组是写在代码里的死结构,换一个Agent产品就要重新配置,而且组与组之间的协作依然没有好的机制。
第三阶段就是我现在在用的agent-skills方案,核心思路做了三个转变。第一个是从“描述函数”转为“声明技能”,每个技能不仅包含函数签名,还包含依赖、权限要求、使用约束、示例用法;第二个是从“全局注入”转为“按需加载”,模型看到的永远是当前任务最相关的几项技能,而不是全量列表;第三个是从“静态工具”转为“可插拔技能包”,技能以独立单元的形式存在,可以跨项目复用、可以动态装卸、甚至可以通过一套元数据描述,让模型在没有预设代码的情况下发现新技能并正确使用。
在早期的需求收集阶段,有些朋友问过我这个方案是不是有点过度设计。当时我给的回答是这样的:如果你只做一两个工具的Demo,那确实用不上技能系统,直接function calling就够;但只要你准备认真做一个Agent产品,工具数量一定会滚雪球式增长,与其到时候做重构,不如第一版就搭好架子。现在回过头看,这个判断是对的。
3. 核心细节解析与实操要点:技能声明的四要素与执行抽象层
3.1 技能描述文件的构成:YAML声明胜过代码注释
在agent-skills的设计里,一个技能不是一段Python函数,而是一个“目录 + 描述文件 + 实现代码”的三元组。描述文件通常用YAML格式写,我规定每个技能必须包含四个核心字段:metadata、inputs、outputs、policy。下面是一个查天气技能的声明示例,这个示例我在很多项目里复制过,结构已经比较稳定。
name: weather_query version: 1.0.0 description: 查询指定城市在未来N天的天气情况,包含温度、降水概率、风力等级。 usage_hint: 当用户询问天气、温度、是否下雨、要不要带伞等问题时使用。 metadata: author: example category: info-query tags: [weather, location, forecast] read_timeout: 5 inputs: location: type: string description: 城市名称,使用中文,例如"北京"。 required: true days: type: integer description: 预报天数,范围1到7。 default: 1 minimum: 1 maximum: 7 unit: type: string enum: [celsius, fahrenheit] default: celsius outputs: data: type: object description: 天气数据结构,包含daily数组,数组内每个元素包含date、temp_max、temp_min、precip_prob。 policy: allow_parallel: false require_permission: false rate_limit: 10 timeout_ms: 3000metadata部分负责“这个技能是什么”,inputs和outputs负责“数据契约”,policy负责“能不能调、能同时调几个”。
3.2 注册表与技能库:让模型能发现技能
写完技能声明还不够,关键问题是:模型怎么在一个技能库里找到自己当下需要的技能?这就要靠“技能注册表”机制。
注册表本质上是一个索引,它不需要把每个技能的完整定义都加载进来,只需要保存每个技能的“元数据摘要”——name、description、usage_hint、tags、category。Agent框架在收到用户消息后,会先做一次检索,把候选技能挑出来。这个检索可以直接用向量匹配,也可以直接让模型做一次轻量级路由,甚至可以简单地走文本评分。我在一个小项目里试过最简单的方案:把用户消息和一个技能库里的usage_hint字段做关键词匹配和语义相似度打分,取Top-5再注入对话上下文,效果就已经比全量注入强很多。
注册表还解决了一个我早期很头疼的问题:技能命名冲突。两个不同技能可能都注册了一个名为query的操作,如果不做隔离,模型会困惑到底调哪个。我在设计里给每个技能分配了一个全局唯一的名字,同时用“技能名/操作名”的层级方式来标识具体动作。比如weather_query/run表示weather_query技能下的执行入口,这样即使将来有十个技能都实现了“查询”动作,也不会彼此干扰。
3.3 执行引擎的结构:技能调用的三层抽象
一个规范的Agent技能系统,执行层至少要拆成三层。第一层是“路由层”,负责从注册表选出候选技能;第二层是“编排层”,负责决定技能调用的顺序、判断是否并行、处理依赖关系;第三层是“执行层”,负责真正启动技能、传入参数、捕获异常、格式化输出。
这里有一个非常关键的细节:路由模型选出来的不一定是一个技能,可能是一个“技能链”。比如用户说“帮我搜索一下最新的AI论文,并把标题和链接整理到我的笔记库里”,这个任务里至少有search_web和note_save两个技能。编排层的作用就是识别这个链路,先让search_web执行,然后把输出结果按note_save的输入格式做转换,再触发note_save。如果不做这一层,模型就需要在上下文中自己“脑补”转换逻辑,一旦输出格式稍微不匹配,后续步骤就全崩。
我在设计执行引擎的时候,特意做了一个“转换器”接口。每一个技能可以声明自己接受什么类型的输入、产出什么类型的输出,而编排层在调用链路上检查相邻技能的数据格式——如果不匹配,就调用一个轻量的transform函数做字段映射。这个设计理念很简单,但在实践中避免了大量“模型临时发挥导致JSON解析失败”的问题。
3.4 安全与权限:技能系统的护城河
技能系统越强大,安全模型就要越细致。我总结出五个必做的控制点:第一是“技能分级”,把技能分成公共技能和私有技能,私有技能必须在对话中明确获得用户授权才能调用;第二是“敏感操作确认”,凡是涉及发消息、改数据、删资源、花钱的操作,执行前必须向用户确认;第三是“数据流隔离”,执行引擎不应该让技能之间共享隐藏的内存变量,所有数据交换必须显式通过参数传递,否则一个技能的数据污染可能蔓延到整条链路;第四是“超时熔断”,每个技能都要有自己的timeout上限,我在policy里设置了timeout_ms,一旦超时,执行引擎直接终止该技能并回滚已有副作用;第五是“审计日志”,所有技能调用都要记录完整入参、出参、耗时、模型决策依据,方便事后排查。
这些控制点在Demo阶段看不出来有多重要,但一旦你要把Agent暴露给外部用户,它们就是保命的底线。我见过有的团队把Agent接上线之后才发现,模型在对话里被用户诱导调用了删除接口,直接清掉了半个测试库——这种事故不是模型坏,是技能系统没做权限控制。
4. 实操过程与核心环节实现:从一个技能到一个可用的Agent
4.1 从零实现技能执行引擎:一份代码骨架
理论部分聊了不少,还是落地最实在。我直接给出一份参考代码骨架,基于Python异步实现,核心只做了两件事:从技能库加载技能描述、根据用户请求路由到对应技能执行。这个版本刻意做得极简,方便你套到自己的项目里。
import asyncio import inspect from typing import Any, Callable, Dict, List, Optional class BaseSkill: name: str = "base" description: str = "" usage_hint: str = "" tags: List[str] = [] async def run(self, **kwargs) -> Dict[str, Any]: raise NotImplementedError @classmethod def manifest(cls) -> Dict[str, Any]: return { "name": cls.name, "description": cls.description, "usage_hint": cls.usage_hint, "tags": cls.tags, "parameters": inspect.signature(cls.run).parameters, } class SkillRegistry: def __init__(self): self._skills: Dict[str, BaseSkill] = {} def register(self, skill: BaseSkill) -> None: self._skills[skill.name] = skill def get(self, name: str) -> Optional[BaseSkill]: return self._skills.get(name) def list_candidates(self, query: str, top_k: int = 5) -> List[str]: query_words = set(query.lower().split()) scored = [] for skill in self._skills.values(): hint_words = set(skill.usage_hint.lower().split()) score = len(query_words & hint_words) scored.append((score, skill.name)) scored.sort(reverse=True, key=lambda x: x[0]) return [name for score, name in scored[:top_k] if score > 0] class ExecutionEngine: def __init__(self, registry: SkillRegistry): self.registry = registry async def execute_chain(self, chain: List[str], context: Dict[str, Any]) -> Dict[str, Any]: results = {} for skill_name in chain: skill = self.registry.get(skill_name) if not skill: results[skill_name] = {"error": f"skill {skill_name} not found"} continue try: results[skill_name] = await skill.run(**context) except Exception as e: results[skill_name] = {"error": str(e)} break return results这段代码里,SkillRegistry的list_candidates用的是最简单的词重叠打分,真实项目中可以替换成向量检索。ExecutionEngine的execute_chain负责按顺序执行技能链,并对每个技能捕获异常。
4.2 技能链路由:让模型决定流程,代码负责执行
仅有执行引擎还不够,还需要一个“路由”,让你选择到底用哪条技能链。这里我采用的方案比较通用:让LLM在一个受限的候选技能集合上生成调用链。
受限意味着模型的自由度是可控的,不会从一百个技能里凭空编排。做法是先调用list_candidates筛选出Top-5技能,然后把它们的manifest注入一个路由prompt,让模型输出一个JSON数组,表示技能链顺序和每个技能的输入参数来源。下面是一个简化版的prompt模板,我用下来召回率和准确率都很稳定。
你有以下可用技能: {skill_manifests} 用户需求:{user_query} 请输出一个技能调用链,格式为JSON数组,每个元素包含skill字段和reason字段。 skill必须是上述技能名之一,reason用一句话说明为什么选择该技能。 示例: [ {"skill": "search_web", "reason": "用户需要搜索最新信息"}, {"skill": "note_save", "reason": "搜索结果需要保存到笔记库"} ]执行完这条链路之后,返回的results字典会按顺序记录每一步的输出。如果某一步返回error,我可以选择停止或者基于已有部分结果生成回复。
4.3 参数注入与数据流转:上下文是最珍贵的资源
技能链路由确定后,接下来要解决每个技能的参数从哪来。我见过两种最常见的做法。第一种是把整段对话历史丢给模型,让它自己抽取参数;第二种是为每个技能预设好参数提取prompt,只把对话中相关的句子传入模型。
我强烈建议用第二种,原因只有一个字:省。对话历史越长,token开销越大,模型抽参数的准确率反而越低。我在实际项目里的做法是,每个技能都声明它的inputs需要的字段,路由层在执行前会把历史中与当前技能相关的内容截取出来,然后只针对这些内容做一次抽取。比如天气查询技能,只需要把“用户提到城市和时间的那一句话”抽出来做实体识别,不需要把整段聊天记录都递给模型。
数据流转还有一个要注意的点:技能的输出往往不是最终答案,而是下一层技能的输入。比如search_web的输出是若干条网页摘要,note_save需要的是标题和链接列表。我在执行引擎里做了一层隐式转换——如果路由链上没有显式指定参数映射,引擎会把上一个技能的outputs展开为下一个技能的可选输入候选,再由一个轻量级LLM调用决定如何填充。虽然多了一次模型调用,但换来的稳定性非常值得。
4.4 一个完整的技能注册示例:搜索技能
为了让上面的骨架更直观,我给一个搜索技能的实现示例。这个技能内部封装了搜索API,同时声明了结构和依赖。
class SearchWebSkill(BaseSkill): name = "search_web" description = "搜索互联网,返回与关键词相关的网页标题、链接和摘要。" usage_hint = "当用户请求最新的信息、新闻、资料查询时使用。" tags = ["search", "web"] async def run(self, query: str, top_k: int = 3) -> Dict[str, Any]: # 假设这里调用了一个真实的搜索API mock_results = [ {"title": f"{query} 最新进展", "url": "https://example.com/1", "snippet": "..."}, {"title": f"{query} 实践指南", "url": "https://example.com/2", "snippet": "..."}, ] return {"results": mock_results[:top_k]}注册方式同样简单:
registry = SkillRegistry() registry.register(SearchWebSkill())你要做的就是把所有Agent能用到的能力都写成一个BaseSkill子类,然后register。后面扩展新能力时,不用改任何调用方逻辑,只要技能描述写清楚、参数定义规范,就能被路由层正确发现。
这个流程走完,一个具备基本技能调用能力的Agent就立起来了。从用户输入到技能发现到链路执行,每一步都有迹可循,也有明确的日志记录点。
5. 常见问题与排查技巧实录
5.1 “模型选错了技能”怎么办
这个问题排在所有Agent落地问题的首位,几乎每个从function calling迁移过来的团队都会撞上。症状很典型:用户说“帮我记录一下这个地址”,模型偏偏调了天气查询;用户说“查一下明天开会的时间”,模型调了搜索而不是查日历。
排查思路分三步。第一步,检查技能描述是否足够区分。很多团队写的技能描述太泛,比如“执行查询操作”——跟没说一样。我的建议是,每个技能的描述里都要包含“什么时候该用我”和“什么时候不该用我”,这两句话比“某某操作”有用得多。第二步,检查候选技能列表是否太多。如果一次注入二十个技能,模型的决策负担就很大,我一般控制在五个以内。第三步,检查路由prompt是否有歧义,不要把多个相似技能放在同一个候选列表里,比如“查找邮件”和“搜索联系人”很容易混,可以合并成一个技能再在参数里区分。
5.2 技能链中途失败,要不要重试
技能链的执行经常会有中间步骤失败,比如搜索超时、数据库连接断开、某个服务返回了不兼容的字段。这里有一个我踩坑后的经验:不要盲目整条链路重试,而是先判断失败是否由“输入数据问题”导致。
- 如果失败原因是技能本身的内部错误,比如外部服务不可用,重试意义不大,直接返回用户一个清晰错误信息。
- 如果失败原因是参数格式不正确,比如上一个技能的输出没有正确映射到下一个技能的输入,那么修正映射后重跑该步即可。
- 如果失败原因是执行顺序错了,比如应该先保存再发送确认,而链路设计成了先发送再保存,那么需要重新生成链路。
我在ExecutionEngine里做了一层错误分类,用异常类型捕获不同状况:TimeoutError直接终止链路,ValueError尝试修正参数后重试一次,PermissionError则停下来向用户请求授权。
5.3 技能调用耗token太多
很多朋友在一次技能调用后会说:怎么整个对话上下文全部卷进了工具调用记录?原因往往是技能输出了大量原始数据,而这些数据最后都拼进了下一轮的messages里。解决办法是在技能输出层加“摘要截断”——技能返回的内容先做长度裁剪,只保留能被下游消费的关键字段。
我在实际项目里给每个技能加了一个max_output_len配置,超过部分强制截断,并在返回结构里加一个truncated标记,方便日志追踪。另外,所有技能的最终输出不要直接追加到长期对话历史中,应该由编排层决定哪些内容值得保留、哪些只做临时中转。实践下来,这个策略能省掉至少三成的上下文开销。
5.4 注册表越滚越大,检索失效
技能库一旦超过几十个,简单的词汇匹配就不够用了。我遇到的状况是:描述里没有直接命中关键词的技能永远也捞不出来。这时候需要把检索升级为向量检索,把usage_hint和description编码成向量,然后在用户请求向量化后做相似度搜索。
这个改造其实不复杂,把embedding模型拉进来,给每个技能建向量索引,检索时直接查相似度Top-K。我在切换之后最明显的变化是:有些技能的相关词表达方式跟用户输入八杆子打不着,但语义上就是同一个意思,模型也能正确找到。这里只提醒一句:不要用同一个embedding模型同时编码超短文本(技能描述)和长文本(用户上下文中提取的关键内容),建议长度归一化之后再计算相似度。
5.5 技能输出格式不总是JSON,怎么办
最后分享一个很现实的坑。技能系统设计得再规范,也架不住外部API返回一堆乱七八糟的格式。有的返回Markdown表格,有的返回纯文本,有的甚至带着不可见字符。如果后续技能依赖结构化的JSON输入,前端一个格式问题就能让整条链路崩溃。
我用的兜底方案是“格式归一化层”,在技能输出进入编排层之前,先过一遍清理逻辑:去控制字符、统一换行符、尝试JSON解析、解析失败则用文本摘要替代。这一步虽然听起来很土,但在项目上线之后,属于回报率最高的代码之一。
6. 从技能库到Agent生态:后续还能怎么扩展
现在agent-skills的体系已经能帮我搞定大部分日常任务自动化的需求。不过用久了之后,我开始把思路从“技能库”上升到“Agent生态”这一层。简单说,技能库解决的是单个Agent的能力问题,而技能生态解决的是多个Agent之间怎么共享、怎么协同的问题。
我目前在做的一个扩展方向是技能版本与ROS的联动,也就是让技能包可以像依赖库一样被不同Agent项目引入。比如搜索技能更新了一个策略参数,所有引用它的Agent项目可以选择跟随更新,也可以固定版本跑旧的稳定行为。这个能力在生产环境意义非常大,因为它把“能力升级”从一次性代码发布变成了可灰度、可回滚的操作。
另一个方向是增加“技能组合模板”。很多任务需要固定链路,比如“搜索→摘要→存储→通知”,与其每次让模型重新编排,不如由业务方预先定义好一套组合模板,模型要做的是填充参数而不是选择流程。这能把更多的业务确定性交还给代码,把模型的不确定性限制在理解和抽取这些更擅长的事情上。
7. 别急着上复杂设计,先把一个技能闭环跑通
说到底,agent-skills这套方法论不是一上来就要把所有机制全部铺开。我最开始写第一版的时候,也只有registry和execute两个文件,技能一共四个。那时候我的目标只有一个:让模型能通过一段自然语言找到正确的技能并完成一次调用。等到这个闭环跑通了,再逐步加上技能链、参数抽取、权限控制、向量检索。
做Agent技术栈的这大半年,我最深的体会是:大模型的能力上限确实是技术瓶颈,但工程上真正拉开差距的,往往是你能不能把模型和工具之间的那一层组织逻辑做得干净、稳定、可扩展。agent-skills对于我就是这样一套组织逻辑,它不算什么高深算法,但实实在在帮我避开了很多坑,也让我后面接到的新需求越来越从容。如果你也在折腾Agent,不妨从给第一个技能写一份规范描述开始,试着给Agent的家当们好好分分类、建建册,整套系统的质感是完全不一样的。