
做 Agent 项目这段时间我踩过的最大的坑就是技能Skill管理。模型本身很简单难的是让它在正确的时候调用正确的能力而且调用过程不出错、不打架、不超时。agent-skills 这个项目就是冲着这个痛点去的。它做的事情一句话能说清把 Agent 能干的每件事都拆成一个可注册、可加载、可计量的技能包让模型在需要的时候按需取用而不是把所有工具一股脑塞进上下文里。这个项目解决的不是“技能怎么写”而是“技能怎么管”。它适合正在做 LLM 应用、工具调用、自动化流程的开发者也适合想把代码库里散落的工具函数逐步沉淀成一个统一技能层的团队。我自己就是从手写各种 tool 列表、改 prompt 改到头大最后才转向这套方案的。这篇就当一次复盘把我踩过的坑、改过的思路、以及最终落地的结构都摊开来说。1. 项目整体设计与思路拆解1.1 agent-skills 到底解决了什么问题现在大家都在聊 Agent但大多数 Agent 的雏形其实就是“模型 一组工具函数 一个提示词”。你定义一堆 function把它们转成 JSON Schema塞进模型 API 的 tools 参数里模型按需调用。听起来很顺但一旦工具数量超过十个、二十个问题就来了。第一个问题是上下文爆炸。每个工具的 JSON Schema 至少要占一两百个 token二十个工具就是几千 token。你还没开始干活预算就烧掉一截。而且模型处理大量工具定义时选择准确率会明显下降经常选错工具或者凭空捏造参数。第二个问题是技能之间的耦合。我最初把所有工具放在一个文件里新增一个工具就在原有的大函数上改来改去。改一个 Redis 查询工具不小心动到了整个工具集的结构联调的时候一堆接口报错。技能不隔离改动就是灾难。第三个问题也是最核心的是“技能能力”和“业务逻辑”混在了一起。工具函数是纯执行层但一个 Agent 真正需要的是一个带描述、带权限、带成本预算的技能层。比如“查 GitHub Issue”和“改 GitHub Issue”它们底层可能都调 GitHub API但前者是只读技能后者是写操作技能权限边界完全不同。如果不把它们分层管理权限控制就是一句空话。agent-skills 就把这些问题拆开了。它设计了一套技能注册表每个技能包有独立的清单Manifest、独立的执行体、独立的权限标记。运行时会根据用户问题做技能匹配只把命中的技能加载到上下文里其他技能一概不出现。这样上下文体积可控、技能边界清晰、权限粒度也能做到技能级。1.2 为什么选“注册表 动态加载”这个架构我最早想的方案其实更简单把所有技能描述都塞进系统提示词里让模型自己挑。本地跑 Demo 没问题但一上真实场景就崩。上下文太长模型总是漏技能技能描述之间互相干扰选错的概率很高而且给运营同学加一个技能包还得改提示词重新发布根本不是可维护的结构。后来我换成了“全量工具注入”的方式就是不靠提示词而是把工具传给 model API 的 tools 参数。这个方案比塞提示词正规一些但同样有上下文膨胀的问题。而且所有工具对模型是平权的没有优先级、没有路由逻辑模型选工具纯看它对描述的理解。工具一多选择质量就下降。最后定型的方案就是 agent-skills 现在的架构注册表 动态加载。注册表负责登记所有技能包记录每个技能包的描述、参数约束、权限等级、估算成本。动态加载负责在每次请求进来时根据用户问题做一次技能筛选只把命中的技能包注入到模型的工具列表里。这样有几个很直接的好处上下文体积被控制在一个稳定范围内不会随着技能库膨胀而线性增长。技能包之间天然隔离新增一个技能不影响其他技能。可以给每个技能设成本值按剩余 token 预算动态决定加载多少技能。权限可以挂在技能包维度而不是散落在业务代码里。三种方案我实测下来差别非常明显。我整理了一张对比表方便你做选型参考。方案上下文占用技能扩展性路由准确性推荐场景全量写入提示词极高差改一次重发一次低技能互相干扰只调试、3个以内技能全量注入 tools 参数高随工具数线性增长中仍要维护长列表中工具多后下降工具少于10个的简单应用注册表 动态加载低只加载命中的技能高加技能包即可高先筛选再注入技能库超过10个的正式应用我现在做任何 Agent 项目只要预期技能数量会超过 8 个就直接上这个架构不在全量注入上浪费时间。2. 核心细节解析与实操要点2.1 技能清单Manifest怎么设计才能让模型一眼选中当一个技能被加载到模型上下文里模型就靠两样东西来理解它技能名字和技能描述。名字起得好不好、描述写得清不清楚直接决定模型会不会选错。我在 agent-skills 里为每个技能包设计了一份 manifest.json字段不多但每个字段我都踩过坑逐个说。name 字段是技能的唯一标识必须全局唯一而且要符合模型的命名习惯。不要用 git_issue_api_v3 这种开发视角的名字模型看不懂也不利于匹配。用 github_issue_lookup 这种“对象 动作”的组合模型一眼就知道这是个查询类技能。description 字段是整个清单的灵魂。这一步是拿来给模型看的”用户手册“你要描述的是这个技能在什么场景下用、能解决什么问题而不是怎么实现。我一开始踩的坑就是把 description 写成了技术接口说明模型根本不知道什么时候该用它。第一个版本我是这么写的{ description: 调用 GitHub API 的 GET /repos/{owner}/{repo}/issues/{issue_number} 接口获取 issue 详情 }结果模型经常在模型想做别的事情时误调或者干脆忽略这个工具。后来我把模板改成了这样{ description: 按仓库名和 issue 编号查询 GitHub Issue 的当前状态、标题与评论摘要适合在处理缺陷报告、复盘用户反馈时使用。 }改动之后命中率提升非常明显。原因是模型理解的是”语义场景“不是“API 路径”。描述里明确写了“什么场景下用”模型做规划时更容易将用户问题与技能关联起来。keywords 字段也很有用。虽然它不出现在最终的工具 Schema 里但它是做规则路由时的重要特征词。比如上面那个技能我加了 [issue, bug, github, 缺陷]规则引擎扫到这些词就能把它优先拎出来。parameters 字段直接决定模型会不会传错参数。这个字段遵循 JSON Schema 规范但我额外加了几个约束所有参数都要写明 type必填项必须出现在 required 里可枚举的值要写 enum。有段时间系统的参数错误率很高排查下来发现是有一个 int 类型的参数没写 enum模型自由发挥填了一堆非法值。cost 字段是我后来加的。它表示这个技能包大概会消耗多少 token主要用于控制加载策略。因为我的架构里技能是按需加载的如果一个技能包特别吃 token加载前就要先算算上下文预算不够就跳过。这个字段的初始值我一般按“描述长度 返回结果平均长度”来估算先粗定一个值跑两周再据实际日志调。permission 字段是安全边界。我分为 read_only、read_write、admin 三档。只读技能默认所有人都能触发读写技能需要额外校验来源admin 技能要二次确认。这个粒度挂在技能包维度比散落在代码里清晰得多。我当前用的完整模板是这样{ name: github_issue_lookup, description: 按仓库名和 issue 编号查询 GitHub Issue 的当前状态、标题与评论摘要适合在处理缺陷报告、复盘用户反馈时使用。, keywords: [issue, bug, github, 缺陷], parameters: { type: object, properties: { repo: {type: string, description: 仓库全名例如 owner/name}, issue_number: {type: integer, description: issue 编号} }, required: [repo, issue_number] }, cost: 1, permission: read_only }这套结构已经从我的项目里直接抽出来了你拿去改改就能用。2.2 动态加载与上下文管理的实现机制动态加载最核心的矛盾是加载少了模型缺技能加载多了上下文又爆炸。所以我做了两层控制。第一层是路由筛选。每次用户问题进来先走一遍规则路由用 keywords 做粗筛。粗筛可能命中五六个技能我会再根据问题长度和转化后的 deepprob 分值做一次排序只取前三名。这个阶段是纯字符串匹配速度极快不会拖累整体延迟。如果粗筛的结果太弱也就是没有任何技能命中这时候才轮到模型路由上场。我把所有技能的描述拼成一段摘要让模型从中选若干项。这个方案准确率更高但成本也更高一个大模型调用就是几千 token。所以我把它的触发条件设置得很严格只有规则路由的置信度低于阈值时才走模型路由正常情况下根本不触发。第二层是成本控制。模型上下文窗口是固定的技能描述占的 token 会挤占真正任务的空间。我给每个技能估算 cost 之后每次加载前都算一笔账当前已使用 token 待加载技能的累计 cost 预留的输出 token是否超过安全水位。如果超了就优先砍掉 cost 值大但命中置信度不高的技能。另外技能包的加载是有缓存的。同一个用户的连续对话技能变化通常不大我把上一个请求加载的技能列表存下来直接复用省掉了重复匹配的开销。缓存失效的条件是用户问题对应的 embedding 变化超过阈值这个阈值我用余弦相似度 0.85 来做。这里有一个我特别想强调的注意点千万不要把技能描述当普通文本随意拼接。每个技能包的 description 和 parameters 都要通过一个统一的 schema 生成器转成 OpenAI 风格的工具定义不能有人手动改格式。我遇到过两次线上故障都是因为有人改了工具描述之后格式不合法整个工具列表解析失败模型直接罢工。后来我加了一个开机自检启动时加载所有技能包并做格式校验不通过就崩绝不带病上线。3. 实操过程与核心环节实现3.1 30 分钟搭一个可运行的技能框架这个框架完全可以用纯标准库做出来不依赖任何重框架。我先给你看我项目的目录组织方式skill_packs/ ├── github_issue_lookup/ │ ├── manifest.json │ └── handler.py ├── weather_query/ │ ├── manifest.json │ └── handler.py └── send_email_notify/ ├── manifest.json └── handler.py每个技能包就是一个独立文件夹里面有 manifest.json 和 handler.py。新增技能时复制一个文件夹改改内容就行不需要动主程序。这比在代码里加 if-else 要清爽太多了。注册表的核心代码就几十行我用 Python 实现import json from pathlib import Path class SkillRegistry: def __init__(self, base_path: str skill_packs): self.base_path Path(base_path) self._skills {} def load_all(self): for folder in self.base_path.iterdir(): manifest_path folder / manifest.json if not manifest_path.exists(): continue manifest json.loads(manifest_path.read_text(encodingutf-8)) name manifest.get(name) if not name: continue self._skills[name] { manifest: manifest, handler_module: folder / handler.py, } return self def list_skills(self): return [s[manifest][name] for s in self._skills.values()] def get_manifest(self, skill_name: str): skill self._skills.get(skill_name) return skill[manifest] if skill else None def match_skills_by_keywords(self, query: str, top_k: int 3): scored [] for name, skill in self._skills.items(): keywords skill[manifest].get(keywords, []) score sum(1 for kw in keywords if kw in query.lower()) if score 0: scored.append((score, name)) scored.sort(keylambda x: x[0], reverseTrue) return [name for _, name in scored[:top_k]]load_all 会扫描 skill_packs 目录下的所有文件夹解析 manifest.json建立索引。match_skills_by_keywords 用关键词给技能打分取最高分的前几个。整个执行链路非常轻加载一个技能包也就是一个文件读写的代价。然后你还需要一个执行器把模型选中的技能名和参数映射到具体的 handlerimport importlib.util class SkillExecutor: def __init__(self, registry: SkillRegistry): self.registry registry def execute(self, skill_name: str, **kwargs): skill self.registry._skills.get(skill_name) if not skill: raise ValueError(f技能不存在: {skill_name}) spec importlib.util.spec_from_file_location( skill_name, skill[handler_module] ) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module.run(**kwargs)handler.py 里只需要约定一个 run 函数参数签名与 manifest 里的 parameters 对应即可。比如 weather_query 的 handler 就是def run(city: str): return {city: city, temperature: 24°C, humidity: 60%}这个框架跑通之后你就会发现一个明显的变化增加新能力的时候整个流程变成了“建文件夹、写 manifest、写 handler、注册表自动加载”代码主流程一行不用改。3.2 接入 LLM 调用链路注册表做完了接下来就要把技能接到模型上。我用的是 OpenAI 风格的 tools 参数接口你需要把每个被选中的技能转成 Tool 结构。转 Tool 结构的代码也很简单def skill_to_tool(manifest: dict) - dict: return { type: function, function: { name: manifest[name], description: manifest[description], parameters: manifest[parameters], }, }每次请求进来先走匹配把命中的技能清单转成 tools再把 tools 传给模型def build_agent_request(user_query: str, registry: SkillRegistry): matched registry.match_skills_by_keywords(user_query, top_k3) tools [] for skill_name in matched: manifest registry.get_manifest(skill_name) if manifest: tools.append(skill_to_tool(manifest)) messages [ {role: system, content: 你是一个多技能 Agent请根据用户问题选择合适技能并合理传参。}, {role: user, content: user_query}, ] return messages, tools模型返回一个 tool_calls 之后你用执行器去跑对应技能# 假定的 API 返回结构 tool_calls response.choices[0].message.tool_calls for call in tool_calls: func_name call.function.name func_args json.loads(call.function.arguments) result executor.execute(func_name, **func_args) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse), }) # 再把附带结果的 messages 发给模型得到最终回复这个链路里我最想提醒的是超时和重试。技能调用不是模型调用本地 Redis 卡顿、API 超时都是家常便饭。我给 executor.execute 加了一个统一超时默认 3 秒超时就抛给上层做兜底回复绝不让用户干等。还有重试策略只读技能失败可以立刻重试读写技能失败只能把错误信息返回给模型让模型决定下一步不能自动重试否则可能造成重复写入。这个细节实施之后线上“重复发通知”的投诉基本清零了。注册表 动态加载这套逻辑跑通之后我最大的感受是代码变“钝”了。以前每次加功能都要重新梳理调用链现在只需要填表格式的 manifest 和 handler主链路几个月没动过。4. 常见问题与排查技巧实录4.1 高频问题速查表我把自己在 agent-skills 上线过程中遇到的典型问题整理成了一张表都是真实踩过的坑。问题现象根本原因排查与解决方法技能始终不被模型触发description 写得像接口文档没有描述场景按“动作 对象 适用场景 返回效果”模板重写描述参数总是传错或传漏缺少 enum 约束必填字段没在 required 中声明补全 JSON Schema 约束int 字段尽量用 enum 限制取值范围上下文经常超限命中的技能太多或者单个技能 cost 估算偏低调低 top_k给吃 token 的技能单独设高 cost加入截断策略多个技能并行执行时数据互踩没有做并发隔离共享了同一个全局资源给技能执行器加线程隔离有状态资源用独立实例处理技能更新后旧代码不生效handler.py 被缓存importlib 加载的是旧文件开发阶段禁用模块缓存生产环境用进程重启的方式发布某次上线后所有工具解析失败有技能包的 manifest 格式不合法增加启动自检所有技能包加载时必须通过 JSON Schema 校验这里面最刁钻的是模块缓存问题。Python 的 importlib 动态加载有个特点同一个路径的模块会进 sys.modules 缓存第二次加载用的还是旧代码。你在调试一个新技能的时候改了 handler.py 却发现行为没变十有八九就是这个问题。解决办法是加载前先从 sys.modules 里把旧模块弹出去或者干脆用独立的加载函数直接执行文件。4.2 落地过程中的避坑建议第一个建议是技能描述要模板化不要自由发挥。我给项目定了一条规矩所有技能描述必须包含“适合在什么场景下用”这个信息。没有场景信息的描述模型选技能就靠猜命中率极不稳定。第二个建议是权限要挂在技能包上不要挂在代码里。我的 manifest 里带 permission 字段执行器在执行前统一校验而不是在 handler 内部做判断。这样安全逻辑集中出问题是单点排查而不是翻遍每个 handler。如果你现在还是把权限写在业务函数里我强烈建议挪到技能层。第三个建议是一定要做“技能调用回放”。我在调试阶段写了一个日志中间件把每次用户问题、路由命中的技能列表、模型选中的技能、参数内容、返回结果、耗时全部记录成 JSON 行写入本地日志文件。后面遇到模型选错技能的问题我不需要猜测直接翻回放日志马上知道在哪一步丢的。第四个建议是针对新手的别一上来就追求复杂的语义检索。我最初调研的时候试过用向量库做技能匹配准确率确实高但部署成本、维护成本都上来了。后来发现对大多数场景关键词 正则 简单打分就够了。你先跑通最小闭环把真实的错误日志攒出来确实发现匹配不准的问题了再考虑上向量检索也不迟。语义检索对技能库规模是有要求的少于二三十个技能的时候带来的提升可能还不如一次好的描述模板来得明显。最后再说一个我个人的体会。写 agent-skills 最早期的时候我把精力都花在了路由算法和加载策略上以为问题出在“选不准”。后来真实跑了一周才发现大部分选错技能的案例根源都是技能描述质量太差算法再好也没用。后来我把重心挪到描述模板和 manifest 规范上用统一的模板重写了全部技能包选技能准确率直接上升了一个台阶。这给我留下一个很深的印象在 Agent 系统里技能的可发现性远比技能的实现复杂度重要。代码写得再优雅模型发现不了它它就是一个死技能。如果你现在也在做类似的项目可以先从规范技能清单开始把每个技能都写清楚“给谁用、什么时候用、会返回什么”再回头看路由问题你会发现自己省了很多折腾。