ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从混乱到有序:用技能库架构打造可复用的Agent模块化能力

从混乱到有序:用技能库架构打造可复用的Agent模块化能力 做个 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, encodingutf-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 fhttps://api.github.com/repos/{repo_path}/issues params {state: state, per_page: 20} headers {} token os.environ.get(GITHUB_TOKEN) if token: headers[Authorization] fBearer {token} resp requests.get(url, headersheaders, paramsparams, timeout10) 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_workers1) as executor: future executor.submit(skill_function, **arguments) try: result future.result(timeouttimeout) 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_asciiFalse)}) 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_pathGitHub 仓库路径形如 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 项目才算是真正有了工程化的地基。
返回列表