2026/10/13 7:53:44

Agent技能化改造:构建可注册可调用的agent-skills技能库

Agent技能化改造:构建可注册可调用的agent-skills技能库 “agent-skills”这个名字第一次见到时很多人以为它只是个普通的工具函数集合实际上它代表的是AI Agent开发里最关键的架构命题——把Agent的能力拆成一组可注册、可发现、可调用的技能。我最近在做基于大模型的自动化助手项目时把Agent的能力层从单体prompt彻底重构为skill化架构核心就是围绕这样一个agent-skills技能库来组织的。这篇文章直接用项目里的真实设计与代码把技能注册、描述、调用、测试、排障完整梳理一遍给正在做Agent应用的工程师一个能直接参考的方案。适合谁看想摆脱“一个Agent啥都干但啥都干不好”困境的开发者打算把业务能力沉淀为可复用技能的产品团队还有所有被模型选错工具、上下文爆掉折磨过的同学。1. 为什么一定要给Agent做“技能化”改造1.1 单体Agent模式的三宗罪我最早做Agent时习惯把所有能力塞进一个大prompt里系统提示词写了两千字塞了十几个工具的说明然后指望着模型自己判断什么时候调哪个工具。第一个月还能跑后面随着业务增加问题集中爆发提示词上下文被工具说明占掉一大半真正给模型思考的空间越来越小回复质量肉眼可见地下降。工具之间职责重叠模型经常选错要查订单的调了物流查询要做数据分析的调了报表生成。任何一个工具升级或修改整个prompt都要重新调回归成本高得吓人。这个阶段很像一个人同时挂十几个科室的号每个医生都觉得自己是总负责人最后没人真正把事情做完。顺着这个思路我后来把所有能力从prompt里拆了出来做成独立的技能模块由Agent在每次任务时动态选择——这个模块集合就是agent-skills。1.2 Skills、Tools、Workflow谁的粒度更合适做技能化之前得先理清三个概念不然拆出来的东西会不伦不类。Tool工具最小的可执行单元比如“调用搜索接口”“读一个文件”“发送一封邮件”。它没有业务语义只负责把动作做完。Workflow工作流固定顺序的流程编排比如“先查库存→再算价格→最后下单”。它适合那些步骤明确、不许模型自由发挥的场景。Skill技能介于两者之间。它是一组相关工具加上执行策略的封装对外暴露的是一个带意图描述的能力例如“整理会议纪要”这个技能内部可能是转写文本→分段→提取待办→生成模板。模型只需要决定“现在应不应该调用整理会议纪要”至于内部怎么一步步做由技能自己保证。用生活化的说法Tool是一颗螺丝钉Workflow是一条流水线Skill是一个标准作业包。Agent面对任务时先想的是“这活儿该派给哪个作业包”而不是“我要拧哪颗螺丝”。1.3 agent-skills项目的定位agent-skills在我的设计里不是一个框架而是一个技能库项目承载三个职责技能的注册与统一描述让Agent知道有什么可用。技能的调用协议与生命周期管理让Agent用起来不出错。技能的测试与质量保障让技能长期可维护。如果拿人体做比喻大模型是大脑agent-skills是神经系统每个技能是肌肉记忆。大脑不需要理解肌肉如何收缩它只需要知道“抬左手”这个意图对应哪个反射弧。2. 核心设计注册表、描述元数据、调用协议2.1 技能注册表先解决“有什么可用”的问题Agent要选择技能前提是它知道有哪些技能。我的做法是在项目里维护一个全局技能注册表Registry所有技能在启动时注册进去形成一份技能清单这份清单经过压缩后放进Agent系统提示词里。注册表的数据结构大致是这样from dataclasses import dataclass, field dataclass class SkillRegistration: name: str description: str input_schema: dict output_schema: dict version: str tags: list[str] field(default_factorylist)核心字段我解释一下name技能唯一标识采用kebab-case写法比如extract-action-items。名字要短方便模型在输出中准确引用。description最重要的字段直接决定模型选得准不准后面单独讲。input_schema / output_schema用JSON Schema描述入参和出参模型看到后才知道“调用前要提供哪些信息”。version技能自己的版本号和Agent主版本解耦。注册时机我选择启动时静态扫描而不是运行时动态注册。原因很简单运行中注册的技能可能没经过测试就上线一旦出问题很难追溯是哪一轮会话引入的。静态扫描的好处是每次发版技能清单是确定的可以跑一遍全量校验。2.2 description怎么写决定模型选得准不准我踩过最大的坑就是description写得像API文档满篇都是“本函数用于获取物流信息并返回实时状态”。模型看到这类描述经常在多个相似技能之间犹豫最后选错。经过反复实验我发现一个好的技能描述要满足三点写意图不写实现。比如“当用户想知道快递到哪里了使用此技能查询物流轨迹”而不是“此技能调用物流查询接口返回轨迹数组”。写清楚触发条件和不触发条件。把“什么时候不要用”写进去能显著减少误调用。给一个示例输入。模型对示例的理解远比对抽象描述的理解稳定。我整理的描述模板当[用户目标场景]时使用此技能[完成的事情]。 输入要求 - 必须提供[字段1]。 - 可选提供[字段2]不提供时默认[行为]。 不要使用此技能当[排除场景]。 示例用户说[一句话]应调用此技能并设置参数[参数示例]。同一个技能用旧写法和新写法在测试集上对比选对率从71%提升到93%。这个提升幅度比换一个更大的模型还明显所以别急着花钱先把描述写对。2.3 统一的调用协议所有技能一个入口技能对外暴露统一的execute接口Agent侧不需要知道每个技能内部怎么实现。from abc import ABC, abstractmethod from dataclasses import dataclass dataclass class SkillContext: session_id: str user_id: str current_time: str intermediate_data: dict None dataclass class SkillResult: status: str # success / failure / partial data: dict error: str | None None class BaseSkill(ABC): name: str description: str input_schema: dict {} output_schema: dict {} abstractmethod async def execute(self, params: dict, context: SkillContext) - SkillResult: ...SkillContext里放的是调用时需要的基础信息会话ID、用户ID、当前时间、已经拿到的中间数据。为什么不能由技能自己从全局拿因为同一个技能可能被多个会话并发调用全局状态会串数据。SkillResult里特意加了一个partial状态表示“部分成功”。比如整理会议纪实时转写模块断了但已有的分段结果可以先用这种情况返回partial比直接失败强。模型拿到partial后会知道“技能尽力了结果不完整”从而决定是继续追问还是用已有结果兜底。3. 实操落地从空目录到一个可用的agent-skills3.1 工程目录怎么组织一个技能一个目录每个技能自包含。目录结构agent-skills/ ├── registry.py ├── base.py ├── skills/ │ ├── meeting_minutes/ │ │ ├── __init__.py │ │ ├── skill.py │ │ └── templates/ │ ├── extract_action_items/ │ │ ├── __init__.py │ │ └── skill.py │ └── code_review/ │ ├── __init__.py │ └── skill.py └── tests/每个技能目录下有自己的实现和资源不跨技能共享内部模块。公共的通用能力比如HTTP客户端、日志、鉴权放在最外层shared里技能之间不许直接互相import。这个约束一开始觉得麻烦后来发现它保证了技能可以独立测试、独立发布、独立废弃维护成本和单体prompt完全不是一个量级。3.2 基类与注册装饰器我提供两个东西给技能作者BaseSkill基类和一个register装饰器。# registry.py from typing import Type _REGISTRY: dict[str, Type[BaseSkill]] {} def register(skill_cls: Type[BaseSkill]) - Type[BaseSkill]: name skill_cls.name if not name: raise ValueError(f技能{skill_cls.__name__}缺少name) if name in _REGISTRY: raise ValueError(f技能{name}重复注册) _REGISTRY[name] skill_cls return skill_cls def get_all_skills() - list[Type[BaseSkill]]: return list(_REGISTRY.values()) def get_skill(name: str) - Type[BaseSkill]: if name not in _REGISTRY: raise KeyError(f技能{name}未注册) return _REGISTRY[name]技能作者需要做的就是继承BaseSkill、填name和description、实现execute然后加一行register。因为这个流程足够简单团队里非核心的同事也能快速贡献新技能技能库从5个涨到30多个基本没花额外的培训成本。3.3 手写一个“会议纪要整理”技能我拿项目里最常用的“会议纪要整理”技能当例子完整走一遍。先定义入参。这个技能要接收两个输入会议转写文本raw_text和会议主题topic可选。from .base import BaseSkill, SkillContext, SkillResult register class MeetingMinutesSkill(BaseSkill): name meeting-minutes description 当用户提供了会议转写文本需要把杂乱的口语内容整理成结构化纪要和待办事项时使用此技能。 输入要求 - 必须提供raw_text即会议的原始转写文字。 - 可选提供topic不提供时根据内容自动归纳会议主题。 不要使用此技能当用户只是想了解会议结论而非整理纪要时。 示例用户说帮我整理一下刚才那场会的纪要应将raw_text设为对话记录topic留空。 input_schema { type: object, properties: { raw_text: {type: string, minLength: 1}, topic: {type: string} }, required: [raw_text] } output_schema { type: object, properties: { summary: {type: string}, action_items: {type: array, items: {type: string}}, decisions: {type: array, items: {type: string}} } } async def execute(self, params: dict, context: SkillContext) - SkillResult: raw_text params[raw_text] topic params.get(topic, ) segments self._split_into_segments(raw_text) extracted [] for seg in segments: result await self._llm_extract_segment(seg, topic) extracted.append(result) merged self._merge_extracted(extracted) return SkillResult(statussuccess, datamerged)execute实现分三步分段、抽取、结构化。我不直接拿整段文本丢给模型而是先按语义切块每个块单独送模型处理最后合并。这样做的原因是会议转写经常超过模型单次输入上限而且分段处理让每一块的指令更聚焦抽取质量明显更高。这里有个细节split_into_segments不要太智能按标点符号和段落硬切就行。我试过用模型做语义分段效果是好一点但每场会多花几十秒和不少token对整体性价比不划算。分段粒度控制在每段200字左右既能保证上下文充分又不会让单次调用的token浪费。3.4 Agent自动选技能的完整循环技能库建好之后Agent的运行循环大概是这样的启动时把所有技能的描述清单压缩后写入系统提示词。每轮用户输入到来模型判断该用哪个技能输出一个结构化调用意图。系统校验参数调用对应技能的execute。技能结果返回给模型模型结合结果生成最终回复。如果模型判断需要连续调用多个技能就循环执行2-4步。步骤2里我强烈建议让模型先输出意图而不是直接输出技能调用结果。也就是说模型先回答“我要用哪个技能、参数是什么”系统确认技能存在、参数合法后才真正执行。这么做的好处是可以在执行前做一次参数安全校验拦截掉明显不合理的调用。有一次我在测试时模型把用户ID填进了会议纪要的raw_text如果直接执行后面所有处理都会跑偏。意图选择的部分可以让模型输出JSON{skill: meeting-minutes, params: {raw_text: ...}}解析JSON比解析自然语言可靠得多。我见过有人让模型直接说“我要调用meeting-minutes”然后用正则去匹配这种方式在技能名多起来以后完全不可靠。4. 实操中常见的问题与排查实录4.1 模型老是选错技能怎么办排查顺序很重要我从实际经验里总结出三板斧先看description是不是写成了实现说明。把所有技能描述打印出来自己以用户身份读一遍凡是出现“调用”“返回”“接口”“参数类型”这类词的大概率要重写。看技能之间重叠度。如果两个技能解决的是同一个用户意图就合并或者明确分工。我遇到过“总结文档”和“提取要点”两个技能看起来不同实际用户意图完全重叠导致模型五五开乱选。增加否定性描述。在description末尾明确写“当用户只是闲聊时不要使用此技能”能大幅降低误触发。如果三板斧都没用再考虑是不是技能名太抽象。技能名用动作化的词汇比如generate-changelog、extract-action-items比stats、helper这种名字好辨认得多。4.2 技能描述把上下文撑爆了技能多起来之后把所有完整描述塞进系统提示词很快就爆。我的做法是分两级启动时只放“技能名一行摘要”完整描述等模型选出候选技能后再临时注入。实操下来token占用少了约七成选技能的准确率没有明显下降。还有一个冷门技巧把用户历史消息里的技能调用记录也放进上下文。模型看到一个技能之前被同类问题选中过会倾向于继续选它这相当于给了模型一个隐性的先验。但要注意这个技巧只对同一用户同一会话有效跨会话的历史反而会引入噪音。4.3 技能内部异常导致整轮对话失败技能内部调LLMLLM偶尔超时或返回垃圾内容如果直接抛异常Agent整轮就废了。我的处理是在execute内部加兜底LLM调用统一走一个带重试的客户端最多重试2次间隔按指数退避。重试仍失败返回statusfailure和错误信息而不是抛异常。对解析模型输出做严格校验解析失败时把这个片段标记为unparsed但整体的result仍然返回success让模型自己决定怎么解释。这背后的思路是技能是给模型用的模型需要的是“情况说明”而不是“程序异常”。只要技能能把“发生了什么”描述清楚模型就能做出合理的下一步判断比如换个方式提问、让用户补充信息、或者直接给出部分结果。4.4 技能升级后的兼容问题技能也要管版本不然会出大事故。我遇到过的情况是某个文本摘要技能换了底层的模型参数结果同一份文档的摘要风格大变用户以为系统坏了。现在我的做法是每个技能在描述里带版本号注册表里记录当前生效版本。升级时先灰度只对内部测试会话启用新版本。技能的输出schema不能轻易改要改就加新字段老字段保留兼容。模型和上层业务都依赖输出结构结构一变连锁反应很难查。4.5 把技能自检做成必备流程最后分享一个我坚持了很久的习惯每个技能合入之前必须配一组测试用例包括3个正常场景和2个边界场景。正常的场景用来验证“该调的技能能被正确调用”边界场景用来验证“不该调的时候不误触”。我把这组用例挂在技能模块的tests目录下每次发版跑一遍一旦有技能描述改动导致误调用立刻就能发现。而且这类测试不仅测代码更要测描述。我会把技能的description喂给一个固定的大模型然后问它“用户说X你选不选这个技能”把它当成一个分类模型来验证。这样能在不跑完整Agent流程的情况下快速迭代description的质量。5. 常见问题速查表问题现象可能原因快速处理方案模型在两个技能间反复横跳技能意图重叠合并技能或明确各自触发边界用户闲聊时误调技能description缺少否定条件补充“不要使用此技能当……”上下文token很快打满技能描述太长改为两段式摘要常驻、完整描述按需注入技能返回结构不稳定输出schema约束不足严格定义output_schema并强制校验新技能上线后老场景变差注册表顺序或描述冲突跑一遍技能分类测试检查重叠度并发会话数据串了技能内使用全局变量所有中间数据走SkillContext传递6. 最后再聊两句体会用agent-skills这套结构重构项目之后最大的改变不是代码变少而是团队的协作方式变了。以前改一个Agent功能要动一大段prompt所有人都小心翼翼的现在改成加一个技能目录、写一个技能类、跑一遍测试边界非常清楚。我个人觉得技能化其实是把不可控的“模型行为”重新拉回“可控的工程”的手段。如果你正在做Agent不管你现在的Agent是单体prompt还是已经有工具调用我都建议你花一个下午试试把能力拆成技能。先从两三个最常用的技能开始把description按前面说的模板重写一遍跑几轮真实对话对比一下。做完之后你再回头看大概率不会再想回到“一个大prompt管所有事”的状态。最后再补充一句技能不是越多越好20个高质量、边界清晰的技能远胜60个互相重叠的碎片技能。定期清理技能库和定期重构代码一样重要。这条经验是我把技能数冲到60多个又被现实教训回30多个之后才真正理解的。