“agent-skills”这个词,如果你最近在折腾大模型应用,大概率已经撞见过。它不抽象,也不神秘。核心思路就是把原来压在模型头上的“临场发挥压力”,拆成一份份边界明确、参数固定、步骤可复现的“技能包”(Skill)。模型不再每次读几千字提示词去碰运气执行任务,而是在技能清单里做选择、填参数,剩下每一步具体动作,全部走代码定义好的固定流程。
这么设计,首先解决的是失控问题。纯靠提示词约束Agent,就像让一个新员工背完整本员工手册再上岗,他说不定还是会在“退货”和“退款”之间踩坑。而技能包相当于把标准作业程序固化下来,模型只负责两件事:判断当前该用哪个技能,以及把关键参数填对。其次是成本问题,长提示词每轮都要完整吃一遍,token哗哗烧,换成技能清单后,模型只需要看到技能名和描述。这套东西适合谁?适合正在把Agent从demo推向生产环境、被“时好时坏”折磨过的开发者,也适合想给团队内部工具加上智能调度层的技术负责人。下面我按自己做过的内部实践,把设计、拆解、踩坑、落地的全过程都摊开讲。
1. Agent Skills是什么:给大模型套上固定流程的“技能包”
1.1 为什么技能包比超长提示词更可靠
先还原一个场景。刚开始做智能客服Agent时,我习惯把所有规则写进system prompt:怎么查订单、什么条件下能退款、退款要走哪几个系统、超时要怎么办。写到3000字时,规则开始互相打架。用户说“我不想要了”,模型有时判断成退货,有时判断成退款,有时直接道歉。原因很简单:上下文一长,注意力被稀释,越是靠后的规则,模型越容易忽略。而且每次请求都把这3000字原封不动发给模型,调用成本居高不下。
技能包的解法是反过来的。把“查订单”做成技能A,把“申请退款”做成技能B,每个技能内部包含预定义好的描述、参数Schema、执行步骤。模型在每一轮只看到一份精简的技能列表,例如“A:查询订单状态,需要订单号;B:申请退款,需要订单号和退款原因”。它先做倾向性判断,再通过function calling或类似机制触发执行,具体怎么调用订单接口、怎么校验状态、怎么落库,全部由后端代码保证。
我自己的体会是:超长提示词试图让模型做到“理解一切并随机应变”,但大模型本质上是个概率系统,上下文越长,约束力越弱,错误动作就越多。技能包则把“怎么做”从模型那里剥离开来,模型只保留“做什么”的决策权。这样一来,可控性、可测试性、可观测性全部回到工程手里。决策由模型做,执行由代码做,这是Agent应用从玩具走向生产力工具的分水岭。
1.2 技能、工具与工作流:三者关系的厘清
很多朋友问,技能和工具(Tool)到底有什么区别?我第一次设计技能系统时也模糊过,回头总结下来可以这么理解。
工具是最小的原子能力,比如“查询数据库”“发送HTTP请求”“获取当前时间”。它不包含业务判断,也不涉及流程约束,就是一个动作。技能则是围绕某个业务目标的多步骤过程,比如“完成一笔退款”内部可能要调用订单查询工具、风控校验工具、退款提交工具、消息通知工具,并在步骤之间加上规则判断。工作流是更上层的编排逻辑,定义Agent什么时候调用技能、技能调用失败后走哪个分支、是否需要人工介入。三者形成层级:工作流调度技能,技能调用工具。
我习惯用快递作类比。工具是“下单”“称重”“打印面单”这些动作,技能是“完整寄出一件快递”的过程,工作流则是“客户打来电话后,先判意图,再决定要不要走寄件技能,寄件失败怎么办”。很多Agent框架会在接口层把技能和工具统一成同一个调用协议,这没问题,但设计时脑子里要清楚:工具随时可以被多个技能复用,技能是对工具的组合和封装,别把两者混着写,否则后面编排会乱成一团。
| 层次 | 定义 | 粒度 | 错误处理 | 例子 |
|---|---|---|---|---|
| 工具 Tool | 单个动作,无业务逻辑 | 极细 | 异常直接上抛 | 查订单、发短信、调接口 |
| 技能 Skill | 围绕任务的多步骤过程 | 中 | 内部校验+分支处理 | 订单退款、物流查询 |
| 工作流 Workflow | 按意图编排技能与分支 | 粗 | 全局状态管理,兜底机制 | 客服Agent主流程 |
2. 一次真实的需求拆解:从模糊任务到技能清单
2.1 确定边界:把任务地图画出来
做技能系统最怕一上来就写代码。我踩过一次坑,闭门造车写了一个“万能处理技能”,里面塞了十几个分支条件,结果模型每次选择时都会犹豫。后来学乖了,先花两天把任务地图画出来。
拿我当时内部做的客服订单处理Agent举例。第一步,把用户问题聚成意图簇。整理近千条历史工单后,发现高频意图只有五类:查物流、申请退款、修改地址、发票申请、转人工。其他问题要么低频,要么需要人工判断,直接转人工处理更稳妥。第二步,给每个意图写清楚边界。我定了几个硬规则:查物流必须拿到订单号或手机号后四位;申请退款必须订单处于已支付状态且未超过售后期;异常订单如退款纠纷不能走自动退款,必须转人工。这些边界不是写到提示词里让模型自己琢磨,而是直接写进对应技能的执行逻辑里。第三步,明确人工兜底的配合关系。技能系统永远不追求100%自动化,遇到模型选不出技能、连续失败、用户情绪激烈、涉及风险操作时,要能体面地把会话交给人工。
任务地图画完之后,每完成一个技能,我就把对应的用户话术、边界条件、依赖数据源、人工兜底路径整理成文档。这样做的最重要收益是:技能目录变成了团队都能对齐的活文档,而不是埋在代码里的隐式逻辑。需求变更时,先改地图,再考虑改哪个技能,而不是重新调整一条3000字提示词。
2.2 技能粒度怎么切:太大变成摆设,太小变成负担
技能粒度是设计阶段最磨人的问题,没有标准答案,但我后来总结出一个经验公式:模型只需要做一次“该不该用”的决策,再提供3到5个关键参数,这个技能的粒度就是合适的。
太粗的技能,比如“处理所有客诉”,内部什么分支都有,模型根本不知道边界在哪,最终结果就是把所有烂摊子都塞给一个技能,技能内部又变成了一个超长提示词,问题绕一圈回去了。太细的技能,比如“读取用户ID”“判断订单是否支付”,这种只做一件事且没有业务约束的,本质上就是工具,单独做成技能会导致调用链特别长,模型要连续选择多次,每多一次选择就多一次出错概率,编排成本还高。
我一般把技能切成两层。上层是用户可感知的业务技能,比如“申请退款”“修改地址”,它们面向模型开放,描述清晰、返回值完整。下层是内部工具,比如“校验订单状态”“生成退款单号”,它们不直接暴露给模型决策层,而是被上层业务技能调用。举个例子,“申请退款”技能内部会依次调用“校验订单”“生成工单”“发送财务系统”这些工具,但对模型来说,它只需要填订单号和退款原因,剩下的全由技能代码完成。
提示:我自己的参考线是,一个技能如果描述写到200字还收不住,多半说明粒度已经太粗了;如果一个技能只有一个步骤且没有任何业务规则,那它应该降级成工具。
2.3 技能描述怎么写,模型才肯乖乖调用
技能描述是模型做路由选择的最关键依据。我最初写描述特别随意,比如“申请退款,用户不要了就用”,结果线上模型经常把“退货”意图也带过来,因为“不要了”和“退货”在语义上太接近。后来我总结了一套结构化的写法。
一个合格的技能描述至少包含四层信息。一是用途,一句话说清楚这个技能完成什么任务。二是适用条件,明确“什么情况下使用”。三是不适用条件,明确“什么情况下不要用”,否定句对模型的约束效果往往比肯定句更明显。四是关键参数补充说明,尤其是容易产生歧义的字段。
举一个对比。写得差的描述:“订单退款技能,可以退款。”模型每次看到都懵。写得好的是这样:“当用户明确要求或同意对已支付订单申请退款时使用本技能。订单必须处于已支付状态且未超过平台售后期限;如果订单已发货并签收,或涉及商品损坏争议,请勿使用本技能,改用转人工流程。参数order_id需要填写用户提供的18位订单号,refund_reason需要填写用户陈述的退款原因原文。”这段描述虽然长,但每句话都是路由信号,模型读完基本能做出稳定判断。
我还发现一个细节:参数名用词要尽量贴近用户话术。比如用户说“收货人”,接口字段是recipient_name,参数名就直接叫recipient_name,同时在描述里写明“用户提到的收货人姓名填到这里”。不要在系统内部用一套抽象的字段名,再用提示词强行把用户话术映射过去,映射规则越多,模型就越容易出错。
3. 技能系统的运行时设计:注册、调度、回退
3.1 技能文件与参数Schema
技能定义的第一载体是描述文件,我用的是YAML,也有团队用JSON。核心结构包括id、name、description、parameters、steps、timeout几个字段。下面是我在内部客服Agent里用过的“订单退款申请”技能定义,做了脱敏简化。
id: order_refund name: 订单退款申请 description: > 当用户明确要求对已支付订单申请退款时使用本技能。 订单必须处于已支付状态且未超过平台售后期限; 如果订单已发货并签收,或涉及商品损坏争议,请勿使用本技能, 应转人工流程处理。 parameters: order_id: type: string description: 用户提供的18位订单号 required: true refund_reason: type: string description: 用户陈述的退款原因原文 required: true max_length: 200 steps: - call: check_order args: order_id: ${order_id} - call: check_refundable args: order_id: ${order_id} - call: create_refund_ticket args: order_id: ${order_id} reason: ${refund_reason} timeout: 10 retry: 2参数Schema为什么重要?因为模型生成参数是概率行为,不校验就可能生成空值、错位、超长文本。我有一次没做枚举校验,模型在channel字段里填了一个内部系统根本不认识的渠道名,直接导致后续流程挂掉。后来所有参数都走JSON Schema校验,每个字段必须有类型、必填标识、描述、可选的枚举范围。校验不通过时,校验器会返回明确错误信息,再让模型重新补充或修正参数,而不是带着脏数据往下走。这一步对生产稳定性是决定性的。
3.2 调度策略与多技能组合
有了技能清单后,怎么让模型稳定地选出来?我采用的是标准的“两步决策模式”。第一轮,把完整技能列表注入上下文,让模型判断当前用户意图最匹配哪个技能;如果匹配不到任何技能,它必须回答“不适用”而不是硬选一个。第二轮,模型产出技能ID和参数后,系统先做参数校验,校验通过才执行技能。两轮之间是异步的,每一轮的输入输出都记录下来,方便事后复盘。
先选择、后执行的顺序执行模式最稳。我不建议第一版就做多技能并行。并行会引入数据竞争和依赖判断问题,比如“查物流”和“申请退款”这两个技能同时被选中时,到底谁先执行?如果并行跑,退款流程可能读到还没更新的物流状态。顺序执行虽然少一点炫酷,但每一步都是确定性的。内部技能之间如果需要组合,我通常的做法是一个技能调用另一个技能,也就是在steps里允许出现嵌套的call: <skill_id>,而不让模型在同一轮里一次性编排多个技能。模型同一轮只决策一个动作,代码控制组合逻辑,这样最不容易乱。
每个技能执行完,结果会写进会话临时上下文,用来做后续判断。这里有个小原则:不要重复调用同一个只读技能,比如用户问完物流又跟着问一句“几天能到”,如果模型又调一次“查物流”,就是浪费token和接口压力。上一轮的结果如果没有过期,应该优先复用。我在执行器里维护一个简单的会话缓存,以“技能ID+参数hash”作为key,相同请求直接返回上次结果。
3.3 回退机制与人工介入
任何Agent系统都会失败,区别在于失败后有没有体面的退路。我建过两套兜底机制,一套给模型,一套给用户。
模型侧的兜底有三层。第一层,模型选不出技能时,系统给出默认话术:“我暂时无法处理这个问题,正在为你转接人工客服。”第二层,技能执行失败时,如果错误可以重试,就按retry参数自动重试;如果重试仍然失败,就触发同一个技能的最简版本,比如完整退款流程失败,改为只记录问题并通知人工。第三层,单次会话内技能调用次数超过阈值(我设为5次),立即停止自动流程,强制转人工。这个阈值很重要,否则模型可能在失败后反复尝试同一个技能,形成技术上的死循环,用户看到的却是对话框一直“正在输入”。
人工介入的体验也需要设计。转人工时,不能丢给客服一句“用户想退款但失败了”。我把整个会话状态封装成一份转交上下文:用户原话、模型意图判断、调了哪些技能、每个技能的结果、失败原因和重试记录。客服拿到后不需要再问一遍用户发生了什么,直接接手处理。对用户来说,手工介入应该是平滑的降级,而不是冷冰冰的报错。
4. 实战记录:一个简单Agent Skills实现Demo
4.1 环境与整体结构
理论知识说得再多,不如跑一个最小闭环。我搭过一个极简版本,不依赖任何重型Agent编排框架,用Python手写核心逻辑,目的就是看清技能系统运转的每个齿轮。整个工程不长,三四个文件就能跑起来。
my_agent/ skills/ order_refund.yaml logistics_query.yaml core/ registry.py executor.py llm_client.py schema_parser.py test/ behavior_suite.json main.pyllm_client.py只封装一件事:给定对话和系统提示,返回模型输出。不要在这个文件里塞业务逻辑。registry.py负责在启动时扫描skills/目录下的YAML文件,解析并校验成内部技能对象。executor.py接收技能ID和参数,按照steps定义执行具体动作。schema_parser.py负责参数校验。main.py把整个决策循环串起来:接收用户消息,拼装技能清单,请求模型选择,解析决策,校验参数,执行技能,返回结果。
我刻意不让代码依赖某个特定的大模型服务,只留一个输入输出接口。这样技能定义、调度逻辑和模型厂商解耦,后续想换模型服务或做不同模型对比时,改一个适配器就好。
4.2 核心代码拆解
先看技能加载和校验。启动时用glob扫描YAML文件,逐个解析,然后用JSON Schema校验参数定义是否合法。这个步骤看起来多余,但能提前拦住大部分配置错误。
# registry.py import glob import yaml from jsonschema import validate SKILL_DIR = "skills" class SkillRegistry: def __init__(self): self.skills = {} def discover(self): for path in glob.glob(f"{SKILL_DIR}/*.yaml"): with open(path, "r", encoding="utf-8") as f: skill = yaml.safe_load(f) self.validate_skill(skill) self.skills[skill["id"]] = skill return self.skills def validate_skill(self, skill): # 必须字段检查 for field in ["id", "name", "description", "parameters", "steps"]: if field not in skill: raise ValueError(f"技能缺少字段: {field}") # 参数Schema合法性检查 param_schema = { "type": "object", "properties": skill["parameters"], "required": [ k for k, v in skill["parameters"].items() if v.get("required") ] } validate(instance={}, schema=param_schema)执行器部分,我采用一个最朴素的实现:遍历steps,每一步按call指定名称去调用对应函数,参数通过字符串模板把${order_id}替换成实际值。这里的关键点是每一步的返回值都要结构化包装,不能把异常直接丢给模型。
# executor.py class SkillExecutor: def __init__(self, tools): self.tools = tools def execute(self, skill, params, context): state = dict(params) for step in skill["steps"]: tool_name = step["call"] if tool_name not in self.tools: raise RuntimeError(f"工具不存在: {tool_name}") tool_args = self._resolve_args(step.get("args", {}), state) result = self.tools[tool_name](**tool_args) if result["status"] != "ok": return {"status": "failed", "step": tool_name, "error": result["error"]} state.update(result.get("output", {})) return {"status": "ok", "result": state}_resolve_args的内部实现很简单,把模板字符串里的${param}替换成state中已有值即可。我不会在demo里做复杂的分支语法,真实生产中如果需要条件判断,我倾向于直接写在技能对应的Python处理函数里,而不是塞进YAML流程定义。YAML只承担描述和顺序编排,逻辑判断留给代码,这样才能被单测覆盖。
4.3 单测与评估:怎么证明技能有效
做完技能系统,最大的问题是:怎么知道它改好了?肉眼试几个用例不靠谱,同一个问题换个说法,效果可能就完全不同。我的做法是建一个行为测试集,用测试样例训练技能选择质量,而不是用人肉调提示词。
测试集是一批JSON用例,每条包含用户输入、期望技能ID、是否应该转人工、关键参数期望值。
[ { "input": "我昨天下的单现在到哪了?订单号202401011234567890", "expected_skill": "logistics_query", "expected_params": {"order_id": "202401011234567890"} }, { "input": "这个破东西我不想要了,能不能退钱", "expected_skill": "order_refund" }, { "input": "你们客服是不是机器人啊", "expected_skill": "__transfer_human__" } ]跑测试的流程是:每条用例喂给决策循环,比对预测技能ID和期望技能ID,一条条过,最后统计准确率。修改技能描述后,重新跑同一批测试,看命中率变化。这个办法很土但非常有效。以前我是凭感觉改提示词,改完也不知道是变好还是变坏,现在有了回归基准,每轮改动都是可量化的。我建议至少准备20到30条覆盖正常、边缘、拒绝三类路径的用例,正常路径保证主流程通,边缘路径兜住模型容易犯浑的场景,拒绝路径确保模型不会硬选技能。
评估指标我关注四个:技能命中率、参数抽取准确率、任务完成率、平均调用次数。前两个反映决策质量,第三个反映端到端效果,第四个反映效率。如果平均调用次数偏高,常见原因是描述歧义导致模型反复尝试,或者参数校验太松产生无效调用,我会结合trace逐条定位。
5. 我踩过的坑与排查实录
5.1 描述用词不统一,模型反复选错技能
我遇到的最莫名其妙的线上问题是,用户说“我要退货”,模型却选了“退款申请”技能。看trace才发现,退款技能的描述里写了“当用户不想要商品时使用”,退货技能的描述里写了“用户退回商品时使用”。在模型眼里,“退货”和“不想要”是高度相关的,它选哪个都说得通。
这个问题的根源是同一种用户意图,在描述里用了不同口径。解法也很笨:编写技能描述时,把历史语料里出现过的同义表达全列进适用条件。比如退款技能描述,我会明确写:“用户可能说‘退款’‘退钱’‘不想要了’‘申请退款’‘把钱退给我’,都属于本技能适用范围。”同时,在“退货换货”技能中明确排除:“如果用户明确说要把货寄回去换尺码,属于换货流程,不要用退款技能。”整理这些同义词需要花时间,但做完之后,技能选择准确率是肉眼可见的提升。
5.2 技能内部崩溃,Agent却在死循环
第二次线上事故发生在技能内部。某个第三方接口超时了,但我在执行器里没把异常包装成“状态失败”,而是让异常直接抛了出去。结果模型没有收到失败结果,以为是技能没被调用过,于是再次生成同一个调用请求,进入死循环。用户看到的现象是,对话框顶部一直转圈,客服系统里却没有任何工单产生。
修复思路有两条。第一,所有技能执行必须返回结构化结果,状态只有ok、failed、need_user_input三种,错误信息单独放error字段,模型能明确看到“技能调用失败了”以及原因。第二,给外部调用统一加超时和重试。超时时间按业务卡片设,下单类接口3秒,查询类接口5秒,重试次数上限2次。超过重试次数直接返回失败状态,由上层回退机制接手。现在我对所有技能函数的要求是:任何异常都可能发生,但必须被接住,不能把异常裸奔给模型。
5.3 缓存与幂等性,生产环境必须处理
第一版没有做幂等保护,结果线上出现过用户点一次退款,财务系统生成两笔退款单的事故。原因是模型在参数校验失败后重新生成了一次请求,前端又因为网络原因重发了按钮消息,两个请求到了执行器都通过了校验。这不是模型问题,是工程问题。
后来我给技能调用加了一个幂等键,由session_id + skill_id + request_hash拼接而成,其中request_hash是对关键参数做MD5。每次执行技能前先查幂等表,如果同样键已经执行过,直接返回第一次结果,不再重复触发。对于退款、下单这类有外部副作用的技能,幂等是底线要求,没有任何商量余地。
5.4 快速排查清单
如果你也在做自己的Agent技能系统,遇到问题时,下面这张表能帮你快速定位方向。
| 症状 | 可能原因 | 优先排查项 |
|---|---|---|
| 模型完全不选技能 | 技能清单太长,被截断;描述不清晰 | 查看真实请求的上下文,确认技能列表是否完整注入 |
| 模型选了错误技能 | 多个技能描述语义重叠 | 查描述中的同义词口径,补排除句 |
| 参数解析失败 | Schema太宽松,缺枚举和边界校验 | 看模型实际生成的参数,补required和枚举 |
| 技能执行成功但用户不满意 | 技能内部逻辑偏离用户预期 | 检查结果是否只回传了状态,没回传用户可读信息 |
| 同一技能反复调用 | 失败结果未正确反馈给模型 | 检查执行器是否返回结构化错误信息 |
| 用户被晾着很久 | 外部接口超时未处理 | 为所有工具调用加超时与重试 |
排查顺序我一般固定为:先开trace日志,看模型每一步收到的技能列表和返回结果;再还原模型决策时的完整上下文,判断是描述问题还是参数问题;最后才动代码。不要一上来就改提示词,改完没有测试集验证,只会制造新的混乱。
最后分享一个我个人实操中的习惯:技能系统的迭代,永远先改测试集,再改描述和逻辑。“这个技能要不要拆”“这个描述会不会有歧义”,先变成一条条测试用例,再动手。agent-skills真正的价值不是把模型封装得更像人,而是把人与模型的分工说得越来越清楚——模型负责理解意图,代码负责兑现结果。技能越多,越要警惕重复和冗余,我每隔一段时间就会翻一遍技能目录,把相似度高的合并,把无人调用的下线,这套系统才能一直保持干净、可控、可解释。