
如果你也调过智能体大概率见过这种割裂感大模型嘴上功夫一流真让它去读写文件、查数据、发请求它就当场“礼貌无能”只会说“我可以帮你但需要你手动操作”。去年我自己搭了一套叫 agent-skills 的技能库项目专门治这个毛病。它不靠某个特定框架而是一套把大模型的意图识别能力翻译成“可执行技能调用”的标准做法。这篇文章我会把当初做这个技能库的设计思路、目录结构、核心代码骨架、多Agent复用的方案全都拆开讲包括我踩过的坑。如果你正在做Agent、准备接工具调用、或者纠结怎么让自己的智能体能真正“干活”这篇文章应该比看一堆概念教程有用。1. 先说清楚agent-skills 到底解决什么问题1.1 一个常见的翻车现场Agent 死活不干活我接手过一个内部需求让大模型读某个数据文件按固定模板生成汇总报告然后把报告写到指定目录。最开始同事直接打开带对话界面的Agent让它“看一下文件然后生成一份周报”。结果模型每次回答都是“我可以帮你总结”可文件它始终没读对生成的内容也是对话窗口里的一段文字而不是实际落盘的文件。这就暴露了当前大模型的本质它擅长理解意图、生成文本但天生不适合执行精确的、有副作用的动作。模型不是不知道“写文件”是什么而是它没有稳定的“手”去调文件系统。你让它连续做两次文件操作哪怕参数一模一样也可能一次成功、一次漏掉。这种不稳定性靠“prompt写得更好”很难彻底解决真正可靠的办法是给模型配一套确定性的技能调用通道。agent-skills 出场的时机就在这里把每个具体动作比如“读取文件”“写文件”“发HTTP请求”“查数据库”“发邮件”封装成独立的小模块并在模块顶部写清楚“什么时候用、参数是什么、返回什么样”。大模型不需要亲手执行代码它只需要从技能清单里挑一个、把参数填对剩下的事情由本地函数完成。1.2 技能库的本质把“会”变成“能做”很多人区分不了“会”和“能做”。模型读完你的技能描述能复述功能这不难难的是在真实任务里稳定触发对应动作、传对参数、处理好返回结果。技能库本质上就是在这两层中间加一道“标准化接缝”。每个技能完成一个原子动作。什么叫原子就是一次调用只做一件事别把“读取文件解析内容写入数据库”写成一个技能。这样做的好处有三个第一模型容易理解触发条件清晰第二方便复用多个Agent都能调用同一个技能第三便于排查问题哪个技能出错直接把范围缩到那一个模块。我用 agent-skills 这个名称也是想强调“技能”而不是“插件”。插件往往绑定宿主系统技能则更像一个能力单元有名字、有描述、有输入输出契约、有版本可以独立发布和更新。换一个模型、换一个Agent框架技能定义本身不用大改。1.3 为什么叫 agent-skills而不是 plugins不是故意造词。plugin 的隐含假设是“被宿主程序加载”而 skill 的隐含假设是“被Agent按需发现”。一个Agent系统可能同时对接文档处理、API调用、数据分析等能力如果只做插件能力之间容易耦合成一锅粥。但技能库把能力拆成彼此独立的“可检索条目”模型选技能时更像是“搜索”而不是“调用加载器”。这个区别直接决定了设计方式。插件通常要考虑框架生命周期、生命周期钩子、界面集成技能则只需要考虑三点怎么描述、怎么执行、怎么返回。描述决定了模型会不会选中它执行决定了能不能跑通返回决定了模型下一步怎么走。后面的设计基本都是围绕这三件事展开的。2. 技能库的整体设计目录、描述、参数与返回值2.1 目录结构与命名规范我的技能库在文件层面就是一个文件夹里面每个子目录对应一个技能。目录名就是技能ID全局唯一。整体长这样skills/ ├── send_email/ │ ├── SKILL.md │ ├── skill.py │ └── requirements.txt ├── query_order/ │ ├── SKILL.md │ ├── skill.py │ └── requirements.txt ├── upload_file/ │ ├── SKILL.md │ ├── skill.py │ └── requirements.txt └── calculate_date_diff/ ├── SKILL.md └── skill.py命名规范是我踩过很多次坑之后定下来的技能ID用动词开头全部小写、下划线分隔。比如send_emailquery_order不要用emailService或者file-handling这种既有大小写又有连字符的写法。原因是模型在一个候选列表里做选择时对“动词开头”的短名字有天然的语义偏向一眼能看出“这技能是干什么的”。如果技能ID本身就模棱两可模型就容易挑错。我试过把技能叫data_processing结果用户说“帮我整理一下表格”时模型好几次选了这个技能实际它连入参是什么都说不清最后还得靠描述补。2.2 SKILL.md 描述文件长什么样每个技能目录里必须有一个SKILL.md。这个文件主要不是给人看的是给模型看的。模型靠读它来决定“当前需求要不要调用这个技能”所以里面的字字句句都很关键。我的做法是使用 YAML front-matter 加简短说明。下面是一个例子--- name: send_email version: 1.2.0 description: 发送一封纯文本邮件。适合通知、告警、报告发送等场景。 input: 收件人地址、邮件主题、正文。 output: 返回邮件唯一ID发送失败时返回错误信息。 when_to_use: 用户明确要求发送邮件、提醒、告警或者需要把生成结果发给某人时。 when_not_to_use: 用户只是讨论邮件内容或者要求发送附件的场景需要看附件处理技能。 permission: [network, smtp] author: internal --- ## Usage 发送一封文本邮件到指定地址。 ## Examples 用户说给 xxxexample.com 发一封邮件主题是“周报”正文是“已完成本周工作”。 调用参数{to: xxxexample.com, subject: 周报, body: 已完成本周工作}你可能会觉得描述带when_not_to_use太啰嗦但这一步很有必要。模型在没有限制时很容易“强行调用”技能比如用户明明只是问“邮件服务现在通不通”模型却可能真的去发一封测试邮件。把“什么时候不该用”写清楚能明显降低误调用率。description原则上不要超过200字。太长了既占上下文又会让模型抓不住重点。我实测下来一段100到150字、含触发场景和输入输出的描述效果最好。2.3 参数契约让模型像填表一样调用技能技能描述是给模型做“要不要选我”的决策那参数定义就是给模型做“怎么填表”的决策。这块必须用结构化Schema不能靠自然语言描述来约束。我习惯在每个技能目录放一个schema.json里面写清楚参数类型、是否必填、取值范围甚至示例值。例如send_email的参数定义{ type: object, properties: { to: { type: string, format: email, description: 收件人邮箱地址多个地址用逗号分隔 }, subject: { type: string, maxLength: 100, description: 邮件主题尽量简短 }, body: { type: string, description: 邮件正文 } }, required: [to, subject] }真正执行前我会先用这个Schema校验模型生成的JSON。比如模型可能把subject写成 null或者漏掉to校验阶段直接拦截不会进入执行函数。这么做不仅安全也能让你快速发现问题次数多的字段反过来优化描述。还有一个容易忽略的点字段名尽量用通用的小写单词不要用带特殊意义的缩写。模型如果看不懂rcpt_addr这种内部叫法它就可能瞎猜。我后来全部改成tosubjectbody这种一看就懂的字段调用成功率明显上升。2.4 返回值格式的约束让结果可被后续Agent处理技能执行完Model 还要读返回值决定下一步。如果每个技能的返回格式都不一样模型就乱了。所以我在 agent-skills 里统一了返回值格式所有技能都必须返回下面这个结构{ ok: true, data: { mail_id: a1b2c3, sent_at: 2025-01-01 10:00:00 }, error: null }失败时返回{ ok: false, data: null, error: { code: SMTP_CONN_FAILED, message: 无法连接到邮件服务器请检查网络配置 } }为什么这么设计因为模型在决定“是否再调用下一个技能”“是否要把错误反馈给用户”时需要快速识别状态。如果每个技能成功时返回一个字符串失败时抛个异常模型处理异常非常困难。统一返回值之后Agent的后续策略写起来也简单oktrue继续下一个动作okfalse读取error.message决定是否需要重新调用或告知用户。经验是error.message一定要写成“人能看懂、模型也能直接引用”的话不要写internal error code 503。因为模型很可能原封不动地把这条信息转述给用户用户看到“503”多半一脸懵。3. 核心实现技能注册、加载与动态路由3.1 注册机制声明文件怎么进库技能库的注册方式有两种一种是“显式注册”在代码里写register(send_email)另一种是“隐式扫描”启动时扫目录自动发现。我最终选了隐式扫描主进程代码不用改新增技能就是“丢一个文件夹进去”这么简单。但纯扫描目录还不够因为技能类型五花八门有Python函数有HTTP请求也有本地脚本。所以我实现了一个极简注册表用装饰器把执行函数绑定到技能名上# registry.py _SKILLS {} def register(name, version): 注册一个技能到全局技能表。 def wrapper(func): _SKILLS[name] {func: func, version: version} return func return wrapper然后技能模块内部可以这样写# skill.py import registry registry.register(send_email, 1.2.0) def send_email(params: dict) - dict: 执行发送邮件的真正逻辑。 # ... 邮件发送代码 return {ok: True, data: {mail_id: ...}, error: None}注册表本身不负责技能描述描述还是放在SKILL.md和schema.json里。装饰器只需要记录哪个函数对应哪个技能ID真正加载时再去读描述文件。3.2 加载器扫描目录、解析描述、绑定执行函数加载器我写成一个SkillManager类它做四件事扫描skills/下所有子目录读取每个目录里的SKILL.md、schema.json导入目录里的skill.py拿到注册表里对应的执行函数组装成完整的技能对象放进内存列表。示例逻辑import os import json class SkillManager: def __init__(self, skills_rootskills): self.skills_root skills_root self.skills [] def load_all(self): for skill_dir in os.listdir(self.skills_root): path os.path.join(self.skills_root, skill_dir) if not os.path.isdir(path): continue self._load_skill(path) return self.skills def _load_skill(self, path): name os.path.basename(path) skill_md_path os.path.join(path, SKILL.md) schema_path os.path.join(path, schema.json) if not os.path.exists(skill_md_path): return # 解析 SKILL.md 头部信息简单起见用字符串处理或 PyYAML # 读取 schema schema {} if os.path.exists(schema_path): with open(schema_path, r, encodingutf-8) as f: schema json.load(f) # 这里执行目录里的 skill.py并捕获注册表项 # 此处略实际会 importlib 动态导入 self.skills.append({ name: name, description: parsed_description, schema: schema, exec_func: func_ref, })这里有个我一开始没注意的坑动态导入时技能里的skill.py应该用importlib导入并且要给每个技能一个独立命名空间避免两个技能都叫skill.py时互相覆盖。用importlib.import_module(f{path}.skill)会让模块缓存混乱我最后是直接用文件路径的哈希生成模块名虽然丑但稳定。3.3 动态路由模型怎么从小技能列表里选对技能技能加载完后核心问题就是用户说了一句自然语言系统怎么知道该调哪个技能这里我不建议一上来就让整个技能列表直接在模型调用接口里生成工具列表。技能少可以一旦超过二三十个上下文占用大模型的选择准确率也会下降。我的方案分两层第一层是“粗筛”。用文本向量的方式把用户输入和每个技能的 description 做相似度匹配取 topK 候选K一般在5到10之间。这一步不需要大模型参与用本地向量库或者简单的词频计算都能实现。第二层是“精排”。把 topK 候选技能的完整描述包括参数schema发给大模型让它从候选里挑出真正匹配的技能并输出调用参数。精排这一步才用到大模型的语义理解能力。这个“先粗筛后精排”的好处非常明显上下文从几百个技能的描述压缩到几个候选技能调用速度更快、模型不容易犯选择困难症。我早期图省事把所有技能直接塞进prompt结果技能到100个左右时模型经常在几个相似技能里“举棋不定”偶尔还会选一个明显不相关的。3.4 权限与隔离技能越权怎么办技能库最大的风险是执行危险动作。比如某个技能可以删文件、发外部请求、执行 shell 命令如果只靠模型判断“这个该不该调”等于把钥匙交给一个容易犯迷糊的人迟早出事。我在 agent-skills 里给每个技能打上权限标签并在执行前做检查。比如send_email标注permission: network, smtpdelete_file标注permission: filesystem然后在运行时设定一套策略如果当前Agent没有被授予某个权限就算模型选择了技能系统也会拒绝执行并返回PERMISSION_DENIED。对更底层的隔离我给高危技能加了沙箱机制。简单做法是让技能跑在一个受限的临时目录里用完就清理再进一步可以用subprocess隔离进程限制可执行文件路径。这些措施不用一开始全做但至少要保留一个“默认拒绝高危操作”的开关否则别人拿到你的技能库再配一个自主权限过大的Agent风险会很大。4. 多Agent共享从单个Agent到技能市场4.1 多Agent场景下为什么需要集中式技能库如果你只是跑一个Demo技能目录放在本地就够。但如果系统里有多个Agent分别处理客服、数据分析、自动化运维你再回头看就会发现问题每个Agent都在重复造轮子。A Agent 写了一个query_orderB Agent 又开始抄一份改改然后两边行为不一致修一个 bug 还要各处同步。我在项目中期做了一次重构把技能库从“本地文件夹”升级成“集中管理”。所有技能统一放在一个服务里各Agent通过接口获取技能描述、提交调用请求。这样每个技能只有一份实现更新一次所有Agent立即生效。集中式还有个额外好处可以统一记录调用日志。谁调了哪个技能、传了什么参数、返回是否成功都集中到一个日志系统里。后面排查“是不是技能本身坏了还是模型选错了技能”会省很多力气。4.2 技能兼容层同一个技能服务多个模型不同大模型对工具调用的格式千奇百怪。有的模型支持原生 function calling有的只支持在 prompt 里给一段 JSON 示例还有的需要用 XML 描述工具。如果每个模型都重写一遍技能库的适配层那会累死人。我在技能库和模型之间加了一个兼容层。兼容层内部维护一份统一的技能描述格式然后根据不同模型的目标格式做转译。比如对支持 function calling 的模型把技能名、描述、schema 转成该模型的 function 列表对不支持 function calling 的模型把技能列表转成一个Available skills:的文本块让模型在回复里夹带一段{skill: ..., params: {...}}再解析出来。这个兼容层让同一个技能库可以同时服务多个Agent也降低了以后换模型的成本。好处是真实存在的我有一次把某Agent的基础模型从A换到B整个过程只改了兼容层里的一个适配器技能库本身完全没动。4.3 技能版本号与灰度发布的实战取舍技能集中之后版本管理就成了不可回避的问题。假设query_order之前返回的订单金额字段叫amount新版改成total_amount所有依赖它的Agent都会受影响。如果没做版本控制改一个字段可能会炸掉整个流程。我会在每个技能对象里带version并在Agent初始化时声明自己需要的版本范围。集中服务更新技能时可以保留旧版本一段时间新调用走新版本旧调用继续用旧版本直到确认没有异常再下掉旧版本。这一套思路本质上就是灰度发布没必要一开始做得很重只需要在技能服务里加一个路由策略按Agent ID或流量比例选择不同版本。我踩过的最痛的一个坑是技能返回结构小改但没有同步改所有下游脚本结果某个Agent后续流程里读旧字段得到 null 以后又去调了另一个技能最后生成了完全错误的报告。自那之后我养成习惯技能返回结构变更时必须发布为新的minor版本不允许原地改字段名。5. 实操落地我给 agent-skills 写的骨架代码5.1 最小化技能定义示例下面是一份能直接跑通最小闭环的骨架。假设我们要做一个read_file技能功能是读取指定文本文件的原始内容。目录结构skills/ └── read_file/ ├── SKILL.md ├── schema.json └── skill.pySKILL.md--- name: read_file version: 1.0.0 description: 读取本地文本文件内容。适合查看日志、读取配置、加载文档等场景。 input: 文件路径。 output: 文件内容如果文件不存在或无法读取返回明确错误。 when_to_use: 用户要求查看某文件内容或者后续操作需要读取文件数据时。 when_not_to_use: 用户只是提到文件名但未要求读取时不要擅自调用。 permission: [filesystem] --- 读取一个本地文件的纯文本内容。schema.json{ type: object, properties: { path: { type: string, description: 要读取的文件的绝对路径 } }, required: [path] }skill.pyimport registry registry.register(read_file, 1.0.0) def read_file(params: dict) - dict: path params.get(path) if not path: return {ok: False, data: None, error: {code: MISSING_PATH, message: 缺少参数path}} try: with open(path, r, encodingutf-8) as f: content f.read() except FileNotFoundError: return {ok: False, data: None, error: {code: FILE_NOT_FOUND, message: f文件不存在: {path}}} except PermissionError: return {ok: False, data: None, error: {code: PERMISSION_DENIED, message: 没有权限读取该文件}} return {ok: True, data: {content: content[:20000]}, error: None}注意我在返回值里把内容截断到20000字。这是为了避免技能返回超长文本撑爆模型上下文。真正需要完整内容时可以追加一个max_length参数超出部分让模型分页读取。5.2 加载与执行完整流程技能库管理器需要提供两个核心方法list_skills和execute。前者让上层Agent获取技能描述列表后者按技能名执行。# skill_manager.py class SkillManager: def __init__(self): self.skills {} # ... 加载过程省略 def list_skills(self): return [ { name: name, description: skill[description], schema: skill[schema], version: skill[version], } for name, skill in self.skills.items() ] def execute(self, name, params, timeout10): skill self.skills.get(name) if not skill: return {ok: False, data: None, error: {code: SKILL_NOT_FOUND, message: f未找到技能: {name}}} # 参数校验 # 调用执行函数并做超时处理 return skill[func](params)执行时用一个超时装饰器避免技能内部发生死循环或网络阻塞。最简单的方式是threading.Thread加result_queue或者用concurrent.futures.ThreadPoolExecutor。我实际写的是一个run_with_timeout工具代码不长但能在技能挂死时让Agent继续响应。5.3 让它跑起来一个简单的调用示例把所有东西串起来以后使用流程大致是这样manager SkillManager() manager.load_all() # 假设模型经过粗筛和精排后决定调用 read_file result manager.execute(read_file, {path: /tmp/demo.txt}) print(result)输出{ok: True, data: {content: hello, agent}, error: None}这个流程虽然朴素但已经覆盖了技能库最核心的闭环描述驱动模型选技能、参数校验、执行、统一返回。后续要接任何Agent框架只需要在框架侧把“模型输出中的技能名参数”交给manager.execute就行。我建议先跑通这个最小版本再慢慢加向量检索、多Agent共享和权限控制。不要一上来就把架构搞得特别复杂否则还没体会到技能库的便利先被各种抽象层绕晕了。6. 常见问题与排查技巧实录6.1 “模型就是调用不到某个技能”怎么排查这是我在群里见到最多的问题。模型明明能看到技能列表但就是不选某个技能。我排查的顺序一般是这样先看技能描述里有没有写到明确的触发场景。比如用户说“帮我看看昨晚的日志”技能描述如果只写“读取日志文件”并且没有提到“昨天”“查看”这类词模型就很容易漏掉。解决办法是用真实对话语料去调描述把用户最常说的几种说法直接写进去。再看粗筛层有没有把这个技能送进候选。如果用的是向量相似度技能描述和用户输入语义差别太大topK可能就不包含它。这种情况可以在粗筛时把“技能名”和“技能关键词”也加入索引不仅仅是描述文本。最后检查是不是模型在精排阶段被其它候选干扰了。如果两个技能描述高度相似模型会难以抉择。我一般把相似技能拆开要么合并要么在when_not_to_use里显式区分。比如query_order和query_refund都涉及订单就分别写上“查询退款信息时不要调用订单查询技能”。6.2 技能返回结果太啰嗦影响后续判断另一个常见问题是技能返回了一个巨大的JSON模型读取时占用大量上下文还可能导致后续调用参数填错。典型场景是查数据库技能把整表几万行都带回来。解决办法是“大结果落盘、小结果回传”。我把查询类技能的返回控制在一个极小的摘要里比如返回总行数、前几条样例、以及一个可以继续读取的临时文件路径。模型要更多数据时再调用另一个read_partial_result技能去取指定行。返回值设计原则是只返回模型“做下一步决策”所需的信息不要返回执行者觉得“完整”的信息。模型不是给人看的它不需要欣赏完整数据它只需要知道“查询成功、有12条结果、前3条是xxx”。6.3 技能稳定性超时、并发与重试技能库跑久了稳定性问题会越来越多。我遇到过几种情况技能内部请求外部API超时Agent被卡住几十秒。我后来给所有技能执行都套了超时默认10秒超过就返回TIMEOUT。对重任务我会拆成“提交任务”和“查询任务状态”两个技能避免长任务长时间占住Agent。并发调用时某些技能不是线程安全的。比如写入同一个配置文件多个Agent同时调用就可能互相覆盖。解决办法是给技能加一把锁同一时间只允许一个执行或者把状态类技能改成“写临时文件原子重命名”的方式最终一致地落盘。重试也要谨慎。如果技能不是幂等的比如发邮件、创建订单重试会造成重复操作。我只对明显幂等的技能开放自动重试其他技能一律让用户确认后重试。6.4 技能列表太长导致模型卡顿怎么办技能库最终会越攒越多我目前维护的库已经超过80个技能。如果每次调用都把80个技能的完整描述发给模型prompt直接膨胀而且模型决策质量会下降。我采用的分层策略是第一层粗筛。先把技能描述做摘要索引只保留约50字的关键描述用轻量关键词匹配或向量匹配筛出top10。第二层精排。把top10的完整描述和schema发给模型。第三层动态加载。真正确定调用某个技能时才把该技能的skill.py代码加载进进程。大部分技能不需要常驻内存。这一套下来我实测技能搜索和调用的整体延迟被压缩到1秒以内模型决策也更准。如果你的技能数量超过200我建议再按领域分桶比如support/、data/、ops/Agent默认只看到自己所在桶的技能需要跨桶再显式检索。7. 一些经验总结7.1 技能不要一开始就设计得过于抽象新手做技能库时很容易想造一个“万能技能”比如execute_code或run_script觉得这样什么都能干。我一开始也这么想过结果是模型经常把不该执行的代码也往这里塞既危险又不可控。后来我改成把能力拆得非常具体read_file就只读文件write_file就只写文件run_shell明确限制在白名单命令。虽然看起来笨但每个技能的职责清晰模型的选择成功率一下子就上去了。技能库的核心不是“少写代码”而是“让大模型有可靠的手脚”。抽象层级太高相当于给模型发了一把万能工具它反而不知道怎么用。7.2 技能描述要写给人看也要写给模型看写描述别只站在开发者角度要站在模型的角度。我每次新增技能都会先不看代码只把description和几个示例扔给模型问它“如果用户说这句话你会调用这个技能吗”。如果模型说“不确定”说明描述还要改。另外我强烈建议在描述里放一到两个“用户说原话”到“参数填值”的映射示例。大模型在 few-shot 场景下填参准确率会明显提升。比如calculate_date_diff的描述里写示例用户说“从今天到下周五还有几天”参数填 {start_date: 今天, end_date: 下周五}注意示例里的参数值可以保留自然语言让兼容层去解析模型不需要自己把“下周五”转换成年月日。这样能降低模型的计算负担。7.3 保留一条手工执行通道方便回滚最后分享一个非常重要的习惯技能库绝对不能只有“模型自动调用”这一条路。我在实际运维中经常遇到模型选错了技能导致数据被误改的情况。这时候如果只能跟着模型走排查会非常痛苦。我在技能库里加了一个命令行工具可以直接手动执行某个技能并传参python cli.py execute read_file --param {path: /tmp/demo.txt}调试时还可以用--dry-run只打印参数和技能信息不真正执行。有了这条手工通道定位问题就快很多先手动跑一次技能确认执行函数没问题再去查模型为什么没选对。很多问题从一开始就能被隔离掉。如果你也在做Agent相关的项目我特别建议从我有一个最小技能库开始哪怕只有两三个技能把“描述、参数、返回、路由”这个闭环跑通再去考虑更多能力。你会发现大模型真正从“会聊天”变成“能干活”关键不在于模型本身而在于你给了它一套多可靠的“手脚”。