资讯详情

资讯详情

构建Agent技能库:从堆提示词到可复用技能清单的实战复盘

做Agent应用做到第三个版本的时候我彻底想明白了一件事能不能把Agent做稳根本不在于你给它写的那段系统提示词有多长而在于你手里有没有一套可复用、可插拔、可度量的技能清单。我管这套清单叫agent-skills。它不是什么高大上的框架也不依赖特定平台它的核心思路极其朴素——把Agent的每一次能力调用标准化成一个一个独立的Skill让模型在需要的时候按需取用而不是把所有能力说明都塞进上下文里让模型领悟。这篇文章我就把从零到一落地这套体系的过程、踩过的坑、以及最终沉淀下来的目录结构和调度逻辑完整分享出来。适合正在做聊天机器人、自动化工作流、或想让自己手头Agent更可控的开发者参考。1. 先说清楚Agent为什么要一套技能库而不是一把万能提示词1.1 堆提示词的三个死穴我最开始做客服Agent的时候处理的问题是订单查询、退款进度、物流跟踪、优惠券核销这些事。第一版特别天真想着把业务规则全部写进系统提示词里用一大段文字告诉模型你是客服助手你要礼貌你要能查订单、查退款、查物流……。结果一跑起来问题立刻暴露。最明显的是上下文膨胀只要我把五个业务模块的说明、字段定义、注意事项全部塞进提示词每轮对话光系统提示词就占掉两三千字。模型注意力被稀释用户问一个很简单的我的快递到哪了它可能在长篇规则里翻半天才找到物流查询的说明有时候还会翻错。第二个问题是改一处坏一片。客服规则是经常变的今天加了新的优惠策略明天改了退款时限。你改系统提示词里的某一段往往会影响模型理解另一个模块的能力经常出现帮A的问题修好了B的问题又冒出来的怪现象。第三个问题是无法度量。提示词里的能力说明到底有没有被模型真正用到用了几次成功了多少次你完全不知道。没有数据你就只能靠感觉迭代这在大规模场景下是灾难。1.2 技能化的本质从告诉它怎么做变成让它按需取用技能库的出发点正好反过来。它把每个业务能力做成了独立的、带参数说明的模块模型不再需要去理解一段混杂的长文本规则只需要在一份技能清单里做选择题用户当前的需求匹配哪个技能名字然后按技能要求的参数格式把必要信息填好交给执行层。这个模式的本质是把声明式规则换成了调用式接口。你给模型的不是如何查询物流的完整知识而是有一个叫logistics_query的技能可以查物流它的参数有这些当用户问物流相关问题时调用它。模型的任务从理解规则变成了模式匹配难度直线下降准确率自然大幅提升。1.3 什么场景值得上技能库不是所有Agent都要做技能库。我试过之后总结了一个判断标准如果你的Agent只需要完成一种固定对话模式比如纯闲聊、单一知识库问答技能库反而增加复杂度直接提示词更轻快。但一旦Agent涉及的动作超过三个——要查数据、要调接口、要操作外部系统——技能库的收益就非常明显了。特别是生产环境里的Agent你需要的不是模型偶尔会灵光一现的能力而是每一次都稳定可复现的行为。技能库把灵光一现变成了标准调用可靠性才会上去。2. 拆解Skill的核心组成描述、参数、执行体三者缺一不可一个标准的Skill我把它拆成三个部分SKILL.json描述文件、参数定义、执行体。听起来简单但每个部分都有大量细节。2.1 描述文件给模型看的菜单SKILL.json里的description字段决定了模型会不会选错技能。我踩过最大的坑就是description写得太书面化。你写查询订单状态的综合接口模型在遇到我的东西到哪了货发了没什么时候到这些口语时匹配上会犹豫。后来我把所有skill的description全部改成了when句式先写什么时候用再写具体查什么最后写返回什么。基本格式是——当用户询问{具体场景}时使用。查询{业务对象}的{哪些信息}返回{什么格式}。比如订单状态查询{ name: order_status, description: 当用户询问订单当前到哪里、发货没有、签收没有时使用。查询订单的物流状态、更新时间、当前环节返回环节名称和时间。, version: 1.2.0 }这个描述看起来不起眼但它就是技能调度准确率的分水岭。我的经验是description控制在150到300字之间太短信息不够太长模型也记不住反而干扰候选排序。2.2 参数定义让模型少编造让代码少报错参数部分我统一用JSON Schema格式。一开始我没做严格校验结果模型经常漏传必填参数或者把一个string类型写成数字执行体一跑就抛异常。后来我做了三件事一是所有必填参数必须声明required二是给每个参数写清楚用户原话里可能怎么表达这会显著提高模型抽取参数的准确率三是在执行体里再加一层防御校验即使模型传错了也只返回参数错误而不是崩溃。一个典型参数块像是这样{ parameters: { type: object, properties: { order_id: { type: string, description: 订单号用户一般会说订单号是SO-202402-001或单号SO-202402-001 }, user_phone_tail: { type: string, description: 手机尾号用于二次校验用户不提则留空 } }, required: [order_id] } }这里有个很细节的点不要相信模型能自动把上下文里的信息填对它经常漏掉用户上一轮已经给过订单号这种情况。所以多轮对话状态管理要在Agent主循环里做而不应该指望单独一次skill调用能自己搞定。2.3 执行体幂等和超时是硬要求执行体可以是本地函数、HTTP请求、Shell命令甚至另一个Agent。但无论哪种都必须满足两个硬性要求幂等和超时控制。幂等是什么意思就是同一个参数执行十次结果和状态都是一样的不会产生副作用。因为模型在调度时可能因为网络重试、异常恢复对同一个skill调用两三次。如果你的执行体是一次转账、一次删除操作重复执行就是事故。我的标准是凡是不是天然幂等的技能执行体内部要自己加请求唯一ID去重。超时控制也必须有经验值。我最初设置所有技能统一5秒超时后来发现查数据库的技能经常要8秒而查缓存的技能200毫秒就该返回了。统一超时只会让快的技能傻等慢的技能频繁失败。现在的做法是每个技能在SKILL.json里单独配置timeout_sec并且Agent主循环里严格执行。2.4 一个完整的最小技能样例用订单状态查询做例子整个目录结构是这样的skills/ order_status/ SKILL.json impl.py refund_status/ SKILL.json impl.pySKILL.json除了名称和描述还带上执行方式{ name: order_status, description: 当用户询问订单当前到哪里、发货没有、签收没有时使用。查询订单的物流状态、更新时间、当前环节返回环节名称和时间。, version: 1.2.0, timeout_sec: 5, idempotent: true, implementation: { type: python_function, module: impl, function: query_order_status } }impl.py就一个普通函数入参是上面定义的参数返回值是结构化JSONdef query_order_status(order_id: str, user_phone_tail: str ) - dict: # 实际业务中这里会查订单服务 if not order_id.startswith(SO-): return {error: invalid_order_id, message: 订单号格式不正确} return { order_id: order_id, status: shipping, current_node: 运输中, estimated_arrival: 2025-03-02 }这一步不用做得花哨关键是每个技能都遵循同一个结构这样调度层才能统一处理。3. 技能注册与自动调度Agent怎么知道什么时候该用哪个技能技能定义好之后真正决定成败的是调度逻辑。我见过很多项目在技能定义上花功夫最后却把所有技能的完整描述一股脑塞给模型结果上下文又爆炸了。所以要分开说检索和决策。3.1 不能让模型看到所有技能当技能数量超过8到10个时把全部技能描述喂给模型模型的选择准确率会肉眼可见地下降。这不是模型笨而是候选太多相似技能之间的边界会被模糊。我现在的做法是两层筛选。第一层是粗筛用关键词规则把技能范围缩小到三到五个第二层才把候选技能的描述交给模型做精细选择。粗筛规则怎么写我用的不是复杂语义就是简单的双向关键词打分。维护一份技能索引表{ order_status: { trigger_keywords: [订单, 物流, 发货, 到哪, 快递, 签收, 什么时候到], module: order }, refund_status: { trigger_keywords: [退款, 到账, 退钱, 原路返回], module: order } }用户问快递到哪了在索引表里命中快递到哪order_status得两分refund_status零分粗筛直接选order_status。只有当分数接近时才把多个候选都交给模型裁决。3.2 调度决策先做规则路由再考虑模型自主做技能调度有两条路线一是让模型自主决定调用哪个技能二是用规则路由直接决定。如果是刚起步的项目我强烈建议先做规则路由甚至可以先不做模型决策。原因是规则路由天然可审计、可复现。线上出问题的时候你能明确知道是哪条规则没命中了而模型自主决策你只能看到模型认为应该用这个技能却很难说清楚它为什么这么认为。规则路由的实现也简单def coarse_route(user_text: str, skill_index: dict) - list: scores {} for name, meta in skill_index.items(): score 0 for kw in meta[trigger_keywords]: if kw in user_text: score 1 scores[name] score scored [(name, s) for name, s in scores.items() if s 0] scored.sort(keylambda x: x[1], reverseTrue) return [name for name, _ in scored[:3]]后续如果发现规则覆盖不住长尾问题再加模型精排。我现在的线上系统就是规则粗筛 模型单选的混合结构。3.3 模型精排的Prompt模板到了模型决策这一步prompt要刻意做减法——只给候选技能的基本信息和当前用户消息不要加多余闲聊指令。我的模板是这样用户问题{{user_text}} 以下是候选技能 {{for each candidate}} 技能名{{name}} 技能适用场景{{description}} 参数{{parameters_summary}} {{end_for}} 请从候选技能中选出最匹配的一个输出JSON格式{skill: 技能名}。 如果不匹配任何技能输出{skill: none}。这里有个反直觉的点不要去给模型解释你现在是一个智能助手之类的话多余的主语设定会稀释注意力。直接把问题和候选摆出来模型挑得反而更准。4. 实战落地从零搭一个轻量Skills管理系统到这一步我给出一个可以直接抄作业的完整实现思路。整体架构不复杂三块技能清单、调度器、执行器。它们之间通过一个简单的循环串起来。4.1 技能管理目录与自动加载我习惯把所有技能放在skills目录下启动时自动扫描加载skills/ __init__.py registry.py order_status/ SKILL.json impl.py weather/ SKILL.json impl.py ...registry.py负责三件事扫描所有子目录、解析SKILL.json、把impl里的函数注册成可调用对象。加载时还要做完整性校验——没有SKILL.json、没有impl.py、参数schema不是合法JSON的直接在启动阶段报错绝不留到运行时。import json from pathlib import Path import importlib class SkillRegistry: def __init__(self, skills_root: str): self.root Path(skills_root) self.skills {} def load(self): for skill_dir in self.root.iterdir(): if not skill_dir.is_dir(): continue json_path skill_dir / SKILL.json if not json_path.exists(): continue meta json.loads(json_path.read_text(encodingutf-8)) impl importlib.import_module(fskills.{skill_dir.name}.impl) fn getattr(impl, meta[implementation][function]) self.skills[meta[name]] { meta: meta, fn: fn, timeout_sec: meta.get(timeout_sec, 5), }4.2 主循环把调用技能变成多轮对话的一等公民Agent主循环的逻辑是每轮用户输入后先通过技能索引粗筛再调用模型做精排如果模型选出了技能就解析参数、执行技能、把执行结果作为工具反馈再交给模型让模型基于结果生成回复。核心伪代码def agent_loop(user_text, history): history.append({role: user, content: user_text}) for step in range(max_steps): candidates coarse_route(user_text, skill_index) decision llm_select_skill(user_text, candidates) # {skill: ...} if decision[skill] none: final_text llm_generate_reply(history) return final_text skill registry.get(decision[skill]) args llm_extract_arguments(user_text, skill.meta[parameters]) result execute_with_timeout(skill.fn, args, timeoutskill.timeout_sec) history.append({role: tool, content: json.dumps(result, ensure_asciiFalse)}) if step max_steps - 1: return llm_generate_reply(history)这段代码在真实项目里跑了很久最大的好处是最小可用。没有引入异步框架、没有任务队列就是一个同步循环但稳定性和可调试性都非常好。4.3 执行反馈的格式直接影响回复质量skill执行完返回的结果不是原样丢给模型就完事。我吃过亏impl.py返回的数据带了一堆调试字段模型拿到后分不清哪些能对用户说结果回复里出现了什么internal_code: 200之类的东西。现在执行结果的格式做了统一约定只有三类字段{ ok: true/false, data: {...}, message: 用户可直接理解的信息 }ok为false时message是给模型的错误提示比如订单号不存在请确认用户是否提供了正确订单号。ok为true时data里的字段名写得尽量贴近业务语言少用code编号多用status、current_node、estimated_time这种能直接转成话术的名字。4.4 扩展点技能链和多步参数追问做了一段时间后你自然会发现单次技能调用不够用。比如用户问帮我取消订单A然后把钱退到原卡这涉及cancel_order和refund两个技能而且有个先后依赖。我的做法是给Skill增加一个可选的前置技能字段{ name: cancel_and_refund, depends_on: [order_status, refund] }调度器识别到depends_on时会先执行前置技能校验前提条件再在主循环里多走一步。这样技能之间就从平行关系变成了有向图。注意这一步不要做得太复杂真到了复杂的多技能编排就应该引入独立的工作流引擎而不是指望对话循环去硬扛。5. 踩坑记录上下文爆炸、技能冲突与权限失控这一节我重点写三条踩过的坑每条都是真实线上环境调出来的经验。5.1 上下文爆炸技能说明书比技能本身还长第一次做技能库时我把每个技能的参数校验规则、错误码表、重试策略全部写进SKILL.json的description想着模型知道的越多越准确。结果候选三个技能时光描述就有三千多字用户的话还没说模型先读了一段长说明书。参数误导和上下文挤占同时发生效果比没有技能库还差。后来定了一条铁律description只写何时用、查什么、返回什么三段其余细节全部放在impl.py内部。模型需要知道的信息量恰好是能做出调用决策的信息量多写一个字都是负优化。5.2 技能冲突两个技能都声称自己能处理查询有一次线上出现诡异现象用户问我的退款到哪了Agent有时会调用refund_status有时会调用一个叫bill_query的技能。二者都能查到退款信息但bill_query返回的是账单流水格式和客服话术完全不搭导致回复质量忽高忽低。排查过程是这样的先看模型决策日志发现它同时看到两个技能最后随机选了一个再看粗筛索引原来bill_query的触发词里也写了退款和refund_status强冲突最后定位到根因是技能边界定义不清——我贪图省事把一个通用的账单查询接口暴露给了模型。修复分三步。第一步把bill_query从通用查询改成只处理账单明细场景description明确写不包括退款到账查询第二步在粗筛索引里去掉退款这个词第三步也是最关键的建立了一个技能边界检查的例行动作新加技能时必须列出它不处理的场景和已有技能做一次重叠比对。5.3 权限失控技能能删表Agent真的会去删这个坑比较惊险。我给某个内部管理Agent接了一个用户注销技能这个技能功能完备参数校验也做了但没做权限控制——任何对话用户只要说把这个用户注销了Agent就会真的去调注销接口。还好发生在一个测试环境不然就是事故。从那以后我给所有技能加了一层权限标记在SKILL.json里声明访问级别{ name: user_deregister, access_level: admin_only, allowed_roles: [agent_admin] }调度器在执行前检查当前会话的角色没有权限直接拒绝连调用都不发起。这一点我强烈建议所有接外部系统写操作的技能都学宁可后面补放权也不要一开始裸奔。5.4 一个错误技能被反复调用的完整排查链路最后分享一次我印象最深的排错过程。现象是某客服Agent在用户问退款到账了没时不调用refund_status而是反复调用order_status连续几轮都这样。排查链路是这样的先看Agent日志发现粗筛阶段两个技能都进了候选order_status因为排序靠前被模型优先选到。看模型精排的输入发现refund_status的description写的是查询退款申请的审核进度而用户问的是到账了没。模型把到账理解成订单的签收到账没理解成退款到账。再看参数抽取步骤模型把用户消息里的退款两个字当成订单号填进去了触发了order_status的防御校验返回订单号格式不正确。到这里根因清晰了不是调度坏了而是description的语义覆盖不够模型对口语变体的理解能力有限。修复方案把refund_status的description改成三句话——当用户询问退款到账、退款到银行卡、钱有没有退回来时使用。查询退款是否打款成功、打款金额、打款时间返回打款状态。注意退款不等于订单状态查询不要调用订单状态技能。改完之后我又加了一批回归用例把历史上十几条类似用户问题全部跑了一遍确认稳定后才上线。这条经验让我养成一个习惯所有skill的description上线前至少用真实用户语料跑一轮匹配测试比你自己读十遍都管用。6. 质量评估与持续迭代别让技能库变成垃圾堆技能库做出来不是终点它和代码一样需要维护。如果放任不管几个月后就是一个没人敢动的巨型怪物。6.1 用三个指标给每个技能打分我在调度器里加了一圈埋点所有技能调用都会记录调用次数、成功次数、平均轮次消耗、失败原因。每个月底汇总成一张表格核心看三个指标调用率某技能被调用的次数除以该场景相关用户问题的总数。太低说明模型没学会用它。成功率调用成功的次数除以调用总次数。太高或者太低都要看太高可能是调用场景过于简单太低头一个怀疑对象就是description和真实用户匹配不上。误用率本来该调用A技能的用户请求模型却去调了B技能。这个指标最隐蔽需要抽样人工看对话才能统计。这三个指标配合起来几乎能判断出每个技能的健康状态。6.2 灰度上线让新技能先在小流量里接受检验新技能上线的常规操作是直接替换旧技能。但如果是一个全新的、和旧路径功能重叠的技能我建议加一个灰度开关。实现起来不复杂在注册中心里给技能加status字段候选阶段只放行status为active的技能新的技能先标成beta只对百分之五的流量生效。灰度观察期通常一到两周。看两个数字beta技能的成功率是否高于旧方案以及是否有新的误用case报告。都没问题再全量放行。6.3 定期裁剪六十天没用的技能直接下架这个决策标准可能有些极端但很有效凡是60天内调用次数为零、或者持续处于只被误用不被正用状态的技能一律下架处理。我最初下架过一个天气查询技能——客服Agent场景里根本用不到它但因为它注册在目录里偶尔用户闲聊今天冷吗时模型就会调一下。天气数据接口不稳定返回失败后模型回复得也不顺畅还不如直接让模型用自己的常识回答。下架之后相关误用率直接降了一半。技能库不是收藏夹每一条都要有存在的理由。精简之后模型做选择时干扰少了整体准确率反而往上走。我自己这套agent-skills体系从搭建到现在前后迭代了四个大版本。最后沉淀下来的不是某一个酷炫的技术而是一套朴素的工程纪律技能描述要口语化、权限边界要明确、上线之前必须用真实语料回放一遍、每60天清理一次。老老实实把这四条守住你的Agent就算不聪明也绝对够稳。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →