ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零构建 Agent 技能体系:定义、注册、路由与避坑实战

从零构建 Agent 技能体系:定义、注册、路由与避坑实战 最近被问到最多的问题已经从大模型能做什么悄悄变成了怎么让 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_taskdescription给模型的说明书说明触发场景、输入输出、注意禁忌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: ftask {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: ftask {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 的你一些可复用的判断让你少走我走过的弯路。
返回列表