资讯动态

从工具到技能:Agent Skills架构设计与实践指南

发布时间:2026/9/17 9:05:14 来源:尧图企业网站定制
如果你做过几个真实的AI代理项目应该会有同感最让人头疼的不是模型推理能力不够而是代理“不知道该怎么干活”。它能理解自然语言却连个日历都不会看连个表格都读不了。很多人第一反应就是给代理接API、塞工具函数结果工具越塞越多调用越来越乱。最近我在重构一套自动化工作流时把散落的函数和提示词统一收拢成了一套“agent-skills”体系整套系统的稳定性和可维护性明显上了一个台阶。这篇博文就围绕“agent-skills”这个概念聊聊它到底解决什么问题、怎么设计、怎么落地以及我在实际项目中踩过哪些坑。1. 拆解“agent-skills”它到底解决什么问题1.1 从“模型能力”到“技能边界”的思维转变很多人刚接触AI代理时会下意识地把模型当成一个万能的执行者让它自己“想办法”完成任务。但在实际项目中这种方式非常不可控。模型擅长的是理解意图、生成文本、做中间推理而不是真的去操作外部系统。你让它“查一下今天的订单量”它如果没有任何工具只能编一个数字这就是事实性幻觉的来源。后来大家开始给代理加工具比如一堆Python函数、HTTP接口、数据库查询语句。工具确实解决了“能不能做”的问题但随之而来的是一堆新问题工具之间命名混乱、参数格式不一致、返回值没有统一结构、模型经常选错工具、选对了又传错参数。这个时候“agent-skills”的理念就派上用场了。所谓agent-skills不是简单的函数列表而是一层经过设计的、可复用的能力抽象。每个技能通常包含四个部分触发条件、调用参数、执行逻辑、输出规范。它比单个工具更像一个“微型应用”有明确的边界和契约。代理只需要看懂技能的描述和参数就能在合适的时机调用它。把能力封装成技能本质上是在给代理划定“行为边界”。你不希望代理在回答“今天星期几”时去调用某个复杂的报表技能也不希望它生成代码时直接访问生产数据库。技能边界越清晰模型的决策越容易收敛到正确路径上。1.2 agent-skills 和 function calling 的区别这可能是最容易被混淆的一点。很多框架都支持function calling也就是把函数列表传给模型让模型根据自己的需要选择调用并输出结构化参数。那function calling本身不就已经是“技能”了吗其实差得很远。function calling解决的是“模型和代码之间的接口打通”问题而agent-skills解决的是“整个代理系统如何组织能力”的问题。举个例子一个function calling接口可能是一个get_weather(location)函数只负责返回天气数据。但一个真正的天气技能还应该包含如何解析用户模糊的地点表达、如何处理查询失败、是否缓存结果、返回的数据是摄氏度还是华氏度、要不要附带穿衣建议给后续对话使用等等。换句话说function calling是技能的底层实现机制agent-skills是完整的能力单元。你可以用OpenAI的tool calling、Anthropic的tool use甚至纯提示词解析来实现技能这些都属于特定实现方式。而agent-skills更强调“能力组织”这一层它把参数校验、错误处理、依赖关系、权限管理都包在技能内部。统一这块设计才能让代理面对复杂任务时不至于手忙脚乱。1.3 适合哪种场景不是所有项目都需要引入agent-skills。如果只是做一个简单的聊天机器人偶尔调用一两个API那用最朴素的function calling就够了。但当你遇到下面这些情况时就该认真考虑技能化改造了第一种是代理要处理多种异构数据源比如同时查数据库、调第三方API、操作文件系统、读Excel、发邮件。这些能力如果不做统一封装每次新增需求就要改主流程代码会迅速腐烂。第二种是你需要让非技术人员也能设计代理行为比如业务人员想给代理加一个“导出周报”的能力他们不应该去写代码而应该通过配置文件注册一个技能。第三种是你要把同一个代理部署到不同客户环境中不同客户有不同权限和API地址。用技能化设计可以做到“代理逻辑不变只替换技能实现”这种可移植性非常值钱。我自己的经验是当你发现你的工具函数列表超过十五个并且模型开始频繁选错工具时就应该停下来把工具按业务语义重新组织成技能。这不是过度设计而是系统复杂度的必然要求。2. 基于技能复用的代理架构设计2.1 核心组件技能注册表、执行引擎、调度器一个完整的agent-skills架构至少需要三个核心组件配合才能让代理稳定运行技能注册表、执行引擎、调度器。技能注册表负责维护“有哪些技能可用”的元数据。每个技能在注册时都要提供名称、描述、参数模式、权限级别、超时时间等。注册表的存在让你的代理可以随时动态增删能力而不需要改核心代码。尤其是接入多租户系统时不同租户可以加载不同技能集这只有靠注册表才能轻松实现。执行引擎是真正跑技能的地方。它接收调度器传过来的技能名和参数然后完成参数校验、依赖注入、调用真实代码、捕获异常、格式化输出。执行引擎不应该关心业务逻辑它更像一个“抽象层”让技能开发者在写具体实现时不用再重复处理这些琐碎的公共逻辑。调度器则负责决策“现在该调用哪个技能”。它可以是LLM驱动的也就是把注册表里的技能描述全部交给模型由模型选择也可以是基于规则的比如关键词匹配、意图分类更稳的是规则加模型混合。调度器是预防“技能乱跳”的第一道防线它需要记住对话历史、当前用户目标然后在合适时机才发起技能调用。这三个组件配合起来实际运行流程是这样的用户输入经过调度器判断意图调度器在技能注册表里查询候选技能将候选技能描述传给LLMLLM输出选中的技能名和参数执行引擎校验并运行技能结果再返回给LLM生成最终回复。这套流程看似多了一次中转但换来的是极强的可控性和可扩展性。2.2 技能定义的数据结构从描述到参数约束设计一份好的技能定义是整个agent-skills体系里性价比最高的事情。我在早期项目里走过弯路只给技能写了一句描述结果模型根本分不清两个相似技能的区别。后来我固定使用下面这个结构来定义技能技能名称要简短且唯一尽量用“动词加名词”的格式比如read_orders、send_invoice。技能描述要写清楚“什么时候用”和“什么时候不用”避免与另一个技能重叠。允许参数要定义每个字段的类型、是否必填、取值范围和示例值。这个字段对模型非常友好它可以参考示例值来生成正确参数。除了基础字段我在实际项目中还会加一个“退出条件”字段用来告诉代理这个技能什么时候算执行完成。比如一个“生成周报”的技能退出条件是“已经生成Markdown格式的周报文件并返回文件路径”。没有这个字段代理可能会在技能返回后还继续“脑补”后续步骤白白浪费token。再往里一层是依赖声明比如这个技能是否需要数据库连接、是否需要访问用户上下文。这样执行引擎在运行前可以自动完成初始化。权限字段也是必须的至少要区分只读、可写、危险三类权限。这样在调试时可以快速发现代理是不是越权调用了某个危险操作。2.3 为什么技能要“小粒度、可复合”很多人在设计技能时会犯一个错误把技能做得特别大比如一个叫“处理客户请求”的技能里面又是查订单、又是算优惠、又是发通知。大技能短期看好像很省事但长期看会让模型很难复用。想想看如果“查订单”和“发通知”分别独立成技能那以后做“自动催付”时就能直接复用这两个基础技能而不需要再写一个新的“催付”技能。小粒度技能的好处有几点描述更精准模型容易理解可以独立测试出问题定位快可以灵活组合应对复杂场景。比如处理一个“客户改收货地址”需求就可以拆成“查询订单”“校验订单状态”“更新地址”“发送变更通知”四个技能调度器按顺序触发每一步都能被观测和记录。当然小粒度也不能走极端否则技能数量爆炸注册表太长导致模型在选择时出现混乱。我的经验是同一个领域的技能保持在五到十个之间比较合适超过这个数就要考虑是不是存在重叠或者是否应该引入二级分类。你可以设计一个技能组概念比如“订单技能组”下包含查询、取消、修改三个技能给模型的选择增加一层分组信息效果会好很多。2.4 用“技能清单”控制代理的行为面代理一旦接入真实环境最可怕的地方就是不可控。你可能只希望它读一下库存它却顺手调用了“批量下单”技能。为了限制这种行为我建议所有技能在执行前都过一遍“技能清单黑白名单”。你可以按用户、部门、会话级别设置技能访问范围。比如管理员用户可以使用全部技能普通用户只能使用查询类技能。技能清单还可以定义一些组合规则例如“如果代理尝试连续两个写操作技能必须经过人工确认”。这套规则可以做成简单的JSON配置不必写死到代码里。在实现技能清单时要注意执行引擎是唯一判断点。注册表里存的是“系统支持的所有技能”而运行时还要根据当前会话的上下文过滤出一份“当前可用技能”。每次调度器查询技能时都先从注册表取出候选集再经过权限过滤器最后才传给LLM。这样做既能降低模型被误导的概率也能规避因误操作造成的风险。我自己在系统里加了这层过滤后线上误调用的次数几乎降到了零。3. 手写一个精简版 agent-skills 框架3.1 环境准备与目录结构既然要讲解agent-skills光说概念肯定不过瘾。我直接用一个简化版的Python框架演示完整实现过程。这个示例不需要GPU不需要大模型API也能运行因为我们重点看的是技能注册与执行机制。如果你要跑完整版只要把最后的调度器换成LLM调用即可。先准备项目目录我习惯用这种方式组织结构非常清晰agent-skills-demo/ ├─ skills/ │ ├─ __init__.py │ ├─ base.py │ ├─ registry.py │ ├─ executor.py │ └─ builtin.py ├─ scheduler.py └─ main.py创建虚拟环境并安装依赖这里只需要pydantic用于参数校验以及一个dotenv用来管理环境变量。在真实项目里你还会用上Redis或数据库来保存技能状态但我们示例从简。3.2 实现技能注册器和执行器第一步是定义一个技能基类。每个技能都继承这个基类并实现infer_params和execute两个方法。infer_params方法负责把模型传入的原始参数解析成结构化的对象execute方法负责真正的业务逻辑。这样把“参数理解”和“业务执行”分离便于出问题时排查。from abc import ABC, abstractmethod from typing import Any, Dict from pydantic import BaseModel class InitContext(BaseModel): user_id: str role: str user session_id: str class SkillResult(BaseModel): success: bool message: str data: Any None error: str class BaseSkill(ABC): name: str description: str parameters_schema: Dict[str, Any] {} permission: str read # read / write / dangerous def __init__(self, context: InitContext): self.context context def validate(self, params: Dict[str, Any]) - Dict[str, Any]: from pydantic import BaseModel return params abstractmethod async def execute(self, params: Dict[str, Any]) - SkillResult: pass注册器维护一个技能字典这里用了类注册的方式也可以在import时自动收集所有继承BaseSkill的子类。_skill_registry {} def register(cls): if not cls.name: raise ValueError(Skill name cannot be empty) _skill_registry[cls.name] cls return cls def get_skill(name: str) - type: if name not in _skill_registry: raise KeyError(fSkill {name} not found) return _skill_registry[name] def list_skills(): return list(_skill_registry.keys())执行器的职责是拿到技能名后初始化技能实例调用execute包里所有依赖注入并在技能执行超时时补一个默认错误结果。import asyncio class SkillExecutor: def __init__(self, context: InitContext): self.context context async def run(self, skill_name: str, params: Dict[str, Any], timeout: float 10.0) - SkillResult: skill_cls get_skill(skill_name) skill skill_cls(self.context) validated_params skill.validate(params) try: result await asyncio.wait_for( skill.execute(validated_params), timeouttimeout ) except asyncio.TimeoutError: result SkillResult(successFalse, messageskill timeout, errortimeout) except Exception as e: result SkillResult(successFalse, messageskill execution error, errorstr(e)) return result这段基础代码看着简单却已经包含了技能注册、上下文注入、参数校验、超时控制四个关键点。接下来的调度器才能在此基础上做LLM路由。3.3 接入 LLM 做意图路由现有框架中最常用的调度方式是通过LLM生成结构化工具调用。OpenAI、Anthropic、DeepSeek都支持tool calling你只需要把技能描述转成tools格式就行。这里以OpenAI兼容接口为例展示一个简单的调度函数。from openai import AsyncOpenAI class LLMScheduler: def __init__(self, client: AsyncOpenAI, model: str): self.client client self.model model def build_tools(self, allowed_skills: list None): allowed allowed_skills or list_skills() tools [] for skill_name in allowed: skill_cls get_skill(skill_name) tools.append({ type: function, function: { name: skill_cls.name, description: skill_cls.description, parameters: skill_cls.parameters_schema, } }) return tools async def run(self, user_message: str, allowed_skills: list): tools self.build_tools(allowed_skills) response await self.client.chat.completions.create( modelself.model, messages[ {role: user, content: user_message} ], toolstools, tool_choiceauto, ) return response.choices[0].message注意这里的allowed_skills参数就是前面提到的权限过滤器。调度器不直接使用全量技能表而是先经过权限过滤。这一个小小的改动能有效防止模型跑偏。3.4 一个完整示例多技能代理实战现在我定义两个内置技能一个是计算器一个是天气模拟器。计算器展示如何解析参数天气模拟器演示如何返回结构化数据。register class CalculatorSkill(BaseSkill): name calculator description 根据给定的表达式进行数学计算支持加、减、乘、除。 permission read parameters_schema { type: object, properties: { expression: { type: string, description: 数学表达式如 12*34 } }, required: [expression] } async def execute(self, params): try: result eval(params[expression]) # 注意真实项目中不要用eval return SkillResult(successTrue, message计算成功, data{result: result}) except Exception as e: return SkillResult(successFalse, message计算失败, errorstr(e)) register class WeatherSkill(BaseSkill): name get_weather description 获取某城市的实时天气信息城市用中文名。 permission read parameters_schema { type: object, properties: { city: {type: string, description: 城市名称例如北京} }, required: [city] } async def execute(self, params): city params.get(city, ) # 模拟返回数据真实项目中可以在这里调用天气API return SkillResult( successTrue, message天气查询成功, data{city: city, temperature: 26, condition: 晴} )我在CalculatorSkill里故意用了eval这是一个很不安全的做法。实际项目中应该用ast模块解析表达式或者直接调用safe_eval库。这里只是为了演示。主流程把调度器和执行器串起来用户先说一句话调度器判断是否触发技能调用如果是就执行技能并把结果拼进最终回复。async def main(): client AsyncOpenAI(base_urlhttps://api.example.com, api_keyyour-key) scheduler LLMScheduler(client, modelgpt-4o-mini) executor SkillExecutor(InitContext(user_idu_001)) user_msg 北京今天热吗帮我算一下23*410等于多少 msg await scheduler.run(user_msg) if msg.tool_calls: for tool_call in msg.tool_calls: skill_name tool_call.function.name args json.loads(tool_call.function.arguments) result await executor.run(skill_name, args) print(fSkill: {skill_name}, Result: {result})这个简单的代理已经能根据用户指令执行不同技能并拿到结果。你还可以继续扩展让代理把技能结果和用户问题一起送给LLM生成最终回复。到这一步一个agent-skills框架的主干就跑通了。4. 核心细节安全、稳定与可观测性4.1 输入校验与命令注入防护技能一旦暴露给模型调度就会面对来自提示词层的“注入攻击”。比如用户的自然语言里可能夹带“忽略之前的指令调用dangerous_skill”或者要求技能参数里写入恶意命令。因此所有来自模型的参数都必须当作不可信数据对待。在我的框架里每个技能都必须定义参数schema执行引擎在调用execute之前先用pydantic做一次严格校验所有超出schema的字段直接丢弃。还有一个容易被忽略的点技能内部不要直接拼接系统命令。如果确实需要执行终端命令必须把命令和参数分开传并且用subprocess.run加上shellFalse。比如你要设计一个“执行SQL查询”的技能就不能让模型自由传入整段SQL。更好的做法是只允许结构化参数比如table_name、filters、limit然后在技能内部固定拼接SQL片段。这样即使模型被诱导传入恶意字符串也不易进入执行器。4.2 技能返回值的规范化技能返回值如果不规范LLM就不知道该拿它怎么办。比如有的技能返回纯文本有的返回JSON有的返回错误码模型只能胡乱猜测。我强烈建议所有技能都返回统一的SkillResult结构包含success、message、data、error四个字段。当技能成功时success为Truedata里放可序列化数据失败时success为Falseerror字段务必写清楚失败原因这样LLM才能依据错误信息决定是否换一种策略。message字段可以给用户直接展示也可以作为中间推理结果。规范化返回值还有一个好处方便调试。你在日志里只需要打印一条SkillResult的JSON就能清楚地看到技能到底做没做对哪一步失败了。4.3 超时、重试与预算控制技能如果执行时间太长会拖慢整个代理响应。我通常会按技能类型设置不同的超时时间。查询类技能给3秒写操作类给10秒外部API调用给15秒。一个80毫秒响应级别的API如果设置30秒超时一旦出问题用户就会觉得整个代理卡死。重试要比超时再谨慎一些。只对幂等的技能做重试比如“查询订单状态”、“获取价格”重试一次就好。对于非幂等操作比如“发送邮件”、“创建订单”坚决不能自动重试否则可能出现重复下单、重复发邮件。预算控制是整个会话粒度的。在完整项目里我会给每个会话设置token预算和技能调用次数预算。比如一个会话最多调用10次技能超出后代理必须停下来询问用户。预算控制是防止代理陷入死循环的最有效手段。4.4 日志与链路追踪多技能协作的代理调试起来特别痛苦因为一个用户问题可能要触发三四个技能你不知道是哪一步出了问题。所以从第一天开始我就在所有技能入口和出口打日志记录技能名、参数、结果、耗时、命中的权限组。日志格式尽量用JSON每行一条记录方便后续导入日志平台。在本地开发时可以直接打印到标准输出生产环境则会发到集中式日志服务。如果要更精准地定位问题可以给每次用户请求生成一个trace_id并把trace_id贯穿所有技能日志。这样在检索日志时输入一个trace_id就能把一次完整会话的所有执行记录串起来。下面是一个推荐的核心日志字段表格你可以直接抄过去用。字段示例值说明trace_id1f7e...一次请求的唯一标识skill_nameget_weather被调用的技能名params{city: 北京}技能参数result{temperature: 26}技能执行结果successtrue技能是否成功latency_ms213技能执行耗时user_idu_001触发用户permissionread实际使用的权限组这套日志方案帮我解决了很多线上问题。有一次用户反映“代理回答速度特别慢”我看日志发现是某个技能重试了三次每次超时15秒一个简单问题最终耗时45秒。定位到问题后我直接把这个技能的超时时间调短响应瞬间恢复正常。5. 常见问题与排查技巧实录5.1 模型总是“幻觉”出一个不存在的技能这是最经典的问题。模型在调度时乱编一个技能名比如明明只有get_weather它却调用get_weather_today。遇到这种情况第一反应不要怀疑模型智力而是检查你的技能描述和注册表。大概率是技能描述写得太模糊或者技能名之间太相似导致模型分不清。解决方法是调整描述在技能描述里明确写出“这个技能适合处理什么”“不适合处理什么”。同时调度器拿到模型返回的工具调用后一定要校验技能名是否真实存在于注册表不存在就返回一个友好错误让模型重新选择而不是直接抛出异常。我通常会在调度层加一个兜底逻辑如果模型返回的skill name不在allowed_skills里就拼接一条错误消息并再次请求模型。这样比让整个流程崩溃要好得多。5.2 参数解析永远不对模型生成参数时经常出现类型错误、字段名错误、字段缺失。比如API期望参数是userId模型却传user_id。解决这个问题的核心是让参数的字段名贴合语义并且在参数schema中给每个字段加入清晰的描述和示例值。可以把参数schema想象成API文档写得越详细模型越不容易出错。必要的时候还可以在技能类的validate方法里做一次“字段名映射”把模型可能用到的别名统一转换成标准字段名。比如用户传user_name也能被修正为username。另外不要低估“枚举值”的作用。如果某个参数只有几种可选项一定要在schema里写清楚枚举值。否则模型可能创造出你从未定义过的值直接让后续流程崩掉。5.3 代理在技能之间反复横跳我遇到过一个情况代理先调用了查询库存技能然后又调用查询订单技能接着又回到库存技能看起来毫无逻辑白白浪费大量token。后来复盘发现是两个技能的描述边界不清楚导致模型认为需要交替调用才能获取完整信息。解决办法是仔细审查技能描述是否重叠必要时把重叠的边界写清楚比如库存技能描述里加一句“如需获取订单相关数据请使用查询订单技能不要使用本技能”。这种显式的互斥描述对模型决策帮助很大。另一种情况是模型在技能执行失败后卡住了不停尝试同一个技能。这时你需要在提示词里加入“如果同一个技能连续失败两次请尝试其他方案或直接向用户求助”。用这种“止损”规则防止代理陷入死循环。5.4 技能多了之后上下文爆掉很多Agent框架实现里每次调度都会把所有技能描述塞进上下文。技能少时没问题技能一多单单工具描述可能就占了上万token。更不用说代理还会带上一大段对话历史上下文很快爆掉。此时你需要考虑动态技能加载。不要一次性把所有技能描述都给模型而是先基于用户问题做一次粗粒度过滤。比如用简单的关键词匹配把候选技能缩到五六个再把这些技能描述喂给模型。这个方法在我项目里显著减少了token消耗而且模型选择准确率反而更高了。如果技能确实很多还可以引入语义检索用embedding把技能描述和用户问题做相似度匹配选Top K技能再进行调度。这相当于给代理加了一个“技能搜索引擎”。下面是一张我常用的排查问题速查表方便你遇到类似情况时快速定位现象主要原因推荐处理方式模型调用不存在的技能技能描述不清晰或注册表缺失校验技能名重新请求模型优化描述参数少传或多传schema描述不详尽补全字段描述、示例值、枚举值技能执行成功但回复很慢技能本身耗时过长缩短超时时间增加日志定位瓶颈代理反复调用同一技能技能失败后没有止损机制在提示词中加入重试次数限制上下文被工具描述占满技能太多描述太长动态技能过滤使用embedding检索权限越权调用没有做运行时权限过滤在执行引擎中增加permission检查6. 写在最后关于 agent-skills 的实现心得这些技能化改造的经验大多是我在踩了无数次坑之后攒下来的。一开始我也觉得给代理加几个工具函数就够了但等到技能数量越来越多、业务场景越来越复杂才意识到“技能”和“工具”是两个完全不同层次的概念。真正稳定可靠的代理背后一定有一套清晰的技能管理体系。如果你准备动手重构自己的代理我建议从最小闭环开始先抽出三个技能跑通注册、调度、执行、日志整条链路再慢慢把周边能力搬进来。不要一次性把几十个技能都塞进系统里那样只会让模型和人类都迷失在技能列表里。每当你觉得调度变乱时就回到技能注册表认真审视哪些技能边界不清、哪些可以合并、哪些已经多余。我最喜欢的一个细节是把技能注册表做成一个可观测的模块代理每执行一个技能都能被记录和复盘。这让我重新找回了掌控感。代理再厉害也只是在执行你设计的技能组合它不是你没法理解的“黑盒”。想清楚这一点再去设计agent-skills你的思路会比原来清晰得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价