☰
agent-skills实战:从客服大模型翻车到技能系统落地
2026/10/14 9:05:20 网站建设 项目流程

最近我们在重构一套客服领域的大模型应用,用户问一句“我买的那个耳机现在到哪儿了”,系统返回的是模板话术里最接近的一句,压根没去查实时物流。气得用户截图发到群里吐槽:又是人工智障。这事让我下决心把agent-skills这套技能系统彻底做扎实。如果你也在做Agent相关工作,或者正被“模型什么都会一点但什么都做不精确”折磨,这篇东西应该能帮你省不少时间。

所谓agent-skills,本质上就是把智能体“会做的事”沉淀成可注册、可发现、可编排、可评估的能力单元,让大模型从“凭感觉发挥”变成“按清单执行”。这篇文章不聊虚的概念,全部来自我们实际改造一个客服Agent的摸爬滚打,包括技能如何定义、参数Schema怎么设计、多个技能怎么协作、上线前怎么评估安全边界,以及我们在真实流量下踩过的坑和填坑过程。

1. 先搞清楚:agent-skills 到底解决什么问题

1.1 从一次失败的聊天机器人改造说起

先说背景。我们手上有一套用了很久的客服对话系统,早期基于检索式问答,就是用户问一句,系统从知识库里捞相似度最高的答案返回。这种方式对“退款政策是什么”“发货地在哪里”这类静态问题还算够用,但遇到“帮我看看这个订单能不能今天发货”“我凑单还差多少钱能用优惠券”这种需要查询实时数据的问题,检索式问答就是死路一条——因为答案根本不在知识库里,得去订单系统、库存系统、营销系统里现算。

第一次改造,我们尝试把用户问题直接丢给大模型,让它“自由发挥”。模型倒是能理解上下文,但输出的答案经常是编的,它会一本正经地说“您的订单预计今天下午发货”,实际上那笔订单还卡在仓库审核。这就是幻觉问题。后来我们做了函数调用(Function Calling),让模型在需要时触发查询工具,思路对了,但又陷入新的泥潭:工具越加越多,模型经常选错工具,或者参数传错,一个查订单状态的工具,它能把订单号填成收货人手机号。问题出在哪?工具之间没有统一的能力抽象,模型面对的是一堆零散函数,而不是一组有边界的“技能”。

1.2 技能(Skill)与函数调用(Function Calling)的边界

很多人会把“技能”和“函数调用”混为一谈。我的理解是,函数调用是底层的原子操作,而技能是一个更高层的封装,它包含“什么时候该用、需要哪些输入、内部怎么执行、失败怎么兜底”这四样东西。举一个很直白的例子:函数调用像是给模型一堆螺丝刀、扳手,它得自己判断怎么拧;技能则是把一个“更换轮胎”的完整工序打包好,模型只需要说“轮胎爆了,我要换轮胎”,剩下按部就班执行。

从工程实现上看,技能的关键在于声明式元数据。每个技能除了有可执行代码,还要有模型能读懂的自然语言描述、参数定义、触发条件、调用权限、超时策略。模型不直接调代码,它先读元数据,然后决定“调用哪个技能、传什么参数”,真正执行的是你写好的调度器。这样一来,模型需要理解的东西变简单了,可调试性也大大提升。日志里可以明确看到:模型选择了哪个技能——而不是“模型调了一个神秘函数”。

1.3 技能系统的评判标准

我们内部定义了一套评判技能系统好坏的标准,不需要很玄学,就看四条:

  • 可用性:该技能是否能在真实业务场景下稳定完成任务,不依赖模型随机发挥。
  • 可发现性:当用户需求出现时,模型能不能准确找到正确的技能,而不是在一堆选项里猜。
  • 可组合性:复杂需求能否通过多个基础技能的编排来完成,而不是每个需求都单独写一个技能。
  • 可治理性:技能上线后能不能监控、评估、下架,出了事故能不能快速定位责任环节。

这四条标准贯穿了后面所有的设计决策。技能不是越多越好,增加一个技能,模型的选择空间就大一分,误选概率也跟着涨。所以我们在新增技能时都要过一遍“可发现性”评审,如果发现两个技能的描述会让模型犯晕,说明边界没切干净,要么合并要么拆分。

2. 技能定义与注册:把“会做的事”变成机器可读的清单

2.1 技能描述怎么写才不会被大模型误解

技能描述是整个agent-skills系统里最容易被低估的东西。我见过太多团队把描述写成“查询订单状态功能”,这种描述在模型眼里跟没有一样。大模型选择技能时,依赖的是语义匹配,描述必须写清楚:这个技能解决什么问题、在什么条件下可以使用、有哪些典型的用户问法。

我们逐步迭代出一套描述模板,写描述时强制包含三个部分:目标行为、触发条件、反例边界。

技能名称:order_status_query 描述: - 目标行为:查询订单的最新状态(待付款、已付款、配货中、已发货、已签收等),并返回状态描述与预计送达时间。 - 触发条件:用户明确询问某个订单的当前进度、发货情况、送达时间,或询问“我的东西到哪了”且上下文中存在订单号。 - 不使用场景:用户仅询问物流政策、配送范围、退货规则时不要使用;用户询问售后申请进度时不要使用,应转售后进度技能。

这段描述里最关键的是“反例边界”。我们后来统计过,加了反例边界之后,技能误触发率下降了整整一半。因为模型在拿不准的时候,很容易把所有带“订单”“物流”字眼的问题都归到这个技能上,边界描述就是给它划了一道红线。

2.2 参数Schema的设计细节与坑

参数定义也不能偷懒。第一次我们用简单的string类型糊弄,结果模型传参数时经常把多个信息塞进一个字段,比如用户说“订单号是12345,手机尾号6789”,模型可能就把“12345 手机尾号 6789”整个填进order_id里,下游解析直接崩。

后来我们全部改成结构化对象,并且给每个字段写清楚格式要求和示例值。这里有一段简化示例,用的是JSON Schema风格:

{ "name": "order_status_query", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,由数字和大写字母组成,通常以字母SO开头,例如 SO20250412001。如果用户只提供手机号,不要提取到这里。", "pattern": "^[A-Z]{2}[0-9]+$" }, "customer_phone": { "type": "string", "description": "用户接收通知的手机号,可选参数。仅当order_id缺失时用于反查订单列表,格式为11位手机号。", "pattern": "^1[3-9][0-9]{9}$" } }, "required": ["order_id"] } }

这个Schema里有几个容易被忽略的点:

  • 字段描述要写负面约束。只写“订单号”三个字,模型会把订单金额、商品编号都当成订单号。写了“如果用户只提供手机号,不要提取到这里”,模型才会更谨慎。
  • 正则要放在description里也提一遍。有些框架支持给字段配pattern,但模型不一定严格遵守,描述里再强调一次,相当于双重保险。
  • 参数尽量做成可选与强校验并行。我们一开始把order_id设为必填,结果用户明明只提供了手机号,模型硬生生造了一个订单号出来。后来改成“order_id缺失时可用手机号反查”,就自然多了。

2.3 技能注册中心:统一管理与版本控制

技能到一定数量后,必须引入注册中心。我们的注册中心就是一个内部配置服务,每个技能上线前要登记五类信息:基础元数据、参数Schema、回调地址、权限标签、版本号。权限标签这玩意容易被人忽略,但它决定了哪些渠道可以调这个技能。比如查订单技能只允许已登录用户触发,营销优惠计算技能只允许特定来源的会话触发,这两者不能混。

版本控制也很关键。模型的行为演化很快,同一个技能,用上个月的定义方式和这个月的定义方式,效果可能差出天际。我们的做法是给每个技能固定版本,Skill A v2.1 对应一套描述和Schema,线上流量可以灰度在不同版本之间切,出问题立即回滚。注册中心还承担了技能下架的职责:某个技能如果评估分数持续下滑,就把它标记为deprecated,新会话不再路由到它,存量会话跑完即止。

3. 技能编排与调度:让多个技能像团队一样协作

3.1 技能路由:从硬编码到语义匹配

技能多了之后,第一个要解决的问题就是路由——用户的一句话到底该触发哪个技能。业界常用做法分两类:让大模型自己选,或者用独立的路由模型。我们的经验是,纯靠大模型选技能在技能数量超过20个后,准确率会明显下滑,尤其是两个技能描述相似时。所以我们走了折中路线:先做一次语义召回,把候选技能缩小到3~5个,再让大模型在这几个候选里做最终决策。

语义召回阶段用的是一套预训练的文本向量模型,把所有技能描述离线编码成向量存起来,用户输入实时编码后做余弦相似度检索。这一层不追求完美,只求别把正确的技能漏掉,所以取Top-K的时候会适当放宽阈值,宁可多召回几个候选,把精确决策交给下一步。

from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer("some-embedding-model-name") skill_vectors = {skill["name"]: model.encode(skill["description"]) for skill in registered_skills} def recall_skills(query: str, top_k: int = 5) -> list[str]: q_vec = model.encode(query) scores = { name: float(np.dot(q_vec, vec) / (np.linalg.norm(q_vec) * np.linalg.norm(vec))) for name, vec in skill_vectors.items() } ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [name for name, score in ranked[:top_k] if score > 0.35]

做过这个方案之后你会发现,真正难的不是写代码,而是调阈值。Threshold设太低,候选集里全是无关技能,大模型更容易被干扰;设太高,又可能把正确技能过滤掉。我们的做法是基于历史日志统计:先拉三个月里人工标注过的真实路由数据,画一条召回率随阈值变化的曲线,选一个“召回率不低于98%”的最低阈值,保底优先。

3.2 复合技能(Composite Skill)与工作流编排

单技能能解决的问题有限。客服场景里用户经常这么问:“我想退货,运费谁承担?”这个问题至少涉及三个技能:查询订单状态、查询退货政策、计算预估运费。如果只让模型选一个,答案永远是片面的。

我们的处理方式是引入复合技能(Composite Skill)。复合技能不直接执行具体查询,它内部定义了一个有向工作流,由调度器按顺序调用若干基础技能,并把前一个技能的输出加工后传给下一个。举例,“退货费用预估”这个复合技能的编排逻辑是:

  1. 调用订单状态技能,拿到商品状态和购买时间;
  2. 调用退货政策技能,根据商品类目和购买时长判断是否在退换期内;
  3. 调用运费计算技能,结合订单信息和政策规则计算费用;
  4. 组装自然语言答案,附上政策原文链接。

定义复合技能时,我们要求工作流必须是“显式可观测”的。每一个节点都有独立的trace id,哪一步失败、哪一步耗时超标、模型在哪一步传参错误,都能单独拉出来看。这一点在排障时救命——有一次用户反馈运费算多了,我们打开链路追踪,三秒钟就定位到是第二步政策判断时,模型把“七天无理由”规则匹配到了“食品类不支持无理由退货”上,上下文拼接顺序的问题,调整节点上下文权重后立刻修复。

3.3 并发、依赖与失败恢复的真实处理

技能编排里的并发和失败处理,是最容易被demo忽略、线上却经常炸的地方。我们线上遇到过这样的情况:用户催单时,一个会话内连续触发三次订单查询,调度器没有去重,同一订单被重复调了三遍下游接口,被订单系统限流。后来我们在调度层加了请求合并与缓存,同一个会话内相同参数的只读技能调用,十秒内直接复用第一次的结果。

失败恢复也要分技能类型对待。查订单这类读操作,失败后重试一次就好;但算优惠、审核售后这类写操作,失败后不能盲目重试,容易产生重复工单。我们的策略是每个技能声明自己的“失败模式”:幂等类技能允许自动重试,非幂等类技能失败后转入人工兜底队列,同时把原因格式化好,免得人工还要自己去翻上下文。

# 调度器中的简化失败处理逻辑 async def execute_skill(skill_name, params, context): spec = registry.get(skill_name) for attempt in range(spec.max_retries + 1): try: result = await call_skill_executor(spec.endpoint, params) return normalize_result(result) except ExternalTimeout: if spec.idempotent and attempt < spec.max_retries: continue raise except InvalidParamError: # 参数错误不重试,直接转人工 context.escalate(skill_name, params, "参数校验失败") return None

这里有个细节:重试间隔一定要做指数退避,而且要带随机抖动。我们刚开始固定等2秒重试,结果下游服务雪崩式恢复时,所有重试请求挤在一起又把它打挂了。后来改成min(2^attempt, 10) + random(0, 1)重试间隔,情况才稳下来。

4. 技能评估与安全边界:上线前的最后一道防线

4.1 技能行为评测:准确率与召回率的另类解读

技能上线的门槛不能只看“大模型觉得行不行”,要有量化评测。我们建了一套技能回归测试集,每个月迭代一次。测试集里每条样本包含“用户Query、期望触发技能、期望参数、期望答案关键词”,跑完自动化流水线后统计四类指标:

  • 触发准确率:期望技能是否被正确选中。
  • 参数抽取完整率:期望参数是否被完整抽取,是否正确填充。
  • 答案通过率:最终答复是否包含期望关键词,且不含高风险否定词。
  • 无效调用率:不该调技能的情况下是否错误触发。

这里特别要提“无效调用率”。很多团队只关注正向准确率,忽略了这个反向指标。结果就是技能平时看着挺准,一到线上被用户各种花式提问,没事就去查下游接口,既慢又贵。我们把无效调用率设定在2%以下才允许全量上线,达不到就回去改描述、改边界。

指标我们初版数据优化后数据备注
触发准确率87.4%94.6%主要靠描述加反例边界
参数抽取完整率78.2%91.8%Schema结构化解决
答案通过率81.9%93.1%复合技能编排优化后提升
无效调用率6.3%1.7%语义路由阈值+反例描述

优化过程不是二选一,而是每一步都做小步快跑:改一版描述,跑全量集,对比指标,有回退就再调。这套数据指标后来成了我们技术评审时的硬通货,没有评测数据,技能根本走不到上线环节。

4.2 权限与数据安全:技能不是越强越好

技能能力越大,责任越大。客服Agent能查订单、改地址、甚至发起退款,每个技能都握着真实业务的操作权限。我们最开始的教训是:技能权限和用户权限没做隔离,结果一个会话里,模型因为上下文里有历史订单号,就把当前登录用户不该看到的他人订单信息给查出来了,属于越权访问。这事当场让我们把安全问题提到了最高优先级。

现在的权限模型分两层。第一层是技能级权限,每个技能标注可访问的数据域,比如订单技能限定“本会话账号的订单”;第二层是动作级权限,像查询这类只读操作放得宽一些,退款申请、修改收货地址这类写操作必须二次确认,并且需要有加密签名才能调用。

数据安全方面,我们强制要求技能返回内容经过脱敏层:手机号中间四位打码,地址只显示到街道,完整的敏感数据绝不进入大模型的上下文历史。这一点很重要,否则模型在长对话里会把之前的敏感信息复用出来,一不留神就泄露了。

4.3 可观测性与日志:技能调用的全链路追踪

技能系统的排障难度比普通程序高得多,因为“错误”往往是隐性的。下游接口返回200,但答案是错的;技能没被触发,但模型直接答了。所以从第一天起就要做全链路追踪,每一轮Agent对话,我们记录以下内容:

  • 模型最终选择了哪个技能,候选技能Top5分别是什么;
  • 参数抽取前后的完整JSON;
  • 技能执行耗时与状态码;
  • 最终回答里哪些片段是技能结果渲染的,哪些是大模型自由生成的;
  • 用户对回答的反馈数据(点赞/点踩/追问)。

日志不只要存,还要生成周报级别的指标看板。我们内部有一个“技能健康度排行”,每周自动统计每个技能的调用量、成功率、耗时、转人工率。技能不是上线就完事,它像业务系统一样需要持续维护。我建议想长期做的团队,一定要投入一个后端人力专门维护这套观测体系,否则技能规模超过50个后,你会完全失去掌控感。

5. 一套可落地的 agent-skills 实战案例(模拟项目)

5.1 项目背景与技能清单设计

上面讲的都是方法和框架,光看不练不行,讲一套我们实际落地时的模拟项目设计。为了描述方便,我们管它叫“客服Agent演示项目”,背景是一家做3C数码配件的网店。用户咨询的高频问题集中在四类:物流进度、退换货、优惠计算、售后退款进度。围绕着四类问题,我们设计了最初版本的技能清单,不多不少正好10个技能。

基础技能(只读类)有:查询订单状态、查询物流轨迹、查询退货政策、查询商品详情、查询优惠券列表。复合技能(带流程编排)有:退货费用预估、退款进度跟踪、凑单优惠建议、物流异常升级、售后工单查询。这10个技能覆盖了大约85%的客服会话需求。我们刻意没有一上来就做更多,先跑通链路比堆功能重要。

5.2 从零到一:核心代码骨架

项目采用Python实现,技术栈选择上主要考虑团队熟悉度与生态成熟度,不需要复杂的框架,核心就三块:技能注册表、语义召回层、执行与编排层。注册表就是个普通字典服务,启动时从配置文件加载所有技能元数据。以下是技能注册表的骨架代码,方便你对着抄:

# registry.py from typing import TypedDict class SkillSpec(TypedDict): name: str version: str description: str routing_keywords: list[str] executor_path: str idempotent: bool permission_tags: list[str] registry: dict[str, SkillSpec] = {} loaded_executors: dict[str, object] = {} def register_skill(spec: SkillSpec, executor): registry[spec["name"]] = spec loaded_executors[spec["name"]] = executor def get_candidate_skills(query: str, top_k: int = 5): return recall_skills(query, top_k) def get_executor(name: str): if name not in loaded_executors: raise KeyError(f"技能执行器未加载: {name}") return loaded_executors[name]

执行器部分,每个技能一个独立模块,对外只暴露一个统一接口:async def execute(params: dict, context: SkillContext) -> SkillResult。SkillContext里带着当前会话的用户身份、trace id、权限标签,这样技能在内部就可以做数据隔离校验。统一接口的好处是可以批量接监控、批量接限流,不需要每个技能单独写一遍。

5.3 实测中遇到的三个典型问题及修复

这块必须单独拎出来讲,因为每个问题都让我们的认知往前推了一步。

第一个问题:模型把“查询物流轨迹”和“查询订单状态”搞混。

这两个技能描述确实太像了,一个说“查订单到哪了”,一个说“查物流节点详情”,模型经常选了前者,结果返回的是订单整体状态,用户明明要的是“包裹在哪个中转站”。排查后发现是反例边界没写清楚。后来我们给“物流轨迹”的触发条件明确加上“用户提到中转、派送中、驿站、快递员、物流节点”这类特征词,还给“订单状态”补了一句“不返回具体物流轨迹节点”。更新描述后,混淆率从8.3%降到了2.1%。

第二个问题:参数抽取把“昨天”翻译成了物理时间而非业务时间。

用户问“我昨天买的鼠标到哪了”,模型把order_date填成一个具体的日期字符串,但业务系统里的“昨天”要与仓库截单时间对齐,晚上11点之后的订单要算到当天。这个坑光靠Schema描述解决不了,属于业务知识不匹配。我们最后的做法是在参数Schema里加一个time_hint字段,并让技能执行器内部基于业务时区重新计算日期边界,而不是直接信任模型给出的字面日期。

第三个问题:复合技能执行到中间步骤失败,模型开始编答案。

“退货费用预估”需要三步,如果第二步政策查询失败,之前的调度器直接返回异常给模型,模型就会自己瞎编一个运费金额,特别危险。修复方案是给复合技能的每个分支都定义了降级路径:政策查询失败时,不再走完整预估,而是返回“由于退货政策暂时无法获取,请稍后重试或联系人工客服”,并附带一个人工入口链接。原则很简单:拿不到数据就不答,也不能编。

6. 最后再分享几点关于agent-skills的个人体会

跑完这一整套改造,我最深的感受是:agent-skills看起来是一个技术框架问题,本质上是工程管理问题。它强迫你从“模型能不能听懂”出发,而不是从“模型有多聪明”出发。技能描述、参数Schema、路由阈值、降级策略,每一行定义都是在给大模型的随机性套缰绳。

如果你现在正准备做一个Agent类产品,我有几条很实在的建议:

  • 从三个技能开始跑通闭环,不要一上来规划三十个。技能系统重在每一层的质量,不在数量。
  • 每个技能上线前强制要求写反例边界,不要只写“这个技能能做什么”,还要写“这个技能不该做什么”。
  • 日志追踪从第一天就做,不要等出了问题再补。没有trace的Agent系统,出故障时就像大海捞针。
  • 拿真实流量回放来评估技能,不要只在测试集上自嗨。测试集永远覆盖不了用户的离谱表达。

我个人最大的教训是:永远不要高估模型对技能描述的遵循能力。同一个技能,换个表述方式,效果能差出一大截。与其不停地调模型prompt,不如花时间把技能的元数据写细、写透。这套工程化的笨办法,在Agent这条路上反而是最稳的走法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询