最早接触“Skills”这个词是在我接一个AI助理项目的时候。需求其实很朴素把大模型接进工单系统让内部员工用大白话查工单进度、改状态、催处理人。模型本身能力没问题能聊能写能总结可一到“办事”环节就卡壳——让它查一个工单号它能顺口编一个状态出来让它改状态它只能回答“请登录系统手动操作”。问题不在于模型不够聪明而在于它没有一套可被自主调用的“技能”也就是Skills体系。Skills本质上是给模型封装好的外部能力单元查工单是一个技能改状态是一个技能派单又是一个技能。模型通过自然语言识别用户意图再决定调用哪个技能、传什么参数、把执行结果翻译回人话。这套机制解决的核心问题是让AI从“只会说”变成“真正能办事”。对做智能客服、内部助理、流程自动化这类项目的人来说这是绕不开的基础设施。这篇文章会从我的实践出发把技能清单怎么规划、单个技能怎么实现、模型怎么自动调用、以及容易踩的坑全部捋一遍适合正在做Agent或准备给大模型接业务系统的开发者。1. 项目背景为什么一个对话助手非要引入Skills机制1.1 一句话说清楚Skills是什么我习惯用一个生活类比来理解Skills大模型像一个人的大脑反应快、知识广但它没有手和脚无法去“动”现实世界里的任何东西。Skills就是给这个大脑装上的手脚——每一个技能对应一件具体的事比如查工单、发邮件、创建日程、更新数据库记录。你可以把这些技能想象成一个工具箱模型根据用户说了什么自己判断该拿哪个工具怎么用用完再把结果讲给你听。各平台对这套机制的叫法不太一样有人叫Skills有人叫Tools还有人叫Actions或Plugins但核心思想一致把外部系统能力封装成结构化的、可被模型动态调用的函数。区别只在于封装粒度和调度方式不同。我这个项目里最终统一用了“Skills”这个叫法因为它的语法最直观——一个技能就是一套完整的“输入说明执行逻辑结果返回”的组合包。1.2 没有Skills时Agent项目会踩的三个坑刚开始我也试过“裸奔”方案不带任何技能直接在系统提示词里塞一大段业务规则让模型假装会查工单。结果很快踩了三个坑。第一个坑是模型会一本正经地编数据。用户问“工单TK20240001到哪一步了”模型可能回答说“已流转到张三正在处理中”。听起来很合理但完全是幻觉。没有真实数据源模型只能靠训练语料里的相似内容编出最像样的答案。这种错误在演示环境里不容易暴露一旦真有人拿真实工单号来问马上就翻车而且用户会对整个系统失去信任。第二个坑是业务逻辑全堆在提示词里很快就堆不动了。为了约束模型不乱说我在系统提示词里写各种限定条件、示例对话、状态枚举两千字、三千字、五千字地往上加。结果一是token消耗越来越大二是逻辑之间容易冲突——改了查单规则忘了改改单规则模型行为变得神一阵鬼一阵。第三个坑是能力无法复用。查询逻辑、参数校验、接口调用这些代码其实和具体业务场景没有绑定关系。但我当时把它们全写在对话处理主流程里换一个项目、换一个入口就得复制粘贴再改一遍。这就像把所有工具都焊死在流水线上换条产线就要重新焊接。直到后来把Skills拆成独立模块才彻底解决复用问题。1.3 对比了几种方案之后我为什么选了技能体系当时我实际对比了四种方案。第一种是全靠提示词上面已经说了不可维护。第二种是传统的规则对话流维护一个意图识别模型把用户输入分类再走分支逻辑。这套方案在业务范围极窄时可行但用户表达方式千奇百怪规则会越写越复杂本质上是把“理解自然语言”的难度转移给了规则编写者。第三种是只封装一个万能工具把查单、改单、催办全揉进一个函数里。这样模型倒是不会乱编了但参数会变成一个巨大的对象模型分不清什么时候该传什么字段问“这个工单怎么样了”和“帮我把这个工单状态改成已完成”会生成完全不可预测的调用结构。最后一种就是拆成Skills每个技能只负责一件小而明确的事技能名、描述、参数Schema都清清楚楚模型拿着这份“说明书”做意图匹配判断成本低准确率高新增能力时也不用动主流程。我最终选了第四种原因很简单技能机制把不确定性留给了模型的理解能力而把确定性留给了代码执行层这个边界划分能让系统整体变得可控。2. 技能清单怎么规划先定边界再写代码2.1 从真实业务场景反向拆解技能列表很多人规划技能时会走进一个误区从系统有什么API出发把能接的接口全接一遍一上来就列二十多个技能。结果模型面对一长串技能列表时选择难度暴涨经常挑错工具。我的做法正相反从用户真实问句出发反向拆解。拿我的工单助理项目来说我先整理了历史客服渠道里用户最常问的几十句话然后归类。结果发现高频需求其实只有六类查进度、改状态、催处理人、驳回申请、转交工单、统计数量。于是第一批只做了这六个技能每一个都对应一个真实使用场景。技能粒度把握有一条经验一个技能只做一件事。比如“查工单状态”和“批量导出工单列表”必须拆开哪怕它们底层都调用同一张数据表。因为技能的目标是让模型能用最短的路径完成任务如果单个技能的参数特别多、逻辑分支特别复杂模型出错的概率就会直线上升。另外还要为“模型不知道该调哪个技能”的情况留一条退路。我建议在技能列表之外加一个兜底技能比如“faq_answer”专门用来回答不在业务范围内的提问。这样模型不会因为找不到合适技能而在多个不相关工具之间乱跳。2.2 技能的三种落地形态怎么选在真实项目里Skills不完全是“一个技能对应一个接口”这么简单。我从实践里总结出三种常见形态各有适用场景。第一种是API技能背后是一个现成的HTTP接口技能本身只做参数映射、鉴权和错误码转换。这种形态最常用适合系统里已经有成熟后端的场景比如工单系统的查询接口、订单系统的状态接口。第二种是脚本技能执行的是本地或服务端的一段代码适合处理那些没有现成接口、或者需要拼装数据的小任务比如读取一个CSV文件、调用命令行工具、做简单计算。第三种是流程编排技能一个技能内部串联多个步骤比如先查数据库判断工单是否在可催办状态再调用通知接口发送催办消息最后记录操作日志。流程编排技能更适合复合型任务但要注意不是流程越长越好如果一个技能内部超过四个步骤我通常会重新思考是否应该拆成多个基础技能再让模型自己编排。这三种形态在一个项目里经常混合使用。我的建议是优先做API技能因为边界最清楚脚本技能用来快速补充临时能力流程编排技能放到后期等功能稳定后再固化。2.3 定义技能核心字段这六项缺一不可我在实现了十几个技能之后总结出一套标准的技能描述结构。无论技能底层逻辑多复杂都需要明确这六个字段。字段作用关键注意点name技能唯一标识供模型和代码引用用英文小写加下划线见名知义如get_ticket_statusdescription模型判断是否调用该技能的依据写清楚触发场景、输入要求、不做哪些事input_schema定义参数的JSON Schema精确到字段类型、必填项、枚举值、格式示例handler实际执行函数接收已校验的参数返回结构化结果output_format规定返回结果的组织方式可选有利于模型稳定地翻译成人话metadata权限、超时、重试、日志等附加配置多Agent场景下必须有权限标记name字段我踩过坑。一开始我把技能名起得很形象比如“checkOrderStatus”后来技能一多代码里引用的时候手忙脚乱。建议命名规则保持统一动词加宾语get_、update_、create_、send_开头一看就知道干什么。description字段则是最费心血的后面会专门讲。input_schema字段是模型生成参数的唯一依据越精确越好宁可多写描述也不要让模型瞎猜。这六个字段配合起来技能才算是完整可上线的状态。3. 一次完整的Skill封装实例从需求到调用链路3.1 环境准备与基础依赖搭建我在这个项目里用的技术栈很简单Python做主语言再加一层极薄的技能封装层。为什么不用现成的Agent框架不是框架不好而是Skills机制本身不依赖特定框架核心逻辑完全可以自己实现。自己写一遍之后你反而更容易理解模型的调用链路排查问题时也更有底。环境准备只需要三样东西。Python 3.10以上jsonschema库用于参数校验以及一个存有真实业务数据的接口或数据库。如果你只想先跑通机制用一个字典模拟数据库也行但要注意模拟数据和真实接口的行为差异很大尤其是超时和错误返回格式建议尽早切换到真实依赖。我的封装层只有几十行代码核心是一个注册表和两个工具函数。注册表负责存技能定义dispatch函数负责根据模型生成的调用意图找到对应技能并执行。这种轻量设计的好处是无论后面接哪个模型服务商还是换成某个开源Agent框架都能把这一段逻辑平滑迁移过去。3.2 第一个技能查工单状态的完整实现先看最底层的技能注册模块import json import jsonschema SKILLS_REGISTRY {} def register_skill(name, description, input_schema, handler): SKILLS_REGISTRY[name] { name: name, description: description, input_schema: input_schema, handler: handler, }然后实现查工单状态的技能def get_ticket_status(ticket_id): # 这里假装调用工单系统的HTTP接口 # 真实项目里可以替换为 requests.post(...) 或数据库查询 mock_db { TK20240001: { status: 处理中, assignee: 客户成功组, updated_at: 2025-01-20 10:30 } } ticket mock_db.get(ticket_id) if ticket is None: return { ok: False, error: TICKET_NOT_FOUND, message: 没有找到编号为{0}的工单.format(ticket_id) } return {ok: True, data: ticket}注册技能register_skill( nameget_ticket_status, description( 当用户查询工单处理进度、状态、当前处理人时调用。 需要用户提供工单号如果用户没有提供请先向用户索要工单号。 本技能只做查询不修改任何数据。 ), input_schema{ type: object, properties: { ticket_id: { type: string, description: 工单号例如 TK20240001 } }, required: [ticket_id] }, handlerget_ticket_status, )调度执行部分def dispatch(skill_name, arguments_json): skill SKILLS_REGISTRY.get(skill_name) if skill is None: return {ok: False, error: SKILL_NOT_FOUND, message: 未找到对应技能} try: params json.loads(arguments_json) jsonschema.validate(instanceparams, schemaskill[input_schema]) except json.JSONDecodeError: return {ok: False, error: INVALID_JSON, message: 参数不是合法JSON} except jsonschema.ValidationError as e: return {ok: False, error: INVALID_PARAMS, message: 参数校验失败: {0}.format(e.message)} return skill[handler](**params)这段代码里面最关键的是注册时传入的description和input_schema。模型并不直接执行这段Python函数它只是根据用户输入在注册表里选一个技能生成类似于{skill_name: get_ticket_status, arguments: {\ticket_id\: \TK20240001\}}的调用指令。我们的调度层负责把这段指令解析、校验、执行再把结果回传给模型。很多初学者会混淆这一点以为模型内部在跑你的代码——实际上模型和代码之间隔着一个运行时这个运行时才是真正有执行权限的部分也是你做安全控制、权限校验、日志记录的地方。3.3 技能的注册、发现与自动调用是怎么串起来的整个调用链路我拆成六个环节每一步都有它存在的理由。模型服务端在系统提示词里注入当前可用的技能清单包括每个技能的name、description、input_schema。这一步相当于告诉模型“你有这些工具可用说明书在这”。用户输入自然语言比如“帮我查一下TK20240001到哪一步了”。模型根据技能描述做意图匹配输出一个结构化的工具调用指令指名要用哪个技能、传什么参数。参数来自用户的原话如果用户没说全模型应该反过来追问。运行时拿到调用指令通过dispatch函数解析JSON、做Schema校验、确认权限然后执行真正的handler。技能执行完以结构化结果返回给模型必要时带上错误码和补充说明。模型把结构化结果翻译成自然语言回复给用户。这个链路里环节4的存在是整个系统稳定性的核心。它把“模型生成”和“真实执行”彻底隔离开。模型永远接触不到你的数据库密码、接口密钥执行动作必须经过校验。如果模型生成了非法参数我们的dispatch函数会返回INVALID_PARAMS错误码模型看到这个错误码后会自行修正参数或向用户索要正确信息而不是干等着崩溃。我第一次把这个链路跑通时最大的感受是模型变得像一个有经验的前台知道自己什么该查、什么该问、什么该交给人来处理。用户说“帮我催下那张单”模型会反问“请提供工单号”拿到工单号后再调用催办技能最后回复“已经催办处理人预计今天下班前反馈”。这种体验是之前纯靠提示词时完全达不到的。4. 让Skills输出更稳定的三个关键描述、校验、兜底4.1 描述写不好再贵的模型也会瞎选技能我把这个经验放在第一位因为它是整个技能系统里投入产出比最高的一环。description写得好不好直接决定模型选技能的准确率。一个常见场景系统里同时有“查工单状态”get_ticket_status和“查订单状态”get_order_status如果不加限制模型很容易把订单号传给工单技能或者反过来。后来我把description当成“写给模型看的说明书”来写遵循三个原则。第一明确触发条件开头就写“当用户查询……时调用本技能”。第二明确输入要求特别是用户没提供关键参数时该怎么应对比如“如果用户没有提供工单号请先向用户索要不要猜测”。第三写清本技能不做什么这能大幅减少误匹配比如“本技能只查询不修改状态如需修改请调用另一个技能update_ticket_status”。有一个实际经验可以分享description不要写成一段干巴巴的功能描述最好在末尾加一句典型触发例句比如“适用于‘我的单到哪了’‘处理得怎么样’这类提问”。模型对自然语言示例的敏感度比对抽象描述更高。加了例句之后技能选择的准确率在我们项目里有肉眼可见的提升。4.2 参数校验给每个字段穿上“防弹衣”模型输出的参数永远不可全信。语言模型可能会把用户提供的日期“明天”解释成一个具体日期也可能把“第一个工单”理解成ticket_id1还可能干脆漏掉必填字段。所以对每个技能参数做JSON Schema校验不是一个可选项而是必须项。我在dispatch函数里用jsonschema做校验Schema里除了字段类型还会写枚举值、格式示例。拿改状态技能来说{ type: object, properties: { ticket_id: {type: string}, new_status: { type: string, enum: [待处理, 处理中, 已完成, 已驳回] }, reason: {type: string} }, required: [ticket_id, new_status], additionalProperties: False }这里有个容易忽略的细节我设置了additionalProperties: false。意思是模型生成参数时不能凭空多出字段。之前遇到过模型自作主张把“remark”字段塞进来导致后端接口报错的情况。开启这个开关后多余字段会在校验层直接被拦截错误信息返回给模型重新生成后端接口的安全性就高了很多。4.3 错误信息也要结构化让模型能读懂并自救技能执行过程中失败是常态。接口超时了、数据不存在了、权限不够了这些错误如果只返回一段中文文本“操作失败请重试”模型能做什么它只能机械地把这句话转述给用户或者傻乎乎再调用同一个技能一次。我后来规定所有技能的错误返回必须遵循固定结构ok、error、message。error是一个机器可读的错误码比如TICKET_NOT_FOUND、PERMISSION_DENIED、API_TIMEOUT、INVALID_PARAMSmessage是一段适合被模型引用的自然语言说明。模型一旦识别到错误码就可以做出更聪明的决策。比如拿到TICKET_NOT_FOUND它会回复“没有查到编号为TK99999999的工单您确认一下工单号是否正确”拿到PERMISSION_DENIED它会回复“当前账号没有修改该工单的权限请联系管理员”。有人可能会问为什么不直接在技能层把错误翻译成用户语言我的经验是技能的职责是执行并返回事实最终表达方式是模型的职责。如果把表达也固化在技能里一旦用户问法变了回来还要改技能代码。结构化返回让技能层和表达层彻底解耦这是后期迭代时省力最多的设计。5. 实测踩过的坑技能多了之后问题开始变得微妙5.1 模型反复调用同一个错误技能进入死循环项目上线前测试时我遇到一个特别经典的问题某个技能依赖的第三方接口不稳定一段时间内持续超时。模型调用一次技能返回API_TIMEOUT模型识别到错误后思路很固执又调用同一个技能又超时又调用来回折腾了六次用户等了两分多钟最后模型才憋出一句“系统繁忙”。这个问题的根源在于模型被设计成“遇到错误就要尝试完成用户请求”。但技能的错误反馈里没有告诉它“这事现在干不成别重试了”。我做了两项修正。第一在技能返回的错误信息里明确写“请不要再调用本技能请直接告知用户系统繁忙稍后再试”。第二在运行时层面给每个技能设置最大调用次数比如同一个会话内同一个技能最多执行三次超过就返回一个CRITICAL错误码模型必须终止尝试。双管齐下之后这个死循环问题就消失了。5.2 中文参数和JSON解析的那些小九九技能参数要传给后端接口时中文和特殊字符经常惹麻烦。用户说“把这张单改成已驳回”生成的参数里带着中文“已驳回”这本身没问题问题是有些用户输入里带有引号、反斜杠、换行符模型在生成JSON时可能忘记转义导致我们的json.loads直接抛异常。我加了一个safe_loads函数在解析失败时先清理控制字符再重试一次import re def safe_loads(text): try: return json.loads(text) except json.JSONDecodeError: cleaned re.sub(r[\x00-\x1f], , text) return json.loads(cleaned)还有一类问题是格式不一致。用户可能说“TK 2024 0001”或者“tk20240001”和系统里的标准格式不一样。这个问题不能指望模型自己处理我建议在技能内部统一做归一化去掉空格和横线统一转成大写再用规整后的工单号去查系统。这种兜底逻辑放在handler里既不影响description简洁度又确保了接口调用正确。5.3 技能一多模型开始“选择困难”当注册技能数量超过十五个时我发现模型选技能的准确率开始下滑。不是模型变笨了而是技能列表变长后模型在每个技能描述上分配到的注意力被稀释了。尤其是有几个技能功能相近时模型经常在两个相似技能之间犹豫。我试过几种优化办法最管用的是“技能分组预选”。把用户输入先做一层轻量检索从二十多个技能里捞出最相关的三到五个再把这几个技能的完整描述喂给模型。这样模型需要判断的范围大大缩小准确率自然就上去了。实现方式可以很朴素每个技能维护一组关键词用简单词频匹配预筛条件允许时也可以用向量检索但核心思路相同——不要把所有技能一次性摊开给模型看。另一个有效手段是场景化加载比如用户明显在问“统计报表”类问题那就只加载统计相关技能。技能列表的瘦身对模型稳定性的帮助比想象中更大。5.4 技能背后的服务挂了怎么优雅降级技能内部的依赖服务出故障时整个对话不能让用户干等着。我一开始没有给技能做超时控制结果某次上游接口卡了三十秒用户的页面转圈转到超时体验非常差。后来我给所有技能调用都加了两层保护。第一层是超时设置技能调外部接口时统一设置三秒超时超过就抛超时异常返回API_TIMEOUT错误码。第二层是熔断机制如果某个技能在五分钟内连续失败超过五次就启动熔断直接返回SERVICE_UNAVAILABLE错误码不再真正发起请求。模型收到这个错误码会直接对用户说“当前服务繁忙请稍后再试”。这个设计本质上是把故障隔离在技能层避免单个依赖服务把整个对话系统拖垮。我还给技能加了日志埋点记录每次调用的耗时、参数、错误码。有了这些日志排查问题时就能直接看到是哪个技能在什么参数下出的错不用靠猜。这套基础数据后来也成了优化技能的决策依据比如哪个技能高频失败哪个技能token占用太多一目了然。6. 落地效果与后续可以扩展的三个方向6.1 接入技能前后项目表现对比这个技能系统上线运行一段时间后我在内部记录了一组对比数据拿来复盘时特别直观观察指标接入 Skills 之前接入 Skills 之后工单查询准确率模型靠猜测不可信走真实接口准确率取决于数据源用户等待体验模型可能编答案2~3 秒拿到真实状态再组织回复能完成的操作只有纯对话查询、催办、转交、改状态等扩展新场景成本每次要改提示词新增一个技能模块即可最直观的变化是用户不再需要专门学习系统操作路径直接说人话就行。之前他们要在工单系统里点好几个页面才能查到的东西现在一句话搞定。而且因为所有操作都有权限校验和操作日志合规层面的压力也小了很多。从团队维护视角看最大的收益是需求迭代方式的改变。以前接到一个新场景需求要动对话主流程、调提示词、回归测试其他对话能力。现在只需照着现有技能模板写一个新的handler配上description和input_schema注册进去就行整个过程通常半天内完成。6.2 技能复用从一个项目到一套“技能集市”技能封装成独立模块后自然会走到复用的阶段。我在项目中把每个技能拆成独立的文件包包含handler代码、配置说明、测试用例三件套。换到另一个项目时直接复制对应技能包改一下接口地址和鉴权配置就能用。更进一步还可以搭建一个内部“技能集市”的概念每个技能是一个发布物有版本号、维护人、依赖说明。项目A需要查单能力去集市拉取get_ticket_status技能项目B需要催办能力拉取send_reminder技能。这样整个团队维护同一套高质量技能而不是每个项目各写一遍低质量版本。技能版本升级时通过固定的发布流程同步更新到各个接入方比到处复制粘贴要可靠得多。我建议每个技能包都附带一份“技能说明书”标注清楚适用场景、不适用场景、已知限制、异常错误码清单。这份说明书不仅是给模型看的description的扩展版也是团队内部交接和排障的文档基础。6.3 多Agent共用一个技能库时的权限隔离项目后期同一个技能库被多个Agent入口共用了网页端客服机器人、企业IM里面的助理、还有内部运营人员的自动化脚本。这时候权限隔离就变得重要了。一个普通用户可能只允许查自己的工单而内部运营人员可以修改任意工单状态绝不能因为是同一个技能就让不同身份的人拥有相同权限。我的做法是在技能的metadata里加入权限标签比如permission: [user, staff, admin]调度层在执行handler之前先校验当前会话身份。没有权限时直接返回PERMISSION_DENIED错误码模型会自然地把“权限不足”反馈给用户。另外涉及“改状态”“批量导出”这类敏感技能的调用还会额外记录操作人、操作时间、参数快照方便事后审计。如果Agent数量更多建议把权限抽象到统一网关层技能模块本身不感知身份细节只接收一个“已验证身份”的上下文对象。这样技能代码可以保持纯粹权限策略的调整也不会牵动所有技能。最后再说一点非常个人的经验技能上线前一定要用真实的高频用户问句做一轮回归测试而不是只测人工设计的标准句式。真实问句里有省略主语、指代不清、口语化表达、错别字这些才是模型最容易翻车的地方。把测试集固定下来每新增一个技能就回归跑一遍你会发现技能系统的稳定性是在这种“笨功夫”里一点点磨出来的。