ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent Skills实战:技能抽象、注册、调度与评估全解析

Agent Skills实战:技能抽象、注册、调度与评估全解析

这个标题我盯了挺久,单看“agent-skills”确实容易懵,但如果拆开看,它就是我在做AI Agent时绕不开的一件事:如何把大模型能用的能力,拆成一个个可复用、可管理、可测试的“技能”。我之前用某个开源框架搭过一个内部项目,名字也叫这个,折腾了近两个月,踩了不少坑,也沉淀了一套自己的做法。这篇文章就把“给Agent做技能”这件事从设计到落地完整梳理一遍,适合正在搭Agent应用、又对技能管理没什么头绪的开发者参考。刚接触的朋友也能跟着走,重点不在框架绑定,而在思路。

1. 先把“Agent Skills”这个概念掰开揉碎

1.1 它到底解决什么问题

日常开发里,大家管大模型能调用的东西叫“工具”或“函数”,比如查询天气、发邮件、算个数学题。但一个人如果只会用固定几个函数,能力上限很低。我见过很多Agent项目,最后卡死的点不是模型不行,而是技能太散,没有规划。技能在这里的定义,不是简单挂一个API,而是把一类能力做成一个完整单元:包括触发条件、参数约定、执行逻辑、返回规范化、失败兜底等等。

为什么“技能”和“工具”要分开说?工具通常指一次函数调用,无状态、简单直接。技能则更接近人的“会做某件事”,它可能包含多个工具调用的组合,甚至包含自己的内部状态。举个例子,查天气是工具,而“安排一次户外活动”是技能,它会拆成查天气、找场地、看看时间线是否冲突、生成行程安排。把后者作为一个技能封装好,Agent遇到类似需求时就不用每次都从零组合链路,准确率和稳定性都会有明显提升。

1.2 为什么不是单纯插件化

市面上插件化方案很多,但实践下来我有个感受:插件解决的是“Agent怎么加载外部代码”,而技能解决的是“Agent怎么理解自己会做什么”。两者很容易混淆。插件偏工程结构,技能偏语义抽象。我的项目前期只做插件化,结果模型经常选错工具,因为插件里面的函数描述粒度太细,几百个函数堆在提示词里,模型根本不知道什么场景该用哪个。

后来我把粒度拉到“技能”这一层,一个Agent只有二三十个技能,每个技能内部再封装若干更细的操作。这个抽象层就像一个中层管理者,不会直接把所有基层员工的话都讲给老板听,老板只需要说“我要办个活动”,剩下的事情由中层下发和协调。因此,标题里的agent-skills,在我的理解里就是一套面向Agent的技能抽象、注册、调度与评估方案。

1.3 我这个项目长什么样

项目不大,核心概念就四个:技能描述、参数Schema、执行器、状态上下文。技能描述是给模型看的说明书,参数Schema约束输入格式,执行器干实事,状态上下文保存技能内部运行的数据。数据流长这样:用户提问进Agent,模型基于技能清单决定调用哪个技能,然后按Schema补全参数,执行器跑起来,结果回归到对话里。整个过程我加了一层“技能注册表”,统一管理所有技能的名称、描述、参数和版本,方便增删和灰度。

很多刚上手的人会问,这不就是给模型加几个函数吗?其实区别很大。如果我往Prompt里塞一堆函数,上下文一长模型就开始“乱点菜单”。技能注册表的关键是让模型每次只需要看到当前场景相关的技能描述,而不是全量列表。也就是后面要说的意图路由加动态技能加载,这部分我在第3章详细展开。

2. 技能设计的第一课:把接口当成“人和模型都要读的说明书”

2.1 技能命名与描述:模型决策的主要依据

技能命名首条原则是动词开头,且动词要具体。比如“create_trip_itinerary”比“trip”好,“generate_sales_report”比“report”清晰。我看到有的项目给技能起名特别文艺,比如“wanderlust_helper”,模型真的不一定认识。别高估模型对模糊语义的推理能力,它需要直白。

描述部分我习惯采用“场景 + 条件 + 边界”三层写法。第一层讲清楚这个技能适合什么场景,如“当用户需要安排出差的交通工具和住宿时”;第二层说明必须具备的条件,如“必须至少有目的地和时间段”;第三层列边界,比如不负责订餐、不负责报销。写这部分时可以参考写Prompt的经验,甚至可以把反例写进去,例如“不要在其他技能已经处理了机票后再调用本技能”。

我把一段高质量的技能描述视为一份“隐性约束”,比在代码里写一堆if判断还要节省成本。模型决策越准确,后续纠错就越少。我自己的经验是,写完描述后要自己读三遍,去掉所有行业黑话和模糊修饰语。描述里出现的每个名词,最好都能在参数Schema里找到对应字段,否则模型会尝试从对话里猜一个并不存在的参数。

2.2 参数定义:宁可多写约束,也别让模型自由发挥

定义参数时最影响体验的是“过度自信”。早期我的参数只写了类型和一句注释,比如city: string,结果模型把“我们下周去杭州”解析成city: 杭州,表面没问题,但我的技能内部用的是城市编码,不是中文名,于是每次都要先做一次翻译调用,既慢又容易出错。后来我把参数Schema写成完整JSON Schema,加上description,加上enum枚举,加上pattern正则限定,准确率马上就上来了。

这里有个关键细节:JSON Schema中每个字段的描述要独立成句,不要依赖外层字段的语境。因为模型在抽取参数时,每个字段的提示是相对独立的。我会为日期类字段写明时区,为枚举类字段列出所有合法值,为数量类字段写明默认单位。比如安排行程的技能里,start_date和end_date这类字段,如果不写明“日期格式为YYYY-MM-DD”,模型很可能输一个“下周五”,执行器解析时就得写一堆兼容逻辑。

参数的必填程度也很重要。我建议必填参数控制在两到三个,其余都设成可选且带合理默认值。模型在信息不足时倾向于“补全”而不是“反问”,与其让它补错,不如允许它省略。省略后技能自己给出兜底方案,比如默认时间区间是一个自然周,默认城市取用户IP所在城市。这样对话流畅度会好很多,而不是每一步都要返回“请提供更多信息”的追问。

2.3 返回结果设计:结构稳定比内容丰富更值钱

技能的返回值,不是给人看的,而是给模型下一轮决策看的。因此它必须高度结构化。我见过有人返回一大段格式化文本,比如“根据查询,杭州未来三天的天气分别为晴天、多云、小雨……”,模型后续如果需要计算“哪天适合户外”,就得重新理解这段文字,容易出错。我的做法是任何技能都返回JSON对象,核心数据字段字段名统一,并且额外附一个natural_summary字段,里面写好一段可以直接用于回复用户的话术。

举个例子,一个查天气技能返回结构是{ "city": "hangzhou", "days": [{ "date": "...", "condition": "sunny" }], "natural_summary": "杭州未来三天以晴到多云为主... " }。模型处理后想直接念给用户听也行,想再进一步分析数据也有结构化字段可用。这样既保证了机器可读性,也照顾了语言表达自然度。

另外,返回结果里我强制要求带success字段。如果是失败,必须带error_code和friendly_message。模型看到失败结果后,可以直接基于friendly_message向用户解释,不需要自己编原因,有效避免“幻觉式道歉”。这一点很多人刚开始不重视,等Agent频繁一本正经地撒谎时才想到补上,但每次补都牵扯很多调用方改动,不如一开始就定好。

3. 落地实现:注册表、路由与调用链

3.1 技能注册表设计

技能注册表是整个系统的核心数据结构。我把它看成一个“目录”,记录每个技能的名称、描述、Schema、版本、启用状态、负责人信息等。实际项目里我用Python表示这个结构,非常简单:

@dataclass class AgentSkill: name: str description: str parameters_schema: dict version: int enabled: bool handler: callable

真正的注册表更复杂,我还会加上tags、visibility、rate_limit、owner_team等字段。tags用来给技能打标签,比如travel、office、analysis,方便在意图路由阶段做粗筛。visibility控制这个技能是仅内测、还是全员可用。rate_limit防止某些重负载技能被频繁调用导致系统被拖垮。

还有一点容易被忽略:技能版本。模型看到的技能描述是实时读注册表的,但技能执行逻辑如果变了,旧对话还在继续,就存在“描述匹配新逻辑、旧对话按旧逻辑记录”的错位问题。我在注册表里加了版本号,并在对话上下文中记录每次调用时的技能版本。如果出现新版本,旧对话继续用旧版本描述回放,不让模型感知到突变。

3.2 动态技能加载与意图路由

前面提到,不要让模型每次都看到全量技能。一开始我把100多个技能全塞给模型,效果惨不忍睹。后来我引入了两级筛选:第一级是轻量的意图路由,用另一个小模型或规则引擎判断用户当前请求大概涉及哪些领域,比如“订机票”命中travel和booking两个标签;第二级再把这些标签下最多10个技能的全量描述交给主模型去决策。

这种方案比起硬塞全部技能,最直接的好处是节省了上下文Token,响应速度也快。缺点是如果路由阶段筛错了,主模型根本看不到正确技能。我的缓解办法是做“相关技能推荐”,在路由结果里额外附带一到两个由向量检索召回的相似技能,作为缓冲。在进行技能选择时,主模型看到的是一个排序后的技能候选列表,而不再是无序的长列表,决策质量明显提升。

路由实际上是整个技能系统里最容易被低估的模块。很多人把精力花在执行器上,忽略了“什么时候不调用哪个技能”同样关键。我在路由层专门维护了一份“反例表”,记录哪些请求不该调用哪个技能。比如用户在闲聊“今天天气不错”时,并不需要真的走天气查询技能,反例表能帮助路由排除这种误召回。

3.3 调用链与错误处理

Agent调用技能时,常见做法是模型自己决定调用顺序,比如先查天气再定场地。这样做灵活,但代价是不可控。我在项目里允许技能内部声明“前置依赖技能”,并在注册表里固化成调用图。例如“安排室外活动”技能声明依赖“查询天气”技能,Agent一旦选中前者,系统会自动先执行依赖项,并把结果注入上下文。

调用链一旦拉长,错误处理就变成大问题。我的原则是:错误尽量在技能内部消化,不要把原始异常堆栈丢给模型。技能上层捕获异常后,统一转换成前面说的error_code和friendly_message。如果错误可以被重试修复,比如上游API临时超时,那技能内部先重试两次;如果不行,就返回friendly_message说明原因,让模型决定要不要换一条路径。

try: result = run_skill(skill_name, params) except SkillRetriableError: result = run_skill_with_retry(skill_name, params, max_retries=2) except SkillFatalError as e: result = { "success": False, "error_code": e.error_code, "friendly_message": e.friendly_message, }

另外,我在系统里设了一个“长耗时技能”的异步队列。凡是预计执行超过10秒的技能,调用后立即返回一个task_id,由后台Worker继续跑,并发完成后再主动写回上下文,并通知模型做后续总结。这种设计对用户体验帮助很大,但实现上有不少细节,比如任务状态要能被模型查询,否则模型会认为任务已经失败。

4. 让技能真正可用:测试与评估不能省

4.1 单技能回归测试

技能不是写出来就完事,需要测试体系长期兜底。我在项目里给每个技能配了一套“输入样例”和“预期输出结构”的测试用例。测试时不仅校验返回结果的JSON结构,还要校验返回数据的语义是否合理。比如查天气技能返回的日期是否落在合理区间,温度是否在现实可接受范围内,而不是只看字段类型对。

我见过不少项目,测试只做“能调用通就过”,结果某天上游数据源字段变了,返回里塞进一堆乱码,结构上还是JSON,但内容已经完全不可用。这类问题需要断言字段值范围。比如数量字段要断言非负,日期字段要断言能被datetime.fromisoformat解析,枚举字段要断言在合法集合内。每一条断言都是在给模型减少一次“一本正经胡说八道”的机会。

还有技能之间的交互测试,比如依赖别的技能时,要验证自己假装依赖技能返回几种典型输出,观察整体是否还能跑通。我常用“Mock依赖技能”的方式测,把一个技能依赖的所有外部能力全部替换成可控的假数据,这样能快速定位问题是出在本技能还是出在外部依赖。这个方法在团队协作时特别省时间。

4.2 端到端场景测试

单技能测试通过不代表Agent整体好用。端到端测试我通常准备20到30个真实用户场景,覆盖常见路径和边界路径。比如针对“会议安排助手”,场景包括:正常安排、时间冲突要重新协调、参加人不在通讯录、日期表述模糊等情况。每个场景跑完之后,人工检查三个点:技能选择是否有误、参数解析是否合理、最终回复是否自然。

端到端测试里,我会记录整个调用链的日志,尤其是模型中间做了哪些决策。如果发现模型在某个岔路口选错了技能,我会回头检查是这个技能的描述有歧义,还是候选列表里混入了相似技能。这里有个经验:描述写得“看起来文笔很好”并不够,还要做“互斥性检查”——把几个相似技能的描述放在一起,人工判断是否区分度足够,如果自己都分不清,模型大概率也分不清。

我也在项目里做了一个简单的自动评估脚本,针对每个场景判断“是否调用了预期技能”“参数是否满足约束”“返回是否安全”。虽然这套脚本做不了语义级别的全面判断,但在每次改动后跑一遍,至少能挡住八成的低级回归问题。配合人工抽查,整体质量会稳定很多。

4.3 安全边界与权限控制

技能本质上是能执行外部操作的能力,权限设计必须提前想清楚。尤其在自动化场景里,一个技能如果能发邮件、删日历、转账,那模型的任何一个误判都可能导致实际损失。我给每个技能配置了权限等级:只读、可写、高危操作需二次确认。只读类技能自动执行;写操作类技能必须在结果摘要里展示给用户,争取确认;高危操作更是要求用户原句复述确认,避免“是”被误判。

还有一个隐蔽问题:技能的执行权限与当前会话的用户身份强相关。比如一个技能接口可以查看所有人的日程,但当前用户只有查看自己的权限。如果不在技能层做权限过滤,模型可能会把越权数据正常展示出来。我项目里的实践是,所有涉及个人信息或私有数据的技能,在执行器内部通过一个统一上下文对象读取当前用户ID,并在SQL查询中强制带该条件,绝不依赖前端参数。

另一个容易漏的是技能输入内容的注入风险。模型解析出的参数可能包含恶意字符串,比如把一段提示当作城市名传进来。技能执行时如果直接拼进系统指令,相当于变相注入。我要求所有参数都必须经过Schema校验,且凡是会作为系统指令一部分的字段,一律做转义或白名单化处理。越是开放性的技能,越要防止这类问题。

5. 实操中的坑和排查记录

5.1 模型死活不调用技能

这是最常被问的问题。模型不调用,首先排查的是技能描述权重不够,而不是模型笨。我的排查顺序是:确认这个技能的描述在不在模型当前能看到的候选列表里;然后看路由是否把它筛掉了;再确认描述里有没有和用户问句明显重复冲突的词。另外一个常见原因是候选技能太多,模型选不过来。我遇到过五十多个技能同时加载时,模型会把好几个能力合并执行,结果没有一条走对。把候选列表压到十个以内后,问题基本消失。

如果依然不调用,我会做一个小实验:把用户输入里相关的实体词挖出来,直接拼到技能描述之后形成一个“唤醒片段”。这个做法听起来有点粗暴,但在一些复杂指代场景里挺管用。比如一段对话中用户说“把它挪到周四”,我不单独靠“它”来解析,而是把上一轮明确提到的事件名称作为技能描述的一部分呈现,模型看到具体实体后,调用意愿明显提高。

5.2 技能描述互相打架

相似技能的边界模糊是常态。比如“查询天气”和“查询空气质量”,描述里都出现“天气”“环境”“城市”这样的词,模型就会乱。我解决的办法是在描述里明确“专属场景词”。比如空气质量技能里写“当用户提到PM2.5、AQI、空气质量指数、适合跑步的空气质量时”,天气技能里则写“当用户问今天会不会下雨、气温多少、是否适合户外活动”,通过专属触发词把两个技能彻底分离。

更复杂的情况是技能功能重叠,比如“创建待办”和“更新日程”都能向日历加事件。我会在代码层面做一层前置逻辑,而不是全靠模型自己区分。具体做法是注册表里加“优先级”字段,当两个技能同时被选中时,高优先级技能先执行,另一个由系统自动忽略并提示。这样可以避免一个事件被重复创建两次。

5.3 上下文塞得太满

动态加载技能能缓解上下文压力,但不能完全解决。我发现真正让上下文爆炸的往往是“技能返回结果太长”。有些技能执行器返回了完整列表,比如一百条记录全部塞进对话,模型后面对话基本被这部分数据拖垮。我的策略是返回结果强制“截断+摘要”。超过50条记录时,只返回前5条和最关键的统计量,并提供full_data标记,模型需要更多数据时,再由另一个技能去按需分页查询。

还有一种情况是多轮对话中历史技能结果一直留在上下文中,导致新请求被旧数据干扰。我在关键节点会主动清理技能执行的中间结果,只保留对当前用户问题真正必要的部分。比如用户已经订好行程,回到主菜单问别的功能时,我会把订票相关的长结果从上下文中压缩成一两条摘要,避免模型把它们误当作当前任务的参考。

5.4 技能状态管理与重入性

技能带状态是常态,但状态管理非常容易出错。例如一个多轮对话里用户在慢慢补全旅行信息,技能内部保存了“当前正在规划的行程”状态。这个状态放在哪里很关键。放全局变量会导致不同用户的会话互相污染,放对话历史里又可能被模型遗忘。我的方案是做独立的会话状态存储,以对话ID为Key,技能可以在任意阶段读改写这个状态。

状态管理还要考虑“重入”问题。用户可能在某个技能执行到一半时突然切换话题,然后又切回来。如果不做状态快照,再回来时就会从头开始。我用的方案是为关键进度节点打标签,技能再次被选中时,通过标签判断是重新开始还是续跑。比如订票技能里已经选了座位,用户切出去查天气再回来,系统能直接从下一步继续,而不是重新问一遍目的地。

6. 后续我还在折腾的方向

做完这个项目之后,我意识到技能系统的价值不只在于让单个Agent更聪明,而在于它把Agent的能力变成了一种“可积累资产”。我现在在尝试把一些内部验证过的技能打包成跨项目共享的技能包,不同项目之间通过统一注册中心同步技能目录,这样新项目开场就能复用成熟能力,不用从零写起。

另一个方向是技能评估常态化。我计划给每个技能在真实流量中的表现做监控,比如调用成功率、用户反馈倾向、参数解析出错率,把这些指标作为技能“健康分”,低于阈值的技能自动降级或通知作者修改。这套机制如果跑通了,技能的迭代就能从人工拍脑袋变成数据驱动,整体质量会更稳。

最后一个想法是技能版本与模型版本的关系。同一个技能,面对不同能力和风格的模型,描述可能需要微调。我在考虑建立“模型能力适配层”,根据目标模型的最大上下文和工具调用能力,动态裁剪技能描述长度和复杂度,争取一套技能适配不同模型。这个方向还在探索阶段,但个人认为比给每个模型写一套技能方案要可持续得多。

回头再看“agent-skills”这个标题,我最大的体会是:做Agent不是堆功能,而是在搭一套能力管理系统。技能描述要清晰到被误解的概率尽可能低,参数要严格到乱猜的成本足够高,执行器要稳到失败了还能优雅降级。把这几件事做好,Agent的上限才能被真正打开。对我来说,目前最满意的就是那套动态技能加载和统一返回结构的设计,它让新技能接入变得很快,也让整个系统在复杂场景里依然稳定。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进