最近两三个月,我把手头一个智能体项目的架构重写了一遍,核心动作就一件事:把散落在各种 Prompt 里的能力描述,统一收编为一套叫agent-skills的模块化技能体系。说实话,动手之前我低估了这个改造的收益——不光代码结构清爽了很多,模型在复杂任务上的稳定性和可维护性都明显上了一个台阶,连新同学上手排查问题的速度都快了不少。
这篇文章就把这次重构的完整思路、技能包设计规范、匹配与执行链路、一段可以直接参考的落地代码,以及我踩过的几个坑一次性讲透。适合那种“工具函数越加越多、Prompt 越改越乱、多步任务经常翻车”的 Agent 项目团队参考,也适合刚接触 Agent 开发、想给自己的机器人构建一个清晰能力框架的同学。
1. 从 Prompt 堆砌到技能模块化:这次重构是被“翻车”逼出来的
1.1 一个线上 Agent 是怎么被几十个工具函数埋掉的
先说背景。我维护的智能体项目早期其实不算复杂,核心是一个大号 Prompt,里面塞了角色设定、对话策略、业务流程,外加一堆 Function Calling 的函数描述。刚上线那阵子体验还行,但随着业务方不断提需求,函数从最早的五六个增加到了三十多个,Prompt 单文件也膨胀到了快 5000 字。
这个阶段问题开始集中爆发。最典型的是漏调用工具:用户问“把昨天的销售数据给我”,模型有时只返回了一段文本,压根没调取数工具。更麻烦的是调错工具,比如有两个工具,一个叫generate_report,一个叫export_report,光看名字都能猜到它们职责有重叠,模型经常把本来该走导出流程的请求发到生成流程里,产出一份格式完全不对的临时文件。
有一次线上事故直接导致我下决心重构。用户要求“把上周各区域的销售汇总发到群里”,Agent 调了数据查询工具,但参数里漏掉了“按区域分组”这个字段——因为这个参数的说明散落在 Prompt 中间一大段文字里,模型根本没读到。当时排查这类问题特别痛苦,先翻 Log 看模型实际传参,再回去翻那一大坨 Prompt 找哪里写漏了,一个晚上就搭进去了。真正的问题是:接口越多,靠“一段自然语言描述”去约束模型准确调用的难度越大,Prompt 写的越长,注意力越容易被稀释。
1.2 技能和 Function Calling、Prompt 模板到底有什么区别
很多人第一次听到 Agent Skills,第一反应是“这不就是包装一下函数描述吗”。我重构前也是这么想的,等真正做完才发现,它跟传统的 Function Calling 和 Prompt 模板有本质区别。
Function Calling 解决的是“如何把函数签名呈现给模型”的问题,它停留在接口层。你把函数名、参数类型、一句话描述传给模型,模型决定调不调用。但它不负责这个函数背后的实现如何组织、依赖怎么管理、边界条件是什么、谁来测试这个能力。
Prompt 模板则更原始,本质是字符串拼接。你把不同场景的指令文本拼在一起塞给模型,它适合做内容生成类任务,但完全不擅长管理工具的复杂度。
Agent Skills 是一种自治单元。一个技能包不只包含“模型看到的描述文本”,还包含实现该功能的代码、依赖声明、参数结构、触发条件、使用示例,甚至测试用例。模型看到的只是这个技能包的“门面”,但整个技能的生命周期是可以独立维护、独立版本化、独立部署的。
打个比方,Function Calling 相当于递给模型一把手术刀,而 Agent Skills 是整套手术箱,里面除了刀还有使用说明书、消毒记录、适用患者范围、以及一把备用刀。模型只需要知道该开哪个箱,剩下的全部由箱子的机制来保证。
1.3 什么规模的项目才值得上 Skills,别过早设计
我也见过反面案例。有朋友看了这套思路特别兴奋,手里就三个工具,硬是要搞一套技能注册中心,结果光搭框架花了两周,收益几乎为零。Skill 是有成本的,主要体现在设计、检索、执行器三层都需要额外代码,所以判断什么时候该上很重要。
我根据自己的实际经验整理了一个判断清单:
| 信号 | 说明 |
|---|---|
| 工具数量超过 10 个 | 描述信息开始互相重叠,模型漏调率明显上升 |
| 单个 Prompt 超过 3000 字 | 中间参数说明容易被模型漏读,改一处常引发其他行为变化 |
| 同一能力被多个场景复用 | 比如“数据查询”在客服、报表、分析三个入口都要用,需要统一维护 |
| 团队需要并行开发 | 多人改同一个 Prompt 的冲突概率极高,技能包能隔离出独立开发边界 |
| 能力需要给非开发人员理解 | 技能描述本身也是一份可读的“能力白皮书” |
如果你的项目还处在三五个工具的小工具链阶段,老老实实用函数调用就够了,没必要为 Skill 付出额外成本。如果上面列出的信号你命中了两条以上,那我认为值得认真考虑一次向技能模块化的迁移。
2. Agent Skills 的技能包结构与元信息设计
2.1 一份最小技能包的目录结构长什么样
技能包本质上是一个自包含的目录。我一开始给每个技能单独开一个 Git 仓库,后来发现这种方式太重了——跨技能改公共逻辑时要同时提很多 MR,非常费劲。最终采用的方案是:一个skills/大仓库统一管理,每个技能一个子目录,每个子目录内部保持完全自治。
以我这个项目早期的“会议纪要”技能为例,目录结构是这样组织的:
skills/ └── meeting_minutes/ ├── SKILL.md ├── requirements.txt ├── scripts/ │ ├── extract_todos.py │ └── render_minutes.py ├── assets/ │ └── templates/ │ └── minutes_template.md ├── examples/ │ ├── input_sample.txt │ └── output_sample.md └── tests/ └── test_skill.py每个目录的职责很明确:
SKILL.md是技能包的“门面”,负责向 Agent 主系统描述这个技能是干什么的、什么条件下触发、有哪些参数、什么情况下不要调用。scripts/放真正的执行代码,每个脚本只做一件小事,便于测试和复用。assets/放模板、静态资源这类非代码文件,比如会议纪要的 Markdown 模板。examples/放一组输入输出示例,这部分的意义不仅是给开发者看,更关键的是它可以作为模型少样本提示的参考素材,也可以作为回归测试的基线。tests/放技能自己的测试用例。我在后面会专门讲为什么这一项容易被忽略却极其重要。
这个目录标准不是凭空定的,我参考了社区里几种常见的 Agent Skills 格式,也结合了自己的使用习惯。核心原则就一条:任何人拿到这个目录,不需要额外的口头说明,只看SKILL.md和examples/就能理解这个技能怎么用、什么时候用、用不了的边界在哪里。
2.2 SKILL.md 里哪些字段最值得认真写
SKILL.md是技能包的灵魂,也是最容易写废的地方。很多人写技能描述跟写产品介绍一样,形容词堆了一大堆,关键时刻那个字段没写。我在反复调整后,用 YAML Front Matter 加上正文的结构来组织,下面是一个实际可以跑起来的示例:
--- name: meeting_minutes_extractor id: skill_meeting_minutes_001 version: 1.2.0 description: > 从会议转录文本或已有会议记录中,提取议程、决定事项、待办任务, 并生成结构化的 Markdown 会议纪要。适用于“整理会议内容”“提取待办事项” “生成会议纪要”等场景。不适用于:对历史会议做效果复盘、 统计报表类查询。 parameters: transcript: type: string required: true description: 会议原始文本,可以是逐字稿、速记,或已有纪要正文 output_format: type: string enum: [md, json] default: md description: 输出格式,md 为 Markdown 文档,json 为结构化数据 timezone: type: string default: Asia/Shanghai description: 生成待办到期时间所使用的时区 output: content: 生成的会议纪要 Markdown 或 JSON todos_file: 待办清单文件的保存路径 dependencies: python: ">=3.10" packages: [pandas] ---字段看起来不少,但真正决定一个技能能不能被模型正确调用的,是description和parameters这两个。
description起着“标签”的作用。模型在面对用户请求时,不可能把每个技能的完整代码都读一遍,它通常只会拿到所有技能的description列表,然后基于这些简短的描述做路由决策。所以描述要写得像“搜索引擎里的索引页”,而不是“产品说明书”。我在这块的写法总结成一个三层结构:
- 第一层:这个技能能做什么,用动词开头,比如“提取”“生成”“转换”“查询”。
- 第二层:适用于哪些具体场景,给出用户可能说的话作为线索。
- 第三层,也是很多人会漏的:不适用于哪些场景,用否定句明确划出边界。
parameters部分则要尽量用enum、type约束模型能传的值。模型不像程序会严格校验类型,它本质是概率生成,所以不约束就一定会出现传错格式的情况。把output_format限制成md或json,模型在推理时会更容易从候选值里选一个,而不是自由发挥出什么text、markdown之类的变体。
2.3 用“反例声明”给技能划出清晰的边界
我在第 1.1 节提到的那个“数据查询”事故,根子其实就在描述文件里。当时query_sales这个技能的描述写的是“查询销售数据”,听起来好像所有跟销售数字相关的问题它都能接。结果用户问“上周销售为什么下滑”,模型也调了这个技能,技能只给了原始数据,根本没有归因分析能力,回答自然是一堆数字堆砌,用户体验极差。
后来我引入了一个习惯:每个技能的description必须写“不适用场景”,并配一两条反例。改完之后的效果是立竿见影的,下面这段对比可以直观说明:
# 改之前 description: 查询销售数据,支持按天、按周、按月查看,支持按地区筛选。 # 改之后 description: 返回销售指标明细数据,支持时间维度和地区维度筛选。 适用于“查询销售额”“对比不同区域数据”“导出明细表”。 不适用于:需要解释数据波动原因、生成分析结论的任务,请使用 analysis_sales_skill;需要修改或删除历史数据,请走人工审批流程。一开始我还担心写这么多“不适用”会限制技能的发挥空间,实测下来恰恰相反。模型在候选技能数量变多之后,最缺的恰恰是“排除法”的依据。有了明确的反例声明,它不会再把分析类请求错误地路由到查询类技能上,误调率下降得很明显。
3. 技能加载、匹配与运行时全链路
3.1 描述索引层:别把技能全文塞进上下文
这是我重构中最关键的一个决策:技能描述不能全量注入。刚开始我把十来个技能的SKILL.md全文拼到系统 Prompt 里,模型上下文很快被吃掉一大块,响应变慢,Token 成本也肉眼可见地涨。真正的问题不是 Token 贵,而是上下文被无关信息污染之后,模型的注意力会被稀释,反而更容易选错技能。
现在的做法是给技能建一个“轻量索引”。注册中心只向主系统暴露每个技能的浓缩信息,一般就三项:name、description、关键tags。把这些浓缩信息合并成一个技能总表,全部加上也就两三千 Token,可以安全地放进系统 Prompt。等到模型确认要用某一个技能时,才动态加载对应SKILL.md的完整内容。
下面这段代码描述了注册中心怎么构建索引:
from pathlib import Path import yaml class SkillRegistry: def __init__(self): self._skills = {} def load_from_dir(self, skills_root: str): for skill_dir in Path(skills_root).iterdir(): skill_file = skill_dir / "SKILL.md" if not skill_file.exists(): continue meta = self._load_front_matter(skill_file) self._skills[meta["name"]] = { "dir": skill_dir, "meta": meta, } return self def build_index(self) -> str: """生成轻量技能总表,注入系统提示词""" lines = [] for name, skill in self._skills.items(): m = skill["meta"] lines.append( f"- {name}: {m['description'][:200]}" ) return "\n".join(lines) def load_detail(self, name: str) -> str: skill_file = self._skills[name]["dir"] / "SKILL.md" return skill_file.read_text()我这里有个经验:索引里的description只用前 200 个字符。因为轮询读完整描述的开销很大,而真正影响路由决策的核心往往就是前几句话。后续如果模型需要详细参数说明,走load_detail单独加载。
3.2 路由匹配:LLM 决策、向量召回和规则路由如何配合
技能匹配是我踩坑最多的部分,先后试过三种主流路由方式,它们的特性各不相同:
| 路由方式 | 实现成本 | 适用场景 | 典型问题 |
|---|---|---|---|
| 规则路由 | 低 | 关键词明确、触发条件可以被枚举 | 用户表达灵活时规则写不完,兜底困难 |
| 向量召回 | 中 | 数量大、语义模糊、需要快速缩小候选集 | 对 token 化质量敏感,容易召回语义相似但实际不同的技能 |
| LLM 决策 | 高 | 场景复杂、需要综合上下文判断 | 候选太多时会犹豫、选错,Token 消耗大 |
我现在跑线上使用的是一种混合策略:先用向量召回做粗筛,把几十个技能缩小到 Top 5 左右,再让模型在候选中做精排。这一步能同时兼顾召回的广度和决策的智能度。
流程上的核心代码大致长这样:
def agent_handle(user_request: str, registry: SkillRegistry, llm): # 第一步:向量召回粗筛 candidates = embed_retriever.retrieve( user_request, top_k=5 ) # 第二步:把候选技能的完整描述注入决策上下文 context = build_base_context(user_request) for name in candidates: detail = registry.load_detail(name) context.compose(skill_detail(name, detail)) # 第三步:让 LLM 在候选集里做最终决策,不再暴露全部技能 plan = llm.decide(context, available=candidates) for action in plan: if action.type == "skill_call": skill = registry.get(action.skill_id) result = skill.run(action.params)这里有一个细节值得展开:向量召回阶段我用的是name + description + tags拼接后的文本做 embedding,而不用完整SKILL.md。原因也很简单,完整文档里的实现细节、依赖信息对语义检索没有帮助,反而会引入噪声。把“门面信息”和“内部信息”分开,检索效果会稳定不少。
3.3 统一执行器:技能代码不能“裸奔”进系统
技能包本质上是可执行代码,如果不做任何约束直接跑,会带来超时失控、权限过大、异常难追踪等一系列问题。我给所有技能设计了一个统一的SkillExecutor,任何技能都必须通过这个执行器运行,不允许直接subprocess.run。
执行器管四件事:参数校验、超时控制、命令白名单、结果格式化。
import subprocess import json class SkillExecutor: def __init__(self, allowlist=None, timeout=30): self.allowlist = allowlist or [] self.timeout = timeout def run(self, skill, params: dict): # 1. 参数 Schema 校验 self._validate_params(skill, params) # 2. 构造执行命令 script_path = skill.exec_script(params) command = [script_path] + self._serialize_params(params) # 3. 白名单判断 if script_path not in self.allowlist: raise PermissionError(f"技能执行路径未在白名单: {script_path}") try: proc = subprocess.run( command, capture_output=True, text=True, timeout=self.timeout, ) except subprocess.TimeoutExpired: return {"status": "error", "error": f"技能执行超过 {self.timeout}s"} # 4. 结果规范化 stdout = proc.stdout.strip() try: data = json.loads(stdout) except json.JSONDecodeError: data = {"raw": stdout} return { "status": "success" if proc.returncode == 0 else "error", "data": data, }实际运行中,超时这一项帮我避掉过很多次事故。有的技能在输入数据量很大时会进入长时间计算,如果没有超时控制,整个 Agent 工作流会被卡死。我这边统一设了 30 秒,个别重计算类的技能单独放宽到 120 秒,但都必须显式配置,不能默默容忍无界执行。
4. 实战:构建一个可接入主流程的会议纪要技能包
4.1 为什么选“会议纪要与待办提取”作为示例场景
单独讲概念容易飘,我拿一个完整实现的技能包拆给大家看。
我选这个场景是因为它在一个技能包里同时覆盖了四类能力:文档类文本输入、LLM 结构化抽取、格式化输出、文件生成。这四类能力几乎覆盖了日常 Agent 技能开发的全部链路,你理解透这一个技能,其他技能基本就是替换输入输出格式的事。
需求拆解下来是这样:
- 输入:一段会议的转录文本,或者是已有的会议记录正文
- 处理:提取出三块内容——议程、决定事项、待办任务;每个待办任务需要有执行人、截止时间、备注
- 输出:一份 Markdown 格式的会议纪要文件,同时生成一个 JSON 格式的待办清单供后续自动化流程使用
- 边界:如果用户输入的内容不是会议材料,技能应该直接报错,而不是强行生成一份假纪要
4.2 SKILL.md 与核心脚本的落地实现
SKILL.md就按第 2.2 节的元数据标准来写。核心脚本scripts/extract_todos.py做的事情是调用 LLM 从原始文本中抽取结构化信息,并转换成 JSON。为了防止模型在宽松提示下输出五花八门的字段,这个脚本会把输出格式硬编码成固定结构,不允许模型自由发挥。
# scripts/extract_todos.py import json import sys RAW_TEXT = sys.argv[1] SYSTEM_PROMPT = """ 你是会议纪要分析器。只允许输出 JSON,不要输出任何其他文字。 JSON 结构必须严格符合: { "agenda": ["议题1", "议题2"], "decisions": ["决定1"], "todos": [ {"owner": "执行人", "task": "任务描述", "due": "YYYY-MM-DD", "note": ""} ] } 如果原文中没有足够信息推断某个字段,用空数组或空字符串,不要编造。 """.strip() def call_llm(text: str) -> dict: # 这里是实际对模型 API 的调用封装 resp = llm_complete(SYSTEM_PROMPT, text) return resp if __name__ == "__main__": result = call_llm(RAW_TEXT) print(json.dumps(result, ensure_ascii=False))这个脚本本身不负责“生成汇报文案”,它只负责把非结构化文本转成结构化 JSON。下游的render_minutes.py再负责把 JSON 套进 Markdown 模板,职责分离,以后想换输出模板只需要动最后一个脚本。
4.3 接入主 Agent 的完整流程代码
技能包写完之后,把它接入主 Agent 的流程比我想象中简单。关键在于注册中心和执行器要提前搭好,接入新技能只是新建目录、写SKILL.md、把脚本丢进去三步:
from skill_registry import SkillRegistry from skill_executor import SkillExecutor registry = SkillRegistry().load_from_dir("./skills") executor = SkillExecutor( allowlist=["./skills/meeting_minutes/scripts/extract_todos.py"], timeout=60, ) # 构建轻量索引并注入模型 skill_index = registry.build_index() def on_user_request(user_input: str): # 向量召回粗筛,这里省略了 embedding 部分 candidates = ["meeting_minutes_extractor"] # 加载候选技能详情 context = f""" 你可以使用的技能列表如下,请根据用户需求选择并调用: {skill_index} 用户请求:{user_input} """ # 模型决策(实际项目会走结构化输出) chosen_skill = "meeting_minutes_extractor" params = {"transcript": user_input} skill = registry.get(chosen_skill) result = executor.run(skill, params) if result["status"] == "success": render_minutes(result["data"])你可能会问:这一步里“模型决策”看起来很潦草,实际项目应该怎么让它自动填参数?我这边用的是让模型输出结构化 JSON 的方式来解这个问题的,即{"skill": "xxx", "params": {...}}。这一步做完后,整个技能调用链路就闭环了。
5. 技能系统真正跑起来之后,我踩过的几个大坑
5.1 排查实例:技能描述“太用力”,模型反而找不到正确的技能
这个坑我印象很深。某次上线了一个新的“周报生成”技能,结果连续几天发现它抢了另一个“数据回顾”技能的活。用户说“帮我写这周的工作总结”,模型直接调了周报生成,这本身没错;但用户说“这周我的数据涨了多少”,它也去调周报生成,产出就是一段模板话术,数据一个都没有。
完整的排查链路是这样走的:
- 第一步,先看模型决策日志,发现所有包含“周”字的请求都被路由到了
weekly_report_skill。 - 第二步,拉出这个技能的
description,发现我写的描述是“适用于周报、总结、每周进展、周数据回顾等各种场景”。 - 第三步,问题定位清楚了:描述里的“周数据回顾”这种用例其实对应的是数据查询能力,不是周报生成能力。我把两个维度的语义混进了同一个技能描述里,向量召回自然会把所有和周相关的请求都拉过来。
- 第四步,修改
description,把“周数据回顾”显式剔除,加反例“不适用于具体数据指标查询”,同时把数据回顾类请求重新映射到data_query_skill。 - 第五步,补了一条测试用例防回归:输入“这周各渠道的转化率是多少”,期望选择
data_query_skill。
这次排查给我最大的启发是:技能描述不是越全越好,它要有非常清晰的“召回边界”。一个技能想覆盖的场景越广,它在向量空间里的位置就越模糊,和别的技能的区分度就越低。所以我现在每写一个技能描述,都会问自己一个问题:“用户说哪类话,绝对不能选中它?”答案要写进描述里。
5.2 Token 失控:从 8k 回到 3k 的上下文瘦身
技能数量过了 20 个之后,另一个问题浮上水面:上下文 Token 每天在涨。我把所有SKILL.md全文都拼进系统 Prompt 时,整个上下文直接到 8k,其中 5k 都是技能说明。这不仅费钱,还让模型响应明显变慢。
我把方案改成第 3.1 节说的轻量索引,看了一下实际效果:
| 阶段 | 系统提示词 Token 数 | 平均响应时间 |
|---|---|---|
| 全量注入 20 个技能详情 | 约 8000 | 约 4.2 秒 |
| 轻量索引 + 按需加载视频 | 约 3200 | 约 2.1 秒 |
瘦身过程中有一个容易忽略的点:当候选技能变少之后,技能详情加载可能要补进“当前轮请求”的上下文里,而不是塞进系统级上下文中。这样做的好处是,用户发起一个新请求时,上一次请求加载的技能详情不会残留,避免了上下文污染。
5.3 状态不同步:技能间通过文件交换数据为什么不可靠
技能拆细之后,第一个真实业务流程是“先抽摘要,再生成待办,最后发送通知”。最开始我的设计是前一个技能把中间结果写到一个临时文件,后一个技能去读这个文件。本地单机跑得好好的,一旦并发量上来就出问题,日志里各种“文件不存在”报错,甚至出现一个用户读到了另一个用户中间文件的情况。
排查后定位到根因:技能执行器是独立进程,工作目录不共享;并发执行时临时文件命名还会冲突。后来我彻底弃用了“文件交换数据”的模式,改成在技能调用链里显式传递结构化数据——前一个技能的输出 JSON 直接作为后一个技能的输入参数。只有最终需要落地磁盘的结果才通过assets/目录写出,并且文件名带执行 ID。
这个经验也是我后来在技能参数设计时特别重视output定义的原因:每个技能都要明确自己的输出结构,技能之间通过结构化的数据契约对接,而不是依赖文件系统这种隐式状态。
5.4 安全边界:允许技能执行命令,但绝不能什么都允许
最后聊一个非常容易被忽略的问题:安全。
在我的架构里,技能是可以执行任意脚本、读取文件、调用系统命令的,这让整个 Agent 的能力上限变得很高,但也意味着任何一个技能开发者失误,都可能成为系统的一个漏洞入口。“白名单机制”是我最终采用的最小方案:
- 技能脚本路径必须显式注册,不在名单里的路径一律拒绝执行。
- 所有技能内部对外的请求(比如 HTTP 调用、数据库查询),必须走统一封装的网关卡,不允许直接用
requests裸连。 - 技能不能无限制写文件,只能写到自己的工作目录。
- 超时是硬指标,任何技能都不能启动无界运行。
我在实际使用中甚至遇到过一种情况:某个技能为了测试方便,在脚本里写死了rm -rf清理逻辑,差点在正式环境删掉一个公共目录。虽然那次被拦截了,但也说明了一个道理——技能包的好用和风险是并存的,权限设计必须在一开始就考虑进去。
好用的技能系统应该像好的乐高积木:每块积木自己是什么形状,能搭出什么结构,写得清清楚楚;但它不能是一个到处乱跑的积木块,必须有拼插规则和安全锁扣。
我个人的体会是,Agent 项目的复杂度增长是必然的,逃避不了,agent-skills这套体系并不是银弹,它只是把复杂度从“模型面前的一大坨 Prompt”转移到了“工程侧的多模块协作”上。想让它真的发挥作用,关键还是耐心维护技能描述的质量和边界,做好回归用例。
最后再分享一个我受益很大的小习惯:每个技能在tests/里配两条回归用例,一条正向用例,一条反向用例。正向用例验证“该触发时必须触发”,反向用例验证“不该触发时必须不触发”。技能少的时候看不出价值,等技能多到几十个,几次大改之后,你就会发现这两条用例能帮你省下大量的回归排查时间。