我手头这个Agent项目,前前后后调了好几周,最让人崩溃的不是模型不够聪明,而是它“什么工具都会,却总不好好干活”。让它查个订单,它能自己编一个订单号;让它处理退货,它能绕到物流接口去瞎猜包裹状态;稍微复杂点的多步任务,它能把同一步重复执行四五次。最开始我以为换个大模型就能解决,后来发现不是;又以为是提示词写得不够狠,改了十几版还是不稳定。最后把整个思路翻了个底朝天,才意识到问题出在“能力”的组织方式上——所有本领都堆在提示词里,没有抽成结构化的技能体系。这篇东西,我就想围绕 agent-skills 这个主题,把“怎么把模型手里的能力变成一组可复用、可评估、可维护的技能”这件事掰开揉碎讲一遍。
这篇文章不是给你讲某个现成框架的API怎么调用,而是讲一套方法论:从业务任务里拆出技能、把技能定义成接口契约、搭一个简单的技能库、再给技能做测试和迭代。适合正在做Agent落地、被模型乱调工具折磨过的人,也适合刚接触Agent、想从第一天就把架构搭对的开发者。哪怕你手头只是一个几千行的小项目,这套思路一样能用。
1. 先想清楚再动手:Agent技能、工具和提示词到底差在哪
1.1 技能不是工具也不是提示词,而是一层“稳定套路”
很多人在做Agent的时候,把“给模型接上工具”当成核心工作。工具确实是基础,但工具只是一组能力接口,比如一个订单查询API、一个物流轨迹接口、一个优惠券计算函数。模型知道这些接口存在,不代表它知道“在什么场景下、按什么顺序、用什么参数、怎么处理异常”去调用它们。这就是技能要解决的问题。
我习惯把这三个层次分开看:工具是汽车本身,提示词是路况提示牌,技能则是驾驶技术。汽车停在路边,路况提示牌告诉你前方转弯,但你得先掌握起步、换挡、刹车这一整套动作,才能安全开到目的地。模型虽然能从提示词里知道“可以用订单接口”,但真要处理一个“用户没说订单号、还想知道包裹到哪了”的场景,它就会开始瞎发挥。技能就是把这套“动作序列”固化下来:先提取订单号,提取不到就追问;查到物流信息后,只把可透传的状态展示给用户;遇到异常状态先解释原因,而不是把报错日志直接甩出来。
所以技能凭什么比一段提示词靠谱?因为提示词是给模型看的一次性说明,模型每次执行都会重新“即兴发挥”;技能则是一个可以被反复调用、单独测试、独立版本管理的封装单元。你在技能里写清楚“做什么、不做什么、怎么兜底”,模型的选择空间就从“自由发挥”收窄到“按套路执行”。这一步收敛,正是Agent稳定性提升最大的来源。
1.2 为什么要把技能拆成独立体系:复用、演进和兜底
我在项目里最开始也偷懒过,把工具调用逻辑直接写在一个巨大的system prompt里,所有业务规则全靠几段长文本描述。结果遇到了三个绕不开的问题:第一个是复用。客服场景里有“查订单”“查物流”“算优惠”这些动作,在退货退款处理里要用,在售后咨询里也要用,但每次写prompt都是一遍新解释,稍不留神两个地方的描述还打架。第二个是演进。业务规则一变,我得在prompt里找到对应段落改,改完还得担心会不会影响其他部分。第三个是兜底。只要模型自由选择工具,就一定会出现参数幻觉、重复调用、错误工具等问题,而提示词对这件事几乎无能为力。
把技能独立成体系之后,三个问题一起解决。“查订单”做成了技能,所有下游流程都引它复用;“优惠计算”规则变了,只改这个技能,跑一遍测试集就行;每个技能内部都写了异常处理和参数校验,模型调用时就算表现不好,框架层也能把它拽回正轨。我们在项目里实测下来,单纯重建技能库这一件事,就让同一个任务的平均工具调用次数降了将近一半,无效调用明显减少。这个收益不是模型升级带来的,是结构本身带来的。
2. 四步法拆解任务:把业务流程变成一组可调用的技能
2.1 盘点任务日志:从高频、易错、强规则三个维度挑环节
动手拆技能之前,你要先搞清楚“哪些环节值得被做成技能”。不是所有逻辑都得技能化,否则你会得到一个比prompt还难维护的技能库里。我的做法是从任务日志开始:把所有用户对话中触发过工具调用的样本捞出来,按业务目标分组,然后从三个维度打分——是不是高频、是不是易错、是不是强规则。
高频很好理解,一天出现几百次的动作,即使再简单也值得做成技能。易错主要看特征:参数难提取的场景(比如用户说话时没给订单号)、需要多轮确认的场景、多步骤操作中一步错步步错的场景。强规则指的是业务逻辑层层叠叠的场景,比如退货退款要判断购买时间、商品状态、物流是否签收,这种判断放在提示词里就要写很长且容易漏,放在技能里可以用显式逻辑实现。低频、一次性的动作就不要做技能了,留在普通流程里让模型自由处理,反而简单。
当时我们客服Agent的任务盘点表大概是这样的:
| 业务动作 | 频次 | 易错点 | 是否做技能 | 理由 |
|---|---|---|---|---|
| 查询订单状态 | 高 | 订单号缺失、格式错误 | 是 | 参数校验复杂,复用率高 |
| 查询物流轨迹 | 高 | 经常与订单查询混淆 | 是 | 边界需要锁死 |
| 退货退款处理 | 中 | 多步骤依赖、状态流转多 | 是 | 流程长,必须编排 |
| 优惠券计算 | 中 | 规则经常变 | 是 | 方便单独迭代 |
| 转接人工客服 | 低 | 几乎无 | 否 | 太简单,直接写流程里 |
这个盘点做完,你就有了一条清晰的边界:哪些走技能,哪些不走走。别凭感觉拍脑袋,拿日志说话最靠谱。
2.2 划分技能粒度:原子技能负责执行,组合技能负责编排
技能粒度是一个经典的分寸问题。太粗,一个技能里面塞了“查询订单、判断退货资格、生成退货单、通知用户”四条逻辑,复用度低;太细,光一个退货流程就拆出八个技能,模型选择成本巨高,还容易选错。我这里采用的是两级结构:原子技能和组合技能。
原子技能是一个不可再拆的最小可复用动作。比如“查订单”“查物流”“校验用户身份”,它们各自独立完成一件事,输入输出清晰,没有“然后”“再”这种流程描述。组合技能是把多个原子技能编排成一个完整业务流程的动作。比如“退货退款处理”,它的内部流程是“校验身份 → 查订单 → 判断退货资格 → 生成退货单 → 通知用户”,每个步骤都指向一个原子技能或者简单的工具调用。
判断粒度是否合适有一个简单的检验方法:读技能的描述文本,如果你发现里面出现了超过两次的“然后”“再”“同时”,那它八成不是原子技能,应该拆开。反过来,如果两个技能永远一起出现,从来没有单独被调用的场景,那它们就该合并成一个组合技能。技能数量的总控制也很重要,我当时给自己定的线是:技能总数尽量控制在几十个以内,超过这个数,你就要开始考虑更复杂的检索和路由机制,复杂度会明显上升。
2.3 用接口契约定义技能:描述、Schema、异常一个都不能少
一个技能要被模型和框架正确使用,需要一份清晰的“接口契约”。我习惯用YAML来定义,每一项字段都有它必须存在的理由。一份技能定义大致长这样:
name: fetch_order version: 1.2.0 description: > 根据订单号和用户ID获取订单的基础信息与当前状态。 当用户询问“我的订单怎么样了、订单到哪一步了、帮我查下订单”时使用。 when_not_to_use: > 当用户只询问物流轨迹、没有给出订单号时不要使用; 当用户明确在问退款进度时,应使用 process_refund 而不是本技能。 input_schema: 订单号: type: string required: true source: user_query validation: "^[A-Za-z0-9]{12,20}$" 用户ID: type: string required: true source: session_context output_schema: order_id: string status: string items: array created_at: string tool_sequence: - call: query_order_api params_from: [订单号, 用户ID] retry: true max_retries: 3 error_handling: on_missing_order_id: ask_user_for_order_id on_order_not_found: return_order_not_found_message on_timeout: return_temporary_unavailable timeout: 3s这里面最容易被忽略的是 when_not_to_use 字段。只写“什么时候用”,模型会在边界场景里反复横跳;多写“什么时候千万别用”,误触发概率会明显下降。这就像给模型划了一条清晰的禁区线,它能更容易理解技能的边界。input_schema 里面的 source 字段也很关键,它告诉执行器参数是从用户当前消息里抽,还是从会话上下文里拿,还是上游技能传过来的。没有这个约束,模型经常会去上一轮对话里乱抓一个订单号填进来,这种错误我见过太多次了。
2.4 画出技能依赖关系,避免循环调用和版本混乱
有了多个技能之后,它们之间不是相互孤立的。组合技能会依赖若干原子技能,它们之间的依赖关系必须被显式管理。我习惯在技能定义里加一个 depends_on 字段:
name: process_refund version: 2.0.0 depends_on: - fetch_order: ">=1.2.0" - verify_identity: ">=1.0.0"这个字段有两层价值。第一层是循环检测。构建技能库的时候,遍历依赖关系,如果发现技能A依赖B、B又依赖A,直接报错,不能等上线跑起来才发现死循环。第二层是版本兼容。组合技能绑定的原子技能版本范围一旦被打破,行为可能突变。有一次我们升级了 fetch_order,把订单状态的枚举值改了,结果 process_refund 里针对“待收货”状态的判断逻辑全部失效。因为版本绑定做得好,回归测试立刻暴露了问题,没有让错误跑上线。
画依赖关系的时候不用画得很复杂,在注册表里维护一张简单的表就够了:技能名、依赖技能、依赖版本、当前版本。组合技能之间的依赖也要检查,避免出现一个组合流程递归调用自己的情况。这一步做扎实,后面迭代升级才有底气。
3. 技能库落地实操:从目录结构到执行器一步步搭起来
3.1 技能仓库的目录结构、命名规范和测试用例怎么放
理论讲完,开始落地。第一步是把技能仓库的目录结构搭起来。我采用的是一套非常朴素但够用的约定:
skills/ atomic/ order/ fetch_order/ SKILL.yaml handler.py test_cases.json logistics/ query_logistics/ SKILL.yaml handler.py test_cases.json identity/ verify_identity/ SKILL.yaml handler.py test_cases.json composite/ refund/ process_refund/ SKILL.yaml flow.py test_cases.json coupon/ calc_coupon/ SKILL.yaml handler.py test_cases.json每个技能一个独立目录,SKILL.yaml 保存元数据和接口契约,handler.py 或 flow.py 保存实际执行代码,test_cases.json 保存这个技能的测试用例。把测试用例和技能代码放同一个目录,是我特别坚持的一点。技能迭代时,第一件事就是跑这个目录里的测试,而不是去找一个集中的测试仓库——分散维护的好处是,每个人改技能的时候注意力能集中在当前技能,测试用例也会和技能一起演进。
命名规范我用的规则是“动词_对象”,全部小写加下划线。比如 fetch_order、query_logistics、verify_identity。不要用“订单查询”“OrderCheck”这种五花八门的风格,否则后面做技能发现和检索的时候,文字匹配会出各种幺蛾子。命名一旦定下来,就写进规范文档,新技能入库时必须遵守。
3.2 注册表与技能发现:让Agent在几十个技能里找到最合适那一个
技能建好了,还得让模型能“看到”它们、能选对。这一步我称之为技能发现。最简单直接的方式是维护一份技能注册表,把每个技能的元数据汇总成一个数组,Agent启动时加载到上下文里。技能数量少的时候,直接把这几个技能的精简描述放进system prompt就够了。技能数量超过十五到二十个,就要考虑检索式发现:先用用户查询去检索技能描述,把最相关的Top-K个技能注入上下文,而不是把所有技能都堆给模型。
注册表里的描述字段,是决定检索和模型选择质量的关键。这个描述要写得像一个“搜索索引”,把用户可能说的话映射到技能上。当时我们有一个教训,“获取订单信息”和“查询物流状态”这两个描述写得太接近,模型经常把物流查询请求路由到订单查询技能。后来我把注册表里的描述重新梳理了一遍,明确写上“查物流轨迹请用 query_logistics,此技能返回包裹运输节点;fetch_order 只返回订单基础状态和商品列表”,误触发率立刻降下来了。
注册表的形式可以很简单,一份JSON或YAML都可以:
skills: - name: fetch_order description: 查询订单基础信息与当前状态,用于用户问“订单怎么样了” version: 1.2.0 status: active - name: query_logistics description: 查询包裹物流轨迹与运输节点,用于用户问“包裹到哪了” version: 1.1.0 status: active这里的关键是:注册表不只是给人看的,更重要是给模型看的。你的注册表描述必须与用户语言习惯对齐,不能只写技术名词。用户不会说“我要调用订单查询服务”,他会说“我东西咋还没到”。描述里最好把这类口语化的触发点也捎上。
3.3 执行器设计:一条技能调用的完整链路和容错细节
技能发现是选择问题,执行器是执行问题。一条技能调用的完整链路大概是:预处理用户输入,提取上下文;把用户查询和技能描述做匹配,选中一个技能;从输入中抽取技能所需参数;校验参数合法性;按 tool_sequence 逐个调用工具;把结果按 output_schema 整理返回;遇到错误按 error_handling 兜底。
其中最容易出问题的环节是参数校验。模型抽取出来的参数,有时候字段名跟schema不一致,有时候多抽了信息,有时候把订单号里的字母O当成了数字0。一个靠谱的执行器要做几件事:类型转换、正则校验、枚举匹配、缺失字段提醒。我当时的思路就是用schema生成一个校验函数,凡是不满足校验的参数,直接返回“参数不完整,需要追问用户”,而不是默不作声地把错误参数传给下游工具。
伪代码大致长这样:
def run_skill(skill, session, user_input): params = extract_params(skill.input_schema, user_input, session) validated = validate_params(params, skill.input_schema) if not validated.ok: return {"status": "need_user_input", "message": validated.message} result = {} for step in skill.tool_sequence: resp = call_tool(step["tool"], params=build_call_params(step, result)) if step.get("retry") and resp.is_error: resp = retry_with_backoff(step, params) if resp.is_error: return handle_error(skill, step, resp) result.update(resp.data) return build_output(skill.output_schema, result)执行器还必须处理两类特殊场景。第一类是超时兜底。一个技能整体执行超过阈值(比如3秒),就停止后续调度,返回“当前服务繁忙,请稍后再试”,不能把超时错误堆给用户。第二类是写操作的幂等设计。像“生成退货单”这种操作,如果第一次调用超时了,重试很可能会生成重复的退货单。解决方法是给每次调用生成一个 client_request_id,下游接口靠它做去重,重试时带上同一个ID,这样最多只会有一个退货单被创建。
3.4 提示词侧的小技巧:把技能清单给模型看,但别把工具代码丢给它
执行器搭好之后,还有一道工序:在提示词层面让模型更稳定地选择技能。这里有个经验:给模型看的技能清单,应该只包含“技能名+一句话能力描述+关键输入要求”,而不是把工具函数的完整代码丢给模型。模型要的是高层语义,不是实现细节。实现细节太多了,反而会干扰它的判断。我当时的system prompt里是这样写技能清单的:
可用技能: 1. fetch_order(订单号, 用户ID):获取订单基础信息与当前状态 2. query_logistics(订单号):查询包裹物流轨迹,没有订单号时不可调用 3. process_refund(订单号, 原因, 金额):执行退货退款完整流程,需要客服二次确认 规则: - 用户未提供订单号时,先向用户索要订单号,不要调用任何技能。 - 用户只问包裹到哪了,用 query_logistics,不要用 fetch_order。 - 所有参数必须从用户当前消息中提取,不要使用历史对话中的过期数据。 - 所有技能均无法处理时,明确告知用户转人工,不要强行调用技能。最后一条规则特别重要。很多Agent出问题不是因为选错技能,而是因为“没得选还要硬选”。给模型一个“无技能可用就转人工”的出口,能让它在该认怂的时候认怂,而不是为了完成任务去编参数、硬调接口。这个出口不仅在提示词里要写,执行器里也要有对应的路由逻辑。
4. 技能不是写完就完了:评估、监控与迭代三板斧
4.1 离线测试集:给每个技能建立体检标准和回归基线
技能库建完只能说明它能跑,不能说明它跑得好。我在把技能上线之前,会做一件事:给每个技能建立一套离线测试集。这个测试集从真实对话中选取样本,每个技能至少准备几十条,覆盖三类场景——正常场景、边界场景、异常场景。正常场景就是参数齐全、流程走通;边界场景是订单号缺失、订单号格式错误、用户中途改主意;异常场景是内部工具返回报错、上游接口超时。
有了测试集,就可以给技能定量化指标。我的体检指标主要是这几个:
| 指标 | 含义 | 我的建议值 |
|---|---|---|
| 触发准确率 | 该触发时触发、不该触发时不误触发 | 95%以上 |
| 参数抽取准确率 | 参数值与标注完全一致 | 90%以上 |
| 任务完成率 | 走完整个技能流程且结果正确 | 90%以上 |
| 平均工具调用次数 | 完成一次任务实际调用工具次数 | 越少越好 |
这里要特别注意参数抽取准确率,我按严格标准算:多一个空格都算错。因为真实下游接口往往对参数格式敏感,模型抽取出来的参数带一个多余换行符,可能整条调用就失败。每次修改技能之后,跑一遍测试集做回归。我吃过不少亏,有一次只是改了一个技能描述,比如把“订单状态”改成了“订单当前状态”,结果模型对相关测试样本的行为整体变了。没有回归测试,这种问题当天根本发现不了。
4.2 线上日志监控:哪些数据必须捞,怎么捞
离线测试再全面,也覆盖不了线上复杂的对话变化。所以监控日志是必不可少的。我给每条技能调用记录了这些字段:request_id、技能名、技能版本、输入摘要、输出摘要、工具调用序列、总耗时、成功或失败状态、错误类型、用户反馈。这些日志不光用来排查故障,更要用于持续改进技能库。
每周我会抽一批失败日志做人工复盘。具体方法是:从失败日志里随机抽一百条,人工标注失败原因——是技能选错了、参数抽错了、工具本身报错了,还是模型压根不按提示词来。把带有共性问题的样本补充进测试集,让下一轮技能迭代有靶子可以打。这个“线上失败日志 → 人工复盘 → 补充测试集 → 技能迭代 → 回归上线”的闭环,是技能库质量能持续上升的根本原因。
还有一个容易被忽略的指标:成本与延迟。如果一次普通咨询触发了四五个技能、每个技能又串行调用好几个工具,用户会明显感到慢,token消耗也会高。所以要监控每次任务的平均技能调用个数,一旦发现上涨趋势,就要考虑是不是组合技能编排得太啰嗦了。我在线上观察过,组合技能里有些“校验用户身份”的步骤其实会话开头已经做过了,盲目加校验就是浪费。
4.3 高频翻车问题排查表:触发失败、选错、参数错、超时、重复请求
把容易踩的坑整理成一张排查表,可以省去大量重复定位时间。
| 症状 | 可能原因 | 排查与解决 |
|---|---|---|
| 技能完全不触发 | 技能描述里的触发条件写得太窄 | 扩大 when_to_use 描述,加入口语化触发词 |
| 技能选错 | 多个技能描述边界不清 | 检查注册表描述,明确写清 when_not_to_use |
| 参数抽取多字少字 | schema缺少校验规则,模型从历史对话取数 | 增加正则校验和枚举限定,提示词里声明“只能从当前消息取参数” |
| 工具调用超时 | 上游接口慢,或技能串行调用太多 | 加超时、重试、部分步骤并行化 |
| 重试产生重复请求 | 写操作未做幂等 | 给写操作加 client_request_id,下游去重 |
排查第一条“技能完全不触发”时,可以先看日志里模型输出的是哪个技能名。如果模型输出了“没有可用技能”,说明它在注册表里检索不到匹配项,大概率是描述问题。如果模型输出的是另一个技能名,比如用户问“包裹到哪了”却触发了 fetch_order,那就按“技能选错”处理,回到注册表描述上找原因。这类问题有一个通用规律:跟模型较劲不如跟描述较劲,描述改了,行为通常就改了。
4.4 版本管理与灰度上线:让技能库可以持续演进不出乱子
技能库上线之后,一定会遇到业务规则变化,技能版本管理就得跟上。我采用语义化版本号:MAJOR.MINOR.PATCH。兼容性扩展(比如新增一个可选输出字段)升MINOR,破坏性变更(比如输入参数变了)升MAJOR,小修小补升PATCH。组合技能引用原子技能时必须带上版本范围,防止原子技能升级后把组合流程搞坏。
灰度上线是我坚持的一步。新版本技能先切一小部分流量,用线上的真实对话验证效果,同时和旧版本并行运行。对比指标没有异常再逐步放量。一旦发现问题,直接回滚到旧版本。因为技能是独立目录、独立版本号的,回滚操作很简单,替换一下handler和注册表条目就行。
这套机制看起来多了一堆流程,但它是技能库能不能长期维护的关键。没有版本管理和灰度机制,技能库大概率会在第三个版本之后变成一团乱麻。
5. 一点个人体会
技能体系建设这件事,做到最后你会发现自己做的不只是技术架构,而且是在给模型“立规矩”。最开始我沉迷于调prompt,觉得只要描述写得好,模型就一定能稳定执行;后来被现实教育了一通才想明白,prompt是给模型看的路况牌,技能才是真正让车能跑起来的那套驾驶动作。把能力从提示词里抽出来,变成结构化的、可测试的、有版本号的东西,Agent的稳定性才能从一个不可控的“模型状态”变成一个可以管理的“工程状态”。
最后再分享一个小技巧:写技能描述的时候,多花点精力在“什么时候不要用”上面,比只写“什么时候用”更能减少误触发。这个反直觉的写法,是我在几十次线上事故之后总结出来的,现在每一份技能定义里,when_not_to_use 都是我必填的字段。希望这篇关于 agent-skills 的实战总结,能帮你少走几步弯路。