news 2026/10/13 5:03:36

智能体技能系统设计与实践:从工具调用混乱到可插拔技能模块

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
智能体技能系统设计与实践:从工具调用混乱到可插拔技能模块

1. 为什么智能体的能力上限取决于"技能系统"

1.1 从提示词堆砌到工具调用的演进:我踩过的弯路

先说个背景。我一直在做企业内部的知识库问答智能体,最开始的做法很粗暴:把所有工具说明、角色设定、回答规则全部塞进系统提示词里,指望大模型自己理解、自己调用。结果模型确实"理解"了,但理解得浮于表面——工具一多,它就晕。二十多个工具的说明堆在一起,提示词本身就有七八千 token,模型经常在"该调搜索工具还是该调文档工具"之间反复横跳,甚至明明该算日期,它却去调了天气接口。

后来我开始重新设计这套体系,核心就是"agent-skills"这个项目。它解决的是一个问题:让智能体的每一项能力都变成一个可插拔、可描述、可独立验证的技能模块,而不是一段写死在提示词里的咒语。技能(skill)在这里不是指模型的能力,而是指我们提供给模型的一个个可执行单元——每个单元有名字、有描述、有参数声明、有真实实现,模型根据任务语义动态挑选并调用它们。

如果你正在跟 Function Calling 搏斗,或者你的智能体工具一多就开始乱选,这篇文章值得看完。我会把这个项目里踩过的坑、用过的设计、实测到的数据都摊开讲。

1.2 技能、工具、工作流的边界在哪里

很多人容易把工具(tool)、技能(skill)、工作流(workflow)混成一团。我的划分是:

  • 工具是最底层的能力单元,比如"调用某个 HTTP 接口""读取某个文件""发送一封邮件"。
  • 技能是对工具的封装加上语义层,它告诉模型"什么场景下用我、需要什么参数、给你什么结果"。
  • 工作流是多个技能的组合编排,比如"先检索文档,再提取关键信息,最后生成周报"。

所以说,技能在中间这一层。它既不是裸的 API 包装,也不是复杂的业务流。它是给模型看的"能力说明书",同时是给开发者用的"功能模块"。agent-skills 这个项目做的就是技能这一层的管理工作:注册、发现、装载、校验、评测。

1.3 什么时候必须自建技能体系

不是所有项目都需要技能系统。如果你的智能体只调一个 API,那直接在代码里写死判断就行。但出现下面三个信号时,你差不多该动手了:

  1. 工具数量超过十个:提示词里已经塞不下完整工具列表,模型开始漏工具、错工具。
  2. 存在多轮组合调用:这次任务要调 A 工具得到结果,再根据结果决定调 B 还是 C,手写条件分支会变成意大利面条。
  3. 能力需要跨场景复用:多个智能体都要用"摘要"和"检索"能力,但各自维护一份实现会导致行为不一致。

我是在工具数到第十三个的时候决定重写的。重写后最大的感受是:把"模型该选哪个工具"这件事交给模型,把"工具是否被正确实现"这件事交回归测试,两者各司其职,问题就清楚多了。

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 项目里得到的最重要结论。

写好技能描述的几个实操要点:

  1. 开头直接写触发场景:第一句就是"当用户需要……时使用",而不是"本技能用于……"。
  2. 明确输入输出的语义:告诉模型它应该提供什么、会拿回什么。
  3. 避免模糊形容词:"高效地""智能地"这种词对模型毫无信息量。
  4. 控制长度:描述太长会挤占上下文,后面专门讲这个问题。

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 里定了两条规范:

  1. 技能函数默认无状态:同一份输入,调用两次结果一致(或者结果里包含"已执行"之类的幂等标记)。
  2. 需要副作用的操作,显式包装成独立技能:例如"发送邮件"是一个技能,但从"生成邮件内容"这个技能返回值里拿到草稿,再做二次确认真实发送。

这两条规则让我避免了一个很惨的线上事故:某个技能在重试时重复写入了两条工单。后来我把所有技能函数强制改为"只返回结果、不改变外部状态",副作用全部上移到编排层处理。

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: - url

3.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 错误处理的一个隐藏细节

技能执行失败时,返回给模型的信息要分层。我用了三层:

  1. 技能发现失败:技能不存在。
  2. 参数校验失败:告诉模型缺哪个参数、类型哪里不对。
  3. 执行失败:区分"可重试错误"和"不可重试错误"。

比如网络超时属于可重试错误,返回给模型的信息可以带上"该错误可能为临时性网络问题,可重试一次";而参数格式错误属于不可重试错误,提醒模型直接修正参数。这层信息让模型的自我纠错能力提升了非常多,因为模型拿到明确的错误归因后,修正动作不会乱来。

4. 接入真实业务后:某知识库问答智能体的效果对比

4.1 接入前的崩坏现场

我拿一个具体项目说数据。这是一个给某团队内部使用的知识库问答智能体,主要任务是查文档、总结周报、统计工单。改造前,系统全部工具共 22 个,全部平铺在提示词里。实测五十条典型问题:

  • 工具选择准确率大约在 62% 左右,经常出现"用户问工单统计,它去调知识库搜索"这种错配。
  • 单次请求的上下文长度经常超过 9000 token,其中工具描述占了三成以上。
  • 排错极难,你说它是调错工具了还是参数没传对?要翻整个 prompt 才能定位。

4.2 接入 agent-skills 后的变化

改造后,我做了两个调整:一是给所有技能按场景分组,每次实际生效的技能不超过 8 个;二是所有技能描述重写,统一为"触发场景 + 输入 + 输出"格式。同一批测试集,结果如下:

指标改造前改造后
工具选择准确率62%89%
平均单次请求上下文 token90003300
一次交互的平均耗时6.8s4.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 最小可复现用例集

技能系统的维护成本其实不在写代码,而在回归测试。模型升级一个版本,可能对技能描述的理解就变了。我在项目里维护了一个最小用例集,每个技能配三条典型输入:

  1. 一条最标准的触发输入。
  2. 一条带干扰词的输入(比如夹杂了无关话题)。
  3. 一条边界输入(比如参数缺失、超长文本、空结果)。

模型每次升级、技能每次改动,都拿这三条跑一遍,看技能选择是否稳定、参数生成是否正确、返回结果是否合理。这套东西没有多高科技,但它能在上线前拦住八成回归问题。

6.2 用三个维度给技能打分

我给每个技能做了个简单的质量评分卡,每次评测汇总三个指标:

指标含义参考目标
命中率相关场景下模型是否选对了技能≥85%
参数正确率模型生成的参数是否通过 Schema 校验≥90%
执行成功率技能内部逻辑是否正常返回结果≥95%

命中率低,说明描述语义不清晰;参数正确率低,说明参数 Schema 设计有问题;执行成功率低,说明技能实现有 bug。三个指标分开看,定位问题极快。

6.3 技能冲突的排序策略

当多个技能的描述都可能匹配当前用户输入时,模型的选择会是概率性的。我最后加了一个小策略:给每个技能加一个priority字段。在生成技能列表时按优先级排序,优先级高的技能排在前面。

实验下来,模型对排在前面的技能有轻微偏好。所以我把高权限、高成本(比如调 LLM 接口)的技能排在后面,把轻量、常用技能排在前面,成本降了一些,稳定度也没受影响。别小看这个顺序,它可能是成本优化的第一粒纽扣。


最后再分享一条个人经验:技能系统这种东西,最忌讳一开始就搭得特别复杂。我见过有人上来就设计技能编排引擎、技能依赖解析、技能热更新,结果一个月还没跑通一个真实技能。先让两三个技能跑起来,把装载、校验、日志、评测这套闭环走通,再往里面加技能,才是正道。agent-skills 做到现在,真正核心的代码量没超过一千行,但它把智能体从"啥都能干但啥都不稳"变成了"能力边界清晰、每一项都靠谱"。这个转变,比任何花哨的算法都值钱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 5:03:30

深入理解C++右值引用与移动语义:从概念到性能优化实践

1. 从值拷贝的性能痛点说起:右值引用到底改变了什么1.1 临时对象让程序白干活的典型场景我最早接触右值引用,是因为一个字符串处理的性能问题。代码很简单,就是用std::string拼接几个片段,最后把结果塞进一个全局容器里。性能分析…

作者头像 李华
网站建设 2026/10/13 5:03:25

深度学习视频行为识别实战:从数据准备到模型部署的避坑指南

简介:这份资源面向具备Python与深度学习基础、希望入门视频行为识别的开发者与研究者,提供了一套可运行的完整项目代码,用于从连续视频帧中自动检测和识别人类行为,可应用于智能监控、安全防范、体育赛事分析及人机交互等场景。压…

作者头像 李华
网站建设 2026/10/13 5:03:23

社区医疗服务系统毕设实战:SSM框架选型与核心功能实现全解析

2026年这届同学做毕业设计,看到“社区医疗服务系统”这个题目,我猜大部分人心里有两个想法:一是“这东西是不是被做烂了”,二是“SSM这套组合是不是早该退休了”。但恰恰是这两个看起来“过时”的标签,在2026年仍然是多…

作者头像 李华
网站建设 2026/10/13 5:03:16

轻量TopoJSON地图渲染器:零依赖SVG世界地图实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 5:03:04

从手工改数据到一键切换:银行模拟器搭建与演示实战

1. 从一个差点翻车的演示说起上次给客户演示一个银行类项目的交互原型,对方产品负责人在开屏前十分钟临时提了一个需求:“把主账户页的余额改成八十多万,我要看看大额数字在首页的排版效果。”那一瞬间我人有点麻。模板页面里的余额字段是写死…

作者头像 李华
网站建设 2026/10/13 5:02:58

Windows定时重绘的底层真相:WM_PAINT与GDI资源管理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华