1. 为什么智能体的能力上限取决于"技能系统"
1.1 从提示词堆砌到工具调用的演进:我踩过的弯路
先说个背景。我一直在做企业内部的知识库问答智能体,最开始的做法很粗暴:把所有工具说明、角色设定、回答规则全部塞进系统提示词里,指望大模型自己理解、自己调用。结果模型确实"理解"了,但理解得浮于表面——工具一多,它就晕。二十多个工具的说明堆在一起,提示词本身就有七八千 token,模型经常在"该调搜索工具还是该调文档工具"之间反复横跳,甚至明明该算日期,它却去调了天气接口。
后来我开始重新设计这套体系,核心就是"agent-skills"这个项目。它解决的是一个问题:让智能体的每一项能力都变成一个可插拔、可描述、可独立验证的技能模块,而不是一段写死在提示词里的咒语。技能(skill)在这里不是指模型的能力,而是指我们提供给模型的一个个可执行单元——每个单元有名字、有描述、有参数声明、有真实实现,模型根据任务语义动态挑选并调用它们。
如果你正在跟 Function Calling 搏斗,或者你的智能体工具一多就开始乱选,这篇文章值得看完。我会把这个项目里踩过的坑、用过的设计、实测到的数据都摊开讲。
1.2 技能、工具、工作流的边界在哪里
很多人容易把工具(tool)、技能(skill)、工作流(workflow)混成一团。我的划分是:
- 工具是最底层的能力单元,比如"调用某个 HTTP 接口""读取某个文件""发送一封邮件"。
- 技能是对工具的封装加上语义层,它告诉模型"什么场景下用我、需要什么参数、给你什么结果"。
- 工作流是多个技能的组合编排,比如"先检索文档,再提取关键信息,最后生成周报"。
所以说,技能在中间这一层。它既不是裸的 API 包装,也不是复杂的业务流。它是给模型看的"能力说明书",同时是给开发者用的"功能模块"。agent-skills 这个项目做的就是技能这一层的管理工作:注册、发现、装载、校验、评测。
1.3 什么时候必须自建技能体系
不是所有项目都需要技能系统。如果你的智能体只调一个 API,那直接在代码里写死判断就行。但出现下面三个信号时,你差不多该动手了:
- 工具数量超过十个:提示词里已经塞不下完整工具列表,模型开始漏工具、错工具。
- 存在多轮组合调用:这次任务要调 A 工具得到结果,再根据结果决定调 B 还是 C,手写条件分支会变成意大利面条。
- 能力需要跨场景复用:多个智能体都要用"摘要"和"检索"能力,但各自维护一份实现会导致行为不一致。
我是在工具数到第十三个的时候决定重写的。重写后最大的感受是:把"模型该选哪个工具"这件事交给模型,把"工具是否被正确实现"这件事交回归测试,两者各司其职,问题就清楚多了。
2. agent-skills的核心设计思路:把技能当作可插拔模块
2.1 技能注册表里的四个关键字段
在 agent-skills 里,每个技能被我定义成四个核心字段:
| 字段 | 作用 | 说明 |
|---|---|---|
name | 技能唯一标识 | 推荐用 snake_case,例如webpage_fetch |
description | 给模型看的场景说明 | 必须写清楚"什么时候用、输入是什么、输出是什么" |
parameters | 参数 Schema | 我用 JSON Schema 描述,模型据此生成调用参数 |
executor | 实际执行函数 | 一个 Python 函数,接收参数并返回结果 |
这四个字段缺一不可。name是模型的调用索引,description是模型的选择依据,parameters是模型的输入约束,executor是真实逻辑。缺了description,模型不知道技能干嘛用的;缺了parameters,模型就算选对了技能也会传错参。
这里有一个很容易忽略的点:技能描述是写给模型看的,不是写给开发者看的。我最初写技能描述时用的是给自己看的缩写,比如"获取网页正文,返回纯文本",结果模型在需要"提取页面主要内容"的场景下完全没想起这个技能。后来我把描述改成"当用户需要读取指定 URL 的网页内容、提取正文纯文本时使用,输入为 URL,输出为字符串",命中率立刻上去了。
2.2 技能描述质量直接决定模型能不能选中它
我做了个对照测试。同一个网页抓取技能,描述分别为:
- 描述 A:"获取网页正文,返回纯文本。"
- 描述 B:"当用户需要读取指定 URL 的网页内容、提取正文纯文本时使用。输入参数 url:目标网页地址;返回该网页去除 HTML 标签后的正文文本。"
在五十条包含"打开链接看看""抓一下这个页面""帮我看看这篇文章说了啥"的测试集里,描述 A 的调用命中率只有 54%,描述 B 的命中率到了 88%。模型对"什么时候用"比"能做什么"更敏感,这是我在 agent-skills 项目里得到的最重要结论。
写好技能描述的几个实操要点:
- 开头直接写触发场景:第一句就是"当用户需要……时使用",而不是"本技能用于……"。
- 明确输入输出的语义:告诉模型它应该提供什么、会拿回什么。
- 避免模糊形容词:"高效地""智能地"这种词对模型毫无信息量。
- 控制长度:描述太长会挤占上下文,后面专门讲这个问题。
2.3 运行时选择机制:模型如何"看到"技能
技能装载后,我会把所有技能转换成模型的工具调用格式。不同模型 API 的格式略有差异,但逻辑一致:把name、description、parameters拼成一个结构化对象,放进请求里。模型根据用户的输入和这些技能定义,自己决定调哪个技能、填什么参数。
这里要说一下 ReAct 和 Function Calling 的关系。在 agent-skills 的早期设计里,我用的是 ReAct 模式——让模型输出"Thought + Action + Action Input"的文本,然后程序解析。后来我换成了 Function Calling 的 JSON 输出模式,解析更稳定、参数校验更可控。
我用过一个比喻:ReAct 模式像让学生写小作文解释自己要干什么,Function Calling 像是直接给学生一张表格让他填。小作文写得再漂亮,解析起来总有意外;表格虽然死板,但每个格子都有明确含义。对于技能调度这种需要高可靠性的场景,我建议直接走结构化输出。
2.4 为什么技能函数要保持纯粹
这一点很重要。技能函数如果包含副作用——比如直接写数据库、发邮件、触发外部通知——那么当模型重复调用、或调试时手动触发时,会产生不可预期的后果。我在 agent-skills 里定了两条规范:
- 技能函数默认无状态:同一份输入,调用两次结果一致(或者结果里包含"已执行"之类的幂等标记)。
- 需要副作用的操作,显式包装成独立技能:例如"发送邮件"是一个技能,但从"生成邮件内容"这个技能返回值里拿到草稿,再做二次确认真实发送。
这两条规则让我避免了一个很惨的线上事故:某个技能在重试时重复写入了两条工单。后来我把所有技能函数强制改为"只返回结果、不改变外部状态",副作用全部上移到编排层处理。
3. 从零实现一套基础技能装载器
3.1 目录结构:按约定优于配置
技能装载器的代码量其实不大,核心是"发现技能、导入技能、校验参数、执行"四步。我在项目里的目录结构长这样:
skills/ webpage_fetch/ skill.yaml impl.py long_text_summary/ skill.yaml impl.py remind_me/ skill.yaml impl.py每个技能独立一个文件夹,里面有一个skill.yaml做元信息声明,一个impl.py写执行逻辑。这样做的好处是:新增一个技能不需要改动任何现有代码,往文件夹里丢新目录就行。
skill.yaml里写的是技能的静态信息:
name: webpage_fetch description: >- 当用户需要读取指定 URL 的网页内容、提取正文纯文本时使用。 输入为 url 字符串,返回为网页正文文本。 parameters: type: object properties: url: type: string description: 目标网页的完整地址,必须包含协议头 required: - url3.2 动态发现与导入:不写注册表,直接扫描目录
我最初的做法是写一个中心化注册表,手动from skills.webpage_fetch import impl。技能少时没问题,到二十个技能后,每次新增技能都要改注册文件,烦且易错。
后来改成自动扫描:程序启动时遍历skills/目录,读取每个子目录下的skill.yaml,再动态导入对应的impl.py。
import importlib import yaml from pathlib import Path def load_skills(skills_dir: str): skills = {} for skill_dir in Path(skills_dir).iterdir(): if not skill_dir.is_dir(): continue yaml_path = skill_dir / "skill.yaml" impl_path = skill_dir / "impl.py" if not (yaml_path.exists() and impl_path.exists()): continue meta = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) module = importlib.import_module( f"{skills_dir.replace('/', '.')}.{skill_dir.name}.impl" ) # 约定 impl.py 里提供 execute 函数 skills[meta["name"]] = { "meta": meta, "execute": module.execute, } return skills这里用了一个约定:impl.py里必须有一个叫execute的函数。约定大于配置,代价是命名不灵活,收益是装载器逻辑极简。
3.3 参数校验与类型转换
模型生成的参数是 JSON 格式,但有时候会出现类型不匹配的情况——比如字段声明是string,模型传成了number。我在执行前强制做一次参数校验,这一层不能省:
import json from jsonschema import validate, ValidationError def run_skill(skill, raw_arguments: str): try: args = json.loads(raw_arguments) validate(instance=args, schema=skill["meta"]["parameters"]) except (json.JSONDecodeError, ValidationError) as e: return { "ok": False, "error": f"参数校验失败: {e},请检查参数类型与名称" } try: result = skill["execute"](**args) return {"ok": True, "result": result} except Exception as e: return {"ok": False, "error": str(e)}注意校验失败时返回的错误要尽量清晰,因为模型会读到这个错误并尝试修正参数。你返回"参数校验失败: 'url' is a required property",模型大概率会把参数补上重试;如果你返回"Error: 500",模型就会一脸懵。
3.4 三个真实可用的示例技能
网页抓取技能:impl.py里用 HTTP 请求拿到 HTML,再用正则或解析库提取正文。注意设置超时时间和Content-Type处理,我实际遇到过不少站点返回application/octet-stream导致解析失败的情况。
长文摘要技能:输入是一段长文本,输出是结构化摘要。这个技能内部自己调一次 LLM 接口,在函数内部设置独立的 prompt,不依赖外层系统提示词。这样做的好处是:技能的"内部行为"对外部透明,模型只关心"给它文本、拿回摘要"这一个动作。
定时提醒技能:这个技能本身只返回"提醒已创建",实际定时任务由调度器处理。它接收time和message两个参数,执行时写入定时任务队列,返回提醒 ID。这就用到了前面说的"技能函数无副作用"原则——写入队列这个操作在技能内部是原子的,不会因为重试产生重复提醒。
3.5 错误处理的一个隐藏细节
技能执行失败时,返回给模型的信息要分层。我用了三层:
- 技能发现失败:技能不存在。
- 参数校验失败:告诉模型缺哪个参数、类型哪里不对。
- 执行失败:区分"可重试错误"和"不可重试错误"。
比如网络超时属于可重试错误,返回给模型的信息可以带上"该错误可能为临时性网络问题,可重试一次";而参数格式错误属于不可重试错误,提醒模型直接修正参数。这层信息让模型的自我纠错能力提升了非常多,因为模型拿到明确的错误归因后,修正动作不会乱来。
4. 接入真实业务后:某知识库问答智能体的效果对比
4.1 接入前的崩坏现场
我拿一个具体项目说数据。这是一个给某团队内部使用的知识库问答智能体,主要任务是查文档、总结周报、统计工单。改造前,系统全部工具共 22 个,全部平铺在提示词里。实测五十条典型问题:
- 工具选择准确率大约在 62% 左右,经常出现"用户问工单统计,它去调知识库搜索"这种错配。
- 单次请求的上下文长度经常超过 9000 token,其中工具描述占了三成以上。
- 排错极难,你说它是调错工具了还是参数没传对?要翻整个 prompt 才能定位。
4.2 接入 agent-skills 后的变化
改造后,我做了两个调整:一是给所有技能按场景分组,每次实际生效的技能不超过 8 个;二是所有技能描述重写,统一为"触发场景 + 输入 + 输出"格式。同一批测试集,结果如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 工具选择准确率 | 62% | 89% |
| 平均单次请求上下文 token | 9000 | 3300 |
| 一次交互的平均耗时 | 6.8s | 4.2s |
| 出错后人工介入次数 | 平均 3 次/10 问 | 平均 1 次/10 问 |
上下文降下来的主要原因就是:不再把 22 个工具全部暴露给模型,而是先做一次场景路由,只把相关的几个技能定义传给模型。这一层路由本身不复杂,做一个关键词匹配就能筛掉一半无关技能。很多时候模型的混乱不是因为它笨,而是我们给了太多它当前任务根本用不上的选项。
4.3 效果之外:调试体验的质变
真实项目里,我感受最深的其实是调试体验的变化。以前系统出问题,我得对着完整 prompt 猜测模型为什么调错;现在每个技能独立一个目录、独立一份日志。模型调用了哪个技能、传了什么参数、技能返回了什么,全部有记录可查。
我给每个技能的执行入口加了一行结构化日志:
[skill] name=webpage_fetch args={"url": "https://example.com/article"} result_chars=2345 duration_ms=800出现问题时,先看模型选没选对技能,再看不看参数,最后看技能实现。三层排查链路清晰得多。如果你也在维护一个工具越来越多的智能体,技能系统带来的最大红利不是准确率,而是万事可追溯。
5. 技能设计与维护中最容易翻车的五件事
5.1 技能描述太长,上下文又被挤爆了
我前面说要让描述清楚,但也不代表越长越好。有一个技能,我为了提升命中率,把描述越写越长,最终写到了 400 多字符。结果命中率没提升,反而因为描述占掉上下文,模型在长对话里开始丢前文信息。
现在的经验是:描述控制在 100 到 200 个字符之间,场景、输入、输出,三句话说完。如果技能内部有复杂规则,不要写在外面,放到函数内部的提示词里,让技能自己处理。
5.2 参数命名太随意,模型传错参
我给某个技能定义了参数ct(content 的缩写),结果模型经常不传这个参数,反而传text、msg这类名字。模型对参数名的语义是有依赖的,你叫它url,它就给 URL;你叫它ct,它在自己词汇表里找不到对应关系,就开始瞎编。
参数命名必须用全名,必须跟描述里的措辞一致。后来我把ct改回content,参数校验失败率直接降了 18 个百分点。
5.3 技能内部有副作用,重试导致重复数据
前面提过一次,这里展开讲。有个技能"创建工单",最初直接把工单写入数据库。某一次模型调用时网络抖动,程序里重试了三次,数据库里多了三张工单。治理方法是:技能函数只生成工单草稿,返回给模型;模型把草稿显示给用户,用户确认后由编排层调用"确认创建工单"技能。把"执行"和"确认"拆成两个技能,比任何编程层面的删重逻辑都可靠。
5.4 权限边界模糊,技能成了脱缰野马
模型不会天生知道哪些技能危险。比如"删除工单"这个技能,你的描述里如果没写清楚"仅限管理员操作",模型就可能在任何"用户抱怨工单太多"的场景下去调它。我见过不止一次这种低级事故。
在 agent-skills 里,技能元信息加了一个permission字段,取值是public或admin。admin技能不直接暴露给模型,而是由上一层业务逻辑判断用户身份后决定是否放行。把权限判断放在技能系统里,而不是指望模型自觉,这是底线。
5.5 技能描述相似,模型开始随机选择
当两个技能描述都包含"查文档"这三个字时,模型就分不清该选"知识库搜索"还是"工单搜索"。解决思路是给每个技能添加场景限定词:
- 知识库搜索:用户想了解制度、流程、操作方法等知识内容时。
- 工单搜索:用户想查询已提交的问题处理进度时。
描述开头第一句的触发条件要有差异,如果两个技能的触发条件确实很难区分,那大概率它们本来就应该合并成一个技能,再用参数区分。技能边界划分的第一原则:不要让模型做模棱两可的选择题。
6. 技能评测:不能只看模型"会调",还要看"调得对不对"
6.1 最小可复现用例集
技能系统的维护成本其实不在写代码,而在回归测试。模型升级一个版本,可能对技能描述的理解就变了。我在项目里维护了一个最小用例集,每个技能配三条典型输入:
- 一条最标准的触发输入。
- 一条带干扰词的输入(比如夹杂了无关话题)。
- 一条边界输入(比如参数缺失、超长文本、空结果)。
模型每次升级、技能每次改动,都拿这三条跑一遍,看技能选择是否稳定、参数生成是否正确、返回结果是否合理。这套东西没有多高科技,但它能在上线前拦住八成回归问题。
6.2 用三个维度给技能打分
我给每个技能做了个简单的质量评分卡,每次评测汇总三个指标:
| 指标 | 含义 | 参考目标 |
|---|---|---|
| 命中率 | 相关场景下模型是否选对了技能 | ≥85% |
| 参数正确率 | 模型生成的参数是否通过 Schema 校验 | ≥90% |
| 执行成功率 | 技能内部逻辑是否正常返回结果 | ≥95% |
命中率低,说明描述语义不清晰;参数正确率低,说明参数 Schema 设计有问题;执行成功率低,说明技能实现有 bug。三个指标分开看,定位问题极快。
6.3 技能冲突的排序策略
当多个技能的描述都可能匹配当前用户输入时,模型的选择会是概率性的。我最后加了一个小策略:给每个技能加一个priority字段。在生成技能列表时按优先级排序,优先级高的技能排在前面。
实验下来,模型对排在前面的技能有轻微偏好。所以我把高权限、高成本(比如调 LLM 接口)的技能排在后面,把轻量、常用技能排在前面,成本降了一些,稳定度也没受影响。别小看这个顺序,它可能是成本优化的第一粒纽扣。
最后再分享一条个人经验:技能系统这种东西,最忌讳一开始就搭得特别复杂。我见过有人上来就设计技能编排引擎、技能依赖解析、技能热更新,结果一个月还没跑通一个真实技能。先让两三个技能跑起来,把装载、校验、日志、评测这套闭环走通,再往里面加技能,才是正道。agent-skills 做到现在,真正核心的代码量没超过一千行,但它把智能体从"啥都能干但啥都不稳"变成了"能力边界清晰、每一项都靠谱"。这个转变,比任何花哨的算法都值钱。