构建可插拔技能系统:让LLM智能体高效编排工具调用
先交代一个背景今年上半年我在做一个人机协作项目核心思路是把大模型从“聊天窗口”里拽出来让它真正去操作文件、遍历数据、调用外部API。结果发现一个非常尴尬的情况——模型能力很强但落到具体任务上它不知道该用哪段“手艺”。后来我花了大量时间把思路收拢到一件事上给智能体搭一套可插拔的技能系统。这个项目我内部就叫agent-skills。如果你也在搞Agent、搞自动化工作流或者单纯想让LLM少说废话多做实事那这篇文章应该能帮你少踩几个大坑。agent-skills本质上做的是这样一件事把离散的工具调用、任务步骤、上下文处理逻辑全部封装成一个个独立的技能单元由LLM感知环境后动态编排调用。这种方式解决的问题很直接——传统硬编码的自动化流程换一个输入场景就崩而基于技能系统的Agent能自己判断该用哪个技能、按什么顺序用。适合所有正在尝试把LLM接入真实业务系统的开发者也适合那些已经跑通了RAG和对话机器人、正准备往下一层“做事”进阶的团队。1. 项目整体设计与思路拆解1.1 核心需求为什么不能让LLM直接调函数业内做Agent早期有个非常粗暴的路径把所有能调用的函数一股脑塞进System Prompt让模型自己选。这个方案Demo阶段跑得非常爽——什么天气查询、待办事项看起来智能得不得了。一旦进入真实业务马上就露馅了。第一个问题是上下文膨胀。塞进去五十个函数定义每个函数带上参数说明光函数描述就能占掉两三千token留给真实对话和推理的空间被严重压缩。第二个问题是函数之间的依赖关系表达不清楚。业务流程里存在明确的先后顺序先查订单状态再决定退款还是补发。这种链路靠“让模型自由发挥”是稳不住的它总会找到一种你完全没想到的刁钻调用顺序。agent-skills的定位就是在“裸函数调用”和“全自动规划”之间找一个折中系统帮你把可复用的能力边界划清楚模型只需要在边界内做选择和排序。我当时定下的核心设计目标是四条新技能接入零代码改动——加新功能不需要改Agent主干代码。运行时技能状态可视化——能随时看到当前Agent调了哪个技能、执行到哪一步、输入输出是什么。技能间无缝衔接——技能A的输出能干净地成为技能B的输入不搞复杂的格式转换。失败可重试、可降级——单个技能调用失败不影响整个任务链路。这套需求听起来很顺但真正落地的时候每一个目标背后都有一堆细节要处理。1.2 方案选型为什么选择“技能描述优先”而不是“代码优先”设计之初我经历过一次特别痛苦的重构。最初版本我采用的是“代码优先”——每个技能就是一个Python类里面定义了run()方法技能之间的衔接靠写胶水代码。结果用了一个月发现最大的瓶颈不是写技能而是改技能。每一次接口微调调用方就要跟着改团队的沟通成本高得离谱。后来我彻底转向了“描述优先”的思路。每个技能文件由两部分组成自然语言编写的技能描述和可执行的代码实现。模型在决策时只读描述不读代码执行时才加载代码。这个转变有本质区别。代码是给人看的哪怕写注释写得再好模型的语义理解依然会偏差。而自然语言描述比如“此技能用于将原始用户反馈文本按照情感极性分类返回JSON格式结果字段包括sentiment、confidence”模型的理解准确率明显高出一大截。描述里写清楚“何时该用”“何时不该用”实际上是对模型做了一次隐式约束。另外我选了技能目录动态扫描的方式做注册发现。每加一个新的技能只要把文件夹丢进指定目录刷新后系统就会自动读取SKILL.md技能描述文件、校验代码文件完整性、把技能注入候选池。整个过程不需要重启服务也不需要注册中心。这个设计网上很多人介绍过但真正跑起来之后你会发现目录扫描方案最重要的价值不是省事而是让“技能”这个概念的边界在物理层面变得极其清晰——一个技能就是一个文件夹所有相关的东西都在里面。2. 核心细节解析与实操要点2.1 技能描述文件的结构设计如果你准备动手搭建技能描述文件我习惯命名为SKILL.md是第一个要死磕的东西。它的质量直接决定了Agent选技能的正确率。我在实践中打磨出的结构是五段式--- name: user_feedback_classifier version: 1.2.0 description: 将原始用户反馈文本分类为正向/负向/中性并提取核心诉求关键词。 当需要分析用户情绪、评估服务满意度、从大量反馈中筛选负面案例时使用。 如果输入不是文本反馈或需要多轮对话交互不要使用本技能。 --- ## Input - 原始反馈文本字符串 ## Output - JSON对象fields: sentiment, keywords ## Examples Input: 你们的快递等了一个星期才到太慢了 Output: {sentiment: negative, keywords: [物流慢, 配送时效]}看着简单里面的门道不少。description字段的语气要写“命令式”而不是“说明式”。“请使用”“可以考虑”这类措辞会让模型在犹豫的时候倾向跳过技能调用。直接写“当...时使用”比写“本技能可以用于...”效果稳得多。还有一点容易被忽略——负面提示一定要写。告诉模型“什么时候不要用”能过滤掉大量错误调用。我试过不写负面提示结果在分类技能上模型把闲聊的句子也丢进来跑一遍白白浪费调用次数。Examples字段是隐含的“锦囊”你不能只给一个。我给每个技能至少配置三个不同形态的输入输出对模型在Few-shot场景下的准确率会有肉眼可见的提升。实测数据一个情绪分类技能只有一个示例时准确率约78%补到三个示例后稳定在86%以上。2.2 技能内部分层入口、校验、执行、回传很多人第一次设计技能代码时会把所有逻辑写在一个函数里表面上“简单直接”最后调试的时候欲哭无泪。我强烈推荐把技能内部拆成四层哪怕技能逻辑再简单也保持这套结构入口层entry统一接收外部传入的参数做一层格式清洗把各种可能的调用方式JSON、命令行参数、函数调用归一化成内部标准字典。校验层validate检查必填参数是否存在、类型是否正确、数值范围是否合理。校验失败要抛出明确的错误码不要只返回“参数错误”四个字。执行层run真正干活的代码。这一层保持纯粹不掺入与任务无关的逻辑。回传层response把执行结果整理成标准格式附带执行时长、状态码、日志信息。这些元数据是后续编排判断是否重试的依据。四层结构看起来多写了很多“废话”代码但是复用性极佳。入口层和回传层往往可以抽象成公共基类。我在项目中做了一个BaseSkill类新技能只需继承并实现validate和run两个方法其余全部复用。一个熟练的工程师写一个新技能从建目录到注册完成基本能控制在半小时以内。2.3 技能结果的统一规范这是整个项目中最“不起眼”却是最关键的一个环节——输出规范。刚开始我允许各个技能自由定义返回结构结果在技能B使用技能A的输出时要做一堆兼容处理痛苦不堪。后来我强制统一了输出结构{ status: success, code: 0, data: {}, meta: { skill_name: user_feedback_classifier, start_time: 1735000000, elapsed_ms: 128, retry_count: 0 }, error: null }这个规范一旦定下来后续所有编排逻辑都轻松了。status字段决定是否走重试或降级链路data里的内容可以做校验后再决定能否直接传给下一个技能meta里的耗时数据还能用于后续的性能分析。关于输入输出格式还有一个词要提醒尽量用JSON不要用纯文本。模型解析纯文本的容错率太低了。有一次我图省事让一个技能返回“成功”或“失败”两个字符串结果模型在某些边缘场景下把它理解成了“success”或“fail”后面环节的匹配逻辑直接失效。统一用JSON结构这个问题就消失了。3. 实操过程与核心环节实现3.1 搭建最小技能运行环境下面进入实战环节。我以Python为例演示一个最小可运行的agent-skills环境。先建目录结构agent_skills/ ├── core/ │ ├── __init__.py │ ├── base.py # BaseSkill基类 │ ├── registry.py # 技能注册与发现 │ └── executor.py # 技能执行器 ├── skills/ │ ├── text_summarizer/ │ │ ├── SKILL.md │ │ └── skill.py │ └── sentiment_analyzer/ │ ├── SKILL.md │ └── skill.py ├── run.py # 启动入口 └── requirements.txtcore/base.py里定义技能基类。这里贴一个精简版实际项目中我会把日志和异常处理做得更细# core/base.py import json import time from abc import ABC, abstractmethod from typing import Any, Dict class BaseSkill(ABC): name: str version: str 1.0.0 def __init__(self, raw_input: Dict[str, Any]): self.raw_input raw_input self._standard_input {} abstractmethod def validate(self, params: Dict[str, Any]) - Dict[str, Any]: 校验并清洗参数返回标准字典 pass abstractmethod def run(self, params: Dict[str, Any]) - Any: 核心执行逻辑 pass def execute(self) - Dict[str, Any]: start time.time() try: self._standard_input self.validate(self.raw_input) result self.run(self._standard_input) return { status: success, code: 0, data: result, meta: { skill_name: self.name, version: self.version, start_time: start, elapsed_ms: int((time.time() - start) * 1000), retry_count: 0, }, error: None, } except Exception as e: return { status: failed, code: -1, data: None, meta: { skill_name: self.name, version: self.version, start_time: start, elapsed_ms: int((time.time() - start) * 1000), retry_count: 0, }, error: {type: type(e).__name__, message: str(e)}, }core/registry.py负责扫描skills/目录下的所有技能。核心逻辑是读取每个子目录下的SKILL.md解析YAML头部的name和description把元信息注册到内存字典中。这里分享一个坑技能目录名、yaml里的name、代码类名三者尽量全部一致。我一开始没注意结果导致日志里显示的名称和实际调起的类对不上排查问题要多花不少时间。3.2 多技能编排让模型做决策的完整链路技能准备好了还需要一个“大脑”来调度。我采用的是经典的LLM循环模式核心流程是用户请求 - 任务分解 - 选择技能 - 构造参数 - 技能执行 - 观察结果 - 判断任务是否完成重点在于任务分解这一步。我设计了一个AgentLoop类它维护当前任务状态每次迭代做以下几个动作把用户请求、历史对话、当前可用的技能列表只放名称和两行描述组合成一次Prompt。让LLM输出一段JSON格式的“计划”内容包含skill_name、params、reason三个字段。代码解析这段JSON调用对应技能。把执行结果追加到对话历史中再次交给LLM让它判断是继续执行还是终止。有个参数很关键——技能列表可见长度。当系统里注册了几十个技能后全量塞给模型不现实我会在注册阶段给每个技能打标签比如text_process、data_fetch、file_op先让模型判断当前阶段需要哪一类标签再把对应子集展开。这个“二级筛选”能把候选技能从几十个压缩到五六个决策精度明显提升。实操时还要注意Prompt里给模型看的技能描述务必提炼成一句话。SKILL.md里的详细内容不要直接灌给模型。比如可选技能 - text_summarizer: 把长文本压缩为要点摘要输入文本输出列表。 - sentiment_analyzer: 判断文本情感极性返回JSON。这样模型不会陷在细节里选技能的速度和准确率都更好。3.3 技能复合编排支持条件分支和循环单一技能调用只是地基真实任务往往是“先做A然后根据A的结果决定做B还是C”。我在agent-skills里实现了两种简单高效的复合编排方式顺序编排和条件编排。顺序编排实现起来最简单在同一个AgentLoop里连续下达多个指令每次指令指定一个技能。有一次用户需求是“把最新的销售报告发到钉钉群”。拆分后就是两个技能file_reader读取报告文件im_sender发送到群聊。我在循环里预先定义好了一个执行顺序模板让LLM按照模板依次执行出错的概率比完全自由发挥低得多。条件编排稍微复杂一点我用的是“技能返回值参与决策”的方式。例如需求是“从Excel里找出销量低于目标的商品发邮件给销售负责人”。流程是Skill A: excel_reader 读取商品表 - 得到商品清单 Skill B: sales_filter 过滤低销量商品 - 得到目标商品列表 Skill C: mail_sender 发送邮件其中B和C之间有个隐式约束如果清单为空不应该发邮件。我把这个约束放在B技能的输出校验里——当data为空数组时返回一个特殊的status: emptyAgentLoop收到这个状态后会直接终止后续技能调用反馈给用户“没有需要处理的商品”。这个设计让我避免了无数次“明明没有异常却把空邮件发出去”的尴尬。3.4 执行中间件的妙用技能系统做厚之后我开始加入中间件middleware机制灵感来源于Web框架的设计。现在每个技能的execute流程是这样的raw_input - middleware_chain(校验、日志、限流、统计) - BaseSkill.execute() - middleware_chain(响应格式化) - final_output中间件解决了一个实际问题很多横切需求不需要每个技能各自实现。比如权限控制、调用频控、参数脱敏、耗时统计这些逻辑放在中间件里可以统一处理。我在系统里做了一个TimeCostMiddleware只做一件事——在请求进入和响应返回时记录时间戳汇入全局指标。上线半个月后看数据发现file_reader这个技能平均耗时是其他技能的5倍顺着这条线索优化了文件读取策略整体性能直接翻倍。如果你也准备加中间件请务必遵循“职责单一”原则。一个中间件只干一件事否则调试的时候多个中间件互相影响排查成本极高。我踩过一次坑把日志中间件和流量限制中间件写在了一起流量超限时日志记录也挂了问题定位花了一个下午。4. 常见问题与排查技巧实录4.1 困难之一技能明明注册了但LLM就是不用这是最常见的问题。排查思路从三个层面展开第一层确认技能描述与模型对场景的认知方式匹配。有一次我注册了一个regex_extractor技能描述里大谈正则规则的强大但模型在遇到“从文本里提取日期”的需求时它觉得用代码技能就够了直接自己生成了正则表达式。后来我把描述改成“当需要从非结构化文本中抽取标准格式数据时使用”模型的调用率立马上来了。第二层检查历史输出里模型是否曾经“意图调用但参数构造错误”。很多时候模型选了技能但参数写错了在用户视角就是“技能没用”。这个问题的根源在于SKILL.md里的参数说明不够详细尤其是“边界条件”。比如时间范围参数必须写明“采用Unix时间戳”否则模型会给你传“2024-12-01 12:00:00”这种字符串。参数格式写清楚后类似的问题减少了大半。第三层考虑候选技能之间的“语义打架”。两个技能描述过于接近时模型会随机选一个看起来就像是“某个技能不稳定”。解决方式是给描述增加明确的差异化信息。我处理过image_resize和image_compress两个技能的冲突最后在描述里分别加上“调整尺寸常用于头像裁剪压缩常用于减小文件体积”模型就分得很精准。4.2 困难之二多技能串联时的“鸡同鸭讲”技能A输出的是一个Python列表技能B期望的输入是字符串数组技能A输出的字段名是phone_number技能B认为应该叫mobile。这种问题在我项目中反复出现。根源在于每个技能的开发者习惯不同。我的解决办法是建了一份数据契约文档把所有技能之间可能传递的通用数据实体统一命名和类型。比如“用户手机号”这个字段全局统一叫user_phone类型为string。“商品销量”统一叫sales_count类型为int。任何新技能在设计输入输出时先查这份文档有的字段必须复用不允许另起炉灶。如果在串联链路上仍然发现格式不对我会在AgentLoop里加一个轻量的“字段映射”步骤——在执行下一个技能前用一个小模型将前序输出转换成目标技能需要的输入结构。注意这一步要加在“参数构造”时做不要侵入技能内部。4.3 困难之三技能执行失败后如何优雅降级真实环境里技能执行失败是常态。最怕的是一失败整个任务就停摆。我在系统里定了三种处理策略直接重试适用于网络抖动、临时性的第三方API超时。重试三次间隔递增。降级替换如果某个技能挂了尝试调用功能相似的备用技能。比如gpt4_text_summarizer挂了自动切换到local_model_summarizer虽然效果差点但流程不中断。部分成功当任务拆分成多个步骤后面的步骤失败时保留前面已经完成的结果把失败信息作为“部分失败”返回给用户。这三种策略的实现都离不开前面说的统一返回结构。status字段为failed时AgentLoop会根据错误类型和重试次数自动决定走哪条路径。实测下来加了降级策略之后长任务的最终成功率从74%提升到了92%。4.4 常见问题速查表现象直接原因解决动作技能被注册但从未被调用SKILL.md描述与用户需求匹配度低精简描述、增强触发条件、加负面提示模型选了技能但参数一次次出错参数说明不完整、类型不明确在SKILL.md中为每个参数写类型、范围、示例值串联流程总是中间断裂下游技能无法解析上游输出结构统一数据契约文档必要时加字段映射层技能执行很慢大量同步IO、缺少缓存加缓存中间件、切换异步执行技能逻辑没问题但结果错误输入参数在校验层被静默修改校验层只做检查不主动改值或记录修改日志多技能同时运行时内存溢出技能加载方式过重改为按需加载执行完释放资源5. 实践心得与进阶建议5.1 从零到一落地的最小路径如果你准备在团队内搭建类似系统我建议按下面的顺序推进不要一上来就追求完美。第一步先把技能注册和动态扫描做出来。这步工作量不大但能让你直观感受“技能目录化”带来的便利。第二步只写两三个技能比如文件读取、API调用、常用文本处理。第三步接入LLM循环让模型可以自主调用这一小撮技能。跑通这个最小闭环后再逐步补充中间件、编排逻辑、降级策略。很多人一开始就设计“技能市场”“技能版本控制”“技能自动化测试”这些以后可以做但初期会严重拖慢进度。先把链路跑通再谈工程化。这也是为什么我在项目早期版本里只保留了最必要的三个中间件日志、耗时统计、异常捕获。5.2 关于技能设计的几条原则经过几个月的实战我提炼出几条“踩坑换来的原则”分享给你原则一技能的粒度宁可细不要粗。我曾经把“读取文件并解析内容提取关键信息”做成了一个技能看起来效率高实际上稍微换一种文件格式整个技能就废了。拆成file_reader、content_parser、info_extractor三个技能后每个技能的适用范围更清晰复用率也高得多。原则二技能描述不是越详细越好。描述的核心是“让模型在恰当的时机调用它”而不是“让模型了解它的实现细节”。如果一个技能描述超过300字很可能是因为你把不该写的东西写进去了。我通常会把实现细节放在代码注释里描述文件的description只保留触发条件和输入输出概要。原则三先跑通再优化。技能系统的调优最好是“线上真实数据驱动”。与其在开发环境反复测试不如让Agent在保护模式下跑真实任务把每一次技能调用的决策记录、执行结果、耗时全部落盘然后定期复盘。我从这些日志里发现最多的三类问题是描述歧义、参数边界不清、技能选择冲突。5.3 后续可以怎么扩展这个系统的边界其实很宽。目前我在探索的方向包括给技能加上“学习”能力——当某个技能经常失败时自动修改描述或参数示例技能的“市场”化——不同团队贡献自己的技能包通过独立的技能管理API互相交流以及基于历史调用数据优化模型决策的Prompt模板。有一点我得坦白说写技能代码的时候最大的工作量其实不在写功能本身而是在“让另一个模型能准确理解并正确使用这个功能”。这一点如果处理好了整个Agent系统的体验会顺滑很多如果处理不好再强的模型也会被粗糙的技能描述拖后腿。如果你现在正准备构建自己的Agent技能库我的建议是从一个真实的业务场景切入选三五个最常用的操作做成技能跑通一整个任务链路再回头来打磨描述和编排策略。毕竟技能系统只有被实际调用、真实反馈才能不断变得好用。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →