☰
Agent技能体系全解析:从设计到实现,让智能体真正学会干活
2026/10/21 8:45:03 网站建设 项目流程

1. 为什么Agent需要“技能”这个东西

先说个场景。你让智能体帮你安排下周的团队周会,它嘴上答应得好好的,结果转头就去翻了半天日历,最后还是没把会议纪要模板塞进正文里。你让它去监控一个线上服务的日志异常,它盯着聊天框等你喂数据,根本不知道自己去拉日志接口。这种“会聊天但不会干活”的智能体,这两年我见过太多,问题不在模型智商,而在我们从来没给Agent一把趁手的“工具”。

围绕agent-skills这个概念,核心就一句话:把Agent能执行的能力从“写在对话逻辑里的死代码”抽离出来,变成一个个独立的、可描述、可复用、可编排的技能模块。你甚至可以把它理解成给Agent装了一套“外挂工具箱”——每个工具箱里的扳手、螺丝刀、电钻都是单独命名的,Agent读到你的任务描述后,自己去选该用哪个工具、按什么顺序用。

为什么这种方式能解决实际问题?因为大模型本身是“对话大脑”,不是“执行引擎”。让它通过自然语言去理解“帮我查天气”没问题,但真要它去调一个实时天气API,它既不知道接口地址,也不知道参数格式,更不知道怎么处理返回的JSON。而技能体系做的事情,就是把“接口地址、参数格式、返回值结构”这些脏活累活预先封装好,让Agent只需要学会“什么时候该调用哪个技能”,剩下的交给代码去执行。

这套思路适合谁来用?后端开发者可以把自己的服务能力暴露给Agent;前端工程师能把浏览器自动化封装成技能;产品经理能通过配置化方式给智能体加新能力;就算你不写代码,理解了技能的概念,也能更好地跟开发团队沟通需求。这篇文章我会从设计思路、接口规范、实际实现、踩坑排查四个维度,把agent-skills这套玩法的全貌拆开讲一遍,核心目标是让看完的人能自己动手把Agent从“聊天玩具”升级成“能干活的同事”。

2. 整体设计与方案拆解:技能的本质是一份给模型看的说明书

2.1 技能的组成:任意一个技能都由“自白”和“手脚”构成

拆开来看,一个完整的Agent技能包含两块:描述层(模型能读懂的那张说明书)和执行层(真实跑起来的代码)。两个缺一不可,少了任何一块,技能要么跑不动,要么Agent根本不知道你给它装了这玩意儿。

描述层要解决的问题是“让模型知道这个技能什么时候该被选中”。模型没有“记忆”,它每次决策都靠上下文推理,所以技能的name、description、参数Schema写得清不清楚,直接决定了模型的命中准确率。很多初学者容易犯一个错:给技能起名特别随意,描述写得模棱两可,比如把技能叫“smart_tool”,描述写“一个有用的工具”。模型看到这种东西完全摸不着头脑,最后宁可回答“我做不到”也不会去调用它。

执行层解决的是“真正把事情做完”。写一个Python函数,接收模型按Schema传入的参数,执行真实的逻辑,再返回结构化的结果给模型。这里有个容易被忽略的点:执行层要处理“模型理解结果”的问题。你的代码可以返回任何内容,但最好把结果整理成简洁、关键信息完整的文本,方便模型直接理解并组织自然语言回答。

2.2 为什么选“注册式”而不是“写死式”

早期做Agent功能,大家的普遍做法是直接在系统提示词里写死“如果用户提到查天气,就讲调用weather_api()这个函数”。这种方案在小Demo里没问题,一旦技能数量超过十个,系统提示词就会膨胀,模型注意力被稀释,命中率直线下降。而且每次加一个新技能,都要把整个Agent逻辑翻出来改一遍,风险高得离谱。

agent-skills的核心改进是“注册式”——所有技能独立成文件、独立实现、独立测试,然后通过一份注册表声明它们的存在。Agent运行时只需要读取注册表,把所有技能的描述信息动态拼进上下文,模型按需选择。加新技能不需要碰主流程代码,删技能也不需要,这种解耦带来的维护收益,只有经历过提示词堆成山的人才能体会。

另外注册式方案天然适合“多Agent共享”。同一个公司里可能有客服Agent、运营Agent、数据分析Agent,它们其实需要大量公共技能(查订单、查库存、拉报表)。把技能做成独立模块后,三个Agent可以挂同一套技能库,各自注册不同的访问权限即可,开发成本从“每个Agent搞一套”降到“一套技能到处复用”。

2.3 技能与传统插件、工具函数的边界在哪里

很多人会问:这不就是函数调用(Function Calling)吗?跟我以前写的工具函数有什么区别?

我的理解是,函数调用是单次交互里模型选择某个函数的能力,它解决的是“这一次对话我调谁”;而技能体系是在函数调用之上盖了一层“工程化壳子”,把函数的元数据、参数校验、注册发现、权限控制、日志追踪全部标准化。你可以把技能看作是“为Agent长期复用而设计的函数”,而普通的函数只是“为单次需求随手写的代码”。

还有一个容易被忽略的边界是“技能的粒度”。一个技能应该是一次完整的能力输出的原子单元,比如“发送邮件”是一个技能,而不是“连接SMTP”“编写邮件正文”“点击发送”三个技能。粒度太细会让模型的选择成本变高,每次都纠缠在低级步骤上,反而容易出错;粒度太粗则灵活性不足,比如“处理邮件”这个技能,模型根本不知道该用在哪一步。实践中建议把技能控制在“人类看到名字就能脑补出这个功能是干嘛的”这个粒度。

3. 核心细节解析与实操要点:把描述写清楚比写代码更难

3.1 技能描述是给模型看的“使用说明书”,不是给人看的

我见过太多人写技能描述时带着写代码注释的习惯,描述写得短而晦涩。但别忘了,技能的读者是模型,模型需要通过描述来判断“当前这个用户意图跟我这技能匹不匹配”。所以描述里必须包含三个要素:功能边界、适用场景、典型触发词。

用一个我最近做的“日程管理技能”当例子。功能是给用户创建、修改、查询日程。初版的description我写的是“manage user schedules”,结果命中率只有四成左右,大量“帮我明天上午十点定个闹钟”“提醒我周五交报告”这类和日程沾边的请求没被路由到。后来我把description改成这样:

description: > 创建、修改、查询、删除用户的日程事件。 适用于用户要求安排会议、设置提醒、查看某一天的安排、 调整活动时间等场景。典型的触发关键词包括:日程、提醒、 安排、预约、改时间、几点有什么。不适用于闹钟类重复性提醒。

这版描述上线后命中率直接跳到八成。核心原因是把“适用场景”和“典型触发词”显式写出来了,模型在做匹配时不用靠猜。还有一个值得注意的点:我在描述结尾写了“不适用于闹钟类重复性提醒”,这是在帮模型做负向排除,减少误调用的概率。实践下来,描述里加一点负向约束,效果通常比只写正向能力要好。

3.2 参数Schema要按“模型生成友好型”设计

如果说描述决定模型“选不选”,那么参数Schema决定模型“能不能填对”。这里有个很微妙的设计矛盾:Schema写得太严,模型生成参数时容易报错;写得太松,你执行层又收不到有效输入。解决办法是把Schema分成两层:必填靠程序硬校验,选填靠模型自由发挥。

以日程创建的Schema为例:

{ "type": "object", "properties": { "title": { "type": "string", "description": "日程标题,例如:周会同步" }, "start_time": { "type": "string", "description": "开始时间,ISO 8601格式,例如2025-06-01T09:00:00" }, "end_time": { "type": "string", "description": "结束时间,ISO 8601格式" }, "attendees": { "type": "array", "items": { "type": "string" }, "description": "参与人邮箱或昵称,可空" }, "remind_minutes": { "type": "integer", "description": "提前提醒分钟数,默认10" } }, "required": ["title", "start_time", "end_time"] }

有几个细节值得展开。一是必填字段要尽量少,我给这个技能只留了三个必填,attendees和remind_minutes都是可选的。只要你把description写清楚,模型通常能根据上下文补齐合理值,但如果必填字段本身不明确,模型就会陷入“猜用户意图”的困局,猜错的概率很大。二是每个字段的description要给出具体格式示例,比如时间格式,你不给示例,模型很容易输出“明天上午9点”这类自然语言,到了执行层你的解析代码又得兜底。

对比一下常见的反面写法:有人把start_time的类型设成integer时间戳,描述也不写格式。模型推测不出用户说的“明天上午10点”该换算成秒级还是毫秒级时间戳,一旦调错,创建一个日程就产生一个1970年的幽灵事件。这种问题不是在执行层打补丁能解决的,得在设计Schema的时候就考虑“模型生成的成本”。

3.3 结果返回要“给模型省脑子”而不是“丢一堆数据”

技能执行完返回什么内容给模型,这是很多人忽视的第二道坎。执行层返回的数据越原始、越冗长,模型整理成自然语言时的出错概率就越高。我给团队定的规范是:所有技能返回结果必须是一段已经整理好的人类可读文本,重点信息前置,不要返回未处理的JSON堆给模型。

继续用日程技能举例。后端创建的日程事件在数据库里有一个包含created_at、updated_at、raw_data等十几个字段的对象,你不能直接把这个结构体序列化后丢给模型。我的做法是在执行层写一个结果格式化函数,把它浓缩成以下文本:

日程「产品评审会」已创建成功。 时间:2025-06-01 14:00:00 ~ 15:30:00 参与人:5人 提醒:提前15分钟 如需修改或取消,请告诉我日程ID:EVT-20250601-001

这样模型拿到的就是结构化、顺序清晰的一句话概要,它在给用户组织回复时几乎不用推理,甚至可以直接复述。我还特意在结尾加上了日程ID,这是引导用户做后续操作的一种手段:模型看到ID,用户追问“改到三点”时,它就能更准确地调用更新技能,而不是靠重新匹配。

3.4 技能的命名直接决定Agent的“第一反应”是否准确

命名这件事,单独拎出来说是因为它对命中率的影响常常被低估。技能名会直接出现在模型的选择列表里,命名越贴近自然语言直觉,模型选择的置信度越高。

我踩过的坑是把技能名写成“calc_revenue_metrics_v3”,模型在面临“看一下这季度营收”的请求时,完全不知道要选这个技能,因为它根本不知道“calc”和“revenue”和“metrics”拼起来是什么意思。后来我改成“calculate_business_revenue”,命中率立刻上来了。

命名的核心规则就三条:动词开头、功能完整、不要带版本号。动宾结构是模型最熟悉的模式,比如“send_email”、“create_meeting”、“fetch_stock_price”;绝对不要在名字里挂v1、v2或者“new”“old”这类修饰词,版本信息应该放在技能的metadata里维护,而不是污染名字。

4. 实操过程与核心实现:五分钟搭一套可用的技能注册中心

4.1 最小可运行的技能文件结构

聊完了设计原则,下面演示一套最小但完整的实现方案。我不搞抽象,直接写一个真实的“会议日程创建”技能,从目录结构到代码一行行过。

典型的技能目录结构大概是这个样子的:

agent_skills/ ├── registry.yaml # 技能注册表:描述所有技能的位置与依赖 ├── skills/ │ ├── create_schedule/ │ │ ├── skill.yaml # 技能的描述文件(模型看的说明书) │ │ ├── __init__.py # 技能入口,暴露 StandardSkill 接口 │ │ └── impl.py # 实际执行逻辑 │ ├── send_notification/ │ │ ├── skill.yaml │ │ ├── __init__.py │ │ └── impl.py

关键设计是每个技能目录完全独立,技能之间不互相引用,只依赖外部的共享库。这样做的好处是复制、删除、上线一个技能的成本降到了最低——你只需要新建一个目录,写两个文件,然后把这个目录挂到注册表里。

先看技能描述文件skill.yaml,这是模型的“说明书”:

name: create_schedule version: 1.0.0 description: > 创建一条新的日程事件。适用于用户要求安排会议、预约时间、 设置提醒、查看某一天的安排等场景。典型触发说法: “帮我约个会”“下周一下午安排讨论”“创建日程”“定个时间”。 parameters: type: object properties: title: type: string description: 日程标题,例如:产品评审会 start_time: type: string description: ISO 8601格式的开始时间,例如2025-06-01T14:00:00 end_time: type: string description: ISO 8601格式的结束时间,示例同上 attendees: type: array items: type: string description: 参与人列表,可空 remind_minutes: type: integer description: 提前提醒分钟数,默认10 required: - title - start_time - end_time

这个yaml文件本质上就是前面讲的设计原则的落地:描述里写了功能边界、适用场景、典型触发词和负向排除,参数Schema每个字段都给了格式示例。

然后是实现文件impl.py,真正的执行逻辑放在这里:

import json from datetime import datetime, timedelta class CreateScheduleSkill: def __init__(self, context): # context 由运行时注入,里面包含配置信息、日志实例等 self.context = context self.storage = context.get("storage_client") self.logger = context.get("logger") def execute(self, parameters): # 参数校验与规范化 title = parameters.get("title", "").strip() start_time = self._parse_time(parameters.get("start_time")) end_time = self._parse_time(parameters.get("end_time")) attendees = parameters.get("attendees") or [] remind_minutes = int(parameters.get("remind_minutes", 10)) if not title: raise ValueError("日程标题不能为空") if end_time <= start_time: raise ValueError("结束时间必须晚于开始时间") # 调用业务服务创建日程 event_id = self.storage.create_event( title=title, start_time=start_time, end_time=end_time, attendees=attendees, remind_minutes=remind_minutes, ) # 返回给模型的文本结果 return self._format_result(event_id, title, start_time, end_time, attendees, remind_minutes) def _parse_time(self, raw_value): if isinstance(raw_value, str): return datetime.fromisoformat(raw_value) return raw_value def _format_result(self, event_id, title, start_time, end_time, attendees, remind_minutes): return ( f"日程「{title}」已创建成功。\n" f"时间:{start_time:%Y-%m-%d %H:%M} ~ {end_time:%H:%M}\n" f"参与人:{len(attendees)}人\n" f"提醒:提前{remind_minutes}分钟\n\n" f"如需修改或取消,请告诉我日程ID:{event_id}" )

4.2 注册中心的实现:让Agent动态发现技能

有了单个技能,下一步是把它们注册到一个中心,让Agent运行时能动态发现、动态加载。注册表registry.yaml的作用就是统一登记技能名称、入口模块、依赖配置。下面是一个示例:

skills: - name: create_schedule module: skills.create_schedule enabled: true timeout_seconds: 10 - name: send_notification module: skills.send_notification enabled: true timeout_seconds: 5

对应地,我在运行时实现了一个简单的注册器:

import importlib import yaml class SkillRegistry: def __init__(self, registry_path, context): self.context = context with open(registry_path, "r", encoding="utf-8") as f: self.registry_data = yaml.safe_load(f) self._skills = {} def load_all(self): for item in self.registry_data["skills"]: if not item.get("enabled", True): continue module = importlib.import_module(item["module"]) skill_class = getattr(module, "StandardSkill") skill_instance = skill_class(self.context) skill_instance.timeout_seconds = item.get("timeout_seconds", 10) self._skills[item["name"]] = skill_instance def get_skill_metadata(self): """供运行时生成技能说明列表,注入进模型上下文""" metas = [] for name, skill in self._skills.items(): metas.append({ "name": name, "description": skill.description, "parameters": skill.parameters }) return metas def execute(self, skill_name, parameters): skill = self._skills.get(skill_name) if skill is None: raise KeyError(f"技能 {skill_name} 未注册") return skill.execute(parameters)

加载流程分成三步:读注册表、动态import技能模块、把技能实例化放到内存字典。get_skill_metadata方法是我强烈建议保留的,因为Agent运行时每次对话都通过它把“当前所有可用技能的介绍”注射到系统提示词里,模型才能“看到”有哪些技能可用。这一步是agent-skills体系最灵魂的设计:技能的发现不靠模型记忆,全靠上下文注入。

4.3 与Agent主循环的衔接:一次完整的调度过程

技能注册好之后,Agent的主循环怎么用?我在这里给出一个极简的调度处理流程,方便理解整体闭环:

def agent_loop(user_message, registry): # 步骤1:构造系统提示词,把技能元数据注入 skill_metadata = registry.get_skill_metadata() system_prompt = "你是一个智能日程助手。当前你可以使用以下技能:\n" for meta in skill_metadata: system_prompt += f"- {meta['name']}: {meta['description']}\n" # 步骤2:调用模型,期望模型返回技能调用指令 response = call_llm(system_prompt, user_message) # 步骤3:如果模型返回需要调用技能,则执行并回填结果 if response.has_tool_call: skill_name = response.tool_call.name args = response.tool_call.arguments result = registry.execute(skill_name, args) # 步骤4:把执行结果作为新的上下文,让模型继续组织用户回答 final_answer = call_llm(system_prompt, user_message, tool_result=result) return final_answer.text else: # 模型没有请求技能,直接返回回答 return response.text

这个流程虽然经过了简化,但覆盖了整个闭环:注入技能列表到模型上下文、模型决定调用哪个技能及参数、代码执行真实的业务逻辑、结果再次交给模型组织语言并回复用户。回头看,核心复杂度全在技能的包装层,真正的主循环反而异常简单。这也是我推荐大家按这种方式重构Agent的原因——主逻辑保持稳定,扩展能力时只加技能目录,风险控制得很干净。

5. 常见问题与排查技巧实录:技能上线后一定会遇到的坑

5.1 模型完全不调用技能

这是新手接入时最高频的问题,表现是技能明明注册了、描述也写了,但模型就像没看见一样,回答时仍然硬编。排查顺序我一般分为三步。

首先看技能元数据到底有没有真的注入到系统提示词里。很多人以为注册了就完事了,实际运行时把注入那一步漏了或者路径写错,技能从未出现在模型眼前。可以通过打印运行时system_prompt的内容来确认。

其次看描述是不是跟用户表达差异太大。比如你把技能起名叫“generate_document”,描述里全是“输出专业文档”,但用户真实说法是“帮我写个方案”“搞个报告”,模型在这个匹配场景里就会犹豫,宁可泛泛回答。解决方式是在描述里显式罗列用户的常见口语触发词。

最后看是不是技能数量过多导致选择困难。当可用技能超过二十个时,部分模型的注意力会被稀释,导致一些不那么常见的技能被忽略。我尝试过把技能列表做分组,每组分一个组名和一句话简介,让模型先选组再选技能,命中率有回升。如果你的技能数量已经上规模,强烈建议这么做。

5.2 模型生成了参数但执行层报错

参数报错大体分两类:格式问题和缺失问题。格式问题的经典案例是时间字符串五花八门,模型有时候输出“2025-06-01 14:00”,有时候输出“2025年6月1日下午两点”,如果你只支持ISO格式,解析必然会炸。我的建议是执行层必须做一层“宽容解析”:先尝试标准格式解析,失败后用一系列正则兜底,再把模型常见变体全部映射过去。不要指望模型永远听话,它只会越来越不按常理出牌。

缺失问题通常是必填字段没有被模型正确提取。排查办法是查看模型实际传的参数JSON,确认它在生成时是否“忘”了某个字段。如果是高频发生的,我的做法是回到Schema,把缺失字段的description写得更自然,比如“start_time,ISO 8601格式,例如2025-06-01T14:00:00”比单纯写“开始时间”要有效得多。还有个辅助手段是允许模型传一个“default_now”作为占位符,在执行层解析时补上当前时间,但这个方法要慎用,容易掩盖真实意图。

5.3 技能返回结果太长或太乱,模型复述失真

这类问题的根源是执行层返回的内容没有做好“面向模型”的整理。处理方式见前面3.3节遵守的规范——所有返回文本应控制在三到五行内,关键信息按重要级排列,尾部给出后续操作线索。如果业务数据本身确实很多,比如查询订单列表有几十条,我给的建议是不要全部返回,而是返回汇总统计信息加上前五条明细,再告诉模型“如果用户需要更多,可通过分页技能补传”。这样既控制了上下文长度,又保持了交互的延展性。

5.4 技能之间的上下文污染问题

当Agent在单次对话里连续调用了多个技能时,前面的返回结果会留在上下文里,可能导致后续的技能选择受到干扰。比如用户先查了天气,又问了日程,模型可能因为前一个技能的结果里含有“温度”“湿度”等词,就把后续的日程查询也错误路由到天气技能。

我用的缓解方案是为每个技能返回结果打一个标签,明确写清楚“以下结果来自技能X的执行”,并且尽量在返回开头就使用高区分度词汇,使不同技能的输出在语义空间里拉开距离。另外,在主循环里我增加了上下文修剪逻辑:一旦模型执行完一次技能调用,就把该技能的详细返回结果压缩成一句摘要,再塞回上下文,可以显著降低后续轮次的污染概率。

下面整理一个常见问题速查表,方便日常排查:

现象优先排查点处理建议
模型从不调用技能技能元数据是否注入到system prompt打印运行时提示词,确认描述可见
技能命中率低描述缺乏触发词与场景按3.1节重写描述,加口语触发词
参数反复缺失Schema必填字段定义过弱必填尽量少,每个字段给格式示例
时间解析崩溃输入格式不统一执行层加宽容解析器,格式兜底
返回内容太长缺少面向模型的结果摘要遵守三五行文本规范,加后续操作线索
技能间互相干扰上下文存在原始结果残留用标签+摘要压缩,清理冗余token
新增技能不生效注册表未刷新或缓存未清优先检查registry.yaml与import路径

6. 从单个技能到技能体系:个人总结和几条实用经验

做完第一版agent-skills体系之后,我最深的感受是:写代码本身不复杂,真正决定成败的是“设计时有没有替模型着想”。技能描述、参数Schema、返回结果这些表面的配置,本质上是模型做决策时的上下文质量。模型没有隐性知识,它只能根据我们给的文本做推理,所以每一个字段、每一句描述都是在帮模型降低认知负担。

我个人的几条实操经验,最后分享给大家。

第一,技能描述要“反直觉地啰嗦”。给人看可以简洁,给模型看必须详尽,最好把任何可能的歧义都挡在描述之外。第二,执行层永远是最后防线,你不能赌模型一定生成合法参数,宽容解析、异常兜底、超时控制这些工程手段一个都不能省。第三,技能上线后一定要用真实用户语料做回归验证,模拟对话通常太规矩,真实表达里充满了省略和指代,你只有拿真实请求喂给Agent,才能发现匹配盲区。

这套体系目前还有很多可以扩展的空间:比如给技能加权限分级,让不同角色的Agent访问不同技能集;再比如技能运行时的流式日志和追踪系统,对排查跨技能调用链路的bug很有帮助;还可以研究一下“技能自动组合”——让多个技能按流程编排工作,这会是Agent能力跃升的下一个重要方向。

回到最初那个“只会聊天不会干活”的Agent,装上技能系统之后,它至少能真正帮你把日程安排了、邮件发了、报表拉了。让模型干模型擅长的事,让代码干代码擅长的事,让技能说明负责在两者之间搭桥——这套思想,值得在所有要做实事的Agent方案里推广开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询