news 2026/10/20 7:57:42

为Agent装上双手:Skill技能系统设计与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
为Agent装上双手:Skill技能系统设计与工程实践

前四篇走下来,我们已经把Agent的主循环、记忆管理、LLM调用封装、上下文构建这些底座都搭得差不多了。如果只是到这里,这个Agent其实更像一个"能聊天的API壳子"——它能理解话,但做不了事。所以这一篇我要做的事情非常明确:给Agent接上"手",也就是把Skill(技能)能力落地。

我不打算堆概念,咱们直接按照工程落地的顺序来:先搞清楚Skill到底是什么、怎么设计接口,然后写注册表和调度器,最后接两个实际技能跑通全流程,再把我实际操作中踩过的坑一并交代干净。

1. Skill是什么:先给Agent装上"手"

如果你一直在做LLM应用,大概率已经听过Function Calling或者Tool Calling这几个词。Skill和它们的关系,我尽量用一句话讲明白:Function Calling是一种协议,Skill是Agent侧的能力抽象。两者不是替代关系,Skill实现完之后,底层仍然可以借助Function Calling把参数从LLM那边要过来。

1.1 为什么不是直接复用Function Calling

最早做Agent功能扩展的时候,我也图省事,直接在系统提示词里堆了一堆"你可以调用以下函数",然后把函数塞给LLM。结果一旦技能数量超过5个,模型就开始犯迷糊:选错函数、编造参数、重复调用,什么毛病都有。这个问题的根子在于——你把底层的函数列表直接暴露给了模型,但模型并不知道这些函数之间的边界、适用优先级和失败处理策略。

Skill做的事情是给这些可调用能力加上一层"边界抽象"。一个Skill是完整的行为单元,它包含名称、描述、输入参数定义、实际执行逻辑、超时策略、返回结果规范。Agent在调度技能时,看到的是一份"技能清单",而不是一堆裸函数。好处有三点:

  • 技能的增删不影响主循环代码结构,完全是配置化。
  • 每个技能可以独立测试,不用为了验一个函数把整个Agent跑起来。
  • 调度策略可以放在Agent这层统一控制,比如并发限制、幂等控制、优先级排序。

1.2 一个Skill到底长什么样

我设计Skill的时候参考了插件系统的常见做法,把它定义为"描述符 + 执行器"两部分。描述符负责说明技能是干嘛的、什么时候该用、需要什么参数;执行器负责真正干活。两者分开很重要,因为调度决策完全依赖描述符,执行器只在实际被选中时才触发。

下面是我目前用的Skill基类定义,参考了业界常见的Skill抽象思路:

class BaseSkill(ABC): """技能基类:所有Agent技能都需要继承并实现以下接口""" # 技能唯一标识,全工程不得重复,例如 "weather_query" name: str = "" # 技能的自然语言描述,必须写清楚什么时候该调用 # 这是调度器的核心判断依据,能用两句说清就别写三句 description: str = "" # 技能适用标签,用于按场景过滤,例如 ["tool", "query"] tags: list[str] = [] # 参数JSON Schema,按OpenAPI规范 # 尽量把required、enum、minimum这些约束写全 parameters_schema: dict = {} # 超时时间,单位秒,防止技能卡死拖垮主循环 timeout: int = 10 # 技能版本号,方便后续热更新排查问题 version: str = "1.0.0" @abstractmethod def execute(self, params: dict) -> str: """执行技能,params为校验后参数,返回值必须是可被LLM理解的字符串""" ...

描述符字段里我最想强调的就是description。这一串字的优先级,比代码实现本身还高。调度器是做自然语言匹配,description写得含糊,Agent就会在错误的时候调用这个技能。新加入团队的开发者最容易忽略这一块——代码跑通就觉得完事了,实际上description信息质量不行,后面调度准确率一定崩,这就是Agent的"技能够用但不好用"的源头。

2. Skill的中心化注册表设计

有了Skill定义,下一步是让Agent知道"我有哪些技能可用"。我直接采用中心化注册表的方案,主要原因是简单可靠。所有技能在启动阶段注册到一个统一Registry中,Agent运行过程中不再动态增删,避免了一堆并发问题。

2.1 为什么非要"注册制"

有人可能会问:为什么不直接扫目录、遇到py文件就自动加载,省得每次都要手动注册?我一开始也这么干过,搞了一个自动发现机制,结果模块加载顺序、循环依赖、重复注册这三个问题轮番咬人,排查起来非常痛苦。

注册制看着多写一行代码,但收益非常明确:启动时能立即发现冲突,技能列表全局可见,调试的时候可以直接打印所有注册项。像这种规模不大的架构,显式优于隐式,能省掉后面大量的灵异问题。

2.2 注册表的数据结构与核心API

我给的注册表实现如下,核心数据结构就两个:_skills字典加上_sorted_names列表。

class SkillRegistry: """技能注册表:管理所有可用技能,支持按名称和场景查询""" def __init__(self): self._skills: dict[str, BaseSkill] = {} self._sorted_names: list[str] = [] self._initialized = False def register(self, skill: BaseSkill): if not skill.name: raise ValueError("Skill name cannot be empty") if skill.name in self._skills: raise ValueError(f"Duplicate skill: {skill.name}") self._skills[skill.name] = skill self._rebuild_sorted_names() def unregister(self, skill_name: str): if skill_name in self._skills: del self._skills[skill_name] self._rebuild_sorted_names() def get(self, skill_name: str) -> BaseSkill | None: return self._skills.get(skill_name) def list_skills(self) -> list[BaseSkill]: return [self._skills[name] for name in self._sorted_names] def _rebuild_sorted_names(self): # 按名称排序,保证每次遍历顺序稳定,后续可在此扩展优先级逻辑 self._sorted_names = sorted(self._skills.keys())

list_skills返回的顺序就是后续调度器构建提示词的顺序,所以稳定性很重要。每跑一次技能顺序乱跳,LLM就很容易选错。所以我干脆在注册表内部统一排好序。

2.3 启动时的自动扫描装配

注册表有了,Agent初始化时要把所有技能装配进来。这里我建议单独写一个build_agent_with_default_skills()这样的工厂函数,不要在__init__里一股脑塞逻辑。

def build_agent_with_default_skills(): agent = Agent() registry = SkillRegistry() # 注册内建技能 registry.register(WeatherSkill()) registry.register(CalculatorSkill()) registry.register(SystemInfoSkill()) # 挂载到Agent agent.attach_registry(registry) return agent

跑起来之后,我通常会加一个自检方法,把所有技能列出来打印一遍。以前我懒,觉得没必要,结果后来有次部署少注册了一个技能,线上日志里Agent反复说"我不能查询天气",排查了半小时才发现是漏注册。先自检,再出门,这个习惯现在雷打不动。

3. 调度器:Agent如何命中并调用Skill

注册表是骨架,调度器才是让技能活起来的关键。我的调度设计分三层:先粗筛,再精排,最后执行校验。这样比一次性让LLM从N个技能里挑一个靠谱得多——N超过10的时候,一次性选择的准确率下降非常明显。

3.1 触发链路简化版

一次完整的技能调用链路是这样的:

  1. Agent从LLM拿到用户意图文本(比如"北京今天冷不冷")。
  2. 调度器遍历注册表,做初步场景过滤,去掉明显不相关的技能。
  3. 剩余技能的描述与意图做相似度排序,取名。
  4. Agent把候选技能列表+用户输入交给LLM,让它做最终选择并产出参数JSON。
  5. 调度器校验参数,执行技能,把结果塞回上下文。

这五步里,第二步和第四步最容易出问题。第二步筛得太激进会把正确答案筛掉,筛得太松等于没筛;第四步则完全依赖LLM对JSON的理解能力,所以参数Schema一定要写得像教科书一样清楚。

3.2 描述相似度匹配的具体实现

我不想引入重型的向量检索,前期用轻量文本相似度就够了。我采用基于difflib的SequenceMatcher实现一个粗糙的相似度打分,再配合关键词命中加权,日常使用已经能拿到比较靠谱的候选集了。

from difflib import SequenceMatcher def compute_similarity(query: str, skill_description: str) -> float: """计算用户输入与技能描述之间的基础相似度""" base_score = SequenceMatcher(None, query, skill_description).ratio() # 关键词加权:如果技能描述中的核心词出现在用户输入里,加分 keyword_boost = 0.0 for kw in ["查询", "计算", "打开", "设置", "天气", "日期"]: if kw in query and kw in skill_description: keyword_boost += 0.1 return round(min(1.0, base_score + keyword_boost), 4) def filter_candidates( query: str, skills: list[BaseSkill], top_k: int = 3, threshold: float = 0.25 ) -> list[BaseSkill]: """先按相似度粗筛,返回得分最高的top_k个候选技能""" scored = [(compute_similarity(query, s.description), s) for s in skills] scored.sort(key=lambda x: x[0], reverse=True) return [s for score, s in scored if score >= threshold][:top_k]

阈值0.25是我试出来的经验值。设太高,语料稍微偏一下就什么都筛不到;设太低,候选集总是混着七八个不相关技能,增加了LLM误选概率。

需要说明的是,SequenceMatcher的优势是零依赖、跑得快,缺点是它只做字面相似,遇到同义词就歇菜。如果技能多了,且用户的表达风格变化很大,后期还是得上向量检索。但这不影响现在的架构迁移——把compute_similarity换成向量模型接口就行了,上层不用动。

3.3 参数绑定与LLM自动填充

参数这块是最能体现Agent"自动化"魅力的环节。传统函数调用要求调用者显式传参,但Skill调度可以让LLM根据上下文自动生成参数。

我推荐的做法是让LLM按这个JSON格式输出:

{ "skill_name": "weather_query", "params": { "city": "北京", "date": "2025-03-10" } }

收下这段JSON之后,调度器先做几个检查:技能是否存在、参数是否齐全、类型是否正确。全部通过才执行。为了保险,我还包了一个手动校验函数:

def validate_params(skill: BaseSkill, params: dict) -> dict: """根据技能参数Schema校验并返回清洗后的参数,失败抛出ValueError""" props = skill.parameters_schema.get("properties", {}) required = skill.parameters_schema.get("required", []) missing = [r for r in required if r not in params] if missing: raise ValueError(f"Skill {skill.name} missing required params: {missing}") cleaned = {} for key, value in params.items(): if key not in props: continue cleaned[key] = value return cleaned

之前图省事,跳过了校验直接执行,结果LLM生成了一个负数温度给天气查询技能,返回结果直接不符合业务预期,排查半天发现源头是参数没校验。校验这步就是给Agent加保险丝,必须留着。

4. 完整实战:给Agent接入天气与计算Skill

原理解完,来点实际的。我选两个特色鲜明的技能:天气查询适合展示"描述符怎么写才能被准确命中",计算器适合展示"参数校验与返回值格式化"。

4.1 实现一个本地天气查询Skill

真实天气服务需要申请第三方API,这里我用一个本地写死的模拟数据源来演示。这样你复现的时候不用配任何密钥,跑起来马上就能看到效果。

class WeatherSkill(BaseSkill): name = "weather_query" description = ( "查询指定城市在指定日期的天气情况,包括天气状况、气温和风力。" "适用于用户问某地今天/明天/后天天气,或某地某日天气的请求。" "如果用户没有明确日期,默认返回今天的天气。" ) tags = ["query", "weather"] timeout = 8 version = "1.0.0" parameters_schema = { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,如北京、上海、广州" }, "date": { "type": "string", "description": "日期,YYYY-MM-DD格式,不传则取当天" } }, "required": ["city"] } def execute(self, params: dict) -> str: city = params["city"] # 实际项目中这里替换为真实API请求即可 mock_data = { "city": city, "date": "2025-03-10", "weather": "晴", "temperature": "18°C ~ 24°C", "wind": "东北风3级" } return f"{mock_data['city']} {mock_data['date']},天气{mock_data['weather']}," \ f"气温{mock_data['temperature']},{mock_data['wind']}。"

这里有个细节值得注意:我把description写得非常具体,不只是说"查天气",还加了"适用于……"的正例。为什么?因为早期版本我没写后半句,结果用户问"北京冷不冷"时,模型完全不触发这个技能,而是自己脑补回答。技能描述里加触发条件示例,命中率立刻上了一个台阶。

另一个关键点:execute函数的返回值必须是纯文本字符串,不要返回dict。因为后续Agent要把这个结果拼进上下文重新喂给LLM,剥掉格式层能让LLM理解得更顺畅。如果你确实需要结构化数据,在外层封装一处序列化就行,不要扔给LLM一个dict。

4.2 实现一个计算Skill

计算器技能看起来简单,但它是检验参数校验能力的好场景。

class CalculatorSkill(BaseSkill): name = "calculator" description = ( "执行数学计算,支持加减乘除、幂运算等基本算术。" "适用于用户要求计算数值、表达式求值的场景,例如'15乘以3等于多少'。" ) tags = ["tool", "math"] timeout = 5 version = "1.0.0" parameters_schema = { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,如 (12 + 4) * 3" } }, "required": ["expression"] } def execute(self, params: dict) -> str: expr = params["expression"] # 注意:这里使用eval存在安全风险,仅限可信场景使用 # 生产环境建议换成表达式解析库 try: result = eval(expr, {"__builtins__": {}}, {}) return f"{expr} = {result}" except Exception as e: return f"表达式计算失败: {e}"

这里的eval我特意注释了风险。实际工程里不要直接裸用eval,用一个第三方表达式解析库更稳妥。但我写这个Skill的目的是展示技能描述和参数Schema的写法,所以未做重型安全处理。

如果你要复现这个示例,把计算器里加一个安全限制:只允许数字、四则运算符、括号和小数点。恶意的表达式输入在真实项目里不是玩笑,一旦暴露到线上Agent,这就是代码执行漏洞。

4.3 跑通全流程的实测

技能写好了,我把它们接入Agent后跑了一轮完整测试。用户输入是:

"我想算一下45和67的和,另外北京天气怎么样?"

Agent主循环跑出来的过程日志大致如下:

1. parse intent: 用户同时请求了计算和天气两个技能 2. skill scheduler: candidate skills = [calculator, weather_query] 3. llm choose: skill_name=calculator, params={"expression": "45+67"} 4. calculator result: "45+67=112" 5. scheduler: candidate skills = [weather_query] 6. llm choose: skill_name=weather_query, params={"city": "北京"} 7. weather result: "北京 2025-03-10,天气晴,气温18°C ~ 24°C,东北风3级" 8. final answer: "计算结果是112;北京今天天气晴,气温18°C ~ 24°C。"

一个比较惊艳的点是,Agent能自动拆分两个连续请求并分别调度技能,不需要用户把指令拆成两句话。这是因为调度器把"候选技能列表 + 请求原文"一起给LLM时,模型能推理出"应该连续调用两个技能"。如果你的LLM版本在复杂多技能场景下效果差,建议把"一次只处理一个技能"写进提示词,宁可多轮,也不要让Agent贪心一把抓。

5. 接入Skill后必踩的四个坑

功能跑通只是第一步。以下几个问题,是我把技能数量从2个扩到15个之后陆续遇到的,每一个都是真金白银换来的教训,单独列出来供后来人参考。

5.1 描述写得太笼统导致误触发

有个技能叫reminder,我最初description写的是"提醒用户事项"。听起来没毛病,但实际运行中,"你记得今天下午有会吗?"这句话被命中成了需要创建提醒。直接后果:Agent把用户的询问当作建立提醒的指令,回复变成了“已为您创建下午会议提醒”。

最后我把description改成了:

只有在用户明确要求设置一个提醒、闹钟、定时通知时使用。查询已有提醒、询问日程内容时不要使用本技能。

多加了"不要使用"之类的反向约束后,误触发几乎绝迹。给技能写描述的时候,一定同时写正例和反例,让调度器"知道不该做什么"和"知道该做什么"同等重要。

5.2 参数定义不清晰让LLM传参靠猜

早期天气技能的Schema里,city字段我写的是"城市"两个字。看着没毛病,但LLM经常传成"北京市"或者"北京(朝阳区)"。后面改成"标准城市名,不要带省/市后缀,例如北京、上海",参数清洁度立刻从及格变成优秀。

凡是"容易让模型自由发挥"的字段,都要白纸黑字标注例子。尤其是日期、时间、数量这类取值范围宽泛的参数,必须明确格式。比如日期统一YYYY-MM-DD,数字统一小数或整数,布尔值统一true/false。你不规定,模型就会用最顺手的方式帮你发挥。

5.3 技能执行超时拖死主循环

有一次天气API上游服务慢,原本几十毫秒的响应变成了40秒超时。Agent主循环是同步等待技能返回的,这期间整个Agent卡死,用户问题也没人回答。

解决方案是在调度器里给技能执行加asyncio.wait_for,或者用线程池配合concurrent.futures.TimeoutError。技能执行必须跑在独立上下文环境里,即使卡死,也只影响当前这一轮调用,不能拖垮整个进程。

我还在执行之后统一记录耗时日志。后面做性能分析时,这堆日志救了大命——一眼就能看出谁的P95耗时在恶化。

5.4 同义技能冲突时的优先级策略

技能多了之后,会遇到两个人做了差不多功能的技能。典型例子是calendar_query和scheduler_check,都能回答"我明天有没有会议"。两个技能同时进入候选集,LLM经常随便选一个,但返回信息格式不一样,导致Agent下一个环节处理出错。

我最终的解法很简单:给技能增加一个priority字段,数值越高越优先。调度时如果候选集里多个技能都命中,按优先级取出最高的1个即可。优先级的设计原则是:核心工具类技能 > 领域业务技能 > 通用推理技能。底层思路是重覆盖轻,确保最直接、最可靠的能力优先被选择。

写在最后

Skill接入是Agent从"能聊天"跨越到"能干活"的关键一步。注册表保证了技能的可管理性,调度器保证了技能的选择和执行可控,描述符的规范化直接决定了技能的命中准度。这一篇涉及到的接口设计、调度策略,目前都在我自己的模拟项目X里稳定跑着,代码虽然不算复杂,但边界清晰,扩展新技能基本就是"写一个类 + 注册一行"的事。

接下来如果要继续往前走,我建议优先级最高的是给Skill层加上缓存与重试机制。同一个问题多次查询天气,没必要每次都请求上游API;而技能执行失败时,能不能自动重试或降级到备选技能,这将是Agent稳定性的下一块试金石。

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

JMeter 5.6.3实战:高并发接口压测与瓶颈定位指南

直接说结论:JMeter 5.6.3是目前做HTTP接口压测最实用的开源工具,没有之一。它不挑操作系统、不挑协议类型、不需要写复杂代码,只要你能看懂测试计划里的线程组和监听器,就能在半天内搭出一套能发现系统瓶颈的高并发压测方案。这篇…

作者头像 李华
网站建设 2026/10/20 7:57:05

Notepad++正则批量给十六进制数加0x前缀的完整指南

第一次看到“在Notepad里给十六进制数加0x前缀”这个问题时,我第一反应是“这还用问?查找替换嘛”。真正做的时候才发现,十六进制数的边界、0x重复前缀、纯数字的十六进制怎么处理,每个细节都能让你白改一堆文件。这是一个很典型的…

作者头像 李华
网站建设 2026/10/20 7:56:40

给AI Agent造一个专属网站:用结构化事实层与Skills解决幻觉问题

1. 从"AI 又胡说八道了"说起:为什么我要给 Agent 造一个专属网站做 AI 应用开发这几年,最让我头疼的不是模型能力不够,而是它"太敢说"。你问它一个 API 的返回字段,它能一本正经地编出三个不存在的参数&#…

作者头像 李华
网站建设 2026/10/20 7:56:40

大模型面试必问RAG:核心原理、对比与实战避坑指南

最近在系统盘大模型应用方向的面经,发现一个绕不开的大头就是 RAG,检索增强生成。这个词几乎出现在每一场大模型相关面试里,不管是算法岗还是应用工程岗,面试官都喜欢从RAG切入,一路往下问。我本来以为原理听熟了就行&…

作者头像 李华
网站建设 2026/10/20 7:56:37

程序员转型AI系统设计避坑指南:从需求翻译到数据闭环的工程实践

1. 为什么程序员转型AI系统设计总踩坑1.1 从写业务代码到设计AI系统,中间隔着什么我带过不少从传统后端、前端转过来的同学,发现一个特别普遍的现象:大家学AI,第一反应是去啃Transformer原理、手推反向传播、把PyTorch的API背得滚…

作者头像 李华
网站建设 2026/10/20 7:56:16

CentOS 7实战:kubeadm搭建Kubernetes集群完整指南

搭 Kubernetes 集群这事儿,很多人一上来就想着去搞二进制部署、自己签证书,结果折腾两三天还没跑起来一个kubectl get nodes。其实在 CentOS 7 上,用 kubeadm 走一遍标准流程才是最稳的路径,尤其对于学习、测试、或者中小规模的生…

作者头像 李华