多模态模型正从单独的技术能力,走向完整的产品化组织方式。这篇围绕标题skills展开的项目复盘,记录了我从零搭建一套可复用、可维护、可升级的 AI 功能体系的过程。核心关键词:skills、智能体架构、提示词工程、功能编排、多模态交互。
如果你也在做 AI 产品,或者在自建 AI 工作流,经常遇到功能像补丁一样越堆越乱、上下文越塞越杂、模型稍微升级就各种失灵,那这篇内容就是冲着你来的。我踩过坑,也把坑填了,沉淀下来一套设计范式,并且在真实项目里跑通了。这篇文章会把这套体系的思路、细节、实现过程、踩坑记录和排查方法全拆给你看,适合正在做 AI 应用架构的开发者、AI 产品经理,以及所有想让模型稳定输出复杂结果的工程实践者。
1. 整体设计思路:从“堆 Prompt”到“搭 Skill 体系”
先说我之前的问题。早期做 AI 功能,我习惯把所有要求写进一个巨大的系统提示词:角色设定、背景数据、任务步骤、输出格式、限制条件、兜底策略,全部塞在一起。结果很典型:提示词超过几千字之后,模型在中部内容的遵循度明显下降,越靠后的规则越容易失效。更麻烦的是,每次需求变化都要动整段提示词,改一个点,经常引起连锁反应,别的功能莫名其妙地崩了。
后来我接触到了skills这个概念,核心变化就一句话:不写巨无霸提示词,而是把能力拆成可独立安装、独立调用的技能单元。从产品角度看,一个 AI 功能就是一个技能,它有名字、有说明、有执行步骤、有需要的资源和工具、有输入输出协议。模型自身只保留最基础的推理和对话能力,具体领域的活儿,通过挂载对应的技能来完成。
我花了差不多三周时间,把原来的单体提示词体系彻底推翻重做,整个系统重构为一组 skill 模块。这一步走完,立刻感受到几个实实在在的好处:
- 可组合性:同一个技能可以被不同场景复用,比如“数据清洗”技能既能用在报表生成,也能用在知识库整理,不需要重复写逻辑。
- 可测试性:每个技能独立验证,输入明确、输出明确,能单独写测试用例,找出问题不再是抓瞎。
- 可版本化:技能文件就是普通文本和脚本,可以放进 Git 管理,改动和回滚都干净,不像以前改提示词只能凭记忆。
这个思路其实类比一下很好懂:以前是让一个实习生背一本一百页的手册去做十件事,结果是每件事都做得稀烂;现在是把十个有明确分工的老员工放进团队,每个员工只负责自己手里这件事,各拿各的标准作业流程,出了问题直接问责单一模块。这就是技能化的本质,用结构化对抗不可控。
在设计整套 skill 体系时,我给自己定了三条原则,这三条也是后续所有设计决策的根源:
第一,职责单一切割。一个技能只解决一个问题,宁可技能粒度小一点,也不要做出一个什么都能干但是全都干不精的万能技能。第二,显式输入输出协议。每个技能必须说清楚自己期望的输入格式和输出的数据结构,没有协议的技能没法被编排。第三,最少依赖优先。技能能不用额外脚本就不加脚本,能少依赖第三方库就少依赖,一个技能拉起来要能在任何环境里低成本跑起来。
这套原则直接决定了后面的文件结构、接口设计还有调度逻辑。可以说,整个项目从方法论层面就是围绕这三个核心约束长出来的。
2. 核心细节解析:技能单元的标准结构与工作机理
2.1 SKILL.md:技能的核心描述文件
整个技能体系里,最基础也最重要的文件是SKILL.md。它不是给模型吃的提示词那么简单的附属品,而是整个技能的中枢控制文件。模型在执行任何技能前,第一件事就是读取这个文件,搞清楚这个技能到底是干嘛的、什么情况下用、操作步骤是什么、有什么约束要注意。
我设计一个SKILL.md时,一定包含六个部分,缺一不可:
- 技能名称:一句话说清技能用途,比如“生成技术周报”“清洗用户反馈数据”。
- 触发条件:明确说明,当用户输入遇到什么情况时,应该调用本技能。这是编排器判断要不要调用技能的依据。
- 执行步骤:把任务拆成 5~10 步以内的操作序列,每一步指令清晰、无歧义,而且可以用脚本辅助的步骤尽量用脚本,减少模型自由发挥的空间。
- 输入要求:说明需要的参数、字段,以及字段的数据类型和格式示例。
- 输出规范:定义输出结构,最好给出一个 JSON 示例或者 Markdown 模板。
- 约束与边界:列明技能不做什么、什么情况下该停止、什么数据不处理。
写执行步骤的时候有一个重要细节:不给模型自由发挥的机会。比如写“整理出本周重点”这种描述,模型就会产生各种理解偏差;但如果写成“从输入中提取本周所有状态为‘已完成’的任务,按完成时间倒序排列,输出前十条”,模型的执行结果就稳定得多。
我用一个很简单的例子说明 SKILL.md 的写法。我做过一个“会议纪要结构化”技能,它的核心描述文件长这样:
--- name: meet-minutes-structurer description: 将一段原始会议记录转为结构化摘要,输出议题、结论、待办 trigger: 用户提供会议记录并希望整理摘要,或对话中出现“会议纪要”“会议记录整理” input: 原始会议文本,支持纯文本或 Markdown output: json object,包括 summary、items、actions 三个字段 --- ## 执行步骤 1. 将原始文本切分为句子级片段。 2. 使用规则脚本过滤无关话语(寒暄、口头语、语气词)。 3. 将剩余内容按议题聚类,标记议题标题。 4. 每个议题提取结论句,若无结论则标记 n/a。 5. 将所有行动项提取到 actions 字段,标注负责人和截止时间。 6. 校验输出 JSON 完整性,缺失字段用 null 补齐。 ## 约束 - 不输出主观建议。 - 不补全缺失信息。 - 议题数量超过 8 个时,按时间顺序保留前 8 个。这种结构的 SKILL.md,模型只需要做“执行者”,不需要做“规划者”。它要做的事情被完全规定死了,偏差自然被压缩到最小。
2.2 辅助脚本与资源文件:让规则代替概率
光有文字说明还不够。模型毕竟是概率生成,文字再精确也有随机性。为了进一步稳定输出,我会把凡是能程序化处理的部分全部用脚本接管,这就是技能目录里的scripts/和resources/。
比如会议纪要技能里,我写了一个很小但很关键的 Python 脚本,作用就是把文本切句、过滤语气词、识别高频议题关键词,并在最终输出前做 JSON schema 校验。
import re import json def split_sentences(text): parts = re.split(r'(?<=[。!?!?])', text.strip()) return [p for p in parts if len(p) > 1] def filter_noise(sentences): stop_words = ['嗯', '这个', '那个', '就是说', '对吧', '然后'] result = [] for s in sentences: if any(w in s for w in stop_words): continue result.append(s) return result def build_summary(sentences): # 简易聚类:按句首关键词识别议题块 topics = [] current = None keywords = ['议题', '接下来', '关于', '重点讨论'] for s in sentences: if any(k in s for k in keywords): if current: topics.append(current) current = { 'topic': s.strip('::'), 'content': [] } elif current: current['content'].append(s.strip()) if current: topics.append(current) return topics if __name__ == '__main__': import sys text = sys.stdin.read() sentences = split_sentences(text) filtered = filter_noise(sentences) topics = build_summary(filtered) out = { 'summary': '; '.join(filtered[:5]), 'items': topics, 'actions': [] # 规则识别含“负责”“跟进”“完成”的句子 } for s in filtered: if any(a in s for a in ['负责', '跟进', '完成', '截止']): out['actions'].append({'text': s, 'owner': 'unknown', 'deadline': 'unknown'}) print(json.dumps(out, ensure_ascii=False, indent=2))脚本的价值不只是提效,更关键的在于它给了 AI 一个“固定答案的地板”。模型负责理解,脚本负责规则,两边各干各擅长的事,整体可靠性立刻上了一个台阶。
脚本还有一类用途是资源查找。有些技能领域性很强,比如医疗术语解析、特定行业法规查询,这类技能会需要一个本地知识库或外部 API 查询脚本。技能触发时,脚本检索出与当前问题最相关的知识片段,把这些片段作为上下文提供给模型,而不是让模型靠它训练数据里的模糊记忆。这种机制在处理强时效性信息时尤其有用。
2.3 技能调度策略:模型如何知道该用哪个
一套技能体系说白了是一堆独立技能的集合,但模型怎么从这么多技能里选出正确的那个?我用的是“描述匹配 + 触发条件排序”的双通道机制。
首先,每个技能的描述文件里都有 trigger 字段,这是给调度器看的。模型在进入任务前会阅读当前技能目录里的所有SKILL.md的 name 和 description 字段,快速判断哪些技能与当前用户意图相关。这一步本质上是一次文本相似性匹配,但注意,模型不是在做数学计算,而是做语义理解。因此 desc 写得越具体、越准确,选错的概率就越低。这就是我为什么坚持每个技能只用一句话描述核心场景,而不是写一段四平八稳的废话。
其次,调度器还维护了一个优先级表,某些高频技能可以被配置为“默认考虑”状态,比如用户对话中只要提到“整理”“总结”“汇总”就直接把整理类技能排在候选列表前列。这样能显著减少模型在几十个技能之间反复犹豫而产生的“什么技能都调一点、结果四不像”的情况。
技能之间还会存在协同使用的场景。比如一个技能负责从原始资料中抽取结构化数据,抽完后的结果要交给另一个技能做可视化分析。这种跨技能协作在架构上怎么处理?我的做法很朴素,在技能描述里显式写清楚“上游输出是什么格式、下游预期输入是什么格式”,两个技能用 JSON 作为中间语言对接口。模型在编排时只要能读懂两个接口描述,就能像拼乐高一样把它们拼起来。
明确了调度策略后,下一个要关注的问题就是技能的完整性校验。技能系统里最容易出现的问题是文件都放那儿了,但内部结构不规范。模型读取时缺字段会直接跳过该技能,造成隐蔽的失效。我开发期间就撞上过这种问题,明明感觉某个技能配置无误,调用时却毫无反应。后来给技能目录加了一个结构检查器,每次保存配置时自动跑一遍字段完整性校验,少了哪个字段、类型对不对、脚本有没有缺失,一目了然。
3. 实操全记录:从零搭建一套多模态技能库
理论部分讲得再多,都不如把一套真实技能库的搭建过程完整走一遍。这一节我以一个具体项目为例:当时我需要给一个内部工具做一个“多模态数据汇报”功能,输入物是一组散乱的报销单据照片和 Excel 表格,输出物是一份可以对外汇报的开支总结。整个过程我拆成四步:环境与骨架准备、技能单元编写、技能加载与测试、整体联调。每一步你都可以照着操作。
3.1 环境准备与技能库骨架
我在一个已有的项目仓库里建立了skills/目录,结构是这样的:
skills/ ├── expense-summarizer/ │ ├── SKILL.md │ ├── scripts/ │ │ ├── extract_receipt.py │ │ └── summarize_expense.py │ └── resources/ │ ├── category_map.json │ └── template_report.md ├── chart-builder/ │ ├── SKILL.md │ └── scripts/ │ └── build_chart.py └── registry.yamlregistry.yaml是技能注册表,记录了每个技能的启用状态、版本号、入口脚本路径。当时想得很简单,后续技能增多时,所有技能信息集中到这里,便于统一管理。实际跑下来这个文件非常有用,尤其是排查“技能为什么不生效”的时候,第一件事就是检查注册表里的条目。
3.2 技能单元编写细节
第一个写的是expense-summarizer。这个技能的目标是从各种原始资料中提取支出数据,汇总成结构化结果。SKILL.md 我是这样写的:
name: expense-summarizer description: 从报销单据图片和表格中提取支出数据,汇总为结构化开支表 trigger: 用户上传收据、发票、报销单、Excel开支记录,并要求汇总、分析、制作报告 input: - 支持 jpg/png 图片,内部通过 OCR 提取文字 - 支持 xlsx/csv 表格,内部通过 pandas 读取 output: json_object: total: number categories: [{ name: string, amount: number }] top_expenses: [{ item: string, amount: number, date: string }] steps: - 调用 extract_receipt.py 提取图片文字 - 调用 summarize_expense.py 读取表格并合并数据 - 按 categories 聚合 - 生成最终 JSON constraints: - 金额单位统一为元 - 日期格式统一为 YYYY-MM-DD - 无数据的字段必须置为 null,不得省略这里有几个细节值得单独拿出来说。
第一,我特别在 input 里写了“支持 jpg/png”“支持 xlsx/csv”这类细节,原因是很多模型在判断文件类型时容易忽略 MIME 类型,直接读字节流,导致数据错位。把支持的格式显式列出来,模型在调用时就不再需要猜测。
第二,constraints 里规定“无数据字段置为 null”,这不是随便写的。JSON 输出中如果字段缺失,下游程序跑起来会直接抛 KeyError,脚本链路当场断掉。定好这个约束,模型输出结构从一开始就符合代码的预期,后面省了无数调试时间。
脚本部分,我给 OCR 和表格读取分别写了独立的工具函数。OCR 用了现成的开源库,表格读取就是 pandas。这里最需要注意的点是要给脚本加上容错。比如 OCR 的图有些旋转了、模糊了、反光了,直接识别就可能输出乱码。我在extract_receipt.py里做了图像预处理,缓解一部分低质量输入的崩坏风险。
import argparse import json from PIL import Image, ImageOps, ImageFilter def preprocess(image_path): img = Image.open(image_path).convert('RGB') # 灰度化、增强对比度、适度降噪,能显著提升OCR识别率 gray = ImageOps.grayscale(img) gray = gray.filter(ImageFilter.SHARPEN) contrast = ImageOps.autocontrast(gray) return contrast def main(image_path): img = preprocess(image_path) text = ocr_engine.recognize(img) # 按所选OCR库的接口调用 return {'raw': text} if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('image_path') args = parser.parse_args() print(json.dumps(main(args.image_path), ensure_ascii=False))注意,我没有在脚本里写死某个 OCR 厂商的接口,这是刻意为之。换不同 OCR 库时,只有recognize这一行需要改动,测试和替换成本被压缩到最低。
3.3 技能加载机制:把技能真正挂到模型上
环境和技术栈不同,加载方式差异很大。我这里用的是通用可控的编排方式:技能的 SKILL.md 通过系统消息注入到对话上下文中,脚本作为外部工具暴露给模型调用。具体来说就是:
- 启动时读取
registry.yaml,遍历注册的每个技能目录; - 解析每个目录下的
SKILL.md; - 将技能描述和触发规则汇总成一份能力清单,放进系统提示词;
- 模型的输出如果是特定本领的调用请求,比如
call_script: summarize_expense.py,由编排器拦截并执行对应脚本,把脚本的输出喂回对话。
整个过程说得更形象一点:模型像是一个总览全局的中层管理者,它手里的能力清单就是一份团队成员简介;当它判断某项任务需要某个成员动手时,就发一个“工作请办单”出去,脚本执行完后把成果汇报给它;它再继续往下走流程。
拦截机制需要一套清晰的中转协议。我之前用过很多不同格式,最后锁定为 JSON。模型返回的 JSON 里包含tool_name和args两个字段,编排器解析后调用本地函数,再把结果以日志形式追加到对话上下文。这套协议简单、透明、容易调错。
3.4 整体联调与试运行结果
我把两个技能都挂载之后,用一批真实报销数据做了联调。输入是一张餐厅发票照片、一张高铁票照片和一张 Excel 开支表。整体流程跑下来约 40 秒,最终输出的 JSON 总金额、分类汇总、金额最高前三条全部正确,并且输出格式直接符合下游模板要求。
试着看一下我当时跑出的中间结果:
{ "total": 3568.50, "categories": [ { "name": "餐饮", "amount": 328.00 }, { "name": "交通", "amount": 2380.50 }, { "name": "住宿", "amount": 860.00 } ], "top_expenses": [ { "item": "高铁票", "amount": 553.00, "date": "2024-11-02" }, { "item": "酒店住宿", "amount": 860.00, "date": "2024-11-03" }, { "item": "高铁票", "amount": 498.00, "date": "2024-11-04" } ] }注意到这里没有报告编号、没有发票编号,因为我在约束里压根没让模型输出这些字段。脚本从图像和表格里真的提取不出也无妨,按协议它们被省掉了。表面看信息少了,实际上减少了幻觉空间,这正是显式协议带来的稳定性红利。
4. 经验沉淀:把技能体系做“稳”的五个关键策略
这一节的内容比较杂,但每一条都是从反复踩坑里提纯出来的。如果你照着我前面的设计搭建技能库,在实战中你一定还会遇到这些看不见的细节。提前写在这里,能让你少浪费几个周末。
4.1 技能边界必须刻意窄化
这是第一条,也是最重要的一条。技能一旦定义的边界过宽,就会出现“什么都想干、什么都干不精”的情况。我早期做过一个叫analyze的万能技能,既能分析文本、又能分析数据、还能分析图片,结果就是在每类输入上都不稳定,经常把文本分析结果输出成图表结构。
我后来把每个技能都改造成职责单一的窄技能。比如“发票识别”只做识别,“支出汇总”只做汇总,“趋势判断”只做趋势判断。窄化意味着模型每次要做的判断变少,遵循率大幅提高。你可以粗暴地理解成:“给模型的自由越少,输出越稳。”
4.2 规则交给代码,理解交给模型
从设计之初我就反复拿捏一个问题:技能里的逻辑到底写在提示词里还是写在脚本里?说白了,文字规则是概率性的,代码规则是确定性的。凡是涉及计数、排序、筛选、去重、校验这一类逻辑,全都应该交给代码脚本;凡是涉及理解意图、提取语义、判断相关性这一类任务,才应该交给模型。
比如我设计抽取动作时,“过滤掉状态为已取消的订单”“按金额降序排列”“合并同一天的多笔支付记录”这些判断统统写进了 Python 脚本,SKILL.md 中反而只写“过滤、排序、合并”这几个动词,模型不用理解这些操作的具体运算逻辑,它只需要触发脚本。这种分工方式让整体可靠性有了质的飞跃。
4.3 输出协议要有兜底设计
再稳定的模型也会有偶尔不守规矩的时候。最经典的坑是,要求输出 JSON,它给你一段 Markdown;要求字段是amount,它给了Amount;要求空字段填null,它直接不输出那个字段。这些都会导致下游代码中断。
所以后来我在每个脚本的最后一步都加上了输出校验逻辑,比如用 JSON Schema 检查字段类型、检查必填键是否存在、检查数字类型是否合法。不合规就自动重试一次,把上次输出塞回去让模型重新生成,再不行就返回固定错误结构。这套兜底设计把失败率从肉眼可见的尴尬降到了几乎可忽略。
4.4 技能文档要写“反面约束”
很多人在写技能描述时会只写“做什么”,不写“不做什么”。我建议反过来想一下:一个技能如果放任模型自行理解,最容易在哪些地方走偏?把这些地方显式写到“约束”里。
比如一个客服技能,约束里写“不得承诺赔偿金额”“不得编造退换货政策”;一个分析技能,约束里写“不做超出既有数据范围的推断”“不自造指标”;一个总结技能,约束里写“不加入原文没有的观点”。有了反面约束,模型的边界感明显强了很多。那些看似理所当然的规则,你不写它,它就真的会不遵守。
4.5 技能库一定要可以自动化回归测试
技能会不断迭代,模型也会时不时更换版本。每一次改动都可能让原本稳定的技能发生回归。在没有自动化测试的情况下,你根本不知道这次改动到底改坏了什么。后来我建立了一套轻量级回归测试机制:准备固定测试用例集,每个用例包含一个模拟输入和一份预期输出样本。每次技能库更新后自动跑一遍,用规则比对输出结构,再辅助模型对结果打分,全部通过才允许进入生产。
说白了,这套自动化测试是技能体系的照妖镜。模型行为没法穷举保证,但至少常见路径和输出格式是随时被盯住的,不会出现上线第二天用户反馈技能失效,而你自己还浑然不知的情况。
5. 实战问题速查与修复思路
这一节整理了我这几次实操过程中遇到频率最高、最有代表性的一些问题,以及我摸索出的修复策略。不一定每条都爱听,但真的都是保命技巧。
| 现象 | 可能原因 | 修复思路 |
|---|---|---|
| 技能明明在列表里,但模型根本不调用 | SKILL.md 的 trigger 字段写得太抽象,模型没能把用户输入关联起来 | 将触发条件改成“用户出现哪些词就调用”的显式写法,用 5~10 个示例短语 |
| 调用了技能,但经常输出错乱 | 执行步骤里出现“适当”“合理”这类模糊词,或步骤数超过模型可使用的工作记忆上限 | 删除模糊限定词,步骤压缩到 7 步以内,且尽量用脚本接手步骤 |
| 输出 JSON 偶尔缺字段 | 输出规范中未说明空值策略 | 在约束里强制要求所有字段必有,无法计算则填 null |
| 脚本调用失败 | 缺少依赖/路径错误/参数类型不匹配 | 技能目录带一个requirements.txt,并控制脚本只从stdin读入、只向stdout输出,路径问题归零 |
| 多个技能同时激活时输出风格漂移 | 技能描述之间存在语义重叠,模型混淆选择 | 精简技能描述,明确“什么时候该用另一个技能,什么时候不该用本技能” |
| 模型换版本后技能效果降级 | 新模型对指令的遵循方式与旧版本不同 | 回归测试跑一遍,快速锁定受影响技能,找出描述中可能产生歧义的细节并改写 |
这里面我最想在单独强调一下的是“脚本只从 stdin 读、只向 stdout 出”这条。一开始我习惯把数据路径直接写进脚本参数,结果模型在不同场景下给出的路径五花八门,脚本动不动就找不着文件。改成统一从 stdin 读全文、执行结果输出到 stdout 后,所有技能脚本的调用方式一致了,模型不再需要猜测文件位置,编排器也不需要解析复杂的参数结构。表面上看这只是一个接口设计的小改动,实际把整个技能系统的调试复杂度降了一个维度。
另外,模型换版本导致技能降级这个坑,远比想象中频繁。很多人以为提示词写得越明确,模型版本更换影响就越小,我实测下来恰恰相反:新模型的分词方式和指令遵循粒度经常变化,哪怕措辞完全相同的指令,在旧版上遵循得好好的,在新版上就可能把某一步忽略掉。唯一的解法就是回归测试,别在每次发版时手动试几个用例就放行。
还有一件事要提醒:技能目录和代码一样需要版本管理。SKILL.md 就是代码,脚本就是代码,注册表也是代码。我见过不少人把技能文件往项目里一堆,标注“已完成的配置”,然后后续怎么改的、为什么改、改了什么,完全没有记录,出了问题根本没法追。把技能文件全部纳入 Git 管理,每次调整都带注释,这是最基本的工程素养。
最后再分享一个我个人的体会:这套技能化体系的本质,是把“大模型对话”从自由发挥变成工程可控。你不可能让模型永远不犯错,但你可以通过结构、协议、脚本、测试,把错误发生的位置死死限制在最小范围内。我在实际使用中最大的感受是,系统性设计带来的稳定性红利远超想象,一次搭好,后续每次迭代都是线性成本,而不是指数级的心累。如果你现在还在被“这功能时好时坏”折磨,我强烈建议你抽一个完整的下午,把技能设计方法论走一遍,然后老老实实做个回归测试,你会回来感谢这套体系的。