最近一直在折腾一件事:把常用的那个AI助手从“能聊几句”变成“真能干活”。核心就落在标题里那个词——skills。我给这套助手框架加了一层技能系统,让它不再只会生成文本,而是能去读文件、查数据、跑脚本,甚至定时执行任务。这篇东西就围绕skills这套技能体系展开,聊聊我踩过的坑、设计时的取舍,以及几个能直接抄走的完整技能包写法。如果你也在调教自己的AI助手,或者想给内部工具加一套可扩展的执行能力,这篇应该能省你不少事。
1. 技能系统的整体思路与设计拆解
1.1 为什么必须抽一层“技能”出来
先说我最初遇到的问题。当时那个AI助手已经有了不错的语义理解能力,你问它“上周的订单量是多少”,它能说出答案,但答案是你喂给它的。一旦你说“去读一下那个目录下的三个Excel,把上周的订单量统计出来”,它就懵了。因为它没有手,只有嘴。
这就是最核心的痛点:对话模型只能处理信息,不能操作世界。想让它操作世界,就得给它工具。但直接写死一堆工具调用逻辑也不行,你会发现业务需求长得太快——今天要读Excel,明天要连数据库,后天要调内部API,你总不能每加一个需求就改一遍核心代码。
所以我抽了一层“技能层”。每个技能是一个独立模块,有名字、有描述、有参数声明、有执行逻辑。AI核心只做两件事:听懂意图、抽取参数。剩下具体的执行,全部交给对应的技能模块。这样核心引擎和业务能力彻底解耦——你往技能目录里丢一个新文件夹,助手就多了一项本领,完全不用动主程序。
1.2 声明式配置比写死逻辑更抗折腾
设计技能包时,我面临一个选择:技能清单用声明式配置文件,还是代码里写死注册?我选了前者,而且现在回头看,这个决定救了我好几次。
声明式的意思,就是每个技能用一份清单文件说明“我是谁、我能干什么、我需要什么参数”,再配一个执行脚本干具体活。比如一个技能想做文件统计,清单文件里写清楚技能名、触发场景的关键词、参数定义(文件路径、统计口径),真正执行时调一个Python脚本。理由有三条。
第一,新增技能的边际成本趋近于零。新需求来了,复制一个模板文件夹,改改清单,写写执行脚本,重启加载就完事,不用动核心代码。
第二,方便做权限管控。技能需要用哪些权限(读文件、执行命令、访问网络),在清单里声明,加载器统一审核。比散落在代码里的各种调用好审查得多。
第三,易于回滚和灰度。技能配置本质是文本文件,配合Git做版本管理,出问题可以秒级回滚。同一套框架里同时存在旧版和新版技能,按比例灰度也容易控制。
1.3 整体架构:大脑、手脚和登记簿
我最终落地的架构其实就四块,用大白话讲就是:
- 核心编排器(大脑):负责理解用户意图,判断该调哪个技能,把参数提取出来传过去。
- 技能注册表(登记簿):启动时扫描技能目录,读取每份清单文件,建立“能力索引”。AI靠这个索引知道你会什么。
- 技能执行器(手脚):真正跑起执行脚本的地方。负责超时控制、日志收集、异常捕获。
- 上下文桥(神经):把AI生成的结构化参数传给执行器,再把执行结果转化成AI能理解的摘要信息。
这个架构的精髓在于:AI不直接碰任何真实资源。它永远只跟“注册表”和“上下文桥”打交道,具体读写哪个文件、连哪台数据库、执行什么命令,全是执行器经手。这样即使模型输出有幻觉,影响范围也被锁死在单次执行里,不会因为一次错误调用把整个系统搞崩。
2. 技能包的核心细节与编写规范
2.1 清单文件字段逐个拆解
一个标准的技能包清单(我用plist.yaml命名)大致长这样:
name: file_stats description: 读取指定目录下的数据文件,统计数量、大小和最近修改时间。 trigger_keywords: ["统计文件", "几个文件", "目录大小", "多久没动"] version: 1.2.0 author: internal-tools timeout: 30 retry: 2 permissions: fs_read: true fs_write: false exec: false network: false parameters: - name: path type: string required: true description: 要统计的目标目录绝对路径 - name: recursive type: boolean required: false default: false description: 是否递归统计子目录几个关键字段值得细说。
description不是写给你自己看的,是给AI看的。它决定了AI在什么场景下会选中这个技能。写得太泛,AI会把它跟别的技能混淆;写得太窄,AI压根不会想到用它。我的经验是:描述里要包含“动作 + 对象 + 输出格式”,比如上面那个“读取…统计…”,AI一看就知道什么时候该调。
trigger_keywords是个双保险。理论上AI靠语义理解就能决定调用哪个技能,但加上关键词命中可以提高决策准确率。出现这些词时,编排器会给这个技能加权,AI就更容易选中它。
parameters用的是JSON Schema风格的声明。这里必须严格声明每个参数的类型、是否必填、默认值。因为AI抽取参数时是拿这份schema去校验的——抽出来是字符串还是数字、没给全时是报错还是用默认值,全靠这份声明控制。我见过有人偷懒不写参数定义,结果AI传进来一个四不像类型,执行脚本崩得稀里哗啦。
2.2 参数抽取与上下文传递的衔接
这是整套系统里最容易出问题的一环,也是我和团队调试最多的地方。
AI理解自然语言后,需要把“上周的订单量”“那个200多MB的大文件”这类表达转成真实的参数值。比如用户说“统计一下/home/data下最近三个月没动过的文件”,AI得从这句话里抽出path=/home/data,然后推断出“最近三个月没动过”这种模糊条件,技能本身通过参数max_age_days接收一个具体数字。
问题的坑在于:AI给的值经常不符合你的参数约束。它可能把“三个月”直接当成字符串传,或者路径里带上了多余的空格。我目前的解决办法是在清单里加normalization规则,比如常用的字符串清理、时间单位换算、路径解析等。核心思想是:不要指望AI传过来的参数可以直接用,执行器必须做一层标准化处理。
现在我的上下文桥,收到AI生成的参数后会先跑一遍schema校验,不合法就反馈给AI让它重新提取,最多两次机会,还不行就用默认值兜底。这个机制加上之后,技能执行的成功率从刚上线的不到六成,一路提到了九成以上。
2.3 安全边界:最小权限这件事不能省
技能系统最大的风险是权限失控。AI一旦能操控执行器,就等于拿到了系统的操作权。不把权限管住,一次幻觉可能就会导致误删文件或者执行奇怪命令。
我在清单里设计了五个开关:fs_read(文件读)、fs_write(文件写/改/删)、exec(执行任意命令)、network(发起网络请求)、secret(读取敏感配置)。所有开关默认关闭,只有确有必要才打开,并且写明用途注释。加载器启动时会扫描所有技能包,任何包声明了敏感权限但没通过审批,直接拒绝加载。
有个小技巧:对fs_write这类高危权限,我加了“路径白名单”子字段。比如某技能需要写文件,就只能写到/tmp/skills_runtime/下,其他路径一律拒绝。这样即使AI被误导想删系统文件,执行器也会在权限层把它拦住。这个设计建议所有做技能系统的人都标配,别嫌麻烦,真出事故再后悔就晚了。
2.4 热加载与版本迭代的实践
技能系统的价值在于快速迭代。我实现了双模式加载:
- 启动时全量加载:扫描技能目录,建立完整注册表。
- 运行期热加载:监控技能目录的文件变化(用文件哈希比对),发现新增或修改技能时自动加载。
用Linux的watchdog库就能实现。核心是给每个技能包算一个内容哈希,如果清单文件变化了就重新注册,执行脚本变化了就清理旧进程引用。不过热加载时我留了个“冷却期”——同一技能包一分钟内最多重新加载两次,防止开发时保存文件频繁触发重复加载导致抖动。
版本迭代上,每个技能包严格遵守语义化版本规则:修复bug发patch版,加参数发minor版,重写逻辑发major版。注册表里保留每个技能的最近三个版本,出问题可以一键切回旧版。灰度时我用随机数采样,比如一个技能要发布新版,先让10%的请求走新版本,观察日志没问题再逐步放开。
3. 实操:从零开发两个实用技能包
3.1 技能包目录规划
我习惯的目录结构长这样:
skills_root/ file_stats/ manifest.yaml handler.py README.md scheduled_reminder/ manifest.yaml handler.py store.json shared/ normalize.py auth_middleware.py每个技能独立成文件夹,至少包含manifest.yaml和handler.py。README.md属于可选的文档说明,但强烈建议写,因为你一周后再看自己写的技能,没文档真的会忘。shared/目录放公共工具库,比如参数标准化、鉴权中间件这些每个技能都会用到的东西。
执行器加载技能时,就是逐个目录读manifest.yaml,然后把handler.py注册成可执行函数。目录名建议和name字段保持一致,避免排查问题时还要翻译一遍,这个习惯能救你不少命。
3.2 实现一:数据文件统计技能
这个技能解决的实际场景是:AI助手被问到“帮我看看这个目录下有多少数据文件”时,能返回准确数字而不是瞎猜。
清单文件声明我需要一个路径参数。参数定义如下:
name: file_stats description: 读取指定目录下的数据文件,统计数量、大小和最近修改时间。 version: 1.0.0 timeout: 15 permissions: fs_read: true parameters: - name: path type: string required: true description: 目标目录绝对路径 - name: recursive type: boolean required: false default: false description: 是否递归统计子目录handler的Python实现核心步骤:
import os import json from datetime import datetime def run(path: str, recursive: bool = False): if not os.path.isdir(path): return {"error": f"路径不存在或不是目录: {path}"} walker = os.walk(path) if recursive else [(path, [], os.listdir(path))] total_files = 0 total_size = 0 newest = None for root, _, filenames in walker: for name in filenames: full = os.path.join(root, name) try: stat = os.stat(full) except OSError: continue total_files += 1 total_size += stat.st_size ts = datetime.fromtimestamp(stat.st_mtime) if newest is None or ts > newest: newest = ts return { "directory": path, "recursive": recursive, "file_count": total_files, "total_size_mb": round(total_size / 1024 / 1024, 2), "newest_modified": newest.strftime("%Y-%m-%d %H:%M:%S") if newest else None, }这里有个细节:执行函数的入参必须和清单里的参数声明一一对应。run(path, recursive=False),如果清单声明了参数而函数签名里没有,执行器在传参时就会直接报TypeError。当时我写了好几个技能才总结出这个规律,现在所有handler都遵循**“清单声明什么、函数就接收什么”**的约定。
另外输出格式建议统一用JSON,因为AI要拿结果生成自然语言回答,JSON结构化数据最好解析。返回的字段名也别搞缩写,比如file_count不要写成fc,否则AI理解起来容易糊涂。
3.3 实现二:定时提醒技能
第二个技能是定时提醒——用户说“二十分钟后提醒我检查服务器”,助手需要真的在那个时间点发出一条提醒。
这个技能的核心要求是状态持久化:AI是无状态的,一次对话结束就“失忆”了,但提醒不能丢。我的方案是把提醒任务序列化到一个JSON存储文件里。
清单文件的关键配置:
name: scheduled_reminder description: 创建定时提醒,在指定时间或经过指定时长后触发通知。 version: 2.0.0 timeout: 10 permissions: fs_read: true fs_write: true fs_write_whitelist: ["*reminder_store.json"] parameters: - name: delay_minutes type: integer required: false default: 0 description: 延迟分钟数,与trigger_time二选一 - name: trigger_time type: string required: false description: 触发时间,ISO格式,如2025-04-01T09:30:00 - name: content type: string required: true description: 提醒内容handler里用两个函数:run负责登记提醒任务,check_due负责被调度器周期性调用扫描到期任务。
import json, os from datetime import datetime, timedelta STORE = os.path.join(os.path.dirname(__file__), "reminder_store.json") def run(delay_minutes: int = 0, trigger_time: str = "", content: str = ""): if trigger_time: due_at = datetime.fromisoformat(trigger_time) else: due_at = datetime.now() + timedelta(minutes=max(0, delay_minutes)) task = { "id": str(abs(hash(content + str(due_at)))), "due_at": due_at.isoformat(), "content": content, "done": False, } tasks = _load() tasks.append(task) _save(tasks) return {"status": "scheduled", "task_id": task["id"], "due_at": due_at.isoformat()} def check_due(): tasks = _load() now = datetime.now() due = [t for t in tasks if not t["done"] and datetime.fromisoformat(t["due_at"]) <= now] for t in due: t["done"] = True _save(tasks) return due def _load(): if not os.path.exists(STORE): return [] with open(STORE, "r", encoding="utf-8") as f: return json.load(f) def _save(tasks): with open(STORE, "w", encoding="utf-8") as f: json.dump(tasks, f, ensure_ascii=False, indent=2)写这个技能时我踩了一个典型的坑:AI传延迟时间时经常传成字符串。明明清单声明了integer类型,但AI从“二十分钟”提取出来的却是"20"。所以执行器在调用前虽然做了schema校验并转换类型,我还是在函数开头加了一层保险,用int()强制转换兜底。经验就是:跟AI对接,永远不要相信类型声明,稳一手总没错。
调度器部分用的是我的主程序自带的cron循环,每30秒扫一次check_due。发现到期任务后,会生成一条提醒消息推回对话上下文。这样用户就算没有主动问,也能收到“你设置的提醒时间到了:检查服务器”这类主动推送。
3.4 注册验证与冒烟测试清单
技能写好不是直接就能用,我有一套冒烟流程,每一步都能过才算上线。
- 加载检查:执行器启动后扫描技能文件夹,确认注册表里有新技能的名字,触发关键词被正确记录。
- 参数校验测试:用空参数、缺参数、错误类型参数三组数据直接调handler,看会不会抛异常。系统要稳就得保证AI传错参数时是优雅降级,而不是整个进程崩溃。
- 真实语义测试:拿十几句真实用户表达去问AI助手,比如“帮我看看目录下几个文件”和“那个文件夹多久没动了”,看AI是否能命中正确的技能。
- 权限审核:核对清单里的权限声明,确认没有额外权限打开。习惯性多开的
exec权限是最常见的隐患。 - 日志观察:上线后把技能相关日志调到DEBUG级别,跑一天,检查参数抽取的准确率和异常发生频率。
这套冒烟流程跑顺后,我上线新技能的速度基本就是“半小时开发、十分钟验证”。
4. 实战典型问题与排查技巧
4.1 高频问题速查表
整理一份我遇到频率最高的问题清单,基本可以覆盖90%的情况:
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 技能一直不触发 | 描述/关键词写得太泛或太窄,AI没识别到 | 检查注册表索引,用更多真实句子测触发率 |
| 触发了但参数为空 | AI没有提取到参数,或者参数名不匹配 | 看DEBUG日志里AI生成的参数JSON,对照list里的name字段 |
| 参数类型对不上 | AI把数字当字符串传,或布尔值成了“是/否” | 在handler入口做防御性转换,或者让上下文桥先规范化 |
| 执行结果乱码 | 编码问题,文件或数据库返回的字符集不对 | 全链路统一用UTF-8,必要时在handler里显式decode |
| 技能执行超时 | 任务太重或者死循环 | 清单超时时间调大,或者在handler里拆分任务分批处理 |
| 模块被重复加载 | 热加载的冷却期没生效,文件被反复触发 | 检查哈希比对逻辑,同一技能包把重新加载间隔至少设为60秒 |
| 权限被拒绝 | 清单没有声明对应权限或白名单没覆盖 | 按需开放,先维持最低原则,再逐步放开 |
4.2 排障三板斧:日志、复现、隔离
很多人的排查毫无章法,一上来就四处乱改。我总结了三个固定套路,遇到问题先按顺序走一遍。
第一板斧:把日志开满。技能执行器的日志至少要能回答三个问题:AI选了这个技能吗?AI传了什么参数?执行器最终跑了什么命令?这三个闭环全打上日志,90%的问题不用看代码就能定位。
第二板斧:最小复现。把完整的用户请求拆到最简单,比如把“统计一下/home/data下最近三个月没动过的文件”化简成“统计一下/home/data下的文件”。如果简单版能起作用、复杂版不行,那就说明问题出在参数抽取这层,跟业务逻辑无关。如果简单版也不行,问题就在技能本体,拿一个固定参数直接调用handler里的run函数,看它自己跑不跑得通。
第三板斧:隔离变量。同一时间只改动一个变量——要么改描述,要么改参数定义,要么改handler逻辑。很多人喜欢同时改三个地方然后测试,结果出来Bug了根本不知道哪步改坏的。一次改一处,测试,没问题再动下一处。
4.3 独家避坑与经验教训
技能系统的坑,写代码的人不一定能注意到,但跑一段时间后几乎都会撞上,我提前说几个。
坑一:description写得太花哨反而误伤命中率。我一开始为了“让AI理解得更准确”,把描述写得像小作文,还堆了一堆同义词。结果发现这些词把别的技能的关键词给抢了,AI在不同技能之间疯狂误判。后来我遵循一个原则:描述用短句,只写“动作+对象+输出”,别写形容词修饰。实测发现命中稳定性比花哨写法高了一大截。
坑二:超时时间一律别设置成“看起来够用”。第一次上线时某个技能清单的timeout设了10秒,结果用户读的文件稍微大点就超时了。后来统一规定:所有技能timeout默认30秒,涉及大数据量处理的,干脆设到60秒。这个超时不是为了限制“能跑多久”,而是为了防止死循环和异常占用,宁可设大点,也不能误杀正常任务。
坑三:并行执行技能时上下文不要共享。AI助手一次对话中可能同时触发两个技能,如果handler里用了同一个全局变量或者临时文件,就会出现数据串扰。我踩过一次:统计文件大小和统计订单数量的技能,在同一个用户场景里被同时触发,结果双方的临时文件写到了同一个路径,数值互相污染了。现在的规矩是:每个技能的运行时数据必须放在自己目录下的runtime/里,不允许跨技能共享写状态。
坑四:AI的上下文长度有限,输出别太啰嗦。执行器返回给AI的结果,如果是一大段JSON,AI可能截断就漏掉关键信息。我的格式化原则:返回摘要信息加上核心数据,尽量控制在几百字节以内。比如文件统计技能,直接返回“文件数12,总大小86.3MB,最近修改4月11日”,把详细清单存到文件里,AI真正需要的就是这几个数。
4.4 关于技能调优的一些体会
技能系统跑了大半年,我最大的体会有两点。
一是动手做对比实验。每次调整技能的描述或参数定义,我都会拿同一组测试用例去跑新旧版本的命中率。不过没有对照组就很容易“觉得变好了”,其实可能是幻觉。所以我定了标准:同一组30条真实用户表达,旧版命中20条,新版命中25条以上才算真正有效改动。
二是技能数量的边界在于维护成本。我见过有人一口气加了四五十个技能,最后AI决策准确率掉得一塌糊涂,因为太多技能让选择变得困难。我目前稳定维持在十二个左右,每个都能保证质量。这种系统跟真实世界一样,“够用且值得维护”一直好过“又多又烂”。
最后分享一个我最近养成的习惯:给每个新技能写一行“非目标”描述。比如文件统计技能的非目标是“不做文件内容搜索,不做删除操作”。别小看这一行,AI决策时会因为这一句“这不是你干的事”,直接排除掉大概率误调用的情况。技能描述不只是告诉AI“你是什么”,也得告诉它“你不是什么”。这套skills体系,越到后面你越会发现,限制住边界比扩展能力更值得花心思。