
做 Agent 的朋友大概都会有同一种焦虑Demo 跑得挺顺一接真实任务就露馅。我上半年把好几个 Agent 项目拆开重做最后都收敛到同一个设计——把 Agent 的“技能”从对话逻辑里剥出来单独维护。agent-skills 这个名字就是我给这套技能库起的内部代号。它能做的事其实很简单把 Agent 每一步可执行的操作定义成结构化的技能运行时不靠人肉的 if-else 分发而是让模型根据任务描述去挑选技能、填入参数再由统一的执行器去跑。它解决的核心问题是复用和失控不再为每个新场景重写一套 Prompt也能在 Agent 做错事的时候知道它错在哪一步。这篇文章适合两类人一类是自己搭 Agent 应用的开发者一类是想给现有框架加技能体系的同学。下面的内容不依赖任何特定厂商的模型只聊一套你能直接抄走的设计。1. 为什么 Agent 需要一套技能库而不只是提示词先把问题说透。很多人一开始做 Agent都是在 system prompt 里写“你可以使用以下工具”然后把函数名、参数说明、注意事项全部灌给模型。这种做法的优点是快半小时就能跑通一个 Demo。但它有个很隐蔽的代价所有能力都被塞进了上下文的同一层模型要自己从一大段文字里找“现在该用什么”一旦工具多了它就开始乱。我试过三十多个工具全部列在提示词里的情况。模型没崩溃但每次调用前都要“回忆”一遍工具列表响应变慢选错工具的概率也明显上升。更麻烦的是提示词里只要有一个格式错误整个会话都会受影响改一个工具的参数其他工具的说明也要跟着重新检查。1.1 提示词的复用上限提示词的复用上限比很多人想象中低。同一个功能如果 A 场景和 B 场景对参数的要求不一样你往往只能复制一份说明再改于是维护成本开始成倍增加。到后来你甚至分不清某段描述是给哪个版本用的线上出了问题也只能靠搜日志去猜。生活里有个类比把技能写进提示词相当于把菜谱贴在便签上交给厨师菜谱多了以后厨师每次做菜都要翻一整叠便签还容易把糖和盐看混。而技能库的做法是给每道菜一个独立卡片卡片上写明原料、步骤和验收标准厨师按菜名查卡片就行。换成工程语言就是把“知道有什么能力”和“具体怎么做”拆开。模型只需要看到技能名称、简短描述、参数结构真正执行时的业务规则放在外部代码里。这个拆法带来的直接好处是技能可以单独测试、单独改版本、单独控制权限不会互相污染。1.2 技能模块解决的三类问题我整理下来agent-skills 主要是在解决三类问题。第一类是复用。把高频操作收成技能后不同 Agent 项目可以共用同一套技能库。比如搜索文档、读取数据库、导出报表这些能力在不同项目里明明是一样的但以前每换一个项目就要重新写一遍调用逻辑。现在只需要注册一次。第二类是可控。技能运行之前执行器可以统一做参数校验、权限校验、超时控制、日志记录。模型给了非法参数不会直接触发一个危险的系统调用而是会被拦下来返回一条可读的错误信息。这一点在真实业务里几乎是刚需。第三类是可测。技能是独立的纯函数单元可以写单元测试、做假数据模拟、跑回归。对 Agent 做测试一直很头疼因为对话流程不稳定但把单个技能拎出来测就简单多了只要输入输出符合约定技能本身的质量就有了保障。1.3 与常见框架的定位差异现在很多 Agent 框架也自带工具调用能力但它们的重心通常放在“如何让模型调工具”上对“工具本身怎么组织”讲得比较少。agent-skills 的定位不太一样它更像是一个偏后端的技能管理模块用来定义技能的格式、注册方式、执行规范至于最终是接对话系统还是接自动化脚本都是上层的事情。我自己的体会是框架自带的工具机制适合快速踩点但项目一旦进入维护期还是需要一套自己的技能注册和治理方案。因为业务技能往往带权限、带审计、带重试策略这些是框架默认配置给不了你的。与其事后打补丁不如一开始就把技能层独立出来。2. 技能库的核心设计接口约定与数据模型这一节是整篇文章的骨架。agent-skills 能不能用好取决于你愿不愿意给每个技能定义一个足够清晰的接口。接口定得越稳模型越不容易出错调试也越省心。2.1 一个技能到底包含什么我给每个技能定义了七个字段技能名、描述、参数结构、执行函数、权限、标签、示例。这七个字段不是拍脑袋定的每一个都在实际调试中踩过坑。字段作用对应问题name全局唯一的技能标识模型靠它来发起调用description告诉模型“什么时候用”描述不清晰会导致选错技能parameters参数结构和类型约束缺少约束会导致运行时解析失败execute实际执行逻辑承担具体的业务行为permissions执行所需权限范围防止技能越权访问资源tags分类标签技能多了之后方便检索和分组examples参考调用示例帮助模型理解参数怎么填字段看起来不多但每个都值得单独推敲。Name 必须稳定一旦技能被外部引用后改名所有依赖它的编排逻辑都要改。Description 不能太长但也绝不能糊弄。Parameters 是整个设计里最容易出错的地方我后面专门讲。Permissions 在纯 Demo 阶段可以忽略只要技能开始碰文件、数据库、外部接口就必须补上。2.2 参数描述用 JSON Schema 而不是自由文本这一点是我调整最大的一次。早期版本里我用自然语言写参数说明比如“query 是搜索关键词top_k 是返回条数”。结果模型填参数时偶尔会给 query 传一个对象或者把 top_k 传成字符串执行器一调用就崩。后来我把参数结构改成 JSON Schema用标准格式描述字段名、类型、是否必填、取值范围。例如一个搜索技能的参数可能是这样{ type: object, properties: { query: { type: string, description: 搜索关键词尽量简洁 }, top_k: { type: integer, minimum: 1, maximum: 20, default: 5, description: 返回结果数量 } }, required: [query] }改用 JSON Schema 之后有两件事自动变好了。第一模型在生成调用请求时更规范因为它能直接看到结构化约束。第二执行器可以在执行前做一次严格的参数校验不合法就直接给模型返回“参数错误”的提示而不是让底层函数抛出一个莫名其妙的异常。在定义参数时我有几条自己的规则能不用嵌套对象就不用嵌套对象扁平结构对模型更友好所有数字字段尽量给最小值和最大值所有字段都要写 description哪怕是布尔值required 里只放真正必填的字段不要为了省事全都设为必填。2.3 技能注册表运行时发现的目录技能库的核心是一个注册表它本质上是“技能名到技能对象”的映射表。所有技能在模块加载时把自身注册进去执行器收到模型调用请求后在这个表里查名字查到了就执行查不到就返回错误。注册表设计得好不好直接影响扩展体验。我采用的方式是装饰器注册在 Python 里看起来是这样的skills.register( namesearch_docs, description在内部文档库里搜索相关资料适合回答文档相关问题时使用, parameters{...}, permissions[docs:read], tags[search] ) def search_docs(query: str, top_k: int 5) - str: # 实际搜索逻辑 return result_text这样做的好处是新增一个技能只需要写一个普通函数加一行装饰器不需要去改执行器也不需要动注册表中心。注册表自动把函数元信息收集起来统一转成模型能理解的工具描述。技能删除也很简单移掉装饰器就行。注册表还需要防止命名冲突。如果两个技能同名加载时应该直接抛错而不是让后加载的覆盖前者。这个坑我踩过有一次两个模块注册了同一个技能名后加载的把前面的覆盖了导致线上行为异常日志里却看不出任何报错。2.4 组合技能与原子技能技能库里不一定要全都是最底层的操作。我习惯把它分成两层原子技能和组合技能。原子技能对应一个不可再拆的操作比如“查询订单”“读取文件”“发送邮件”。组合技能则是把多个原子技能串起来比如“处理每日销售报表”可以拆成查询订单、汇总金额、写文件、发送通知。组合技能的好处是模型不需要为了一个固定流程发起多次调用只需要调一次内部逻辑就帮你把后续步骤都编排好。实现组合技能时我建议不要在 execute 函数里直接调用另一个技能的 execute而是调用同一个注册表。因为注册表里保存了权限和日志信息通过注册表调用可以完整记录调用链排查问题时能看到“组合技能 A 在执行过程中依次调用了原子技能 B 和 C”。如果直接调用底层函数日志链就断了。3. 从零实现一个最小可用技能库光讲概念不够我直接给你看一个可以跑起来的最小实现。这个版本我只保留核心逻辑方便看懂数据是怎么流转的。3.1 项目目录与模块划分我喜欢把技能库拆成四个模块注册表、执行器、技能定义、模型调用循环。目录结构大致是这样agent_skills/ registry.py # 注册表与装饰器 executor.py # 参数校验与执行入口 skills/ search.py # 具体技能 report.py # 具体技能 loop.py # 模型调用循环模块划分的目标是让职责单向依赖loop 依赖 executorexecutor 依赖 registryskills 里的技能函数只和业务逻辑打交道不关心模型怎么调。这样以后想换模型、换调用方式技能不用动。3.2 技能注册与加载先看注册表和技能定义的简化写法# registry.py from dataclasses import dataclass, field from typing import Callable dataclass class Skill: name: str description: str parameters: dict execute: Callable permissions: list field(default_factorylist) tags: list field(default_factorylist) examples: list field(default_factorylist) class Registry: def __init__(self): self._skills {} def register(self, name, description, parameters, **kwargs): def decorator(func): if name in self._skills: raise ValueError(fduplicated skill name: {name}) self._skills[name] Skill( namename, descriptiondescription, parametersparameters, executefunc, **kwargs ) return func return decorator def get(self, name): return self._skills.get(name) def all_skills(self): return list(self._skills.values())注册表本身不复杂复杂的是注册顺序和命名冲突。装饰器里检查同名技能就是为了保证每个技能名在全局唯一。我还会在注册时顺便检查 execute 是否是可调用对象、parameters 是否有 required 字段提前暴露配置错误。3.3 模型调用循环执行循环是 Agent 和技能之间的桥梁。这里用一个极简版本演示模型决定要不要调用技能执行器执行完把结果回传给模型模型再继续生成答案。# loop.py def run_agent(task, registry, call_model, max_steps5): messages [{role: user, content: task}] for _ in range(max_steps): response call_model(messages, skillsregistry.all_skills()) if response.get(finish): return response.get(answer) skill_name response[skill_name] raw_args response[arguments] skill registry.get(skill_name) if skill is None: messages.append({ role: tool, content: f技能 {skill_name} 不存在 }) continue try: result executor.execute(skill, raw_args) except Exception as exc: result f执行失败: {exc} messages.append({ role: tool, content: result }) return 超过最大步数请简化任务真实项目里模型接口返回的结构会和这里有差异比如有些模型会一次性返回多个技能调用。但核心思想是一致的不断把执行结果追加到对话里直到模型给出最终答案。每个循环都有步数上限防止模型陷入死循环。3.4 一次完整的执行流程举一个实际例子。假设终端用户提问“帮我查一下上周五的订单数量并按金额倒序输出前五条。”技能库里有三个技能search_orders、sort_orders、format_report。模型看了任务描述后第一步大概率会调用 search_orders参数可能是{date: 2025-06-13, limit: 20}。执行器校验参数后执行函数把订单数据返回成文本共找到 18 条订单明细: ...。模型拿到这一步结果发现还需要排序于是再调用 sort_orders参数{sort_key: amount, order: desc}。执行器返回排序后的订单列表。最后模型调用 format_report生成用户能直接阅读的表格文本。整个过程中模型只负责决策“用什么技能、填什么参数”具体的数据查询、排序、格式转换都在技能内部完成。这也意味着即使模型换了一个供应商技能执行结果依然稳定因为业务逻辑没变。4. 几处容易翻车的实现细节概念和 Demo 都看完了下面这部分是真正让我在项目上花掉最多时间的几个细节。它们不会第一眼就出问题但会在你上线后慢慢折磨你。4.1 不要把 description 写成说明书我见过很多新手写 description会写得很长恨不得把实现细节都写进去。比如“本技能用于搜索文档会连接数据库查询条件是 query 字段支持模糊匹配返回结果包含标题、摘要、链接注意网络超时场景下会重试两次”。这种描述对模型来说信息量太大反而干扰决策。正确的做法是把 description 的中心放在“什么场景下应该选我”而不是“我怎么实现”。一个比较理想的模式是先一句说明功能再说适合什么场景最后补一句不适合什么场景。例如推荐在回答文档相关问题、需要引用内部资料时使用。不推荐当用户只是闲聊不需要查资料时不要调用本技能。模型选择技能的机制很像搜索引擎query 是当前对话意图技能描述是索引条目。索引写得越准确匹配越容易命中。索引写得再华丽如果和真实意图错位照样白搭。4.2 参数校验要交给实际执行器而不是模型模型再聪明也偶尔会传错参数。你不能指望模型每次都能按照 JSON Schema 填出完全合法的参数。所以在执行前执行器必须用 jsonschema 库或等价逻辑做一次硬校验。在校验失败时返回给模型的错误信息也要设计。不要直接返回KeyError: date这种底层信息模型读不懂也没法修正。比较好的方式是返回结构化提示例如参数校验失败缺少必填字段 date。可选字段包括 date, status, limit。请修正后重试。这里有个小技巧尽量把 JSON Schema 里的 required 字段和错误提示联动起来。工具函数可以自动化生成“缺少什么字段”的提示不用每个技能手写。我封装过一个简易校验器内部捕获校验错误把错误原因格式化成上面这种模型友好文本再返回给 Agent。4.3 超时、并发与幂等技能执行是真实代码不是纸面定义。只要技能涉及外部调用就必须考虑超时。我给每次 execute 都加了统一超时机制默认 10 秒超过时间就中断并把“执行超时”作为结果返回给模型。否则模型会一直等一个永远不会返回的技能整个 Agent 流程卡死。并发问题容易被忽略。多个 Agent 同时执行同一个技能时如果技能内部操作了共享文件或数据库会互相干扰。我的处理方式是在技能函数上增加一个可选参数request_id由执行器自动注入。每次执行都把 request_id 写到日志对写操作尽量使用带业务唯一键的幂等写法重复执行不会产生重复数据。还有一个经验不要在技能执行函数里做无限等待的循环。如果你发现某个技能经常卡住排查时优先看它是不是在等锁、等网络、等用户输入。把超时控制做在外层比在技能内部到处加判断要省事得多。4.4 安全边界权限与操作白名单技能一旦可以执行外部行为权限问题就不是“以后再说”而是“现在必须想”。我给每个技能都加了一个 permissions 字段执行器在调用前会检查当前运行环境是否拥有对应权限。比如搜索技能需要docs:read发邮件技能需要mail:send写文件技能需要fs:write。在测试环境里我会用一个宽松的权限文件在线上环境只放最小权限子集。这个设计和操作系统的权限模型很像区别在于执行者是 Agent是一个由模型驱动的进程你没法预测它下一步会调用什么所以只能在权限层把边界卡死。更严格的场景还应该把能执行外部命令的技能单独隔离到沙箱里。我的原则是凡是技能内部出现os.system、subprocess.run、路径拼接、数据库写操作都要经过人工审批后才允许注册。宁可少一个功能也不要让模型拿到一把没有保险栓的枪。5. 常见问题与排查实战这部分是我从大量调试记录里整理出来的问题清单每个都真实发生过。为了方便阅读先放一个速查表再讲几个典型的案例。现象可能原因排查思路模型老是选错技能技能描述不准确候选技能过多缺少示例精简 description减少每次暴露的技能数补充 examples技能执行成功但 Agent 不认返回格式不规范结果被截断状态信息缺失统一返回 status/data/error 三件套参数校验报错反复出现JSON Schema 约束错误模型不会按格式生成检查参数结构在 examples 中给合法调用示例技能越来越多加载变慢注册时扫描太重技能列表太长按需加载按标签分组暴露一个 Agent 卡住影响其他进程技能执行时间过长共享资源锁统一超时引入 request_id 隔离日志5.1 模型老是选错技能怎么办选错技能是最常见的问题也是最让人血压升高的。有一次用户问“订单退款到账了吗”模型没有选“查询退款进度”而是选了“查询订单列表”。原因是我给这两个技能写的 description 高度相似模型根本分不出来。后来我做了三件事。第一重新打磨 description让每个技能有一个明确的“区别于其他技能的关键词”。退款进度技能里写上“包含退款申请时间、审批状态、到账时间”订单列表技能里写上“只返回订单汇总信息不包含退款进度”。第二在 examples 里给出真实场景下的调用参数模型看到示例后更容易做匹配。第三每次调用时不要再把所有技能都暴露给模型先根据标签筛选出候选集合把无关技能过滤掉。我实测下来候选技能数量控制在五到八个效果最好。太多会分散注意力太少又担心漏掉正确选项。筛选逻辑可以很简单把任务内容里的关键词和技能 tag 做匹配优先暴露高相关的。5.2 技能执行成功了Agent 却认为失败了这个问题最隐蔽因为后端明明拿到了正确结果但模型在最终回答里说“查询失败”。我第一次遇到时以为是模型幻觉后来才发现是我返回给模型的字符串太随意。比如我返回的字符串是18模型不知道这个数字代表什么于是怀疑过程没走完。解决办法是把技能返回值改成结构化文本至少包含状态和数据status: success data: 最近一笔订单金额为 199 元支付时间为 2025-06-10 14:30。如果有异常则返回status: error error: 订单编号不存在模型看到 status 字段后能非常确定地判断是否成功。如果你想再细一点可以加一个next_action_hint比如“你可以继续引导用户查看物流信息”帮助模型在拿到结果后知道下一步该干什么。5.3 技能越来越多以后怎么维护技能数量超过二十个以后维护成本会指数级上升。如果你不做分类、不做测试最后一定会失控。我的做法是有新技能加入时必须同时提交两样东西一条真实场景的测试用例一段技能说明文档。维护工具上我写了一个小脚本遍历注册表并生成一个 Markdown 索引包含技能名、描述、tag、权限、最近修改时间。每次更新索引我就能看到哪些技能长期没被调用、哪些技能权限过宽、哪些技能描述已经过时。这比在源码里翻注册装饰器直观得多。技能测试也建议自动化。给每个技能准备一套模拟输入直接调用 execute 函数校验返回结果。不需要跑完整的 Agent 对话只需要跑单个技能就能在早期发现参数结构变化、依赖缺失、返回值格式不兼容等问题。6. 沉淀下来的几条心得项目做到后面我对 agent-skills 的期望已经不是“跑通”而是“稳定可维护”。这一步靠的不是某一个漂亮设计而是每一步都愿意多花十分钟去加固。6.1 我给新技能定下的验收标准现在新增技能时我都会按固定清单审查一下参数结构是不是 JSON Schema描述里有没有写出适用的用户意图示例参数是否真实可用执行函数有没有设置超时权限字段是否最小化返回值是否包含 status。清单看着琐碎但它能挡住大部分线上问题。有一次我想加一个“读取服务器日志”的技能功能很简单一行代码就能实现。但过清单时发现这个技能没有做路径白名单。我补上“只允许读取日志目录下的文件不允许任意路径”之后才觉得它可以安全注册。这份谨慎是踩过几次日志泄露的坑换来的。6.2 可以继续扩展的方向技术选型和架构上还有几个方向可以继续做。一个是技能自动生成让模型基于函数签名和文档生成 JSON Schema 和 description减少手写成本。另一个是技能热更新通过独立进程动态加载、卸载技能不改主程序。还有一个方向是技能缓存对相同参数的结果做短期缓存可以减少外部接口压力。这些方向不急着一口气做完。最优先的还是前面那些基本功规范、校验、权限、日志。基础不稳扩展越多坑越深。6.3 给刚开始做技能库的同学一点建议如果你想在自己的项目里也搭一套技能库我建议不要一上来就铺开几十个技能。先把最高频的三五个操作做好了跑通注册、执行、日志这一整条链路再慢慢加。技能少的时候改设计成本很低技能多了以后再重构会非常痛苦。我个人现在的习惯是每加一个新技能都在真实任务里跑一遍再回到 description 和 examples 里调整措辞。这个过程有点像给搜索引擎调索引不是一个纯代码问题而是一个需要观察模型行为的问题。踩过几次坑之后我越来越认同一个观点Agent 项目的成败很多时候不是模型不够聪明而是工程的边界够不够清楚。agent-skills 这个方向我会继续往下做因为它解决的恰恰是我自己每天都会遇到的真实痛点。