去年我在做某个智能客服项目的时候,遇到过这样一个问题:大模型本身已经能理解用户意图,但真正要落地,它必须去查订单、改预约、调库存。当时我们的团队自己定义了一套内部工具调用协议,每个成员按自己的习惯写,代码里全是if-else判断,维护成本越来越高。后来整套系统用“agent-skills”的思路重新整理之后,效果立刻不一样了:技能边界清晰、调用路径稳定、模型选择率也上来了。这篇文章就是我对这套技能体系的一次完整复盘,内容包括设计思路、标准化定义、代码实操、常见坑点和进阶心得,适合正在做智能体应用落地、或者已经在用大模型做自动化任务的同学参考,无论你是在做客服机器人、办公助手还是某个垂直领域的工具型智能体,这套方法都能直接用。
1. 智能体技能系统设计与拆解
1.1 技能到底是什么
很多人一听到“agent-skills”,第一反应是“这不就是给大模型接几个API吗”。最开始我也这么想,但实际做下来之后发现,如果只是单纯把函数挂给模型调用,你会遇到一个很尴尬的局面:模型有时候能调对,有时候明明有这个工具却不用,有时候又张冠李戴,把A工具的参数传给B工具。
这里面的核心问题在于,我们给模型的不应该只是一个“函数列表”,而应该是一套完整的、结构化的能力描述体系。技能(skill)本质上是一个“原子操作单元”,它包含明确的触发条件、输入输出格式、执行逻辑以及模型判断是否该使用它的全部上下文信息。
我给它下了一个操作层面的定义:技能是智能体为了完成某一类任务而封装的功能模块,它必须能被大模型根据当前对话状态自主选择,并按照约定格式完成参数传递和结果返回。这个定义里最重要的就是“自主选择”和“约定格式”这两点,前者依赖技能描述的清晰度,后者依赖代码实现前的协议设计。
在实际项目里,技能不一定非得是“调用外部API”这么复杂,它可以是一个简单的本地计算、一个数据库查询、一段规则脚本,甚至是一个固定的回复模板。关键是它在整个智能体系统里有明确的边界、入口和出口,可以被单元测试覆盖,可以被日志追踪,这样整个系统才具备可维护性。
1.2 为什么需要一套“技能”而不是一堆函数
我见过很多失败的项目,问题几乎都出在同一个地方:开发人员把能力工具直接以函数方式罗列给大模型,没有任何组织和管理。什么叫罗列?就是几十个工具函数扔在同一个目录下,没有统一命名,没有参数规范,描述随便写一句或者干脆不写。这种做法的直接后果是模型“看不到”工具之间的关系,也无法判断哪个工具更合适。
一套良好的技能体系要解决的不只是“怎么调用”的问题,还要解决“什么时候该调哪个”“参数怎么传”“失败怎么办”“多个技能怎么协同”这些更上层的问题。这有点像开餐厅,材料都放在厨房里,但如果没有菜单、没有配菜标准、没有出菜流程,厨师(模型)可能把所有材料乱炒一通。
在我们设计的体系里,每个技能都有独立的描述文档、参数Schema、执行入口和返回结构。这些元素组合在一起,变成了一份模型可以“理解”的说明书。例如“查询天气”这个技能,描述字段里不仅写“查天气”,还要写明“用户提到城市、日期、天气这些词时,使用此技能查询目标地点在未来指定日期的天气情况”,这比一句干巴巴的“天气查询”要有效得多。
另外一点,技能体系还承担着“权限边界”的职责。某些操作比如“发送邮件”“修改数据库”,在真实系统里是有风险的动作,我们不能让模型随意调用。在技能设计阶段就应该给每个技能标注权限级别、确认策略、以及是否允许无条件执行。这个问题放到后面章节详细展开,但设计层面必须从一开始就考虑。
2. 技能的设计与标准化:决定模型“看得懂也找得对”
2.1 技能命名与描述:决定大模型能不能找到你
技能命名这件事,看起来简单,实际操作的时候最容易翻车。我见过一套系统里既有“get_weather”又有“query_temperature”,模型根本分不清。按理说,工具名称应该一眼能看出用途,并且相互之间要避免语义重叠。
我给团队定了几条硬性规范:技能名称统一使用动词开头;业务域一致的功能必须统一前缀;名词部分尽量贴合领域术语而非大白话。比如在客服系统里,订单相关的技能统一以“order_”开头,后面接“query”“modify”“cancel”等;价格相关以“price_”开头。这样做的好处是,模型在处理请求时可以通过语义关联快速定位到目标技能组,而不是在海量函数名里猜测。
描述字段比命名更重要。很多模型的函数调用准确率不佳,根源在于description写得太敷衍。一个合格的技能描述应该包含以下几个层次:首先,说明技能的功能,尽量用一句话说明“在什么场景下使用该技能”;其次,列出技能不擅长或不做的事情,主动排除干扰项;最后,如果技能依赖前置条件,必须在描述里写明,例如“该技能仅用于查询已支付订单,退款中的订单状态请使用order_refund_status查询”。
我自己的经验是,描述写得越具体,模型选对技能的几率越高。你可以把它理解为给实习生写交接文档,不能只说“帮我处理订单”,要告诉他“什么样的订单该走什么流程,遇到什么情况别碰”。
2.2 输入输出参数:不要靠猜,要靠Schema
参数定义是技能系统里最基础也最严谨的部分。大模型本质上是在做文本生成,它读入的是参数的JSON Schema描述,生成的是具体的JSON值。如果Schema定义不清晰,模型就会“自由发挥”,结果就是参数类型错误、缺字段、多字段这些低级问题高频出现。
我们在实践中统一用JSON Schema来描述每个技能的入参和出参。入参方面,每个字段都必须定义清晰的类型、是否必填、字段含义和可枚举值。举例来说,“查询订单”技能需要order_id,那就要明确这个order_id是什么格式、正则表达式是什么,是纯数字还是可以带字母前缀,这些信息全都要写进字段描述里。
出参也必须严格结构化。模型一般会根据技能返回的结果来生成给用户的自然语言回复,如果返回结果结构混乱,回复就会前言不搭后语。我们的做法是给每个技能定义统一的ResponseBody结构,至少包含code、message、data三个字段,data内部再按业务需要细分子结构。这样即使技能内部实现变了,对外输出的基础结构不变,模型端的prompt就可以保持稳定。
这里还有一个很容易被忽略的细节:参数名不要贪图简短。像“t”“q”“r”这种单字母参数,模型根本猜不出是什么意思。宁可参数名长一点,比如“target_city”“start_date”,也要保证模型能够理解字段含义。在LLM应用场景里,明确性远大于简洁性。
2.3 技能注册表与清单管理
当技能数量超过十几个,手动管理就开始变得不可靠。我们项目里最多的时候有四十多个技能,如果没有统一的注册表,你根本不知道某个技能是否已上线、当前版本是什么、依赖哪些服务。
我建议用一份集中式的技能注册表来管理所有技能。注册表里记录的字段至少包括:技能ID、技能名称、版本号、描述摘要、入口函数路径、依赖服务、超时时间、权限级别、是否启用。这份注册表可以是YAML文件、数据库表或者一个常驻内存的注册对象,务必确保它是系统里唯一可信状态。
技能注册表的存在,还方便我们在启动时做合法性校验。比如一个技能配置了入口路径,但代码里没有对应的函数,启动阶段直接报错,而不是等模型调用的时候才爆出“工具不存在”的运行时异常。这种做法让我们省去了大量排查时间。
另外,注册表里要记录技能的“可见性”。有些技能尚在开发阶段,不希望模型调用,那就标为“disabled”;有些技能只对部分用户开放,那就做灰度字段。这里的核心思想是:技能上线是一个需要明确“发布动作”的过程,而不是代码合并就算上线。
2.4 技能元信息:版本、权限与成本
版本管理容易被小型项目忽略,但技能一经上线,就会有大模型在真实流量里调用,如果你直接修改函数逻辑而不维护版本,前后的行为差异就会变得不可控。我们的做法是给每个技能维护一个version字段,并让技能编排层根据版本号来路由请求,这样线上跑旧版本、测试环境跑新版本互不干扰。
权限控制和成本控制是技能元信息里比较进阶的话题。一个常用的策略是在技能定义里增加“requires_confirmation”字段,标记为true的技能在模型调用前,需要先向用户发起确认。比如“删除文件”“执行转账”这类危险操作,必须经过人工确认。同时,给每次技能调用计算成本,记录token消耗、外部API调用耗时,这样你能知道哪个技能是最烧钱的,做针对性的优化。
元信息设计不要一步到位,建议最小集起步,后面按需扩充。最基础的版本号、超时时间、权限级别、启用状态这四项必须有,其他的比如调用频控、成本阈值、备案人等字段,可以直接写在注册表里作为扩展字段,不用一开始就规范化得很重。
3. 技能开发的实操过程与实现要点
3.1 先搭一个干净的目录结构
实操的第一步,不是急着写代码,而是先把项目结构规划好。我推荐按技能类型建目录,而不是按代码模块划分。比如下面这个结构,是我在一个模拟项目里实际使用的:
agent-skills/ ├── registry/ │ └── skills.yaml ├── skills/ │ ├── order_query/ │ │ ├── __init__.py │ │ ├── schema.py │ │ ├── handler.py │ │ └── README.md │ ├── order_cancel/ │ │ ├── __init__.py │ │ ├── schema.py │ │ ├── handler.py │ │ └── README.md │ └── weather_lookup/ │ ├── __init__.py │ ├── schema.py │ ├── handler.py │ └── README.md ├── engine/ │ ├── dispatcher.py │ └── executor.py └── tests/ ├── test_order_query.py └── test_weather_lookup.py每个技能目录里有三个核心文件:schema.py负责定义参数Schema,handler.py负责实际业务逻辑,README.md是为这个技能写给人看的文档。registry目录下放统一的技能注册表。
这个结构的好处是,每个技能的所有相关文件都在同一个目录里,不分散;新增技能只需要复制某个目录再修改对应的三个文件就行;测试文件按技能对应,容易定位回归测试案例。
3.2 写一个完整技能:天气查询实战
我们拿“天气查询”技能举个例子,它简单、实用,很适合用来演示整个技能开发流程。我全部用Python和标准数据类型来写,方便你直接照搬。
第一步,定义参数Schema。在schema.py里,我们需要定义技能入参和返回结构:
# skills/weather_lookup/schema.py WEATHER_QUERY_SCHEMA = { "name": "weather_lookup", "description": ( "查询指定城市在指定日期的天气情况。" "当用户提到城市、天气、温度、降雨、出行建议等关键词时使用。" "仅支持查询未来7天内的天气,不适用于历史天气查询。" ), "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "目标城市名称,例如北京、上海、广州", "minLength": 2, "maxLength": 20 }, "date": { "type": "string", "description": "查询日期,格式YYYY-MM-DD,仅限今天起未来7天内", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } }, "required": ["city", "date"] } }这里有几个值得注意的点。描述字段覆盖了“什么时候用”和“什么时候不用”,避免模型在用户问历史天气时错误调用;city字段我限制了长度,防止用户输入乱码;date字段用了正则校验,确保模型传参格式符合预期。
第二步,写handler逻辑。handler是技能的执行入口,它的输入是已经通过Schema校验的参数,输出是标准化的返回结构:
# skills/weather_lookup/handler.py import datetime def handle_weather_lookup(params, context): city = params["city"] date_str = params["date"] # 业务上做一个简单的日期校验,防止模型传了非法日期 query_date = datetime.date.fromisoformat(date_str) today = datetime.date.today() if query_date < today: return { "code": 400, "message": "不支持查询历史天气", "data": None } if query_date > today + datetime.timedelta(days=7): return { "code": 400, "message": "仅支持查询未来7天内的天气", "data": None } # 实际项目中这里会调用外部天气服务,我直接返回模拟数据便于演示 mock_weather = { "city": city, "date": date_str, "weather": "晴转多云", "temperature": { "min": 18, "max": 29 }, "wind": "西北风3级" } return { "code": 0, "message": "success", "data": mock_weather }注意我在handler里加了业务校验,即使模型在Schema层面传了非法日期,代码层面也要兜底拦截。这一点很重要:Schema校验解决的是“参数格式对不对”,业务校验解决的是“值合不合理”,两者不能互相替代。
第三步,在注册表里登记技能。skills.yaml里对应加上这个技能的配置:
skills: - id: weather_lookup_v1 name: weather_lookup version: "1.0.0" description: "查询城市未来7天内的天气信息" handler: "skills.weather_lookup.handler.handle_weather_lookup" schema_ref: "skills.weather_lookup.schema.WEATHER_QUERY_SCHEMA" timeout: 5000 requires_confirmation: false enabled: true这样注册完之后,技能编排引擎可以在启动阶段自动加载handler和schema,在收到模型请求时完成调度。
3.3 环境隔离与依赖管理
技能多了以后,你会发现不同技能依赖的第三方库版本经常打架。有的技能需要旧版请求库,有的需要新版,直接装在同一个环境里迟早炸掉。我们后来规定:每个技能目录允许自带requirements.txt,由底层执行器为不同技能创建独立的运行环境。
这种做法实现起来并不复杂,本质上是为每个技能创建一个虚拟环境或者容器,在调用时用子进程启动。但代价也很明显,冷启动延迟会升高,所以需要对常驻技能做温池预热。在项目初期技能数量不多的时候,我反而建议不要过度设计,统一用同一个环境就好,等真正遇到冲突再拆分也不迟。
依赖管理的另一层含义是第三方服务的依赖,比如某技能要调用外部的天气API、地图API或内部数据库。这些依赖需要统一配置在注册表里,并且做健康检查。一个很常见的场景是:外部API服务挂了一小时没被察觉,直到用户投诉才知道。如果每个技能在执行前都能先判断依赖的健康状态,整个系统的鲁棒性会好很多。
3.4 离线测试与在线评估:先验证再上线
技能开发完成后,千万不要直接接到大模型上测试,一定要先做离线单元测试。我们的实践是,为每个技能准备一组包含正常、边界和异常情况的测试用例。
拿天气查询技能来说,正常用例是“查询上海明天天气”;边界用例是“查询今天天气”“查询7天后的天气”;异常用例是“查询去年的天气”“城市名为空”。每个用例都要覆盖参数解析、业务逻辑、返回格式三个层面。
# tests/test_weather_lookup.py from skills.weather_lookup.handler import handle_weather_lookup def test_lookup_success(): params = {"city": "上海", "date": "2025-01-20"} result = handle_weather_lookup(params, {}) assert result["code"] == 0 assert result["data"]["city"] == "上海" def test_lookup_history_rejected(): params = {"city": "上海", "date": "2024-01-20"} result = handle_weather_lookup(params, {}) assert result["code"] == 400离线测试通过之后,才进入在线评估阶段。在线测试要关注三个指标:技能选择准确率(该调用技能时有没有调用)、参数解析正确率(参数传得对不对)、流程完成率(整个任务是否成功走通)。在线评估需要采集样本做标注,这项工作看似麻烦,却是提升模型调用准确率最有效的手段。
4. 调度编排与多技能协同
4.1 从“单技能调用”到“技能编排”
单个技能能解决的问题有限,真实场景往往需要多个技能配合。比如用户问“帮我订一个明天下午能到的礼物送到某城市”,背后可能涉及商品查询、价格计算、库存确认、地址校验、下单等多个环节。
做技能编排有两种常见思路。一种是完全交给大模型,让它在一轮回复里连续调用多个技能;另一种是在系统层写一个流程编排器,由流程定义决定调用顺序。我个人在复杂任务里更倾向于后者,因为大模型在长链路任务里很容易中途偏航,而固定流程可以确保关键业务节点不被跳过。
流程编排器的实现思路比较简单:每个流程是一串有序节点,节点之间传递参数,节点执行结果要校验是否满足进入下一个节点的条件。比如上述送礼场景,流程可能是商品查询 -> 库存确认 -> 地址校验 -> 下单,每个节点结束后把结果拼装到上下文,供下一节点使用。
4.2 技能路由:多技能之间的选择策略
当模型面对多个相似技能的时候,选择策略直接决定用户体验。我们的做法是在引擎层加一个路由分数机制:模型为候选技能打分,引擎结合技能当前的可用状态(是否启用、是否超时、权限校验)做最终裁决。
路由的裁决规则可以人工配置为优先级模式。例如“下单”技能和“订单改期”技能,如果用户表达里同时出现“取消”和“重新下单”的含义,路由层可以设定为优先走取消流程,避免误操作。这种规则必须写出来,不能指望模型自行理解。
这里还要注意“兜底技能”的设计。当模型给不出合理的技能选择时,系统不能直接报错,而是返回一个礼貌的澄清话术引导用户。这个兜底技能其实是给整个系统上了一道保险,用起来之后明显降低了对话失败率。
4.3 权限边界与危险操作的控制
危险技能的控制是技能系统里最容易被忽略但又最重要的一环。所谓危险技能,指的是执行后会产生不可逆影响的操作,比如删除数据、发送消息、转账付款。对这类技能,一定要在注册表里打上危险标记,并在引擎层强制做二次确认。
二次确认的流程并不复杂:模型把意图和参数预先解析好,返回给用户一段预览话术,用户明确确认之后,技能才真正执行。在某次项目里,模型误判了用户意图,把“取消订单”识别成了“删除订单记录”,幸好有确认机制兜底,否则后果无法挽回。
权限控制还体现在调用方身份上。一个面向C端用户的智能体,和内部运营人员使用的智能体,能调用的技能范围应该完全不同。我们通过注入context上下文来实现场景隔离,每个技能执行时都能看到当前调用者的身份和权限等级,从而决定是否放行。
5. 常见问题与排查技巧实录
5.1 模型死活不调用应有的技能
这是遇到最多的一个问题,用户明明说了“帮我查一下明天天气”,模型却没有调用天气技能,而是自由发挥编了一段天气。排查方向有三步:
先看技能描述是否太窄,比如只写了“查询天气”,没有覆盖“明天冷不冷、要不要带伞”这类意图变体。其次看技能名称是否以用户习惯的词汇出现,某些名称模型根本不理解。最后看参数的必填项是否过多,如果模型认为缺少必要信息,它会倾向不调用而不是主动反问。
我们解决这个问题最有效的方式,是给技能描述补上丰富的触发关键词和示例句式。以天气技能为例,描述里会加上“提问中涉及天气预报、温度、穿衣建议等语义时也使用该技能”。这样做的效果非常明显,技能调用率从七成提升到了九成以上。
5.2 模型调用技能时报参数解析错误
参数解析错误是另一个高频问题,常见报错是required字段缺失,或入参字段类型不符合Schema定义。比如明明定义的是JSON字符串,模型却传了一个数组。
这类问题的根因通常不是模型能力,而是我们的参数描述不够细。我之前做个一个案例,某技能有个参数叫“items”,我只写了“商品列表”,模型就不知道具体要传什么结构。后来我在描述里直接给了示例值“{"sku_id": "A10001", "count": 2}”,模型解析准确率立刻上来了。
另外在底层,我们会在技能执行前做一次宽松的Schema校验,把可以自动修复的类型问题先修掉。比如把整数字符串转成整数,把单值转成数组,这些能做自动修复的尽量做自动修复,减少硬性报错。
5.3 技能执行超时或第三方服务抖动
技能调用第三方服务,超时是难以避免的。我们统一为每个技能设置了超时时间,默认三秒,重试一次,重试间隔二百毫秒。如果两次都失败,则返回给模型一个明确的失败结构,让模型用自然语言告知用户“当前服务繁忙”。
第三方服务的抖动还容易引发连锁反应:一个技能超时,拖累整个智能体响应超过用户忍耐阈值。解决思路是把超时的技能调用改成异步队列,用户先收到“正在查询”的中间状态,执行完成后再推送结果。虽然实现成本稍高,但对于用户体验提升很大。
5.4 技能调用链路不透明,出问题无法定位
当智能体已经上线,用户反馈“刚才回复不对”,如果日志里只有大模型的最终输出,排查会非常痛苦。所以从第一天开始,就必须给每个技能调用记录完整链路日志。
我们为每次会话生成一个trace_id,贯穿模型请求、技能选择、参数解析、技能执行、结果返回全流程。日志结构如下:哪个模型、选了哪个技能、传了哪些参数、返回了什么结构、耗时多少、错误码是什么。有了这套日志,配合注册表信息,大多数问题都能在几分钟内定位到具体环节。
5.5 问题排查速查表
| 症状 | 可能原因 | 快速排查方向 |
|---|---|---|
| 模型不调用技能 | 技能描述不完整、名称语义不符 | 扩充描述中的触发场景,检查名称是否吻合用户语言习惯 |
| 参数报缺失 | 字段描述含混、必填项过多 | 给每个字段写清含义,提供示例值,减少非必要必填项 |
| 技能超时 | 外部服务波动、超时时间设置过短 | 增加超时值和重试次数,必要时改异步调用 |
| 结果与预期不符 | 返回结构不标准、下游解析错误 | 统一code/message/data结构,检查下游消费逻辑 |
| 多技能串用 | 技能间描述重叠、路由规则缺失 | 在描述中写明能力边界,设置路由优先级 |
6. 一些进阶经验与总结
6.1 从“技能”到“技能树”:大规模技能组织
技能数量一旦超过几十个,平铺式管理就会触及天花板。模型在上下文中看到太多技能描述,注意力反而会被稀释,导致选择准确率下降。我建议在技能之上增加一个分类层,形成“域-组-技能”的树状结构。
举个例子,订单域下面拆成“查询组”“变更组”“退款组”,每个组下面再挂具体技能。模型先选域,再选组,最后落到具体技能上。这里的实现方式可以借助系统Prompt结构化注入,让模型只在当前可能涉及的域里选择,缩小候选集。
这种分层方式还有利于运营,新人接手项目时,看技能树就能快速理解整个系统的能力全貌,比直接看几十个技能条目清楚得多。
6.2 技能的可观测性:不止是日志
可观测性对于线上稳定运行的智能体系统来说是很关键的。除了基础的链路日志外,我建议定期统计每个技能的调用频次、成功率、平均耗时和参差分布。这些数据能帮你回答很多业务问题:哪个技能最受欢迎、哪个技能经常失败、哪个技能可能设计得过于复杂应该拆分。
用组件化的思维看待技能体系:它不是一个静态的代码目录,而是一套有生命周期的线上服务。技能的每次变更都应该有记录,每个版本都要经历灰度、观察、全量、回滚四个阶段。想清楚了这一点,技能系统的日常维护就不再是“写新函数”,而更像是在运营一组微服务。
6.3 我对技能设计的一点个人体会
踩过几次坑之后,我个人对技能设计最深的体会是:好的技能设计不是从代码出发,也不是从模型能力出发,而是从“用户最终要完成的任务”出发。你把任务拆得清楚,技能边界自然清晰;你把描述写得像人人都能看懂的说明书,模型的调用准确率自然提升。最重要的不是模型多强,而是你给模型搭的台阶有多稳。先把技能定义做到极致,再去追求模型上限,事半功倍。