直接进入主题。最近社区里到处都在聊 agent-skills 这套玩法,我自己也在模拟项目X里试了一圈,感慨挺多的。很多人以为这是某个新框架的功能,其实它更像一套思路:与其让 Agent 在那个巨大的上下文里临场发挥,不如把能力拆成一个一个独立、可插拔、带说明书和参数的“技能包”。项目标题“agent-skills”所代表的,就是这种以技能为基本单位来组织智能体能力的模式。这篇博文我就把从零搭建、核心实现到调参避坑的完整过程写出来,给想入坑或者已经卡在路上的朋友做个参考。
先说它适合谁。如果你已经跑通过一个能对话的 Agent Demo,但总觉得它“会聊天、不会干活”;如果你手里有大模型 API,却不知道怎么让模型稳定调用某些固定流程——那这套技能化改造正好对症。我也试过直接往 System Prompt 里塞指令清单,本质上也能触发工具调用,但随着指令变多,模型开始混淆相似功能,参数传错、调用错技能的情况非常频繁。把技能拆出来之后,每条能力都有独立描述、独立参数校验、独立退出条件,模型只需要做一件事:按说明书决定调用谁。
1. 先搞明白:agent-skills 到底解决了什么问题
很多第一次接触这个思路的人会问:这跟普通的 function calling 有什么区别?说实话,底层机制没有本质区别,模型都是输出结构化调用指令,再由宿主代码执行。真正的差异在于组织方式和抽象粒度。
1.1 从“什么都能答”到“什么都能做”
先讲一个直观对比。传统 Agent 项目里的工具函数,往往是一堆散落的 Python 函数,比如def send_email(user_id, content),然后在 system prompt 里写一句“你可以发邮件”。刚开始没问题,但等工具超过十个,你会遇到几个很典型的问题:
- 模型记不住所有工具的准确签名,特别是参数默认值、可选字段这些细节。
- 工具之间的边界模糊,比如“查天气”和“查空气质量”,描述差不多,模型常常选错。
- 给模型看的“工具说明”和真正执行代码之间缺少统一校验层,出错时很难定位是模型的问题、参数的问题还是内部逻辑的问题。
agent-skills 的解法很直接:把每个能力定义成一个目录,目录里放一份标准说明文档(通常叫 SKILL.md),外加几个脚本文件。Skill 的名字、描述、参数 Schema、执行入口、依赖文件全都被约束在固定的目录结构里。于是决定调用哪个技能的,不再是模型在作业堆里翻答案,而是它读清楚每个技能自己的说明书,再给出结构化参数。
我当时做了一件很幼稚但很有用的事:把所有技能说明全部打印出来,自己充当模型,模拟“如果我要让系统帮我查三小时后某地天气”应该怎么选。结果发现如果按普通工具的扁平描述,很容易同时选中“天气查询”和“历史天气查询”两个技能。但在技能化的结构里,我给每个技能加了明确的触发场景与禁止场景字段,这种歧义就消失了。这不是模型的进步,而是工程上把问题想清楚了。
1.2 “技能化”思维:把能力做成一级公民
技能化这个思路,有点像你家里装修时,把所有电器说明书各自装订成册,而不是全部印在一张大挂图上。每本说明书里只讲这一台机器怎么用、有什么限制、注意事项是什么。对 Agent 来说,这份说明书就是 SKILL.md,它决定了模型何时、如何、以什么参数去调用这个技能。
我在项目里维护了一整套技能库,每个技能都遵循同样的模板:
name: 技能短名,英文加下划线,比如 weather_now。description: 两三句话讲清楚它做什么,什么时候用。when_to_use: 明确列出适用场景,甚至可以包含典型请求句式。when_not_to_use: 明确列出不适用场景,比如“用户问历史天气时不要用这个”。parameters: 定义入参,类型、必填性、默认值、取值范围。returns: 定义出参结构,让调用方知道拿到的是什么。
有人可能觉得 description 写那么长,模型也不一定看。这里我可以给出一个经验数据:结构化说明比自然段落的效果好很多。我试过在 description 里写“这是一个用于查询天气的工具”,模型偶尔会对不该查天气的请求也触发它。改成“仅当用户明确表达以下意图时使用:获取某地未来一段时间内气象条件”之后,误用率显著下降。所以别嫌这些字段啰嗦,它们才是技能的第一道守门员。
1.3 这项目要不要做?先看门槛
如果你是在单机脚本里调一两个 API,那确实没必要上技能化,直接写函数就行。但如果你的目标是做一个能应对多种任务的助手,或者想长期沉淀一套可复用的能力集合,那这个成本是值得的。我自己的判断标准有三个:
- 工具或流程数量是否会超过 10 个。
- 是否有多个相似功能需要区分。
- 是否希望后续可以单独给某个能力加鉴权、加缓存、加日志。
这三个问题里只要有一个是“是”,技能化改造基本不亏。反过来,如果数量少、边界清、不需要单独维护,那保持简单就是最好的设计。
2. 整体方案设计:怎么搭一套可扩展的技能库
这一节我重点说说目录结构和注册机制。好的技能库设计,应该做到“加新技能时不用改主程序逻辑”,哪怕写技能的人对整体调度一无所知,也能直接塞进去跑通。
2.1 目录结构:一个技能一个文件夹
我采用的目录结构非常朴素,但实测下来很稳:
agent-skills/ ├── skills/ │ ├── weather_now/ │ │ ├── SKILL.md │ │ ├── main.py │ │ └── requirements.txt │ ├── summarize_text/ │ │ ├── SKILL.md │ │ ├── main.py │ │ └── requirements.txt │ └── set_reminder/ │ ├── SKILL.md │ ├── main.py │ └── config.json ├── registry.json ├── scheduler.py └── executor.py每个技能文件夹里,SKILL.md 是给模型看的说明书,main.py 是给执行器调用的入口。requirements.txt列出该技能额外依赖的 Python 包,这对于隔离依赖环境非常有用。就算各个技能用了不同版本的同一个库,也能通过虚拟环境或按需安装的方式避免冲突。
2.2 SKILL.md 怎么写才不会被模型忽略
这是整个设计里最容易被低估的部分。很多人的 SKILL.md 就写一段话,模型看完跟没看一样。我建议把说明书做得“像客服系统里的工单模板”,固定字段,固定顺序,少描述、多界定。
我在天气技能里写的 SKILL.md 大概是这样的:
## 技能名称 weather_now ## 技能描述 获取某个地点当前或未来短时间段内的天气实况与预报。 仅当用户包含明确地名或可推断地名,并且意图是了解天气情况时使用。 如果用户询问“过去”的天气或“气候统计”,请改用历史天气技能。 ## 参数说明 - location: 字符串,必填,城市名或区县名,例如“北京市朝阳区”。 - days: 整数,可选,默认 1,预报天数,1 到 7 之间。 - unit: 字符串,可选,取值 celsius / fahrenheit,默认 celsius。 ## 输出格式 JSON 对象,字段包括 location、temperature、conditions、forecast。注意我这几个点:先讲了“不要什么时候用”,这比只写“什么时候用”更能帮模型排除错误候选。参数部分直接给了“必填”“默认值”“范围”,模型生成参数时会更有依据,不会瞎编。输出格式也提前固定下来,调用方不用猜它返回什么。
2.3 技能注册表:可被发现、可被检索
有了说明书,还差一层索引。我维护了一个registry.json,启动时加载到内存里,负责把所有技能的元信息汇总出去,供模型按名检索,也供执行器按名分发。
{ "skills": [ { "name": "weather_now", "entry": "skills/weather_now/main.py", "description": "实时天气查询", "enabled": true }, { "name": "summarize_text", "entry": "skills/summarize_text/main.py", "description": "长文本摘要", "enabled": true } ] }注册表的好处是:调度器只跟注册表打交道,不需要硬编码技能路径。后续添加技能,我只需要把新目录丢进 skills 文件夹,再往 registry.json 加一行,主流程完全不用动。
3. 核心技能实现:从零手写三个可用技能
讲完架构,直接看实现。我这里拿三个常用技能做示例:天气查询、文本摘要、定时提醒。这三个技能代表了三种典型形态:外部 API 调用型、本地模型调用型、状态管理型。
3.1 天气查询技能:从鉴权到请求失败兜底
第一个技能,weather_now。它的核心逻辑非常直白:解析参数,请求某个公开气象服务,解析响应,返回结构化结果。但真正写起来,几个细节坑得很。
# skills/weather_now/main.py import json import sys import urllib.request def resolve_location(raw): # 实际项目中这里会接一个地理编码服务 # 演示直接采用参数值返回 return raw def fetch_weather(location, days=1, unit="celsius"): api_key = "your_api_key_here" url = "https://api.example.com/v1/weather" params = { "q": location, "days": days, "unit": unit, "appid": api_key } query = urllib.parse.urlencode(params) with urllib.request.urlopen(f"{url}?{query}", timeout=10) as resp: return json.loads(resp.read().decode("utf-8")) if __name__ == "__main__": args = json.loads(sys.argv[1]) try: loc = resolve_location(args["location"]) data = fetch_weather(loc, args.get("days", 1), args.get("unit", "celsius")) result = { "location": loc, "temperature": data["current"]["temp"], "conditions": data["current"]["condition"], "forecast": data.get("forecast", []) } print(json.dumps(result, ensure_ascii=False)) except Exception as exc: print(json.dumps({"error": str(exc)}, ensure_ascii=False)) sys.exit(1)执行器调用技能时,会把模型生成的参数转成 JSON 字符串,作为sys.argv[1]传给 main.py。这种方式有几个好处:技能脚本独立运行,即使一个技能崩了,也不会带崩主进程;标准输入输出即接口协议,想换成其他语言写技能也完全可行。
需要注意,鉴权信息不要让模型经手的参数去拼,应该在技能内部通过环境变量或配置文件读取。模型只负责业务参数,密钥这种敏感信息永远不要出现在参数列表里。
3.2 文本摘要技能:调用本地模型还是远端接口?
第二个技能是 summarize_text,它更像一个“计算型”技能。输入是一段长文本,输出是摘要。这个技能的关键分歧在于:用远端大模型接口还是本地模型。
我两种方案都试过。远端接口效果稳定,但存在隐私风险,而且调用成本要仔细控制;本地模型则方便离线调试,但摘要质量参差不齐。最后在模拟项目X里我采用的是混合策略:先根据输入长度和敏感级别判断走哪条路,太长或包含特定标记的文本优先走本地小模型。
# skills/summarize_text/main.py import json import sys import hashlib def detect_sensitive(text): # 仅示例:检测手机号等敏感模式 import re return bool(re.search(r"1[3-9]\d{9}", text)) def summarize(text, max_length=200): # 这里调用文本摘要接口 # 实测返回结果受长度和语言影响较大 payload = {"text": text, "max_tokens": max_length} # 请求省略,可能是远端 API,也可能是本地模型服务 return "这是摘要结果占位。" if __name__ == "__main__": args = json.loads(sys.argv[1]) text = args["text"] max_length = args.get("max_length", 200) if detect_sensitive(text): result = {"summary": "输入包含敏感信息,已拒绝处理。"} else: result = {"summary": summarize(text, max_length)} print(json.dumps(result, ensure_ascii=False))文本处理类技能最容易踩的坑是输入长度不加限制。模型有可能把一整个网页源码当作文本来求助,技能内部必须做截断或分块处理,否则请求很容易超时。我后来在参数 Schema 里对 text 字段加了 max_chars=50000 的约束,超出就要求模型先做裁剪,这才把异常率降下来。
3.3 定时提醒技能:边缘情况与时间解析
第三个技能 set_reminder,比较特殊,因为它是“有状态”的。用户说“半小时后提醒我喝水”,系统不能立刻返回结果就完事,而是要真的在半小时后触发一次提醒。这就涉及事件调度和日历解析。
# skills/set_reminder/main.py import json import sys from datetime import datetime, timedelta def parse_delay(text): # 自然语言相对时间解析,这里仅做演示 if "分钟后" in text: return int(text.split("分钟后")[0]) return None if __name__ == "__main__": args = json.loads(sys.argv[1]) message = args["message"] when = args.get("when") if when is None: delay = parse_delay(args["query"]) if delay is None: print(json.dumps({"error": "无法解析时间"}, ensure_ascii=False)) sys.exit(1) fire_at = datetime.now() + timedelta(minutes=delay) else: fire_at = datetime.fromisoformat(when) print(json.dumps({ "reminder_id": "remind_12345", "fire_at": fire_at.isoformat(), "message": message }, ensure_ascii=False))这里我要特意提醒时间解析的坑:千万别让模型直接输出“相对时间表达式”就完事,然后由执行器去猜。最稳的做法是让模型输出一个绝对时间 ISO 字符串,相对时间由前端提前换算,或者模型在生成参数时已经完成换算。因为执行器不具备自然语言理解能力,时候解析失败会显得特别蠢。
4. 调度与编排:主控 Agent 怎么选择合适的技能
技能本身写得再好,如果主控不会安排,一切都是白搭。这一节说说调度器的设计思路,以及我在处理多技能协同、优先级冲突时的一些实测经验。
4.1 基于描述的匹配策略
我把主控的决策过程简化成三步:
- 模型读取注册表里所有技能的 description。
- 根据用户请求判断哪些技能候选。
- 为候选中选中的技能填充参数,并输出调用计划。
其实这跟 function calling 的机制非常像,所以也可以用现成的 function calling API 替代。我在项目里是自己拼 prompt 然后让模型以 JSON 方式输出调用计划,核心部分长这样:
你是一个智能任务调度器。当前你可以使用以下技能: 1. weather_now: 实时天气查询 2. summarize_text: 长文本摘要 3. set_reminder: 定时提醒 请根据用户请求,选择最合适的技能并输出以下 JSON: {"skill": "技能名", "parameters": {...}, "reason": "选择理由"}这里有个小小的设计讲究:我让模型额外输出“选择理由”,不为别的,就为了排查问题的时候,能知道模型是凭哪句话判断出来的。如果理由跟用户请求明显对不上,我就知道是描述出了问题,而不是参数有问题。
4.2 多技能协同与数据传递
早期的技能设计是“一个技能一次调用”,但真实场景往往需要多步。用户说“北京明天会下雨吗,如果下雨就提醒我带伞”,这就同时涉及天气查询和定时提醒。我最初的调度器是一次只调一个技能,结果这类请求直接失败。
后来我把调度器改成支持“技能链”:先执行技能 A,把它的输出结果作为技能 B 的输入参数的一部分。比如天气查询返回后有雨,就自动调用 set_reminder,message 等于“明天出门记得带伞”。
实现上我引入了一个简单的中间缓存区,用于暂存上一个技能的输出,让下次技能可以从prev_result字段里取数。实测下来,这种浅层的链式调用非常实用,而且不会把复杂度推给模型。模型只需决定调用链的顺序,具体数据流转由调度器代码接管。
4.3 优先级与冲突消解
有些场景下,多个技能都可能“接得住”同一个请求。比如用户说“帮我看看明天适合适合跑步”,既可能是天气查询,也可能是运动建议技能。这类模糊场景,模型必须有一个消解规则。
我的做法是在注册表里给每个技能增加一个priority字段,数值越高越优先。同时要求技能描述里明确写上“如果不满足某个条件,请改用其他技能”。测试下来,加了优先级的调度器,比纯靠模型随机选择的准确率高不少,错误调用率下降了非常明显。这里有一个经验:优先级字段必须写在注册表里,而不能放在 SKILL.md 里,这样优化时只需要调一处索引,不用逐字改说明书。
5. 常见问题与排查技巧实录
踩坑是学习的捷径。下面按我实际遇到的高频问题,按视频排查思路整理了一份速查表,挨个说说现象和解决办法。
5.1 技能描述太模糊导致错误调用
现象:用户问“今天多少度”,模型不去调用天气技能,而是调用了历史天气技能。排查时先把注册表和 SKILL.md 打印出来,如果你自己都觉得两个技能的 description 区分度不高,那模型肯定也分不清。解决:给每个技能增加 when_not_to_use 字段,并明确“当用户询问XX时不要使用本技能”。
我在给 weather_now 和 weather_history 两个技能做区分时,给前者的 SKILL.md 里加了一句“仅关注当前或未来预报”,给后者的加了一句“仅关注历史时间段”。从那以后错误调用基本绝迹。
5.2 技能加载失败:约定大于配置
现象:新技能目录放进去,注册表也更新了,但执行时总是报错 “module not found” 或者 “file not found”。原因往往是路径写错了,或者技能目录里缺少入口文件。我在 executor 里加了启动自检,把每个注册技能的文件是否存在、能否编译、依赖是否已安装全部校验一遍,启动不发警告就跑。
排查时别犯懒,先看路径用的是绝对路径还是相对路径。相对路径在不同工作目录下表现完全不同,统一在配置里设置一个 base目录,再拼接技能路径,最省心。
5.3 循环调用与上下文爆炸
这是多技能协同阶段最容易出的问题。技能 A 的输出被传给技能 B,B 的结果又被塞回给模型继续决策,往复几轮后,上下文字符数翻倍暴增,响应延迟越来越明显。我的处理方式是:每轮调用后对中间结果做摘要,把长文本裁剪成要点,再拼接进下一轮的上下文。举个例子,天气查询返回的 7 天预报每个小时都有数据,中间结果只提取“未来三天有雨”“气温低于 10 度”等关键标签,后续决策完全够用。
5.4 参数校验:给模型“上一道保险”
模型生成的参数有时候就是离谱。明明 days 限制在 1 到 7 之间,它会生成 10 甚至 -3。技能内部如果不对参数做约束,外部请求就出怪事。我给每个技能入口都加了一个轻量级校验函数,专门检查必填项是否缺失、类型是否正确、取值范围是否合法。
经验法则:校验逻辑要放在技能脚本自身的最前面,而不是依赖调度器统一校验。因为技能是独立运行的,万一哪一天你跳过调度器直接调用它,校验逻辑依然有效。
def validate(args): if "location" not in args: raise ValueError("缺少 location 参数") days = args.get("days", 1) if not isinstance(days, int) or days < 1 or days > 7: raise ValueError("days 必须是 1 到 7 之间的整数")我看过太多项目,把校验放在上层,底层技能裸奔,一被绕过就是事故。把它放到技能内部之后,稳定性提升非常明显。
5.5 超时与重试:外部 API 不像本地代码那么听话
天气查询这种依赖外部接口的技能,一定要设计超时和重试。我踩过一次线上事故,外部接口响应超过 15 秒,调度器一直在等,用户端以为机器人死掉了。后来在 fetch_weather 里加上了 timeout=10,并在技能之外包了一层重试:第一次失败后隔 1 秒重试,最多 3 次。大部分偶发故障都能被重试吸收,真的连失败的话,再返回一个友好错误给用户,比卡住强太多。
5.6 技能内部日志:排查问题的关键线索
最后提醒一个容易被忽略的点:给每个技能入口打日志。不是让技能自己打印到终端,而是把输入参数、执行耗时、输出结果统一写到约定的日志文件里。正则日志一旦形成习惯,回溯问题会轻松非常多。我在执行器里封装了一个 log_record 函数,技能只需要调用一行,就能把当次调用的关键信息存档。
很多疑难杂症,比如“用户偶尔抱怨某条回复很奇怪”,如果你有日志,翻一下就知道当时模型选择了哪个技能、传了哪些参数、输出是什么。没日志的话,就只能在黑盒里瞎猜了。
写在最后的体会
我在模拟项目X里推完这套 agent-skills 模式之后,最直观的感受是:Agent 的问题,很多时候不是模型不够聪明,而是我们没把执行环境整理得足够清晰。技能化让每个能力都有了边界、有说明书、有校验、有日志,模型只需要当“调度员”,而不是“代码解释器”。
如果从头再搭一次,我会把 SKILL.md 模板再往细里推一步,给每个技能配上几个“反例问题”作为内置测试,这样每次改描述都能自动回归,防止优化一个技能反而带崩另一个。办不到自动化的时候,手动往前翻几个案例也是值得的。
最后再分享一个实战小技巧。你给技能写 when_not_to_use 字段的时候,别只写抽象的“不适用”,最好写一条用户典型原话,例如“当用户问'前几天下了多少雨'时不要调用 weather_now”。模型在识别具体例句的时候,比对抽象规则灵敏度高得多。这个小改动,帮我省下了一整轮的描述调优成本。