"agent-skills"这个词,最近在AI开发圈子里热度涨得很快。如果你跟我一样,日常在跟大模型Agent打交道,一定遇到过这种尴尬:同一个agent项目里,工具函数堆了一大堆,prompt越写越长,逻辑纠缠在一起,改一个地方崩三处;换个场景复用,又得从零开始复制粘贴。agent-skills就是冲着这个痛点去的——它把“技能”做成一等公民,用一套标准化的方式定义、注册、组合、复用agent的能力,让复杂任务变得像搭积木一样清晰。
这篇文章不是官方文档的翻译,而是我自己在多个项目里把这一类思路落地之后,踩过坑、翻过车、最后沉淀下来的实操总结。内容适合两类人:一是已经写过几个agent demo、正在思考怎么工程化的人;二是想给自己的agent加“可插拔技能”但还没找到干净方案的人。我会从概念拆解讲到具体实现,再给出完整的代码示例和排错记录,尽量让你读完就能在自己的项目里动手改起来。
1. 项目整体设计与思路拆解
1.1 为什么需要“技能”这个抽象层
先聊一个本质问题:大模型Agent的本质,是让模型在“思考”和“行动”之间循环。思考靠prompt和上下文,行动靠调用外部工具。刚开始做demo的时候,你可以在system prompt里写“你可以使用以下工具”,然后把函数定义一股脑塞进去。项目小的时候没问题,但一旦工具超过五六个、业务流程超过两轮,这种写法立刻失控。
失控体现在几个层面。第一是上下文污染:每个工具的长描述、参数说明、使用限制全都挤在prompt里,模型注意力被稀释,关键指令反而容易被忽略。第二是耦合严重:工具之间的调用顺序、数据格式传递、错误处理散落在主逻辑里,改一个工具,其他逻辑跟着遭殃。第三是复用困难:你在项目A里写好的“搜索总结”流程,换到项目B里根本搬不过去,因为它是跟A的prompt和状态管理长在一起的。
agent-skills解决的核心问题,就是把“能力”和“流程”解耦。一个skill不是简单的函数,而是一个自包含的模块——它包含触发条件、运行逻辑、输入输出schema、依赖的其他技能、以及对应的prompt片段。Agent的主循环只需要做一件事:根据当前目标,从技能库里选出合适的技能,然后执行它。这样设计之后,新增能力就是塞一个新的skill文件夹,复用能力就是加载一个现成skill包,排查问题就是把每个skill当成一个独立的黑盒来测。
1.2 与函数调用(function calling)的区别和关系
很多人会问:OpenAI的function calling已经很成熟了,为什么还要自己做一套skill层?我的理解是,function calling是“单个动作”的接口规范,而skill是“一组动作”的流程封装。打个比方,function calling好比给工人提供了一把电钻,而skill是一个“打孔+埋膨胀螺丝+挂画框”的完整工序。
举一个具体场景:你让agent“帮我把这篇网页文章整理成摘要发到我的笔记里”。如果裸用function calling,你需要写fetch网页、格式转换、调用摘要模型、写笔记四个函数,然后在主逻辑里编排它们的调用顺序,还要处理中间数据。而如果用skill设计,你只需要一个“web_to_note”的skill,内部自己管理步骤,外部只暴露两个参数:URL和目标笔记ID。模型只需要做一次决策——“这个任务匹配web_to_note技能”——剩下的步骤全部由skill内部消化。
当然,这两者不是对立关系。现代Agent框架里,skill的内部实现完全可以依赖function calling或tool calling。关键在于分层:底层是细粒度的function,上层是粗粒度的skill,模型先选skill,再让skill内部调度function。这样既保留了function calling的结构化输出优势,又获得了业务流程的可编排性。
1.3 技能库应该具备哪些核心能力
经过几次重构,我认为一个合格的agent-skills项目至少得满足六个条件,缺一个后期都会还债:
- 标准化的技能定义:每个skill有统一的元信息(名称、描述、版本、作者)、输入schema、输出schema、依赖声明。
- 独立可测试:每个skill可以脱离agent主流程单独跑通,输入确定,输出可预期。
- 动态注册与发现:系统能扫描技能目录,自动加载新技能,不用改主程序代码。
- 依赖管理:技能之间可以互相调用,但依赖关系要显式声明,避免隐式引用导致的神秘错误。
- 上下文管理:技能执行过程中的中间数据要可控,不能把无关上下文丢回主对话里。
- 可观测性:技能调用的开始、结束、耗时、成功失败要有日志,方便定位问题。
这些能力听起来多,其实实现起来并不复杂。核心思路就是约定大于配置:定义好目录结构、文件格式、接口签名,然后写一个轻量级的注册器。下面两章我会从零开始搭一个最小的可运行版本。
2. 核心细节解析与实操要点
2.1 技能目录与元信息设计
我在实践中采用的目录结构是这样的:
agent-skills/ ├── skills/ │ ├── web_search/ │ │ ├── skill.yaml │ │ ├── run.py │ │ ├── prompt.md │ │ └── requirements.txt │ ├── summarize/ │ │ ├── skill.yaml │ │ ├── run.py │ │ └── prompt.md │ └── weather_lookup/ │ ├── skill.yaml │ ├── run.py │ └── requirements.txt ├── core/ │ ├── registry.py │ ├── executor.py │ └── schema.py └── main.py每个技能文件夹就是一个独立的技能包。skill.yaml是核心元信息文件,下面是一个实例:
name: web_search version: "1.2.0" description: 通过搜索引擎获取网页结果,并返回标题、链接和摘要列表。 author: alice tags: [search, web] input_schema: type: object properties: query: type: string description: 搜索关键词。 max_results: type: integer description: 返回结果数量,默认5。 default: 5 required: [query] output_schema: type: array items: type: object properties: title: { type: string } url: { type: string } snippet: { type: string } dependencies: - http_client这里有几个容易忽略的细节。description字段非常重要,因为它是模型判断“该不该用这个技能”的依据。写得太泛(比如“搜索”),模型可能该用的时候不用;写得太啰嗦,又会占用token。我一般控制在50到100个汉字,包含触发场景关键词和与相似技能的区分点。例如,如果同时有“web_search”和“internal_search”,我会在description里写明“适用于查询公开互联网信息,不适用于公司内部文档”,降低误选率。
依赖声明dependencies解决的是跨技能复用问题。比如一个“写完笔记自动搜索相关图片”的技能,依赖note_writer和image_finder两个技能。注册器在加载时会做拓扑排序,保证先加载依赖项。
2.2 技能执行接口的标准签名
所有技能的执行入口,我都统一用Python函数形式,签名保持完全一致:
async def run(context: SkillContext, **kwargs) -> SkillResult: ...SkillContext是环境上下文,包含logger、模型客户端、HTTP会话、配置项等全局对象。kwargs是经过schema校验后的参数。SkillResult是统一返回结构,包含success、data、error、duration等字段。
之所以用async def,是因为技能里大概率会发网络请求或调用模型,异步是基本需求。统一签名带来的最大好处是:executor可以用完全一样的代码去调度所有技能,不需要针对某个技能写特例。热加载新技能时,只需要把新技能的run函数注册进去,其余全部复用。
在SkillContext里我还会放一个step_notify回调函数,用来向主流程汇报技能内部的关键进度。这个设计是之后做可观测性的基础。你可以在run.py里执行长耗时任务时,每完成一个子步骤就调用一次step_notify,这样即使技能最后失败,你也能知道它卡在了哪一步。
2.3 Prompt片段如何跟随技能走
很多人把技能里的prompt写在主system prompt里,这恰恰是导致混乱的最大原因。正确的做法是让每个技能自带prompt片段,在执行时才注入。我的做法是,在skill文件夹放一个prompt.md,里面写的是该技能在与主模型交互时的系统级指令。
举例,summarize技能的prompt.md:
你是一个内容摘要专家。你将收到一段文本,请输出结构化摘要。 要求: 1. 用不超过200字概括核心观点。 2. 分点列出关键论据,每点的第一个词必须是动词。 3. 最后输出“可引用的原句”,最多三条,必须加引号。 4. 如果文本包含数据结论,必须注明数据来源。这个prompt片段在该技能被选中执行时,以独立消息块的形式注入,而不是预先塞进主prompt。好处很明显:主prompt始终精简,模型注意力更聚焦。其次,prompt跟代码放在同一个技能目录,改摘要风格不需要动主程序,直接改md文件即可,对非程序员也很友好。
一个需要特别注意的地方:模型调用时的角色分配。技能里的prompt片段不是单纯拼进user消息,而是作为system级别的指示,或者作为单独的“技能指令”标识。我在实现中是把主system prompt和技能prompt拼接后用两个消息块传给模型,中间用“【当前技能:summarize】”这样的天然语言分隔,实测比单块拼接效果好很多。
2.4 技能的输入校验与容错
skill.yaml里的input_schema不是摆设,它应该被真正执行校验。我不建议自己在代码里手写一堆if判断,而是直接用pydantic或jsonschema来做。在加载技能时,根据yaml里的schema动态生成对应的校验器,调用run之前强制校验。
校验失败的错误信息要足够清晰。我见过最坑的错误提示是“invalid input”,完全不知道缺了哪个字段。我在registry里会把校验错误翻译成这种格式:
技能 web_search 参数校验失败: - query: 该字段是必填项 - max_results: 值必须为整数这种提示既方便人看,也方便模型下一次自我修正。容错方面,我在SkillResult里专门留了retryable字段。对于超时、临时网络错误这类可重试的错误,executor会按预设策略自动重跑一次。对于业务逻辑错误(比如搜索结果为空),则不重试直接返回,避免浪费token和时间。
3. 实操过程与核心环节实现
3.1 最小可运行技能系统的完整代码
下面我给出一个经过精简但可直接运行的实现。为控制篇幅,这里用一个内存技能库和两个示例技能来演示,重点展示注册、校验、执行的完整链路。
先看core/schema.py:
import time import uuid from dataclasses import dataclass, field from typing import Any, Callable, Optional @dataclass class SkillResult: success: bool data: Any = None error: Optional[str] = None retryable: bool = False duration: float = 0.0 trace_id: str = field(default_factory=lambda: str(uuid.uuid4())) def to_dict(self): return { "success": self.success, "data": self.data, "error": self.error, "retryable": self.retryable, "duration": self.duration, "trace_id": self.trace_id, } @dataclass class SkillContext: logger: Any http_session: Any = None llm_client: Any = None step_notify: Optional[Callable[[str], None]] = None config: dict = field(default_factory=dict)这个文件定义了最核心的数据结构。SkillResult是所有技能的统一出口,trace_id用来关联日志。
再看core/registry.py:
import asyncio import importlib import inspect from pathlib import Path from typing import Dict import yaml from pydantic import BaseModel, create_model class SkillDefinition: def __init__(self, meta: dict, module): self.name = meta["name"] self.description = meta["description"] self.version = meta.get("version", "0.0.1") self.input_schema = meta.get("input_schema", {}) self.output_schema = meta.get("output_schema", {}) self.dependencies = meta.get("dependencies", []) self.run_func = module.run self.prompt_text = self._load_prompt(module) def _load_prompt(self, module): prompt_path = Path(module.__file__).parent / "prompt.md" if prompt_path.exists(): return prompt_path.read_text(encoding="utf-8") return "" def build_input_validator(self): fields = {} props = self.input_schema.get("properties", {}) required = set(self.input_schema.get("required", [])) for field_name, prop in props.items(): ptype = prop.get("type", "string") if ptype == "string": py_type = (str, ...) elif ptype == "integer": py_type = (int, ...) elif ptype == "boolean": py_type = (bool, ...) elif ptype == "array": py_type = (list, ...) else: py_type = (Any, ...) if field_name not in required: default_val = prop.get("default", None) py_type = (Optional[py_type[0]], default_val) fields[field_name] = py_type model = create_model(f"{self.name}_input", **fields) return model class SkillRegistry: def __init__(self): self._skills: Dict[str, SkillDefinition] = {} def load_from_directory(self, skills_dir: str): skills_path = Path(skills_dir) for skill_folder in skills_path.iterdir(): if not skill_folder.is_dir(): continue meta_file = skill_folder / "skill.yaml" if not meta_file.exists(): continue meta = yaml.safe_load(meta_file.read_text(encoding="utf-8")) module = importlib.import_module(f"skills.{skill_folder.name}.run") skill_def = SkillDefinition(meta, module) self._skills[skill_def.name] = skill_def self._validate_dependencies() def _validate_dependencies(self): for name, skill in self._skills.items(): for dep in skill.dependencies: if dep not in self._skills: raise RuntimeError(f"技能 {name} 依赖 {dep},但未找到该依赖") def get(self, name: str) -> Optional[SkillDefinition]: return self._skills.get(name) def list_skills(self): return [ {"name": s.name, "description": s.description, "version": s.version} for s in self._skills.values() ]这里有个小技巧:用pydantic的create_model动态生成校验模型,这样yaml里改schema,代码不用跟着改。加载目录时直接importlib导入每个skill包里的run.py,理所应当要求每个技能都是独立可导入的包。
然后是core/executor.py:
import asyncio import time class SkillExecutor: def __init__(self, registry: SkillRegistry, context: SkillContext): self.registry = registry self.context = context async def execute(self, skill_name: str, params: dict) -> SkillResult: skill_def = self.registry.get(skill_name) if skill_def is None: return SkillResult(success=False, error=f"未找到技能: {skill_name}") validator = skill_def.build_input_validator() try: validated = validator(**params).model_dump() except Exception as e: return SkillResult(success=False, error=f"参数校验失败: {e}") start = time.monotonic() try: result = await skill_def.run_func(self.context, **validated) if isinstance(result, SkillResult): result.duration = time.monotonic() - start return result return SkillResult(success=True, data=result, duration=time.monotonic() - start) except Exception as e: duration = time.monotonic() - start return SkillResult(success=False, error=str(e), retryable=True, duration=duration)执行器的逻辑很直白:取出技能定义,校验参数,调用run函数。这里我把所有异常统一捕获并转成SkillResult,防止技能内部异常直接打穿主流程。
写两个示例技能。第一个是web_search技能,内部用fake数据代替真实请求:
skills/web_search/skill.yaml:
name: web_search version: "1.0.0" description: 执行互联网搜索,获取网页标题、链接和摘要。适用于需要查询实时信息、最新资讯、事实核查等场景。 input_schema: type: object properties: query: type: string description: 搜索关键词。 max_results: type: integer description: 返回结果数量。 default: 5 required: [query]skills/web_search/run.py:
import asyncio async def run(context, query: str, max_results: int = 5): context.logger.info(f"[web_search] 查询: {query}") if context.step_notify: context.step_notify("正在发起搜索请求") await asyncio.sleep(0.1) # 模拟请求耗时 # 真实场景这里调用 requests/aiohttp 调用搜索引擎API fake_results = [ {"title": f"{query} 相关结果 {i}", "url": f"https://example.com/{i}", "snippet": f"这是关于{query}的第{i}条摘要"} for i in range(max_results) ] return fake_results第二个是summarize技能,它不直接依赖外部搜索,而是接收文本:
skills/summarize/run.py:
async def run(context, text: str, max_words: int = 200): context.logger.info(f"[summarize] 输入文本长度: {len(text)}") if context.step_notify: context.step_notify("正在生成摘要") # 真实场景这里调用LLM summary = f"(模拟摘要)核心观点:{text[:50]}…… 共约{max_words}字。" return {"summary": summary, "keywords": ["示例"]}最后写main.py,把所有东西串起来:
import asyncio import logging from core.registry import SkillRegistry from core.executor import SkillExecutor from core.schema import SkillContext logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") logger = logging.getLogger("agent-skills-demo") async def main(): registry = SkillRegistry() registry.load_from_directory("skills") logger.info("已加载技能: %s", [s["name"] for s in registry.list_skills()]) context = SkillContext(logger=logger, step_notify=lambda msg: logger.info(f"进度: {msg}")) executor = SkillExecutor(registry, context) # 执行搜索技能 result = await executor.execute("web_search", {"query": "agent skills 最佳实践", "max_results": 3}) print("搜索结果:", result.to_dict()) # 执行摘要技能 result2 = await executor.execute("summarize", {"text": "这是一段很长的文章内容,讲的是Agent技能化的设计思路。"}) print("摘要结果:", result2.to_dict()) if __name__ == "__main__": asyncio.run(main())运行main.py,输出应该类似这样:
INFO 已加载技能: ['web_search', 'summarize'] INFO [web_search] 查询: agent skills 最佳实践 INFO 进度: 正在发起搜索请求 搜索结果: {'success': True, 'data': [...], 'error': None, 'retryable': False, 'duration': 0.11, 'trace_id': '...'}到这里,一个最简技能系统已经跑通了。它具备注册、参数校验、统一调度、进度回调、错误封装这些核心能力,后续加新技能只是加一个目录的事。
3.2 与主Agent循环的集成方式
上面这个小系统是纯调度器,但真实场景里需要跟大模型推理循环集成。我的做法是:在Agent的规划阶段,把技能列表转成模型的function schema,让模型决定调用哪个技能。关键点是,这个function schema里对应的是skill级别,而不是底层工具级别。
例如,将web_search技能转成如下function定义:
{ "name": "call_skill", "parameters": { "type": "object", "properties": { "skill_name": { "type": "string", "enum": ["web_search", "summarize"] }, "skill_params": { "type": "object" } }, "required": ["skill_name", "skill_params"] } }模型输出“调用skill_name=web_search,skill_params={...}”之后,主循环直接丢给executor执行,再把SkillResult塞回对话上下文。这种做法的最大好处是:当技能数量增加,模型的function列表不会膨胀,因为永远只有一个call_skill函数,真正的选择发生在参数里。模型的任务从“从50个工具里挑一个”变成“从文本描述的技能列表里挑一个”,准确率高很多。
不过这种方案也有代价:描述字段必须精心编写。我在skill.yaml里加过一个字段叫_agent_usage_hint,里面写了一段供模型阅读的“技能选择指南”,例如“当用户想要了解今天天气时,优先使用weather_lookup;当用户提到‘最近’‘最新’时,优先使用web_search”。这段hint不会注入给模型,但会自动拼接到call_skill函数的description里。实测下来,这个字段能明显降低模型误选技能的概率。
3.3 技能间调用与数据流设计
高级一点的场景,技能间会互相调用。比如“写一篇今日科技新闻简报”,内部需要先调web_search拿新闻,再调summarize做摘要,最后调note_writer保存。
技能间调用有两种方案。方案一:技能A内部直接import技能B的run函数,然后调用它。这种做法耦合最强,但违反了自包含原则,skill B的参数校验、错误处理都被跳过。方案二:在SkillContext里注入executor的引用,技能A内部通过executor.execute调用技能B。这样技能B的完整生命周期依然受管控。
方案二是我推荐的,但要注意循环依赖。比如A调用B,B又依赖A,就会死锁。我在registry的_validate_dependencies里用DFS检测了环,如果发现循环依赖就直接报错。技能间传递的数据要符合B的input_schema,所以A在调用B之前,需要把数据重新组装成B需要的样子,不能直接把A内部的数据结构丢过去。
还有一个数据流细节:技能内部产生的中间数据,默认不回传主对话。比如web_search拿到的原始HTML,模要转成模型理解的结构化摘要。我在SkillResult里设计了一个visible_data字段,只有标注了对用户可见的数据才会回传,其余都留在本地日志里。这样做能大幅减少token浪费。
4. 常见问题与排查技巧实录
4.1 模型总是选错技能,怎么调优
这是技能化改造后最常遇到的问题。我接手过一个项目,技能库里有“获取订单状态”和“获取物流信息”两个技能,模型经常把查物流的请求打到订单状态上。排查后发现原因很简单:两个技能的description太像了。
我的调优技巧有三个,按优先级排列:
- 在description里写清楚“不适用”的场景。比如订单状态里加一句“本技能只返回订单审核、支付、完成状态,不返回快递流转信息”。负向约束有时候比正向描述更有效。
- 增加“技能选择指南”字段,用if-then句式指导模型。例如“如果用户询问‘快递到哪了’‘发货了吗’,使用物流查询技能,不要使用订单状态技能”。
- 如果还是选错,考虑把两个技能合并成一个,用参数区分。毕竟模型面对选择时,选项越少越稳。
4.2 技能执行超时与重试策略
技能里最常见的问题就是外部请求挂起。我的默认策略是给每个技能设置超时,默认10秒,可在上下文配置里覆盖。实现时不是依赖Python函数内部自己超时,而是在executor层用asyncio.wait_for包住run调用。
try: result = await asyncio.wait_for( skill_def.run_func(self.context, **validated), timeout=self.timeout ) except asyncio.TimeoutError: return SkillResult(success=False, error="技能执行超时", retryable=True)重试策略不是无脑重试。我规定只有retryable错误的技能才重试,且最多重试2次,两次间隔用指数退避(1秒、2秒)。对于幂等性差的技能(比如“创建订单”),重试会重复下单,这类技能要把retryable标记为False。怎么标记?我在skill.yaml里增加了retry_policy字段,配置allowed和max_retries,代码里读取这个配置,而不是硬编码。
4.3 技能热更新与版本冲突
开发过程中免不了频繁改技能代码。最开始我的做法是每次改完重启整个服务,后来技能多了,重启一次要好几十秒,实在受不了。于是做了热更新:通过文件监听,发现某个技能目录的skill.yaml或run.py变更后,自动重新import并替换注册表里的条目。
热更新有一个天坑:Python的importlib缓存。直接重新import_run.py拿到的还是旧模块,必须先用sys.modules.pop把旧模块踢掉。正确姿势:
module_name = f"skills.{skill_folder.name}.run" if module_name in sys.modules: del sys.modules[module_name] module = importlib.import_module(module_name)另外,热更新期间正在执行的旧技能进程可能还没跑完,我是用引用计数解决的:在executor执行前给技能定义加一把读锁,热更新时持有写锁,等正在跑的技能执行完再替换。如果你的项目并发不高,可以简单点,每次热更新前sleep两秒,图个省事。
版本冲突也踩过一次。同事给“摘要”技能升了级,但另一个技能里显式依赖了旧版摘要的行为,结果输出格式对不上。后来我在skill.yaml里加了api_version字段,并在调用依赖方时检查版本兼容范围,不兼容就直接抛异常,宁可报错也不能静默输出错误结果。
4.4 可观测性:日志与排错实战
技能化之后,一个请求可能会跨多个技能,排错难度上升。我的做法是围绕trace_id做全链路日志。每一个SkillResult生成时都带trace_id,主代理收到一个请求就生成一个根trace_id,然后所有技能调用都带上这个trace_id。
具体落地上,我在SkillContext里维护一个trace_id,技能内部所有日志都通过context.logger输出,这个logger在初始化时绑定了trace_id和技能名。于是日志长这样:
2025-04-05 10:22:31 INFO [trace=a92fd] [skill=web_search] 查询: agent skills 2025-04-05 10:22:31 INFO [trace=a92fd] [skill=web_search] 发起搜索请求 2025-04-05 10:22:32 INFO [trace=a92fd] [skill=summarize] 开始摘要排查问题时,我习惯用一条命令查全链路日志:
grep "trace=a92fd" agent.log除了日志,我还会为每个技能记录指标:调用次数、平均耗时、成功率、错误类型分布。这些数据对判断哪些技能需要优化非常关键。我见过一个“生成图表”技能平均耗时8秒,但其他技能都在1秒以内,一查是它内部调用了一个慢速模型,及时替换掉了。
4.5 上下文管理:防止技能输出污染主对话
最后一个高频问题:技能执行过程中产生的大量中间输出,会被模型当成上下文读进去,导致后续对话质量下降。比如一个技能内部做了三次搜索,返回了三十条结果,主模型全看一遍既费token又分心。
我的解决方案是分两级上下文。一级是“可见摘要”:技能向主对话回传的数据必须经过精简,通常是结果列表的前N项。另一级是“完整上下文”:存到本地或向量库里,模型需要细节时再通过检索技能获取。这个设计让我在保持主对话轻量的同时,又能随时深挖细节。
实操中,我在executor里对SkillResult.data做一次大小检查,超过约1500字符就强制走摘要压缩。这个阈值可以配置,但1500是我试过多个项目后的甜点值——模型不会遗漏关键信息,token也省很多。
5. 一些加分设计与扩展方向
5.1 技能的市场化:能力包与分享机制
当技能库积累到一定数量,你会发现“技能”本身可以成为一种可分发的资产。我后来在项目里加入了打包功能:一个技能目录能被打成zip包,内含skill.yaml、run.py、prompt.md、requirements.txt、README.md。别人拿到这个包,解压到skills目录,注册器自动识别,技能就能跑了。
为了确保可移植性,我规定了技能代码里不能出现绝对路径,所有外部资源都必须放在技能目录内部,或者通过外部服务获取。这一条避免了很多“在我机器上能跑”的问题。给他人分享技能时,我喜欢附上一张“技能卡”,用表格写明输入输出、依赖项、示例调用,接收方一眼就能判断是否适合自己。
5.2 技能的快速评测体系
技能开发和普通功能开发最大的不同是:技能的结果很难用布尔值衡量对错。我建了一个评测集,针对每个技能准备十来条典型输入,每条标注了“预期行为描述”。每次改动技能后,跑一遍评测集,人工核对输出是否合理。这个操作很土,但确实有效。
更高级一点的,可以用LLM当裁判。把技能输出给一个评测模型,让它按照既定标准打分。比如摘要技能,评测模型根据“是否正确压缩核心内容”“是否包含原文不存在的观点”等维度打分。自动化评测跑起来后,技能库的迭代速度才能提上去,否则每次改需求都要手动回归,迟早累垮。
5.3 从技能库到智能体编排平台
技能库一旦稳定,自然会长出编排需求:多个技能如何串成一条工作流?什么时候可以并行执行多个技能?这些需求已经超出技能本身,属于智能体编排平台的范畴。
我目前正在做的扩展是让agent能生成执行计划,计划由技能节点组成,支持并行和串行依赖。executor升级成plan_executor,先解析计划,再调度技能。技能之间通过一个简单的数据总线交换信息,每个技能声明自己“产出”和“消费”的数据类型,总线负责路由。
这条路走通之后,“agent-skills”就不再只是技能管理器,而是一个轻量级的智能体运行时。回过头来看,最初那个“让技能可复用”的小目标,最终撬动了整个Agent工程的架构演进。这也是我觉得这个方向值得持续投入的原因——低成本、强复用、可积累,正是工程化最需要的东西。