做个 Agent 项目不难,难的是做出来的东西换个场景就废掉。我见过太多团队在项目初期把所有逻辑都灌进一个巨大的 prompt 里,看起来灵活,实际上推进一步就崩一次。今天想聊的这个项目思路,核心就两个字——拆解。把 Agent 的能力拆成一组可独立维护、可复用、可组合的“技能”,让大模型成为一个调度器,而不是一个什么都懂但什么都做不精的百科全书。这个方向在业内叫 agent-skills,你也可以理解为给 Agent 装一套模块化的“工具箱”。
这套思路解决的是 Agent 从上手 Demo 到真实落地之间最痛的那些问题:能力边界模糊、代码耦合严重、新增一个工具要牵动全局、大模型经常性地跑偏调用。这篇文章会从设计思路、目录结构、运行机制、最小实现、问题排查到实战心得,完整走一遍我是怎么搭出一个质地可靠的技能库的。如果你正在纠结“Agent 的后端到底怎么组织”,或者手头的多智能体项目已经隐隐有失控的迹象,这篇内容应该能给你省下不少弯路。
1. 内容整体设计与思路拆解
1.1 核心需求:大模型应用为什么会越写越乱
先复盘一个典型场景。最早我尝试做一个通用型个人助理 Agent,能查邮件、能拉会议纪要、能分析 CSV 数据,还能写周报。一开始确实顺手,因为只有三五个功能。但随着场景增加,代码变得不可控了——大模型的 system prompt 越来越长,工具列表越来越长,Agent 的判断准确率却越来越低,经常出现拿着计算器去炒菜之类的问题:让它分析数据,它调用了一个无关工具;让它查日历,它根据想象编了一个日历 ID 出来。
问题的根子不在模型能力,而在架构。传统的 Agent 构建方式倾向于把所有能力和说明都塞进一个文件,或者把每个工具都写死在代码分支里。这会导致两类经典故障:
- 职责不清晰。工具和技能的边界模糊,模型不知道哪个“能力”干什么用,靠猜。
- 耦合度过高。改一个能力可能影响整个系统,无法独立测试,更谈不上跨项目复用。
说到底,Agent 项目失控的起点,往往就是对“能力”的抽象层级没想清楚。模型本身不会因为你的脚本越来越长而变聪明,相反,它需要的是越来越明确、越来越原子化的指引。这时候,把“能力”封装成“技能”就成了一种很自然的解法。
1.2 “技能”这个抽象到底解决了什么
一个技能(skill),简单说就是把一个可重复执行的能力模板化。比如“把 CSV 文件转为 Markdown 表格”是一个技能,“查询 GitHub 仓库最近一周的 issue 并汇总”是一个技能,“从一段会议录音里提取行动项”也是一个技能。
技能不是工具函数,也不是简单的 API 封装。它的核心特征是三个:可描述、可复用、可组合。
可描述的意思是,每个技能都需要有一套机器可读的说明(元数据),让大模型知道“这个技能是干什么的、什么时候该用、需要什么参数”。这直接解决了上面提到的“模型拿计算器炒菜”的问题——它不是在几十个扁平工具里胡猜,而是在一组有明确边界的技能里做选择。
可复用很容易理解。一个写好的技能就是一个独立包,带目录、代码、说明,放到任何 Agent 项目里都能跑起来。一个团队里张三写好了一个 PDF 解析的技能,李四的项目里不需要再重复造轮子。
可组合则更进一步。Agent 在完成复杂任务时,不再是一个技能干到底,而是把多个技能串起来。比如“把销售日报整理成看板”这个任务,实际需要“解析 Excel”→“清洗数据”→“生成图表”→“输出 Markdown 报告”四个技能按顺序执行。
这一个抽象层带来的改变是巨大的,主动权开始掌握在开发者手里,而不是只能靠模型自由发挥。大模型不再需要“知道怎样执行细节”,只需要“知道在什么场景选哪个技能”,执行细节由确定性的代码保证。
1.3 技能库与单体代码的取舍
有人可能会问,为什么不直接用现成的 LangChain Tool 或者 OpenAI Function Calling?这里我需要说句公道话:Function Calling 是一个非常有用的机制,但它解决的是“模型怎么把参数填对”,不是“我的代码怎么组织”。如果你有一个 300 行的工具轮子,往里塞几个函数声明就完事,那确实不需要技能库。但一旦工具数量上来了,达到二十个甚至更多,就该考虑技能库这一层了。
技能库本质上是在 Agent 的调度层和执行层之间加了一个“目录层”。它和单体代码最大的区别在于:
- 单体代码把“有哪些能力”写死在代码里,模型只能在代码预先定义好的函数里选。
- 技能库把“有哪些能力”变成运行时动态加载的资源,新增能力不需要改核心调度代码,只要往技能目录里扔一个新技能包,再重启 Agent 服务让它重新扫描一遍。
这一点在大型项目或者多 Agent 协作场景里的收益非常明显。我做一个数据洞察 Agent 的时候,一度要同时管 15 个工具函数,维护起来极其痛苦。后来统一收敛为技能包模式,核心调度代码几乎没再动过,每次需求迭代都变成“写新技能包+补充测试用例+发版”,心里的负担一下子轻了很多。
2. 核心细节解析与实操要点
2.1 技能目录的组织结构
一个典型的技能库,目录结构大概是这样的:
agent-skills/ ├── skills/ │ ├── csv-to-markdown/ │ │ ├── SKILL.md │ │ ├── skill.yaml │ │ └── main.py │ ├── github-issue-report/ │ │ ├── SKILL.md │ │ ├── skill.yaml │ │ └── main.py │ └── meeting-action-items/ │ ├── SKILL.md │ ├── skill.yaml │ └── main.py ├── core/ │ ├── loader.py │ ├── registry.py │ ├── scheduler.py │ └── executor.py ├── config.yaml └── requirements.txt其中skills/目录下每一个子目录就是一个独立的技能包。每个技能包内部至少有这三个文件的组合:
skill.yaml:机器的元数据,包含技能名、描述、参数 schema、入口函数等。SKILL.md:给人看的自然语言说明,同时会被拼进给大模型的 prompt 里,用于增强模型对技能的理解。main.py:技能的确定性执行代码,写清楚输入输出,保持独立。
这套结构和 Ansible 的 role 结构很像,好处是任何人都能在不阅读其他代码的情况下,快速看懂一个技能的意图。
2.2 技能元数据:skill.yaml 怎么定义
skill.yaml是整个技能库的中枢,大模型能不能正确地调用技能,很大程度上取决于这份元数据写得好不好。
一个典型的skill.yaml长这样:
name: csv_to_markdown description: 将 CSV 文件内容转换为 Markdown 格式的表格。 仅当用户提供了 CSV 文件路径时使用。如果输入不是 CSV,不要使用此技能。 version: 1.0.0 author: your_name parameters: type: object properties: file_path: type: string description: CSV 文件的本地路径或 URL。 delimiter: type: string description: 分隔符,默认为逗号。 default: "," required: - file_path entrypoint: main:convert_csv_to_markdown timeout: 30这里我要特别强调description字段,它别看只是一句话,其实是个工程活。写得模糊,模型就会在无关场景下调用它;写得过于具体,模型又会变得不敢调用。正确写法是做到“含义明确、触发条件清晰、反例也写清楚”。
一个用词上的小技巧:描述里写“当用户需要把表格数据格式化为 Markdown 时”,比写“处理表格数据”要强得多。因为前者给了模型明确的触发信号,后者只说了一个“类别”,模型还需要自己推理“表格数据是否等于 CSV,要不要转 Markdown”。
2.3 自然语言说明:SKILL.md 的必要性
有些初学者会问:既然有 skill.yaml,为什么还要一个 SKILL.md?
因为 yaml 描述是用来给调度器做工具选择的,而 SKILL.md 是用来给大模型做上下文理解的。尤其当你用的模型不是单纯的 Function Calling 模式,而是 ReAct 风格(Reasoning + Acting),模型会在思考过程中反复阅读技能说明。一份 10 行的自然语言文档可以帮助模型更从容地判断复杂场景。
举个实际案例,我写过一个解析 PDF 银行对账单的技能,skill.yaml里的描述不可能把银行对账单的各种格式差异都写进去,但在SKILL.md里可以详细说明“支持中国银行、招商银行、建设银行的标准导出格式,其他银行可能会解析失败,需要先转成 PDF 再解析”。模型读了这段说明之后,虽然不会自动帮你转格式,但至少它在用户给了不支持的银行账单时,能明确回复“当前技能不支持该银行格式”,而不是胡编一个结果。
2.4 技能代码保持纯粹的输入输出
这一条是最容易被忽视的。技能内的main.py必须是一个“纯函数式”的代码,即相同的输入一定产生相同的输出,不依赖环境状态,不偷偷修改外部文件。这个原则有多重要,我给你讲个踩坑案例。
之前我为了省事,在一个技能里加了自动写日志到/tmp/agent.log的代码,结果 Agent 在高并发场景下处理了多个文件,日志全都串了,还导致后续几个任务读到了脏数据。排查了几个小时才发现是这个“顺手加的功能”惹的祸。后来我把技能收敛为“输入路径、输出结果,不做任何附加动作”,问题直接消失了。
遵循“纯输入输出”原则的好处是显而易见的——技能可测试、可缓存、可并行、可回滚。
3. 实操过程与核心环节实现
3.1 技能加载器与注册表设计
要让 Agent 能动态加载技能,我需要两个核心组件:一个扫描器(loader),一个注册表(registry)。
扫描器负责遍历skills/目录,读取每个子目录下的skill.yaml,校验字段完整性,然后动态加载对应的 Python 模块。注册表负责维护一份“目前可用技能”的内存索引,并提供给调度器查询。
下面是一个极简扫描器的实现思路:
# core/loader.py import os import yaml import importlib def load_skill_package(skill_dir: str): meta_path = os.path.join(skill_dir, "skill.yaml") if not os.path.exists(meta_path): return None with open(meta_path, "r", encoding="utf-8") as f: meta = yaml.safe_load(f) module_name = meta["entrypoint"].split(":")[0] func_name = meta["entrypoint"].split(":")[1] module = importlib.import_module(f"{os.path.basename(skill_dir)}.{module_name}") skill_func = getattr(module, func_name) return { "name": meta["name"], "description": meta["description"], "parameters": meta["parameters"], "timeout": meta.get("timeout", 30), "function": skill_func, }这里把技能目录名作为模块前缀来导入,前提是技能目录必须是一个 Python 包,也就是要有__init__.py文件(可以留空)。这个细节容易踩坑,我见过不少新手直接把技能目录当普通文件夹处理,import 阶段直接报错。
3.2 调度器:怎么让大模型把任务交给正确的技能
调度器是整个技能库的大脑。它负责把用户的自然语言请求转化成对技能的选择和参数填充,然后调用执行器运行技能。
我目前最稳定的调度方案是按照 structured output + 工具选择的方式做。流程大概是:
- 将注册表里所有技能的 name、description、parameters 拼装成一个工具列表,交给大模型。
- 大模型根据用户输入输出一个 JSON,格式为
{"skill": "技能名", "arguments": {...}}。 - 调度器拿到这个 JSON 后,去注册表里找到对应的技能函数,做参数校验,再交给执行器。
核心的伪代码长这样:
# core/scheduler.py import json from core.registry import get_skill def dispatch(llm_response: str, registry): try: parsed = json.loads(llm_response) skill_name = parsed["skill"] arguments = parsed.get("arguments", {}) except Exception: return {"error": "无法解析模型输出"} skill = registry.get(skill_name) if not skill: return {"error": f"技能 {skill_name} 不存在"} validated_args = validate_parameters(skill, arguments) if validated_args["ok"]: return {"skill": skill_name, "arguments": validated_args["data"]} return {"error": validated_args["message"]}这里有个很实际的经验:不要直接把模型的原始输出拿去调用技能。必须先经过参数校验,因为模型即使再聪明,也偶尔会漏参数、给错误类型、多传一个无关键。校验失败时宁可让 Agent 返回“参数不足,请补充信息”,也不要带病执行。
3.3 从零手写一个可用技能
动手实践是最好的学习方式。我们实现一个在 Agent 场景里极其常用的技能:“获取指定 GitHub 仓库的最近打开 issue 列表”。
首先创建技能目录:
skills/github-open-issues/ ├── SKILL.md ├── skill.yaml └── main.pyskill.yaml写入:
name: get_github_open_issues description: 获取指定 GitHub 仓库当前所有打开的 issue 列表。 当用户询问仓库的未解决问题、待办任务、Bug 列表时使用。 应始终要求完整的仓库路径,格式为 owner/repo。 version: 1.0.0 author: your_name parameters: type: object properties: repo_path: type: string description: GitHub 仓库路径,形如 owner/repo。 state: type: string enum: ["open", "closed", "all"] description: issue 状态,默认为 open。 default: "open" required: - repo_path entrypoint: main:fetch_open_issues timeout: 15main.py写入:
# skills/github-open-issues/main.py import os from typing import List, Dict import requests def fetch_open_issues(repo_path: str, state: str = "open") -> List[Dict]: url = f"https://api.github.com/repos/{repo_path}/issues" params = {"state": state, "per_page": 20} headers = {} token = os.environ.get("GITHUB_TOKEN") if token: headers["Authorization"] = f"Bearer {token}" resp = requests.get(url, headers=headers, params=params, timeout=10) resp.raise_for_status() issues = resp.json() result = [] for item in issues: # GitHub API 中带有 pull_request 字段的是 PR,不是 issue,需要过滤 if "pull_request" in item: continue result.append({ "number": item["number"], "title": item["title"], "state": item["state"], "labels": [label["name"] for label in item["labels"]], "created_at": item["created_at"], "url": item["html_url"], }) return result注意这里我在代码里特意做了“pull_request 过滤”,这是一个只有真正调过 GitHub API 的人才知道的巨坑——GitHub 的 issues 接口会把 PR 也混在里面返回,不处理的话,你的 Agent 会把所有 Pull Request 当成 Bug 汇报给用户。
在SKILL.md里写上执行说明:
# GitHub Open Issues 获取指定 GitHub 仓库当前打开状态的 issue 列表。 ## 使用场景 - 用户询问“这个仓库有哪些未解决的问题” - 用户想了解项目的待办 Bug ## 注意 - 仓库路径必须是 `owner/repo` 格式,例如 `pallets/flask` - API 返回的数据中 PR 会被过滤掉,不会出现在结果中 - 如果环境变量 `GITHUB_TOKEN` 存在,会使用认证请求,避免速率限制这样一个技能包就完整了。放到skills/目录,重启 Agent 服务,调度器扫描注册后就能被大模型自动选用了。
3.4 执行器:带超时和隔离的运行环境
技能代码是动态加载执行的,这就意味着你需要一个“执行器”来兜底。执行器至少要做三件事:超时控制、错误捕获、资源限制。
Python 里最直接的做法是用concurrent.futures.ThreadPoolExecutor做超时控制:
# core/executor.py import concurrent.futures def run_with_timeout(skill_function, arguments, timeout: int): with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(skill_function, **arguments) try: result = future.result(timeout=timeout) return {"ok": True, "result": result} except concurrent.futures.TimeoutError: return {"ok": False, "error": f"技能执行超时(>{timeout}秒)"} except Exception as exc: return {"ok": False, "error": str(exc)}注意:ThreadPoolExecutor的超时并不能真正杀死线程,只能让调用方放弃等待。对于大多数 IO 型的 Agent 技能(调 API、读写文件)来说,这个方案够用了。但如果是 CPU 密集型的危险代码,还是得走进程级隔离甚至容器沙箱,这个就看项目级别了。
我想强调一下这个执行器的存在价值。没有它,一个技能里的requests.get卡住,整个 Agent 线程就挂了;有了它,最多这条执行链返回一个超时错误,Agent 还会根据错误信息重新规划方案,比如换一个技能,或者让用户确认网络状态,体验完全不一样。
3.5 把技能接入 Agent 项目
技能库单独存在是没有意义的,必须能自然地接入主 Agent 应用。这里我提供两种最实用的接入方式。
方式一:以 Claude/OpenAI 的 function calling 形式,把所有技能转成 JSON Schema:
# core/adapter.py def skills_to_openai_tools(skills): tools = [] for skill in skills: tools.append({ "type": "function", "function": { "name": skill["name"], "description": skill["description"], "parameters": skill["parameters"], }, }) return tools方式二:在 ReAct 模式下,直接把技能列表拼到 prompt 里给大模型“阅读”。适合对模型工具有限制、但支持长上下文的场景:
def build_skill_prompt(skills): lines = [] for skill in skills: lines.append(f"技能名: {skill['name']}") lines.append(f"描述: {skill['description']}") lines.append(f"参数: {json.dumps(skill['parameters'], ensure_ascii=False)}") return "\n\n".join(lines)方式一稳定性更高,模型不容易“角色扮演”跑偏;方式二更适合思维链推理链比较长的复杂任务,因为模型能在思考中反复“看到”技能。实际项目里我会优先用方式一,遇到复杂推理场景再降到方式二。
4. 常见问题与排查技巧实录
4.1 模型“幻觉式”调用:技能名被篡改
排在第一位的坑绝对是模型生成了一个根本不存在的技能名。比如技能库里有get_github_open_issues,模型却输出了get_github_issues,然后带着并不存在的参数去执行,返回“技能不存在”,体验就很糟糕。
排查思路是:检查是不是技能名太长太难记。模型对没见过或读着不顺的名字,有一定概率自己“脑补”一个相近的。解决办法有两条:
- 技能名尽量短小且语义明确,去掉不必要的修饰词。
- 校验阶段不要一棍子打死,加一层“模糊匹配”。在 registry 里维护一个别名映射表,比如把
github_issues、get_issues都映射到标准技能名上。实测这个举措能把误调用率降低一半以上。
4.2 参数对不上:类型、缺失、多余
这是第二高频的问题。模型的 JSON 输出往往丢三落四,比如 repo_path 忘传了,或者传了一个 int 类型的 file_path。我处理的模式是写一个严格的参数校验器,并对缺失参数做“引导式追问”。
不要在报错文案里只写“参数缺失”,而是写“技能 get_github_open_issues 需要参数 repo_path(GitHub 仓库路径,形如 owner/repo),请提供完整的仓库地址”。把这个报错回传给大模型,它就能在下一轮对话中主动向用户索取缺失信息。
4.3 技能冲突与命名空间
如果你同时在项目里维护多个技能库,比如“数据技能库”和“办公技能库”,极有可能出现两个技能拥有相同name字段的情况。注册表加载时,后加载的会悄悄覆盖先加载的,且没有任何报错。
这个问题很隐蔽。我的处理方案是在扫描器里就加冲突检测:加载每个skill.yaml时检查注册表里是否已有同名技能,有就直接抛异常并打印警告,让开发者及时察觉到目录里有两个csv_to_markdown。如果你确实需要同名技能,那就应该把它们设计成不同命名空间的包,比如office.csv_to_markdown和data.csv_to_markdown。
4.4 技能执行超时
一个技能本来写着预期 10 秒内完成,但用户的输入规模一大,它跑了 1 分钟。这种不稳定是很影响体验的。建议在每个技能内部再设置一次“业务层超时”,别只依赖全局执行器的硬超时。比如解析 PDF 的技能可以对单页解析设置单独的超时,超过就跳过当前页并记录错误,而不是让整个任务失败。
4.5 技能间的数据传递问题
组合技能时,一个技能的输出要作为另一个技能的输入。这里容易出的问题是格式不一致,比如 A 技能返回的是{issues: [...]},B 技能却期望{issue_list: [...]}。
我的做法是设计一个轻量的“数据总线”,在技能组合链里统一用中间态数据结构。比如所有“数据读取类”技能统一返回Dataset对象,所有“数据输出类”技能统一接收Dataset对象。这样组合的时候就不用担心字段名对不上。
5. 真实部署后的几点核心心得
5.1 技能粒度怎么定才不后悔
这是我在实践里被问得最多的一个问题:一个技能到底应该写多大?
我的经验是:一个技能只做一件完整的事情,并且这件事能被一句话说清楚。如果一句话说不清楚,说明粒度太粗;如果一句话都不用说就懂,说明粒度太细。
举个例子。“解析 CSV 并生成图表并发送邮件”是一个合格的技能吗?不是,因为这件事至少可以拆成三个技能。但“把 CSV 列内容生成图表”是不是合格?是的,因为它的边界足够清晰。
技能粒度太粗会导致复用能力差,太细则会让调度器面对几百个技能,反而决策困难。我维护的成熟技能库,正常规模控制在三十到五十个技能之间,单技能的代码量大多在五十到一百行。
5.2 Demo 易做完,工程化难在哪
技能库的 Demo 确实很容易做完,把扫描器、注册表、调度器一套上,跑两个示例技能,20 分钟就能截个图发朋友圈了。但它真正的工程难点,都藏在后面:
- 技能的可观测性。技能执行失败时,是模型判断错了,还是参数填错了,还是代码 bug?没有一个可观测层,你会在联调阶段浪费大量时间。
- 技能的自动化测试。技能不仅要跑得通,还要在改造后不倒退。我给每个技能包都配了独立的 smoke test,覆盖“正常输入、边界输入、错误输入”三个最小集合。
- 技能版本的演进。旧技能更新后,之前依赖旧参数结构的调度记录全废了,需要设计好 version 字段的兼容策略。
这些内容写出来都是经验,不真正跑过线上项目,很难在文档里悟到。
5.3 后续可以扩展的方向
技能库做完,后续的扩展路径其实非常清晰。最直接的进阶方向是给技能库加一套共享协议,让不同团队甚至不同组织之间的技能包能够互相复用。到时候“技能市场”会变成一个真正的可能——类似 Ansible Galaxy 或者 npm registry,开发者发布技能包,别人一条命令拉下来就能接入自己的 Agent。另一个方向是把技能采集和评估自动化。你可以维护一个“技能评测集”,每次修改技能库之后自动跑一遍所有技能的回归评测,确保改动灰色地带没有影响已有能力。
从我个人的实际体感来说,把 Agent 的能力组织成“技能库”这件事,带来最大的改变不是代码结构变好了,而是思路变了:不再想着“让模型做所有事”,而是“让模型在恰当的时候调用准备好的能力”。这种思路一旦建立,Agent 项目才算是真正有了工程化的地基。