agent-skills:为智能体构建标准化的技能工程框架

发布时间:2026/10/13 18:43:29
agent-skills:为智能体构建标准化的技能工程框架
做 Agent 开发的人应该都有同感大模型本身已经够聪明了但让它真正干活又是另一回事。模型会聊天、会写诗可你要它把一个数据分析任务完整跑下来它可能开着好几个不存在的工具自说自话地给你一个“看起来很对”的结果。agent-skills 这个项目就是为解决这个问题而生的。它算是一套面向智能体的技能工程框架核心思路是把大模型能执行的每一个原子能力封装成独立的“技能单元”让模型不再靠提示词里的即兴发挥而是通过统一的注册、召回、路由和执行机制稳定地调用真实能力。说白了就是给 Agent 建立一套“岗位说明书和标准作业流程”让它从“会说话”变成“会办事”。这篇文章适合正在做大模型应用落地、尤其是卡在“模型总是不按套路执行”这个阶段的人也适合想搞清楚 Agent 工程化到底怎么设计的初学者。下面我会把整套架构的拆解思路、文件设计、路由机制、落地代码和实际踩坑逐层讲清楚。1. 项目背景与整体思路为什么需要 agent-skills1.1 技能库要解决的真实痛点在我接触过的各类智能体项目里最让人头疼的其实不是模型回答质量而是执行过程的不可控。你让模型批量处理一批订单表格它可能这次记得先清洗空值下次就直接把空值行删了你让它调用某个数据分析接口它可能在连续几次交互后开始“编造”接口返回内容。这些问题并不是模型变笨了而是我们没有给它一套足够收敛的执行框架。agent-skills 试图解决的正是这几类问题。第一把任务步骤结构化成技能避免模型自由发挥第二把可复用的业务能力沉淀下来避免每个新项目都重复堆提示词第三让执行过程有统一的输入输出协议方便上层系统做日志追踪和结果校验第四通过对技能的召回和路由降低模型在复杂流程中的决策负担。可以说它把软件工程里的模块化、接口化思想搬到了智能体领域。另一个很实际的痛点在于上下文窗口的限制。如果你把十几个工具的调用说明、参数格式、示例全部塞进系统提示词里模型很快就会被冗长内容干扰甚至出现指令冲突。技能库的思路是让模型只需要感知“有哪些技能可用、大概在什么场景用”真正详细的参数说明和调用方式隐藏在技能内部这样既保护了上下文资源也让技能本身的维护变得更加独立。1.2 技能和工具调用、插件不是一回事很多人会把技能等同于“工具调用”但这个说法其实不够准确。工具调用一般指的是模型原生支持的 function calling它建立在模型对单个函数的识别上适合处理“查天气”“发邮件”这种单步操作。而技能强调的是编排能力和内部状态一个技能内部可能包含多次工具调用、数据校验、失败重试和结果聚合它是比单函数高一个层级的抽象。插件体系则更强调热插拔通常是一些已经打包好的第三方能力比如文档解析、图像处理。agent-skills 里的技能虽然看起来类似但侧重点在于面向任务的可编排单元。我习惯用一个比喻来解释工具调用相当于给员工发了一张“工具清单”他可以随手拿起钳子或扳手插件体系相当于给他一套常备工具箱随取随用而技能库则是“岗位说明书”写清楚这个岗位在什么情况下要做什么事、第一步做什么、遇到异常怎么办。有了岗位说明书Agent 就不再是一个什么都会一点的杂役而是一个有明确职责的专业人员。从工程角度看这种抽象也会带来很直接的好处技能可以被单独测试、单独升级甚至可以在不同的 Agent 之间共享。回到项目本身agent-skills 的命名已经从视角上提醒我们重点不是“模型能调用什么函数”而是“模型被赋予了哪些完整的能力”这是一个从功能导向到能力导向的转变。2. 整体架构设计一个可用的技能文件应该长什么样2.1 把每个技能做成独立目录我在设计技能库时最先确定的原则是一个技能一个目录目录名就是技能的短标识里面放配置、实现代码、依赖声明和说明文档。以最常见的技能为例可以按这样的结构组织skills/ repo_analyzer/ skill.yaml run.py requirements.txt README.md order_cleaner/ skill.yaml run.py requirements.txt README.md这种结构的好处很明显每个技能的所有相关文件都待在自己的“家”里不会互相干扰。你可以在不改动其他技能的情况下对一个技能做升级、回滚或者直接禁用。对小团队来说这种隔离也方便不同人负责不同技能不用在一个巨大的代码库里反复解决冲突。如果你做的是比较大型的系统建议再把公共工具类提取为独立的 shared 包而不是让每个技能都复制一份解析、日志、网络请求的代码。技能内部可以依赖这个公共包但技能之间不要互相导入否则就破坏了隔离性。我见过有人为了让两个技能复用一段代码直接在技能 A 里引用技能 B 的文件结果技能 B 升级之后技能 A 莫名其妙挂了这种隐式依赖是工程上最需要避开的。2.2 技能配置让模型“看得懂”的元信息技能目录里最重要的文件就是 skill.yaml它不仅是给系统看的更是给模型看的。因为无论是召回还是路由核心依据都来自这份配置里的描述和参数定义。一个合格的 skill.yaml 至少要包含以下字段name技能唯一标识全局不能重复。description详细描述技能的使用场景、能做什么、什么时候不要用。这一项直接决定召回效果宁可写长一点也别写太抽象。version技能版本号方便后续做版本管理。entry入口函数的位置格式类似 run.execute。timeout技能执行超时时间防止某个技能卡死拖垮整个流程。parameters参数列表数组每个参数包含名称、类型、是否必填、默认值和描述。我见过很多失败的技能配置问题几乎都出在 description 写得太泛比如“分析数据”或“处理表格”这种描述让召回模型根本无法判断应该在什么场景调用。更合适的写法是明确场景和边界比如“当用户要求统计 Excel 销售额、过滤重复行、生成透视表时使用如果只是问一个数字的含义不要使用本技能。”这样召回准确率会显著提升。参数定义也不只是做做样子。系统可以基于这些定义生成 JSON Schema用来校验模型输出的参数值如果参数类型不对可以直接在路由阶段纠正而不是等到技能执行到一半才报错。参数描述同样要写清楚含义因为大模型填入参数值时主要依赖对字段名的理解一个模糊的“info”字段很容易让模型把用户原话塞进去。2.3 技能实现模板一份能直接改的代码示例技能的实现代码需要遵循约定的函数签名这样执行器才能用统一的方式去加载和调用。下面是一份我常用的模板以代码仓库分析技能为例import json import subprocess from datetime import datetime, timedelta def execute(params, context): repo_url params[repo_url] days params.get(days, 30) workdir context.get(workdir, /tmp) target_dir os.path.join(workdir, repo_checkout) # 1. 克隆或拉取仓库 subprocess.run([git, clone, --depth, 1, repo_url, target_dir], checkTrue) # 2. 统计最近 days 天的提交数量 since (datetime.now() - timedelta(daysdays)).strftime(%Y-%m-%d) proc subprocess.run( [git, -C, target_dir, log, --since, since, --oneline], capture_outputTrue, textTrue, ) commit_lines [line for line in proc.stdout.strip().split(\n) if line] return { status: success, result: { repo: repo_url, commit_count: len(commit_lines), since_days: days, }, message: 仓库分析完成, }这段实现并不复杂但它体现了一个核心原则技能内部可以自由发挥但对外必须遵守统一的输出协议。无论内部是调 API、跑爬虫还是执行本地命令最终都要返回 status、result、message 三个字段这样上层系统才能用完全一致的方式解析和执行后续逻辑。参数注入方面params 是调用方传入的已校验参数context 是运行时上下文里面会带工作目录、会话 ID、密钥池等环境信息。我特别强调不要把密钥或全局配置直接写死在技能代码里因为技能会被多个项目复用一旦写死迁移时就得逐个翻代码改配置。2.4 登记注册手动注册表还是自动扫描技能文件准备好了系统怎么知道有哪些技能呢通常有两种做法手动注册表和目录自动扫描。手动注册表是这样的思路在项目代码里维护一个列表把技能名称和入口显式写进去。它的优点是可控性强、启动速度快缺点是每新增一个技能都得改动主程序对追求自动化的团队来说比较繁琐。我推荐的做法是启动时的目录自动扫描。系统启动后遍历 skills 目录逐个读取 skill.yaml校验必填字段、检查入口模块是否存在然后把合法的技能装入注册表。这样做的好处是新增技能只需要把目录放进去不需要改任何代码。为了避免配置出错扫描过程中要记录详细的错误日志不能因为一个坏技能导致整个 Agent 启动失败而是应该跳过并报警把问题留到开发阶段处理。3. 技能召回与路由到底该让谁来决定调用什么3.1 技能召回别指望模型从几百个技能里自己挑如果技能数量只有三五个也许你可以直接把它们全部塞进系统提示词让模型自行决策。但技能一旦超过二十个这种方案就会迅速失效模型在选择时会出现严重的注意力分散甚至开始幻觉出并不存在的技能名称。agent-skills 采用的是两阶段策略先召回后排布。召回阶段通常使用语义相似度计算。系统把用户的当前请求转换成向量与所有技能的 description 向量做比对取相似度最高的一批候选技能。这里要注意embedding 模型的选择很重要通用向量模型在技能描述和用户口语表达之间做匹配效果尚可但如果你面向的是金融、医疗等专业领域最好用领域微调过的向量模型否则“查询本季度营收”和“汇总销售额”这种同义表达可能匹配不上。我的建议是召回阈值不要卡得太低否则大量无关技能进入候选列表会干扰模型决策。可以先按相似度排序借助一个较高的阈值比如 0.72过滤掉明显无关项然后保留前二三名作为候选集。如果阈值设得太严可能偶尔漏掉正确技能这时候就需要下一条规则兜底。3.2 语义召回搭配关键词规则来兜底单纯依赖语义召回有一个风险用户请求中存在非常明确的关键词而向量模型没有抓住重点。比如用户说“帮我跑一下仓库分析”语义上“仓库分析”可能已经接近技能描述但如果用户只说“查一下 commit 数量”embedding 可能匹配到其他代码类技能。我通常的做法是在召回层并行维护一份关键词索引。每个技能在 skill.yaml 里可以额外声明 aliases也就是触发词列表例如 repo_analyzer 的 aliases 可以是 [“commit”, “仓库分析”, “提交记录”, “git log”]。当用户输入命中任一关键词时该技能直接进入候选列表不参与相似度竞争。关键词规则的优先级要设定得比纯语义召回更高。因为它一旦命中说明意图非常明确不应该因为向量相似度不够高而被过滤掉。规则和语义互为补充最终合流得到候选集。这个设计让召回环节从“猜”变成“算”稳定性会提升很多。3.3 从候选集到最终决策模型只需要做二选一拿到候选集之后再让模型做最终决策就轻松多了。候选技能数量少每个技能的描述也足够详细模型只需要判断“哪个更贴合用户当前请求”同时把必要的参数从对话中抽取出来。这里有一个非常关键的设计不要直接让模型输出一个技能名称了事而是要让它输出一个结构化的调用请求包含技能名和参数对。比如{ skill: repo_analyzer, parameters: { repo_url: https://example.com/repo.git, days: 30 } }拿到这个请求之后系统会先做参数校验再执行技能。这一步的意义在于模型负责“决策”系统负责“执行”两者通过协议隔离模型即使是决策错了系统也可以根据校验结果及时拦截而不会把错误扩散到业务层。4. 核心实现一个通用技能管理系统的落地写法4.1 技能管理器启动时全量扫描并加载注册表技能管理器是整套框架的起点核心职责是扫描技能目录、读取配置、检查依赖并对外提供查询能力。下面是一段精简版实现from pathlib import Path import importlib.util import yaml class SkillRegistry: def __init__(self): self._skills {} self._errors [] def scan(self, skills_dir: str): skills_path Path(skills_dir) for yaml_file in skills_path.glob(*/skill.yaml): try: skill self._load_skill_yaml(yaml_file) self._validate_skill(skill) self._skills[skill[name]] skill except Exception as exc: self._errors.append(f{yaml_file}: {exc}) return self def _load_skill_yaml(self, path: Path): with open(path, r, encodingutf-8) as fp: return yaml.safe_load(fp) def _validate_skill(self, skill: dict): required [name, description, entry, parameters] for field in required: if field not in skill: raise ValueError(f缺少必填字段: {field}) def get_skill(self, name: str): return self._skills.get(name) def all_skills(self): return list(self._skills.values())这段代码只做了最基本的校验在实际项目里建议再加上入口函数是否存在的检查和依赖包的检查。如果你的技能目录里有一个技能依赖了额外第三方包系统启动时最好能及时提示而不是等到运行时才知道缺库。4.2 路由决策把技能召回和执行分开路由模块的职责是接收用户意图输出结构化的调用请求。它上面依赖召回模块下面把请求交给执行器。实际项目里召回和路由可以通过一个 skill_router 对象组合在一起class SkillRouter: def __init__(self, registry: SkillRegistry, embedding_fn): self.registry registry self.embedding_fn embedding_fn self.keyword_index self._build_keyword_index() def _build_keyword_index(self): index {} for skill in self.registry.all_skills(): for kw in skill.get(aliases, []): index.setdefault(kw, []).append(skill[name]) return index def recall_candidates(self, user_message: str, top_k: int 3): candidates [] # 1. 关键词规则命中 for kw, skill_names in self.keyword_index.items(): if kw in user_message: for name in skill_names: candidates.append(name) # 2. 语义召回 user_vec self.embedding_fn(user_message) scored [] for skill in self.registry.all_skills(): skill_vec self.embedding_fn(skill[description]) score cosine_similarity(user_vec, skill_vec) scored.append((skill[name], score)) scored.sort(keylambda item: item[1], reverseTrue) for name, score in scored[:top_k]: if score 0.72: candidates.append(name) # 3. 去重保持规则命中优先 seen set() unique_candidates [] for name in candidates: if name not in seen: seen.add(name) unique_candidates.append(name) return unique_candidates def route(self, user_message: str, model_fn): candidates self.recall_candidates(user_message) if not candidates: return None prompt build_route_prompt(user_message, candidates) decision model_fn(prompt) return parse_decision(decision)这种设计把“哪些技能可以考虑”和“最终选哪个”彻底分开。即使最终决策模型没有返回合法结果系统也可以给出一个默认的“无法调用”反馈而不是抛异常。4.3 执行器与反馈闭环技能跑完不算完得能接得住执行器是真正调用技能介入代码的模块。它要处理几件事动态加载入口函数、构造 context 上下文、把参数传递给 execute、捕获异常、记录日志、返回结构化结果。同时它还应该负责将执行结果回传给模型让模型能基于结果继续规划下一步。import importlib import logging import traceback logger logging.getLogger(__name__) def run_skill(skill: dict, params: dict, context: dict): module_path, func_name skill[entry].split(:) try: module importlib.import_module(module_path) func getattr(module, func_name) raw_result func(params, context) return normalize_output(raw_result) except Exception as exc: logger.error(技能 %s 执行异常: %s\n%s, skill[name], exc, traceback.format_exc()) return { status: error, result: None, message: f技能执行失败: {exc}, } def normalize_output(raw_result): if isinstance(raw_result, dict) and status in raw_result: return raw_result return { status: success, result: raw_result, message: 执行成功, }把日志和异常处理放在执行器这一层技能内部就不需要反复写 try-except可以保持自身逻辑的干净。如果技能执行成功但返回结果过大执行器还应该做截断避免后续模型把超长结果全部吞进上下文导致注意力分散或者超过上下文长度限制。4.4 给技能做体检用最小测试用例保证可用性每新增一个技能我都会写一组最小测试用例直接调用执行器跑一遍。这样做的成本很低但价值非常高。它至少能验证技能配置能被系统正确加载、参数能穿透到代码内部、输出结果符合协议、异常路径返回正常。def test_repo_analyzer(): skill load_skill(skills/repo_analyzer) result run_skill( skill, params{repo_url: https://example.com/repo.git, days: 7}, context{workdir: /tmp}, ) assert result[status] success assert isinstance(result[result][commit_count], int)用 pytest 把每一个技能都纳入自动化测试之后后面无论谁改了公共包、换了向量模型还是升级了依赖只要技能行为发生任何变化测试都会立刻发出警告。这是技能库能够持续演进的重要安全网。5. 踩坑记录与排查技巧这些问题基本都是共性的5.1 技能描述写不好召回率和决策准确率一起崩这是我反复强调的重灾区。某次我负责的一个技能团队把 description 写成“创建一个可视化面板”结果用户说“我想看下最近几个月的销量走势”系统并没有召回它。原因很简单“销量走势”和“可视化面板”之间虽然语义相关但向量相似度并不够高。后来我们把这段描述特意补充了触发场景和边界“当用户希望查看销量、订单量、访问量等数据的趋势图、柱状图、折线图时使用。如果用户只是询问数据数值不涉及图表展示则不要使用本技能。”改完之后召回命中率直接拉升。我的经验是写完 description 之后假设自己是一个完全不懂代码的用户用五种不同的口语方式表达同一个需求再检查描述是否覆盖了这五种表达。5.2 参数类型和缺失问题模型填错参数是常态模型抽取参数时经常犯类型错误例如把整数字段填成字符串“30”或者在用户没有提供某些信息时按默认值或者瞎编一个值填进去。因此参数校验一定要做在路由阶段不能直接把模型输出传给技能代码。在实践中我强烈建议参数定义里给每个字段配上足够清晰的描述。例如一个日期字段如果描述里只写“日期”模型可能填“上周三”这种自然语言写到“日期格式 YYYY-MM-DD例如 2025-01-01”情况就会好得多。校验层遇到缺失参数时不要直接报错而是先尝试从对话历史中补齐补不齐再回到模型侧请求用户澄清。这种闭环在面对真实 User 输入时非常关键。5.3 技能输出过大导致模型上下文爆炸有一个常见的隐性坑技能执行成功并返回了完整结果却因为结果太长把后续对话冲垮了。比如一个查询技能返回了几百行原始数据表格系统把这些全部塞给模型模型后续可能根本抓不住重点甚至干脆在思考中迷失方向。我给执行器加了一条约定任何技能的 result 字段默认保留摘要信息详细数据放在 result_detail 里并允许被截断。如果上层确实需要完整数据可以再通过后续调用去取而不是每次技能结束都一股脑全推给模型。这条约定看似简单但在长会话场景里对稳定性的提升非常明显。5.4 技能写缓存不设锁并发直接乱套当多个 Agent 实例共用一个技能库时技能内部如果直接写本地缓存文件就会出现互相覆盖和资源竞争问题。某个下载类技能把拉取到的项目文件写到生成的工作目录里两个会话同时触发就会各自写不同数据然后互相覆盖。我的建议是技能内部不直接操作本地共享路径而是把需要跨会话共享的数据统一放到由技能管理器负责的存储区通过带业务 ID 的子目录隔离。缓存全都要加上时效策略宁可每次多请求几次也不能让用户拿到一个过时的结果。此外技能内部凡是涉及远程接口调用都应该设置超时和重试上限避免某个服务响应变慢时整个 Agent 卡死。6. 这个项目后续还能怎么扩展一点个人体会技能库最让我喜欢的地方在于它一旦跑通后续的扩展方向非常清晰。比如你可以在此基础上增加技能版本管理让系统可以同时保留多个版本的技能实现在召回时优先选择信任度更高的版本。也可以构建一个技能评估集给每个技能配几条真实用户问法作为回归测试基准每次技能更新或模型升级后自动跑一遍全量评估观察召回率、决策准确率和执行成功率是否有变化。我们团队的实际体验是不要一开始就追求技能数量的大而全先沉淀三到五个自己业务里最高频、最核心的技能跑通整个过程把配置、路由、测试、监控这些机制打磨顺了再逐步扩充。技能库是个典型的“复利结构”基础框架稳定之后每新增一个技能就是在为后续所有 Agent 应用复用做积累。我可以这么说项目做到后面最值钱的往往不是大模型有多聪明而是那套能让模型稳定干活的能力资产。先把一个技能写到能稳定复现比堆一百个半成品技能有价值得多。