最近接手了一个智能体项目的复盘,团队把基座模型从开源换到商业API,提示词工程也迭代了好几版,用户问"帮我查一下上周的订单为什么还没发货",系统还是经常答非所问——要么直接说"我没有这个权限",要么把订单号都搞错。我翻了一周的调用日志,发现80%的失败发生在同一个环节:模型不知道该调用哪个工具,或者工具的参数压根传不对。这件事让我彻底想明白一个问题:Agent能不能落地,很多时候不取决于模型多聪明,而是取决于你把"能力"暴露给模型的方式。这也是我写下这篇关于"agent-skills"实战总结的起因。
"agent-skills"这个名字听起来像某个框架的插件库,但它本质上描述的是一整套把Agent能力系统化的方法论:把可复用的操作封装成带元数据、带参数协议、带调度策略的"技能",然后让模型像查API文档一样去发现、选择和调用。我在这轮项目里把这套体系从零搭了一遍,走了不少弯路,也沉淀了一套可以直接抄作业的代码结构。这篇文章会把设计思路、核心模块、代码实现和生产环境里的坑一次性讲清楚,适合正在做Agent应用的工程师,也适合想理解Agent内部协作机制的技术负责人。
1. 为什么Agent项目会死在"技能"这一步
先聊聊我看到的普遍现状。很多人搭Agent的第一版,就是往系统提示词里塞一个"可用工具列表",长这样:你有以下工具:get_order_status()、update_address()、create_return(),当用户需要查询订单时调用get_order_status,需要修改地址时调用update_address。
这种做法的本质是——把函数签名硬编码在提示词里,让模型靠上下文理解来"碰运气"式调用。小demo完全够用,一旦业务复杂起来,问题就成指数级增长。
1.1 把工具硬塞进提示词的"三无"困境
第一是"无边界"。一个月后,工具列表从5个涨到50个,提示词被塞到几千token,模型开始混淆相似工具。比如get_order_info和get_order_status两个函数,连开发团队自己都要看注释才分得清,模型更分不清。第二是"无协议"。每个函数期望什么参数、参数格式是什么、异常了怎么反馈,全都靠自然语言在提示词里描述,模型一旦传错参数,整个调用链就断了。第三是"无治理"。工具的版本更新、灰度下线、权限控制都没有统一入口,前端Agent的代码和后端API的改动永远在打架。
我印象最深的一个故障:运营同学要求把"赠品发放"的逻辑从下单环节挪到售后环节,开发改了API,但没同步改提示词里的工具描述。结果模型连续三天在用户问"赠品什么时候到"时调用下单接口,再造出好几个错误订单。这就是没有技能层管理的典型后果——工具和Agent之间是裸连接的,任何一个环节变了,其他环节全崩。
1.2 技能层到底解决什么:重新定义Agent能力的基本粒度
后来我换了个思路:不再把工具当作散装函数暴露给模型,而是把"能力"包装成"技能"。技能和工具的区别在于,技能携带完整的自我描述和调用协议,并且有独立的生命周期。
一个技能至少包含以下结构:
- 元数据:技能名称、功能摘要、适用场景、不适用场景,这是给模型"看"的说明书
- 参数协议:输入参数的JSON Schema,字段类型、必填性、取值枚举,这是给模型"遵守"的约束
- 执行器:真正干活的业务代码,可以是本地函数,也可以是远程API封装
- 策略集合:超时时间、重试次数、权限范围、降级方案
这么一包装,Agent的运行逻辑就清晰了。模型不再需要在提示词里"记住"50个函数怎么写,而是通过语义匹配从技能注册中心里检索出当前任务最相关的3-5个技能,再按技能的参数协议生成标准调用。
这个设计和人脑的记忆机制很相似。你不需要记住每个同事的工位号和全部职责,只需要知道"找财务能报销、找行政能领电脑",具体流程由对方告诉你。技能层就是Agent的"人事通讯录"。
2. 技能体系的四个基础模块:从定义到调度
我在项目里把技能体系拆成四个模块,每个模块各管一段,互不越界。这四个模块分别是:技能定义层、技能注册中心、技能发现机制、技能调度器。
2.1 技能元数据:一份让模型"看得懂"的技能说明书
技能元数据是整个体系的基石。我早期犯过一个错误:把技能描述写得特别"文学",比如"当用户流露出不满情绪时,调用安抚话术技能"。结果模型对着用户说"我理解您的沮丧",但在真正需要调用退换货技能时犹豫不决。
后来我把技能描述规范化成四段式:功能概述、触发条件、不触发条件、参数说明。拿订单查询技能举例,它的元数据大概长这样:
- name: get_order_status_v2
- description: 根据订单号查询订单当前状态,包括待付款、待发货、运输中、已签收、售后中。适用于用户询问"我的订单到哪了""发货了吗""什么时候到"等场景。不适用于查询历史已完成订单的详情。
- parameters: order_id(string,必填,订单号,格式为14位数字,示例:20250101123456);customer_phone(string,选填,用户手机号后四位,用于二次校验)
这样一份元数据,模型不需要猜测,照着协议调用就行。描述里不能有歧义词,不能有让模型"自由发挥"的空间。
2.2 注册、发现、调度:技能层的工作流
技能的生命周期可以概括为四个阶段:
- 注册:新技能上线时,向注册中心提交元数据和执行器,注册中心分配技能ID并验证参数Schema的合法性
- 发现:用户请求进来后,Agent把请求文本通过语义模型向量化,在注册中心的技能索引里做相似度检索,召回候选技能
- 编排:调度器结合上下文和召回结果,让LLM做最终选择,决定调用哪个技能、传什么参数(也可以是规则引擎直接绑定技能)
- 执行:调度器调用执行器,把结果返回给LLM,LLM生成面向用户的最终回复
这里的核心变化是——模型不再"记住"所有工具,而是每次动态"检索"相关技能。技能多了也不怕,索引在向量数据库里,检索是亚秒级的。我目前管理着120多个技能,提示词里的工具列表长度从3000token降到了200token,模型准确率反而提升了。
2.3 技能不是工具列表:生命周期管理
传统工具列表是静态的,技能体系则要求每个技能有版本、状态、负责人、依赖关系。我在注册中心里给每条技能记录维护了以下状态机:草稿、审核中、已上线、灰度中、已下线、已废弃。
灰度是特别重要的一环。我试过直接上线一个新技能,结果它和旧技能在边界场景上打架,导致一小部分用户被错误引导。后来所有技能改动都走灰度:先让5%的流量用新技能,对比调用成功率和用户满意度,没问题再逐步放大。这个机制救了我好几次,尤其在上线涉及财务计算的技能时。
3. 从零搭建技能注册中心:一个可落地的实现方案
理论讲完,直接上代码。我用的技术栈是Python + FastAPI + Redis + 向量数据库,但注册中心的核心逻辑不绑定具体框架,你完全可以用Node.js、Go重写。核心就三件事:定义数据模型、实现注册与查询接口、接入技能发现。
3.1 先定接口:执行器与元数据分离
技能执行器我抽象成一个统一接口,业务代码只需要实现它。这样做的好处是:注册中心完全不关心技能内部怎么实现,它只负责元数据和调度。
from typing import Any, Dict from pydantic import BaseModel, Field class SkillRequest(BaseModel): skill_name: str parameters: Dict[str, Any] class SkillResponse(BaseModel): success: bool data: Any = None error: str | None = None latency_ms: int = 0 class BaseSkill(ABC): name: str description: str parameters_schema: Dict[str, Any] @abstractmethod async def execute(self, params: Dict[str, Any], context: Dict[str, Any]) -> SkillResponse: ...以订单查询技能为例,实现类大概长这样:
class GetOrderStatusSkill(BaseSkill): name = "get_order_status_v2" description = ( "根据订单号查询订单当前状态,包括待付款、待发货、运输中、已签收、售后中。" "适用于用户询问'我的订单到哪了''发货了吗''什么时候到'等场景。" "不适用于查询历史已完成订单的详情。" ) parameters_schema = { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,14位数字,示例:20250101123456", "pattern": "^\\d{14}$" }, "customer_phone": { "type": "string", "description": "用户手机号后四位,用于二次校验", "pattern": "^\\d{4}$" } }, "required": ["order_id"] } async def execute(self, params, context): order_id = params["order_id"] # 实际业务逻辑:调订单服务API,查数据库,或者走缓存 result = await fetch_order_status(order_id) return SkillResponse(success=True, data=result)这里有一个值得注意的设计细节:参数Schema必须用JSON Schema标准而不是自由文本。因为LLM对JSON Schema的遵循能力远高于对自然语言规则的遵循能力。你写"order_id是必填的14位数字",模型可能偶尔出错;但你在Schema里用pattern字段声明格式,模型按格式生成参数的准确率会高很多。
3.2 注册表实现:把技能变成可检索的资产
技能注册表的核心是一个存储技能元数据的索引。我用Redis做热数据缓存,用向量数据库做语义索引,元数据的唯一来源存在PostgreSQL里。
class SkillRegistry: def __init__(self, redis_client, vector_store, pg_pool): self.redis = redis_client self.vector_store = vector_store self.pg = pg_pool async def register_skill(self, skill: BaseSkill, owner: str): # 1. 校验技能元数据完整性 validate_metadata(skill.name, skill.description, skill.parameters_schema) # 2. 存入PostgreSQL,生成技能ID和版本号 skill_id = await self.pg.execute( "INSERT INTO skills (name, description, parameters_schema, owner, status) VALUES ($1, $2, $3, $4, 'draft') RETURNING id", skill.name, skill.description, json.dumps(skill.parameters_schema), owner ) # 3. 生成技能描述的向量表示,写入向量库 embedding = await embed_text(f"{skill.name}: {skill.description}") await self.vector_store.upsert( collection="agent_skills", id=skill_id, vector=embedding, payload={"name": skill.name, "status": "draft"} ) # 4. 写入Redis热缓存 await self.redis.hset(f"skill:{skill_id}", mapping={ "name": skill.name, "description": skill.description, "params_schema": json.dumps(skill.parameters_schema), "status": "draft" }) return skill_id async def search_skills(self, query: str, top_k: int = 5) -> List[str]: # 语义检索:把用户请求转为向量,在技能索引中找最相似的技能 query_embedding = await embed_text(query) hits = await self.vector_store.search( collection="agent_skills", vector=query_embedding, top_k=top_k, filter={"status": {"$in": ["active", "gray"]}} ) return [hit.id for hit in hits] async def get_skill_metadata(self, skill_id: str) -> Dict: # 先查Redis,miss再查PG,写回缓存 cached = await self.redis.hgetall(f"skill:{skill_id}") if cached: return cached row = await self.pg.fetchrow("SELECT * FROM skills WHERE id = $1", skill_id) if row: await self._refresh_cache(row) return dict(row) if row else None这套注册表实现下来,我最大的感受是:技能入库的那一刻,就应该把"描述是否清晰"当作代码评审一样对待。一个模糊的技能描述,入库时毫无感觉,等到线上检索召回错技能,排查成本是当时的十倍。
3.3 从docstring生成技能描述:减少人的主观性
技能描述写多了以后,我发现一个规律:开发者的描述水平不稳定,有的人写得极清楚,有的人写出来全是套话。为了保证入库质量,我写了一个从docstring自动生成描述的小工具。
def skill_description_from_docstring(func) -> str: doc = inspect.getdoc(func) if not doc: raise ValueError(f"{func.__name__} 缺少docstring,无法生成技能描述") sections = {} current = "overview" for line in doc.splitlines(): line = line.strip() if line.startswith("场景:"): current = "scenario" sections.setdefault(current, []) sections[current].append(line.replace("场景:", "").strip()) elif line.startswith("参数:"): current = "params" sections.setdefault(current, []) elif line.startswith("返回:"): current = "response" sections.setdefault(current, []) elif line: sections.setdefault(current, []).append(line) description = f"{sections.get('overview', [''])[0]}。" if sections.get("scenario"): description += "适用于" + "、".join(sections["scenario"]) + "等场景。" return description开发者在写业务函数时只需规范docstring格式,入库时自动生成技能描述,再人工审核一遍即可。这样既减少了工作量,也保证了描述风格的统一。
4. 技能编排的运行时逻辑:选择、组合与降级
有了技能注册中心,下一步是解决运行时的问题:用户请求进来,技能这么多,谁来选、怎么选、选错怎么办。
4.1 单技能调度与多技能组合
最简单的情况是单技能调度:语义检索召回一个最相关的技能,LLM生成参数并调用,然后返回结果。这种情况适合用户意图非常明确的场景,比如"查订单状态"。
但真实业务中大量需求是多技能组合。比如用户说"我上周买的连衣裙要退货,但我的收货地址也改了,帮我一起处理"。这里涉及两个技能:查订单(找到订单号)和发起退货(执行退货流程),可能还涉及更新收货地址。如果只召回一个技能,另一个需求就被忽略了。
我的做法是引入"技能链"机制:调度器先做一个意图拆解,把用户请求拆成多个子任务,每个子任务匹配一个技能,技能之间按依赖关系串联。
class SkillChainPlanner: def __init__(self, registry: SkillRegistry): self.registry = registry async def plan(self, user_query: str, context: Dict) -> List[SkillCall]: # 第一步:用LLM做意图拆解,输出结构化子任务列表 subtasks = await llm_extract_subtasks( user_query, available_schemas=self._get_compact_schemas() ) # 第二步:为每个子任务检索技能 skill_calls = [] for subtask in subtasks: skill_id = await self.registry.search_skill_for_subtask(subtask) if not skill_id: # 找不到技能时,记录miss,走降级逻辑 await record_skill_miss(subtask) continue skill_calls.append(SkillCall(skill_id=skill_id, params=subtask.args)) # 第三步:检查技能依赖,做拓扑排序 return topological_sort(skill_calls)这里有个关键的眼力活儿:并不是所有子任务都需要技能。有些子任务LLM自己就能回答,比如"用户问退货需要几天",这是常识或政策知识,不需要调技能,调了反而慢。我加了一个规则:子任务先过一个"是否需要外部数据或操作"的分类器,只有确认需要才进入技能检索流程。这一步把平均调用次数从3.2次降到1.8次,总延迟降了40%。
4.2 技能链的失败处理与降级
技能链一旦某个环节失败,整个流程怎么走?我踩过一个大坑:退货链路里,查订单成功、发起退货失败,用户的订单状态没有变化,但系统给用户回复"退货申请已提交"。这是灾难性的。
后来我确定了一套失败处理规范:
- 前置依赖失败:比如订单号没查到,后续所有依赖订单号的技能直接跳过,回复用户"暂时没查到您的订单,请核对订单号"
- 技能本身失败:区分可重试和不可重试错误。超时、网络抖动属于可重试,设置2次重试,间隔递增(1s、3s);参数校验失败、业务规则不允许属于不可重试,直接返回错误原因给LLM
- 部分成功:更新地址成功、发起退货失败,必须如实告知用户"地址已更新,退货申请处理中遇到了问题,请稍后重试",不能让LLM脑补成功
降级方案我统一走"语义兜底":当技能调用多次失败,LLM直接基于已有上下文给出安全回复,绝不编造技能执行结果。
4.3 可观测性:技能调用的全链路监控
技能体系上线后,监控是必须同步做的。我维护了一张技能调用分析表,每个H2都要回答三个问题:技能被召回了多少次、被实际调用了多少次、调用成功了多少次。
召回和调用之间的差值,就是"召回错但最终没用"的技能,这个指标可以反向优化技能描述。我遇到过有技能被召回了2000次,调用量却是0,最后发现它的描述里有"订单"两个字,导致所有订单相关请求都把它召回,但LLM最终都选了别的技能。改描述后召回量降了一半,整体精度提升明显。
我的活跃技能监控表格大致长这样:
| 指标 | 定义 | 我关注的阈值 |
|---|---|---|
| 召回次数 | 语义检索命中该技能的次数 | 低于100次/天需检查是否集成错误 |
| 调用次数 | 实际被LLM选中并执行的次数 | 与召回次数的比值应超过30% |
| 成功率 | 技能执行无异常的比例 | 低于95%触发告警 |
| 平均耗时 | 技能执行平均延迟 | 超过2秒需考虑异步化 |
| 参数错误率 | 参数不符合Schema导致校验失败的比例 | 高于5%需检查Schema是否过严 |
5. 我在生产环境里踩过的几个技能设计坑
这一节是全文最有"血泪感"的部分。技能体系从理论搭建到稳定运行,我至少踩了五个坑,每一个都浪费了少则几天多则两周的时间。全部列出来,给后面的朋友当路标。
5.1 粒度失控:一个技能变成"小应用"
我第一个技能上线时,塞了太多职责。那是"处理售后全流程"技能——能查订单、审核退款资格、生成退货标签、通知仓库,甚至处理用户情绪安抚话术。结果就是:LLM面对一个长着"售后"大名的技能,根本不知道该从哪个步骤开始用,参数列表也极其混乱。
后来我按"最小可理解动作"来切分技能粒度。判断标准很简单:一个技能在业务描述里能不能用一句话说完。"查询订单状态"可以,"处理售后全流程"不行。切完之后,技能数量从30个涨到90个,但每个技能的调用成功率反而高了不少。
5.2 描述里写"作文":让模型不知道怎么选
有个技能是"推荐相似商品",开发同学在描述里写了三大段推荐算法的原理,甚至包含协同过滤、向量召回这些技术词。结果模型开始"喜欢"这个技能——因为它看起来无所不能,在任何购物相关请求里都想调用它。
技能描述是写给LLM看的,不是写给领导汇报的。最好控制在50字以内,关键是讲清楚"这个技能是干什么的、什么时候用、什么时候不用"。我后来给团队定了一条硬规定:描述超过80字必须打回重写。
5.3 参数Schema的两难:太松和太紧都不行
参数Schema太松,模型会传错;太紧,模型会频繁校验失败。最典型的是订单号字段,我一开始严格规定死14位数字,结果线上有老订单是12位老编码,导致一堆老用户查询失败。
调整思路是"分层校验":必填字段必须严格,格式校验改为"宽松识别+确认回显"。模型传order_id=123456(不足14位),不再直接报错,而是补0后查询,如果查不到再告知用户。这样既保持了执行安全,又提升了鲁棒性。
5.4 从不声明"不适用"场景
大部分开发者写技能描述只写"适用"场景,不写"不适用"。我上面提过退货技能描述里加入了"不适用于查询历史已完成订单详情",这种负向描述的价值远超正向描述。
原理很简单:LLM的检索和选择本质上是概率匹配,正负边界越清晰,模型在边界情况下的表现越稳定。就像你给新同事指路,光说"财务在那栋楼"不够,还得说"不是旁边那栋行政楼"。
5.5 技能冲突仲裁:两个技能都能处理同一个请求
最后这个坑特别隐蔽。当技能库超过100个,必然出现语义重叠。比如"修改收货地址"和"变更偏好设置"都能处理地址变更。用户说"把地址改了",召回结果里两个技能都在,LLM随机选了一个,有时候行为不一致。
我的解决方案是加一层显式的冲突仲裁:在技能元数据里增加"优先级"字段和"互斥声明"。当召回结果里有互斥技能时,调度器按优先级强制排序,而不是把选择权完全交给LLM。这类规则不用多,覆盖最常出问题的几个业务点就够了。
6. 技能体系的下一步:从人工设计走向半自动沉淀
技能体系稳定跑了两个月之后,我开始思考一个更省力的问题:能不能让系统自己发现"需要新技能"?
现在我维护技能的流程还是偏人工:运营提需求、产品定义、开发实现、审核入库。这个链路太长,等技能上线,用户的痛点可能已经换了。我目前在做的一个试验是把"技能缺失"变成可观测信号——当用户请求无法匹配到合适的技能、且LLM长期用"抱歉,我暂时无法处理"来兜底时,把这些对话流量聚合起来,做成"潜在技能需求"报告,推给产品团队评估。
另外一个方向是把一些低频但逻辑固定的技能链做成"模板技能"。比如退款处理流程其实是固定的几个步骤,我可以把它固化成一个"退款处理v3"技能,内部自动调三个子技能,对外只暴露一个入口。这样LLM的外部选择空间变小,内部执行的稳定性反而变高了。
说回个人体会——技能体系建设最耗时间的不是写代码,而是"统一语言":让产品、开发、LLM三方对"一个技能到底是什么"达成默契。产品想的是业务动作,开发想的是函数接口,LLM需要的是带边界的语义描述。这三者能对得上,技能体系的杠杆效应才会真正显现。我当时把这个项目起名叫agent-skills,就是想说清楚:Agent的能力不是靠模型单点突破,而是靠一层组织良好的"技能资产"来放大。谁先把这层资产管好,谁就能让Agent从玩具走向生产力。