先说一个我自己的体会:这两年做大模型应用,最容易出现的一种尴尬是——Demo里Agent跑得风生水起,一上真实任务就原地打转。要么是模型压根没调用应该调用的工具,要么是调用了但参数传得乱七八糟,要么是任务执行到一半毫无反馈地静默失败。一个很经典的场景:让Agent查本月销售额并生成分析,它查完数据就停了,完全忘了"生成分析"这个动作;或者它记得要生成分析,但输出了三句正确的废话,没有调用任何分析工具。很多人把这类问题归结为"模型不够聪明",但以我自己的经验看,八成原因是:你根本没有把能力组织成可以被Agent稳定调用的技能(Skills)。
这也是我近期持续在打磨的一个内部项目,代号就叫"agent-skills"。它不是一个雄心勃勃的通用框架,而是一套朴素的工程方法论,外加一个很轻的代码骨架,用来解决"怎么让大模型稳定地复用一组能力"这件事。这篇文章我想把里面最核心的设计取舍、踩过的坑、以及我在两个内部场景里实测后的效果变化写出来。如果你正在做Agent类应用,又被"模型时灵时不灵"折磨过,这篇应该能省你不少时间。
1. 从"裸奔式Prompt"到"可复用技能封装"
1.1 三个让我下定决心改造的真实事故
先交代一下背景。去年下半年我开始负责一个企业内部的智能助手项目,核心能力包括文档问答、数据查询、执行脚本、生成报表等等。一开始我们走的是很多人都会走的路:一个超大System Prompt,塞进去几十条工具描述,配上几个Few-shot示例,然后就开始祈祷模型稳定。
结果并不稳定。我挑三个印象特别深的问题:
第一个是指令冲突。System Prompt里写"查数据时优先使用数据查询接口",结果用户问"上个季度华东区的退货率是多少"这种本身包含明确数据诉求的问题时,模型反而去调用文档检索接口,把一份PDF里的数字翻出来当答案。究其原因,是文档检索接口的描述写得"看起来更具体",在向量检索阶段拿到了更高分数,模型被带偏了。
第二个是调用参数随心所欲。我们的订单查询接口要求传日期范围、区域编码、统计粒度三个参数,模型经常只传两个。更麻烦的是它还会自己发挥,把区域写成汉字"华东",而接口要求的是编码EAST_CN。数据服务那边从"宽容失败"改成了"严格失败"之后,整个Agent的直接报错率肉眼可见地上升。
第三个最折磨人:能力无法沉淀。这周我们帮模型调试好了"周报生成"这个复杂流程,下周换了个Prompt写法或者换了模型版本,这个能力就"失忆"了。同一个能力,今天能用,明天不能用,连个标准化的验证手段都没有。
这些问题集中爆发之后,我意识到一个本质问题:我们不是在"构建Agent",而是在靠Prompt蛮力表演Agent。Prompt当然重要,但它不适合作为能力的持久化承载层。
1.2 技能到底是什么:一个可复用、可观测、可演进的能力单元
我想明白了一个很简单的道理:与其让模型每次都在几十上百条工具描述里"自由发挥",不如把那些真正成熟、真正反复被调用的能力,从提示词里抽出来,封装成带名字、带接口、带实现、带失败策略的技能单元。
我定义的技能(Skill)不是简单的"一个函数",而是三层东西的组合:
- 描述层:面向模型的文本,包括技能名称、适用场景、输入参数、输出含义、注意事项。这一层决定了模型"什么时候选它、怎么用它"。
- 执行层:真实跑逻辑的代码,负责调用数据库、发HTTP请求、执行脚本、处理文件。这一层决定了技能"能不能可靠地把事做成"。
- 策略层:围绕这个技能的特有策略,比如参数校验失败怎么办、超时怎么办、结果为空怎么办、是重试还是降级。这一层决定了技能"在异常情况下是否体面地失败"。
封装之后,Agent拿到用户输入,不是"翻Prompt找答案",而是从技能库里选一个技能,然后执行它。这就像一个员工不再需要每次背诵SOP手册,而是可以直接调用成熟的工作流。而技能一旦被抽出,它就获得了三个Prompt方案很难给到的东西:
- 可复用:同一个技能可以被多个Agent、多种场景调用,不需要重复写提示词。
- 可观测:技能的输入、输出、耗时、错误率都能被系统地记录下来,而不是藏在一次性的对话日志里。
- 可演进:技能可以独立迭代、独立测试、独立回滚,不牵一发动全身。
可以说,"agent-skills"这个项目的核心,就是用工程手段把"模型的理解能力"和"系统的执行能力"解耦。模型负责理解和决策,技能负责可靠落地,两者通过一个清晰、稳定的边界交互。
2. 技能粒度怎么定:原子技能、组合技能与元技能
2.1 从"一刀切"到按复杂度分层
聊完封装的意义,接下来是最容易踩坑的设计题:技能应该多细?我见过两种极端。一种是把"获取订单数据后做统计再生成报告"整件事做成一个技能,结果技能内部又臭又长,想要复用其中"获取订单数据"这一段就非常痛苦。另一种是把"调用一个HTTP工具"都拆成技能,结果技能列表爆炸,模型在几百个技能里做路由,准确率惨不忍睹。
我在内部实践中逐渐形成了三层粒度:
原子技能(Atomic Skill):不可再拆的、完成单一明确动作的能力。它的内部可能有复杂的实现,但对外语义是原子的。例如:"查询订单列表"、"读取指定网页正文"、"执行一段Python代码"、"把Markdown转成HTML"。原子技能是技能库的地基,数量控制在几十个以内。
组合技能(Composite Skill):由多个原子技能按固定流程编排而成,流程相对固定,参数入口统一。例如"生成季度经营分析报告"这个组合技能,内部路径就是:查询经营指标 -> 对比去年同期 -> 生成图表 -> 组装报告。组合技能的价值在于,它把一段"模型容易做到一半就忘掉"的多步流程,固化成一条确定性流水线,模型只需要在最上层做一次选择。
元技能(Meta Skill):作用于技能本身的能力,例如"任务规划"、"技能选择"、"信息整合"。元技能不直接面对具体业务,而是帮助Agent决定"下一步应该做什么、该用哪个技能"。规划能力如果不够鲁棒,我就用元技能把它固化成几个决策模板,而不是依赖模型在长上下文里临场发挥。
2.2 技能描述模板:按API文档的写法写给模型看
三层粒度定了之后,真正的功夫在技能描述上。我在内部强制要求每个技能都必须填写一张描述模板。一开始团队有人嫌麻烦,说"这不就是写注释吗",实测后大家才明白,这张模板直接决定了模型能不能在正确时机选中技能。
我使用的模板结构(简化版):
- 技能名称
- 一句话概述(做什么、什么时候用)
- 使用条件(明确描述什么时候应选它、什么时候不应选它)
- 参数说明(参数名、类型、取值范围、缺省行为)
- 输出说明(返回结构、边界情况、空结果的表达方式)
- 常见错误与注意事项(给模型看的提醒)
这里有三个实际教训需要展开说:
第一,名称和概述要"按模型会怎么检索来写",而不是按人怎么称呼来写。之前有个技能叫"查询退换货数据",描述里写的是"获取退货、换货及相关售后记录",结果模型在应该使用它的时候跑去调用"订单列表"接口了。原因是"订单列表"的描述里包含了"订单、退款、物流"这类关键词,和用户问题里的"退货"在向量空间里更近。我把"查询退换货数据"的概述改成了"获取退货/换货/退款/售后相关的订单明细与统计数据",并增加了一条使用条件:"当用户提到退货、退换、售后、退款时,优先选择本技能"。改动之后,路由准确率提升了近十个百分点。这说明技能描述不能只写"这个技能是什么",还要写清楚"哪种措辞对应这个技能"。
第二,参数说明必须是"给模型看的保守合同",而不是"给开发者的设计文档"。我见过最典型的反面案例是:参数说明里写着"timeRange: 查询的时间范围",模型传了"最近三个月"进去,服务端解析直接失败。现在的写法我会改成:
timeRange: - 类型: string - 格式: ISO8601区间,如 "2024-01-01/2024-03-31" - 规则: 不接受"最近X个月""本季度"等自然语言表达,请在调用前完成日期推算这样把"模型天然喜欢犯的错"提前在描述层做了约束。参数校验在技能内部还是要做,但描述层定义得越严,模型的成功率就越高。
第三,输出说明里要写明"空/异常时怎么表达"。之前一个技能查询结果为空时会返回status: "not_found",模型读到这个状态后经常答非所问。后来我在输出说明里补了一句:"如果查询结果不存在,请在回复中直接告知用户'没有找到相关记录',不要推测原因,不要编造替代数据。"模型的表现立刻规矩了很多。
2.3 先不用管理粒度问题时,我建议你从"业务动词"入手
很多人一上来就纠结"技术上的最佳粒度",其实对业务项目来说,更实用的入手方式是:把用户在真实对话里反复出现的业务动词列出来。
比如"查一下""统计一下""生成一下""对比一下""分析一下",这些动词背后对应的就是潜在技能。一个动词如果被用户高频使用,且内部的执行逻辑相对固定,它就是最值得先封装成技能的对象。我更倾向先收录20个左右的高频动词技能,把链路走通,再逐步补充低频但专业的技能。你先别想着一步到位,技能库是一步步长出来的,不是设计出来的。
3. 注册与路由:如何让模型在几十个技能里不选错
3.1 技能注册中心:像一个标准化的货架
技能多了以后,第二个核心矛盾就来了:模型每次要面对的不是3个工具,而是几十上百个技能描述。如果一股脑全塞进上下文,token消耗大、注意力分散、路由准确率也差;如果只靠向量检索去粗筛,又经常选到表面相似但实际不对的技能。
我的方案是做一个"注册中心"(Skill Registry),它完成三件事:
- 存储:所有技能的结构化描述、版本、状态(启用/灰度/禁用)都保存在中心。
- 索引:生成技能描述的Embedding向量,并维护额外的关键词倒排索引,用于检索阶段的召回。
- 过滤:根据当前用户意图、上下文环境、权限范围,过滤掉明显不合适的技能(比如用户不在对应权限组时,不提供导出数据的技能)。
路由阶段我采用的是"三明治"结构:
第一步,候选召回。对用户问题做Embedding,在技能库里召回最相关的top 20;同时把带权限、时间、环境等硬约束的技能过滤掉。这个环节不需要模型参与,纯计算,成本低。
第二步,规则初筛。如果某些技能带有强触发条件(比如问题里出现某个专有名词时几乎可以锁定某个技能),用规则直接提升其排序权重。这类规则是在实际跑流量的过程中逐步沉淀的,一开始少,后面越来越多。
第三步,LLM精排选择。把召回+初筛后的技能列表(通常控制在10~15个)连同原始问题一起发给模型,让模型从里面选择要调用的技能并抽取参数。这一步只在需要决策时调用一次模型,token消耗可控。
下面是我在项目里用的一个高度简化的路由逻辑示意(Python风格):
def route_skill(query_embedding, user_context, skill_registry): # 第一阶段:向量召回 + 硬过滤 candidates = skill_registry.recall(query_embedding, top_k=30) candidates = [s for s in candidates if s.visible_in(user_context)] # 第二阶段:规则加权 for s in candidates: for rule in s.trigger_rules: if rule.match(query_text): s.score += rule.weight # 第三阶段:LLM精排选择 shortlist = sorted(candidates, key=lambda s: s.score, reverse=True)[:15] chosen, params = llm_choose(shortlist, query_text) return chosen, params这个三段式设计,本质上是在检索的确定性和理解的灵活性之间找平衡。完全靠LLM去两阶段选技能,成本高且不稳定;完全靠规则和向量,又无法应对开放式的自然语言表达。实测下来,将候选技能从"全量塞给模型"收窄到"top 10~15"之后,路由准确率不仅没有下降,反而上升了,因为模型被干扰项干扰的概率小了。
3.3 两个路由阶段的常见坑复现
再补充两个我都踩过的坑,供参考。
第一个坑:Embedding召回被"近义词"误导。我们的"数据导出"技能和"数据查询"技能,在Embedding空间里距离非常近。用户说"把这张表导出来",模型在候选召回阶段两个都召回了,LLM精排时选错了。后来我做了两处修正:一是"数据导出"技能的使用条件里显式加了一条"当用户表达下载、导出、Excel、CSV等词汇时优先选择本技能";二是在规则初筛阶段加了关键词规则,命中"导出/下载/Excel/CSV"就直接提升该技能权重。修复之后这个场景几乎不再错。
第二个坑:候选列表里混入了"低频但刚好长得很像"的技能。业务部门曾经建了一个"客户情感分析"技能,描述里大量出现"评论、分析、评分"这类词。结果用户问"分析一下这个客户的评分",模型选了"客户情感分析",但用户其实是想看客户打分记录。这里的问题不是模型笨,而是描述层没有做排除性说明。我后来给这类描述里补了一段"排除说明:本技能只处理评论/反馈文本的情感倾向,不处理数值型评分查询"。宁可描述啰嗦一点,也要把边界画清楚。这大概也是技能路由和传统函数调用最大的不同——你写的每一句话既是在给系统看,也是在给模型看。
4. 最容易翻车的三件事:参数提取、失败恢复与可观测性
4.1 参数提取:把自然语言翻译成严格的Schema
技能被选中只是开始,真正的翻车高发期在参数提取。模型特别擅长把"大概意思"填进参数里,但下游系统需要的是精确值。
我整理了一套标准的处理流程:
- 第一步,模型抽取参数:从用户输入里提取结构化参数,这一步不可避免,交给模型做。
- 第二步,Schema校验:定义一个严格的Pydantic/Schema模型,对参数做类型、范围、枚举校验。不通过就带着具体错误返给模型,让它重新提取一次。
- 第三步,参数补全:如果缺少必填参数,有两种方案——要么基于上下文推断(比如用户之前说了时间段,现在没重复说),要么主动向用户提问。我为了体验优先,设定的策略是:能安全推断的就推断,推断不了的就问,不瞎猜。
- 第四步,归一化处理:把"最近三个月""上周"这类自然语言时间表达换算成具体日期范围,这一步我建议放在技能内部做,不要指望模型每次都能不出错地推算。
这里有一个很值得说的细节:模型在参数提取失败后,如果你只回一句"参数格式错误,请重新输入",大多数模型会陷入死循环,反复用同一种错误的表达方式再试,白白浪费调用次数。正确的做法是把错误精准地反馈给模型,例如:
{ "success": false, "code": "PARAM_FORMAT_ERROR", "field": "timeRange", "expected": "ISO8601区间,如 2024-01-01/2024-03-31", "actual_value": "最近三个月", "hint": "请先调用时间推算技能,将自然语言时间转换为具体日期后重试" }这个带结构化错误信息的返回,让模型知道"我不是泛泛地失败,而是具体哪里错了、应该怎么改"。加了这套失败反馈之后,参数提取的成功率提升非常明显。这也是技能封装的一个隐形收益:你可以在技能的边界上设计"错误即教学"的反馈机制,这是裸Prompt很难做到的。
4.2 失败恢复:重试、熔断和降级的取舍
技能执行过程中必然会有下游系统不稳定的时候。我定的原则是"先重试,再熔断,最后降级"。但具体怎么判断重试还是降级,要根据技能类型来。
- 幂等技能(比如查询、生成类):失败后可以安全重试2~3次,一般网络抖动都能扛过去。
- 非幂等技能(比如创建订单、发送消息、删除数据):绝对不能盲目重试,否则可能造成重复下单、重复发送的严重事故。对这种技能,我宁愿失败一次就直接进入"需要人工确认"的状态,也不允许自动重试。
- 可降级技能(比如"联网搜索"失败后可以用"缓存搜索"兜底):在技能设计阶段就应该列出降级路径,而不是运行时临时拍脑袋。
- 不可降级技能:直接返回明确的错误码,并附带一句"请联系技术支持"级别的提示,让上层Agent知道这不是它的错,不要再胡编答案。
这里我最想强调的一点:技能失败时,绝对不能让模型"自由发挥"来填补空缺。我们曾经出现过一次事故,数据查询技能超时后,模型为了让对话显得"有用",编了一个看起来非常合理的销售数据出来。用户差点按那个数据去做决策了。从那以后,我在所有技能的策略层默认加了一条规则:"如果技能执行失败,必须向用户透明反馈失败状态,禁止编造结果。"这条规则也被我加进了系统级的Agent行为约束里。
4.3 可观测性:每条技能调用都能被回放
技能化改造带给我的另一个惊喜,是可观测性大幅提升。以前用纯Prompt方案时,出问题只能翻对话日志,从几百行对话里靠肉眼找模型到底调用了什么、传了什么参数、工具返回了什么。技能化之后,我调整了日志结构:每次技能调用都会记录一个包含trace_id的调用链,日志里至少包括以下几类信息:
- 是哪条用户消息触发的这次技能调用
- 技能选中阶段:候选技能有哪些,LLM选了哪个,理由是什么(如果能拿得到的话)
- 参数提取阶段:模型最初提取的参数长什么样,校验是否通过,是否发生了修正
- 执行阶段:实际调用了哪个接口,请求响应体是什么,耗时多少
- 失败处理阶段:走了重试还是降级,最终返回给模型的是什么
有了这个链路,排查问题的时间从"小时级"降到了"分钟级"。有一次线上技能成功率下跌,我跑了一下链路日志,立刻发现是某接口更新后返回值多了一个嵌套层级,导致后续校验全部失败,整个过程一目了然。技能化的可观测性,本质上是因为你有了标准的边界、标准的接口、标准的日志格式——这些都是Prompt方案给不了你的。
5. 技能库的持续迭代:用回归评测集守住稳定性
5.1 为什么Prompt方案的"每次都重新洗牌"不可接受
技能库建起来以后,还有一个长期问题:怎么保证它越改越好,而不是越改越乱?我之前的经历是,Prompt方案改一次就是一次全量抽奖,这一周看起来没问题,下周业务方改了一句话,可能又把之前的bug带出来了。
技能方案把"能力"变成了真正可以版本管理的代码,但技能描述、组合流程的改动,本质上还是会影响模型的行为。为了不重蹈覆辙,我建立了一个轻量级的"技能回归评测集"。
做法很简单:针对每个技能,人工编写一组典型测试用例。里面既包含"应该调用该技能"的正例,也包含"不应该调用该技能"的负例。例如对"查询退换货数据"这个技能,正例是"上个月退货率最高的产品有哪些",负例是"帮我写一份退款政策说明"(这应该去文档问答流程,不该走数据查询)。
每次技能库有改动,我就把这组用例跑一遍,记录几个核心指标:技能命中率、参数完整率、任务成功率、平均调用轮次。这些指标的变化就能快速告诉我,这次改动是变好了还是变差了。
5.2 三个我用了很长时间才总结出来的迭代原则
第一个原则:"新增技能"要像"新增接口"一样谨慎。技能每多一个,模型在路由时做选择的难度就大一点。我要求新技能必须先以灰度方式上线,只对一部分流量开放,观察路由冲突率和误用率,达标后才全量。灰度期间如果发现它频繁抢走别的技能的流量,就要回头改描述,而不是急于全量。
第二个原则:组合技能的触发阈值要设置得比想象中更高。组合技能往往是"多步骤+有副作用"的,一旦模型误触发,用户就要被迫接受一个漫长的、没必要的流程。所以描述里我通常加上"当且仅当用户明确表达了完整目标时,才可调用本技能;如果只是部分相关,请先调用原子技能"。这是用描述层的约束来抑制模型"过度调用复杂技能"的倾向。
第三个原则:每次模型版本升级,都要完整回归评测集。不同版本模型对技能描述的"阅读理解方式"差异很大,尤其是一些措辞微妙的排除条件。我经历过一次大版本升级后,路由准确率从88%掉到79%,原因就是新版模型对"不应使用本技能"这类否定描述的理解变弱了。还好有回归集,问题暴露出很快,我需要做的就是在描述里把否定条件写得更直白,或者改成正向表达,重新回归通过后再切换。
5.3 一个对中小团队友好的起步建议
如果你所在的团队没有足够人力做大规模评测,我建议不要一上来就做全覆盖的测试集。可以先做一个"冒烟集":每个技能挑3个典型正例、2个典型负例,总共哪怕只有一两百条用例,也足够把最低限度的稳定性兜住。对我来说,这套冒烟集如今就在每次发布前自动跑一遍,跑挂了就直接阻塞发布流程。
所以在技能体系这件事上,我一直觉得"可持续维护"比"一开始做得大"重要得多。技能库不是一次性的项目,而是一个随着业务、模型能力、接口演进持续生长的活体。每一次新增、修改、删除,都值得像维护一套受控的接口系统一样认真对待。
6. 最后再分享一点个人体会
做了几轮技能化改造之后,我最大的感受是:Agent工程的复杂度,其实并不在模型选择上,而在于你有没有一个稳定的"能力编排层"。agent-skills这套做法本质上很朴素——把能力变成明确的单元、给单元配上面向模型的描述、用路由决策选出单元,然后像对待接口一样去维护它。它没有用任何花哨的算法,只是把很多工程上已经验证过的经验搬到了Agent场景里。
如果你现在正被"Agent行为不可控"折磨,我的建议是别急着上更复杂的编排框架,先做一次"减法":把你系统里高频用到的20个能力抽出来,写清楚描述,加上基础校验和日志,然后跑一周真实流量,看看可观测性和稳定性提升了多少。我自己就是从20个技能开始的,后来慢慢长到了上百个,业务方对Agent的信任度也是在那之后才真正建立起来的。
如果你已经开始做这件事,欢迎分享你踩过的坑,尤其是技能描述和路由偏置这一块,我总觉得这里面还有不少值得挖的东西。