1. 先搞清楚:Agent的技能到底是什么
1.1 技能不是普通函数,差的不是语法
做Agent开发有一段时间的人应该都有同感:把一个大模型接上工具,让它能查天气、发邮件、查数据库,这事儿本身不难。真正难的是让模型在合适的场景下、用正确的方式、把一系列动作组合起来完成一个完整目标。这就是“agent-skills”或者说Agent技能体系要解决的核心问题。
我理解的技能,不是单个工具函数,而是把“模型的一个能力片段”做了标准化封装。比如“查询订单状态”是一个技能,“根据用户情绪调整客服话术”也是一个技能。前者需要调用接口拿数据,后者可能只依赖模型自身的推理能力。从模型视角来看,技能就是它可以去调用的一组能力,每个能力有名字、有描述、有入参、有出参、有执行逻辑,还有触发它的前置条件。
很多人容易把技能和Function Calling混为一谈。我的看法是:Function Calling是底层协议,技能是在这个协议之上设计出来的一层语义封装。打个比方,Function Calling相当于给你一把电钻,技能则是告诉你“在什么位置、用什么角度、打多深的孔”。没有电钻不行,但只有电钻也干不了装修的活儿。
另外一个常见的误区是觉得“模型能力强了,技能设计可以随便一点”。实测下来恰恰相反。模型越强,它越会主动调用看起来可用的工具,哪怕你的技能描述写得含糊,它也会凭着上下文猜。猜对了皆大欢喜,猜错了排查起来非常痛苦。所以我写这篇东西的核心观点就一句话:技能设计是一等公民,前期多花一小时,后期省下十小时。
1.2 我把技能拆成了三层来理解
在动手做技能之前,我习惯把它拆成三层:语义层、执行层、治理层。这三层各管各的事,但缺一不可。
语义层解决的是“模型知不知道这个技能是干嘛的”。这一层包括技能名称、描述信息、参数说明、适用场景、典型示例。模型接受到用户请求后,先通过语义层来挑选应该调用哪个技能。所以这层做得不好,后面执行层写得再漂亮也没用——模型根本不会选到它。
执行层解决的是“技能被选中后,怎么稳定地把事情做完”。这一层包括实际的代码逻辑、API调用、数据处理、异常处理、超时重试等。执行层是工程师的主战场,也最容易让人上头,一上来就写各种复杂逻辑,反而忽略了语义层。
治理层解决的是“多个技能之间如何协作,冲突如何规避,权限如何管控”。比如某个技能依赖用户登录态,某个技能只允许特定角色调用,某个技能执行时需要审批确认。这些如果不在设计阶段想清楚,上线之后一定会被各种边界情况找上门。
把技能拆成这三层之后,你就会发现,写一个技能本身不难,难的是一套技能体系能不能互相配合。后面我会按这个框架展开讲,每一个环节都配合实操案例。
2. 技能设计里最容易被忽视的一环:描述质量
2.1 一份合格技能描述应有的四个部分
很多团队在给技能写描述时,就是一句话:查询订单状态。这种描述信息量太低,模型遇到相似场景时根本分不清该选哪个技能。我自己在实践里总结了一套描述模板,分为四个部分:技能职责、触发场景、执行约束和典型示例。
技能职责就是一句话说明这个技能做什么,最好包含核心动词和对象。比如“查询订单当前物流状态和预计送达时间”就比“查询订单”清晰得多。触发场景要写清楚“什么情况下用户应该用这个技能”。比如“当用户询问包裹走到哪里了、什么时候能到、物流是否异常时,应使用此技能”。这一步很关键,因为用户不会直接说“我要调用查询物流技能”,他会说“我东西怎么还没到”,你要让模型能识别出这句话背后的意图。
执行约束写的是调用这个技能时需要满足的条件,比如是否要求用户已登录、是否需要传订单号、接口的超时时间、是否有调用频率限制。这些信息模型自己是推不出来的,全靠写在描述里。典型示例则是给模型提供的参考样本,让它学习到“这个技能对应的用户问法长什么样”。示例越多越多样化,模型选择的准确率就越高,这个动作几乎没有任何副作用。
2.2 怎么写触发场景,让模型选得对
我在调试时会反复看日志,发现模型选错技能的原因,大部分都出在触发场景写得不对。写触发场景最容易犯的毛病是写得太抽象。比如“处理用户关于物流的问题”这种描述,模型看了等于没看。
正确做法是写具体问法,最好能覆盖正面问法、侧面问法、情绪化问法三类。以下面这个“查询订单物流”技能为例:
正面问法:“我的包裹到哪了”“物流更新了吗”“什么时候能送到”
侧面问法:“我买的手机怎么还没发货”“上周下单的东西现在到哪儿了”
情绪化问法:“我的快递是不是丢了”“怎么好几天没动静了”
把这些问法写进触发场景里,模型在遇到新表达时就能通过语义相似度正确路由。虽然大模型有很强的泛化能力,但你给它越明确的参考样本,它的选择稳定性就越高。这个事没有太多玄学,就是数据质量和覆盖面的问题。
参数说明这一块也值得多说一句。不同模型对参数schema的理解方式不同,但共同点是:参数名称和描述一定要贴合业务语义。比如传查询起始日期的参数,命名成“start_date”然后描述里写“用户想要查询的起始日期,格式为YYYY-MM-DD”,比只写“start日期”要有用得多。最好把可选项的枚举值也列出来,减少模型自由发挥的空间。
3. 从零开始定义一个可用技能:完整实操
3.1 先想清楚技能边界
动手写代码之前,我建议先在文档里把这个技能的设计案写清楚。以“查快递物流”这个技能为例,我会先定义它的边界:它只负责查物流信息,不负责退货申请,也不负责修改收货地址。边界不清晰的技能,后期很容易被模型误调。
定义技能边界时,我会同时列一个问题清单:这个技能的输入最少需要哪些信息?哪些字段是必填,哪些可以自动获取?用户问得模糊时,是主动澄清还是采用默认值?技能执行失败时,返回给用户的话术应该是什么?这个清单一口气列完,后面写代码的速度会快很多。
还要想清楚技能的执行方式:是同步执行还是异步执行。如果是同步执行,接口响应时间必须控制在模型等待范围内;如果是异步执行,就要设计好任务轮询或回调机制。很多Agent卡顿问题,根源就是技能设计成了同步,但实际执行耗时太长,模型侧等待超时。
我自己的习惯是:超过3秒才能出结果的,优先拆成异步任务,先给用户一个“正在查询”的反馈,再通过轮询把结果补回来。这个体验设计对用户感知影响很大。
3.2 代码实现和配置要点
以TypeScript为例,我会先在项目里创建一个技能文件,比如skills/queryLogistics.ts。代码结构分为四个部分:技能元信息、参数校验、业务执行、结果格式化。每部分都单独拆开,后续扩展和维护都会方便很多。
// skills/queryLogistics.ts export const queryLogisticsSkill = { name: "query_logistics", description: "查询订单物流状态和预计送达时间", triggers: [ "用户询问包裹位置、物流进度、预计送达时间", "用户反馈快递长时间未更新或疑似丢失", "用户询问发货状态或物流异常原因", ], parameters: { type: "object", properties: { order_id: { type: "string", description: "用户的订单号,必须是纯数字字符串", }, }, required: ["order_id"], }, async execute(params: { order_id: string }) { // 参数校验 if (!/^\d{6,20}$/.test(params.order_id)) { return { success: false, message: "订单号格式不正确,请确认后重试" }; } // 业务执行 const logisticsInfo = await this.queryLogisticsFromAPI(params.order_id); // 结果格式化 if (logisticsInfo.completed) { return { success: true, data: { status: "delivered", summary: `您的包裹已于${logisticsInfo.deliveredAt}签收`, detail: logisticsInfo.trace, }, }; } return { success: true, data: { status: "in_transit", summary: `包裹正在运输途中,预计${logisticsInfo.eta}送达`, detail: logisticsInfo.trace, }, }; }, };这段代码只做了一件事:把技能的四层逻辑隔离开。参数校验放在最前面,不让脏数据进入业务逻辑;业务执行只负责对接外部API;结果格式化负责把第三方接口返回的复杂结构翻译成用户能看懂的平实语言。第三个环节很容易被忽略,但它直接影响用户对Agent的信任感——用户要的是“你的包裹预计明天下午送达”,不是一串JSON。
关于结果格式化,我的原则是:如果技能本身可以做,就不要让模型再去加工。很多人习惯把第三方数据原样丢给模型,让模型生成最终话术。这样做可行,但会带来两个问题:一是模型可能编造数据,二是额外增加一次模型调用,成本和延迟都上去了。技能层直接输出格式化结果,只是在必要情况下让模型做润色,这样最稳定。
3.3 本地测试与效果验证
技能写完之后,不能只在正常流程里测一遍就完事。我会在本地做三轮测试:正常路径测试、边界参数测试、意图干扰测试。
正常路径测试就是给一个合法订单号,看链路通不通;边界参数测试是故意传空字符串、传字母、传不存在的订单号,看技能能不能优雅地返回错误信息;意图干扰测试是最容易被人忽略的,我会准备一批和订单查询相近但实际不同的问法,比如“帮我取消订单”“我要退货”,看模型会不会错误地选到这个技能上。
这一轮测试下来,会发现不少问题。最常见的是参数schema定义太宽松导致模型把缺失值填成空字符串,还有触发场景和类似技能重叠导致的误路由。发现问题后回到设计文档去改描述和参数定义,迭代两三轮之后技能的稳定性才会明显提升。
我在实践中还会用一些自动化的方式来做回归测试,把历史对话样本整理成测试集,每次修改技能描述之后跑一遍,看选择准确率和执行成功率有没有下降。这听起来像是要做一套测试平台,其实用简单的脚本加CSV就能跑起来:
# test_skill_routing.py import json test_cases = [ {"user_input": "我的快递到哪了", "expected_skill": "query_logistics"}, {"user_input": "这个订单还能改地址吗", "expected_skill": "update_address"}, {"user_input": "我要退货", "expected_skill": "return_apply"}, # ... 更多历史对话样本 ] # 调用模型工具选择接口,对比选择结果 def run_routing_test(model_client): total = len(test_cases) correct = 0 for case in test_cases: selected = model_client.select_skill(case["user_input"]) if selected == case["expected_skill"]: correct += 1 else: print(f"FAIL: {case['user_input']} -> {selected} (expected {case['expected_skill']})") print(f"Accuracy: {correct}/{total}")如此反复,技能描述的迭代就有了数据支撑,不是靠拍脑袋改。
4. 常见问题与排查技巧实录
4.1 技能列表越加越多,模型反而越选越乱
这是我踩过最大的坑。技能从5个加到30个的时候,模型的选择准确率不升反降。原因很好理解:技能越多,描述之间的语义重叠概率越高,模型面对的决策空间越大。
解决思路有两个:收敛技能粒度和建立技能导航机制。
收敛技能粒度,就是把语义相似、执行逻辑相近的技能合并成一个。比如“查询订单物流”和“查询订单状态”如果在一个业务体系里语义高度重叠,就直接合并。宁可一个技能内部多做几个分支,也不要在顶层让模型自己去判断“用户到底要物流还是要状态”。
建立技能导航机制,则是提供一个顶层的“skill router”技能,先让模型判断用户意图属于哪一类,再分发给子技能。这相当于把一次大决策拆成两次小决策,准确率会高很多。
还有一个经验:当技能数量超过15个时,建议把所有技能名称和一句话描述生成一份索引,交给模型先做“粗筛”,再详细参考候选技能的完整描述。这种两段式选择能有效降低干扰。
4.2 参数类型写对了,模型还是传错值
出现这种情况,大概率是描述里没给足候选值或格式示例。比如一个参数定义成string类型,描述只写“订单号”,模型就有一定概率把用户输入的“我的订单”这种口语化表达原封不动地传进来。
解决方式是把描述写具体:明确值的来源、格式、必需长度、示例值。如果是枚举值,就把枚举列表写进描述里;如果是需要从历史记录里获取的,就明确写成“根据用户上下文中的订单号字段获取,不要猜测或编造”。
参数层面的另一个坑是必填参数太多。本来一句话就能查到结果的事,技能却要求用户提供登录态、订单号、验证码三个参数。模型只能去反问用户,对话体验一下就差了。我的原则是:尽量让参数可从上下文推导,必填参数只保留真正必要的,能自动获取的就不让用户填。
4.3 工具调用无响应或超时
Agent长时间不回复,多半不是模型的问题,而是技能执行卡住了。排查时我会先看日志,确认技能是否真的被触发;其次确认技能内部是否有第三方接口调用,接口是否超时;再看整个链路是否有重试机制。
第三方接口超时是常见根源。我习惯给所有外部调用都加上超时和降级逻辑:
async function queryLogisticsFromAPI(orderId: string) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); try { const response = await fetch(`https://api.example.com/logistics/${orderId}`, { signal: controller.signal, }); return await response.json(); } catch (error) { // 降级处理:返回缓存数据或友好错误提示 return { completed: false, trace: "物流信息暂时无法获取,请稍后再试" }; } finally { clearTimeout(timeout); } }超时时间、重试次数、降级策略,这些在设计期就要定好,不能等上线了再补。另外,如果多个技能会并发执行,也要关注并发限制。很多API提供商对单账号有QPS限制,技能层不做限流的话,调用一多就会触发限流报错,用户看到的反馈就是“服务暂时不可用”。
4.4 技能组合起来就容易失控怎么办
单个技能测试没问题,一旦让Agent在一个任务里串联多个技能,就开始出乱子。常见情况是:模型先调了A技能,拿到结果后又调了A技能一次,然后才想起该调B技能。或者A技能的执行结果被B技能拿去当参数,但格式对不上,导致B技能报错。
这个问题的根源在于技能之间的依赖关系没有设计好。我用的一个有效办法是,在技能执行结果里明确标注哪些字段可以被其他技能使用,以及它们的格式规范。相当于在技能之间定义了一份“数据契约”。
另外一个更实用的办法:把频繁组合的技能做编排。比如“查询物流+推送客服反馈”这两个技能组合频次很高,就直接做一个复合技能,内部串行调用两个服务,减少模型做决策的环节。这个做法牺牲了一些灵活性,但换来了稳定性和响应速度,在业务需求明确时非常值得。
4.5 最后说权限与安全
技能越做越多,权限控制就必须跟上。我经历过一次安全事故:一个技能本应只读数据,但由于权限位配错了,用户通过对话绕了一圈,间接触发了一个写操作。那次之后,我要求所有技能在元信息里强制声明权限级别。
- 只读技能:数据查询、信息获取
- 写操作技能:必须经过二次确认
- 高危操作技能:需要额外鉴权,甚至审批流程
模型在执行技能时,其实并不知道操作的高危等级,它只是按指令行动。所以技能设计者必须把安全兜底做在代码里,不能指望模型自己能识别“这个操作是不是该停一下”。在技能描述里写清楚约束的同时,执行层对关键操作也要有强制校验,形成双保险。
我现在对技能体系的感受是:它不是一个静态的代码仓库,而是一套持续迭代的能力系统。技能描述、参数定义、触发场景、权限策略都需要跟随业务变化不断优化。把技能当成一份长期维护的知识资产来运营,而不是用完就丢的工具函数,这个Agent才会越用越顺手。基于我自己的项目经验,这样的认知转变,比多写几个技能本身更有价值。