今年年中我们内部把一批零散的自动化脚本统一改成了“agent-skills”模式,起因很简单:靠一段又长又乱的系统提示词去驱动模型做各种杂事,已经走到头了。同一个底层模型,用户问一句“帮我查一下排期”,系统要判断该不该调接口、调哪个接口、参数怎么补全、结果怎么回传,而这些判断全部糊在一段几千字的提示词里,改一处牵一发动全身,线上时不时就出现“模型自由发挥”导致的翻车。
后来我们把这类“让代理能够稳定完成某类子任务”的可复用能力单独抽出来,统一改名就叫 agent-skills。说白了,就是给代理配置一组明确的、可组合的技能,而不是让它靠临场发挥处理所有事情。这篇文章不聊理论,就讲我在实际项目里怎么拆技能、怎么挂载、怎么排坑,以及这套设计到底解决了什么问题、还能往哪走。
1. 为什么我们需要 agent-skills 这一层抽象
1.1 从“全能对话”到“能力复用”
模型本身的强项是语言理解和生成,但它的弱项也非常明显:不擅长精确计算、不擅长稳定地按固定流程操作、也不擅长“多次尝试后保证结果一致”。如果所有能力都靠提示词让模型“现场想”,同样的需求今天能做好,明天换个措辞可能就做歪了。
这就有点像带一个新员工,你让他“随机应变把客户搞定”,他大概率手足无措;但如果你给他一本操作手册,告诉他什么情况走什么流程、什么话术不能讲、什么数据必须记录,他上手就会快很多,也更少捅娄子。agent-skills 就是给模型准备的这种操作手册,把模糊的意图固化成结构化的能力单元。
我在做的模拟项目里,最开始也是一条大 prompt 管所有事情,结果每次加一个新功能,都要重新调一遍全局描述,测试量爆炸。改成技能化之后,新增能力就是新增一个技能文件,不影响其他技能,改动范围一下子小了很多。
1.2 agent-skills 与普通函数、工作流的区别
有人可能会问:这不就是写几个函数吗?和封装 API 有什么区别?
我的理解是,技能和普通函数的定位不同:
- 普通函数是“给程序员用的”,输入输出类型写清楚,调用方是代码;
- 技能是“给模型用的”,除了输入输出,还要有面向模型的语义描述,告诉它在什么场景下调用、什么情况下不要调用、参数怎么从对话里提取。
工作流也类似。工作流强调的是“顺序”:先做 A,再判断条件,然后做 B 或 C。技能强调的是“能力集合”:我手里有哪些动作可以做,至于怎么编排这些动作,由代理在主循环里决策。换个模型跑同一套技能,技能本身的描述和执行体可以复用,但决策逻辑可以换。
所以我认为 agent-skills 更像一堵承重墙:把底层的模型能力、外部接口、业务逻辑都封装在技能里,上层代理只负责“选技能、填参数、看结果”。这样即使模型升级了,技能只要描述写得稳,就不会跟着变成玄学。
1.3 谁适合用这套思路,谁不需要
先说结论:如果你的项目只是一个小工具,一次对话解决一个固定问题,那确实不需要上技能这套体系,直接提示词写清楚就行。
但如果你的项目遇到下面这些情况,就应该考虑技能化:
- 同一个功能在多个场景都要用到,比如“内容摘要”“格式转换”“信息抽取”;
- 不同模型、不同版本的 agent 之间要共享能力;
- 你需要给代理叠加权限控制、审计日志、灰度发布;
- 你希望测试集能稳定衡量某个能力做得好不好,而不是每次都看运气。
我自己判断的标准很简单:如果哪天改了一个需求,发现要动十几个地方,那说明能力边界糊了,尽早抽成技能会让你睡得更安稳。
2. 技能应该拆到什么粒度才算合适
2.1 技能的标准结构:五个要素
我习惯把每个技能看成一份“说明书 + 实现代码”的组合,核心结构是这样的:
{ "skill_name": "meeting_minutes", "version": "1.2.0", "description": "当用户提供会议录音转写或对话摘要、并要求生成会议纪要时使用。提取议题、结论、待办和负责人;缺少关键信息时明确报错,禁止编造。", "input_schema": { "type": "object", "properties": { "raw_text": { "type": "string", "description": "会议原始文本" }, "language": { "type": "string", "enum": ["zh", "en"], "default": "zh" } }, "required": ["raw_text"] }, "execution": "python函数注册在skills目录下,由运行时按skill_name路由", "output_format": "标准化JSON:议题列表、结论列表、待办列表、风险列表", "constraints": [ "不访问外部链接", "不补全未提及的时间和负责人", "单次执行超时不超过30秒" ] }这五个要素里,我最看重的是 description、input_schema 和 constraints。description 决定了模型会不会在正确的时候选中它;input_schema 决定了调用会不会因为参数错乱而崩;constraints 则是帮模型划清红线,尤其能减少幻觉输出。
2.2 技能描述的写作诀窍
描述写得好不好,直接决定“技能命中率”。
我一开始写的描述很粗糙,比如“处理会议纪要”,结果模型在用户只是简单说了一句“帮我记一下明天三点开会”的时候,也去调用这个技能,输出一堆空模板。后来我把描述改成:
当用户提供一段会议录音转写文本或一段完整的会议对话记录,并要求生成正式会议纪要时使用。如果用户只是提醒日程、设置提醒,不要调用本技能。提取议题、结论、待办事项、负责人和时间节点;如果原文没有提到的时间或负责人,一律标记为“未提及”,不要猜测。
改完之后命中率明显提升。原因很简单:模型是靠描述做意图路由的,你只在描述里写“做什么”,它就无法判断“不该做什么”。多给正例和反例,比多堆形容词有效得多。
另一个经验是:描述不要面面俱到,核心信息点到为止。如果描述里塞了一大段业务背景,模型反而被带跑偏,关注了错误的特征。保持干净、准确、有边界。
2.3 输入槽位与参数校验
技能的输入不要设计得太宽松。比如“会议纪要”技能,如果 raw_text 不传,执行体就是空转,白白消耗模型调用次数。
我一般会用 JSON Schema 做一层硬校验,进入执行体之前先把关:
{ "type": "object", "properties": { "raw_text": { "type": "string", "minLength": 20 }, "meeting_type": { "type": "string", "enum": ["周会", "评审会", "复盘会", "其他"] } }, "required": ["raw_text"], "additionalProperties": false }这样做的直接好处是:模型如果漏了参数,我们能返回结构化的错误提示,让代理针对缺什么补问用户,而不是硬着头皮生成一份没有来源的纪要。参数校验本身就是对模型幻觉的一道防火墙。
2.4 执行体代码:面向可靠而不是复杂
技能的执行体不一定非要调一个大模型。它可以是一个简单函数、一个外部接口封装,甚至是一段固定规则的处理逻辑。
我的原则是:能用规则处理的绝不让模型处理。比如提取日期、计算时长、去重合并,这些用代码做又快又准;只有需要语义理解的才交给模型。执行体里只做一件事,尽量保持无状态,输入输出都用标准 JSON,不要悄悄吞异常。技能里的临时文件、缓存数据也要有命名规则和清理策略,不然跑几天就会有一堆垃圾文件拖慢系统。
我见过最崩溃的情况是,某个技能执行体里偷偷修改了配置文件的全局状态,导致另一个技能突然不工作了。技能之间一定要隔离,宁可多传两个参数,也不要共享任何可变全局变量。
3. 把技能挂进 Agent:我能落地的三种接入方式
3.1 提示词注入式:暖启动阶段的省力方案
最直接的方式是把技能描述拼到代理的系统提示词里,让模型看到所有可用技能。
这个方式适合快速验证技能设计是否合理。缺点是提示词会越来越长,模型选错技能的概率也随之上升,而且每加一个技能都要重新过一遍完整上下文,消耗很大。
我的做法是:在项目初期用它跑通闭环,一旦技能数量超过五个,就考虑下面第二种方式。
3.2 工具调用式:让模型按函数签名选择
现在主流模型基本都支持工具调用,也就是让模型从一组函数定义里选一个,同时返回结构化参数。这种方式比提示词注入可靠,因为函数定义和参数 schema 是独立传输的,模型的选择压力更小。
我把每个技能对应成一个工具函数,函数名是技能名,函数描述沿用技能的 description,参数沿用 input_schema。代理主循环拿到模型返回的工具调用请求后,调用技能执行体,再把执行结果以一条新的消息喂回给模型。
这个方案要特别注意返回值大小。执行体返回的原始数据如果很大,比如一篇文章全文,直接塞回上下文会让后续生成质量下降。我一般在技能输出里做摘要化,只回传关键字段,详细结果落到内存或日志里供需要时取用。
3.3 统一技能注册表:中心化管理与动态加载
当项目有十几个技能之后,我把所有技能迁移到了一个注册表体系,形式上就是“技能目录 + 配置索引 + 按名称路由”。
技能目录大概长这样:
skills/ meeting_minutes/ skill.json executor.py todo_extract/ skill.json executor.py meeting_search/ skill.json executor.py加载器读取所有 skill.json,把描述建成一个技能索引,执行体统一走executor.py里的入口函数:
# runtime/loader.py import json from pathlib import Path def load_skills(skills_dir="skills"): skills = [] for skill_dir in Path(skills_dir).iterdir(): if not skill_dir.is_dir(): continue config_path = skill_dir / "skill.json" exec_path = skill_dir / "executor.py" if config_path.exists() and exec_path.exists(): config = json.loads(config_path.read_text()) config["exec_path"] = exec_path skills.append(config) return skills注册表方式带来的好处是:技能可以通过外部配置决定启用或下线,也可以直接在运行时热加载。权限、超时、重试都可以在这一层统一做,而不是散落在每个技能里。
三种方式的选型,我按项目阶段做了一个对比:
| 接入方式 | 适用阶段 | 优点 | 主要缺点 |
|---|---|---|---|
| 提示词注入 | 原型验证、技能数量少于5个 | 改动最快、无需额外机制 | 上下文膨胀、选择准确率下降 |
| 工具调用 | 正式项目、技能数量5-20个 | 结构化参数、命中率较高 | 依赖模型对工具调用的支持 |
| 统一注册表 | 项目放量、多环境部署 | 可编排、可审计、可灰度 | 需要开发加载器和配套机制 |
4. 实操现场:一个会议助理代理的技能库
4.1 场景设定与技能清单
为了把上面的思路落成看得见摸得着的东西,我拿一个虚构的“会议助理代理”举例。它要处理四种高频需求:
| 技能名 | 用途 | 输入 | 输出 |
|---|---|---|---|
| generate_minutes | 生成会议纪要 | 原始转写文本、语言 | 结构化纪要 |
| extract_todos | 从讨论中拆解待办 | 对话文本、负责人名单 | 待办列表 |
| search_history | 检索历史会议 | 关键词、日期范围 | 相关片段 |
| check_conflicts | 检查日程冲突 | 事件列表、目标时间段 | 冲突提醒 |
这四个技能覆盖了会议场景最常见的动作,每个技能只负责一个意图。任何需要组合的场景,比如“生成纪要把待办发出来”,由代理先调 generate_minutes,再调 extract_todos,而不是在一个技能里塞一堆分支。
4.2 一个技能的具体实现
以generate_minutes为例。它的执行体不是纯代码,而是“用代码调用模型完成语义抽取”的混合型技能:
# skills/generate_minutes/executor.py import json def build_model_prompt(raw_text: str) -> str: return f""" 请根据下面的会议记录生成结构化纪要。 要求: 1. 按议题、结论、待办、风险四类输出。 2. 没有提到的内容标记为“未提及”,不要推测。 3. 待办必须有负责人,如果原文没有负责人则标记“待确认”。 会议记录: {raw_text} """ def execute(params: dict, llm_func) -> dict: raw_text = params["raw_text"] if len(raw_text) < 20: raise ValueError("会议记录太短,无法生成有意义的纪要") llm_output = llm_func(build_model_prompt(raw_text)) try: parsed = json.loads(llm_output) except json.JSONDecodeError: return {"status": "error", "message": "模型输出不是合法JSON"} required_keys = ["议题", "结论", "待办", "风险"] for key in required_keys: if key not in parsed: parsed[key] = [] return {"status": "ok", "data": parsed}这里llm_func是一个注入的模型调用函数,好处是执行体保持纯逻辑,便于测试。测试时我可以传一个假模型函数,返回固定文本,验证解析逻辑是否健壮。
4.3 意图路由与参数补全
代理的主循环负责三件事:识别用户意图、从对话里抽参数、调用技能后把结果加工成自然语言回复。
路由逻辑在系统里长这样:
# runtime/agent_loop.py def route(intent, params, skills): for skill in skills: if skill["skill_name"] == intent: executor = load_executor(skill["exec_path"]) result = executor.execute(params, llm_func=call_model) return result raise SkillNotFound(intent)实际项目里,intent 通常不是硬编码的,而是让模型从用户消息和技能描述列表里选。我会在模型选择后加一道置信度判断:如果模型选中的技能对应分数太低,就先让代理多问一句“你是想生成纪要,还是只需要整理待办”,而不是硬跑技能。
参数补全也建议在路由层统一做。比如search_history缺少start_date时,不要静默地用默认值,而是先返回“缺参数”状态,让上层决定是自动填充还是回问用户。
4.4 一次完整调用的链路示例
一个用户说:“把上午周会的纪要生成一下,待办单独列出来。”
链路是:
- 代理读取技能列表,模型选中
generate_minutes,参数为{"raw_text": "上午的转写文本..."}; - 技能执行体调用模型,产出结构化纪要;
- 代理看到用户还要“待办单独列出”,继续调用
extract_todos,输入是纪要中的待办字段; - extract_todos 返回待办列表,代理以列表形式输出纪要,并把待办单独呈现在回复里。
这链路看起来简单,但每一步都可能翻车。最典型的坑在第 3 步:模型生成了纪要之后,待办拆解没有用纪要的结构化结果,而是又去看了一整段原始转写,导致重复抽取、信息丢失。所以我会显式地在上下文里声明“extract_todos 的输入是 generate_minutes 的输出字段,不是原始记录”,让模型不要跑偏。
5. 踩坑实录:这些坑你大概率也会遇到
5.1 模型选中了错误技能
我遇到过最普遍的问题就是选错技能。比如用户说“帮我看看这周哪天有空开会”,模型不去调日程查询,反而去调generate_minutes。
排查之后发现,问题出在两个技能描述太像,都提到了“会议”“时间”“安排”这些词。后来我把描述改成带强触发词和强排除词的结构,比如日程查询的描述开头写“仅当用户需要查询日历空闲时段时使用”,会议纪要的描述里加一句“不处理日程安排类请求”,冲突率立刻降了很多。
5.2 参数校验失败导致链式崩溃
有时候用户只说了“整理一下”,参数严重缺失,但代理为了讨好用户,硬是带着空参数调用技能,然后执行体抛异常,整个会话卡死。
我的解法是两层:第一层,参数校验错误时返回标准错误结构,不抛未处理异常;第二层,在代理指令里明确要求“缺参数先问,不要在缺少必需信息时调用技能”。这两层加完,链式崩溃的情况基本消失。
5.3 上下文污染与返回结果过度膨胀
技能执行完,最怕把整个中间结果塞回对话上下文。比如一个检索技能命中了十篇文章,回去之后模型很容易张冠李戴,把 A 文章的观点安到 B 文章上。
我现在统一做“结果降噪”:技能返回给代理的是小摘要 + 必要字段,原始全文写进日志和临时存储区;只有当代理判定用户需要查看具体内容时,才通过另一个技能去读取全文。这个设计也让 token 消耗降了差不多三分之一。
5.4 技能漂移与版本升级
技能不是写完就完事。业务变了、底层模型变了,技能描述可能就过时了。最典型的是,某次业务调整后,技能描述里说的“负责人字段”已经从文本改成了 ID,但旧技能还在让模型从文本里猜,猜得乱七八糟。
后来我学乖了,每个 skill.json 里强制带 version、updated_at 和 changelog。升级技能时,先跑一遍离线回归集,确认旧用例不会被新描述破坏,再上线。否则就会遇到“今天能用,昨天不能”的玄学问题。
5.5 权限边界与副作用
技能调用外部 API 时,如果没有任何权限控制,模型又恰好被误导,可能会执行一些危险动作。比如一个“发送邮件”的技能,如果描述里没有明确约束“只有确认收件人后才能发送”,模型可能边生成边发送,后果很吓人。
我在外围加了一道统一的权限层:每个技能配置需要的权限标签,代理运行时检查用户身份是否有该权限。同时所有涉及外部副作用的技能都加“dry_run”模式,默认先试跑一次,不真正落库,用户确认后才执行。这也是我强烈建议任何生产环境都保留的一层保险。
常见问题速查表:
| 现象 | 可能原因 | 排查方法 | 解决建议 |
|---|---|---|---|
| 模型选错技能 | 技能描述边界不清 | 打印模型实际选择的技能与置信度 | 补强触发词、排除词 |
| 参数缺失报错 | 校验层缺失 | 检查是否走了参数校验 | 进入执行体前强制 JSON Schema 校验 |
| 生成内容张冠李戴 | 上下文混入过多原文 | 查看回传上下文 token 量 | 只回传摘要和关键字段 |
| 技能时好时坏 | 旧版本技能未下线 | 核对版本号与上线时间 | 做回归集测试,多版本灰度 |
| 误执行外部操作 | 缺少权限与确认机制 | 审计日志查调用链 | 加 dry_run 模式和权限标签 |
6. 这是一条长期路线:维护、评测、扩展
6.1 给每个技能配一份“成绩单”
我把技能当团队成员来管,每个技能都有一份离线评测记录。评测集是针对痛点场景硬造的,比如会议纪要技能,我会准备十份不同风格的转写文本,里面故意混入无负责人、多议题、口语化表达等情况。
每次技能描述或执行体更新后,我都会在本机跑一遍评测集,对比输出字段的完整率和准确率。这个习惯帮我挡下了至少两次上线事故,一个是描述改完后的选择准确率下降,另一个是解析逻辑漏掉了空数组。
评测不一定要复杂,关键是固定、可重复。如果今天跑了明天忘了,等于没测。
6.2 技能组合与动态注册
技能不是越细越好,也不是永远只能单兵作战。当技能数量多起来之后,我开始尝试把常用链路变成“组合技能”。比如“会议结束后的系列动作”可以组合三个原子技能:生成纪要、拆待办、写入跟踪清单。
组合技能本身不重复实现逻辑,只是编排调用顺序。这样底层原子技能可以持续优化,上层业务流程也可以灵活调整。
动态注册的想象空间更大:如果每个技能都遵循同样的描述和执行体规范,那技能库就可以像插件市场一样扩展。让代理定期从一个内部技能仓库拉取新技能配置,新能力无需改主程序代码就能生效。我目前正在往这个方向完善,核心挑战是技能的沙箱隔离和权限审计,还没完全解决,但这套思路确实是技能体系走向工程化的必经路。
6.3 我个人的一点体会
如果真的让我总结一条最值得分享的经验,那就是:先别急着写执行体,先把技能描述写好、写窄、写到有正反例。描述就相当于技能的接口文档,接口文档混沌,后面的路由、参数、回归全是空中楼阁。
还有一点是,万物皆可技能,但没必要万物皆技能。一个一次性的小需求直接塞进现有技能里就行,硬拆只会多出一堆没人维护的僵尸技能。宁可少而精,也不要做多而全的技能仓库。
如果你手头的代理项目已经堆了一大段“万金油”提示词,我建议你挑一个最常出错的场景,先抽成一个技能试跑一周。大概率你会回来把剩下的也拆了。