上个月我把手上的助手项目从“一个系统同一套提示词跑到底”改成“按技能组织逻辑”之后,我才真正意识到一个问题:大部分Agent做不出来,不是模型不够聪明,而是压根没有把能力拆成可管理、可复用、可测试的单元。项目标题起得很直白,就叫“agent-skills”,它解决的问题也很朴素——当你手里这个Agent需要具备搜索、计算、查询、报表、定时推送等十几项能力时,靠一份超长系统提示词去约束大模型,注定会失控。这篇稿子就是来讲清楚我在这套体系里做了什么、为什么这样做、以及哪些坑是你看文档绝对看不到的。它适合已经跑通过一个简单Agent、想把它推进到生产可用状态的开发者,也适合把Agent能力当成产品资产来管理的团队。
我理解很多朋友看这类项目时会习惯性问一句:不就是给模型多塞几个工具函数吗?最开始我也是这么想的,但真正把仓库结构建起来之后才发现,问题根本不在“多塞几个函数”,而在于——你的系统里到底有没有一个东西能回答“这个技能什么时候该被触发、参数怎么才算合法、输出以什么结构返回、失败了往哪儿退”。这才是agent-skills这个概念真正的分量所在。
1. Agent的本事不取决于模型,取决于“技能系统”
先说一个反直觉的结论:仅仅换更强的大模型,并不会让你的Agent质变。去年我在一个内部自动化场景里做过对照实验,同一套任务流程分别用两个不同规格的模型来驱动,差距是有,但远没有把技能体系理顺前后的差距大。原因是,模型负责的是“理解力”,而技能负责的是“确定性”。一个Agent能不能稳定地完成“查会议室—订时间—发通知”这样的链条,取决于每个环节是否有可验证、可重试、可追溯的执行单元,而不是模型的临场发挥。
1.1 同样的模型,为什么换个做法效果天差地别
我当时踩过一个比较典型的场景。最初版本里,我把所有能力描述直接堆进系统提示词,让模型自己去调用一个唯一的总接口,总接口内部再分支。结果就是:模型理解得好的时候一切正常,理解偏一点就开始传错参数、漏掉必要前置操作、甚至自己编造出不存在的功能名。最折磨人的是这类错误没法复现,你在调试环境把上下文补得再完整,生产环境的用户输入一复杂,问题又冒出来了。
所以“技能”这个概念被我重新定义成:它不是一个函数,而是一个完整的能力单元。每个单元的名字、触发条件、参数结构、输出格式、超时策略、失败回退路径,全部单独写在配置里,甚至单独维护版本。这样一来,模型降级为一个“调度者”:它只负责判断该调用哪个技能、该往技能里填什么参数,而不再负责“假装记住某个功能的全部实现细节”。
1.2 技能的三个层次:原子技能、组合技能、工作流
我把技能体系分了三个层次,这套分法在后来的维护里帮了很大的忙。
- 原子技能:不可再拆的基础能力,比如“查天气”“查时间”“执行SQL查询”“发送邮件”。这些技能对应到代码里就是一个个独立函数,输入输出结构清晰。
- 组合技能:由多个原子技能拼装而来的能力,比如“生成日报并发送”就包含取数、排版、发信三个原子技能。组合技能内部可以有一些固定的编排顺序。
- 工作流:跨多个组合技能的复杂过程,比如“每周五下班前生成周报、发给直属领导、并把重点内容同步到群公告”。工作流关心的不只是技能,还包括触发时机、冲突处理、人工审批节点。
在agent-skills这个项目里,我只重点把前两层做扎实了。工作流那一层没有过度设计,因为一旦任务复杂到那个程度,问题就明显超出了“技能定义”的范畴,进入状态机和并发控制的领域了,硬塞进技能模块反而会让体系变得臃肿。
1.3 什么样才算一个“好技能”
标准其实不复杂,就五条。一,名字要能精确表达能力边界,比如“获取可用会议室列表”就比“查询信息”好。二,输入参数必须有明确的schema校验,宁可拒绝调用也不能接受脏参数。三,输出必须是结构化数据而不是一大段散文,让模型能稳定提取关键字段。四,技能要能单独测试,不依赖外部环境的意外状态。五,技能要有可观测性,调用前、调用后、失败时都要能记录到信息。
这五条每一条都是我从失败案例里反推出来的。早期版本里我最轻视的就是输入参数校验,结果模型在某个场景里传了个明显不对的日期格式,把下游系统搞出脏数据,排查了半天。后来我把所有技能的入口统一改成JSON Schema校验,不合格的参数直接返回明确错误码,错误码本身就是一个结构化输出,模型自然就知道该怎么修正了。
2. 一套能落地的“技能定义”长什么样
聊完理念,直接上手给大家看一个技能定义的真实结构。在agent-skills里我没有用特别的DSL,而是用了一份非常朴素的YAML加几个函数组合来完成技能注册。这里的关键不是技术选型花哨,而是信息完整。
skill_name: booking_conference_room description: 根据日期、时段、人数查找并预订可用会议室 category: atom enabled: true trigger_hint: 当用户表达出需要预订或查找会议室时 parameters: required: - date - start_time - end_time - capacity optional: - building - preferences schema: date: { type: string, pattern: "\\d{4}-\\d{2}-\\d{2}" } start_time: { type: string, pattern: "\\d{2}:\\d{2}" } end_time: { type: string, pattern: "\\d{2}:\\d{2}" } capacity: { type: integer, minimum: 1, maximum: 100 } building: { type: string, optional: true } output_schema: success: { status: string, room_id: string, floor: string, confirmed_time: string } failure: { status: string, error_code: string, message: string } fallback: on_timeout: retry_once_then_return_timeout_error on_validation_error: return_schema_error_with_help_message2.1 为什么必须有trigger_hint
trigger_hint这个字段是我自己加的,它不参与最终的逻辑判断,但会注入到调度提示词里。它的作用是给模型一个明确的触发线索,避免模型在相似能力之间摇摆。
举个例子,系统里如果同时存在“查询会议室”和“预订会议室”两个技能,它们的名称很接近,模型很容易选错。加上trigger_hint之后,模型看到“预订”时会优先走向booking技能,看到“查询”时走向query技能。这不是什么高级机制,本质上是给模型多喂了一条决策提示,但效果非常显著,技能命中准确率肉眼可见地提升了。
2.2 状态应该被封装在技能内部
在设计技能参数时,我坚持一个原则:尽量把计算所需的状态全部放进参数里,不要依赖技能从外部环境读变量。换句话说,技能最好是一个无状态函数,给它什么输入,它返回什么结果,不偷偷读取什么全局变量。
这在实际工程里意味着:如果一个技能需要知道“当前用户是谁”,那调用方就应该显式地把user_id放进参数字段,而不是让技能从某个全局session里自己抓。这样做的好处是调试时你可以直接拿一条参数去复现问题,不用还原整个会话。我见过太多Agent项目死在“本地正常、线上偶发”这个阶段,根源十有八九就是隐式全局依赖。agent-skills的理念就是尽量消灭隐性依赖。
2.3 技能注册表与校验
技能不能只是散落在代码里的函数,必须有一个集中注册的地方。我在项目里维护了一份技能注册表,每条记录至少包含:技能名、入口函数引用、参数Schema、输出Schema、启用状态、版本号。启动时系统会遍历注册表,做一次静态校验。
这个静态校验我非常重视。它在Agent真正被调用之前就替你拦下一批低级错误,比如参数Schema里声明了某个字段必填、但解析代码里根本没用到;或者技能入口函数不存在;或者输出Schema里缺少必填字段。这些错误如果在运行时炸开会让排查非常痛苦,而静态校验把它们变成启动即报错,成本极低,收益却很高。
2.4 再往下走一步:组合技能的编排
组合技能在agent-skills里的实现方式也很直白:一个组合技能内部维护了一个有序的子技能列表,每个子技能依赖前一个技能的部分输出作为输入,这些映射关系在组合技能的定义里写死。
我还是用“生成日报并发送”来举例,这个组合技能内部包含三个子技能:拉取数据、渲染内容、发送邮件。拉取数据的输出是JSON,渲染内容的输入是JSON,发送邮件的输入是渲染后的字符串。我指定了明确的字段映射规则,驱动器会按顺序执行,并在任意一步失败时走该步的fallback。
这里有一个容易被忽略的决策:组合技能的“顺序”和“失败处理策略”必须写在配置里而不是由模型临场决定。如果让模型临场编排,你得不到一个稳定的行为基线。反过来,把编排逻辑固定了,即使某一次单步的结果不太完美,只要每步都可控,整个组合技能的行为就是可预期的。
3. 技能从定义到调用:运行时究竟发生了什么
定义写得再好,最终都要落到运行链路里。我这里把agent-skills的运行时流程拆成了四段来展开:意图路由、技能匹配、参数填充、执行与验证。
3.1 意图路由:绝不让模型直接执行技能
在agent-skills体系里,大模型不是直接执行技能,它只做决策:判断当前用户请求命中了哪个技能,然后输出一个结构化的“技能调用请求”。这个请求包含技能名和参数对象,然后由运行时代码真正去执行技能。
这样分层有三个直接好处。第一,安全控制点在执行器这边,你可以对特定技能加白名单、限流、权限校验;第二,技能返回的错误可以被结构化成模型能够理解的下一条消息,模型可以基于错误信息自助纠正;第三,真正消耗token的对模型推理只发生在意图路由阶段,具体技能执行不会产生多余token消耗。
3.2 技能匹配:相似技能的取舍
模型在意图阶段输出的技能名,不一定和注册表里的完全一致。所以我不让运行时直接拿字符串做严格匹配,而是用一个轻量的模糊匹配层:先把模型输出的技能名标准化,比如去掉空格、转小写;然后和注册表里的技能别名做匹配;匹配不到时返回一个“技能不存在”的标准错误,并附带可用的技能列表。
这个设计是我在调试中摸索出来的。没有它之前,模型经常输出一个和注册表略有出入的名字,比如把get_meeting_info说成get_meeting_details,严格匹配直接就断了,体验相当糟。加了别名表和模糊匹配后,这类问题基本从运行记录里消失了。
3.3 参数填充:模型只负责填值,不负责判断格式
参数填充是一个看起来简单、实际最容易出问题的环节。我的做法是:先把参数Schema和技能描述一并注入模型上下文,让模型按Schema的字段名和类型去提取用户请求里的信息,然后运行时再做一次强制校验,不合法就拒绝执行并把校验错误返回给模型。
之所以要“运行时再校验一次”,是因为模型偶尔会输出字符串格式的日期、或者漏掉必填字段。如果直接把这批脏数据传进技能,段错误、空指针、脏数据问题会接踵而来。反过来,如果约束在入口处拦住,整个链路会非常干净。我在这段逻辑里最想强调的是:永远不要相信模型的输出格式,校验层不能省。
3.4 执行与验证:结构化输出和错误码设计
技能执行完之后,返回体必须是标准化JSON结构,我在项目里统一成三字段:status, data, error_code。status只有success和failure两个值。data在成功时存放结果对象,在失败时为空。error_code存放失败原因代码,便于上层进行统计和分类。
比较关键的是error_code的设计。我参考了HTTP状态码的思路,做了几类:参数错误、权限错误、上游依赖错误、超时错误、未知错误。这几种错误码在运行时分别走了不同的处理路径:参数错误让模型重读Schema,权限错误直接终止不再重试,上游依赖错误可以做一次重试,超时错误按技能配置决定重试次数。这种分类的价值在于:错误码不再只是给人看的文案,而成为调度系统的决策依据。
提示:错误码千万别写成自然语言的长句子,比如“上游系统连接失败请稍后再试”。这种错误文案看起来友好,但程序没法便捷地根据它做分支处理。标准化短码加独立消息文本,才是正确做法。
4. 我踩过的坑,和后来的处理办法
这部分是私货时间。每个做Agent技能体系的人最终都会踩到一些自己的坑,我这边挑四个比较有代表性的,希望能帮大家少走弯路。
4.1 技能粒度过粗,导致复用困难
第一版agent-skills里我把“获取预订信息并生成日历邀请”做成了一个技能。当时觉得整体很顺手,但后来要做“只获取预订信息但不生成邀请”的场景时,我只能复制一份代码。这类重复代码一旦多了,维护成本就会飙升。
后来我把技能粒度调整成“一个技能只做一件事”,再把“获取预订信息”和“生成日历邀请”拆开,用组合技能把两者串起来。这里最重要的是拆分边界要顺应业务变化的方向,而不是顺应当前单一需求的方便。多花十分钟拆解,后面能省几个小时的重构。
4.2 隐性依赖:本地一时爽,线上火葬场
前面提到过隐式全局依赖,这里再展开一个具体案例。早期某个技能需要获取当前登录用户,我图省事直接读了一个全局变量,开发时一切正常。直到某次并发压力测试,多个请求同时触发该技能,全局变量互相覆盖,用户A的数据跑到用户B的会话里去了。
排查过程极其曲折,因为错误不是每次都出现,而且日志里看不出关联。后来我把所有技能入口统一握手为显式参数列表,凡是当前用户、当前会话这类信息,一律由调用方在参数里传进来。从那以后,并发类故障就基本绝迹了。如果你在自己的Agent项目里看到类似“偶发性串号”问题,先查有没有隐式全局状态。
4.3 上下文污染:技能说明塞太多,模型反而失去重点
这个坑出在提示词工程和技能定义的接缝处。我一度为了让模型更精准调用技能,把每个技能的完整字段说明、示例、边界条件全部塞进上下文,结果上下文膨胀得厉害,模型反而在长文本里失去重点,调用准确率不升反降。
解决办法是大幅精简注入内容。每个技能在上下文中只保留:技能名、一句话描述、trigger_hint、参数名列表、关键约束。其余细节都收进技能定义文件,运行时只在模型发起调用请求后才用到它们。这其实也说明了一个底层道理:你想让模型做什么决策,就只给它那个决策相关的信息,信息过载和信息不足一样危险。
4.4 评估体系缺失:靠手感做事,迟早失控
在技能体系搭建初期,我几乎没有评估环节。每次改一段技能定义,都是自己拿十来个测试用例按一遍,感觉差不多就上了。直到有次微调了某个技能的trigger_hint,结果导致另一个技能命中率明显下降,而我只测了改动的那个技能,问题完全没被发现。
后来我搭了一套非常简单的回归测试集:每个技能准备十到二十条真实用户话术,记录正确命中的技能名和参数提取结果,每次调整后批量跑一遍。这套测试集不需要什么高级框架,纯判断调用结果字符串是否等于预期即可。效果却极其显著,它保证了我每次重构都不是在赌运气。
还有一些零碎经验也值得记一下:技能命名要选动宾结构,避免两个技能名字都叫“信息查询”之类;技能依赖的上游接口要单独做健康检查,技能自身可以报错但一定要让错误路径可控;所有技能调用必须有日志和trace_id,这样排查问题时能还原完整链路,而不是面对一堆没有关联的记录。
5. 从agent-skills延伸出来的几个实践建议
在做完这一整套技能体系之后,我对Agent工程化有了几个明显的体会,写在这里,算是给同样在摸索的开发者一个参考。
第一个建议是,技能体系一定要和模型解耦。今天你用的是某个模型,明天可能换另一个品牌、换一个规格,如果你的技能定义全部耦合在提示词细节里,迁移成本会高到让你放弃升级。agent-skills的思路是把所有技能的定义、校验、执行、错误处理都下沉到框架层,模型只保留一个决策职责,这样换模型时,技能层基本不动。
第二个建议是,从第一天起就把技能当资产来管理,而不是当临时脚本。技能要有版本号、有作者、有变更记录、有调用统计。如果你只是写给自己用的小工具,这套流程看着繁琐;可只要你的Agent会在无人值守环境里运行,资产的完整程度直接决定你的修复效率。
第三个建议是,先做出一两个原子技能跑通全链路,再扩展技能数量。不要一开始就铺开几十个技能,那是给自己挖坑。两个技能跑通以后,你会更清楚自己的运行时缺什么、评估集长什么样、错误码够不够用,这些经验会直接影响后面的扩展质量。
在我个人实测里,agent-skills这套方法把“给Agent新增一个能力”这件事从“改提示词,然后提心吊胆地观察”变成了“写一个技能定义文件,注册进去,跑一遍回归测试,上线”。这个转变带给人的安心感,是之前那种打补丁式开发完全给不了的。如果你现在正被Agent行为不稳定、能力难复用、排查链路长这些问题困扰,可以认真考虑把技能系统抽出来独立管理,而不是继续在提示词泥潭里打转。