这两年做大模型智能体,我最大的一个体会就是:模型的能力上限由参数决定,但能力的边界却由工程决定。你可以用提示词把Agent调教得头头是道,可一旦涉及真实业务——查库存、算报价、改配置、跑批处理,它就像个只会纸上谈兵的实习生。而那个让智能体真正“动手干活”的模块,我习惯叫它 agent-skills。这玩意儿不是什么新框架,也不是某个商业平台的专属功能,它本质是一套把模型之外的可执行能力封装成标准模块的工程规范。今天我会从设计思路、目录结构、实现细节到蹚坑实录,把一个技能模块从零到一的全过程掰开揉碎讲清楚,给正在做Agent落地的开发者一份可以直接抄作业的参考。
做技能模块最忌一上来就写代码。你得先想明白一个问题:既然模型本身已经能对话、能推理、能写东西,为什么还要在它外面挂一层“技能”?这个问题想不透,后面全是烂尾工程。
1. 技能化设计的底层逻辑与核心收益
1.1 为什么需要把能力拆成独立技能
先把场景摆出来。你有个智能体,要让它做三件事:读邮件、提炼待办、写周报。最粗暴的方案是把所有指令和背景塞进系统提示词里,让模型在一个超长上下文里完成所有工作。第一版可能还能跑,等需求一多,提示词撑到几千字后你会遇到三个经典症状:模型的注意力被稀释,明明该调接口的时候它在编数据;上下文窗口被打满,连基本的对话记忆都保不住;你改一处逻辑,整条prompt的逻辑链就崩了。
隐藏的问题是能力无法复用。你在这个项目里写好的“读邮件并分类”逻辑,换个项目就得重写一遍。代码拷贝容易,但里面附带的提示词策略、接口连接配置、错误处理分支全都耦合在一起,迁移成本高得惊人。agent-skills的思路就是把这层能力从“提示词里的描述”变成“独立的可执行模块”——每个技能都有自己的说明文档、代码实现和配置项,模型根据任务描述动态加载它,用完就释放,互不干扰。
从操作系统的视角看这件事特别好理解。你往电脑上装个打印机驱动,系统不会在启动时把驱动全读进内存,而是在你点“打印”那一刻才把对应模块拉起来。Agent技能也是这么个逻辑:一个装满技能的目录就是系统的驱动库,模型是CPU,调度器是内核,任务到达时,它挑出最匹配的技能来执行。
1.2 技能模块与工具调用的边界在哪儿
有经验的读者会问:这不就是function calling吗?工具(Tool)和技能(Skill)之间有重叠,但站的角度不一样。函数调用是“接口契约”,它定义你给模型暴露了哪些函数、参数怎么填、结果怎么回。技能则是完整的能力包,它不仅包含接口实现,还包含模型在什么场景下应该选择它、输入数据前需要做哪些预处理、执行期间遇到特定错误怎么办。
| 对比维度 | 函数/工具调用 | 技能模块 |
|---|---|---|
| 核心内容 | 接口描述、参数Schema | 接口 + 使用说明 + 上下文样例 |
| 触发方式 | 模型显式发起调用 | 调度器按意图匹配后加载 |
| 生命周期 | 随对话上下文存在 | 按需加载,执行结束即释放 |
| 可复用范围 | 单项目内共享 | 跨项目、跨Agent复用 |
| 附带信息 | 函数签名和描述 | 示例、约束、权限配置、版本信息 |
我在工程里对团队定的规矩很简单:一个计算、一次查询、一步操作,做成工具;一段需要多步骤配合、有状态流转和条件分支的逻辑,封装成技能。比如“算两个日期差多少天”是个工具,“根据接单时间自动计算交付日期并校验节假日顺延”就是个技能,因为后者包含业务规则,你的模型不该自己拍脑袋想这些规则,它该做的只是选对技能。
2. 技能模块的目录组织与元数据设计
2.1 一个技能包应该包含哪些东西
我推荐一个技能包至少包含五个部分,这在我参与的多个项目里已经收敛成稳定结构:
skill-name/ ├── skill.toml # 元数据定义 ├── main.py # 技能实现入口 ├── schema.json # 入参出参的JSON Schema ├── README.md # 技能使用说明与示例 └── assets/ # 静态资源、参考数据(可选)很多新手写的技能包只有main.py,这是个坑。缺失元数据文件,调度器就无法知道这个技能是干嘛的、适合什么场景、有哪些参数,模型只能靠猜。缺失schema.json,入参校验全靠运行时debug,线上环境出一堆四不像的错误。至于README,它最大的作用不是给人看,而是被上游的模型语义索引模块转成向量,用于意图召回——你写得好不好,直接决定模型能不能找对技能。
为了让这套结构真正生效,需要约定一个加载约定:技能根目录下的 skill.toml 采用UTF-8编码,依赖清单声明在系统或技能级,运行环境由统一容器提供。只要遵守这些约束,任何新技能加入系统时,不需要修改一行调度代码。
2.2 元数据字段设计,尤其是描述和样例行
skill.toml里最关键的不是版本号,而是description和examples。先说description,它是模型判断“什么时候该用这个技能”的依据。写过提示词的人都知道,给模型的指令不能含糊,技能描述同理。举个例子,你写“本技能用于处理销售数据”,模型无法确定它能不能在月度分析场景里用;改成“本技能适用于月度销售明细的清洗、指标计算与趋势汇总,输入一张包含日期、渠道、销售额的表格,输出按渠道和月份聚合的汇总表”,匹配准确率能上一个台阶。
examples字段则更像是给模型的“考卷参考答案”。我会在每个技能的元数据里放两到三个完整的输入输出示例,用于帮助模型理解调用边界。曾经有个内部测试场景:模型在40%的情况下会把“统计接口响应时间P99”误判为“统计接口错误率”。在技能描述里加了正例和反例之后,误判率直接降到5%以下,这就是示例的价值。
还有一个容易被忽略的字段是permissions。它声明技能执行的最低权限,比如只读、写临时目录、可访问特定域名。权限设计的目标是把执行环境对模型幻觉的影响降到最低——模型可以选技能,但不能靠技能给自己提权。
2.3 技能描述与意图匹配的写法技巧
写技能描述有一条我总结的心法:给模型讲“你正在处理什么”,而不是“技能做了什么”。举例:
错误示范:
本模块包括数据读取、异常值处理、缺失值填充、统计输出等功能。正确示范:
当你拿到一份包含空值或异常样本的历史订单数据集,需要先清洗,再按日汇总订单量、销售额,最后输出趋势表时,请使用该技能。任务中若包含“清洗数据”“统计汇总”“趋势分析”等动词,优先匹配本技能。两者差别在于,前者是功能导向,后者是场景导向。模型对齐的不是功能,而是用户当下的意图。这也解释了我为什么始终强调examples的重要性——在技能数量超过20个之后,模型几乎完全依赖正反样例去做匹配决策,描述文本只是辅助。
3. 核心实现:注册、加载与执行链路
3.1 技能注册器的实现思路
技能模块的入口是一个注册表,所有技能在系统启动或热加载时登记进场。工程上我比较喜欢用装饰器方式,它写起来最干净,也便于框架复用。下面是你的第一段可以直接抄走的代码块:
from typing import Callable, Dict, List class SkillRegistry: def __init__(self): self._skills = {} def register(self, name: str, entry: Callable, description: str = ""): if name in self._skills: raise ValueError(f"技能重复注册: {name}") self._skills[name] = { "entry": entry, "description": description, } def get(self, name: str): skill = self._skills.get(name) if not skill: raise KeyError(f"技能不存在: {name}") return skill["entry"] def list_skills(self) -> List[str]: return list(self._skills.keys()) registry = SkillRegistry() def skill(name: str, description: str = ""): def decorator(func: Callable) -> Callable: registry.register(name, func, description) return func return decorator用起来长这样:
@skill("order_daily_summary", "按日聚合订单数据并输出汇总表") def order_daily_summary(payload: dict) -> dict: ...注册器的核心价值是保持系统各模块之间的解耦。技能之间不互相引用,它们通过注册表被调度器统一选择。引入一个新技能对已有代码的影响为零。
3.2 加载机制:动态导入是优雅的关键
市面上的框架一聊到加载技能就喜欢搞插件扫描机制,复杂一点还上个抽象基类。可我实测下来,最稳的还是按命名规范动态导入。你准备好模块名和类名后,直接用Python内置的importlib就够了:
import importlib import pathlib from typing import Dict, Any SKILL_DIR = pathlib.Path("./skills") def load_skill_module(skill_name: str) -> Dict[str, Any]: module_path = SKILL_DIR / skill_name if not module_path.exists(): raise FileNotFoundError(f"技能目录不存在: {skill_name}") if not (module_path / "skill.toml").exists(): raise FileNotFoundError(f"技能缺少 skill.toml: {skill_name}") spec = importlib.util.spec_from_file_location( skill_name, module_path / "main.py", ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return { "module": module, "metadata": parse_skill_metadata(module_path / "skill.toml"), }这套方案有几个天生优势:不需要维护集中式的插件注册表,技能目录结构即是配置文件的映射;删除或替换技能时学不到旧代码,做到真正的可插拔;加载速度远快于执行子进程式隔离方案,适合技能被高频调用的场景。
我的实际经验是,优先保证单技能加载时间在50毫秒以下。如果你的业务场景里要做跨语言技能调度,建议把技能封装成独立的命令行进程,再通过Socket通信返回结构化结果。图形界面应用不是问题,因为Agent不是强交互路径上的短延迟服务。
3.3 执行会话与上下文传递
技能执行不能像裸函数调用那样只传参数,它需要一个独立的执行上下文,承载prompt前缀、临时目录路径、日志处理器和运行时长限制。这个上下文对象要反映“一次技能调用的完整生命周期”。
一个经验是:技能之间的上下文必须隔离。A技能写入的环境变量绝不能影响B技能;一个技能执行中产生的临时文件,结束后应该被清理。清理逻辑要靠上下文管理器保证,哪怕技能内部抛了异常也要触发清理。我在生产环境里见过太多因为临时文件没有清理导致磁盘爆掉的案例。
3.4 安全边界
技能是代码,代码就一定有破坏力。模型运行时若直接拿到shell权限,那你就是把整个系统的钥匙交给了概率采样器。我有些习惯供你参考:
- 每个技能运行在受限用户态进程里,不能触碰系统全局配置
- 技能能访问的文件目录被限定在它自己的沙箱目录内
- 网络访问用白名单域名列表,默认禁外联
- 单次技能执行最长不超过60秒,超时自动terminate
- 技能返回的文本大小限制在100KB以内,防止内存被撑爆
安全设计的核心原则是:即使技能是一段恶意代码,它也做不了超出最小权重的坏事。往好的方向推测,大部分技能是没问题的,但你要为最坏的情况兜底。
4. 从零写一个可用技能:实战演示
4.1 需求场景与整体规划
假设现在要做一个“会议纪要一键归档”技能。需求背景是:业务团队的每日站会、周会、评审会纪要散落在各个群聊里,我们需要把新产生的会议内容自动归档进结构化知识库。这个技能要接收一段会议记录文本,提取主题、参会人、待办事项、结论,输出JSON式摘要。
先定方案:不采用让模型实时抽取的在线方案。因为每次调模型成本高、延迟大,且面对长文本不稳定。改为模型离线预解析后,在技能内部用一个组合流程做结构化校验和归档。整体执行分成四步:输入预处理、字段抽取、格式校验、写回存储。
4.2 写元数据,这是模型的“使用手册”
先创建技能目录和skill.toml:
name = "meeting_minutes_archive" version = "1.0.0" description = """ 将会议记录文本转换为结构化归档信息。适用于站会、周会、评审会 等场景,输入为原始会议纪要文本,输出为包含主题、参会人、待办、 结论的JSON对象。 """ author = "some_developer" entrypoint = "main.py" permissions = ["read:meeting_notes", "write:knowledge_base"] [examples] positive = [ "会议记录:今天讨论订单接口超时问题,参会人张川、李倩、王雷,结论:下周三前修复,待办:李倩出排查报告。", "会议纪要:版本发布会复盘,参会人全员,结论:发版流程要加自动化检查,待办:王雷整理检查清单。", ] negative = [ "帮我把订单数据按周汇总。", "查询库存不足的SKU列表。", ]注意我把反面案例也写进去了。实践结论是,存在正确反例的技能匹配准确率平均高出15到20个百分点,这在多技能系统里差异非常明显。
4.3 实现技能主逻辑
main.py逻辑不复杂。我用正则加规则做粗提取,再用模型接口做兜底,这样既控制成本,又保证复杂文本不漏关键字段:
import json import re def parse_participants(text: str) -> list: # 按常见分隔符切分 names = re.findall(r"([\u4e00-\u9fa5]{2,3})(?:[、,,\s]+|$)", text) return list(dict.fromkeys(names)) def run(payload: dict) -> dict: raw_text = payload.get("text", "") if not raw_text.strip(): raise ValueError("输入文本不能为空") # 粗提取主题,通常取首个“:”或“讨论”前的内容 topic_match = re.search(r"(?:主题|议题)[::]\s*(.+)$", raw_text, re.MULTILINE) topic = topic_match.group(1).strip() if topic_match else raw_text.split()[0] # 待办事项提取 todos = re.findall(r"(?:待办|TODO)[::]\s*([^\n]+)", raw_text) result = { "topic": topic, "participants": parse_participants(raw_text), "todos": [t.strip() for t in todos], "conclusion": "", "status": "parsed", } # 这里省略真实的模型兜底调用,用空串占位 # result["conclusion"] = llm_extract(raw_text)["conclusion"] return {"ok": True, "data": result}真实场景里你需要接入模型兜底,这个代码结构已经给了你扩展点。把规则抽取当作主路径,模型兜底当作补充,能让命中率稳定在90%以上。
4.4 schema定义与工具链接入
schema.json按标准JSON Schema定义,用来做入参校验,千万别省:
{ "$schema": "http://json-schema.org/draft-2020-12/schema", "type": "object", "required": ["text"], "properties": { "text": { "type": "string", "minLength": 1, "description": "原始会议记录文本" }, "source": { "type": "string", "enum": ["group_chat", "email", "other"], "default": "group_chat", "description": "会议记录来源" } } }配好schema之后,不管哪个框架来调用,能传什么参数、参数长什么样,都由这份文件说了算,不会因为框架差异导致参数漏配或错配。这也是让技能模块“一次编写,多处复用”的基础。
4.5 挂载技能并完成一次完整调用
现在把技能目录放到统一技能目录下,运行一段测试脚本:
import json from skill_loader import load_skill_module skill = load_skill_module("meeting_minutes_archive") result = skill["module"].run( payload={"text": "主题:订单超时问题复盘,参会人:张川、李倩、王雷。结论:下周三完成修复。待办:李倩出排查报告。"} ) print(json.dumps(result, ensure_ascii=False, indent=2))输出长这样:
{ "ok": true, "data": { "topic": "订单超时问题复盘", "participants": ["张川", "李倩", "王雷"], "todos": ["李倩出排查报告"], "conclusion": "", "status": "parsed" } }这一步走通,说明你的技能从编写到加载执行的全链路已经通了。剩下的是在调度层做意图匹配,让模型在对话中自动感知“这段会议纪要该归档了”,并主动拉起这个技能。
5. 技能编排与状态管理的进阶玩法
5.1 多技能协同:链式调用与条件分支
技能封装好了之后,真正产生价值的是组合。一个复杂业务往往是多个技能按特定顺序配合的结果。比如“每日经营播报”这个功能,先调用数据提取技能拉取销售表,再将表传给指标计算技能,最后让报告生成技能把指标转成自然语言。这就是链式调用。
链式调用需要编排层。我的实现里用了一个极简的step定义:
playbook = [ {"skill": "sales_fetch", "input": {"date": "2025-01-14"}}, {"skill": "metric_calc", "input": {"source": "step_0", "metrics": ["revenue", "orders"]}}, {"skill": "report_narrate", "input": {"source": "step_1"}}, ]执行器按顺序执行,前一步的输出作为后一步的输入。每步结果都缓存到内存里,供后续步骤引用。这种模式让玩法变得异常灵活:你可以把playbook存成JSON配置,不同的场景挂不同的playbook,新玩法上线时不再需要改代码。
5.2 条件分支与并行执行
链式调用毕竟线性的,真实场景还经常遇到“如果数据量超过阈值就走向量库否则走缓存”这种分支逻辑。我的做法是在执行器里加一个continue字段,允许每个步骤声明“满足何种条件才继续”或“走到哪一步结束”。
并行执行适合处理互不依赖的独立技能。比如,用户提交一份文档,你希望同时完成敏感词检测、摘要抽取、关键词索引。这三个任务可以起三个线程并发跑,总耗时取决于最慢的那个技能,而不再是三者之和。实测中这种并行优化能把端到端延迟压缩60%。
5.3 技能内部的状态缓存与会话记忆
内部状态管理是工程中容易被忽视的环节。单个技能执行是无状态的,但业务往往需要技能产出的中间结果能在多次会话间复用。一个常用手段是技能级缓存目录,技能执行结束后把计算结果以JSON格式落盘,下次执行时先查缓存,命中则免跑。
最典型的应用是“模型摘要缓存”。会议纪要归档时,同一份会议稿被重复处理很多次,但后续提问只需要读取归档结果,可以直接缓存到内存或独立的Redis实例。每次调用前先检查缓存键,它的复杂度远低于重新抽取一遍,带来的收益却是几个数量级的响应速度提升。
做缓存有个致命注意点:保证缓存键设计正确。我一般用输入文本内容哈希加参数版本的组合作为缓存键,代码层面严格隔离环境变量,避免脏数据互相污染。还有一点就是缓存结果必须跟技能版本绑定,技能升级后要自动失效旧缓存。
5.4 会话上下文如何跨技能传递
跨技能传递的数据结构是执行上下文的延续。这里有个容易翻车的设计:把全量对话历史都塞给每个技能。技能需要的是最小上下文,塞一堆无关信息只会让模型越来越糊涂,还会让解析变慢。我建议每次只传“面向当前任务的最小子集”,并通过统一的数据格式在技能间传递。你可以在上下文对象上挂一个轻量的KV存储,让前序技能能写入状态,后续技能能读取依赖。
总之,状态管理的核心是把“能并行的坚决不串行,能缓存的不重复算,能断链的不死等”落实到每一层设计里去。
6. 常见问题与排查技巧实录
6.1 技能加载失败的三种姿势
第一类问题,技能目录不存在或路径配错。明明技能就放在当前目录,但加载器找的是另一个相对路径。排查时先打印路径解析值,写死成绝对路径最省事。第二种是技能文件名不符合约定,比如加载器要求main.py但技能结构里是run.py。第三类最隐蔽:TOML文件里带BOM头,标准解析器直接报错。我建议从用户的配置模板里继承同一个TOML模板,每次改动收敛到一个已知的可用的格式。
6.2 模型“執拗地”不调用某个技能
这大概是所有开发者都头疼的事。方案分两步。第一步检查技能描述和示例是否足够清晰,是否出现模糊用词或者泛化写法。第二步检查是否有另一个技能与它“竞争上岗”,两个技能描述有重叠时,模型随机选其中一个,你的目标技能经常被挤下去。解决方式是明确划分边界,在冲突描述中写明“不适合什么场景”,签名式的排除法通常能解决问题。
6.3 技能升级引发的兼容性灾难
技能从v1.0升到v2.0,接口变了,但旧调用方还在传旧参数。如果你只覆盖了新逻辑,不报错,那么它会静默返回错误结构,这种bug极难排查。我建议在技能入口处做两层守护:第一层用schema.json做入参校验,第二层在工程层面启动自检,对比实际参数机制,与元数据声明一致后才进入逻辑流程。
6.4 常见问题速查表
| 症状 | 可能原因 | 处理手段 |
|---|---|---|
| 技能未触发 | 意图匹配描述模糊 | 补正例和反例,缩小描述范围 |
| 调用报“技能不存在” | 注册时名称与目录不一致 | 统一从skill.toml读取name字段 |
| 入参经常缺字段 | schema未定义必填项 | 严格定义required和时间参数做校验 |
| 执行超时 | 技能内死循环或外部依赖阻塞 | 全局设置超时并日志输出耗时分布 |
| 结果格式混乱 | 输出结构不统一 | 在入口强制JSON Schema校验出参 |
| 缓存命中次级下降 | 版本升级没同步缓存键 | 版本号打入缓存键前缀 |
| 上下文太大 | 每次往返都传全量对话 | 改成只传任务相关的最小字段集 |
6.5 一些值得分享的调试技巧
技能调试最好能热加载。我在本地开发时,写了一个文件系统监听器,技能代码一旦有变化就自动重新加载模块,省去反复重启Agent进程的时间。配置改动同理,监听skill.toml变化就能让新描述立即参与意图匹配,对调试描述效果非常有帮助。
调试期可以在注册表层面对每次技能调用做一次日志:记录输入摘要、选用原因、耗时、输出结果。把这些日志汇聚起来,你基本就拥有了技能调优的数据底座。根据这个底座,你可以区分出哪些技能是高频命中但效果差,哪些技能是低频但业务价值高,就知道优化精力该往哪里投了。
7. 更进一步:技能的版本化与资产沉淀
技能和代码一样,需要版本管理、测试、发布流程。我在项目里会把每个技能包直接放进独立的Git仓库,基于语义化版本打tag,发布时生成锁文件。这样做的直接好处是:当业务需要回退到上一版技能时,一条指令就能做到,不用手动改代码。
测试这块,我强烈建议给技能写一份“黄金输出”样例集。每个技能配上三到五组典型输入与期望输出的配对,每次改动都跑一遍回归测试。技能是给模型用的接口,模型的行为有随机性,有了黄金样例集,至少能保证上游逻辑不变的情况下,输出是稳定的。我把这套东西叫“技能回归基线”,实测效果屡试不爽。
资产沉淀还包含技能之间的依赖管理。当技能B依赖技能A的时候,我们不建议直接在代码里调工具,而应该让编排层去调用,保证技能B的输入和输出始终是结构化的JSON,进一步做到灵活替换和编排。
8. 最后说点实际心法
做了这么多Agent技能模块之后,我自己的技术审美已经有了一个倾向:把技能做薄,把描述做厚。技能内部代码越薄越好,薄到核心逻辑一眼看完,厚的是元数据文件和ROS文档——模型靠这个理解技能,运维靠这个追溯行为。一个技能如果代码超过300行还塞了一堆复杂分支,我的第一反应不是优化它,而是拆成多个技能,然后把流程编排交给调度层去组合。
另一个心法是先让技能在自然语言下可用,再去追求自动调度。手动测试时把技能调用直接写在对话里,命令得到预期结果后再去调描述和示例,等命中率稳定了,再把它挂到编排里去做链式组合。顺序反了,你会发现在错误链路上修修补补,永远找不到真正的问题源头。
最后再分享一个小技巧:给技能起名字时,用业务口径里的高频动词加业务名词,比如order_summary、meeting_archive、risk_scan,而不是用技术名词如data_process_001。模型对中文和高频业务动词的语义对齐程度远高于抽象技术名,这一点在多技能并存时能让误召回率降低不少。
agent-skills这条路没有终点,技能目录会像业务沉淀一样越来越厚,但只要你守住了元数据规范、目录组织、权限边界这三条底线,整个体系就会越来越顺。踩过几次坑之后,你基本就掌握这套设计,剩下的都是体力和迭代。