☰
Agent技能封装:从裸工具函数到可复用技能库的工程实践
2026/10/14 10:06:26 网站建设 项目流程

我最初接触 agent-skills 这个方向,是被一个实际需求逼的。当时我在搭建一整套 Agent 工作流,发现每次让大模型调用外部能力,都得重新写一遍提示词、函数定义、参数校验、结果解析……代码越来越臃肿,同一个功能换个场景就得复制粘贴一大段。后来我把这些能力整体“封装”成一个个带描述、带参数契约、带执行逻辑的独立技能模块,整套系统瞬间清爽了许多。这篇文章就围绕我在实践中总结的 agent-skills 设计思路、代码实现和排坑经验展开,适合正在做 Agent 应用、准备把一堆工具调用规范化,或者想给智能体做“能力资产化”的开发者参考。

1. 先搞清楚:agent-skills 到底解决什么问题

1.1 从一次失败的多工具调用说起

之前我做了一个类似“个人数据管家”的 Agent,需要它同时完成联网搜索、读取本地文件、调用外部分析脚本三件事。最开始我把每个能力都暴露成一个普通 function,然后在大模型的 system prompt 里用一大段文字描述这些函数的参数和用法。看起来没毛病,实际运行起来问题很多:

  • 大模型经常对“什么时候该调哪个函数”产生混淆,尤其是两个功能相近的函数,它选错概率很高。
  • 每次对话都要把全部函数定义塞进上下文,token 消耗巨大,响应变慢。
  • 函数内部改一个参数名,就要同步改提示词、改解析逻辑,牵一发动全身。
  • 没有统一的重试、错误返回、语义校验机制,Agent 一旦遇到异常就整个流程中断。

这些问题本质上不是“函数调用”这个机制不行,而是缺少一个语义层:大模型需要的是“做什么”和“为什么选这个”,而不是一堆底层接口签名。agent-skills 的思路,就是把底层函数重新包装成带“身份说明”的技能单元,让大模型像人在看工作手册一样去理解每个技能。

1.2 技能不是工具,是“带预设的完整能力”

很多开发者容易把技能理解成“一个函数”,这是第一个误区。工具(tool)是纯粹的接口,它只管输入输出;而技能(skill)至少要包含三样东西:

  • 意图说明:这个技能在什么场景下用、解决什么问题、不解决什么问题。
  • 参数契约:每个字段的类型、取值范围、默认值、必要与否,以及参数之间的依赖关系。
  • 执行逻辑:真正干活的代码,包括前置校验、调用外部资源、错误降级、结果格式化。

举个例子,“查天气”可以是一个工具,但“会议出行前查天气并给出穿衣建议”就是一个技能。技能里不仅调天气接口,还包含了天气数据的解读规则、穿衣建议的生成模板、以及“如果查询失败就走历史同期数据”的降级逻辑。这种封装,才是让 Agent 变得真正可用的关键。

我个人的判断标准很简单:如果一段能力描述超过 200 字才能讲清楚该干什么、怎么干,那它就值得封装成一个技能,而不是裸奔的工具函数。

2. 技能库的整体架构与设计思路

2.1 技能的四层结构

我在实际项目里把 agent-skills 拆成四个层次,每一层各管一件事。这个分层并不是拍脑袋定的,而是在做了几个版本之后,发现这样分离最方便维护和扩展。

层次名称职责类比
L1技能定义层描述技能用途、适用场景、参数约束工作手册的职位说明
L2技能注册层把技能挂载到 Agent 运行时,维护索引公司的岗位名录
L3技能选路层根据用户意图匹配最合适的技能HR 把简历分给对应岗位
L4技能执行层调用底层实现、处理异常、格式化结果正式办公干活

L1 最重要,也是最容易被忽略的。很多技能失效,问题不在执行代码,而是“描述写得不清不楚”,导致 L3 的匹配阶段就选错了技能。L2 负责管理技能的生命周期,包括动态加载、卸载、更新版本。L3 一般有两种实现方式:一是让大模型直接从技能列表里选,二是先用本地规则做一次粗筛再让大模型精排。L4 则是传统工程问题,需要考虑超时、重试、并发控制。

2.2 为什么技能描述比实现更重要

在 agent-skills 里,有一个反直觉的经验:你花在写技能描述上的时间,决定了技能可用性的 70%。大模型并不看你的代码逻辑,它只通过技能描述来判断“这个技能适不适合当前的用户请求”。如果描述含糊,再牛的执行代码也白搭。

我吃过一次亏。某次我给“导出报表”技能写的描述是“导出指定格式的报表”,结果当用户说“帮我把上个月的销售数据整理成图表发给我”时,模型一会儿选这个技能、一会儿选另一个画图技能,来回试错。后来我把描述改成:

导出报表: - 适用场景:用户明确要求生成文件,如“导出”“下载”“生成报告文件” - 输入:报表名称、时间范围、格式(仅支持 csv、xlsx、pdf) - 输出:文件下载链接或本地路径 - 不适用:用户只是要求查看数据,并未要求生成文件

改完后,选路准确率明显提升。关键变化在于我给了“触发信号”和“负向排除条件”。大模型选技能本质上是一个分类任务,给它越清晰的边界条件,分类越准确。负向排除条件尤其重要,它能防止模型在模糊请求上随意发挥。

2.3 技能粒度怎么定

这是所有 Agent 开发者都会纠结的问题:技能拆得太大,复用性差、耦合度高;拆得太小,技能数量爆炸,大模型选路时容易被大量相似描述搞晕。

我自己的经验是遵循“场景内聚”原则:一个技能对应一个完整的使用场景闭环,而不是对应一个底层能力。“发送邮件”是一个闭环,拆成“连接邮件服务器”“构造邮件内容”“执行发送”三个技能就是过度设计。反过来,“处理所有文档类操作”就是一个大杂烩技能,因为它包含了阅读、编辑、格式转换多个不相干的场景闭环。

判断拆得合不合理,有一个简单可操作的方法:看一条用户请求(如“帮我把这份 Excel 转成 PDF 发到群里”),是否会触发多个技能才能完成。如果经常需要 2 个以上技能配合才能完成一个原子请求,说明技能粒度偏小;如果一个技能里有超过 5 个完全迥异的执行分支,说明偏大。

3. 实操:搭一个可用的技能注册与调用系统

3.1 目录结构设计

我目前的技能库目录结构是这样的,每个技能独立一个目录,互不干扰:

skill_library/ ├── manifests/ │ ├── weather_report.yaml │ ├── sales_export.yaml │ └── document_summary.yaml ├── handlers/ │ ├── weather_report.py │ ├── sales_export.py │ └── document_summary.py └── registry.py

manifests目录存技能的元数据,handlers存执行逻辑,registry.py负责把两者组合起来。之所以把描述和实现分开,是因为我有一次想对比不同描述对选路准确率的影响,如果写在一起就得大改代码,分开之后只需要替换 YAML 文件即可。实践下来,这种分离让测试成本低很多。

3.2 定义技能描述与参数 Schema

技能描述我用 YAML 格式维护,相比 JSON 更容易写注释和阅读。一个标准的技能清单长这样:

name: sales_export description: | 导出销售数据为本地文件。 适用场景:用户要求“导出”“下载”“生成报表文件”等操作时触发。 时间范围参数支持相对时间(如近7天、上月)和绝对时间(2024-01-01~2024-01-31)。 不适用场景:用户仅询问数据内容,未要求生成文件。 version: 2.1.0 parameters: - name: date_range type: string required: true description: 数据时间范围,支持相对或绝对方式 - name: format type: string required: false default: csv enum: [csv, xlsx, pdf] - name: columns type: array required: false description: 需要导出的字段列表,缺省表示全部 timeout_seconds: 30 retry_policy: max_retries: 2 backoff_seconds: 1

参数 Schema 部分,我强烈建议把enum和default写完整。大模型填充参数时会参考这些约束,缺省值和枚举值不给全,它就容易生成超出预期的值。比如format只写 string 类型而不给枚举,它可能给你输出txt,然后你的执行代码又得写一堆兼容逻辑。

另外,参数的描述不要只写“含义”,要写“怎么写”。比如date_range的描述我特意写了“支持相对时间(如近7天)和绝对时间”,这能有效减少解析出错。我在另一套系统里试过,参数描述里带范式的,误解析率下降超过一半。

3.3 技能注册与动态索引

接下来是注册中心。它的职责是扫描所有 manifest,构建一份技能索引,然后按需加载对应 handler。我用 Python 实现,核心逻辑大致如下:

import importlib import yaml from pathlib import Path class SkillRegistry: def __init__(self, manifests_dir: str, handlers_dir: str): self.manifests_dir = Path(manifests_dir) self.handlers_dir = Path(handlers_dir) self._skills = {} self._index = {} def load_all(self): for manifest_file in self.manifests_dir.glob("*.yaml"): with open(manifest_file, "r", encoding="utf-8") as fp: manifest = yaml.safe_load(fp) handler_path = self.handlers_dir / f"{manifest['name']}.py" if not handler_path.exists(): raise FileNotFoundError(f"handler not found: {handler_path}") self._skills[manifest["name"]] = { "manifest": manifest, "handler": None, # 按需加载 } self._rebuild_index(manifest) def _rebuild_index(self, manifest): # 把技能名称和核心描述建个倒排,用于本地粗筛 tokens = (manifest["name"].replace("_", " ") + " " + manifest["description"]).split() for t in tokens: self._index.setdefault(t.lower(), set()).add(manifest["name"]) def get_manifest(self, skill_name): return self._skills[skill_name]["manifest"] def get_handler(self, skill_name): if self._skills[skill_name]["handler"] is None: module = importlib.import_module( f"handlers.{skill_name}" if False else f"handlers.{skill_name}" ) self._skills[skill_name]["handler"] = module return self._skills[skill_name]["handler"]

这里的小细节是 lazy loading:handler 模块在第一次被调用时才 import,避免 Agent 启动时加载几十个模块拖慢速度。实际测试中,技能数量达到 50 个以上时,这种延迟加载能省下大约 1-2 秒的冷启动时间。

注册中心还有一个隐藏功能,就是版本管理。manifest 里有version字段,当你在线上迭代技能逻辑时,可以让旧版本的技能仍被旧会话使用,新会话用新版本。这个好处我后面在排查问题时细说。

3.4 意图匹配与技能选路

选路是整套系统的灵魂。我采用“本地粗筛 + 大模型精排”的两级方案。为什么不直接用大模型从全部技能里选?因为当技能库里有几十个技能时,一次性把全部描述塞给大模型,token 开销大、噪声多、响应慢。本地粗筛先把候选技能从几十个压缩到四五个,再把这几个候选的完整描述交给大模型挑,效果和速度都会好很多。

from difflib import SequenceMatcher def coarse_filter(query: str, index: dict, skills: dict, top_k: int = 5): scores = {} query_terms = query.lower().replace(",", " ").replace("。", " ").split() for term in query_terms: for idx_key, skill_names in index.items(): if term in idx_key or idx_key in term: for name in skill_names: scores[name] = scores.get(name, 0) + 1 # 对没有命中的技能,做名称相似度兜底 for name in skills: ratio = SequenceMatcher( None, query, name.replace("_", " ") ).ratio() if ratio > 0.6: scores[name] = scores.get(name, 0) + ratio * 2 ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [name for name, _ in ranked[:top_k]]

这段代码的兜底逻辑很关键。如果用户说“导出销售数据”,但技能名称是sales_export,倒排索引可能因为分词不一致而匹配不上,名称相似度兜底就能救回来。实际效果中,粗筛环节的召回率能稳定保持在 90% 以上,剩下的精度靠大模型精排解决。

精排阶段,我把候选技能的完整描述(包括参数说明和负向排除条件)交给大模型:

def rerank_by_llm(query, candidates): descriptions = [ f"技能名:{cand}\n描述:{registry.get_manifest(cand)['description']}" for cand in candidates ] prompt = ( "根据用户请求,从以下技能中选择最合适的一个。\n" "只输出技能名,不要输出任何解释。\n\n" "用户请求:" + query + "\n\n" "候选技能:\n" + "\n\n".join(descriptions) ) response = llm_call(prompt) # 你的LLM调用封装 return response.strip()

这段 prompt 只要求输出技能名,不输出解释,是为了减少大模型“啰嗦”的冗余输出,精简 token。实测中这一步的准确率可以达到 95% 以上,剩余的 5% 错误多数来自用户请求本身有歧义。

3.5 执行引擎与结果回传

选完技能后,执行引擎要做四件事:参数提取、参数校验、调用 handler、格式化返回。参数提取通常也交给大模型,从用户请求里抽取结构化字段。

def parse_params(manifest, query): param_descs = "\n".join( f"- {p['name']}: {p['description']}, " f"类型={p['type']}, 必填={p['required']}, " f"枚举={p.get('enum', '无')}, 默认={p.get('default', '无')}" for p in manifest["parameters"] ) prompt = ( f"从用户的请求中提取以下参数。\n{param_descs}\n\n" f"用户请求:{query}\n\n" "请以JSON格式输出,只输出JSON。" ) raw = llm_call(prompt) params = json.loads(raw) return params

拿到参数后,执行引擎先做一次传统校验,不要盲目信任大模型的填充结果。我发现大模型经常犯低级错误,比如枚举字段填了一个不在列表里的值、数字字段填了字符串。校验逻辑不复杂,但必须有:

def validate_params(manifest, params): for p in manifest["parameters"]: if p["required"] and p["name"] not in params: raise ValueError(f"缺少参数: {p['name']}") if p["name"] in params: if p["type"] == "string" and not isinstance(params[p["name"]], str): params[p["name"]] = str(params[p["name"]]) if p["type"] == "array": if isinstance(params[p["name"]], str): params[p["name"]] = [params[p["name"]]] if "enum" in p and params[p["name"]] not in p["enum"]: # 有默认值就用默认值,没默认值就报错 if "default" in p: params[p["name"]] = p["default"] else: raise ValueError( f"参数 {p['name']} 值 {params[p['name']]} 不在枚举范围内" ) return params

执行结束后的结果回传,也需要设计统一格式,方便 Agent 总结成自然语言回答。我常用的返回结构是:

{ "success": true, "data": { "file_url": "..." }, "meta": { "execution_ms": 420, "rows": 120 } }

统一返回格式的价值在于,Agent 不需要关心每个技能的内部返回差异,按照success字段判断成败即可。

4. 常见问题、性能优化与避坑指南

4.1 技能冲突:两个技能看起来“都像”

最常见的问题是技能描述写了太多的泛化词。我遇到过数据备份和数据恢复两个技能,描述都写“处理用户数据”,结果模型经常混淆。解决办法不是把描述改得花哨,而是在每个技能描述中显式写明“不适用场景”。比如数据备份明确写“不适用于数据丢失后的恢复操作”,数据恢复明确写“不适用于常规周期备份”。大模型在这种确定性语义面前,选路错误率会降到很低的水平。

另一个技巧是给相似技能加“互斥标签”。在 manifest 中加一个conflicts_with字段,选路时如果候选技能间有互斥关系,可以提前人工消解。

4.2 技能膨胀与上下文失控

技能数量一多,即使用了粗筛加精排,技能描述本身可能还会累积出大量重复的公共说明。比如每个技能都写“如果用户没有明确时间范围,默认最近一个月”,这些重复句占 token 不说,还可能互相干扰。

我把所有技能共用的规则抽到一段“全局技能使用公约”里,放在 system prompt 中,技能描述里只保留各自差异点。这种做法让技能描述平均长度从 320 字下降到 180 字左右,而选路准确率反而上升,因为模型不再被冗余信息干扰。

4.3 参数提取的“幻觉值”

大模型从用户请求里提取参数,最让人头疼的是它会在模棱两可时自己编一个值出来。比如用户说“帮我导一下这个月的报表”,模型可能把date_range填成具体的2024-04-01~2024-04-30,但实际用户想的是“最近一个月”而非“自然月”。解决思路不是禁止大模型自由发挥,而是要求它为每个参数附上置信度:

{ "params": {"date_range": "2024-04-01~2024-04-30"}, "confidence": 0.62, "source": "inferred" }

当置信度低于某个阈值(我自己设为 0.8)时,执行引擎不直接执行,而是先向用户确认:“你指的是自然月的 4 月 1 日到 4 月 30 日,还是最近 30 天?”这个确认机制虽然多一次交互,但避免了很多不可逆的错误操作,比如导出错了文件、发错了数据。

4.4 实测中的性能数据与优化建议

我在一套约 40 个技能的 Agent 系统上做过一次对比测试。基线是“全量技能描述直塞大模型”,优化后是“粗筛 + 精排 + 低置信度确认”。结果如下:

指标全量直塞两级选路
平均选路耗时2.8 秒0.9 秒
选路准确率87.6%95.3%
平均每次请求 token 消耗6.2k3.1k
参数解析错误率12.4%6.8%

token 消耗几乎减半,这一项在实际成本控制上非常可观。还有一个数据值得注意:启用“低置信度确认”后,虽然交互次数增加了约 15%(原来一次能完成的任务现在要问一句),但任务最终失败率从 8.2% 降到了 1.7%。也就是说,多一次确认换来的稳定性收益,远超交互增加的成本。

我后续优化主要会往两个方向走:一是给每个技能增加自动评测用例,每次修改 manifest 后可以批量回归选路准确率;二是离线分析历史失败案例,用做技能描述的迭代参考。这个内容还可以进一步扩展,比如把技能执行日志做成可视化看板,或者为每个技能配置独立的内存空间以支持跨会话的记忆能力。落在实际场景里,agent-skills 的价值就是让大模型应用从“能跑”变成“跑得稳、改得动、测得准”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询