最近被问到最多的问题,已经从"大模型能做什么"悄悄变成了"怎么让 Agent 真正把活干完"。agent-skills 这个热词在圈子里不断出现,背后的核心命题其实很朴素:大模型本质上只会生成文字,它要变成一个能操作外部系统、能独立完成任务闭环的 Agent,靠的正是层层叠叠挂在它身上的"技能"。这篇文章是我从零搭建 Agent 技能体系的一次完整复盘,包括技能的定义方式、注册机制、实战代码、路由策略,以及我在真实项目中踩过的一堆坑和对应的排查思路。对象是正在做 Agent 应用的开发者、想从普通 Chatbot 转向 Agent 方向的工程师,还有那些对"Agent 落地到底卡在哪"感到好奇的产品和技术负责人。
1. Agent Skills 到底解决什么问题:从"会说话"到"会动手"
我在去年做了一个客服类项目,用户评价永远是"聊得挺好,但然后呢?"——模型能把售后政策背得滚瓜烂熟,却无法帮用户发起退款流程;能解释清楚什么是"定时备份",却没办法真的在服务器上创建一个备份任务。这个"然后呢"的缺口,正是 agent-skills 要补上的部分。
1.1 换个角度理解 Agent 的"手"
如果把大模型比作大脑,那它天生没有手,所有对外部世界的操作都必须借助工具完成。agent-skills 就是这套"工具能力"的标准化封装:一个技能代表一种可复用的能力,比如查天气、发邮件、操作数据库、调度任务。Agent 收到用户请求后,由模型自己判断"现在该调用哪个技能、参数填什么",然后由技能的执行层完成具体动作。
这里有个很容易被忽略的点:技能不只是"一段能跑的代码",它包含三样东西——给模型看的说明书(描述)、给模型填的参数规范(Schema)、给外部世界执行的逻辑(函数体)。三者缺一不可。我第一次做的时候只写了函数体,结果模型根本不知道这个函数什么时候该用,等于白搭。
从架构上看,技能层位于模型与外部系统之间,起的是"翻译"和"隔离"的作用。翻译是指把模型输出的意图转成具体的系统调用,隔离是指外部系统的复杂性不会污染模型的推理过程。这个分层想清楚之后,后面做扩展就顺很多。
1.2 Skill 和普通函数调用的本质区别
很多人问:我不就是一个 API 封装吗?为什么非要叫 Skills?区别在于"谁来决定调用它"。普通函数调用里,调用方是写死的业务代码,流程是确定的;在 Agent 体系里,调用方是大模型,模型根据用户的自然语言输入动态决定要不要用、怎么用。
举个例子。传统做法里,你写一个create_task(title, time, action)函数,然后在某个菜单按钮的点击事件里调用它。你永远不会在"用户问今天星期几"的时候去触发创建任务。但 Agent 场景下,调用决策完全交给模型,模型需要在"用户想创建任务""用户想查询任务""用户根本不想碰任务"之间做选择。这个决策一旦出偏差,就会出现经典的"幻觉调用"——用户只是随口问了一句"你平时怎么管理任务",模型就真的去创建一个定时任务。
所以技能定义的核心,不是把代码写出来,而是把"什么时候用、什么时候不用"这件事用模型能理解的方式表达清楚。这也是为什么我会把技能描述文件放在第一优先级,代码反而是次要的。
2. 技能的定义与注册:先设计好 Agent 手里的"工具清单"
技能体系的设计顺序应该是:先定义清楚清单,再写执行代码。这个顺序反了,后面大概率要返工。清单里每一项技能的信息结构如下:
| 字段 | 作用 | 说明 |
|---|---|---|
| name | 技能唯一标识 | 建议用 snake_case,如create_task |
| description | 给模型的说明书 | 说明触发场景、输入输出、注意禁忌 |
| parameters | 参数规范 | 使用 JSON Schema 格式,严格声明类型 |
| returns | 返回结构说明 | 告诉模型调用成功后会拿到什么 |
| execute | 执行函数 | 实际跑业务逻辑的代码 |
2.1 描述文件是给模型看的说明书
描述文件里最重要的字段是description,它的质量直接决定模型能不能正确路由。我一开始写得很随意,比如"创建定时任务",结果模型在用户咨询任务功能时也调它。后来我把描述改成这样:
Create a scheduled task. Use this when the user explicitly wants to set up, register, or schedule a recurring or one-time action (e.g., daily report, reminder, backup). Do NOT use this when the user is only asking about how task scheduling works or expressing a general interest in the feature.这段描述里有两个关键动作:一是正面列举触发场景,二是用"Do NOT"明确排除容易误判的场景。实测下来,负面排除的收益比正面列举还大,模型对"不要做什么"的理解往往更可靠。
描述文件的长度也有讲究,每个字段控制在两到三句话以内最好。太长的描述会稀释注意力,模型反而抓不住重点。如果技能有使用限制,比如"仅限管理员调用"或"需要传入时区参数",也一定要写进描述里。
2.2 参数 Schema 设计得越严格,模型就越不容易出错
参数设计是另一个容易被低估的环节。模型从自然语言里抽取参数,本质是一个信息抽取任务,如果没有严格的约束,模型会自由发挥。比如用户说"明天早上八点提醒我开会",模型可能把时间抽取成"明天早上八点",也可能抽成"8:00",格式完全不统一。
我现在的做法是,所有时间参数统一要求 ISO 8601 字符串,并且在 Schema 的description里明确写出格式示例。金融类、任务类的枚举值参数必须列出所有合法选项,并设置默认值。遇到可选参数,必须显式声明required字段,否则模型经常漏传。
一个值得分享的经验是:每个参数都要假设"模型可能传进来任何东西",所以执行层必须做二次校验,不能完全信任 Schema 的约束。我的执行函数里永远有一个validate_parameters()入口,宁可多写几行校验代码,也不能让脏数据穿透到业务层。
2.3 注册与加载:让技能在合适的时机出现
技能注册机制决定了 Agent 能"看到"哪些技能。当前主流思路是启动时全量注册、运行时按需加载。技能少的时候全量注册没问题,但超过十几个之后,把所有描述一次性塞给模型,既浪费 token 又增加路由错误率。
我的方案是给技能打标签,按场景分组。比如"任务管理类""信息查询类""系统操作类"。当用户输入进来,先用一个轻量的分类模型或规则引擎判断意图域,再只把对应域内技能的描述加载进上下文。这个两层设计把路由准确率从最初的 86% 提到了 94% 左右,代价是多了一次本地分类调用,延迟增加可以忽略。
另外,技能加载要考虑热更新。我在实际运营中发现,技能的 bug 修复或描述优化不应该要求整个服务重启。把技能定义存到配置中心或数据库里,运行时监听变更并重新加载,这个机制虽然前期花了一点时间,但后期维护成本降低非常明显。
3. 一个完整的 Skill 实战:定时任务管理
理论说了不少,下面用一个我自己做过很多遍的"定时任务管理"技能来完整演示。从需求拆解到代码实现,再到验收清单,走一遍完整流程。这个例子足够简单,但涵盖了技能设计的大部分核心环节。
3.1 需求拆解与边界划分
定时任务管理这个域,用户的需求通常有四种:创建任务、查看任务列表、取消任务、修改任务时间。这四种操作是分开做四个技能,还是合并成一个?我倾向于拆成四个,因为每个操作的触发条件、参数、返回结构都完全不同,合并成一个会让描述写不清楚。
边界划分有一个原则:一个技能只做一件原子的事。判断标准是,用一句话能不能说清楚这个技能是什么。如果一句话说不清楚,就得继续拆。解决边界问题的核心是明确技能最小粒度,粒度太粗,模型无法准确选择;粒度太细,加载成本高,调用链路长。
在做技能拆分设计时,最好的方法是用一个表格梳理每个技能的边界和参数信息。
| 技能名称 | 一句话描述 | 关键参数 | 返回内容 | 不适合使用的场景 |
|---|---|---|---|---|
create_task | 创建一条定时任务 | title, schedule_time, action, timezone | 任务ID、创建状态 | 查询或取消任务时 |
list_tasks | 查询当前所有定时任务 | status(可选) | 任务列表 | 用户要求新建时 |
cancel_task | 取消一条已创建的任务 | task_id | 取消状态 | 任务不存在但用户要求修改时 |
update_task | 修改任务时间或内容 | task_id, new_time(可选), new_title(可选) | 更新状态 | 用户只是想删除任务时 |
模型大概率会混淆cancel_task和update_task,因为两者都需要 task_id,所以描述里必须用"Do NOT"明确区分。我踩过一次很典型的坑:用户说"把明天的会议取消",模型调了update_task而不是cancel_task,导致会议没取消,只是时间被改掉了。就是因为那次教训,我后来在所有修改类技能的描述里都强制加了一句"If the user wants to remove/delete the task, use cancel_task instead."
3.2 核心实现与代码骨架
下面我用 Python 写一个最简但完整的实现。这里用内存字典模拟存储,真实项目中替换成数据库或 Redis 即可。
import json import uuid from datetime import datetime from typing import Any, Dict, List, Optional class TaskSkill: """定时任务技能集合:使用内存存储模拟,便于演示""" def __init__(self): self._tasks: Dict[str, Dict[str, Any]] = {} def create_task( self, title: str, schedule_time: str, action: str, timezone: str = "Asia/Shanghai", task_id: Optional[str] = None, ) -> dict: # 参数校验:所有必填字段为空时直接拒绝 if not title or not schedule_time or not action: return {"success": False, "error": "title/schedule_time/action are required"} # 时间格式校验:强制 ISO 8601 try: datetime.fromisoformat(schedule_time) except ValueError: return {"success": False, "error": "schedule_time must be ISO 8601 format"} tid = task_id or uuid.uuid4().hex[:8] self._tasks[tid] = { "task_id": tid, "title": title, "schedule_time": schedule_time, "action": action, "timezone": timezone, "status": "pending", "created_at": datetime.now().isoformat(), } return {"success": True, "task_id": tid, "task": self._tasks[tid]} def list_tasks(self, status: Optional[str] = None) -> dict: tasks = list(self._tasks.values()) if status: tasks = [t for t in tasks if t["status"] == status] return {"success": True, "tasks": tasks, "total": len(tasks)} def cancel_task(self, task_id: str) -> dict: if task_id not in self._tasks: return {"success": False, "error": f"task {task_id} not found"} self._tasks[task_id]["status"] = "cancelled" return {"success": True, "task_id": task_id, "status": "cancelled"} def update_task( self, task_id: str, new_time: Optional[str] = None, new_title: Optional[str] = None, ) -> dict: if task_id not in self._tasks: return {"success": False, "error": f"task {task_id} not found"} if new_time: try: datetime.fromisoformat(new_time) except ValueError: return {"success": False, "error": "new_time must be ISO 8601 format"} self._tasks[task_id]["schedule_time"] = new_time if new_title: self._tasks[task_id]["title"] = new_title return {"success": True, "task_id": task_id, "task": self._tasks[task_id]}这段代码本身不复杂,关键是几个容易被忽视的细节。第一,所有用户输入都要过校验,包括空值和格式。第二,每个函数都返回结构化 dict,方便模型理解结果。第三,操作类技能返回中一定包含task_id,否则模型在多轮对话里没法引用前面的操作结果。
实际接入模型时,我会给每个函数自动生成 JSON Schema 描述,然后通过 function calling 机制让模型按需调用。如果模型选择create_task,但抽取的参数是空的,就需要一个"参数不足时反问用户"的兜底逻辑。这个可以在系统提示词里声明:当模型发现参数缺失,不要尝试自己编造,直接向用户询问。
3.3 测试与验收清单
技能开发完不是跑通了就结束,我整理了一份验收清单,每一条都在真实项目里被验证过价值:
- 正确性测试:每个技能的 happy path 能否返回预期结果。
- 边界测试:空参数、超长字符串、非法时间格式、不存在的任务 ID。
- 多轮对话测试:用户先创建任务,再查询,再取消,模型能否在后续轮次正确使用已经获得的 task_id。
- 误调用测试:用户聊无关话题时,模型是否保持不动,不触发任何技能。
- 并发测试:同一个用户短时间内重复触发,系统是否产生脏数据。
- 可用性测试:技能执行失败的反馈是否足够明确,模型能否给出让用户理解的自然语言错误说明。
多轮对话测试是最容易翻车的。很多模型在第一轮能正确调用技能,但到了第五轮,用户说"把它改到明天",模型就不记得"它"指的是哪个 task_id 了。解决方式是让技能返回足够明确的上下文,并把关键信息写进对话记忆。这一点我会在下一节展开。
4. 多技能协同:Agent 怎么决定接下来用哪个技能
单个技能跑通不难,难的是十几个技能放在一起,模型怎么在每一轮对话里选出正确的那个。这个决策过程就是 Agent 的"路由策略"。我花了很多时间在这块,下面把经验按从简单到复杂的顺序讲清楚。
4.1 从单技能到多技能,路由策略是关键分水岭
技能少于五个时,把所有描述都塞进 system prompt,让模型用 function calling 直接选择,效果就不错。但技能数量超过十个之后,全量描述会让模型注意力分散,路由错误率明显上升。
我实测过一个数据:8 个技能全量注册,路由准确率约 92%;15 个技能全量注册,准确率掉到 87% 左右。表面看差别不大,但考虑到每个错误调用的代价(执行了不该执行的操作),这个差距实际很难接受。
所以我在中间加了一层"意图分类器"。先用一个分类模型把用户输入归到几个粗粒度域(如"任务管理""信息查询""对话闲聊"),然后只将对应域内的技能描述加载进来。这个思路有点像一个公司先有前台接待,再有各个部门,前台帮你判断该进哪个门,省得你挨个敲门问。实现上可以用轻量的嵌入式分类器,甚至可以复用大模型做一次超低成本的分类调用。
此外,路由策略还需要处理"模糊请求"。用户说"帮我安排一下明天的日程",可能同时涉及创建任务和查询日历两个技能。这时候正确的做法是让模型生成一个组合计划,先查日历再创建任务,而不是强制二选一。
4.2 调用结果与记忆的回写
技能调用完成后,返回结果需要写回对话记忆。否则模型在后续轮次会失去上下文依赖。比如用户先问"我有哪些任务",模型调用list_tasks拿到了任务列表,如果这个列表不写回对话上下文,用户接着问"第一个是什么时间",模型就答不上来。
我现在的做法是,技能返回结果经过一个"摘要化"处理,把长列表压缩成包含关键 ID 和核心信息的摘要,再注入到对话上下文中。这样做有两个好处:一是节省 token,二是避免模型被过长原始数据干扰判断。
记忆回写还涉及一个隐私问题。技能返回的数据可能包含用户敏感信息,在写回上下文之前要过一遍脱敏过滤。比如任务描述里如果含手机号或地址,就替换成脱敏形式。这个细节前期不做,后期合规审查时会很被动。
4.3 失败重试与降级:不要把所有意外都抛给用户
技能执行失败是常态,关键是失败之后怎么处理。我总结了三层降级策略:
- 第一层:参数修正重试。模型抽取的时间格式不对,执行层自动尝试解析一次,成功则继续,失败则进入第二层。
- 第二层:信息补全反问。如果是因为缺参数失败,向用户确认关键信息后再重试,而不是直接报错。
- 第三层:显式失败。如果操作本身逻辑冲突(比如取消一个不存在的任务),如实告知用户,并给出可操作建议。
这个三层策略让我的 Agent 在真实用户面前显得"聪明"很多。用户对 Agent 的耐心很低,一次失败就可能劝退。但如果你能在失败后给出清晰解释和替代方案,用户的容忍度会明显上升。
在重试逻辑里还要注意防止死循环。我设置重试上限为两次,超过后直接走显式失败,避免浪费 token 和时间。
5. 实测里最容易翻车的地方,以及我的排查思路
把技能体系放到真实环境里跑,问题永远比你预想的多。这一节我把自己踩过的坑按"症状—原因—排查—解决"的结构整理出来,希望对你有参考价值。
5.1 "幻觉调用":描述文件写得太宽泛的代价
症状是用户随便聊聊,模型却真的执行了操作。最典型的场景发生在"查询类技能"上——用户说"你能帮我查一下吗",模型就去调数据库查询接口,白白消耗资源。
排查思路是打开日志,看模型调用技能时的完整推理上下文。我那次查下来,发现技能描述里的措辞会让模型过于敏感,才导致误判。解决的思路是给描述补上反例,明确写出"Do NOT use this tool for general questions or casual inquiries about features."这行字大概能减少一半以上的误调用。
另一个有用的做法是把技能的调用条件量化。比如"用户明确要求""用户提供了必要的参数"这些条件都写进描述里。模型对条件句的理解比对开放式描述的把握要强很多。
5.2 参数校验缺失引发的连锁故障
有一次定时任务技能在线上出问题,用户创建了一个时间格式完全错误的任务,直接导致后续调度系统解析失败。排查后发现模型把"下周一早上九点"抽取成一种非标准格式,而执行层没有做二次校验,脏数据就穿透到了下游。
这个坑的教训是:永远不要假设 Schema 能拦住所有错误。模型生成参数本质是概率行为,任何约束都有概率被绕过。现在我把执行层的validate_parameters()当作一个安全门,所有新技能开发都必须带完整的参数校验代码,否则不允许上线。校验失败时返回的错误信息也必须是结构化的,方便模型理解和修正。
5.3 并发与幂等:一次操作被重复执行了三次
在用户网络抖动或者模型重试机制的共同作用下,同一个操作请求可能到达执行层多次。定时任务这种有副作用的操作,如果重复执行,会造成灾难性后果。
我遇到过用户创建任务时,客户端重试加模型重试,最后同一个任务被创建了三次,用户收到三个提醒。排查链路完成后,我做的修复是给所有操作类技能增加幂等键机制。客户端或会话层生成一个 request_id,执行层检查这个 ID 是否处理过,处理过就直接返回上一次的结果,不再重复执行。
这个机制对"查询类技能"意义不大,但对"创建、修改、删除、转账"这类有副作用的操作,必须加上。
5.4 权限边界:技能不是越大越全就越好
还有一个容易被忽视的问题:技能权限过大。我刚开始做 Agent 的时候,为了省事,把一个能执行任意 shell 命令的技能挂了上去,想着后台限制业务范围就行。结果模型在某个测试场景里真的生成了一个不在预期内的命令,差点出事故。
排查后的结论是:技能的最小权限原则比什么都重要。一个技能只能做它描述里那一件原子的事,不要为了复用而把多个操作塞进一个技能里。比如"执行 shell 命令"应该拆成"备份数据""清理日志""查询磁盘空间"三个独立的技能,每个技能内部再做参数白名单校验。
权限边界还包括用户身份校验。操作类技能必须在执行前获取当前用户身份,确认其拥有对应权限,否则直接拒绝并返回明确的权限不足信息。把这些控制放在技能执行层,而不是依赖模型自觉,是安全底线。
通过这几轮迭代,我的技能体系从最初"能跑但经常乱来"的状态,慢慢变成了"稳定、可控、可扩展"。本质上,Agent 的能力上限不取决于模型有多强,而取决于技能层设计得有多扎实。模型的聪明只是锦上添花,技能层才决定了事情能不能真正落地。希望这篇实战梳理能给正在做 Agent 的你一些可复用的判断,让你少走我走过的弯路。