
最近我在做一个智能体Agent项目时被工具函数的维护问题搞得有点头疼。每次要给机器人加一个新能力比如查天气、算算术、读 CSV都得在代码里新写一个函数然后在系统提示词里把函数描述粘一遍再处理参数映射。一套流程走下来代码越来越像“意大利面条”新同事接手也一脸懵。后来我把这套逻辑抽出来写成了一个叫 agent-skills 的轻量级技能管理库专门用来把 Agent 能做的事拆成一个个独立的“技能”Skill按目录放好、声明清楚、自动注册让大模型在需要时自己选择调用。如果你也在开发 AI 助手、自动化机器人或知识库问答系统并且受够了把工具函数写得乱七八糟那这篇分享会很适合你。agent-skills 的核心思路并不复杂把“给模型准备技能”这件事从写代码变成写配置。你只需要创建一个技能文件夹里面放一个 YAML 描述文件和一段 Python 函数剩下的加载、校验、提取参数、超时处理全部由框架自动完成。这样一来技能可以像积木一样自由组合也方便团队里非算法岗的同事一起贡献新能力。今天我打算从设计思路、核心机制、实操落地和踩坑记录四个方面把整个项目从头到尾拆给你看。1. 为什么需要 agent-skills智能体技能管理的痛点1.1 智能体应用里的技能到底指什么先对齐一下概念。在智能体语境里“技能”一般指模型自身之外可以被调用的外部能力比如搜索网页、调用计算器、操作数据库、发送邮件、解析文档甚至跑一段 Python 脚本。你可以把大模型想象成一个聪明的调度员它擅长理解人类意图、拆解步骤、生成文本但它不会真的去查数据库也不会真的去发送 HTTP 请求。所谓“技能”就是给这个调度员配备的“专业助手”调度员说“帮我查一下今天北京的天气”助手就去调用天气接口把结果拿回来再整理成自然语言。在很多 RAG 或者 Agent 项目里这些技能经常以“函数调用Function Calling”的形式存在。模型提供参数程序负责调用函数再把返回值交还给模型。这个过程听起来简单但实际做起来有很多细碎的问题函数描述怎么组织、参数类型怎么校验、技能状态怎么隔离、调用失败怎么重试。agent-skills 想做的就是把这一大坨细节统一收口让开发者只关心“这个技能要完成什么任务”而不是“怎么挂进 Agent”。1.2 技能散落在代码里维护成本越来越高我最早的做法是在 Agent 主程序里集中写一批函数然后手工构造一个 functions 列表传给大模型接口。一开始只有两三个函数感觉还挺好。等到功能慢慢变多比如加了日历、邮件、Excel 操作、企微通知之后问题就来了函数描述、参数说明和实际函数体散落在同一个文件的不同位置改参数时经常漏改描述。不同 Agent比如客服机器人和数据分析机器人需要不同的技能集合只能靠复制粘贴来隔离改一处要同步好几个文件。新加一个功能需要同时改动系统提示词、函数定义和业务逻辑动一处可能引出别的 bug。函数里如果有状态比如用户登录态、临时缓存并发跑多个请求时很容易相互污染。这些痛点的本质是技能和 Agent 主程序耦合太紧。一旦技能超过十个靠手写函数列表来维护基本等于走钢丝。所以我开始寻找一种更“插件化”的方式希望技能本身就是独立的、可以无缝插拔的模块。这也是 agent-skills 最早的出发点。1.3 设计目标插件化、声明式、可视化可追踪有了痛点接下来的问题就是设计目标。我希望这个技能库具备几个特性插件化每个技能放在独立目录具备自描述能力可以被自动发现和加载。声明式技能的名称、用途、参数约束、所需权限都用配置文件声明业务代码只需要写真正执行的动作。可视化可追踪每次技能调用都能生成结构化日志清楚知道模型调用了哪个技能、传入了什么参数、执行结果是什么。安全可控优先级高于便利性技能可以被白名单限制参数必须过校验敏感操作有二次确认。用一句话总结就是让技能变成“插上就能用”的零件而不是和 Agent 主逻辑纠缠不清的胶水代码。这样做的另一个好处是当模型本身升级或者 Agent 编排逻辑调整时技能层不需要大规模改动稳定性会好很多。2. 技能定义与注册机制的核心细节2.1 技能文件长啥样用声明式描述替代硬编码在 agent-skills 里一个技能就是一个目录里面最少包含两个文件一个skill.yaml作为元数据声明一个main.py作为执行入口。举一个最简计算器技能的例子skills/ calculator/ skill.yaml main.pyskill.yaml的内容如下name: calculator description: 执行四则运算用于需要精确计算数字表达式的场景例如加减乘除、取余和幂运算。 version: 1.0.0 author: your_name parameters: - name: expression type: string required: true description: 合法的数学表达式字符串比如 12 * (3 4) / 7。 - name: precision type: integer required: false default: 6 description: 计算结果保留的小数位数范围 0 到 10。对应的main.pyimport math def execute(expression: str, precision: int 6) - dict: # 出于安全考虑这里只允许数学函数和基本运算符 allowed_names {abs: abs, round: round, min: min, max: max, pow: pow, math: math} try: result eval(expression, {__builtins__: {}}, allowed_names) if isinstance(result, float): result round(result, precision) return {status: success, result: result} except Exception as exc: return {status: error, error: str(exc)}你可能已经注意到了框架没有规定execute函数的固定签名只要求它接收关键字参数并且返回一个字典。参数声明里的name会和execute的参数名一一对应这样模型在调用时传{expression: 12 * (3 4) / 7, precision: 2}框架就能自动把这些参数透传给函数。这种约定比强制继承某个基类要灵活得多技能作者甚至不需要 import 框架的任何东西只用一个规范的函数接口就行。2.2 技能注册中心如何工作技能注册中心是 agent-skills 的核心组件它负责在项目启动时扫描指定的技能目录把每个技能的元数据和执行函数加载进内存并建立索引。整个过程分成四步发现、加载、校验、缓存。发现框架递归遍历技能根目录找到所有包含skill.yaml的文件夹。加载读取 YAML 文件同时使用 Python 的importlib动态导入main.py拿到execute函数对象。校验检查name是否唯一、参数声明格式是否正确、execute是否为可调用对象。如果发现重复技能名或缺少必要字段直接抛出异常并给出详细提示。缓存把技能对象放入一个字典键是技能名值是一个包含了描述、参数 schema、执行函数、版本号等信息的Skill实例。下面给一个简化的示例代码方便你理解这个过程import yaml import inspect from pathlib import Path from types import SimpleNamespace class SkillRegistry: def __init__(self, skills_dir): self.skills_dir Path(skills_dir) self.skills {} def discover(self): for skill_path in self.skills_dir.glob(*/skill.yaml): self._load_skill(skill_path.parent) def _load_skill(self, skill_dir): with open(skill_dir / skill.yaml, r, encodingutf-8) as f: meta yaml.safe_load(f) # 动态导入 main.py import importlib.util spec importlib.util.spec_from_file_location( f{meta[name]}_module, skill_dir / main.py ) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) skill SimpleNamespace( namemeta[name], descriptionmeta[description], parametersmeta.get(parameters, []), executegetattr(module, execute), versionmeta.get(version, 0.0.1), ) self.skills[skill.name] skill实际项目里我还会把discover之后的校验逻辑单独拆出来比如检查参数名是否和execute参数匹配、是否缺必填参数等。这样可以把配置错误提前暴露在启动阶段而不是等到模型调用了才发现函数参数对不上。框架设计上有一个原则宁可启动失败也不能运行中掉链子。多花一秒钟做校验能省下后续好几个小时的排查时间。2.3 上下文与生命周期管理技能执行不是简单的“传入参数返回结果”还需要考虑超时、异常、数据清洗和状态隔离。我在实战里把每个技能调用都放进统一的执行上下文管理器它负责将模型传来的参数和技能声明的 JSON Schema 做校验不合法直接拒绝调用。给execute设置超时时间默认 30 秒超过则返回超时错误避免某个技能卡死整个 Agent。捕获函数内部的异常把它格式化成稳定的错误结构返回给模型让模型知道自己调错了参数可以自行修正后重试。清空技能执行过程中的临时状态保证下一次调用是干净的。这里有一个容易忽略的点技能函数如果使用了全局变量或者缓存多个请求并发时可能互相干扰。比如一个技能要在内存里保存“当前用户信息”那 A 用户的数据就可能被 B 用户覆盖。我建议技能内部不要维护任何跨调用的状态如果非要有状态请使用上下文对象传入并在每次执行后自动清理。agent-skills 的注册中心也支持按请求维度创建技能实例而不是共享同一个单例对象这能有效规避状态污染。def execute_with_lifecycle(skill, params, timeout30): # 1. 参数校验 validated validate_params(skill.parameters, params) # 2. 执行并设置超时 with Timeout(timeout): result skill.execute(**validated) # 3. 标准化输出 return normalize_result(result)以上这段是在非常理想情况下的简版实现。其实真正执行时还需要考虑asyncio还是线程池以及模型重试次数。我在项目里默认用线程池执行同步技能异步技能则额外标记is_async: true调度器会走另一条分支。这个细节后面在“常见问题”里还会再提。3. 实操过程从零搭建一个 agent-skills 项目3.1 项目结构和环境准备为了让你能直接把这份经验落地我在这里给出一套可以跑通的基础工程结构你可以在此基础上扩展。my_agent/ agent.py skills/ calculator/ skill.yaml main.py weather/ skill.yaml main.py file_reader/ skill.yaml main.py requirements.txt config.yaml其中agent.py是 Agent 的主入口负责初始化技能注册中心并调用大模型接口。config.yaml里配置大模型 API Key、基础 URL、默认模型名、技能目录位置等。requirements.txt至少要包含pyyaml、openai和requests如果你用别的模型服务商就按实际情况来。准备环境时我强烈建议先建一个虚拟环境别把依赖装到全局 Python 里不然技能依赖冲突时你会非常痛苦。这里给出常用命令python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install pyyaml openai requests如果你的技能需要执行外部命令比如调用ffmpeg处理音视频我建议用子进程而不是直接引用系统命令并且要把可执行文件的路径配置到技能的 YAML 里方便在不同环境迁移。反正记住一点技能应该运行在“有约束的环境”里不要让execute函数随随便便动整台机器。3.2 编写第一个技能计算器与天气查询计算器技能上面已经展示过了我再说一下天气查询。天气技能需要调用外部 API它的价值在于演示技能如何和网络请求、API Key 配置结合起来。这里我选用了一个不需要注册的公开接口仅作示例name: weather description: 查询指定城市的天气情况包括温度、天气状况和风力。 version: 1.0.0 parameters: - name: city type: string required: true description: 城市中文名比如“北京”“上海”“广州”。对应的main.pyimport requests def execute(city: str) - dict: url https://api.open-meteo.com/v1/forecast # 这里简化了地理编码实际项目中建议先查城市转经纬度 geocode_map {北京: (39.9042, 116.4074), 上海: (31.2304, 121.4737), 广州: (23.1291, 113.2644)} if city not in geocode_map: return {status: error, error: f暂不支持该城市: {city}} lat, lon geocode_map[city] params { latitude: lat, longitude: lon, current_weather: true, timezone: Asia/Shanghai, } resp requests.get(url, paramsparams, timeout10) data resp.json() current data[current_weather] return { status: success, city: city, temperature_c: current[temperature], windspeed_kmh: current[windspeed], weathercode: current[weathercode], }这里有个小小的“坑”如果城市不在预设字典里技能会返回一个错误。正确的做法是让技能先判断参数如果无法处理就返回明确错误码而不是只抛异常。大模型看到错误后可以自行决定要不要换一种调用方式或者询问用户。这种“容错式技能”在实际体验中要远远好于那种动不动就抛一个堆栈错误的实现。3.3 把技能接入 LLM Agent技能开发完成后最大的问题就是怎么把技能描述告诉大模型。我采用了 OpenAI 兼容的 Function Calling 模式下最自然的路径把技能注册中心里的每个技能都转换成一个 JSON Schema拼到tools参数里。模型决定调用某个技能后会返回一个tool_calls数组程序再根据其中的function.name找到对应技能并执行。下面是一个简化的agent.py片段import json import openai import yaml from skill_registry import SkillRegistry # 1. 加载配置 with open(config.yaml, r, encodingutf-8) as f: config yaml.safe_load(f) client openai.OpenAI(api_keyconfig[openai_api_key], base_urlconfig.get(openai_base_url)) registry SkillRegistry(config[skills_dir]) registry.discover() # 2. 将技能描述转换成 tools 列表 tools [] for skill in registry.skills.values(): tools.append({ type: function, function: { name: skill.name, description: skill.description, parameters: { type: object, properties: { p[name]: { type: p[type], description: p[description], } for p in skill.parameters }, required: [p[name] for p in skill.parameters if p.get(required)], }, }, }) messages [{role: user, content: 北京今天多少度}] # 3. 第一轮调用模型可能会返回 tool_calls resp client.chat.completions.create( modelconfig[model], messagesmessages, toolstools, tool_choiceauto, ) for call in resp.choices[0].message.tool_calls or []: skill_name call.function.name arguments json.loads(call.function.arguments) skill registry.skills.get(skill_name) result skill.execute(**arguments) # 把工具结果追加回 messages再调用第二轮 messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse), }) messages.append(resp.choices[0].message) final_resp client.chat.completions.create( modelconfig[model], messagesmessages, toolstools, ) print(final_resp.choices[0].message.content)注意我这里是刻意把skill.execute直接调用了。真实项目里应该走注册中心的execute_with_lifecycle因为直接调用绕过了参数校验和超时控制。你只需要把框架内部那个调度方法暴露成公共 API并在这里替换即可。3.4 置信度与禁用策略功能跑通以后紧接着要考虑的不是“再加几个技能”而是“怎么防止模型乱用技能”。技能越多模型选错的概率就越高。我经验上有几类策略白名单模式在config.yaml里配置enabled_skills只允许特定 Agent 加载指定技能集合。比如数据分析机器人只加载file_reader和calculator不加载email_sender。描述优化技能描述写得越具体、边界越清晰模型选错的概率越低。比如不要写“处理文件”要写“读取本地 CSV/Excel 文件内容解析为一个表格结构”。参数约束在 YAML 里增加enum或者pattern约束参数的取值范围。比如邮件技能里action字段只能取send_draft或send_now能挡住相当一部分无效调用。二次确认对于发送邮件、删除数据这类高风险操作给技能增加一个confirm_required: true字段在执行前额外询问用户确认而不是直接执行。这些策略都做进框架之后Agent 的可用性和安全性会提升一个量级。我见过很多翻车现场不是因为大模型太笨而是因为技能系统没有给模型足够的约束信息。4. 常见问题与排查技巧实录4.1 技能加载失败路径、命名、依赖这是新手最容易遇到的问题。技能加载失败的三大原因我整理成了一张表错误现象可能原因解决方案启动时报 Skill xxx not found技能目录没有放在配置的skills_dir下检查目录路径确认skill.yaml确实在对应位置导入main.py报ModuleNotFoundError技能内部依赖了不常见的第三方库把依赖写入技能目录下的requirements.txt并在部署时批量安装启动时报 Duplicate skill name不同目录下的skill.yaml使用了相同的name改名建议用“动作主体”命名法比如weather_query、file_readYAML 解析错误文件里用了非法缩进或特殊字符使用 IDE 的 YAML 插件校验并统一用 UTF-8 编码这里我要强调一个习惯每个技能目录里单独放一个requirements.txt并且用pip install -r requirements.txt -t .venv/lib/python3.x/site-packages来安装会导致全局污染不推荐。更好的做法是用requirements.in加pip-tools锁定版本但那是后话基础项目直接把所有技能依赖合并到项目根目录的requirements.txt里也够用。4.2 参数校验报错类型、必填、枚举在跑 Agent 的时候最常见的就是模型乱传参数。比如天气查询技能声明了city是字符串但模型硬传成一个 list又比如计算器技能声明了expression必填但模型忘了传。这种问题靠skill.yaml的required和type就能拦住大半。但有一个容易被忽略的坑模型可能传一个“包含多个逻辑含义”的参数比如城市字段传成北京和上海技能层没有能力也无法擅自拆开。我通常会在技能实现里做“参数容忍”比如city字段如果包含分隔符就按顺序返回多个城市的天气。这样做的好处是不会因为校验失败而让整个对话流程中断。另外针对整数和浮点数我建议在参数声明里加上minimum、maximum这类约束。有些高温场景或者分页场景模型给一个负数或巨大数字会导致技能执行异常。加了约束以后框架会直接返回参数校验错误模型看到报错后自动修正。4.3 并发调用与状态隔离当你开始让多个用户同时使用 Agent 时技能的状态隔离就会成为大问题。最典型的是文件处理类技能如果一个技能在函数里把临时文件写到了固定路径/tmp/temp.xlsx那么两个用户同时触发解析时后一个用户的数据就可能覆盖前一个用户的文件。我的经验是所有临时文件和数据都应该放进一个“请求上下文”里。agent-skills 为每次执行上下文分配一个唯一 ID技能可以通过上下文对象获取属于自己这次请求的临时目录结束后由框架统一清理。换句话说技能函数里不要写死路径也不要使用全局变量。如果你在设计技能时发现自己必须用全局变量那大概率是设计有问题。# 不好的写法全局变量保存临时状态 temp_path None def execute(file_content: str) - dict: global temp_path temp_path f/tmp/{id(file_content)}.tmp ...# 好的写法所有数据通过参数传递函数无副作用 def execute(file_content: str, context: dict) - dict: temp_dir context[temp_dir] temp_path f{temp_dir}/input.tmp ...在代码层面可能只是几行的区别在线上一旦遇到并发问题调试成本极高所以一定要提前做好隔离。4.4 日志与追踪让技能调用可观测技能调用黑箱化是另一个容易被低估的问题。模型调用技能失败后你会想知道是参数传错了是技能实现 bug还是外部 API 不稳定如果没有日志这几类问题的排查会非常痛苦。我在 agent-skills 里默认给每次技能调用生成一条结构化日志包含技能名、入参、出参、耗时、错误信息、请求 ID。格式类似[REQUEST_ID: 20240520101234-abcd] CALL skillweather city北京 - statussuccess latency812ms [REQUEST_ID: 20240520101234-abcd] CALL skillcalculator expression1/0 - statuserror errordivision by zero latency1ms通过请求 ID 可以把同一轮对话里的所有技能调用串联起来即使现场已经过去了几个小时也能快速复现问题。另外我还会把每条日志的低风险摘要推到监控看板比如技能调用成功率、平均耗时时长、超时次数。只要某个技能的成功率掉到 90% 以下就立刻告警方便早于用户发现故障。5. 这还能怎么玩技能模板与团队协作5.1 常用技能模板汇总技能这套机制的价值在于可以沉淀和复用。我把日常开发中比较常用的技能模板整理成一张表方便你按需使用技能名功能说明需要的外部依赖风险等级calculator四则运算和数学函数计算无低web_search调用搜索 API 获取网页摘要搜索服务 API Key中weather_query查询城市当前天气公共天气接口低file_reader读取本地 CSV/Excel/JSON 并返回结构化数据pandas中email_sender发送邮件草稿或邮件SMTP 配置高date_time获取当前时间、日期计算日期差无低http_request发送自定义 HTTP 请求适合对接内部接口无高code_interpreter在沙箱环境中执行 Python 代码沙箱运行环境高你不需要一次性把这些技能全部做完建议从风险最低、出现频率最高的几个开始比如calculator和date_time然后根据真实用户提问来迭代。技能数量确实重要但更重要是每个技能都被描述清楚、边界明确。十个整理得井井有条的技能效果远比五十个一知半解的技能要好。5.2 技能市场与版本管理技能被认为是独立模块以后团队协作方式也会随之改变。每个人都可以新建一个技能目录按照规范写skill.yaml和main.py然后提交到同一个代码仓库。代码评审的对象从“整个 Agent 逻辑”变成了“单个技能”这会让 review 变得更轻松。我建议在仓库里约定一个技能命名空间比如“领域/动作”的方式search_web、file_read、email_send、data_plot。skill.yaml里的version字段要遵守语义化版本规则主版本不兼容时不能直接替换。如果你把技能发布成内部包还可以做版本锁定保证不同 Agent 依赖的是特定版本的技能行为。更进一步的玩法是做“技能市场”。因为我们内部很多 Agent 集群会共享一批基础技能所以我把技能注册中心扩展成了可以从 Git 仓库拉取技能目录的模式新增技能只需要往中心仓库推一个分支然后 CI 自动扫描、验证、更新服务。这有点像一个“技能版 npm”但实现并不复杂本质就是git clone加自动discover。5.3 从工具调用到技能编排的路径当技能数量多到一定程度你会发现模型单次只调用一个技能往往解决不了复杂问题。比如用户问“对比一下北京和上海未来三天的天气”模型可能需要先调用weather_query两次再把结果整理成表格。这是技能编排的雏形。在大模型原生支持多工具调用之前我在 agent-skills 里加了一个简单的“组合技能”机制一个技能的执行函数内部可以调用其他技能。比如写一个weather_compare技能它内部调用两次weather_query然后把结果拼成一个对比文本。这样做的好处是模型可以一次调用就完成多步操作对用户来说体验更流畅。组合技能需要注意递归调用深度的限制避免出现 A 技能调用 B 技能、B 又调用 A 的死循环。我在框架里限制了每个请求的“总技能调用深度”不超过 5 层超过即报错。这个限制在大多数场景下足够也能提前识别出编排逻辑上的设计问题。写在最后的实践体会我在实际使用 agent-skills 这个项目的过程中最大的感受是它解决的不只是代码组织问题更是一种思维方式的变化。原来我总是在想“我给 Agent 写了哪些函数”现在我更习惯问“我的 Agent 具备哪些能力”。一个能力对应一个技能目录能力边界清楚测试独立扩充方便。哪怕未来模型底层换了只要技能接口保持不变整个系统依然能稳定运转。如果你打算在自己的项目里尝试这套思路我建议从一个小场景开始先写三个技能并接入 Agent跑通整个链路再逐步增加数量和复杂性。中间遇到问题别怕多看看技能调用的结构化日志大部分问题都能快速定位。这套模式我已经用了差不多半年帮我在好几个项目里省下了大量重复造轮子的时间希望你也能从中找到适合自己的用法。