做Agent落地这么久,我一直有个感受:单点能力好做,真正难的是让大模型稳定地把一堆能力用起来。你搞了个查询天气的插件,又写了个发邮件的脚本,单独测都没问题,一交给Agent做任务编排就露馅——要么调用参数不对,要么技能之间互相打架,要么模型压根不知道该调哪个。后来我才想明白,问题不在模型,在技能本身没有体系化。这其实就是“agent-skills”这个项目要解决的事情:把零散的函数、API、工具,统一成一套Agent能够理解、检索、正确调用的技能系统。
这篇文章我把这套东西的完整设计思路和实操过程捋一遍,包括技能该用什么结构定义、怎么注册、怎么让模型稳定选中正确技能、踩过哪些坑,适合正在做Agent开发、尤其是被工具调用搞到头疼的开发者参考。
1. 为什么Agent需要一套技能体系,而不是一堆函数
1.1 Agent不是聊天机器人那么简单
传统聊天机器人只要把用户的话映射到固定意图就行,意图是事先枚举好的,对话流程也是定死的。但Agent不一样,它需要在运行时自己决定调用什么工具、按什么顺序调用、参数怎么填。这就相当于你请了个实习生,他得自己翻工具手册、自己决定用哪把扳手、自己判断拧几圈。
我之前做过一个内部运维机器人,最初就是给模型塞了20多个Python函数,每个函数写一段docstring,然后用function calling让模型选。效果一开始还行,但函数一多就开始乱。模型的上下文窗口是有限的,20个函数的描述全塞进去还能接受,50个呢?100个呢?系统提示词被撑爆,模型反而抓不住重点,经常调用错。而且每个函数得自己处理参数校验、错误上报、版本管理,写起来很痛苦。
1.2 技能化拆分的核心收益
后来我把这堆函数重构成了技能体系,核心思路是用元数据描述“这个技能是什么、什么时候该用、参数长什么样”,而不是一上来就写函数体。收益是立竿见影的:
- 技能描述和函数实现解耦,模型看到的是一份结构化的说明书,而不是一堆代码。
- 技能可以动态注册和卸载,不用改主程序就能扩展新能力。
- 参数Schema统一管理,模型输出后先校验再执行,大幅减少运行时错误。
- 技能之间通过名称和依赖关系进行编排,实现“技能调用技能”,而不是把所有逻辑塞在一个函数里。
打个比方,直接给模型函数就像在一家餐厅直接把食材丢给厨师,配菜、切菜、焯水全得他临时摸索;技能体系则是给厨师一套完整的菜谱和配菜间,他只需要照着流程执行,翻车概率自然低得多。
2. 技能体系的顶层设计与关键决策
2.1 单一职责原则:一个技能只做一件事
我犯过的最大错误就是把技能写得太重。有一个技能叫“处理用户订单”,里面又是查库存、又是算价格、又是发通知,模型调一次要等好几秒,中间任何一步出错整个调用就失败,排查起来特别费劲。
后来我把这个技能拆成了四个独立技能:查库存、算价格、创建订单、发通知。每个技能职责单一,参数简单,成功率立刻上来了。
为什么拆开反而更好?因为Agent的编排能力本质上是一个决策链问题,模型每次只需要做一个小决策,而不是在一个步骤里做一串决策。就像你写代码时不会把登录、查询、下发塞进同一个函数,技能拆分同理。每个技能的输入输出边界越清晰,模型就越容易判断“现在该调哪个”。
2.2 声明式元数据:让模型“读懂”技能
技能描述是整个体系里最关键也最容易敷衍的部分。写双下划线docstring敷衍了事,模型自然就胡乱调用。
我最终确定的技能元数据Schema是这样的:
{ "name": "query_ticket_price", "description": "查询指定航班舱位的实时价格,返回含税费的总价。仅用于查询,不可用于下单。", "keywords": ["机票", "航班", "价格", "票价", "舱位"], "input_schema": { "type": "object", "properties": { "flight_no": { "type": "string", "description": "航班号,如CA1831" }, "cabin_class": { "type": "string", "enum": ["economy", "business", "first"], "description": "舱位等级,默认economy" } }, "required": ["flight_no"] }, "output_schema": { "type": "object", "properties": { "total_price": {"type": "number"}, "currency": {"type": "string"} } }, "timeout_seconds": 5, "allowed_callers": ["ticket_order_flow"] }description字段是给模型看的,不能写含糊的“这是一个查询价格的功能”,要写明触发场景(什么时候该用)、行为边界(只查询不可下单)、与其他技能的区别(别和查订单混淆)。keywords字段是我后来加上的,相当于给技能打了标签,检索时用词向量匹配,命中率有明显提升。
提示:描述里不要出现“这是一个工具/这是一个函数”这类废话,模型已经在工具列表里看到它了。描述要聚焦于“什么时候调用它”、“它返回什么”、以及“它不做什么”。不做什么往往比做什么更能减少误调用。
2.3 技能注册表:把技能变成可插拔插件
技能体系需要一个注册中心,不管是内存字典、数据库还是单独的配置文件,都行。核心是支持动态注册、反注册、列表查询和模糊匹配。
我在项目里维护了一个全局技能注册表,每个技能注册时做三件事:格式校验(Schema合法)、依赖检查(依赖的技能是否存在)、冲突检测(有没有同名技能)。注册通过后,技能才进入可用列表。
class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: BaseSkill): """注册技能,返回True表示成功""" # 1. 检查同名冲突 if skill.name in self._skills: raise SkillConflictError(f"技能 {skill.name} 已存在") # 2. 检查依赖技能是否已注册 for dep in skill.requires: if dep not in self._skills: raise SkillDependencyError(f"依赖技能 {dep} 未注册") # 3. 通过检查,写入注册表 skill.validate() self._skills[skill.name] = skill self._index_by_keyword(skill) return True def unregister(self, skill_name: str): """反注册技能,同时清掉关键词索引""" skill = self._skills.pop(skill_name, None) if skill: self._remove_keyword_index(skill) def search(self, query: str, top_k: int = 5) -> List[Skill]: """根据自然语言查询匹配技能""" # 先用关键词索引精确匹配,再用向量模糊匹配 exact = self._keyword_match(query) if exact: return exact[:top_k] return self._vector_match(query)[:top_k] def get_all_metadata(self) -> List[dict]: """把全部技能元数据压缩后交给Agent""" return [s.metadata for s in self._skills.values()]注册表的另一个用途是控制送入模型上下文的技能数量。当技能超过30个时,全部塞给模型会导致检索质量严重下降,我通常会先让模型或者规则层做一次预筛选,把候选技能缩小到5到8个,再作为工具列表交给模型。这个预筛就像搜索引擎的召回再排序,第一轮粗筛,第二轮精排。
3. 核心细节与实操要点
3.1 技能的输入输出Schema:定义清晰边界
在Agent开发里,模型的幻觉不只是在回答内容上,参数填充同样会幻觉。明明技能要求flight_no是字符串,模型可能传一个空字符串,或者把用户的话原封不动塞进来。所以输入校验这一环不能省。
我在BaseSkill基类里强制要求所有子类声明输入的Pydantic模型,并且在实际执行前先用Schema校验:
from pydantic import BaseModel, ValidationError class FlightQueryInput(BaseModel): flight_no: str = Field(description="航班号,如CA1831", min_length=5, max_length=10) cabin_class: Literal["economy", "business", "first"] = "economy" class BaseSkill: input_model: type[BaseModel] = None @classmethod def validate_input(cls, raw: dict) -> BaseModel: if cls.input_model is None: raise ValueError(f"技能 {cls.name} 未定义input_model") try: return cls.input_model(**raw) except ValidationError as e: raise SkillInputError(f"参数校验失败: {e}")这里有个容易被忽略的点:模型返回的参数类型未必是你定义的JSON Schema类型。比如定义number,模型传了字符串"199.5",所以校验时不要只交给JSON Schema,最好再过一次Pydantic,做类型强制转换,能容错的地方尽量容错。但strict模式在Agent场景不一定好用,因为模型输出经常带多余字段,此时需要额外设置extra="forbid"来决定是报错还是忽略。
3.2 技能执行的生命周期管理
技能执行不是一个简单的函数调用,尤其涉及外部API时,要考虑超时、重试、限流和中间状态。我把技能执行分成四个阶段:参数校验、执行前检查、执行、结果标准化。
执行前检查这一步是我在项目后期加进去的。因为有些技能需要前置条件,比如“下单”技能必须先查过库存,否则直接调用可能失败。技能内部可以维护一个简单的状态,标记依赖条件是否满足。
另一个实操要点是超时设置。模型调用技能时通常有整体超时限制,但技能本身可能调用第三方API。如果技能端不设单独的timeout,第三方接口一卡,整个Agent流程就被拖死。
async def execute_with_timeout(skill: BaseSkill, input_data: dict, timeout: int = 5): async with asyncio.timeout(timeout): return await skill.execute(input_data)我一般把技能超时设为Agent整体超时的三分之一,给模型最后生成回复留出时间。如果超时了,技能层要捕获异常并把它转成一段可读的错误消息回传,而不是把堆栈直接扔给模型。
3.3 技能的版本管理与灰度发布
技能也是代码,也要迭代。我吃过一个亏:改了一个查询技能的SQL逻辑,没加版本号,上线后跟旧逻辑混在一起,跑了两天才发现部分请求还在走旧代码,数据查错了。
解决方案是给注册表增加版本字段,每次注册时如果同名技能存在但版本号不同,默认做灰度切换——保留旧版本一段时间,新版本运行时记录成功率,低于阈值自动回滚。
@dataclass class SkillVersion: name: str version: str success_rate: float created_at: datetime灰度逻辑不复杂,但收益很大。尤其是那些调用第三方API的技能,第三方接口经常悄无声息地改参数或限流,没有版本管理和回滚机制,排查起来会极其痛苦。
3.4 技能依赖关系的显式声明
技能之间不是孤岛。我做的那个订单流程里,“创建订单”技能依赖“查询库存”技能提供的可用性信息。但这种依赖关系不能靠约定,必须显式声明,注册表才能检查并保证顺序。
class CreateOrderSkill(BaseSkill): name = "create_order" requires = ["query_stock", "calculate_price"]这样做的意义在编排层体现得最明显。当Agent计划执行顺序时,可以看到技能A依赖技能B,就知道必须先把B的结果拿到,再执行A。这比让模型自己瞎猜顺序要稳定得多。
4. 实操过程:从0到1搭建技能引擎
4.1 定义技能基类与元数据解析
我直接用Python写了一个轻量级技能引擎,核心是BaseSkill基类和Registry注册表。技能开发只需要继承BaseSkill,然后实现execute方法,再标注好元数据就行。
基类代码如下:
class BaseSkill(ABC): name: str = "" description: str = "" keywords: list[str] = [] requires: list[str] = [] input_model: type[BaseModel] = None output_model: type[BaseModel] = None timeout_seconds: int = 10 @abstractmethod async def execute(self, validated_input: BaseModel) -> Any: """执行技能核心逻辑""" def complete_metadata(self) -> dict: """生成给模型看的完整元数据""" return { "name": self.name, "description": self.description, "keywords": self.keywords, "input_schema": self.input_model.model_json_schema() if self.input_model else {}, "output_schema": self.output_model.model_json_schema() if self.output_model else {}, "timeout_seconds": self.timeout_seconds }这里有个细节是model_json_schema()生成的JSON Schema里有一大堆默认字段,比如title、description、default之类。全部塞给模型会占大量token,所以我封装了一个精简函数,只保留name、type、properties、required、enum、description。精简后每个技能的元数据大概缩小了40%,上下文空间省了不少,多技能场景下效果特别明显。
4.2 技能加载:从目录自动发现并注册
技能多了以后,手写注册列表很不现实。我做了个技能加载器,扫描指定目录下的所有.py文件,自动实例化所有BaseSkill子类并注册。
def load_skills_from_module(module_name: str, registry: SkillRegistry): module = importlib.import_module(module_name) for attr_name in dir(module): attr = getattr(module, attr_name) if isinstance(attr, type) and issubclass(attr, BaseSkill) and attr is not BaseSkill: skill_instance = attr() registry.register(skill_instance)这样做的好处是新技能写完后,往目录里丢一个.py文件就能上线,不需要任何中心化配置。坏处是如果代码有隐患,注册时可能炸。所以每个文件在加载时要try-catch,单个技能加载失败不影响其他技能注册,日志里记清楚就行。
4.3 编排引擎:让LLM稳定选中正确技能
有了技能注册表,接下来就是让LLM调用技能。这块我没有直接用原生的function calling,而是包了一层函数选择器。思路是:先用用户的问题检索候选技能,再用精简元数据构建工具列表,最后让模型在候选项里选。
async def choose_and_execute(agent, user_question: str): # 1. 召回候选技能,压缩到8个以内 candidates = registry.search(user_question, top_k=8) # 2. 精简元数据,构造给模型看的工具列表 tool_specs = [candidate.complete_metadata() for candidate in candidates] # 3. 让模型选择技能并提取参数 response = await agent.choose_tool(user_question, tool_specs) if not response.selected_skill: return "不好意思,我没有找到可用的技能" skill = registry.get(response.selected_skill) # 4. 参数校验后再执行 validated = skill.validate_input(response.parameters) result = await skill.execute(validated) return format_result(result)这层包装的关键点是不要让模型接触超过8个技能。就算注册表里有50个技能,候选集也控制在8个内。我测试过,候选技能5个时,准确率能有90%以上;塞到15个,准确率直接掉到70%。上下文中的选择空间越大,模型的决策就越不稳定,这个现象在复杂技能上尤其明显。
4.4 一个完整的多技能场景实战
拿一个具体的业务场景走一遍:用户说“帮我查一下明天北京到上海最早那班飞机的价格,如果低于500就帮我下单”。
整个流程如下:
- Agent先把问题拆解为两个子任务:查航班、对比价格、下单。
- 技能选择阶段,检索出候选技能:query_flight_list、query_ticket_price、create_order。
- 模型先调用query_flight_list,参数是“北京-上海、明天”,返回航班列表。
- 模型发现需要价格,调用query_ticket_price,参数是第一步选中的航班号和舱位。
- 模型判断价格499元低于阈值500,决定调用create_order。
- create_order依赖query_stock技能,先调用query_stock检查该航班还有票,再提交订单。
在这个流程里,模型每一步的决策都比较轻,它不需要知道“查库存”的内部逻辑,只需要知道“下单前必须确认有票”。如果这些逻辑全部写在一个大技能里,模型一次要关注十几个参数,失败概率会大幅上升。
实操心得:让Agent“多问一次”并不是坏事。我早期追求一次调用完成所有事情,结果参数满天飞,成功率极低。后来改成让模型分步执行、每步只做一个小决策,成功率反而上去了。Agent的可靠性来自分解,而不是糅合。
4.5 技能调用的可观测性
技能执行的过程必须有日志记录,这是从“跑通demo”到“上线可用”最关键的工程动作。我在项目里为每个技能调用都打了一条结构化日志,包含技能名、版本、输入摘要、执行时长、是否命中缓存、错误类型。
日志格式类似:
{"event": "skill_invoke", "skill": "query_ticket_price", "version": "1.2.0", "success": true, "latency_ms": 312, "cache_hit": false}有这个日志之后,分析问题方便多了。比如某个技能成功率突然下降,一查日志发现是第三方供应商接口响应时间翻倍,于是决定给这个技能增加缓存和降级策略。没有这一层,线上出了问题只能靠用户截图反馈,体验会很被动。
另外一个实用功能是技能调用的链路追踪。给Agent的每次对话分配一个trace_id,从用户问题到技能选型、参数提取、执行、结果回填,整条链路的每个环节都记录同一个trace_id,这样排查一个问题时可以把整个链条拉出来看,而不是翻各个模块的独立日志。
5. 常见问题与排查技巧实录
5.1 LLM总是调错技能或填错参数
这是遇到最多的一个问题。调错技能通常是因为描述写得太宽泛,或者两个技能的description区分度不够。
排查步骤:把模型实际选中的技能和用户意图对比,看描述里的哪些关键词把模型带偏了。然后改描述,把“不适用于什么场景”明确写进去。
填错参数则需要在prompt层面强调,同时在代码层做参数兜底。比如把枚举字段放在input_schema的enum里,同时给Pydantic模型加默认值。模型填了非法值,校验失败后不要直接报错,可以降级:尝试强制转换,不行就自动清空该字段用默认值,再不行才通知模型重新提取参数。
5.2 技能多了以后,匹配准确率下降
技能超过30个后,描述匹配的准确率会肉眼可见地下降。我试过两个办法:一是用向量检索替代关键词匹配,每个技能的keywords加上描述,在向量库里建立索引;二是加一层意图路由,先把用户问题粗分类到某一领域,再在该领域内做技能检索。
第二次方案的效果更稳定。比如用户说“我要改签机票”,先路由到“机票服务”领域,再在领域内的10个技能里精排,准确率高很多。而且这个路由规则可以不断积累,每错一次就更新一条路由规则,实际用起来越用越准。
5.3 技能之间状态不同步
当两个技能共享同一个数据库或缓存时,容易出现状态不同步。比如“查库存”技能看到剩余5张票,“下单”技能却提示库存不足,其实是因为查库存的数据缓存了三分钟。
解决方法是技能编排不允许直接访问共享缓存,必须通过统一的中间层读写状态。或者在下单前强制刷新一次实时库存数据,牺牲一点速度换正确性。我的经验是,涉及钱的场景坚决用实时数据,不要用缓存,宁可慢,不要错。
5.4 并发调用导致限流和资源竞争
Agent执行多步技能时,有些技能可以并行调用,比如同时查价格、查天气、查积分。但如果所有技能都走同一个第三方接口又不做限流,很容易被对方封掉。
我的办法是给每个技能配置独立的限流器和超时时间。限流器用的是简单的令牌桶算法,代码量不大但非常实用:
class TokenBucket: def __init__(self, capacity: int, refill_per_sec: float): self.capacity = capacity self.tokens = capacity self.refill_rate = refill_per_sec async def acquire(self): await self._refill() if self.tokens < 1: raise RateLimitError("技能调用过于频繁") self.tokens -= 1同时并发量高的场景加一层信号量,控制最大并行数。比如查询类技能允许20个并发,写操作类技能只允许3个并发,避免数据错乱。
5.5 模型把技能结果编造成参数
技能返回的结果是结构化数据,模型有时会把结果里的字段“加工”一下再传给下一个技能,比如把返回里的“available"(布尔值)自己脑补成"stock=5”,传给下单接口时数据就错了。
这个问题的根源是模型在中间环节对技能结果做了自然语言转换,加了脑补。我的对策是:让技能返回结果直接以结构化格式存储在Agent的上下文里,下一个技能取参数时,直接从上下文里抓取结构化字段,不做自然语言转换。代码上通过限制模型在这一步只能做参数映射、不能做自由发挥的prompt就能大幅减少幻觉。
6. 技能体系的进阶扩展思路
技能体系稳定跑起来之后,可以往几个方向扩展。
一是技能市场,把跨业务通用的技能沉淀成公共模块,不同项目共用一套技能库。比如“发送短信通知”、“查询天气”、“生成UUID”这类基础技能,完全可以做成一个公共服务,新项目直接接入注册表,不用重新写。
二是技能编排可视化。当技能数量大、依赖关系复杂时,靠代码检查依赖很痛苦。可以做一张依赖关系图,把技能之间的依赖关系可视化出来,既方便排查,也能用来做流程设计——拖拽几个技能节点,就生成一个Agent执行流程。
三是技能自动生成。有一些重复性很高的技能,比如“调用某个HTTP接口并解析JSON”,可以直接根据接口的OpenAPI规范自动生成技能代码和元数据,开发只需要审核一下描述字段写得合不合适。
这三个方向里,技能市场是投入产出比最高的。一套技能体系的价值,一半来自设计本身,另一半来自技能库的复用率。同样一个查询接口,A项目写一遍,B项目再写一遍,那就是两套维护成本和两套出错概率。把技能做成资产,才是这个体系的长期红利。
最后分享一个我踩过几次坑才养成的习惯:技能体系上线初期,一定要留一个开关,可以一键切回到直接调用函数的老路子。不是因为技能体系不好用,而是线上出问题时,回退手段比排查手段更快。等技能体系稳定运行一周之后,再关掉这个开关也不迟。做Agent跟写普通服务不太一样,模型的行为是概率性的,你再自信也要给自己留后路。