最近一个月,我大部分时间都泡在Agent开发上。从最开始用Prompt硬怼,到后来把能力拆成一个个独立技能注册进系统,整个思路转变带来的效果提升非常明显。今天想聊的这套“agent-skills”体系,就是基于这段实践沉淀下来的一套方法。
如果你正在做AI Agent相关的东西,无论是个人的自动化助手、企业内部的知识库机器人,还是面向C端的复杂任务执行器,只要发现“提示词越写越长、模型越来越懵、效果越来越差”,那这篇文章应该能给你一些参考。我会把技能体系是什么、为什么值得做、怎么设计怎么落地、以及我实际踩过的坑,一次性讲清楚。
1. 别再用Prompt硬撑了:结构化技能体系的必要性
1.1 当Prompt膨胀到失控时,问题就来了
我先描述一个场景。早期做Agent的时候,最直接的思路就是把所有能力描述写进系统提示词里:“你可以搜索网页”“你可以调用计算器”“你可以查天气”“你可以读写文件”“你可以调用代码解释器”……一开始十几个功能还好,等规模上到几十个的时候,Prompt随便就是上万字。然后你会遇到几个非常头疼的现象。
第一个是模型开始“丢功能”。它明明“知道”自己有某个工具,但在具体任务里怎么都想不起来调用。我测试过一个案例:系统接了十几个工具,模型在需要查询数据库的时候,愣是绕路去读了本地缓存文件,结果数据都是旧的。第二个是功能之间的边界开始模糊。你定义了“日程查询”和“日程创建”两个功能,模型可能把“帮我看看明天几点开会”理解成“创建一个日程”,然后写了个根本不存在的事件进去。第三个是维护成本飙升。每加一个能力,你要重新测一遍所有旧功能会不会被新功能干扰,Prompt的调优变成了打地鼠。
我意识到Prompt不是不能做,而是它适合描述“how to behave”,不适合描述“what capabilities are available”。能力这个东西,应该被结构化地表达、注册、调度,而不是塞在自然语言里让模型自己猜。所以agent-skills的思路就是:把Agent的能力从Prompt里剥离出来,形成独立的、可描述、可组合、可调度的技能单元。
1.2 什么是技能(Skill),它和工具(Tool)的关系
很多文章里Skill和Tool是混着用的,但在我实践的过程中,两者必须分清。Tool是原子操作,比如“发送HTTP请求”“执行一段SQL”“写一个文件”。Skill则是面向任务的完整能力,它通常是多个工具调用的组合,再加上前置条件、后置处理、参数校验、异常恢复这些逻辑。
举个例子。“发一封带附件的邮件”就是一个Skill。它内部要调用联系人查询Tool、草稿生成Tool、SMTP发送Tool,还要处理附件大小超限、收件人格式错误这些情况。如果只暴露Tool给Agent,它就需要在对话中自己决定按什么顺序调用哪些Tool,出错了怎么办。这等于把实现逻辑交给了模型的自由发挥,结果必然是偶发性的失败——同一句话问十次,可能三次成功七次出错,或者每次走的路径都不一样。
Skill则把这一切收敛起来。Agent只需要知道“有个技能叫发送邮件,输入是收件人、主题、正文、附件路径”,剩下的复杂操作全在技能内部完成。这种封装带来的直接好处是:模型要做的事情更简单,错误面更小,行为也更可预测。
1.3 这套体系到底解决了哪几类问题
总结下来,agent-skills解决的核心问题有三类。
第一类是能力发现。Agent得知道自己有哪些技能、各自是用来干什么的、什么时候该用哪一个。结构化技能注册表天然解决了这个问题,只要Agent能读到技能清单,就不会出现“想不起来调用”的情况。
第二类是能力执行。技能内部的自有逻辑保证了执行过程是稳定可控的。参数校验、前置检查、调用顺序、异常处理都在代码层面写死,而不是依赖模型的临场发挥。
第三类是能力迭代。新增一个技能不需要改动系统提示词,不需要重新跑全部回归测试。只要新技能不和其他技能的触发条件强冲突,它就是独立可部署的。这点在多人协作开发Agent时尤其重要,不然每加一个功能都要互相等、互相牵制。
2. 技能体系设计:从业务任务到技能清单的拆解方法
2.1 自顶向下:先用用户意图聚类,而不是按功能堆叠
在动手写代码之前,最需要想清楚的是技能怎么划分才对。我一开始犯过的错误是“按功能类型分”:搜索类、计算类、文件类、数据库类、API调用类……看起来很整洁,但到了实际使用阶段你就会发现,模型根本不知道该怎么选。因为用户的表达从来不是“我要调用搜索功能”,而是“帮我找一下去年Q3的销售数据”。后者可能涉及数据库查询、数据清洗、生成报告、保存文档,横跨了我划分的好几个类别。
正确的做法是自顶向下,从用户的实际意图出发。你把所有你能想到的任务场景写下来,比如“查询经营数据”“生成周报”“安排日程”“发送通知”“整理文档资料”,然后对每个场景问三个问题:需要哪些信息?需要经过哪些处理步骤?最终期望产出什么?答完之后,一个场景往往就对应一个或几个候选技能。
2.2 技能的粒度:多大算合适,多小算碎片化
技能粒度的拿捏是整个设计里最微妙的部分。粒度太粗,技能内部变成一个大杂烩,什么都能干但又什么都干不好,而且复用的可能性极低;粒度太细,技能数量和触发条件爆炸,Agent光做技能选择就晕了。
我给一个比较实用的经验判断标准:一个技能应该能回答“做什么”和“怎么做完”,并且它的输入输出范围足够窄,窄到你可以用一段话准确描述它。如果你发现描述一个技能需要超过三句话才能说清楚边界,那大概率它应该拆成两个或多个子技能。反过来,如果两个技能经常在同一个任务里被连续调用,而且顺序固定,那它们可以考虑合并成一个组合型技能。
比如一开始我把“解读销售数据”和“生成数据图表”分成两个独立技能。实测中发现几乎每个请求都是先解读再画图,命中率接近百分之百,而后合并成“销售数据分析与可视化”。合并之后效果更好,因为这本来就是用户脑子里一个完整的预期,拆成两步反而制造了中间状态的传递问题——分析结果放在内存里还是临时文件?怎么传给下一个技能?这些都是额外成本。
2.3 技能描述怎么写,模型才真正看得懂
技能描述的重要性不亚于技能本身的实现。在Skill调度中,模型是通过描述来选择技能的,描述写得模糊,模型选错就是必然的。参考我大量测试后的经验,一份好的技能描述应该包含四类信息:能力概述、适用场景、关键参数说明、使用限制。
能力概述要一句话直击本质,例如:“生成一份包含趋势分析和结论建议的PDF格式销售周报”。适用场景要给出正例和反例,例如:“适用于需要用自然语言查询近30天销售数据的场景;不适用于需要逐笔明细数据的场景”。关键参数要说明每个输入字段的含义和可选值。使用限制说明什么情况下不能调用这个技能,比如依赖外部数据库不可用,或者需要管理员权限。
描述的高级技巧是“用模型的语言写描述”。什么意思呢?模型对自然语言的理解能力远强于对结构化标签的理解。与其写“data_range: str”,不如写“data_range: 本次分析的时间范围,格式为YYYY-MM-DD,支持“近7天”“本月”“上季度”这样的自然语言表达,由调用方提供原始文字”。这样模型在提取参数时,能直接把用户的原始表达映射过来,不用再做一轮“翻译”。
3. 注册、加载与调度:让Agent知道自己“会什么”和“怎么用”
3.1 技能注册表的结构设计
技能注册表是整个agent-skills体系的地基。它告诉Agent两个核心信息:系统里有哪些技能,以及每个技能怎么被调用。我用的是一份JSON格式的注册表,每个技能包含:skill_id、name、description、parameters_schema、required_permissions、entry_point。
其中skill_id是全局唯一标识;name是给Agent和日志看的人类友好名;description是按照上一节标准写的详细描述;parameters_schema是JSON Schema格式的参数说明,用于校验和引导模型提取参数;required_permissions是这个技能需要的权限级别,比如“只读”“读写”“执行外部命令”;entry_point指向技能的实际实现模块路径。
这个注册表本身要设计成语义化加载的,也就是说程序启动时扫描注册表目录,每个技能一个JSON文件,自动读入内存。增加新技能只需要放一个文件进去,不需要改主程序代码。我当时把注册表也暴露给了模型查询接口:任何一次会话开始前,Agent都会先调用“列出所有可用技能”这个内置方法,拿到当前最新的技能清单。这一步虽然有点笨,但确实有效,它保证了Agent永远不会带着过期的技能认知开始工作。
3.2 动态加载技能模块:运行时发现与热更新
注册表只是元数据,真正的技能实现需要动态加载。我用的语言是Python,所以优先考虑了模块热加载方案。具体来说,每个技能实现是一个Python文件,内部定义一个继承基础类的Skill类,实现execute方法。执行入口是这样的:
import importlib class SkillRegistry: def __init__(self): self.skills = {} self.loaded_modules = {} def load_skill(self, skill_meta: dict): module_path = skill_meta["entry_point"] module = importlib.import_module(module_path, package="skills") skill_class = getattr(module, "Skill") skill_instance = skill_class(skill_meta) self.skills[skill_meta["skill_id"]] = skill_instance return skill_instance热更新怎么做呢?监听注册表目录的文件变化事件,一旦有新增或修改,就重新加载对应的模块,注销旧的技能类。这样在运行中的Agent进程里添加新技能,完全不需要重启。开发联调阶段的体验比“改一行代码重启一次服务”好了不止一个档次。
3.3 调度逻辑:意图识别、技能匹配、参数提取、执行、结果回填
调度流程是技能体系运转的核心。我把整个链路分成五个环节:意图识别、技能匹配、参数提取、执行、结果回填。
意图识别的输出不是自然语言,而是一个“候选技能列表”。这里我不建议让模型一次性决定用哪个技能,因为一次判断出错就全盘皆输。更稳的方式是让模型先给出两到三个候选,再通过一个轻量级的评分函数做最终确认。评分函数可以考虑用户表述和技能描述之间的语义相似度、历史任务中该技能的命中率、以及技能自身标注的优先级。
参数提取是最容易出问题的一环。用户说“查一下上个月北京的销售数据”,技能是“销售数据查询”,参数列表是time_range、region、metric。模型需要完成的是从这句话里把三个参数都抠出来,这是个典型的“结构化信息抽取”任务。我强烈建议在这个环节单独给模型一次机会,用独立的LLM调用做参数填充,而不是和执行决策混在一个调用里。混在一起的结果往往是:技能选对了,参数没填全,执行阶段只能靠默认值硬跑,跑出来结果用户根本不想要。
执行阶段就是把参数传进技能实现,得到结果。这一步要注意的是超时控制。某些技能会调用外部API,外部服务可能长时间无响应,如果不设超时,整个Agent流程就被卡死了。我给每个技能设置了独立的超时时间,数据查询类的给30秒,文件处理类的给10秒,达到阈值直接返回超时错误并触发降级回复。
结果回填要做的不是简单把执行结果原样返回给用户,而是先做一次“结果整理”。执行结果通常是结构化数据或中间产物,需要翻译成用户能看懂的话。这个翻译动作也建议用独立LLM调用完成,并且把原始结果和整理后的回答放在两个字段里,方便后续追踪问题到底出在执行还是出在语言组织。
def run_skill(self, skill_id: str, params: dict): skill = self.skills.get(skill_id) if not skill: raise SkillNotFoundError(skill_id) if not skill.validate_params(params): raise ParamValidationError(skill_id, params) task = asyncio.create_task(skill.execute(params)) try: result = await asyncio.wait_for(task, timeout=skill.timeout) except asyncio.TimeoutError: raise SkillTimeoutError(skill_id) return skill.post_process(result)4. 技能实现细节:从手写代码到工具调用的关键路径
4.1 一个完整技能的代码骨架
说再多理论,不如看一个完整的技能实现。下面这个技能负责“获取指定股票的实时行情”,它是系统里最简单的一类技能,非常适合作为参考模板。
import json import requests from typing import Dict, Any from skills.base import Skill class Skill(Skill): def validate_params(self, params: Dict[str, Any]) -> bool: symbol = params.get("symbol") if not symbol or not isinstance(symbol, str): return False if not params.get("period", "day").lower() in ["day", "week", "month"]: return False return True async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: symbol = params["symbol"].upper() period = params.get("period", "day").lower() url = f"https://api.example.com/v1/quote" resp = await self.http_get(url, params={"symbol": symbol, "period": period}) data = resp.json() return { "symbol": symbol, "period": period, "price": data["current_price"], "change_pct": data["change_percent"], "timestamp": data["last_update"] }这个骨架体现了几个优秀实践。validate_params和execute分离,让校验逻辑独立于业务逻辑。真正执行的时候,技能内部所有的网络请求都应该异步化,避免阻塞Agent主线程。返回的数据要精简,只保留Agent生成回复真正需要的字段,别把整个API响应一股脑倒给模型。
4.2 基础能力库:每个技能都绕不开的通用函数
技能多了以后你会发现,大量技能都在做相似的事情:调用外部HTTP API、读取文件、查询数据库、调用命令行工具。每实现一个技能就把这些逻辑重复写一遍,是不可能有好的维护性的。所以有必要沉淀一个基础能力库,把通用动作封装成可复用的函数。
我维护的基础能力库目前包括:async_http_get/post(封装了超时、重试、User-Agent管理)、DBConnector(统一处理连接池、事务、查询结果转JSON)、FileOperator(限路径范围内的读写操作,防止路径穿越)、CommandRunner(执行白名单内的命令行,并设超时和输出截断)。
有一点要特别提醒:基础能力库的接口设计一定要从“技能需求”出发,而不是从“通用性”出发。我看到过有人把基础库设计成了一个超级抽象框架,各种设计模式、多层继承,结果写技能的人为了用其中一个函数,要搞懂四层类的继承关系。这完全是本末倒置。基础库的核心目标是让技能实现尽量短小精悍,而不是展示设计能力。
4.3 技能内部的状态管理和上下文隔离
技能执行过程不是永远一次就成功的。外部API会超时,数据库连接会断开,文件可能不存在。好的技能需要具备一定的故障恢复能力。我在技能基类里实现了一个简易的状态机:READY、RUNNING、SUCCEEDED、FAILED、RECOVERED、TIMEOUT。
当技能执行抛出异常时,基类会捕获并检查该技能是否声明了retry_policy。retry_policy里面定义了最大重试次数、每次重试前的等待时间、以及哪些异常类型允许重试。网络超时这种瞬时错误值得重试,参数错误这种永久性错误重试一万次也没有意义。重试N次仍失败后,技能返回一个结构化的失败原因,Agent拿到这个原因,可以决定是否换一个技能来兜底。
上下文隔离也很重要。每个技能执行时,应该有独立的上下文对象,里面包含输入参数、中间状态、临时文件目录、日志记录。技能之间不能直接共享上下文,只能通过返回值传递数据。这样做的原因很简单:避免了并发场景下不同技能互相污染数据的灾难性情形。我遇到过两次诡异Bug,查到最后都是全局变量被同时执行的两个技能改了,从那以后上下文隔离就成了硬性规范。
5. 实测中的翻车现场:技能调度最容易踩的四个坑
5.1 技能误判:用户只是随口一提,Agent却真的调用了
实测下来,最容易出问题的就是技能误判。典型场景是用户说“我在想是不是应该做一个数据分析类的产品”,这明显是用户的思考过程,不是下达的执行指令。但Agent听到“数据分析”四个字,可能就直接调用了数据分析技能,生成一份报告出来。
这个问题怎么办?我在技能描述里明确加入了“触发条件”和“不触发条件”两个字段,并且要求模型在决策时必须同时核对这两项。更重要的是,增加了一个“确认前置”机制:当技能的触发置信度在70%到90%之间时,不在内部直接执行,而是生成一条确认信息:“我理解你需要做XX,是否现在就开始?”等用户明确说“开始”之后再执行。
这个机制的副作用是降低了一点自动化程度,但大幅度减少了误执行带来的恶劣后果。在用户体验上,多一次确认远远好过执行一个用户根本不想要的操作。对于高权限技能,比如“发送邮件”“修改数据库”“删除文件”,我强烈建议无条件开启确认前置。
5.2 参数错位:时间范围漏了单位,模型自己脑补数值
参数提取的失败案例里,时间类参数错得最离谱。用户说“帮我查本周的销售”,模型的参数提取结果可能是“time_range: 2025-01-01, 2025-01-07”,把整个季度开头当成本周。为什么会出现这种错误?因为“本周”是一个相对时间表达,模型需要先确定今天是什么日期,才能推算本周的起止日。但很多模型的训练数据里没有当前日期这个概念,除非你在参数提取提示词里塞进系统当前时间。
我的解决方案是双管齐下。第一,参数提取之前,系统先自动注入当前日期时间戳到模型上下文。第二,Schema层面强制校验:如果time_range的格式不符合YYYY-MM-DD,或者起止日期差值超过30天,校验直接失败并触发一次重新提问:“抱歉,我需要的查询范围是在最近30天内,请确认你的时间范围。”实测下来,这两步把时间参数的错误率从大概15%降到了3%以内。
5.3 上下文污染:上一个技能的执行细节,干扰了下一次决策
Agent往往是在同一个会话里连续处理多个任务的。用户可能先让你查一个数据,然后让你根据这个数据写一封邮件,再然后让你把邮件内容更新到公司维基上。这三个任务对应三个不同的技能,按理说彼此独立。但实际情况是,第一个任务的中间日志、临时文件路径、甚至错误堆栈都留在上下文窗口里,模型在决策第三个任务时,可能会被这些无关信息干扰。
我采取的方案是把每个技能调用封装成一个闭包式的执行上下文,在执行结束后,将这段上下文内容压缩成一个“执行摘要”。摘要里只保留任务目标、执行结果、最终产物位置,其他所有中间过程全部丢弃。这样下一个技能做决策时,能看到的只是上一个技能的输入输出摘要,而不是几十行原始日志。
举个例子,“查询销售数据”技能的摘要可能只有一行:“技能query_sales执行成功,产出PDF报告链接:/tmp/reports/sales_2025_06.pdf,耗时2.1秒。”后面的“生成邮件”技能只需要知道有这个PDF存在,知道它的路径就够了,完全不需要知道查询过程里的SQL语句、API重试了几次这些细节。
5.4 技能组合时的冲突:两个技能同时都想抢占一个资源
当Agent开始连续调用多个技能时,资源竞争的问题就出现了。常见的冲突场景是:技能A申请了临时目录/agent_tmp,在后台长时间运行;技能B同时启动,也往/agent_tmp里写文件,结果A写到一半发现文件被覆盖。这类问题的本质是缺少资源隔离和锁机制。
我现在的方案是:每个技能执行时分配一个独立的工作子目录,目录名是skill_id加随机短码,比如/agent_tmp/send_email_k3j9。技能只能访问自己子目录内的文件,访问其他目录直接拒绝。对于某些需要全局独占的资源,比如“修改总配置文件”“重启后台服务”,技能基类里单独提供acquire_global_lock声明,同一时间只允许一个技能持有全局锁,其余技能等待或超时返回忙。
这套机制引入以后,技能组合执行的成功率从原来的85%出头,稳定提升到了97%以上。剩下的3%大多来自外部服务的偶发故障,已经不在技能内部能解决的范畴。
6. 技能的可观测性与自省:别让Agent成为一个黑盒
6.1 技能调用日志:记录每一次“思考”与“执行”
Agent开发里最容易被忽略、但后期最救命的,就是完整的调用日志。我的日志体系分三层:决策日志、执行日志、结果日志。
决策日志记录的是模型每次调度技能时的思考链,包括候选技能列表、每个候选的置信度、最终选中的技能ID、以及选中的理由(从模型返回的思考文本里提取)。执行日志记录的是技能运行过程中的关键节点,比如参数校验通过、开始调用外部API、API返回状态码、重试了几次。结果日志记录的是技能最终的返回内容和整理后的用户回复。
这套日志的价值在出问题时体现得淋漓尽致。有一次用户反馈Agent答非所问,我翻日志发现模型决策时选择了“生成报告”技能而不是“查询数据”技能,置信度分别是0.72和0.68,就差一点。这一眼就定位到是技能描述边界不够清晰,我调整了描述之后这个案例就再没出现过。
6.2 技能回放与离线评测:改了一个技能,敢不敢直接上线
还有一个关键实践是离线评测。Agent的技能调度涉及模型行为,模型是非确定性的,你不能只看一两个案例通过了就认为改了描述没问题。我的做法是准备一个评测集,里面每条样本是一条用户请求加上期望调用的技能ID和期望参数。任何一次技能描述或调度逻辑变更,都先在评测集上跑一遍。
评测指标我用的是三个:技能选择准确率、参数提取准确率、任务完成质量评分。前两个可以自动化计算,第三个需要结合人工抽查。我给自己定的标准是:技能选择准确率不能低于95%,参数提取准确率不能低于90%,否则变更不能上线。不要觉得95%很高,真正测试下来,一个有经验的人写的技能描述,配合清晰的参数Schema,达到这个标准是可行的。
每次运行评测还会生成一份错误的case列表,我会逐个去看错在哪。大部分错误集中在描述中的反例不够充分、参数说明存在歧义这两个原因。修掉之后重新评测,通常就能过线。
6.3 技能健康度监控:上线只是开始,持续调优才是常态
技能上线之后,还有一个长期工作要做:健康度监控。我给每个技能挂了三个指标:调用成功率、平均耗时、用户反馈负面率。这些指标按日聚合,出现异常时自动告警。
调用成功率下降通常意味着技能依赖的外部服务出问题了,或者是参数提取的质量下降导致校验大量失败。平均耗时上升要排查是外部API变慢了还是技能内部逻辑出现了意外的循环等待。用户反馈负面率是最直接的信号,用户在原回答上点了“不满意”,这个数据必须和技能调用日志关联起来看。
我把健康度数据和技能描述做了一次联动:当一个技能的负面率连续三天超过阈值,系统会自动在技能描述开头插入一条警告字段:“该技能近期用户反馈较差,使用时请确认用户真实意图,若不确定建议换用其他更合适的技能或直接询问用户”。这看似是个笨办法,但实测确实起到了作用——模型读到这个警告后会倾向于多问一次确认,而不是无条件执行,间接减少了坏结果的发生。
说完这些,说说我现在的体会
把Agent的能力从一段段Prompt改成一套结构化、可注册、可调度、可观测的skills体系,这个过程最大的感受是:开发心态完全变了。以前每改一处Prompt都提心吊胆怕影响别的功能,现在新增技能就像往工具箱里放一把新螺丝刀,独立、清爽、可测试。技能体系的建立也让我终于敢把Agent放到生产环境里处理真实的用户请求,因为每一个技能调用都有日志、有监控、有回放,出了问题能快速定位。
如果你也正被Agent的不可控性搞得焦头烂额,不妨先从拆一个最常用的功能开始,把它做成第一个技能,搭好注册表和调度链,然后再慢慢把其他功能迁移进来。不要想着一步到位,先跑通一个小闭环,你会很快感受到这种方式的差别。