LLM Agent技能化重构:从工具乱炖到可编排能力层 上个月我在重构一个agent项目时遇到一个非常典型的症状同一个操作模型时不时就选错工具参数填得牛头不对马嘴多轮对话里来回纠正却依然不稳定。后来我彻底把“工具层”推翻改用了一套类似agent-skills的“技能化”组织方式问题才真正扭转。这篇文章就是把我这次从混乱到清晰的完整过程记录下来包括我如何定义技能、如何让模型自己选对技能、如何把多个技能编排成一条工作流以及中间踩过的一堆坑。它适合正在做LLM Agent应用、被多工具调用准确性折磨、或者准备给自己的项目设计一套可扩展能力层的开发者照着这套思路走能少走很多弯路。1. 从“工具乱炖”到“技能体系”我为什么推翻了原来的执行层1.1 最初的实现到底问题出在哪最早写agent时我用的是市面上最常见的做法定义一堆函数每轮对话把全部函数声明塞给大模型让模型根据函数名、参数描述决定调用谁。表面上看起来很灵活实际上当函数数量超过20个后问题开始集中爆发。第一个问题是上下文被大量工具描述占满。每个函数如果完整声明包含函数名、参数类型、参数说明、返回值说明加起来几百甚至上千token是很正常的事。几十个函数一轮请求下来光工具定义就可能占据3K到5K token留给真实对话和上下文推理的空间被压缩得很严重成本也跟着涨。第二个问题是相似语义下的选择困难。我有两个函数都涉及“读取日志内容”一个按关键词过滤一个按时间范围提取。在模型眼里这两者的边界非常模糊它经常把时间范围参数填错或者选错函数导致下游数据完全不对。我在测试阶段很崩溃一度怀疑是不是模型不够聪明后来才想明白——问题出在我给模型展示能力的方式太原始了。第三个问题更隐蔽这些函数彼此之间没有关系模型只能“一次性选择并调用”无法把一个函数的输出自然传递给另一个函数来组合完成复杂任务。只能靠我在业务代码里硬编码流程agent变得非常死板。1.2 像人一样组织技能而不是像API一样暴露函数后来我研究了一些开源项目包括agent-skills这类方案的思路最触动我的一点是模型对于“技能”的理解方式本质上应该更像人学习一项技能——它需要知道“这个技能是干什么的”“在什么场景下用”“具体怎么操作”“需要什么前置条件”而不是只收到一张函数签名。这就带来两个核心转变。第一把每个函数改造成“自包含”的技能单元。每个技能除了有可执行的代码还要有完整的元信息技能名称、用途描述、适用场景、参数说明、输出说明、依赖关系、可能的风险提示。名字上还是函数本质上已经变成了一个小型服务。第二引入“技能注册与选择”机制。模型不再每一轮被灌输所有能力而是先由一个轻量的路由层根据用户请求筛选出候选技能再把候选技能的描述交给模型让模型决定最终调用哪个。相当于先靠规则快速缩小范围再用模型做精确判断准确率提升非常明显。我把这套思路落地成一个叫skillhub的模块当然这名字不重要重要的是它的抽象结构帮我把整个项目的可维护性抬了一个台阶。这也是为什么我愿意专门写一篇长文来复盘它。2. 技能在代码里到底长什么样协议、元数据与执行器2.1 一个技能的最小完整结构先给大家看一个最简单的技能定义。我用了Python Pydantic来规范输入输出你也可以根据自己项目用的语言换掉但结构基本是通用的。from pydantic import BaseModel, Field from typing import Dict, Any class SkillParameter(BaseModel): name: str type: str string description: str required: bool True default: Any None class SkillDefinition(BaseModel): 核心技能定义 name: str description: str scenarios: list[str] [] parameters: list[SkillParameter] output_schema: Dict[str, Any] {} executor: str # 对应注册表里的执行器标识 llm_hint: str # 给模型看的补充提示 def to_prompt_block(self) - str: 转换成模型容易理解的自然语言描述 lines [ f技能名称: {self.name}, f用途: {self.description}, ] if self.scenarios: lines.append(f适用场景: .join(self.scenarios)) for p in self.parameters: required_note (必填) if p.required else f(可选默认 {p.default}) lines.append(f参数 {p.name} ({p.type}) {required_note}: {p.description}) if self.llm_hint: lines.append(f使用提示: {self.llm_hint}) return \n.join(lines)这个结构里name是给程序用的唯一标识description是给模型看的第一印象scenarios是辅助路由层的关键词索引parameters则是模型填参时的对照表。重点在llm_hint它可以写入文档里很难涵盖的经验信息比如“这个技能对超过100MB的日志文件会截断需要提醒用户”“如果返回结果为空建议改用search_logs_v2技能”等等。2.2 为什么“给模型的说明书”比函数签名重要一百倍我在踩过大量坑之后有一个非常深的体会大模型调用工具本质上是“阅读理解任务”而不是“函数匹配任务”。它先理解用户的自然语言再看你的工具描述然后判断两者是否匹配。如果你的工具描述只是干巴巴的“读取日志”对模型来说几乎等于没有信息。我举个例子。同样一个功能两种描述方式描述A读取日志文件。 描述B读取本地或远程日志文件支持按时间范围、日志级别、关键字过滤。当用户提到最近1小时、错误日志、查询日志详情时使用。若日志过大自动只返回前100条并在返回结果中附带truncated标记。我实测下来模型在B类描述下的选择准确率接近95%在A类描述下只有60%左右。差距就是这么大。因为B类描述做了三件事说清楚功能边界、给出常见的触发词、预告异常行为。模型在看到用户说“看看最近的报错日志”时能很自然地把“报错日志”和“错误级别过滤”“最近1小时”“日志详情”这些字眼映射起来。2.3 把任意业务函数包装成技能有了协议之后剩下的事情就是写适配层。我的做法是把老代码包一层SkillAdapter对外暴露统一的执行入口。class BaseSkill: def get_definition(self) - SkillDefinition: raise NotImplementedError async def execute(self, params: Dict[str, Any]) - Dict[str, Any]: 执行具体逻辑入参是经过校验的参数字典 raise NotImplementedError每个技能只需要继承BaseSkill实现定义和执行两个方法。业务代码完全不需要关心模型调用细节。我刚开始把项目里30多个函数全部按这个协议重写了一遍大概花了三天时间但后续每一次新增能力都变得非常简单。这里有个小技巧参数校验一定要在execute之前完成。我使用的是Pydantic的模型把SkillDefinition里面的parameters动态构造成一个参数校验类执行前统一校验、统一报错。否则模型填了一个错类型参数错误会在业务代码内爆出来很难溯源。def build_validate_model(skill_def: SkillDefinition): fields {} for p in skill_def.parameters: if p.type integer: python_type int elif p.type number: python_type float else: python_type str fields[p.name] (python_type, Field(default..., descriptionp.description)) return create_model(f{skill_def.name}Params, **fields)上面的代码只是示意实际可以用Pydantic的create_model动态建类。这样做的好处是当模型漏传必填参数时我们能拿到清晰的参数错误提示而不是一个莫名其妙的业务异常。3. 技能注册表与路由层让agent自己选出“对的那把钥匙”3.1 为什么需要在模型前面加一层“粗筛”最开始我把全部技能注释一股脑写入system prompt后来发现技能一旦超过30个模型就会开始“眼花缭乱”——它其实能大概猜出该用哪个但由于候选太多它容易被相似描述干扰。尤其是多个技能描述里都含有“日志”两个字时模型会犹豫甚至误选。于是我决定在模型判断之前先加一个轻量级“技能检索器”。它的作用不是代替模型做最终决策而是把候选技能列表从几十个缩小到三到五个。缩小范围之后模型的选择难度大幅降低准确率自然就上来了。这个检索器不需要用复杂的向量数据库早期甚至一套关键词规则就够了。我的实现逻辑是把用户最近一轮的请求文本和技能描述里的scenarios做关键词匹配再对技能描述做子串匹配比如用户说“读取”和“日志”那么包含这两个词且相关性高的技能进入候选同时记录技能的调用历史最近使用过的技能加权优先最终返回topN个候选技能定义。如果项目语义需求更复杂还可以接embedding做语义召回但先用关键词规则能把大量简单场景cover掉这个优化性价比极高。3.2 路由决策当技能候选进入模型视野之后候选技能选定后我把它们的to_prompt_block拼接在一起再加上用户请求让模型输出一个结构化的调用计划格式类似这样{ skills: [ { name: search_logs_v2, reason: 用户想查看最近一小时的错误日志符合该技能的适用场景, params: { time_range: 1h, level: ERROR } } ] }要用JSON格式输出方便我后续解析。我用的是OpenAI兼容接口直接在请求里设置response_format为json_object如果你用的模型不稳定也可以让模型先输出一句话“我要使用哪个技能”再单独解析但实测下来json_object模式效果最好。这里有一个值得注意的细节模型输出的参数往往不精确尤其是时间、数字这类值。比如用户说“刚才”“几分钟前”模型可能直接输出“几分钟前”这种没法解析的字符串。我的做法是再加一层“参数清洗器”——接收模型输出参数把它交给一个专门用于参数规范化的prompt把模糊表达转成精确值再交给execute执行。比如“几分钟前”被转成具体的UTC时间戳这个步骤大幅减少了因参数不规范导致的执行失败。3.3 对比评测这一层带来多少真实收益我整理了一份我项目中的实测对比。同一个测试集包含60条真实用户请求覆盖日志查询、监控告警、报表生成、配置变更五类场景统计一下方案技能选择准确率参数完全正确率平均多轮纠正次数每请求工具描述token全量函数混在tool里61.7%43.3%2.4次约4200技能化但无粗筛76.7%61.7%1.3次约2300技能化关键词粗筛模型路由93.3%85.0%0.4次约1100数据可能不算惊艳但它让我看到了三个直接收益一是选择准确率上去了二是参数正确率大幅提升三是token成本直接下降了七成不止。因为只需要把候选技能的描述传给模型而不是把整个技能库塞进去。4. 组合技能让单个原子操作变成一条可执行的工作流4.1 从“调用一个技能”到“编排多个技能”当技能都被定义成标准接口后自然的下一步就是让它们互相调用。这个想法在我原来的工具堆里完全没法实现因为函数之间只有硬编码的数据流没有任何统一协议。但技能化之后一切变得很简单。比如我有一个需求“检查系统状态并生成日报”。拆解下来至少需要三步检查CPU、内存、磁盘的使用率查询过去24小时的告警列表汇总成一份日报并发送到即时通讯群里。这三步分别对应三个技能collect_system_metrics、query_alerts、send_im_message。如果每个技能都只能被单轮独立调用那么agent需要分三次和用户确认体验很差。如果我把它们组合成一个“日报生成”工作流一次性完成还会自动在最后发消息用户基本上只需要一句话触发。4.2 用编排器让agent自己写出“技能脚本”实现组合的简单思路是不要直接在程序里写死调用顺序而是让模型基于技能定义生成一个“技能调用脚本”再由本地编排器解释执行。脚本本质上是JSON数组描述每一步调用什么技能、传什么参数、怎么处理上一步的输出。我给一个典型的顺序执行示例[ { skill: collect_system_metrics, output_var: metrics }, { skill: query_alerts, params: { time_range: 24h }, output_var: alerts }, { skill: gen_daily_report, params: { metrics: ${metrics}, alerts: ${alerts} }, output_var: report }, { skill: send_im_message, params: { content: ${report} } } ]这个脚本看起来简单但落地时有两个大坑。第一个是变量引用。我用${variable_name}的语法表示上一个步骤的输出编排器在执行到具体技能前会把脚本中的变量占位符替换成对应的真实值。这个替换必须发生在参数清洗之后否则模型可能把${metrics}当成字符串传进去。第二个是错误处理。任何一个步骤失败整个工作流怎么办我的方案是每个技能执行结果都有一个status字段表示success、failed、retryable。编排器遇到retryable会按策略重试两次比如网络抖动导致的瞬时失败遇到failed则停止后续步骤并返回一个语义化的错误汇总给用户说明哪一步失败、具体原因是什么。这样即便日报没生成成功用户也知道是发消息环节挂了而不是把错误吞掉。4.3 并发与条件分支复杂编排的两种常见模式顺序执行能解决70%的场景但还有两种模式经常用到。一种是“并行扇出”。比如我需要同时检查多个服务器的健康状态每个服务器都调用collect_node_health但参数不同。顺序执行会串行耗时完全可以一次性生成多个调用项同时发出请求然后再汇总。编排器里我用asyncio.gather的方式做并发调度注意控制并发上限避免一次性打爆被监控的服务。另一种是“条件分支”。比如“如果磁盘使用率超过80%则告警并执行清理脚本否则只记录日志”。这种场景模型在生成脚本时可以输入一个if_condition字段编排器执行到这一步时会先判断条件再决定走哪个分支。条件本身可以是用户传入的参数也可以是上一个技能返回的某个字段。目前我的编排器支持这三种模式已经能把大部分自动化运维场景跑通。这里不展开每一行代码但可以说一个原则不要试图把业务逻辑全部装进编排器编排器只负责“调度”业务逻辑留在各个技能内部这样职责才清晰。5. 落地过程中最隐蔽的几个坑和我的最终取舍5.1 技能描述越长越好错描述精度比长度重要我最初写技能描述时总觉得给模型的信息越多越不容易出错结果发现描述过长导致关键信息被淹没。比如一个技能description写了五百字把背景、原理、参数演进史都写进去了模型反而抓不住重点。后来我定的规则是第一句话必须是“触发该技能的典型场景”第二句话是“该技能做什么”第三句话是“和相似技能的区别”。剩下的细节全部放进llm_hint并且llm_hint不需要每次都给模型看可以在模型选择之后再嵌入到执行上下文中从而进一步减少prompt token。这个细节帮我解决了一个典型冲突check_log_file和search_log_keyword两个技能第一个人称摇号看了半天第二个人称过滤器。以前模型经常混淆现在两个技能的第一句话分别写成“当你需要查看单个日志文件的完整内容时”“当你需要在大日志库中按关键字搜索并统计时”模型基本不再选错。5.2 动态加载技能时容易出现的“幽灵缓存”技能多了之后我需要支持动态加载也就是项目运行期间新增技能不用重启服务。实现方式是把技能定义和执行代码放在特定的目录下通过importlib在运行时加载。这里我踩了一个很深的坑Python的import机制默认会缓存已经加载的模块。当我修改了一个技能执行器的代码文件然后重新加载时importlib.import_module拿到的是旧模块导致新代码永远不生效。表面上项目“支持热更新”实际上改了半天看不到效果。解决办法是加载后强制清除sys.modules里对应的模块缓存再重新导入。更稳妥的是给每个技能绑定一个版本号加载时先检查版本是否变化变化才重新导入。我在技能注册表里存储了技能文件的mtime和hash每次请求触发回调时快速核对hash几乎零开销但彻底解决了“改了不生效”这类问题。import hashlib import sys import importlib def load_skill_module(module_path: str, skill_name: str): with open(module_path, rb) as f: file_hash hashlib.md5(f.read()).hexdigest() if skill_name in loaded_skills and loaded_skills[skill_name][hash] file_hash: return loaded_skills[skill_name][module] sys.modules.pop(skill_name, None) spec importlib.util.spec_from_file_location(skill_name, module_path) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) loaded_skills[skill_name] {module: module, hash: file_hash} return module5.3 什么技能不该开放给agent自动执行这一点我觉得必须单独提醒。技能化之后agent的能力边界变得更清晰但容易让人产生“什么都能自动化”的错觉。我在项目里给每个技能增加了一个access_level字段分成observer、operator、admin三个等级。observer级别的技能只能读取数据比如查询指标、查日志operator级别的技能可以执行变更比如重启服务、清理文件但需要二次确认admin级别的技能涉及核心配置和权限变更我规定即使agent请求了也必须走人工审批通道。实战中发现模型的意图判断偶尔会“越权”比如用户说“帮我查一下配置文件”模型可能会想用编辑器技能去打开文件。如果编辑器被标成operator级别编排器会在执行前拦截并请求用户确认这样既保留了自动化的便利性又不至于让agent在无人监督时做出危险操作。5.4 版本化与回滚机制我建议从第一天就开始做技能迭代速度快今天改一个参数名明天改一个执行逻辑如果没有版本管理隔一个月再回来看根本不知道哪个技能对应哪个版本。我的做法是skillhub里不只存放当前技能定义而是维护一个完整历史列表每次技能变更都会生成新版本并在注册表里记录变更时间、变更说明、变更前后的定义。执行日志里也会记录技能版本号。一旦用户反馈“这个功能以前是好的现在不对”我可以瞬间定位到具体版本的改动快速回退到上一个版本。这个机制看起来成本高实际上实现起来就是一个以技能名为目录、以版本号为文件名的存档结构。每次更新时把旧的SkillDefinition序列化存一份再写一份CHANGELOG。配合git整个过程不超过半小时就能搭好但它给后续维护省下的时间却远远超过这一点投入。我从这个项目里学到的最重要一件事是给agent设计能力层本质上不是写几个函数而是设计一套让模型更容易理解、让工程更容易治理的协议。agent-skills这类思路好的地方不是它有多炫而是它把“模型选择工具”这件事变成了一套可维护的工程实践。如果你也在被模型选错工具、参数填错、上下文爆炸、能力难以扩展这些问题困扰不妨也试一下这种技能化重构——不要一上来就上很重的框架先用一个轻量的注册表和路由层跑通后面再按需加组合、版本管理这些能力你会看到稳定的效果。