很多人搭 Agent 都是从“写 Prompt 调模型”开始的,跑着跑着就会发现一个尴尬的局面:工具函数越堆越多,提示词越写越长,上下文动不动就爆掉,模型偶尔还调错工具、传错参数。我最近一直在折腾一套叫agent-skills的东西,核心思路很简单:把 Agent 的能力拆成一个一个可以独立开发、独立测试、独立插拔的“技能包”,而不是把所有东西都塞进一个大 Prompt 里。这篇就来聊聊我踩过的坑,以及这套方案的完整落地过程。
先说清楚它解决什么问题。假设你现在手里有一个 Agent,既要查天气、又要算报销、还要调内部 API,最笨的办法是把所有工具说明、调用规则、示例全部拼进 System Prompt。一旦超过十个工具,模型开始选择性遗忘,漏调用、错参数、幻觉满天飞。agent-skills 这种思路就是把“所有工具堆在一起”改成“技能包 + 注册中心 + 路由规则”,模型需要哪几个技能,就动态加载哪几个,Prompt 保持精简,上下文也清爽得多。
这篇内容适合谁?如果你正被多工具调用的稳定性折磨,或者准备把一个 Demo Agent 往复杂业务方向扩展,又或者你想给自己的智能体做一套可复用的工具层,那这套思路应该能给你不少启发。我会从设计思路、目录结构、核心代码、排错经验一路拆到底,你可以直接照着落地。
1. 项目定位与整体设计
1.1 我为什么最后选了“技能包 + 路由”而不是“大而全的 Prompt”
最开始我做 Agent 是非常原始的打法,把能用到的所有工具都列在一份文档里,再让模型自己挑。这种方法在小规模跑通的时候没问题,但规模一上来,短板极其明显。
首先是 Prompt 膨胀。每个工具的 description、参数说明、注意事项都要占 Token,工具数量一多,上下文直接失控。我试过把 25 个工具全堆进 System Prompt,一次请求的输入 Token 接近 2 万,成本倒还是小事,模型在这么多内容里反而抓不住重点,经常忽略最该用的那个工具。
其次是冲突。两个工具如果参数名相似,或者描述方式含糊,模型很容易把 A 工具的参数传给 B 工具。有一次我让模型查一下某商品的库存,它居然调用了“价格计算”工具,还煞有介事地传了三个库存相关的字段,那一瞬间我意识到,单纯靠 Prompt 约束工具选择,天花板已经到了。
agent-skills 的应对方式是把工具变成“技能包”。每个技能包自带三样东西:技能说明(什么时候该用)、工具定义(Function Calling 用的 JSON Schema)、规则提示词(调用参数怎么映射、结果怎么处理)。然后由一个注册中心统一管理,Agent 每次执行任务前,先根据用户意图做一次路由,只加载匹配的技能。
这么做的确让你多写一层逻辑,但换来的是上下文可控、工具之间互不干扰、每个技能包可以单独测试单独迭代。实际体验下来,稳定性提升非常明显,尤其当你的技能数量超过十个的时候,这种模块化的优势会远大于那点架构开销。
1.2 设计目标与取舍:先收敛,再谈智能
我在设计这套方案的时候给自己定了几个原则,也算是被现实教育出来的经验。
第一,每个技能包必须独立可用。也就是说不依赖某个全局提示词,单独把技能包拿出来,配上一个最小的测试脚本,就能跑通。这样做的好处是开发的时候不需要启动整个 Agent,排查问题也不需要看一整个调用链,直接对着单个技能测就行。我遇到的大部分模型误调用问题,最后都是在单技能环境下复现并解决的。
第二,路由规则要尽量白盒化。不要让模型自己决定“该用哪个技能”,而是用一套可配置的规则,比如关键词匹配、正则判断、甚至向量相似度兜底。这个选择和很多人的直觉相反,我一开始也觉得“让模型自己选”更智能,但后来发现模型选技能这件事本身就不靠谱,因为你等于让模型做一次没有监督的分类任务,准确性很难保障。改成规则之后,虽然看起来不那么“智能”,但每个路由决策都可以解释、可以测试、可以手工修正。
第三,不重复造轮子。底层只做轻封装,调用模型还是走各家 SDK,工具层就用标准 Function Calling 格式,我只是在上面加一层“技能注册 + 技能路由 + 技能插拔”的壳。这样以后模型厂商切换、底层接口升级,对技能包本身的影响很小。
这套设计哲学可以用一句话概括:先让系统收敛到可控,再在可控的基础上谈智能。很多复杂 Agent 项目的失败都不是因为模型不够聪明,而是系统熵太高,今天能跑、明天跑不通,最后根本没法维护。
2. 技能规范与注册机制
2.1 技能包目录结构:一份文件、一组代码、一条规则
为了让技能包做到“独立、可测试、可插拔”,我规定了一个最小目录结构,所有技能都必须照这个来:
skills/ ├── remind_me/ # 技能包名,全局唯一,建议使用单词+下划线 │ ├── manifest.yaml # 技能元信息:名称、版本、启用状态 │ ├── skill.md # 技能说明:什么时候用、什么时候不用 │ ├── tools.py # 工具函数实现,每个函数是一个可被模型的工具 │ ├── schema.json # Function Calling 的 JSON Schema 定义 │ └── tests/test_tools.py # 本地单测,不依赖模型解释一下每个文件的定位。manifest.yaml 是技能的身份证,里面写了技能的 id、版本号、路由匹配规则,还有依赖项。我踩过一个坑,就是技能包之间传参数没有规范,后面为了兼容各种写法写了一堆补丁,后来统一用 schema.json 来做参数校验,情况才好转。
skill.md 是给模型看的说明书,这段写得越清晰,模型调用就越准。我的经验是不要写一大段背景知识,只写三块就行:什么时候调用、什么时候禁止调用、特殊规则。尤其是“什么时候禁止调用”这块,很多人忽略,但我实测下来它对防止误调用特别有效。
tools.py 是真正的工具实现,要求函数必须是纯逻辑,不能在内部访问全局变量。这样设计是为了后续能并行加载、独立测试。你想想,如果某个工具函数偷偷依赖了别的模块里的全局状态,单测的时候可能没问题,一合到主程序里就炸,排查起来非常痛苦。
2.2 注册流程:把技能包变成模型“看得懂”的工具
每个技能包在被 Agent 加载之前,都要经过注册。注册这一步的核心工作是把技能包里的函数定义转换成模型能识别的 Function Calling 格式。
我推荐用 JSON Schema 来描述函数,而不是靠 docstring。原因很简单:很多模型厂商对 docstring 的解析不稳定,换一个厂商就得重新调,而 JSON Schema 是标准格式,各家都认。
给个例子,假设技能包“remind_me”里有一个创建提醒的函数:
def create_reminder(content: str, remind_time: str) -> dict: """创建一个提醒任务""" ...对应的 schema.json 大概是这个样子:
{ "type": "function", "function": { "name": "create_reminder", "description": "为当前用户创建一个定时提醒。当用户要求'提醒我'、'记得告诉我'、'定时消息'时调用。不要用于查询已有提醒。", "parameters": { "type": "object", "properties": { "content": { "type": "string", "description": "提醒的具体内容" }, "remind_time": { "type": "string", "description": "提醒时间,格式为 ISO 8601,例如 2024-12-31T18:30:00" } }, "required": ["content", "remind_time"] } } }这里需要注意两个细节。第一,description 要写清楚触发条件和禁止条件,模型会根据这段描述决定要不要调用,写得太模糊就等着它乱调吧。第二,时间格式这种参数,你必须在 description 里给一个绝对明确的格式,否则模型会用各种奇怪的格式传给你,什么“明天下午”、“五点半”,你解析的时候会被逼疯。
注册函数要做的就是把 schema.json 和 tools.py 里的函数映射起来,然后放到一个全局注册表里:
class SkillRegistry: def __init__(self): self._skills = {} def register_skill(self, skill_id: str, skill: Skill): self._skills[skill_id] = skill def get_tools(self, skill_ids: list[str]) -> list[dict]: tools = [] for skill_id in skill_ids: skill = self._skills[skill_id] tools.extend(skill.get_function_schemas()) return tools这套注册机制看起来简单,但它解决了一个很实际的问题:你可以把“模型能看到哪些工具”和你“系统里有哪些工具”解耦。模型每次请求前只需要传入本次用到的技能 ID 列表,注册中心会把对应的工具 Schema 传给模型,模型永远看不到它不需要的东西。
2.3 技能路由规则:谁来决定这次加载哪些技能?
这一步是 agent-skills 里比较关键的设计。路由决定了每次请求激活哪几个技能包,路由不准,后面的所有环节都会跟着跑偏。
我用了一个“三级路由”策略,从快到慢逐级匹配:
| 层级 | 匹配方式 | 场景 | 速度 |
|---|---|---|---|
| 第一级 | 关键词精确匹配 | 用户明确说了“提醒我”“查天气”“算一下” | 微秒级 |
| 第二级 | 正则/规则匹配 | 日期、金额、地址等模式识别 | 毫秒级 |
| 第三级 | 向量相似度兜底 | 用户表达较模糊,没有触发关键词 | 秒级 |
第一级是最常用的。每个技能包在 manifest 里会定义一组 trigger keywords,比如提醒技能的触发词可以是“提醒”“记得”“定时”,天气技能的触发词可以是“天气”“气温”“下雨”。只要用户消息命中其中一个词,就将该技能加入候选。
但光有关键词还不够,我加了一个负面规则。比如用户说“帮我看看有没有未完成的提醒”,这里也出现了“提醒”,但意图是查询而非创建,如果你不加区分,模型就会被搞糊涂。所以我要求技能包在 manifest 里同时配置 positive_keywords 和 negative_keywords,路由的时候先剔掉命中负面的技能。
第三级的向量兜底用得少,但它保证了系统不会因为用户没说关键词就直接失败。做法是把用户消息和所有技能的说明书做一遍向量相似度,取最高分并且超过阈值才激活,不够阈值就不激活。这对路由异常是一个很好的兜底。
3. 核心机制与代码骨架
3.1 一次请求的生命周期:从路由到结果回填
搞清楚一次请求是怎么流转的,你就理解了这个框架里每个组件的位置。我在项目里把整个流程分成了五个阶段,每个阶段都有对应的日志输出,排查问题的时候按日志过一遍基本就能定位。
第一步是意图识别与路由。用户消息进来,先走三级路由,得到一串技能 ID 列表。如果一个技能都没有命中,就当作闲聊处理,不加载任何工具,直接让模型自由回复。这也是一个很小但很实用的优化:没有工具调用需求的时候,何必把一大堆工具定义塞给模型浪费 Token 呢?
第二步是技能加载与 Schema 拼接。路由命中之后,从注册中心取出这些技能对应的工具 Schema,拼进请求参数里。我通常会限制一次请求最多激活 3 个技能,超过就取路由分最高的 3 个。这个限制来自我的血泪教训:一次给模型 6 个工具,它决策的质量会明显下降,而且更容易出现工具调用之间互相干扰。
第三步是模型决策。这一步基本不做额外干预,让模型根据技能说明自主决定调用哪个工具、传什么参数。因为前面路由已经过滤过一遍,模型面对的候选集已经足够小,一般表现得比较稳定。
第四步是工具执行。得到模型返回的 tool_calls 之后,在本地执行对应的函数,然后把结果作为 tool message 返回给模型。工具执行这里要加一个超时处理,这个太重要了。我遇到过某个工具内部请求第三方 HTTP 接口超时,整个 Agent 卡了 40 秒没反应,后面统一包了一层超时之后,最差的情况也能稳定返回“操作超时”。
第五步是结果总结。模型拿到工具执行结果之后,生成面向用户的回答。这里有一个技巧:如果工具返回结果是一个很长的 JSON,不要让模型把整个 JSON 都复述出来,要在 skill.md 里约定让模型只回复关键字段,这一点能省下不少输出 Token,而且用户的阅读体验也更好。
3.2 注册中心的实现:动态加载与隔离
注册中心是整个技能库的“物业前台”,所有技能包入住、注销、被查询,都要经过它。它需要做到两件事:动态加载和隔离。
动态加载的意思是,技能包不是写死在代码里的,而是扫描目录然后按需加载。我实现了一个简单的 loader:
def discover_skills(skills_dir: str) -> list[Skill]: skills = [] for entry in Path(skills_dir).iterdir(): if not entry.is_dir(): continue manifest_path = entry / "manifest.yaml" if not manifest_path.exists(): continue manifest = yaml.safe_load(manifest_path.read_text()) tools_module = importlib.import_module(f"skills.{entry.name}.tools") skill = Skill( id=manifest["id"], name=manifest["name"], version=manifest["version"], positive_keywords=manifest["positive_keywords"], negative_keywords=manifest["negative_keywords"], tool_functions=inspect.getmembers(tools_module, inspect.isfunction) ) skills.append(skill) return skills这里有一个容易踩的坑:技能包如果重名,或者 manifest 里的 id 重复,注册的时候就会互相覆盖。我吃过一次亏,两个技能包 id 都写成 reminder,结果后加载的覆盖了先加载的,某个技能突然消失了,查了半天才发现是 id 冲突。后来我在注册前加了唯一性校验,一旦发现有重复 id 直接抛异常,宁可启动失败也不能让系统带病运行。
隔离方面,我的做法是技能包只能通过参数与外部交互,不能共享全局变量,也不能跨技能调用对方的内部函数。每个技能如果需要调用外部能力(比如数据库、HTTP API),统一通过依赖注入的方式传入。这样做的好处是技能包可以在完全隔离的测试环境里跑,不会出现 A 技能改了全局变量、B 技能行为突变的诡异问题。
3.3 技能执行器的设计:超时、重试与结果归一化
技能执行器是实际调用技能函数的那一层,我把它理解成一个“翻译官”:模型传出函数名和参数,执行器把参数解析出来,调用真正的 Python 函数,再把返回值转成模型认识的文本格式。
三个设计点花了我不少时间。第一个是参数校验。不能直接拿模型的参数去调 Python 函数,因为模型偶尔会输出缺字段、多字段、类型错误的内容。我在执行器里用 schema 里定义的参数规则做一次校验,失败的话直接把错误消息返回给模型,让它自己修正。这里有个很有意思的现象:你给模型返回“参数错误,缺少content字段,它是必填的”这种信息,模型十有八九能自己补齐参数重试。这比你在执行器里强行容错要好得多,因为模型的自我纠错能力其实很强,只是需要你告诉它哪儿错了。
第二个是超时控制。工具函数执行时要包一层超时机制,对于网络调用尤其重要。我的经验是标准工具给 10 秒,涉及外部第三方接口的可以放宽到 30 秒。如果超时了就返回一个标准错误信息,让模型告诉用户当前操作失败,而不是整个 Agent 卡死。没有超时控制的 Agent 就像没有安全阀的锅炉,迟早要出事。
第三个是结果归一化。工具返回的类型可能很杂,有的是 dict、有的是 list、有的直接返回字符串。我会统一转成一段 JSON 文本再喂给模型。但这里要注意 Token 量的控制,如果工具返回了一个大数组,比如几百条记录,你不能全塞给模型,要做一个截断逻辑,只保留前几条或者做聚合摘要。否则模型会迷失在一堆数据里,总结出来的东西根本没法看。
4. 动手实现一个完整技能包
4.1 从 manifest 写起:一份合格的“技能说明书”
现在来动手写一个完整的技能包,以我项目里的“事务提醒”技能为例,你可以直接照着仿写。我要强调一个顺序:先写 manifest.yaml,再写 skill.md,然后写代码。为什么?因为 manifest 定义了技能的对外契约,先想清楚它什么时候被触发,后面都不会跑偏。
id: todo_reminder name: 事务提醒 version: 1.0.0 description: 为创建、查询、完成提醒事务提供能力 enabled: true positive_keywords: - 提醒 - 记得 - 定时 - 待办 negative_keywords: - 天气提醒 - 消息提醒设置注意我这里加了一个 negative_keyword,把“天气提醒”排除掉了,是因为我的路由层里还有一个天气技能,如果不排除,用户说“帮我关掉明天的天气提醒”这种话,事务提醒技能和天气技能会同时被激活,然后互相抢着处理,结果一个都不对。这种低频但致命的冲突,靠关键词层就能规避掉,尽量别丢给模型去判断。
4.2 skill.md 怎么写模型才爱用
skill.md 是给模型读的说明文档,我一开始写得很啰嗦,讲了一堆背景知识和业务流程,结果模型反而不抓重点。后来调整成三段式结构,效果立竿见影。
# 事务提醒技能 ## 什么时候用 - 用户要求创建一条提醒事务,例如“提醒我下午3点开会” - 用户要求查询已有提醒事务 - 用户要求将某个事务标记为已完成 ## 什么时候不要用 - 用户只是随口提到“记得”两个字,不是真的要设置提醒 - 用户询问天气类信息,例如“明天会下雨吗” ## 特殊规则 - 时间参数必须转换成本地时区的 ISO 8601 格式 - 如果用户说“等一下”,不要创建提醒,直接追问具体时间 - 查询操作不需要填写 content 字段,传空字符串第三部分“特殊规则”是调试出来的精华,都是实测中遇到过的模型行为。比如我一开始没有写“等一下”这条规则,模型经常会因为用户说“等我想想”就自作主张创建一个内容为空的提醒,特别蠢。加了这条规则之后,模型会主动追问时间,行为正常了很多。
写完 skill.md,把它和 manifest 放在同一级目录,这个技能包就算定义完了。
4.3 工具函数与测试用例:不加载模型也能验证
工具函数只是普通的 Python 函数,但有一个要求:入参出参都与 schema.json 保持一致,并且不能依赖于全局环境。这是为了后续测试方便,你可以不启动任何 Agent、不调用任何模型,就对这个函数做单元测试。
我做了一个很轻量的测试脚本,专门用来在本地验证技能包的行为:
def test_create_reminder(): todo = TodoService(memory_backend=True) result = todo.create("开会讨论季度目标", "2024-12-31T18:30:00") assert result["status"] == "created" assert result["content"] == "开会讨论季度目标" def test_missing_content(): todo = TodoService(memory_backend=True) try: todo.create("", "2024-12-31T18:30:00") assert False, "应该拒绝空内容" except ValueError as e: assert "content不能为空" in str(e)这一步看似简单,但价值非常之高。我后来把技能包的测试都嵌进了 CI 流程,每次改动技能代码,先跑一遍单测,全绿了才会进入 Agent 主流程。这套体系让我的技能包迭代速度明显加快,再也不用担心改了一个函数导致模型表现突变还找不到原因。
5. 上下文管理与性能优化
5.1 用“清单式加载”控制 Token 消耗
技能包方案最大的红利就是 Token 控制。传统做法把全部工具说明放进上下文,每次请求都是全量输出;现在只加载命中的技能包,输入 Token 能省 50% 以上,在一些工具特别多的系统里,省得更多。
我算过一组实际数据。原来系统里挂了 25 个工具,所有 Schema 加一起大概 6000 Token。每次请求光工具定义就要输出 6000 Token,用户消息长一点就很容易破万。拆成技能包之后,绝大多数请求只命中 1 到 2 个技能,对应工具 Schema 只有几百到一千 Token,差距非常明显。
但我还要提醒一点:Token 节省不是唯一目标,模型的可用性才是。你会发现,当工具数量少了之后,模型的工具调用决策质量也会提升。因为候选集变小了,模型更容易计算哪个工具匹配当前意图,误调用的概率直线下降。这也是我为什么把每请求最多加载 3 个技能作为硬性限制。
5.2 技能加载的缓存与并行策略
技能加载本身也有性能开销,尤其是 schema.json 的解析和工具函数的导入,虽然比一次模型调用快很多,但在高频场景下仍然不能忽略。
我的优化方式很简单:启动时预加载全部技能包的 schemas 和函数映射,请求时只做路由筛选,不再做磁盘 I/O 和模块导入。这样就把加载成本一次性摊到启动阶段,请求路径上只剩下一个字典查找。
如果你有更极端的高并发需求,可以在这个基础上加一层本地缓存或 CDN,但这属于锦上添花,我实测下来,预加载的方案在单机并发 100 的场景下已经足够用了。
此外,并行策略也值得关注。现在的系统流程是串行的:路由 → 模型决策 → 工具执行 → 模型总结。其中模型决策没法并行,因为要等模型返回,但工具执行阶段如果命中了多个技能包,可以考虑并行执行,然后汇总结果。我尝试过把两个独立技能的工具调用并行跑,整体延迟能降低 30% 左右。但要注意,如果工具之间有依赖关系,或者会操作同一个存储,并行就要格外小心,别把数据搞出并发问题。
6. 技能包的安全与模型行为沙盒
这里多说一层。技能包本质上是让模型间接控制系统里的能力,所以安全边界必须从一开始就设计进去。我不会在工具执行完成后才检查行为,而是在工具定义层就施加约束。
我给自己定了三条安全准则。第一,对待模型的不确定信息采取“验证后执行”的策略。比如某个技能要求写入数据库,模型传上来的用户 ID 必须和会话绑定的用户 ID 一致,否则直接拒绝。第二,读写操作要分权限等级。读取类技能可以快速放行,写入类技能必须额外校验;涉及删除、批量修改等高危操作,需要单独走二次确认。第三,所有工具执行日志要完整保留,并且能按会话关联起来,方便事后审计。
这条安全策略在技能包体系里实施起来特别顺。因为每个技能包都是独立调用的,你可以在执行器层加一个统一的安全拦截器,按技能包 id 配置不同的权限级别,不需要在每个函数里手工写检查逻辑。这个思路极大减少了安全意识方面的重复工作。
7. 常见问题与排查技巧实录
7.1 高频问题速查表
在这一年多的折腾过程中,我把遇到的典型问题整理成了一张表,每次 Agent 行为诡异,我都会先对照着过一遍。
| 症状 | 常见原因 | 处理方式 |
|---|---|---|
| 模型完全不调用任何工具 | 路由阶段没有激活技能,模型看不到工具 | 检查 manifest 的 positive_keywords 是否覆盖用户表达 |
| 调用了错误的工具 | 两个技能的触发条件过于相似 | 增加 negative_keywords,在 skill.md 中补充“不要使用”场景 |
| 工具参数格式不对 | schema.json 中的 description 描述不明确 | 补充参数格式示例,尤其是时间和枚举值 |
| 工具返回结果后模型乱总结 | 没有在 skill.md 里约定输出规范 | 增加“回复用户内容须简洁,只提取关键字段”等规则 |
| 多个技能同时被激活导致冲突 | positive_keywords 重叠过多 | 在路由层增加优先级配置,或手动指定互斥关系 |
| 技能包加载失败 | manifest.yaml 的 id 与其他技能包重复 | 注册时增加唯一性校验,重复id直接抛异常 |
7.2 一次完整排查实录:那个一直沉默的天气技能
最后一个案例,我用一个实际排错过程来收尾。
有一次我加了一个新的“物流查询”技能包,上线之后发现用户问“我的快递到哪了”,模型完全不调用这个技能,而是直接回复了一段模棱两可的话。排查的时候我先看路由日志,发现“快递”“物流”“到哪了”这些词确实命中了物流技能的 positive_keywords,路由层是通的。那问题就出在模型决策层。
我调出当时的完整请求包,发现物流技能的 schema.json 确实传给了模型,但排在前面的是另一个命中的技能“发货提醒”,模型可能被后者的描述干扰了,认为用户意图是“发货提醒”而不是“物流查询”。再加上物流技能的 description 写得比较模糊,模型权衡之下选择了不调用。
修复方式分两步。第一步把物流查询技能的 description 改得更明确,加入了“当用户询问包裹当前位置时必须调用本工具”这样的强指令;第二步是两个技能之间设置路由优先级,物流查询优先于发货提醒。改完之后重新测试,模型每次都能正确调用物流查询服务。
这个案例说明,Agent 的技能调用问题往往是多层原因叠加的。你排查的时候不能只看一层,要顺着路由日志、请求包、模型返回一层一层查,通常问题不在最明显的那一层。
写在最后的一点体会
agent-skills 这套东西做到现在,我最大的感受是:做 Agent 工程,前期的组织方式决定了后期的天花板。技能包看起来只是把工具拆开再组装,但它在开发效率、稳定性、可维护性上的收益,会随着你的技能数量增长而越来越明显。
如果你也想尝试这套思路,我建议从两三个技能包开始,不要一上来就规划庞大的技能库。先把一个技能包做完整、做扎实,跑通整个链路,再逐步添加新技能。技能之间一旦出现冲突,你就知道该往 manifest 加负面关键词了,该在 skill.md 里补规则了,这些经验都是在实战里长出来的。
最后再分享一个小技巧:写技能描述的时候,尽量用“当用户……时,必须调用本工具;当用户……时,不要调用”这种句式。我试过很多种写法,这种带触发条件和禁止条件的描述,模型对它的命中率和准确率是最高的,比你写一大段“本工具用于……”要有效得多。你写技能包的时候,不妨直接从这句话开始。