1. 从“agent-skills”这个标题说起它到底在解决什么问题第一次看到“agent-skills”这个标题我脑子里蹦出来的不是某个具体框架而是一类很实际的需求怎么让一个智能体agent真正具备可复用、可组合、可评估的能力模块。过去两年里我参与过几个自动化流程项目从最简单的“定时抓数据发通知”到稍微复杂一点的“多轮对话工具调用结果校验”踩过的坑几乎都集中在同一个地方——agent 的“技能”没有清晰的边界和接口导致它要么什么都想干却什么都干不好要么换个场景就得从头重写。“agent-skills”这个标题本质上指向的是一套技能抽象层的设计与实现。它要回答的问题很具体一个 agent 需要哪些基础能力这些能力怎么封装成独立的 skillskill 之间怎么协作怎么判断一个 skill 是否真的“学会”了适合谁来参考如果你正在做智能助手、自动化工作流、RPA 升级、或者多智能体协作系统这个标题下的内容基本都能直接套用。哪怕你只是想让自己的脚本更“聪明”一点理解 skill 的拆分逻辑也会很有帮助。我见过太多项目把 agent 写成一个大泥球一个主函数里塞了意图识别、参数提取、API 调用、结果格式化、异常重试最后维护成本高到没人敢动。agent-skills 的思路恰恰相反它把 agent 看作一个调度器把具体能力下沉到一个个 skill 里。调度器负责理解目标、选择 skill、传递上下文、处理失败skill 负责单一职责、明确输入输出、可独立测试。这个分工一旦理清整个系统的可维护性和可扩展性会有质的提升。接下来的内容我会从整体设计思路、核心细节、实操过程、常见问题四个大块展开中间穿插我实际踩过的坑和验证过的参数。文章里提到的所有案例都是虚构代称但技术逻辑和操作步骤都来自真实项目经验你可以直接拿去改改就用。2. 内容整体设计与思路拆解2.1 为什么要把 agent 拆成 skill 而不是写成一个巨型函数先讲一个我早期犯的错。当时做一个客服自动回复的 demo需求很简单用户问订单状态就查数据库返回用户问退货政策就返回固定话术用户骂人就转人工。我一开始写了一个handle_message函数里面用 if-else 堆了七八层。前两周跑得挺好第三周产品说“再加一个查物流的功能”我加了两百行第四周说“退货政策要分品类”我又改了一堆条件判断。到最后这个函数有六百多行每次改动都心惊胆战因为不知道哪条分支会被意外影响。后来我把这个函数拆成了四个 skillquery_order_status、query_logistics、get_return_policy、detect_sentiment。每个 skill 就是一个独立的类或模块输入是结构化的参数输出是统一格式的结果。主调度器只做三件事判断用户意图、提取参数、调用对应 skill。改退货政策只动get_return_policy一个文件。加新功能新写一个 skill 注册进去就行。这就是 agent-skills 最核心的价值隔离变化。从设计模式的角度看这其实就是策略模式加责任链的变体。但我不想把它说得太学术你只需要记住一个原则如果一个能力的变更频率和另一个能力不一样它们就不应该写在同一个函数里。订单查询和退货政策变更频率完全不同硬绑在一起就是自找麻烦。2.2 skill 的粒度怎么定太粗和太细都是坑拆 skill 最难的从来不是“要不要拆”而是“拆到什么程度”。我试过拆得太细比如把“查订单”拆成“连接数据库”“执行查询”“格式化结果”三个 skill结果调度器要串三个调用中间任何一步失败都要处理复杂度反而上升了。也试过拆得太粗一个 skill 叫handle_user_request里面又包含了意图识别和业务逻辑等于没拆。我的经验是一个 skill 应该对应一个“业务动作”而不是一个“技术步骤”。业务动作是站在用户角度看的比如“查订单状态”“取消订单”“修改收货地址”技术步骤是站在实现角度看的比如“发 HTTP 请求”“解析 JSON”。前者适合做 skill后者应该封装在 skill 内部。具体判断标准有三条第一这个能力能不能用一句话描述清楚且这句话里不包含“然后”“接着”这类连接词第二这个能力的输入参数能不能在五个以内说清楚第三这个能力能不能独立写单元测试。三条都满足粒度就差不多了。如果一条都不满足说明要么太粗要么这个能力本身就不该做成 skill。2.3 调度器与 skill 的通信协议别让 skill 直接改全局状态早期我图省事让每个 skill 直接读写一个全局的context字典。结果调试的时候根本不知道是谁改了哪个字段一个 skill 把user_id覆盖了另一个 skill 读到的就是错的数据。后来我强制改成显式传参、显式返回调度器把 skill 需要的参数打包成一个输入对象skill 返回一个统一的结果对象里面包含状态码、数据、错误信息。skill 内部不允许访问任何全局变量。这个改动一开始觉得麻烦但带来的好处是巨大的。首先每个 skill 可以单独跑测试不用构造整个运行环境其次调度器可以清楚地知道每个 skill 的输入输出方便做日志和监控最后当某个 skill 需要替换实现时只要接口不变上层完全无感。我后来把这个协议固化成一个基类所有 skill 继承它代码风格统一了很多。注意如果你用的是动态语言不要因为“灵活”就省略接口定义。我见过太多 Python 项目因为不定义 skill 接口最后每个 skill 的返回格式都不一样调度器里全是if isinstance(result, dict)这种判断维护起来非常痛苦。2.4 为什么 skill 需要“元数据”而不只是代码一个 skill 如果只有代码调度器很难做智能选择。比如用户说“帮我看看我的包裹到哪了”调度器怎么知道该调用query_logistics而不是query_order_status靠关键词匹配那用户说“我的东西怎么还没到”就匹配不上了。靠大模型判断可以但大模型也需要知道每个 skill 是干什么的。所以每个 skill 除了实现代码还需要一份元数据名称、描述、适用场景、输入参数说明、输出示例、依赖项。这份元数据可以用 JSON 或 YAML 写调度器在初始化时加载所有 skill 的元数据形成一个“技能清单”。当用户请求进来时调度器先把请求和技能清单一起交给意图识别模块可以是大模型也可以是规则引擎让它选出最合适的 skill。我实测下来元数据里的“描述”字段非常关键。描述写得好意图识别准确率能提升一大截。比如query_logistics的描述写成“查询包裹的物流轨迹和当前状态”就比“物流查询”要好得多因为前者包含了用户可能说的“包裹”“轨迹”“状态”这些词。这个细节后面在实操部分还会展开。3. 核心细节解析与实操要点3.1 skill 的输入输出设计用数据类而不是裸字典在 Python 里很多人喜欢用字典传参因为方便。但字典的问题是字段名没有约束调用方写错一个 key运行时才报错。我现在的做法是每个 skill 定义两个数据类一个输入类一个输出类。输入类的字段就是 skill 需要的参数输出类的字段就是 skill 返回的结果。举个例子query_order_status的输入类可能是OrderQueryInput(order_id: str, user_id: str)输出类可能是OrderQueryOutput(status: str, items: list, total: float, error: str)。调度器在调用前构造输入对象调用后读取输出对象。这样做的好处是 IDE 能自动补全类型检查能提前发现错误而且数据类的定义本身就是一份文档。如果你用的是 JavaScript 或 TypeScript可以用 interface 或 type 来定义如果是 Java用 POJO 或 record。核心思想是一样的让 skill 的契约显式化。我见过一个项目用 JSON Schema 来定义每个 skill 的输入输出然后在调度器里做校验效果也很好只是写起来稍微繁琐一点。3.2 错误处理skill 不应该抛异常给调度器这是我在生产环境踩过的最大的坑。早期我让 skill 内部直接抛异常调度器用 try-catch 包住。结果有一次一个 skill 抛了一个没预料到的异常类型调度器没捕获整个服务挂了。后来我改成skill 内部捕获所有异常把错误信息包装在输出对象的 error 字段里返回。调度器只检查 error 字段是否为空不处理异常。这样做的好处是错误处理逻辑统一了。不管 skill 内部是网络超时、参数错误还是数据库连接失败调度器看到的都是一个带有 error 信息的正常返回。调度器可以根据 error 的类型决定是重试、降级还是转人工。而且日志也好打每个 skill 的失败率一目了然。提示error 字段建议用结构化的对象而不是一个字符串。比如{code: TIMEOUT, message: 请求超时, retryable: true}。这样调度器可以根据 code 做不同处理根据 retryable 决定是否重试。字符串错误信息只适合给人看不适合给程序判断。3.3 参数提取别让 skill 自己解析自然语言我见过一些设计让每个 skill 自己去解析用户的自然语言输入。比如query_order_status里面写正则去提取订单号。这样做的问题是参数提取逻辑分散在各个 skill 里重复且难以维护。而且同一个参数可能被多个 skill 需要比如user_id每个 skill 都解析一遍就是浪费。正确的做法是参数提取由调度器统一完成skill 只接收结构化参数。调度器在选定 skill 后根据 skill 元数据里声明的参数列表从用户请求中提取对应参数。提取可以用大模型也可以用规则但只做一次。提取完成后构造输入对象传给 skill。这样 skill 的代码非常干净只关心业务逻辑不关心语言理解。我实测下来把参数提取集中到调度器后代码量减少了大约三分之一而且参数提取的准确率也提升了因为可以针对所有 skill 的参数做统一的上下文管理。比如用户上一轮说了订单号这一轮说“查一下物流”调度器可以从对话历史里拿到订单号不需要用户重复。3.4 skill 的注册与发现用注册表而不是硬编码最早我在调度器里写了一个字典手动把 skill 名称映射到函数。每次加新 skill 都要改调度器代码很容易漏。后来改成自动注册每个 skill 模块在导入时把自己的元数据和实现注册到一个全局注册表里。调度器启动时扫描指定目录下的所有 skill 模块自动加载。具体实现可以用装饰器。比如定义一个skill装饰器挂在每个 skill 类上装饰器负责把类注册到注册表。调度器只需要import skills包所有 skill 就自动加载了。这样加新 skill 只需要新建一个文件不需要动任何现有代码符合开闭原则。注册表里除了存 skill 的实现还存元数据。调度器做意图识别时从注册表读取所有元数据拼成一个技能清单发给大模型。大模型返回选中的 skill 名称后调度器再从注册表拿到实现并调用。整个流程非常清晰。3.5 技能清单的排序与过滤别把所有 skill 都塞给大模型当 skill 数量少的时候比如十个以内把所有元数据塞给大模型没问题。但当 skill 数量到几十个甚至上百个时提示词会变得很长不仅浪费 token还会降低大模型的判断准确率。我试过把五十个 skill 的元数据一次性发给大模型结果它经常选错因为信息过载了。解决办法是分层过滤。第一层用关键词或向量检索从所有 skill 中粗筛出十个候选第二层把这十个候选的元数据发给大模型让它做精细选择。粗筛可以用简单的 TF-IDF 或嵌入向量相似度不需要太复杂。我实测下来两层过滤后意图识别的准确率比一次性全量发送提升了大约百分之二十而且响应速度也快了不少。注意粗筛的召回率要保证宁可多召回一些也不要漏掉正确的 skill。我一般把粗筛的候选数量设为最终需要的三到五倍。比如最终要让大模型从十个里选一个粗筛就召回三十到五十个。这样即使粗筛排序不是特别准正确的 skill 大概率还在候选里。4. 实操过程与核心环节实现4.1 环境准备与目录结构我以 Python 为例因为生态最成熟。你需要 Python 3.9 以上主要依赖两个库pydantic用于数据类定义和校验openai或类似的 SDK 用于调用大模型做意图识别。目录结构建议这样组织agent_skills/ ├── scheduler/ │ ├── __init__.py │ ├── registry.py # 注册表 │ ├── dispatcher.py # 调度器主逻辑 │ └── extractor.py # 参数提取 ├── skills/ │ ├── __init__.py │ ├── base.py # skill 基类 │ ├── query_order.py │ ├── query_logistics.py │ └── get_return_policy.py ├── models/ │ ├── __init__.py │ └── schemas.py # 输入输出数据类 └── main.py这个结构的好处是调度逻辑和 skill 实现完全分离。skills目录下每个文件就是一个独立 skill加新 skill 只需要新建文件。models目录集中放数据类方便复用。scheduler目录放调度相关代码不涉及具体业务。4.2 定义 skill 基类与注册装饰器先写基类。每个 skill 必须继承BaseSkill并实现execute方法。基类里定义name、description、input_schema、output_schema四个类属性。from abc import ABC, abstractmethod from pydantic import BaseModel class BaseSkill(ABC): name: str description: str input_schema: type[BaseModel] None output_schema: type[BaseModel] None abstractmethod def execute(self, input_data: BaseModel) - BaseModel: pass然后是注册装饰器。用一个全局字典SKILL_REGISTRY存所有 skill 类。SKILL_REGISTRY {} def register_skill(cls): if not cls.name: raise ValueError(skill 必须有 name) if cls.name in SKILL_REGISTRY: raise ValueError(fskill 名称重复: {cls.name}) SKILL_REGISTRY[cls.name] cls return cls用的时候挂在类上register_skill class QueryOrderSkill(BaseSkill): name query_order_status description 查询订单的当前状态、商品明细和总金额 input_schema OrderQueryInput output_schema OrderQueryOutput def execute(self, input_data): # 业务逻辑 ...这样只要import skills包所有 skill 就自动注册了。我一般会在skills/__init__.py里用importlib动态导入当前目录下所有.py文件避免手动维护导入列表。4.3 调度器主流程从用户输入到 skill 执行调度器的核心逻辑分四步加载 skill、意图识别、参数提取、执行 skill。我用一个Dispatcher类来封装。class Dispatcher: def __init__(self, llm_client): self.llm llm_client self.skills SKILL_REGISTRY def handle(self, user_input: str, context: dict) - dict: # 第一步粗筛候选 skill candidates self._coarse_filter(user_input, top_k30) # 第二步大模型精细选择 selected self._fine_select(user_input, candidates) if not selected: return {error: 没有找到合适的技能} # 第三步参数提取 skill_cls self.skills[selected] params self._extract_params(user_input, skill_cls, context) # 第四步执行 input_obj skill_cls.input_schema(**params) skill skill_cls() output_obj skill.execute(input_obj) return output_obj.dict()粗筛我用的是简单的关键词匹配加编辑距离没有上向量检索因为 skill 数量不多的时候够用了。如果 skill 上百建议换成嵌入向量。精细选择就是把候选 skill 的name和description拼成提示词让大模型返回最匹配的一个名称。参数提取的提示词里要包含 skill 的input_schema字段说明让大模型输出 JSON 格式的参数。我一般会加一句“如果某个参数在用户输入和上下文中都找不到返回 null”避免大模型瞎编。4.4 一个完整 skill 的实现示例查询订单状态以query_order_status为例输入输出数据类先定义好class OrderQueryInput(BaseModel): order_id: str user_id: str class OrderQueryOutput(BaseModel): status: str items: list [] total: float 0.0 error: dict {}execute方法里做三件事校验参数、查数据、包装结果。注意所有异常都要捕获并放到error里。def execute(self, input_data: OrderQueryInput) - OrderQueryOutput: try: if not input_data.order_id: return OrderQueryOutput(error{code: MISSING_PARAM, message: 缺少订单号, retryable: False}) # 模拟数据库查询 order self._fetch_order(input_data.order_id, input_data.user_id) if not order: return OrderQueryOutput(error{code: NOT_FOUND, message: 订单不存在, retryable: False}) return OrderQueryOutput( statusorder[status], itemsorder[items], totalorder[total] ) except Exception as e: return OrderQueryOutput(error{code: INTERNAL, message: str(e), retryable: True})这个模式我用了很多次非常稳。关键是retryable字段调度器可以根据它决定是否重试。比如网络超时设为True参数缺失设为False。4.5 参数计算与选择粗筛 top_k 怎么定粗筛的top_k我一般设为最终候选数的三到五倍。假设精细选择时我希望大模型从十个里选那粗筛就召回三十到五十个。这个数字不是拍脑袋来的我做过对比测试top_k设为十时正确 skill 的召回率只有百分之七十左右设为三十时召回率到百分之九十五设为五十时召回率到百分之九十八但提示词长度增加了一倍。综合考虑三十是一个比较平衡的值。如果你的 skill 总数不到二十个其实可以跳过粗筛直接全量发给大模型。我一般以二十为界超过二十就加粗筛。这个阈值可以根据你的大模型上下文窗口调整窗口大的可以放宽到三十。4.6 实操现场记录从零搭一个三 skill 的 demo我实际搭过一个最小 demo包含三个 skill查订单、查物流、查退货政策。整个过程大约花了两个小时其中写代码一个小时调试一个小时。调试主要花在提示词上因为大模型一开始总是把“我的包裹”识别成查订单后来我在query_logistics的描述里加了“包裹、快递、物流、配送”这些词准确率就上来了。运行流程是这样的用户输入“我的包裹到哪了”调度器粗筛出三个候选因为总共就三个大模型选中query_logistics参数提取拿到order_id从上下文里取执行 skill 返回物流轨迹。整个过程响应时间大约一点五秒其中大模型调用占了一秒skill 执行占零点五秒。如果对响应时间敏感可以把意图识别换成小模型或规则引擎但准确率会下降一些。提示demo 阶段建议把每次调用的提示词和返回结果都打到日志里方便调试。我一般会记录用户输入、候选 skill 列表、大模型选择结果、参数提取结果、skill 执行结果这五项。出问题时一眼就能看出是哪一步错了。5. 常见问题与排查技巧实录5.1 意图识别总选错 skill 怎么办这是最常见的问题。排查顺序是这样的先看粗筛有没有把正确的 skill 召回如果没召回说明粗筛逻辑有问题检查关键词或向量模型如果召回了但大模型没选中说明描述写得不够好或者候选太多导致信息过载。我的经验是描述里要包含用户可能说的同义词和场景词。比如query_logistics的描述不要只写“物流查询”要写“查询包裹、快递、配送的物流轨迹和当前状态”。另外候选数量控制在十个以内太多就加粗筛。还有一个技巧在提示词里给大模型几个例子。比如“用户说‘我的东西怎么还没到’应该选 query_logistics”。少样本提示对准确率提升很明显我实测能提升百分之十到十五。5.2 参数提取总是缺字段或提取错参数提取的问题通常有两个原因一是提示词里没有说清楚每个参数的含义和格式二是上下文没有正确传递。我的做法是在提示词里把input_schema的每个字段名、类型、说明都列出来并给一个 JSON 示例。比如“order_id 是字符串表示订单编号通常以字母开头”。另外上下文里要包含最近几轮的对话历史这样大模型才能从历史里找到用户之前提到的订单号。如果某个参数确实提取不到不要让它瞎编返回 null然后 skill 内部做校验并返回错误。我见过一些实现让大模型“猜”一个订单号结果查出来别人的订单这是严重的事故。5.3 skill 执行超时或失败怎么处理调度器里要对 skill 执行加超时控制。我一般用concurrent.futures的ThreadPoolExecutor加timeout参数或者用asyncio.wait_for。超时后返回一个TIMEOUT错误retryable设为True。调度器可以根据配置决定重试几次我一般重试两次间隔一秒。对于retryable为False的错误比如参数缺失直接返回给用户提示不要重试。对于retryable为True的错误重试两次后如果还失败就降级处理比如返回“服务暂时不可用请稍后再试”。5.4 常见问题速查表问题现象可能原因排查方法解决方案意图识别选错 skill描述不清晰或候选过多检查粗筛召回和提示词优化描述加同义词控制候选数参数提取缺失提示词未说明字段或上下文不足查看提取日志补充字段说明和对话历史skill 执行超时外部依赖慢或死循环加超时日志设置超时重试降级错误信息不统一skill 直接抛异常检查 skill 代码统一捕获异常包装 error 字段加新 skill 要改调度器硬编码注册检查注册逻辑用装饰器自动注册响应时间过长大模型调用慢打点计时加粗筛换小模型缓存结果5.5 独家避坑技巧别在 skill 里做重试我早期在 skill 内部做重试比如网络请求失败后循环重试三次。后来发现这会导致调度器层面的超时控制失效因为 skill 内部重试把时间耗光了调度器以为 skill 卡死了。正确的做法是skill 只执行一次失败就返回错误重试逻辑放在调度器层面。调度器知道全局的超时预算可以决定是否重试以及重试几次。这样职责更清晰也更容易控制整体响应时间。另一个坑是 skill 之间的依赖。我见过一个设计skill A 内部直接调用 skill B。这会导致依赖关系混乱测试困难。正确的做法是skill 之间不直接调用需要组合时由调度器编排。比如先调 skill A拿到结果后再调 skill B把 A 的结果作为 B 的输入。调度器负责编排逻辑skill 保持独立。5.6 性能优化缓存与并发当 skill 数量多、调用频繁时两个优化手段很有效。第一是缓存意图识别结果对于相同的用户输入如果上下文没变可以直接复用上次的选择结果。我用一个简单的 LRU 缓存命中率大概百分之三十响应时间明显下降。第二是并发执行无依赖的 skill如果一次请求需要调多个 skill且它们之间没有依赖可以用线程池并发执行。我实测并发后多 skill 场景的响应时间从三秒降到一点二秒。不过并发要注意线程安全。如果 skill 内部访问了共享资源比如数据库连接池要确保连接池大小足够。我一般把连接池大小设为并发数的两倍留一些余量。6. 技能评估与持续迭代6.1 怎么判断一个 skill 是否“合格”skill 写完不是就完了需要有一套评估标准。我一般看四个指标准确率执行结果正确的比例、失败率返回 error 的比例、平均耗时、调用频次。准确率需要人工标注一批测试用例跑一遍看结果。失败率可以从日志里统计。平均耗时和调用频次也是从日志里来。我建议每个 skill 上线前至少跑五十条测试用例覆盖正常情况、边界情况和异常情况。正常情况比如订单存在且状态正常边界情况比如订单号为空、用户 ID 不匹配异常情况比如数据库连接失败。测试用例用 JSON 文件管理方便回归测试。6.2 根据日志迭代 skill 描述和参数上线后要定期看日志重点关注两类问题一是被选错 skill 的请求看看用户实际想干什么然后优化描述或加同义词二是参数提取失败的请求看看缺哪个字段然后优化提示词或调整参数提取逻辑。我一般每周看一次日志每次迭代都能把准确率提升几个百分点。还有一个技巧把用户输入和选中的 skill 做成一个分布图看看哪些 skill 被频繁选中哪些很少被选中。很少被选中的 skill 可能是描述有问题也可能是这个功能用户根本不需要。前者优化描述后者考虑下线。6.3 技能版本管理与灰度发布当 skill 数量多、团队多人维护时版本管理很重要。我一般给每个 skill 加一个version字段注册表里存最新版本。新版本上线时先灰度比如只对百分之十的请求生效观察失败率和准确率。如果指标正常再逐步扩大比例。如果指标下降立即回滚。灰度发布可以用配置中心实现调度器根据请求 ID 的哈希值决定用哪个版本。这个机制在 skill 频繁迭代时非常有用能避免一次改动影响所有用户。7. 我个人的一些实操体会这套 agent-skills 的架构我用了大概一年半从最初的三四个 skill 到现在几十个 skill整体运行很稳。最大的体会是前期在接口设计和注册机制上多花的时间后期会十倍地省回来。我见过太多项目为了赶进度skill 接口随便定结果三个月后没人敢加新功能因为一加就崩。另一个体会是不要过度设计。我一开始想搞一套很复杂的 skill 编排引擎支持条件分支、循环、并行后来发现百分之九十的场景就是“选一个 skill 执行”根本用不上那些。所以现在的调度器非常薄只做选择、提取、执行三件事。需要复杂编排时我宁愿写一个专门的 skill 来封装也不愿意把调度器搞复杂。最后分享一个小技巧给每个 skill 加一个example字段写一句典型的用户输入。这个字段在调试时特别有用你可以直接拿这些例子跑一遍看看意图识别能不能选对。我一般会攒一个“回归测试集”每次改完提示词就跑一遍确保没有退化。这个方向后续还可以扩展的地方很多比如 skill 的自动发现和组合、基于反馈的 skill 排序、跨 agent 的 skill 共享。但那是另一个话题了先把基础的做好比什么都强。