☰
Agent Skills 技能库架构设计:从函数调用到模块化 AI Agent 实践
2026/10/21 8:35:36 网站建设 项目流程

Agent Skills 这个话题,最近在 AI Agent 开发圈子里讨论度很高。我理解的 Agent Skills,简单说就是把一个 Agent 的能力拆成一格格可以独立维护、独立复用的小模块——每个技能负责一类具体的事,比如查天气、算报价、解析日志、操作数据库,然后由 Agent 根据用户意图按需调用。我前阵子把一个原本所有工具逻辑都堆在主进程里的 Agent 应用做了重构,改成基于技能文件的架构,跑通之后后劲很大。今天把设计思路、目录规范、代码实现和踩过的坑一次性整理出来。

这篇文章适合正在做 Agent 应用开发、或者想把现有 Agent 架构做得更规范更容易扩展的读者。不需要多深的功底,但如果你写过简单的 Agent demo,理解起来会非常快。

1. 技能拆解:Agent 为什么需要一套「技能库」而不是一堆「函数」

1.1 先想清楚:技能和工具到底差在哪

很多人在刚开始做 Agent 时,习惯把所有能力都写成一个个函数,然后在系统提示词里列一长串说明,告诉模型“你有这些函数可以用”。这种做法在小 demo 里跑得通,但一旦函数数量超过二十个,问题就来了:模型经常选错函数、参数填得乱七八糟,提示词稍微改一个词,行为就飘了。

技能库的思路和函数列表不一样。它的核心是把每个能力打包成一个独立的目录,目录里有描述文件、实现脚本、示例数据,整个能力像一个“插件”一样可以被扫描、加载、卸载。Agent 要做什么事,先看技能库里有哪几个候选,再根据描述决定调用哪个。

我打个比方,函数列表像一本全是目录索引的电话本,你得知道对方叫什么才能查;技能库像是把每个联系人做成了带照片、带简介的名片夹,模型翻名片的时候一眼就能判断谁合适。差别就在于那一段描述性的“名片内容”,而不是函数签名本身。

1.2 技能库的整体目录设计

我的项目里技能库根目录长这样:

skills/ ├── weather-search/ │ ├── SKILL.md │ ├── main.py │ └── config.yaml ├── invoice-calc/ │ ├── SKILL.md │ ├── main.py │ └── templates/ ├── log-parser/ │ ├── SKILL.md │ ├── parser.py │ └── examples/ └── ...

每个技能文件夹里,SKILL.md是门面,负责告诉 Agent “我是什么、我能干什么、需要什么参数”;main.py或者任意命名脚本是实际执行逻辑;其他资源文件按需添加。技能之间互相不引用,不共享内部状态,所有通信都通过标准输入输出。

这个设计的最大好处是隔离性。我可以在不重启主服务的情况下往目录里丢一个新技能文件夹,加载器扫到就注册;出问题的技能直接删掉文件夹,不影响其他部分。对于业务快速变化的场景,这个能力是真香。

1.3 为什么要用 Markdown 描述文件做「门面」

SKILL.md选用 Markdown 而不是 JSON 或 YAML,是我试过几种方案后的决定。最早我用 YAML 写技能元信息,字段写得很紧凑,模型确实能解析,但有一个致命问题:YAML 不适合写“自然语言说明”。技能的适用场景、边界条件、注意事项,这些都是长文本,塞进 YAML 字段里要么转义麻烦,要么层级混乱。

Markdown 反而恰到好处。它本身是模型训练时见过最多的文档格式,结构天然友好;而且可以优雅地混合“给人看”的说明和“给模型看”的字段。我在每个SKILL.md里用---包裹的头部块放结构化元数据,正文用自然语言写长说明,实测下来模型对正文的理解准确率明显高于纯结构化配置。

2. 核心细节:一份能被 Agent 正确理解的技能描述长什么样

2.1 描述文件的必填字段与写作要点

一个合格的SKILL.md,头部元数据至少得有这几项:

--- name: log-parser description: 解析各类服务日志,提取错误码、耗时和请求链路信息,输出结构化 JSON。 version: 1.2.0 author: internal tags: [日志, 运维, 排查] parameters: log_path: type: string required: true description: 日志文件的绝对路径或相对路径 max_lines: type: integer required: false default: 500 description: 最多解析的行数,防止超大日志撑爆内存 ---

这里面的关键其实是description。写描述有个很实用的原则:用“动词+宾语+结果”的结构,并且写清楚这个技能不干什么。比如上面那个例子,“解析各类服务日志,提取错误码、耗时和请求链路信息,输出结构化 JSON”已经说清楚了;如果技能不支持 Windows 路径,要明说“仅支持 Linux 路径”,否则 Agent 会把不合规的输入也传进来。

我在重构最初期踩过一个坑:某个技能描述写得很宽泛——“处理数据文件”,结果 Agent 把 CSV、JSON、Excel 甚至图片都往这个技能里塞。后来把所有技能描述都重写了一遍,每条都加“仅支持”和“不支持”约束,误调用率直接降了一半以上。

2.2 参数建模:别让 Agent 猜你的入参

参数定义是技能系统里最容易被低估的部分。我的经验是:宁可多写几行说明,也不要让模型猜测参数含义。尤其是枚举参数,一定要给全可选项。

比如一个控制输出格式的参数:

format: type: string required: false default: plain enum: [plain, json, csv] description: 输出格式。plain 用于人读,json 用于后续程序处理,csv 用于导入表格工具。

我见过不少项目,只写type: string,剩下全靠模型猜。结果模型自由发挥,填了个table,技能脚本不认识,报错后 Agent 又重试,白白浪费一轮对话。把枚举值列全,把每个值的适用场景写清楚,模型就基本不会跑偏了。

参数数量也要克制。我给自己定过一个规矩:单个技能入参不超过五个。超过五个,要么拆技能,要么把多个参数合并成一个配置对象。入参越多,模型填错概率越高,这是大模型推理的固有特性,跟模型聪明不聪明关系不大。

2.3 返回内容的长度控制

技能执行完返回给 Agent 的内容,是另一个容易翻车的地方。模型一次能处理的上下文有限,如果技能返回一个几十 MB 的日志内容,后面 Agent 基本就废了,别说推理,连历史对话都可能被挤出去。

我的处理方式是在技能脚本里做三层控制:

  1. 截断:只返回前 N 行或者前 N 个字符,默认值定在 2000 个字符左右。
  2. 摘要:如果内容是结构化数据,可以做一个简单的统计摘要,比如“共 1200 条记录,其中 error 占 15%”。
  3. 落盘:完整结果写到文件,返回里只带路径,Agent 需要时再通过“读取文件”这个技能去拿。

这三层搭配起来,大部分场景都能兼顾完整性和上下文友好度。我在log-parser技能里就是这么做的,实测模型分析日志时,基于摘要信息就能给出靠谱的结论,需要细节再按路径去读,体验比之前一股脑全塞进上下文好太多了。

3. 实操过程:从零搭建一套可用的 Agent Skills 框架

3.1 技能注册与加载流程

技能框架的核心是一个加载器,它做三件事:扫描目录、解析SKILL.md、注册到技能表里。代码不复杂,但要做好异常兜底。我这里的实现参考了一个很常见的做法:

# skill_loader.py import yaml import logging from pathlib import Path from dataclasses import dataclass, field @dataclass class Skill: name: str description: str version: str entry: str params: dict tags: list = field(default_factory=list) class SkillRegistry: def __init__(self, root: str = "skills"): self.root = Path(root) self._skills: dict[str, Skill] = {} def load_all(self): """扫描 skills 根目录下所有子目录,解析 SKILL.md 并注册。""" if not self.root.exists(): logging.warning("skills 目录不存在: %s", self.root) return for skill_dir in self.root.iterdir(): if not skill_dir.is_dir(): continue skill_file = skill_dir / "SKILL.md" if not skill_file.exists(): logging.warning("跳过 %s,缺少 SKILL.md", skill_dir.name) continue try: skill = self._parse_skill_file(skill_file, skill_dir) self._skills[skill.name] = skill logging.info("已加载技能: %s@%s", skill.name, skill.version) except Exception as e: logging.error("加载技能 %s 失败: %s", skill_dir.name, e) def _parse_skill_file(self, skill_file: Path, skill_dir: Path) -> Skill: raw = skill_file.read_text(encoding="utf-8") # 简单的 frontmatter 解析:取出 --- 之间的 YAML 部分 if not raw.startswith("---"): raise ValueError("SKILL.md 必须以 --- 开头") _, meta_block, _ = raw.split("---", 2) meta = yaml.safe_load(meta_block) entry = skill_dir / meta.get("entry", "main.py") return Skill( name=meta["name"], description=meta["description"], version=meta.get("version", "0.0.1"), entry=str(entry), params=meta.get("parameters", {}), tags=meta.get("tags", []), ) def get_skill(self, name: str) -> Skill | None: return self._skills.get(name) def list_skills(self) -> list[Skill]: return list(self._skills.values())

这个加载器有几个值得注意的细节。首先是SKILL.md必须存在,否则整个目录跳过并打日志——这样即使有人误创建了一个空文件夹,也不会让整个系统崩掉。其次,解析 frontmatter 时如果 YAML 格式出错,会被except兜住,记录错误后继续加载其他技能。我实际跑下来的经验是:加载器一定要做成“单个失败不影响整体”,否则上线后等着半夜被报警吵醒。

3.2 一个完整技能的最小实现

技能的实际执行逻辑我统一采用“命令行脚本 + 标准输入输出”的模式。这样做的好处是:Agent 进程和技能进程完全隔离,技能崩了不会拖垮主进程;技能可以用任何语言写,Python、Node、甚至 shell 都行。

拿一个简单的“文本摘要技能”举例:

# skills/text-summarizer/main.py import argparse import json import sys def summarize(text: str, max_length: int = 200) -> str: """极简摘要:按标点切句,拼接至接近 max_length。""" sentences = [] buf = [] for ch in text: buf.append(ch) if ch in "。!?.!?;;": sentences.append("".join(buf).strip()) buf = [] if buf: sentences.append("".join(buf).strip()) result = "" for sent in sentences: if len(result) + len(sent) > max_length: break result += sent return result or text[:max_length] def main(): parser = argparse.ArgumentParser() parser.add_argument("--text", type=str, required=True) parser.add_argument("--max-length", type=int, default=200) args = parser.parse_args() output = summarize(args.text, args.max_length) # 统一输出为 JSON,方便 Agent 解析 print(json.dumps({"summary": output, "length": len(output)}, ensure_ascii=False)) if __name__ == "__main__": main()

这里我有意把所有技能的输出都统一成 JSON。早期我让不同技能各输各的格式,结果 Agent 解析时经常出错;统一之后,主流程只需要做一个通用的 JSON 解析器,省了一堆麻烦。

调用技能的方式也很直接,主进程用subprocess执行脚本:

import subprocess import json def run_skill(skill: Skill, params: dict) -> dict: """执行技能脚本,返回解析后的 JSON 结果。""" cmd = [skill.entry] for key, value in params.items(): cmd.append(f"--{key}") cmd.append(str(value)) proc = subprocess.run( cmd, capture_output=True, text=True, timeout=30, encoding="utf-8", ) if proc.returncode != 0: return {"error": f"技能执行失败: {proc.stderr}"} try: return json.loads(proc.stdout) except json.JSONDecodeError: return {"error": f"技能输出非合法 JSON: {proc.stdout[:200]}"}

超时时间我默认设 30 秒,这是试出来的值。很多技能第一次跑需要加载模型或初始化资源,10 秒太紧;但超过 30 秒还不返回,大概率是卡死了,再等也没有意义。

3.3 技能调用与路由策略

技能加载好、执行器写好,接下来是 Agent 如何选择技能。我这里没有用太复杂的方案,就是用大模型做一次函数调用级别的路由:

from openai import OpenAI client = OpenAI() # 把技能表转换成模型可以理解的工具列表 tools = [ { "type": "function", "function": { "name": skill.name, "description": skill.description, "parameters": skill.params, }, } for skill in registry.list_skills() ] response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, tool_choice="auto", )

这个方法本质上还是“工具调用”,但区别在于技能表是动态生成的——新技能丢进目录,重启加载器,tools列表自动变长,不用改主代码。这也意味着这套架构可以慢慢演变成一个“技能市场”:团队里不同的人维护不同技能,各自提交目录,主系统一键扫描加载。

路由策略上,我还加了一道“候选过滤”:在把全部技能发给模型前,先用一个轻量级 embedding 模型把技能描述和用户问题进行粗筛,只保留最相关的三到五个技能。这样既省 token,又能降低模型从几十个技能里选错的风险。试过的团队如果模型调用成本敏感,这个过滤非常值得做。

3.4 上下文管理与技能内记忆

再补一个很多人问过的点:技能执行过程中产生的中间状态存在哪。我的方案是在技能目录里放一个state/子目录,每个会话用独立文件,主进程不感知。比如一个数据分析技能,第一步清洗数据,第二步画图,第三步生成报告,这三步之间需要共享中间 DataFrame,就直接序列化存到state/{session_id}/data.parquet。

这个设计的好处是技能内部的记忆完全自治,不会污染其他技能;坏处是磁盘文件需要定时清理。我在技能基类里加了一个“会话过期时间”,超过 24 小时没碰的文件,下次执行前自动删。对于一个个人项目来说,这个粒度够用了。

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

4.1 为什么 Agent 总是选错技能

选错技能是我自己遇到频率最高的问题,而且七成以上不是模型的问题,是描述写得不到位。

排查的时候先反问自己一个问题:如果一个人没看过代码,只看这段描述,能不能准确判断这个技能该不该接这个需求?如果人都会犹豫,模型更会犹豫。比如某个技能叫file_utils,描述写的是“文件处理工具”,这就是典型的失败案例。人得点进去才能知道它到底能干什么,模型也一样。

我自己实践下来,描述写作有一个可以照抄的模板:

技能名:动词 + 对象 描述第一句:在什么场景下、对什么输入、做什么处理、输出什么格式 描述第二句:不适用于哪些情况、有哪些限制条件

按这个模板重新整理之后,技能选择的准确率提升非常明显。如果你改了描述还是选错,那再检查参数定义——有些技能选对了,但 Agent 给的参数驴唇不对马嘴,本质上还是参数描述不够清楚。

4.2 技能超时与失败重试怎么处理

技能执行失败,最忌讳的是让 Agent 自己无限重试。模型陷入重试循环之后不仅浪费时间,还会把错误信息当成对话内容反复出现,让整个会话质量断崖式下降。

我的处理策略分三层:

  1. 第一层:主进程捕获非零退出码,把 stderr 摘要传给模型,并注明“只允许重试一次”。
  2. 第二层:重试时强制走默认参数,不再让模型重新猜测参数。
  3. 第三层:第二次失败直接返回兜底结果,比如“技能执行失败,建议人工介入”,把选择权交还给用户。

这个“最多重试一次”的约束一定要写进系统提示词里,因为模型在对话里天然倾向于“再试一次”,你不拦着它就会一直试下去。

4.3 上下文爆炸的预防

还有一个高频事故是上下文越滚越大。根因通常不在技能本身,而是技能返回内容没有做长度限制,Agent 把返回内容原样塞进了下一轮对话。

我的预防方案有两条线:

  • 在技能层强制限制输出长度,所有脚本出口处套一个truncate_to(text, 2000);
  • 在主进程层做一次“上下文审计”,每一轮对话结束后检查 token 数,超过阈值的会话自动把最早的历史消息摘要化。

摘要化我用的也是模型调用,但成本不能忽略,所以触发阈值要调得比较保守。我自己设的是 12000 token,超过就把三轮以前的消息合并成一段 300 字以内的摘要。这个机制上线之后,长会话崩溃的问题基本绝迹了。

4.4 技能依赖另一个技能怎么办

技能之间原则上不应该互相调用,但真实业务里难免出现“先解析日志,再统计错误码分布”这种场景。我的建议是:不要让技能脚本内部直接调用另一个技能脚本,而是把组合逻辑放到 Agent 层面,由 Agent 分两步调用两个技能。

原因很简单:技能之间内部直连会让目录变得像一个图,图的依赖关系一旦复杂了,加载顺序就变成灾难。而让 Agent 来做组合,不仅灵活,而且可观测——每一轮调用了哪个技能,是不是合理,全部有日志可查。等你真的遇到必须复用的逻辑,把那段逻辑下沉成公共代码库,通过包管理依赖进技能项目,而不是直接调用技能命令行。

最后分享两个实操中的小技巧

第一个是关于技能版本的。每个SKILL.md里我都要求带version字段,加载器注册时保留版本日志。这个习惯平时感觉不到价值,直到某次我改了技能的参数结构,旧会话还在调用旧参数导致一连串报错,翻了版本日志才找到是什么时候改的。对个人项目来说,这个日志就是最低成本的“回滚依据”。

第二个是关于技能目录命名的。一开始我用中文命名技能文件夹,比如技能/日志解析,后来在部分环境下遇到路径兼容问题,全部改成了小写英文加中划线。如果你后面想把技能分享给别人用,这个习惯能帮你避开很多坑。

Agent Skills 这套模式我大概跑了两个月,最大的感受是:它把“给 Agent 加能力”这件事从改代码变成了“加文件夹”。团队协作的时候,这个转变带来的效率提升比任何技术优化都直观。如果你现在正在做一个功能越加越多的 Agent 项目,不妨试试把技能目录拆出来,从最小的一两个技能开始,你会发现后面收不住手。

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

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

立即咨询