☰
Agent技能库设计:从工具函数到可插拔技能组件
2026/10/7 4:21:17 网站建设 项目流程

最近一直在折腾Agent项目,说实话最大的体会是:模型本身的推理能力已经不太让人头疼了,真正卡脖子的反而是“工具层”。每次接到需求都要重新写工具函数、重新调Prompt、重新对接一遍模型,代码重复得让人想骂人。后来我把这些零散实践整理成了一个叫agent-skills的技能库项目——把“技能”做成了独立的、可插拔的组件,让Agent按需加载、按描述匹配、按约定执行。这篇文章会从整体设计思路聊到具体落地细节,再把我在实际使用里踩过的坑一并写出来,希望能给同样在做Agent工程化的朋友一点参考。

先说这东西适合谁看:如果你正在做一个需要多轮调用工具的Agent,如果你觉得每次给大模型配函数都像在打地鼠,如果你想把“会干活的能力”沉淀成一套可复用的资产——那这篇就是给你的。

1. agent-skills 是什么:一个给大模型“配工具”的项目

1.1 这类型项目的痛点在哪

现在的大模型本身已经很强了,可一旦要落在真实业务里,就绕不开“它得会调用外部能力”这件事。比如用户说“帮我把这个文件夹里的Excel整理成摘要”,你光给模型一个read_file函数是不够的——它即使读到了文件,也不知道接下来该怎么组织、怎么输出。这就是我反复遇到的核心矛盾:模型不缺推理能力,缺的是“把任务拆成步骤,再把步骤固化成可执行技能”的中间层。

早期我做Agent,最常见的做法是写一堆functions,塞进模型API的tools参数里。这在单一场景下还行,但一旦技能多了,问题就全出来了:函数描述写得不好模型就乱调、参数结构稍微复杂一点模型就传错、技能之间互相重叠导致路由不稳定、每次新增技能都要改一遍调用主流程……说白了,就是把所有逻辑都绑死在编排代码里了。

那换个思路呢?既然模型是靠“描述文本”来理解工具的,那我们干脆把“技能”本身做成一个可描述、可检索、可独立执行的单元。agent-skills 的核心就是把技能从代码中解耦出来,变成一个个带元信息的“技能包”。一个技能包里不只有执行函数,还带着它的使用说明、输入输出约定、执行步骤、错误处理策略。这样模型看到的是一份清晰的说明书,编排层看到的是一个统一的接口,业务方看到的是一套可以不断沉淀的能力资产。

1.2 核心设计:把技能做成一等公民

我在设计 agent-skills 时,第一个原则是技能不是函数,技能是面向任务的能力单元。函数是原子操作,比如“读取文件”“发送请求”;技能是完成一个目标的完整方案,比如“分析Excel并生成摘要”“从网页采集数据并结构化保存”。一个技能内部可以包含多个函数调用,甚至可以编排其他技能。

第二个原则是技能必须自描述。一个技能要能被模型正确使用,至少得包含三部分信息:什么时候用它、输入什么、干了什么。所以每个技能包都用 YAML 或 JSON 作为元信息载体,里面写明触发条件、参数 schema、输出格式、依赖关系。为什么用结构化文件而不是写死在代码里?因为要兼顾机器可读和模型可读——模型加载技能时读到的是干净的描述文本,执行引擎加载运行时读到的是结构化的契约定义,两条路径互不干扰。

第三个原则是统一执行接口。不管技能内部是调API、跑Python脚本、执行Shell命令还是调用子Agent,对外都暴露同一个入口:输入JSON → 执行 → 输出JSON。这一步很重要,它让上层编排逻辑完全与具体技能实现解耦,也为后续做技能路由、技能测试、技能监控留好了空间。

我实际做下来,这套设计最大的红利是:新增一个能力时,流程从“改主程序代码重新部署”变成“往技能库目录扔一个技能包”。模型侧通过描述自动发现新技能,编排侧通过统一接口自动调起它。整个系统从“一堆散落的函数”变成了“一套可生长的能力库”。

2. 技能库的核心细节与设计思路

2.1 技能包内部长什么样

一个技能包在文件系统里大概长这样:

skills/ └── data_analyzer/ ├── skill.yaml # 技能元信息:描述、参数、输出 ├── plan.md # 执行步骤说明(SOP),模型可读 ├── executor.py # 实际执行代码 └── tests/ └── test_basic.py # 技能自测脚本

skill.yaml是整个技能包的门面,模型主要通过它来判断“这个技能适不适合当前任务”。我通常这么写:

name: data_analyzer version: 1.2.0 description: > 用于分析表格类型数据文件(Excel/CSV),能够读取文件内容、 计算关键字段的汇总统计,并生成结构化摘要。当用户要求 "分析表格""整理数据""统计汇总""生成数据摘要"时优先选择。 trigger_keywords: [excel, csv, 表格, 分析, 统计, 摘要] input_schema: type: object properties: file_path: type: string description: 待分析文件的绝对路径 analysis_type: type: string enum: [summary, statistics, compare] default: summary required: [file_path] output_schema: type: object properties: summary: type: string metrics: type: object error_handling: file_not_found: "提示用户检查路径并退出" parse_error: "尝试备用引擎重新解析"

这里有个细节值得单独说:输入输出 schema 一定要用 JSON Schema 格式。因为大模型在生成工具调用参数时,天然对 JSON Schema 的理解能力比对自定义注释强得多。你给模型一个清晰的enum或required,它就会严格按约束生成,参数幻觉的概率大大降低。

2.2 技能描述是灵魂

我在这个项目里最大的感触是:技能描述写不好,其他全白搭。模型不会像人一样自动“猜”你的意图,它完全是靠 description 文本来匹配技能的。描述写得太泛,它会在不合适的场景里强行调用;描述写得太细则容易跟其他技能互相干扰。

举个我实践中的反例。最早我给一个“网页内容采集”技能写的 description 是:Collect web page content.。结果模型在用户问“帮我查一下今天的天气”时,也去调它。后来我改成:

description: > 从指定的URL页面中提取主体内容,适用于用户明确给了网址、或者要求 "抓取某个网页的信息""爬取页面数据""获取链接正文"等场景。 不适合:天气查询、知识问答等非特定网页数据获取需求。 URL必须来自用户输入或上下文,不允许自行编造地址。

加了“不适合”的部分和“URL来源约束”之后,误调用率肉眼可见地降下来了。总结下来描述文本要覆盖四点:这个技能做什么、什么场景触发、什么场景禁止使用、关键约束条件是什么。缺一不可。

2.3 任务拆解与技能编排

模型拿到一个复杂用户请求,不会天然就知道该按什么顺序调哪些技能。所以在技能之上,需要有一个“编排层”。我的做法是让主Agent先做任务规划,产出一个技能调用序列,再逐个调用。

比如用户说:“帮我把这个文件夹里所有Excel汇总一下,然后生成周报”。编排层拆出的步骤是:

  1. 扫描文件夹、识别所有Excel文件(filesystem_scan)
  2. 逐个读取并提取结构(data_reader)
  3. 合并计算关键指标(data_analyzer)
  4. 生成周报Markdown(report_generator)

每个技能返回标准JSON,下一步技能把这个JSON作为输入。如果中间某个技能失败,编排层要能回退:重新让模型选替代技能,或者明确告诉用户“这一步做不了”。

技能内部的执行逻辑也要写成SOP——这就是plan.md的作用。它不是给人看的文档,而是给模型看的“操作手册”。模型在调用一个复杂技能时,可以把这页计划注入上下文,让它知道执行过程中有哪些决策点。比如 data_analyzer 的 plan.md 会写明:先检查文件编码,再识别表头,空值超30%的列要打标提醒……这样模型在执行过程里就知道每个判断该怎么做了。

3. 实操过程:从零搭一个技能库

3.1 先定义技能的公共抽象

在设计代码层面,我建议不直接写死一套实现,而是先定义一个统一接口。我最终用的是抽象基类的方式,每个技能包继承这个基类,实现对应的 execute 方法:

# core/skill.py import abc import json from typing import Any, Dict, Optional class BaseSkill(abc.ABC): """所有技能包的基础抽象类""" def __init__(self, skill_config: Dict[str, Any]): self.name = skill_config.get("name", self.__class__.__name__.lower()) self.version = skill_config.get("version", "0.0.1") self.description = skill_config.get("description", "") self.input_schema = skill_config.get("input_schema", {}) self.error_handling = skill_config.get("error_handling", {}) @abc.abstractmethod def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: """执行技能,params是经过校验后的输入参数""" raise NotImplementedError def pre_validate(self, params: Dict[str, Any]) -> Optional[str]: """基于JSON Schema校验输入参数,返回错误信息""" # 这里用jsonschema库做严格校验 import jsonschema try: jsonschema.validate(instance=params, schema=self.input_schema) return None except jsonschema.ValidationError as e: return str(e.message)

每个技能包里的 executor.py 只要继承BaseSkill,实现 execute 就行。注意description和input_schema不是随便填的,它们会进入技能索引库,供模型检索和路由,所以必须跟技能实现严格对齐。

3.2 一个最小技能包的实现示例

以一个最常用的能力为例——“读取CSV文件并统计关键指标”,完整技能包可以这样实现。

skill.yaml 我按 2.1 节的格式写,executor.py 是这样的:

# skills/data_analyzer/executor.py import csv import os import json from core.skill import BaseSkill class DataAnalyzerSkill(BaseSkill): """读取CSV/Excel文件并输出关键统计摘要""" def execute(self, params: dict) -> dict: file_path = params["file_path"] if not os.path.exists(file_path): return {"success": False, "error": f"文件不存在: {file_path}"} # 简易实现:只处理CSV,实际项目可接pandas rows = [] with open(file_path, encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: rows.append(row) if not rows: return {"success": True, "summary": "文件为空,无数据可统计", "metrics": {}} # 对每一列做基础统计 numeric_cols = {} text_cols = {} for col in rows[0].keys(): values = [r.get(col) for r in rows if r.get(col) is not None] if all(isinstance(v, (int, float)) for v in values): numeric_cols[col] = { "min": min(values), "max": max(values), "avg": round(sum(values) / len(values), 2), } else: text_cols[col] = {"unique_count": len(set(values))} summary = f"共读取 {len(rows)} 行数据,{len(rows[0].keys())} 列。" if numeric_cols: summary += " 数值列包括:" + "、".join(numeric_cols.keys()) + "。" return { "success": True, "summary": summary, "metrics": {"numeric_columns": numeric_cols, "text_columns": text_cols}, }

实际项目里肯定不止一个技能,所以还需要一个技能注册中心,负责加载所有技能包、构建描述索引、提供统一调用入口。

3.3 注册、路由与调用主流程

技能注册中心我习惯做成一个单例,启动时扫描skills/目录,把每个技能的描述和类实例加载进内存,同时把所有描述的向量索引建好。流程分四步:扫描 → 实例化 → 向量化 → 就绪。

主流程的调用伪代码如下:

# core/registry.py class SkillRegistry: def __init__(self): self.skills = {} self.embeddings = None def register(self, name: str, skill_instance): self.skills[name] = skill_instance def match_skill(self, task_text: str, top_k: int = 3) -> list: """根据用户任务文本做语义检索,返回候选技能列表""" # 实际项目里可以用embedding模型把task_text和所有技能描述向量化, # 然后做余弦相似度排序;也可以先用trigger_keywords做一轮粗筛 candidates = [] for skill_name, skill in self.skills.items(): # 这里简化成关键词粗筛,实际可以换成向量检索 score = sum(1 for kw in skill.trigger_keywords if kw in task_text.lower()) if score > 0 or any(k in task_text for k in ["分析", "统计", "汇总"]): candidates.append((skill, score)) candidates.sort(key=lambda x: x[1], reverse=True) return [s[0] for s in candidates[:top_k]] def invoke(self, skill_name: str, params: dict) -> dict: skill = self.skills[skill_name] error = skill.pre_validate(params) if error: return {"success": False, "error": f"参数校验失败: {error}"} try: return skill.execute(params) except Exception as e: return {"success": False, "error": f"执行异常: {str(e)}"}

调用主流程中,模型会话层拿到用户输入后,先match_skill检索候选技能,把候选技能的 name 和 description 塞给模型,让模型做最终选择并生成参数 JSON,再调invoke执行。这里有个关键取舍:检索层是粗筛,模型层是精排。不要试图用关键词或向量一步到位选技能,否则误选率会很高。让模型在2~3个候选里做决断,准确率能到90%以上。

3.4 技能库的版本管理与效果评估

技能库跟普通代码库一样,要管理版本。每个技能包都带版本号,技能描述里也带。我遇到的实际问题是:同一个技能升级后,旧版本被模型以旧逻辑调用,导致行为不一致。所以我在注册中心里强制每个技能只保留一个活跃版本,升级时自动生成 changelog,旧的 API 参数格式要做兼容映射。如果升级会破坏输入 schema,那宁可换新技能名,避免模型混用。

另外,效果评估一定要在项目里尽早做。我给技能库加了一个简单的评估集,格式是“任务文本 → 期望技能名 → 期望参数”。每跑一次改动之后,用这个评估集回归一遍,看路由准确率、参数推导正确率、执行成功率三个指标。我的要求是:路由准确率低于85%不允许上线。刚开始做数据评估的时候确实很痛苦,但后面每次改描述、加技能,都能靠着这套评估集及时发现问题。一个技能库没有评估机制,跟盲人摸象没什么区别。

4. 常见问题与排查技巧实录

4.1 技能描述冲突,模型总调错

这是我在项目里遇到最多的问题,尤其当技能库超过10个技能后,模型经常在两个描述相似的技能之间“犹豫不决”,然后随机选一个。排查思路是:拉出所有技能 description 的头部,看是否存在“覆盖场景高度重叠”的情况。

解决方式有两种:一是合并,如果两个技能的输入输出和执行逻辑都差不多,直接合并成一个,用analysis_type这类参数做细分;二是在描述里明确写清“边界声明”,比如 A 技能里写“本技能不处理文件路径读取,那属于 file_scanner 的职责”,B 技能里也反向声明。我做过一个实验:加了边界声明后,两个重叠技能之间的路由准确率从72%提到了91%,立竿见影。

4.2 参数错配与类型不匹配

模型生成的参数经常出现类型不匹配,比如 schema 里写col_index是 integer,模型却给了 string “第一列”。这个问题我分两层解决:底层用jsonschema严格校验,只要不符合 schema 就直接拒绝,不尝试隐式转换,拒绝后把错误信息抛给模型让它重试一次;上层在 schema 里把字段名和描述写得足够直白。

还有个技巧是给枚举型参数留一个default值,并明确标注“如果不确定,使用default”。这招对减少参数幻觉很有效。

4.3 模型幻觉导致“伪技能”调用

有时候模型根本没看技能描述,就根据任务文本“脑补”出了一个不存在的技能调用。最典型的场景是:技能库里只有data_analyzer,用户却问“帮我把这个文件语音朗读出来”,模型居然调data_analyzer并传了个tts=true的参数。这说明模型在强行套用技能。

我的处理办法是三重兜底:第一,参数校验失败时立刻返回错误,不执行任何动作;第二,编排层收到“技能执行成功但输出与用户请求完全不相关”时,触发二次验证,让模型自评“该输出是否解决了用户问题”;第三,如果连续两次验证都不通过,就交给人工兜底,明确告诉用户“当前能力不支持该操作”。Agent 工程里不能指望模型每次都做对,兜底链路必须存在。

4.4 长技能脚本导致上下文溢出

有个技能实现特别重,内部要跑十几步处理逻辑,我把这些逻辑写成代码后,又希望在模型执行过程中能实时判断每一步的结果。这就导致我每次调用都要把 plan.md 全文注入上下文,再加上用户对话历史,很容易把上下文塞爆。后来我改了架构:复杂技能的 SOP 不进主上下文,而是技能内部自己在每一小步之间做决策,只有最终结果返回给主模型。

换算一下更直观:一个技能包约 2000~3000 token 的计划文档,如果每轮交互都塞进去,泡上10轮就是 3 万 token,再算上历史对话和工具定义,直接溢出了。所以我的经验是技能计划文档只在“技能被选中”和“参数生成中”注入一次,执行过程中不再反复注入。如果确实需要跟模型联动,就用一个轻量摘要代替完整计划。

4.5 技能库的安全边界

最后提醒一个容易忽略的点:技能库不是直接把任意技能暴露给模型就完事了。比如一个“执行Shell命令”的技能,被模型误调去删文件,后果不堪设想。我实际给技能库加了权限层级:普通技能只允许读操作;涉及文件写入、网络请求、命令行执行的技能,必须二次确认才能调用,确认信息里要包含具体命令和影响范围。

“最小权限”这三个字在Agent系统里不是空话。模型没有道德意识,它只会按概率行事——你越是把危险技能描述得清楚,它越有可能在模棱两可的场景里调用。设计技能包的时候,要把“安全约束”直接写死在description里,并且在不该用的时候明确加上“禁止使用”指令。虽然模型对这种指令不是100%遵守,但至少能显著降低误触概率。后来我在这套技能库上继续做了技能流编排、观察日志和评估回归,整体稳定多了。尤其是那段“把技能描述当成产品文案反复打磨”的过程,投入产出比非常高。如果你正在搭类似的技能系统,我的个人建议是:先别急着堆技能数量,把5个核心技能的描述、边界、评估做到位,比盲目扩展几十个技能更有价值。技能库跟人一样,少而精永远比多而杂走得远。

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

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

立即咨询