这一年多我最常被问的一句话是“agent-skills到底是个啥”说它是个工具箱吧又不只是工具说它是个框架吧它明明更像一套约定。我最早接触这个概念是被一个AI客服项目逼的——prompt里塞了十几个function的说明每次改功能都要全局回归模型还经常把相似的工具选错。后来我把这些能力拆成一个个独立技能混乱才算真正结束。这篇文章不打算讲空泛的“Agent趋势”就聊聊我在实际项目中总结的Agent Skills设计思路技能怎么定义、注册与调用链路怎么搭、多技能如何编排以及踩过哪些坑。适合正在做AI Agent应用、被工具调用问题折磨过的人也适合刚接触智能体工程的新手。1. 为什么prompt里堆function call会失控Skill抽象的出发点1.1 从一次线上事故说起我们的客服机器人上线三个月后功能从查订单扩展到了查物流、预约安装、申请发票、咨询促销、投诉工单……所有能力的说明都堆在system prompt里。表面上看没毛病大模型嘛给足说明就能选对。但实际用起来完全不是那么回事。第一个爆发的问题是token膨胀。一个能力的说明最少要写两三百字包括什么时候用、参数怎么填、给个示例20个能力就是五六千token。每个请求都背着这些东西成本上去不说响应时间也明显变长。更可怕的是prompt越长模型越容易“注意力涣散”——它开始忽略后面的说明前面的优先级被放大。第二个问题是意图混淆。我们有两个能力“查订单”和“查物流”description写的是“查询用户订单信息”和“查询物流公司配送信息”。看起来分得很清楚实际测试时用户说“我买的东西到底到哪了”模型能选对但用户说“你看下我那个单子发货了没”它俩就开始抽风。后来才发现问题出在“单子”这个词在两个description里都存在暗示空间。第三个问题是改一个功能牵一发动全身。有次我想在“查物流”里加上“显示签收人姓名”的逻辑顺手改了description结果“查订单”的召回率掉了好几个点。因为两个描述更相似了模型更难区分。这个事故让我意识到当能力数量超过某个阈值把“能力说明”和“提示词”混在一起的做法必然失控。Skills抽象就是从这个痛点出发的。1.2 Skill是“带使用说明的能力单元”那Skill到底是什么我喜欢的类比是写代码时你不该把逻辑塞进一个巨大的main函数而是抽成一个个函数、模块Agent的技能也是同理。一个Skill就是一组自洽的、可以独立评估和调用的能力单元。它通常包含几个部分技能名字和ID全局唯一机器可读一句话描述告诉Agent这个技能什么时候用、什么时候不用参数定义输入什么、类型是什么、从哪提取执行体真正干活的那段逻辑返回格式结构化输出方便模型继续推理和普通function calling的区别在于Skill往往还打包了“使用边界”和“失败行为”。比如查快递如果单号不存在执行体要返回“NOT_FOUND”而不是报错比如查天气如果只给城市名没给日期描述里就要约定默认查今天。这些细节决定了Agent在多轮对话里能不能把你的能力用好而不只是“调通”。1.3 不是所有项目都需要惊动技能系统也要说句公道话Skills不是银弹。我自己判断要不要上这套抽象就看几个指标意图种类的数量超过五六个再上一两个就先别折腾功能是否要跨项目复用如果另一个项目也想用这套能力那值得抽是否多人协作一个人写、一个人调可以糙快猛人一多规范和注册机制就必须跟上是否需要隔离和灰度面向外部用户的Agent技能升级最好能单独发布如果只是做个Demo我建议先老实把工具说明写在prompt里等真出现选错、改不动、复用不了的情况再动手拆。一上来就上重型框架大概率是给自己找麻烦。2. 一个技能的长相定义、描述与参数的实操规范2.1 技能定义文件怎么写我在项目里倾向用YAML做技能定义原因很简单可读性好团队里非程序员也能看懂和评审。下面是我经过几次返工后沉淀下来的模板name: query_express_status version: 1.2.0 description: | 根据快递单号查询物流轨迹适用于用户询问“快递到哪了”“什么时候能送”“包裹派送状态”等场景。 仅在用户明确提到快递/包裹/物流时使用不要用于查询订单金额、申请退款或联系客服。 parameters: - name: tracking_no type: string required: true description: 快递单号。优先使用用户直接提供的数字如果用户只给了订单号需要先调用query_order获取对应单号再填入。 - name: carrier type: string required: false description: 快递公司编码如SF、ZT、YT。用户提到公司名称时填写否则留空执行体自动识别。 execution: type: http endpoint: https://api.example.com/logistics/query method: POST timeout_ms: 3000 retry: 2 return: type: json schema: status: PENDING|IN_TRANSIT|DELIVERED|NOT_FOUND latest: 最新一条物流信息简短摘要 detail: 完整物流轨迹最多10条这个文件看起来简单但每一个字段都是我踩过坑换回来的。下面重点讲几个容易被忽略的地方。2.2 description是技能的“门面”决定模型会不会选错先说一个反直觉的结论description的优先级比我当初以为的高得多。模型在大多数情况下不是靠代码逻辑选技能而是靠读description做语义匹配。所以description写不好后面全白搭。我总结了三件事。第一写清楚正面触发也就是“什么时候用”。不要光写“查询物流”要写“用户询问快递到哪了、什么时候能送、包裹派送状态”把用户可能的口语都放进去。实测下来在description里加了“到哪了”“怎么还没来”“发了没有”这些口语变体之后查快递技能的召回率从82%提到了94%。第二写清楚负面排除也就是“什么时候不要用”。这一条很反直觉因为很多人觉得description越短越好。但实际上明确说“不要用于查询订单金额、申请退款”这类边界能大幅减少和相邻技能的混淆。模型在犹豫时这句排除比十句正面描述都管用。第三一个技能只做一件事。我见过有人把“查订单查物流催发货”写成一个技能理由是这三个经常一起触发。结果就是模型每次调用都把三个参数试着填一遍要么缺这个要么错那个。拆开之后每个技能参数少了、边界清楚了整体效果反而更好。单个技能的责任边界越小Agent做路由时就越容易选对。2.3 参数定义少而精宁可选别强求参数设计是最能看出一个人有没有真实做过Agent工程的。新手喜欢一上来定义七八个参数把接口字段全部铺开。但我问几个问题就崩了模型能从用户的话里把这些字段都提出来吗提错了你是报错还是容忍我的原则很简单参数能少就少只保留开干必需的字段required字段一定要少。每次把字段标记为必填都是把可靠性押在模型自动补参的准确性上参数描述要写清楚“从哪提取”。比如tracking_no的描述写了“如果用户只给了订单号先调用query_order获取单号再填入”这种引导式描述比单纯写“快递单号”管用得多类型尽量宽松。快递单号表面是数字但有些会带字母直接定义成integer模型一旦抽到字母就废了。统一用string是更稳的选择对需要格式化的字段在描述里给出示例比如日期写“2024-03-01”不要只写“日期”两个字2.4 执行体要做兜底别把脏活留给推理执行体其实就是真正干活的代码但它不只是“调接口”那么简单。我把执行体的最低要求定为三件事。一是参数清洗。模型传出来的参数经常不干净比如单号里带了空格、日期带了“明天”这种相对表达。执行体要在入口做归一化而不是直接透传给下游接口。宁可多做一步不要抱着“模型应该填对”的幻想。二是失败要有定义好的返回。我习惯定义几个固定错误码NOT_FOUND查无此单、UNKNOWN_ERROR系统异常、PARAM_INVALID参数不合法。模型拿到这些错误码才能在下一轮对话中给出正确应对比如“您提供的单号不存在请确认后再试”。如果错误码没设计好模型就只能看着原始报错瞎猜。三是超时与重试。技能调用外部接口网络波动是常态。我通常设3000ms超时、重试2次间隔100ms起、指数退避。别等一个接口把Agent的响应拖到10秒以上用户早就走了。3. 注册与调用链路把“技能”真正挂载到Agent上3.1 注册表别让技能散落各处技能定义好了接下来就是注册。这一步很多半路出家的项目做得很随意——直接在代码里import技能类或者把技能文档写进一个没人看的目录。等到技能多了没有一个地方能回答“当前线上到底有哪些技能、谁在用、什么版本”。我采用的注册表方案其实很简单本质是一个技能元信息清单。目录结构如下skills/ query_express_status/ SKILL.yaml main.py requirements.txt tests/ create_return_order/ SKILL.yaml main.py requirements.txt tests/启动时框架读取skills目录下所有的SKILL.yaml构建一个注册表包含skill_id、name、version、enabled、endpoint这些字段。新增一个技能就是新增一个目录下线一个技能就是置enabled为false。整个过程不碰主程序代码。为什么要用目录加文件的方式而不是数据库因为技能本质也是代码它应该和代码一起走Git、一起做Code Review、一起打版本标签。放进数据库版本管理和测试就没法跟代码流程完全对齐了。这一点在多人协作时尤其重要。3.2 一条完整的调用链路当一个用户消息进来背后大致要经过这几跳意图路由LLM拿到用户消息和所有技能的description输出一个结构化选择比如“调用query_express_status参数tracking_noSF1234567890”参数校验框架检查这个技能存在、参数类型对不对、必填项是否完整。这一段不要依赖LLM自觉要做硬校验权限检查用户有没有权限用这个技能。比如“内部员工查询别人订单”这种权限问题必须在框架层拦截执行调用执行体返回解析执行体返回JSON后框架把它连同错误码交给LLM让LLM结合用户上下文生成自然语言回复这里最容易被忽略的是第2步。很多人让LLM直接输出JSON就去执行了一旦参数缺失技能返回一堆错误LLM只能边兜路边道歉。我的做法是框架层拿到LLM的结构化输出后先用JSON Schema做校验缺了必填参数就直接返回给LLM“请补充以下参数tracking_no”而不是进入执行环节。这一步把很多无意义调用都挡在了外面。3.3 隔离与权限看着不起眼出事就是大事技能跑到最后你会发现真正难的不是让模型调得对而是控制技能能干什么。我在生产环境遇到过一次事故一个文生图技能的参数没做约束用户传了超长文本直接把下游渲染服务的内存打爆了。从此我给自己定了几条硬规矩执行体按最小权限跑能无权限就不给权限能只读就不读写外部HTTP服务、Shell命令类技能必须放进沙箱或容器限制网络访问和资源上限每个技能设独立的超时和并发上限防止一个技能拖垮整个Agent技能可观测性每个技能的调用次数、失败率、平均耗时必须有日志和监控面板这些听起来不像AI工程师该操心的但Agent应用的在线可靠性恰恰取决于这些基础设施。你永远不希望用户问个快递结果因为某个技能炸了整个机器人都不回话。4. 从单个技能到技能编排多技能协作才有Agent的样子4.1 一个真实场景客服退款流程单个技能好用不算本事多个技能编排起来还能不崩才是Agent化的分水岭。我拿一个再常见不过的售后场景说事。用户说“我要退货但找不到订单号了刚查快递已经签收了。”这个需求按顺序拆开是这样的先查快递确认签收状态query_express_status用手机号或用户ID查订单拿到订单号和商品信息query_order校验订单是否在退货期内、是否满足退货条件validate_return_policy创建退货单create_return_order把结果通知给用户send_message每一步的输出都是后一步的输入。这个链条如果靠模型自己在一次对话里“临场发挥”去调用大概率会在某一步卡住。所以我的做法是把这套流程定义好、固定下来。编排不在prompt里而是在代码里。原因很简单稳定压倒一切关键业务链路不能赌模型的随机性。4.2 三种编排模式按场景选我把实际项目里的编排模式归纳成三种。顺序编排最常用。上面这个场景就是典型的顺序链路A的结果是B的入参环节是固定的。用工作流Workflow的方式写死中间任何一步失败就停下来向用户说明不硬闯。条件编排适合分叉逻辑。比如“查询订单”之后如果订单状态是“已发货”就走物流查询如果“待付款”就发提醒如果“已关闭”就解释原因。这种我用简单的规则引擎或者直接在代码里表达分支。并行编排适合互相独立的能力。比如用户问“我的订单和物流一起查一下”两个技能没有依赖关系就可以用asyncio或Promise并发调用等两个结果都回来再合成最终回复。并行能省一半时间但要注意别让一个慢接口拖住整体等待时间。编排代码看起来是最不“AI”的部分——它其实就是传统后端编程。但这部分恰恰决定了Agent的稳定性。我见过太多人把编排完全交给模型“自由发挥”结果是同一个问题今天走A路径、明天走B路径用户完全摸不到规律。我把主干流程做成确定的模型只在分支参数的填充上有自由度。4.3 技能间数据传递用一个上下文粘合多技能协作最烦的是数据传递问题。A技能返回了一个大JSON里面fields很多B技能只需要其中两个字段这中间怎么倒我的做法是维护一个任务上下文Context结构大致是{ user_id: u_123, session_id: s_456, current_input: 我要退货..., data: { query_express_status: { status: DELIVERED, latest: 已签收 }, query_order: { order_no: 202403011234, item: 智能音箱 } } }每个技能的输出会被按skill_id存进context.data下游技能按需取用。这样有几个好处不依赖模型在上下文中“回忆”上一步的结果每个技能看context能知道全局进度出了问题排查也方便——直接看data里存了什么。代价就是技能的输出别太大。我要求执行体返回之前先裁减只保留对后续有用的字段动不动塞几百条明细既存不下也会干扰模型在长对话里的注意力。上下文是Agent的“工作台”不是数据仓库。5. 实测半年后最值得说透的五个坑5.1 把description写得太“正确”反而没人会用刚上线查物流技能时我的description写的是“根据快递单号查询物流轨迹返回配送状态与时间节点”。自认为简洁准确。结果上线第一周这个技能的调用率低得可怜用户在对话里说“我的快件怎么还没到”模型居然去调了查订单的技能。我把线上日志拉出来一看就明白了“快件”“包裹”“到哪了”“发货没”这些用户嘴里的高频词我的description一个都没覆盖。模型只能靠语义瞎猜。问题不在模型在我把description写成了接口文档而不是使用说明。修改后的description加上了这些口语变体和一条明确的负面排除“仅在用户明确提到快递/包裹/物流时使用不要用于查询订单金额、申请退款或联系客服。”第二天召回率就上来了。这件事让我养成一个习惯每次上线新技能先去看真实对话日志把用户的原话抄进description。5.2 返回结果太长多轮对话直接“失忆”有次做报表导出技能执行体返回了用户所有历史订单整整一百多条明细。第一次回复时模型还能引用开头几条到了第三轮对话用户问“我之前那单红色的毛衣呢”模型完全找不到方向因为上下文里太多无关数据把它淹没了。后来我养成了一个习惯执行体的返回值不是“数据全量”而是“供模型进一步推理的摘要”。全量明细走接口侧缓存技能只返回一个精简视图——总数、最近三条、关键字段。如果后续步骤真的需要全量再提供显式参数去查。这个改动对长会话的稳定性提升非常明显。记住Agent的技能输出不是给人看的报表是给模型用来推理的燃料。燃料太杂引擎反而点不着。5.3 模型自动填参错得比你想的更多参数自动填充是Agent技能最香的特性也是最不可控的环节。拿日期来说用户说“查一下上周五的快递”模型真有可能把“上周五”解析成今天的日期因为它默认这是当前时间附近。它会给一个看起来合理、实际错误的date参数。对付这个问题我的办法是在参数的description里写得非常显式“请使用ISO格式日期YYYY-MM-DD不要使用相对日期今天是2026-04-20上周五应为2026-04-17。”虽然啰嗦但参数错误率明显降低。还有一类问题是参数合并。用户说“南京到上海的快递”模型有可能把“到上海”当成收件地址传成两个address字段。所以执行体入口必须做归一化清洗。这类脏数据问题框架层很难通用地拦截只能在技能内部做防御。5.4 技能描述相似度高模型开始“随机跳舞”技能库到二三十个的时候我碰到了所谓的“意图冲突”查订单和查物流、预约安装和售后上门两两之间的description高度相似模型选择开始变得不稳定。同一个问题上午调A技能、下午调B技能这不是玄学是模型面对相似文本时的内在随机性。解法有两层。第一层是做冲突检测上线前跑一批“技能路由测试集”把最容易混的场景喂给模型看它选哪个第二层是给冲突技能加差异化锚点——在description里明确加入对方没有的关键词并且把负面排除写得更重。比如预约安装和售后上门一个强调“按预约时间派师傅上门”一个强调“已有故障设备需要维修”把关键词分清楚之后路由稳定了很多。技能库越大这部分维护工作越重要它本质上是给模型画的一张“技能地图”。5.5 技能升级不灰度线上随时可能翻车代码领域的灰度发布习惯在Agent技能这里经常被忽略。很多团队改完一个技能的执行逻辑就直接部署结果老用户那边突然就变了样子——同样一句话昨天触发的是按订单号查物流今天变成了下单前先做库存校验体验直接跳变。我现在维护技能的版本是严格走灰度流程的SKILL.yaml里的version字段每次迭代递增上线先切1%流量观察错误率和调用延迟确认稳定后再逐步放量。如果技能包含执行体代码代码本身也要走构建-测试-发布的管道不能只改yaml就热加载。这个流程看着重其实只是把传统后端发布的最佳实践平移到了技能系统上。少走一步线上就要替你多还一次债。说实话Agent Skills这套东西发展到今天市面上已经有各种框架帮你把注册、调用、编排都做了。但框架能帮你把路铺好不能替你把技能定义好、描述写好、参数设计好。我复盘这半年的实战最深的体会是技能系统的上限不取决于模型多聪明而取决于你愿意在“如何把一个能力说明白”上花多少心思。先把一个技能定义扎实再谈Agent能做什么大事。