1. 为什么Agent需要一套独立的技能体系
这几年做大模型应用,尤其是做偏落地的Agent项目,我最多被问到的一个问题不是"模型怎么选",而是"这堆工具函数到底该怎么管"。一开始我也觉得这事简单,写几个函数,把description填清楚,让大模型自己选就行。但随着项目从Demo走向生产,技能变多、调用变复杂、不同角色复用同一批能力的时候,问题就来了。
我用 agent-skills 这个名字,不是为了造一个花哨的框架,而是想整理一套能支撑真实业务的技能管理方案:技能如何定义、如何注册、如何被路由、如何执行兜底、如何组合编排。尤其是当你需要让多个Agent共享能力池时,没有这套体系会非常痛苦。本文没有任何夸张的包装,所有内容都来自我在内部项目和交付场景里反复验证过的实践方案,适合同样在搞Agent落地、被工具函数管理折磨的开发者参考。
1.1 技能散落在代码里到底有多痛
先说说我最初踩到的坑。最开始做某个垂直场景的智能助手项目X时,我把所有工具函数都写在一个叫actions的目录里,每个函数用装饰器标记一下,然后塞给大模型当 tools 用。看起来没问题,但业务跑到一半就崩了。
第一个问题是命名混乱。团队里不同成员各自命名,有人叫search_order,有人叫query_order_by_user,大模型在意图不明确时经常选错。第二个问题是权限边界不清。某些技能只允许特定角色调用,但那会儿所有技能都平等地暴露给模型,结果模型在处理超权限请求时甚至会把内部管理接口当普通查询用。第三个问题是参数校验全凭运气。有的函数内部做了校验,有的完全没有,模型一旦传错参数,要么报个莫名其妙的错误,要么直接返回一段丑陋的异常堆栈。
这些问题单独看都能靠"多写几个if"解决,但它们叠加起来,项目可维护性会急剧下降。每次加一个新技能,都要翻一堆旧代码,问一圈"这个函数以前是怎么调的",效率极低。所以后来我做了一次重构,把技能从业务代码中剥离出来,形成独立的定义、注册、路由、执行链路,这就是 agent-skills 的雏形。
1.2 agent-skills要解决的三个核心问题
对整个方案做减法之后,我明确了这个体系只需要解决三件事。
第一件事是技能描述的结构化。大模型判断"什么时候该调用哪个技能",依赖的是技能的名称、描述、参数定义和约束条件。这些信息如果不统一,模型的判断质量就完全不可控。所以我要让每个技能都有一份规范的"技能说明书"。
第二件事是技能调用的权限与校验收敛。技能不能再是裸露的函数,而必须经过统一入口:参数注入、权限校验、超时控制、错误归一化。统一入口的意义在于,所有技能行为都可观测、可管控。
第三件事是技能的组合复用。单一技能只能解决单一动作,但真实需求往往是多步骤的:查信息、做分析、生成结果。技能之间要能编排,就像乐高积木一样,既能单块玩,也能拼成更大的结构。
agent-skills 不是一个庞大的框架,它甚至不需要依赖任何特定的大模型SDK。它只是一套组织代码的方式,外加少量辅助类。接下来我把每个模块的设计思路和实现细节逐步展开。
2. 技能定义:一份同时给人和模型看的契约
在 agent-skills 里,技能的最小单位是一个"技能描述文件 + 实现函数"的组合。描述文件负责告诉模型"你什么时候该用我、用我的时候怎么传参",实现函数负责真正干活。两者分离的好处是:调整提示词和调整代码互相不干扰,产品经理能改描述,开发人员能改逻辑。
2.1 技能描述该包含哪些元信息
我把每个技能描述文件设计成 YAML 格式,字段尽量少,但每个字段都有明确用途:
| 字段 | 用途 | 示例 |
|---|---|---|
name | 技能唯一标识,模型调用时使用的名字 | query_ticket |
description | 详细说明技能功能、适用场景、何时不该调用 | 根据工单ID查询客服工单详情,适用于售后场景 |
parameters | 参数Schema,JSON Schema格式 | 见下文 |
permissions | 技能所需的权限级别,例如user/admin | user |
timeout | 单次调用的最大超时时间,单位秒 | 10 |
tags | 用于路由分组的标签,例如ticket,search | [ticket, read] |
version | 技能版本号,升级时用于追踪 | 1.2.0 |
这里有一个我踩过坑的教训:description一定不能只写"功能是什么",还要写"什么情况下不该用"。大模型的工具选择往往只看正面描述,如果不加负面约束,它会在类似场景下泛化调用。比如你写"查询工单详情",它可能在用户问"工单为什么被关闭"时也选择这个技能,但正确答案是另一个统计技能才能回答的。所以我推荐的写法是:"查询工单详情,适用于用户询问某个工单的状态、内容、处理进度时。如果询问的是工单统计数据,请使用 query_ticket_stats,不要使用本技能。"
一般情况下,一份中等规模的技能库,描述里最容易出问题的不是描述太少,而是"该说的没说,不该说的说了一堆"。保持每个描述稳定在两三句话以内,第一句说功能定位,第二句说适用场景,第三句说不适用场景,这个结构足够用了。
2.2 参数Schema:让模型不猜、不瞎传
如果参数定义不清楚,大模型在生成函数调用时会出现"猜参数名""瞎填参数值"的情况。我在 agent-skills 里全面采用 JSON Schema 描述参数,而不是简单的"类型+必填"列表。
parameters: type: object properties: ticket_id: type: string description: "工单ID,格式为TCKT加8位数字,例如TCKT12345678" include_notes: type: boolean description: "是否需要返回内部备注,默认false,仅管理员可查询" default: false required: - ticket_id这样做的好处是,模型能通过description字段理解业务格式约束。以前我经常遇到模型传一个不存在的格式化字符串,后来在每个字段的 description 里加上具体格式示例,问题基本消失了。
另外,required必须严格。能设置default的字段尽量设置默认值,这样模型少做一次推断,就少一分出错概率。实践中我发现,把必填参数控制在2个以内,模型调用的成功率会显著上升。如果某个技能需要5个参数才能执行,那么考虑把它拆成两个技能,或者把其中一部分参数改为可选。
2.3 实现函数与描述文件的绑定
描述文件只是声明,真正的逻辑在实现函数里。我使用 Python 作为示例语言,但语言本身不限制这套方案,核心思路是"加载器读取描述文件,然后通过技能名找到对应的实现函数"。
# skills/query_ticket/__init__.py from datetime import datetime async def execute(ticket_id: str, include_notes: bool = False) -> dict: # 这里是实际的业务逻辑,比如查数据库、调API # 返回统一格式:{"success": bool, "data": ..., "error": {...}} try: data = await fetch_ticket_from_db(ticket_id, include_notes=include_notes) return {"success": True, "data": data, "error": None} except Exception as exc: return {"success": False, "data": None, "error": {"code": "FETCH_FAILED", "message": str(exc)}}每个技能都返回一个统一的ActionResult结构,包含 success、data、error 三个顶层字段。这一步极其重要。如果不统一返回结构,后续的"执行器兜底"和"模型结果再理解"都会变成一场灾难。
你可能觉得这有点死板,但我的体验是:在大模型应用里,约定比灵活重要得多。格式统一,才能让监控、测试、重试逻辑全部复用,不用对每个技能单独适配。
3. 注册中心:新技能即插即用
有了技能定义之后,下一步是解决技能的装载问题。agent-skills 用"目录约定"来实现自动发现,完全不需要手动维护一个巨大技能列表。
3.1 目录约定优于配置
我规定项目的技能目录结构如下:
workspace/ skills/ query_ticket/ manifest.yaml # 技能描述文件 __init__.py # 实现函数 assets/ # 技能专属的提示词模板或静态资源 refund_order/ manifest.yaml __init__.py ...加载器只需扫描workspace/skills下的所有子目录,读取每个子目录中的manifest.yaml,然后通过约定的模块名导入execute函数。新增一个技能,只需要新建一个目录、写上描述文件、实现函数,重启后就能自动注册。如果不想重启,可以接一个目录监听器,实现热加载。
# agent_skills/registry.py import importlib import os from pathlib import Path def scan_skills(skills_dir: str) -> dict[str, Skill]: skills = {} for manifest_path in Path(skills_dir).glob("*/manifest.yaml"): skill_name = manifest_path.parent.name manifest = load_yaml(manifest_path) module = importlib.import_module(f"skills.{skill_name}") skill = Skill( name=manifest["name"], description=manifest["description"], parameters=manifest["parameters"], permissions=set(manifest.get("permissions", ["user"])), timeout=manifest.get("timeout", 10), tags=set(manifest.get("tags", [])), version=manifest.get("version", "1.0.0"), executor=getattr(module, "execute") ) skills[skill.name] = skill return skills这段代码很简单,但它把"技能是不是注册了"变成了一个完全可预期的事情:技能目录存在,则技能存在;目录删除,则技能消失。排查问题时不用再去翻注册表。
3.2 加载阶段的校验与去重
扫描不等于直接能用。我在加载阶段还做这几件事,从根本上避免"上线之后才发现技能写错了"的尴尬:
- 描述文件完整性校验:
name、description、parameters缺失时直接报错,不允许一个残缺技能注册进来。 - 参数Schema合法性校验:用 JSON Schema 的 Python 校验库对 manifest 里的 schema 做一次 schema-validate,防止出现类型写着写着变成字符串的问题。
- 名称冲突检测:同名字技能出现两份时,根据 manifest 里的
version决定保留哪个,并打印警告日志。
版本字段最初我并没有太重视,后来在一次灰度中吃了亏。当时有两名开发者同时添加了一个同名技能,没有校验的情况下,后扫描到的覆盖了先扫描到的,生产环境的行为完全依赖文件系统的排序,极难排查。加入名称冲突检测后,这类问题在启动阶段就会暴露。
3.3 技能版本演进与兼容处理
技能的升级比普通代码升级更微妙,因为技能是直接被大模型消费的。技能实现函数可以内聚升级,但技能的描述文件如果要改,建议遵循"新旧并存再切换"的策略。
例如旧版技能是query_order,新版想改成query_order_v2,不要直接修改原技能的名称和参数结构,否则所有依赖于旧技能的会话上下文、测试用例都会受影响。先在注册中心里同时注册两个版本,通过路由配置逐步把流量导到新技能,等模型对新技能的调用表现稳定之后,再下线旧技能。
这个策略在 Agent 里尤其重要,因为你有大量历史会话的意图与动作映射是绑定在旧技能名称上的。如果技能突然消失,模型在复述历史信息时就会产生幻觉。
4. 技能路由:让模型做选择题,但别让它裸奔
技能注册完毕,下一步是"给定一个用户请求,决定调用哪个技能"。这里我的核心观点是:不要把所有决策都交给大模型,而是采用"规则前置 + 模型兜底"的两层路由。
4.1 规则前置:能确定的绝不麻烦模型
规则前置的思路很简单:根据技能集的tags和关键词特征,先做一个本地的快速路由判断,命中后再交给后续流程。例如:
# agent_skills/router.py def rule_match(query: str, skills: dict[str, Skill]) -> Skill | None: # 针对高频、固定格式的查询,直接走本地规则 query_clean = query.strip().lower() if query_clean.startswith("tcicket ") or query_clean.startswith("查询工单"): return skills.get("query_ticket") if query_clean.startswith("退换货"): return skills.get("refund_order") # 没有一个明确命中的,返回None表示需要模型决策 return None为什么要这么做?有两个直接好处。
第一,降低时延和成本。固定句式在高频场景中占的比例不低,如果每次请求都发给大模型做一次函数选择,既慢又费钱。规则能把这部分请求零成本地分流掉。
第二,保证高稳定场景的正确性。大模型偶尔会在简单句式上犯傻,而规则不会。比如只包含一个工单号的查询,用正则提取参数就足够可靠,没有必要创造让模型选错的机会。
当然,规则不能覆盖所有情况,它能覆盖的应该是最常见的、句式最稳定的那批请求。复杂请求和开放域意图,模型路由才是主力。
4.2 模型路由,关键是"候选集"和"提示词模板"
当一个请求没有被规则命中时,我会把当前技能池中的所有技能描述,动态组装成一份候选清单,喂给大模型。这里的细节是:不要每次把所有技能都塞给模型,先用标签粗筛,再用模型精选。如果技能总量超过50个,所有技能全量塞进去不仅 token 消耗大,模型的效果也会下降。
我通常的做法是:先从用户请求中做一次零样本主题分类,预测一个大类标签,比如ticket、order、knowledge,再从标签对应的技能子集内让模型做精确选择。主题分类一次,精确选择一次,效果比"一次全选"稳定很多。
提示词模板大致如下:
你是技能路由助手。根据用户问题,从以下技能列表中选择最合适的技能。 技能列表: {formatted_skills} 用户问题:{query} 要求: 1. 如果某个技能明确不适用,不要强行选择。 2. 如果没有合适的技能,返回 no_match。 3. 只要输出技能名称,不要额外解释。注意最后一条,让模型只输出名称,可以显著降低解析难度。不要要求模型输出一段话,然后去里面找技能名,那是在给自己找麻烦。
4.3 并行调用与串行调用的取舍
有些场景下,一个请求可能需要多个技能配合。比如用户问"我的工单TCKT12345678 的退款处理到哪一步了",可能既需要query_ticket获取工单状态,又需要query_refund_progress获取退款进度。这两个调用互相没有依赖,就可以并行。
agent-skills 在路由阶段会允许模型输出一个技能列表,并附上每个技能的参数。执行器拿到这个列表后,优先判断技能之间是否存在依赖关系,无依赖的并行执行,有依赖的按序执行。
并行调用需要在执行器层面做并发控制。我一般用信号量限制最大并发数,避免某个请求一次性触发十几个技能把资源池打满。并行调用能明显缩短响应时间,代价是需要更完善的超时和错误隔离,这块会在下一节详细说。
5. 执行器与错误兜底:技能在真实世界的运行边界
技能定义得再好、路由选得再准,最终都要落到真实执行。真实环境里永远是网络抖动、数据缺失、上游接口发疯。执行器就是那层"防弹衣"。
5.1 参数注入与敏感字段脱敏
在把模型生成的参数传给技能函数前,执行器要做一次参数清洗。模型可能生成乱七八糟的多余字段、带前后缀的字符串,这些都要处理。
# agent_skills/executor.py import json from jsonschema import validate, ValidationError def clean_and_validate(params: dict, schema: dict) -> dict: # 1. 只保留 schema 中声明的字段 allowed = set(schema.get("properties", {}).keys()) cleaned = {k: v for k, v in params.items() if k in allowed} # 2. 执行 schema 校验 try: validate(instance=cleaned, schema=schema) return cleaned except ValidationError as exc: raise SkillParamError(f"参数校验失败: {exc.message}")参数校验必须放在执行器里做,不能依赖每个技能自己判断。因为一旦校验逻辑分散,错误处理方式必然不一致,有人抛异常,有人返回错误码,最后所有调用方都会变得混乱。
另一个经常被忽略的点是脱敏。模型传入的参数可能包含敏感信息,比如手机号、地址。在技能执行日志里必须做脱敏处理,我只记录参数的类型和脱敏后的值,比如138****1234。这个在大模型应用的合规审查里几乎是硬性要求。
5.2 错误分类与重试策略
执行器兜底的核心是对错误进行分类。我自定义了几种错误类型,每种类型对应不同的处理策略:
| 错误类型 | 常见原因 | 处理策略 |
|---|---|---|
SkillParamError | 模型参数不合法 | 不重试,记录日志并返回给路由层重新匹配 |
ExternalTimeout | 上游接口超时 | 最多重试2次,指数退避,重试间隔1s/3s |
ExternalError | 上游接口返回5xx或业务错误 | 最多重试1次,之后降级返回缓存 |
PermissionDenied | 当前角色无权调用 | 不重试,直接返回友好提示 |
不应该对所有错误都一视同仁地重试。参数错误重试一万次也是错的,外部依赖临时抖动才应该重试。分类错误的作用就是让重试策略有依据,不至于盲目消耗资源。
一个小细节是重试必须配合asyncio的超时控制。技能函数应当是"可被取消的",否则一个睡死过去的接口调用会把整个请求线程占住。执行器给每个技能套一层asyncio.wait_for,配合 manifest 里的timeout字段,超时就直接抛ExternalTimeout。
5.3 技能熔断,保护外部依赖
某个技能依赖的上游接口如果持续报错,系统应该触发热路熔断,而不是每个请求都去撞墙。我在执行器里做了一个轻量熔断器:每个技能维护一个滑动窗口,统计最近30秒内的失败率,失败率超过阈值(例如60%),则熔断10秒,期间直接返回降级结果。
# agent_skills/circuit_breaker.py class CircuitBreaker: def __init__(self, custom_threshold: int = 0.6, duration: int = 10): self.failures = deque(maxlen=30) self.open_until = 0 self.threshold = custom_threshold self.duration = duration def can_call(self) -> bool: if time.time() < self.open_until: return False if len(self.failures) < 10: return True return sum(self.failures) / len(self.failures) < self.threshold熔断之后返回什么?不是一个冷冰冰的错误,而是一条"该服务暂时不可用"的提示文案,让大模型可以继续回答用户,而不是卡在一个死循环里。比如用户问退款进度,技能熔断了,大模型应该礼貌地回复"退款进度查询接口暂时不稳定,请您稍后再试或致电客服"。这种兜底对用户体验的维护非常关键。
我在某个项目里实测过:不做熔断时,上游故障会导致整个Agent请求超时率接近50%;加上熔断后,超时率降到5%以下,且用户不会收到一堆堆栈错误。
6. 技能编排:从单技能调用到复合任务
单技能解决单动作,但业务往往需要多个动作组合。技能编排是 agent-skills 里最出价值的地方,也是最容易设计过度的地方。
6.1 原子技能与聚合技能
我倾向于把所有技能分成两类:原子技能和聚合技能。
原子技能是指执行一次单一、不可再拆分的操作,例如查询一条工单、发送一条通知、检索一篇文档。聚合技能则指组合多个原子技能来完成一个更复杂的任务目标,例如"如果工单超时未处理则通知客服组长"。
聚合技能的定义方式和原子技能一样,也有一套 manifest,但它内部不直接操作数据库或外部API,而是调用注册中心里的其他技能。这样做的好处是:聚合技能可以复用已被测试过的底层技能,不用重复实现基础调用。
在实现聚合技能时,需要特别控制上下文。技能A的输出如何传给技能B,不能靠函数间隐式传参,而应该在聚合技能内部显式定义数据流。
6.2 一个跨技能协作的实例
举个具体的例子。某个客服场景里有三个原子技能:
query_ticket查工单详情query_product_guide查产品知识库send_service_message给用户发送消息
用户问:"我的路由器TCKT99887766设置不了5G频段,怎么办?"
这时候聚合技能handle_ticket_tech_issue会执行:
- 调用
query_ticket(ticket_id="TCKT99887766"),获取用户的工单信息和产品型号。 - 如果工单中存在产品型号,调用
query_product_guide(product_model="某型号"),检索该型号关于5G频段设置的文档。 - 把文档中的关键步骤拼装成回复,如果工单标签是"高优先级",则额外调用
send_service_message给客服组长发送一条通知。
整个流程看起来像一个普通的多函数调用,但关键在于每一步的输出都是结构化的ActionResult,聚合技能可以检查每一步的 success 字段。一旦第一步失败,聚合技能立刻返回错误提示,不再继续执行后面的步骤,避免在无意义的上下文里浪费调用。
6.3 编排结果如何回填上下文
Agent 和普通程序一个很大的不同是:它需要在多轮对话中保持语境。聚合技能执行完,生成的结果不应该只被当作"最终答复",而应该被写回对话的上下文记忆。
我在 agent-skills 里提供一个轻量的上下文存储器,本质上是一个带过期时间的 KV Store。聚合技能可以把自己的结果写入上下文,比如ticket.updates = [...]。后续用户追问"那条通知发出去了吗"时,质检技能或者路由层可以先读上下文,直接给出答复,而不必重新调用查询接口。
这里的实现只需要几行代码,但思路作用很大:它把 Agent 从"每次都要做出动作"变成"有时可以直接回答重复问题",既省时间又减少了上游接口的压力。
7. 可观测性与调试经验:实测中的翻车现场与修复
最后这部分,写点我在实际改动过程中踩过的坑。如果你照着上面的思路搭了一套技能体系,运行起来最需要盯紧的就是日志和可观测性。没有它,技能出错时你根本不知道是路由选错了、参数传错了、还是上游崩了。
7.1 技能调用链日志记录要点
我在执行器的入口和出口都会记录一条结构化日志,包含以下字段:
request_id skill_name status (success / failure / timeout / circuit_open) latency_ms params_cleaned (脱敏后) error_code任何一次技能调用,都能通过这些字段还原出完整的时间线。request_id 从路由入口一直透传到技能出口,一次用户请求涉及的所有技能日志都能串起来。
调试时我最常用的做法是:先按 request_id 查全部链路日志,看串行还是并行、每步耗时多少、哪一步失败。这套习惯帮我在生产环境里省下了大量排查时间。日志丢失是大忌,任何"查不到"的调用都应该当作事故处理。
7.2 三个实测踩坑场景
第一个坑:技能描述里写了"关联查询",导致模型在不需要关联数据的时候也调用技能。这个问题最后靠改写 description、明确"仅当用户主动要求查看关联信息时使用"解决的。所以描述文件必须做"不适用场景"的负向约束,前面在 2.1 里提到的写法,就是从这里吸收的教训。
第二个坑:参数校验太严格,把模型生成的合法输入也拒绝了。有一次 schema 里把工单号格式写成了TCKT\d{8},但真实库里存在几条历史工单号是TCKT\d{6},模型照实传了,校验却直接报错。从那以后,我的 schema 描述尽量放宽,尽量用"开头为TCKT的字符串"而不是"固定8位"。大模型不是程序,它对格式的遵循天然有限,能用正则尽量别上强约束。
第三个坑:并行调用技能时,其中一个技能静默失败没人管。当时我并行调用了三个技能,其中一个个因为上游超时返回了 error,但执行器的汇总逻辑没检查,直接把部分失败的结果当成功返回给了大模型,大模型还一本正经地回答了错误的合并信息。修复方式很简单:只要有一个并行子技能失败,聚合结果里的 success 就必须为 False,并且要为模型提供"哪些部分获取失败"的说明,引导它诚实回复用户。
7.3 稳定运行的配置参考
分享一份我在比较稳定的业务环境里使用的参考配置,供你结合实际调整:
| 配置项 | 参考值 | 备注 |
|---|---|---|
| 单个技能超时 | 8~15秒 | 外部API依赖多取15秒,内部查询取8秒 |
| 最大并行技能数 | 3 | 太高容易打爆下游 |
| 熔断失败率阈值 | 60% | 连续请求较少时降低到40%更稳妥 |
| 技能描述中负向约束条数 | 至少1条 | 避免模型越权泛化调用 |
| 技能总数 | 50以内 | 超过50必须引入标签分组 |
| 上下文过期时间 | 30分钟 | 过长会干扰后续判断 |
这套配置不是金科玉律,但作为起步的基线足够。不同业务得根据真实流量和上游稳定性再调。
我在实际使用中还有一个体验:技能体系越往后,越考验的是人而不是代码。团队里每个人新增技能时,是不是按照统一规范写描述文件、做参数校验、填权限标签,直接决定了这套体系能不能长命。与其追求一个花哨的编排引擎,不如先把一套简单、一致的规范贯彻到底。agent-skills 对我的意义不是那些辅助类函数,而是它把"技能"这件事变成了团队里所有人都能理解、可检查、可扩展的工程对象。如果你也在做Agent项目,不妨从统一技能描述和注册中心这两步开始改造,收益很快就能见到。