“agent-skills”这个词最近在圈子里出镜率很高,但很多人第一眼看到会懵一下:Agent我懂,Skills我也懂,合在一起“agent-skills”到底是个什么东西?它既不是一个新的Agent框架,也不是某个具体的大模型,而更像是一层被严重低估的中间件——一种把Agent从“只会对话”变成“真的会干活”的能力组织方式。简单说,Agent负责决策和思考,Skills负责执行和落地,两者合起来才是一套能跑通业务的完整系统。这篇文章就围绕agent-skills展开,把技能体系的定义、设计、落地实现和避坑经验完整过一遍,适合正在做Agent应用、或者准备把LLM接入真实业务系统的开发者和产品负责人参考。
我接触过不少把Agent项目做“烂尾”的团队,复盘下来十有八九不是模型不够聪明,而是技能层没做好——要么技能定义得含糊,LLM根本不知道什么时候该调;要么技能参数和return格式写得不规范,模型就算调了也拿不到可用结果。所以这篇文章的核心不是教你怎么写一个技能函数,而是把整个agent-skills体系的架构思路和实操细节掰开讲清楚,让读到的人能直接拿去用,少踩我踩过的那些坑。
1. 为什么Agent需要一套技能体系
1.1 先想明白:技能到底是什么
很多人把“技能”理解成API封装,理解成一个函数,甚至理解成一段提示词。这都不全对。在我的定义里,Agent技能是“大模型意图理解和外部工具执行之间的一层标准化契约”,它包含三样东西:可被LLM理解的语义描述、严格定义的参数结构、可执行的真实逻辑。
为什么强调“标准化契约”?因为LLM本身是一个概率推理引擎,它不保证每次都按你想象的格式输出。如果你直接把一个裸函数丢给Agent去调用,它大概率会猜错参数名、猜错调用时机,甚至自己编造一个不存在的函数名。而技能体系做的事情,就是把“调用什么、传什么参数、拿到什么结果”这三件事完全固定下来,用模型最容易理解的方式喂给它,把不确定性降到最低。
我之前做过一个内部的报表Agent,早期版本在prompt里写了几段自然语言说明“如果需要查数据就调用query_data函数”。结果线上跑了一周,Agent经常在用户问一句“今天销售情况怎么样”的时候,自己编一个SQL查了错误的表,甚至把函数名拼错成query_dates。后来我把技能注册机制加进去,每个技能都用结构化的JSON描述清楚,这个问题基本绝迹。
1.2 技能体系的三个核心模块
一套完整的agent-skills体系,我认为至少由三个模块构成,缺一个都会在实战里出问题。
第一个是技能注册中心。所有能被Agent调用的技能,都要先“登记”到这个中心里,登记的内容不是函数本体,而是技能的元数据:技能名称、功能描述、参数Schema、返回格式说明、权限级别。Agent每次推理前,会把注册中心里的技能列表拉出来作为上下文,模型看到哪些技能可用,才知道该怎么规划行动路径。
第二个是技能执行器。它负责接收LLM发出的结构化调用请求,完成参数校验、权限校验、实际执行、异常捕获,最后把结果转成LLM能继续推理的格式返回给模型。这个模块看起来像个壳子,但实际是最容易出问题的地方,参数校验和异常处理不到位,轻则返回垃圾数据,重则拖垮整个Agent循环。
第三个是技能编排逻辑。单技能调用好做,复杂任务的难点在于多个技能如何组合。比如“查一下天气,如果下雨就提醒我带伞,顺便把去机场的路况也看一下”,这种任务需要Agent自己决定先调天气再调路况,或者并行调两个技能。技能编排逻辑要解决的是:依赖关系怎么表达、结果怎么聚合、分支条件怎么让LLM理解。这部分没有标准答案,但设计原则是一致的——给Agent足够的自由度,同时用约束保住底线。
2. Agent技能库的设计思路与关键取舍
2.1 技能描述的写法:一个描述词决定调用成败
如果说代码是给机器看的,那技能描述就是写给LLM“看”的。很多开发者写技能描述时特别随意,比如“上传文件”这种描述,模型根本分不清应该传文件路径还是文件内容、上传到哪个服务、返回什么。但我见过最离谱的问题是描述过短,导致跟其他技能描述撞车。
举个真实例子。我维护的一个技能库里有两个技能,一个叫“send_mail”用来发邮件,一个叫“notify_user”用来给用户推送站内信。如果前者的描述写成“发送一条消息”,后者的描述写成“给用户发消息”,LLM在调用时就会纠结,甚至随机选一个。我把两个描述改成“发送一封电子邮件,需要指定收件人地址、主题和正文内容”和“向指定用户发送系统站内通知,需要指定用户ID和通知内容”,模型的选择准确率直接从62%提升到接近90%。
写技能描述我总结了三原则:一是动词要明确,别用“处理”“操作”这种泛动词,尽量用“查询”“创建”“删除”“发送”这种可观测的动作;二是把触发条件写清楚,比如“仅当用户明确要求查询具体城市天气时调用”;三是把关键约束前置,比如“本技能返回的单位是摄氏度摄氏而不是摄氏度华氏度”。别嫌啰嗦,对LLM来说,清晰的描述就是最好的提示词工程。
2.2 参数定义:用JSON Schema把话说清楚
技能的参数定义是另一个容易翻车的点。有些项目用简单字符串模板传参,有些直接在函数签名里写类型,但到了真正接入LLM的环节,最稳妥的方案还是JSON Schema。
为什么非要用JSON Schema?因为它把参数的可能结构、类型、约束、必填项全部结构化表达出来了,LLM天生擅长理解和生成JSON结构,你用Schema喂给它是“顺着毛捋”,相反你用自然语言描述参数,它就会自由发挥,然后你就得花大量的代码去纠错。
举个例子,一个天气查询技能参数Schema长这样:
{ "name": "get_weather", "description": "查询指定城市当前天气,包括温度、湿度、风力,单位为摄氏度", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名称,例如上海、北京、广州" }, "with_forecast": { "type": "boolean", "description": "是否返回未来3天预报,默认false", "default": false } }, "required": ["city"], "additionalProperties": false } }注意additionalProperties: false这行,很多人会忽略,但它的作用是禁止模型传Schema没定义的额外参数,能有效防止幻觉参数。还有必填字段要严格控制,一个技能如果三个参数都设成required,模型很容易卡壳,我一般只把真正核心的参数设成必填,其余给默认值。
2.3 技能注册与发现:别把字典搞得一团乱
技能数量少的时候怎么放都行,一旦技能库上了规模,注册和发现的乱象就会冒出来。我见过有人把写好的技能函数随手丢进一个工具目录,然后在代码里逐个import,再手工拼成列表传给模型。这个方案在20个技能以内勉强能用,超过50个就彻底失控——新增技能忘注册、重名覆盖、权限混乱,都是这么来的。
我的做法是用一个统一的注册装饰器或者声明式配置文件,把技能的注册动作和实现解耦。比如每个技能模块自己定义一个registration块,集中扫描加载,注册时自动做三件事:检查名称唯一性、检查参数Schema合法性、把技能加载进全局技能表并同步索引。这样新增技能只需要写一个模块,扫描程序会自动发现它,然后出现在Agent的tool列表里。
技能发现的问题也值得单独说:Agent每个轮次要把所有技能描述塞进上下文,技能越多占的token越多,还容易干扰模型判断。所以技能库必须支持按任务动态筛选,比如通过关键词召回、通过粗粒度分类过滤,只把候选的TopK个技能描述给LLM。这个优化能把代码污染和开销同时降下来。
3. 从零搭建一套可用的Agent技能库
3.1 技能定义的标准结构
我习惯把每个技能定义成一个独立的Python模块,统一暴露一个register_skill()函数,返回标准化的技能对象。这样做的好处是每个技能可以单独测试,不用启动整个Agent系统就能验证逻辑对不对。
一个典型的技能模块结构如下:
skills/ ├── __init__.py # 技能扫描入口 ├── weather.py # 天气查询技能模块 ├── calendar.py # 日历日程技能模块 ├── search.py # 搜索技能模块 └── ...每个模块内部的核心代码是这样的:
from dataclasses import dataclass, field from typing import Any, Dict @dataclass class Skill: name: str description: str parameters: Dict[str, Any] execute: callable timeout: int = 10 allowed_roles: list = field(default_factory=list) def register_skill() -> Skill: def _execute(city: str, with_forecast: bool = False) -> Dict[str, Any]: # 这里写真实业务逻辑,可以调用外部API、数据库、内部服务 data = weather_api.query(city) return { "city": city, "temperature": data["temp"], "humidity": data["humidity"], "forecast": data.get("forecast") if with_forecast else None } return Skill( name="get_weather", description="查询指定城市当前天气,包括温度、湿度、风力,单位为摄氏度", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "城市中文名称"}, "with_forecast": {"type": "boolean", "description": "是否返回未来3天预报", "default": False} }, "required": ["city"], "additionalProperties": False }, execute=_execute, timeout=5 )这里_execute函数收到的参数一定是JSON Schema校验通过后的真实值,而不是LLM原始输出的字符串或者嵌套结构,这一步很关键。有人直接拿LLM的输出去调用外部接口,字段类型对不上就报错,然后又回头怪模型不听话,其实中间少了一个“参数清洗”的环节。
3.2 路由与执行:让LLM自动选技能
技能注册之后,怎么让Agent在合适的时机调用它?这里我采用的是当前最成熟的做法:把技能列表转成LLM的tools参数,利用模型原生的function calling能力做路由。
整体循环简单示意如下:
def run_agent_with_skills(task: str, skill_registry: SkillRegistry, llm_client): messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}] max_turns = 5 for turn in range(max_turns): tools = skill_registry.build_tools(prompt=task, messages=messages, top_k=10) response = llm_client.chat(messages=messages, tools=tools, tool_choice="auto") if not response.tool_calls: return response.content for tool_call in response.tool_calls: skill_name = tool_call.function.name raw_args = json.loads(tool_call.function.arguments) result = skill_registry.execute(skill_name, raw_args, user_context) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) return "已达最大轮次,无法完成该任务。"这个循环里我加了几个保护措施:最大轮次限制防止无限循环;技能列表按上下文动态裁减;执行结果用JSON序列化后回传。每一条看起来简单,但都是踩过坑换来的,特别是最大轮次,没有它的时候,一个调错技能的Agent能来回兜圈子调用十几轮,浪费时间和token。
3.3 完整链路:一个天气查询技能的实战
光看代码框架肯定不够,我拿一个具体场景走一遍完整链路,你就能直观感受到每个环节在真实运行时发生了什么。
假设用户输入:“上海今天适合穿短袖吗?如果明天降温,帮我定一个后天早上九点的闹钟。”
这个任务涉及两个技能:天气查询get_weather和闹钟创建create_alarm。Agent的推理链路大致是:
第一步,模型看到技能列表后,先决定调用get_weather,参数生成的是{"city": "上海", "with_forecast": true}。注意它特意把with_forecast置为true,因为它意识到“明天降温”这个判断需要未来一天的数据。这说明技能描述里对参数的说明起到了作用。
第二步,get_weather执行完后返回一个结构化的JSON,包含今天和明天的温度、天气。Agent读到结果后,开始推理“今天气温27度,湿度适中的话短袖可以穿;明天最高降到22度,确实是降温”,于是得出结论并继续调用create_alarm,参数是{"time": "2025-06-12 09:00", "label": "起床"}。这里有一个常见坑:模型可能把时间算错。所以create_alarm的Schema里我特意在time字段的description里写了“必须是ISO8601格式,且基于当前日期计算”,并且技能执行前会再校验一次时间合法性。
第三步,闹钟创建成功返回{"status": "created", "alarm_id": "12345"},Agent把最终回答整理成自然语言返回给用户。这一整条链路看起来顺理成章,实际上任何一个环节的Schema描述写得模糊一点,模型就会卡壳或者绕路。这套链路我跑通之后,就再也不想回到“纯提示词驱动工具调用”的写法了。
3.4 技能版本管理与灰度发布
技能会迭代,但Agent系统的稳定性要求不能因为技能升级而掉链子。我见过因为改了一个技能函数的返回字段名没同步更新Schema,导致Agent拿不到关键数据,最后整个业务流程崩掉的案例。
所以技能库一定要有版本概念。我的做法是每个技能模块除了当前实现,还保留上一版本的备份,注册中心记录active_version。发布新版本后先在测试环境跑一轮回归用例,用例是历史真实用户问题的抽样集合,逐一跑Agent完整链路,看技能调用是否正常、结果是否符合预期。验证通过后再在线上按比例放量,比如先让10%的流量使用新版本技能,观察错误率,稳定后再全量切换。
技能灰度发布期间,新旧版本可以并存,Schema里加一个version字段区分。新版本参数有变化时,旧版本的调用请求也能被兼容处理。这套机制听着挺重,但技能数超过30个的项目完全值得做,因为它帮你挡掉的是线上的事故。
4. 常见问题与排查技巧实录
4.1 问题速查表
技能体系跑起来之后,日常碰到的坑五花八门,我整理了一个速查表,基本覆盖了最常见的几类问题,可以对照着排查。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 模型根本不调用技能,只凭记忆回答 | 技能描述与用户意图匹配度低,或技能名不直观 | 优化技能描述,加入触发条件和示例话术 |
| 模型调用了错误的技能 | 两个技能描述语义重叠 | 重写描述,明确各自边界和适用场景 |
| 参数必填项缺失 | Schema required字段过严 | 精简必填项,把次要参数加默认值 |
| 参数格式错误导致执行报错 | 缺少参数清洗和校验 | 执行前用jsonschema库强校验,拒绝非法入参 |
| 执行结果返回后模型继续乱猜 | 结果格式不结构化,无法被识别 | 统一用JSON返回,关键字段加说明 |
| Agent循环调用不终止 | 缺少最大轮次控制 | 在Agent循环中加turns上限 |
| 技能数量太多导致上下文膨胀 | 未做技能动态筛选 | 实现TopK技能召回机制 |
| 技能返回超时 | 外部依赖接口响应慢 | 为技能执行设置超时,超时先返回“暂不可用” |
这张表里的每一条,我都能对应一个真实翻车现场。比如“执行超时”这一条,最初我有个技能要调一个第三方接口,正常时200ms返回,但高峰期能拖到8秒。早期没设超时,Agent循环在等待中挂死,整个服务跟着阻塞。后来给每个技能执行都包了functools.timeout,超时直接返回一个占位结果,Agent会主动告诉用户“暂时查不到,请稍后重试”,体验反而更可靠。
4.2 技能误调用与参数幻觉
参数幻觉是LLM接入技能库时最隐蔽的问题。模型经常会传进来一些Schema里根本没有的字段,或者把字段值编造得煞有其事。比如get_weather的Schema里只定义了city和with_forecast,模型某次却传了{"city": "深圳", "unit": "fahrenheit"}。如果执行端不校验,函数里拿不到unit字段还好,但是用户本来要摄氏温度,模型却可能基于“F”判断穿衣指数,结果就完全错乱了。
我目前的实践是双重防线:第一道防线在LLM调用侧,把additionalProperties设成false,从生成端限制额外字段;第二道防线在技能执行器侧,入参执行严格的schema校验,校验不通过的请求直接返回明确错误信息,并附带“可用参数列表”给模型纠错。这两道防线叠加后,参数幻觉导致的异常比例从12%降到了不到1%。凡是做过实际Agent项目的人应该都有共鸣:模型对参数的自由发挥是常态,不作约束就等着出乱子。
另外一个特别隐蔽的幻觉点是类似参数。比如传字符串当数组用、传城市名当邮编、传时间字符串格式不一致等等。我的建议是每个技能执行函数开头写一组防御性断言,宁可多写几行代码,也不要在生产环境里赌模型输出一定规范。
4.3 性能、超时与并发
Agent技能体系一旦接入线上流量,性能和并发就是绕不开的话题。很多开发者在本地demo跑通之后直接上生产,然后发现几个致命问题。
第一是技能执行的串行阻塞。标准Agent循环里,多个技能调用如果做成串行,一个技能卡住后面全卡。我建议对无依赖的技能调用做并行执行,比如“同时查天气和路况”这种场景,用asyncio.gather并发发出去,整体响应时间能缩短一半以上。
第二是技能执行器的线程池和连接池隔离。技能要访问数据库或外部服务,如果跟主服务共用连接池,Agent并发一高直接拖垮核心服务。我的做法是给技能执行器单独分配连接池,并且设置独立的并发信号量,超过阈值的请求排队等待而不是疯狂重试。
第三是超时与重试策略。技能调外部接口时,超时时间不能一刀切,内部数据库查询和外部API的时延差异巨大。建议按技能类型分档:读操作3-5秒、写操作5-8秒、外部慢接口10-15秒。超时后的重试次数最多两次,每次间隔递增,超过两次就返回友好错误。这样既不会因为一次抖动就失败,也不会因为无脑重试把下游系统压垮。
性能优化的原则就一句话:不能因为Agent技能层拖慢主流程,也不能因为技能执行失败导致整个对话不可用。这两条守住,线上稳定性基本就有保障。
5. 高级技巧:让技能库具备自我进化能力
5.1 从调用记录中挖掘技能描述优化点
技能库跑一段时间后,会产生大量调用日志。这些日志是优化技能描述的一手素材。我会定期做一件很简单但效果很好的事:把所有因“参数校验失败”或“模型调错技能”而失败的日志捞出来,分析失败模式,然后反向优化技能描述或Schema。
举个我自己的例子。我的技能库里有一个send_reminder技能,描述里写的是“发送提醒”,后来发现模型经常把它跟create_alarm混淆。通过日志分析,我发现大量请求在用户说“提醒我”的时候调了send_reminder,而用户说“定个闹钟”的时候调了create_alarm,其实两个场景本质一样。最后我把两个技能合并成一个create_reminder,用reminder_type区分“闹钟”和“普通提醒”,误调率直接清零。
这种从日志反推设计的方式,比任何纸上谈兵的优化都有效。Agent技能体系是典型的“运行得越久越聪明”的系统,前提是你要把每次错误都当成优化素材。
5.2 技能冲突处理与编排策略
多个技能同时命中一个请求时,怎么决定优先级?比如用户说“帮我安排明天的日程”,可能同时涉及日历查询、交通耗时预估、会议邀请发送三个技能。它们之间有依赖关系:先查日程才知道有没有空,先知道交通耗时才能判断需不需要早起。
我的编排策略是给技能定义depends_on字段,明确依赖关系。LLM在做function calling时会看到这些依赖提示,从而选择正确的调用顺序。同时在系统提示词里写清楚:如果任务需要多个技能协作,必须先调用不依赖其他技能的技能,逐步推进。
但这里有个度的问题,依赖关系设置得太死板,Agent就失去灵活性。我一般只设“硬依赖”和“软建议”两级。硬依赖是技术上必须谁先谁后,软建议是模型可以自己判断顺序。前者写死在编排逻辑里,后者留白让LLM发挥。
5.3 把Agent技能库当成独立产品来维护
最后分享一个观念层面的建议:如果把Agent技能库当成业务代码之外的一个“独立产品”来维护,很多问题会提前化解于无形。它要有自己的文档、自己的负责人、自己的版本记录,而不是散落在各个业务模块里。
当技能库的API面像产品一样被管理时,前后端之间的理解误差会大幅减少,模型能稳定使用的技能也越来越多,整个Agent系统的能力边界真正地长在了技能库上。而一个好的技能库,本身就是Agent系统最值钱的资产之一。
我自己在多个项目里反复调整之后,最大的体会是:大模型的推理能力会持续升级,但技能层的积累才是每个团队真正差异化的部分,它融合了业务知识、系统边界和工程经验,是Agent能不能在真实环境里站稳脚跟的分水岭。希望这篇文章能帮你把自己的agent-skills体系搭得清楚、跑得稳定、改得起来。