☰
Agent Skills 实战:用技能包重构 Function Calling,告别上下文污染
2026/10/7 11:45:12 网站建设 项目流程

上个月前,我接手了一个频繁告警的客服机器人项目。排查几轮之后发现问题根本不在模型不够聪明,而是智能体每一轮调用都要把二十几个function_calling定义连同参数说明一起塞进上下文,导致关键指令被“稀释”,模型经常在几类动作之间犹豫,甚至把相近的函数张冠李戴。后来我把项目里那套函数逐步重构为agent-skills的技能包体系,才真正把“可用”变成了“可维护”。

简单说,“agent-skills”不是某个神秘框架,而是我在实践中沉淀下来的一套做法:把智能体要执行的每一项操作,连同它的触发场景、入参约定、出参格式、失败后的补救逻辑,打包成标准化的“技能模块”,运行时再按当前用户意图只装载必要的技能。这样做最大的效果不是能力变多了,而是上下文干净了,模型的选择质量明显稳定下来,新成员接手时也不需要翻几百行工具定义才能改需求。这篇文章适合正在做LLM智能体应用、被工具爆炸和重复描述搞得头痛的开发者,也适合刚打算给机器人加技能编排、但还没想清楚边界的朋友。我会尽量把设计动机、目录结构、签名踩坑、线上故障与评估方式都讲透,毕竟“技能包”听起来简单,真正落地时全是细节。

1. 用着用着就乱:从“工具堆”到“技能包”的动机拆解

1.1 工具堆的维护成本与上下文污染

很多团队搭智能体的第一步,是往系统提示词里写工具列表。初期只有三五个函数时还好,函数一多就立刻暴露问题。我见过最典型的情况是:业务方要求机器人同时支持查订单、改地址、申请退款、查物流、领优惠券、查商品库存、转人工、投诉登记。看起来每个函数都独立,但实际调用时模型需要理解“改地址前要不要先验证用户身份”“退款申请和投诉登记能不能同时发生”“领优惠券失败后要不要转人工”这一类隐含规则。

如果把这些规则全部压到工具描述里,描述就会越来越长。比如一个check_order_status的函数,原本一行能写完,后来被要求加上“仅当用户提供订单号或手机号时可用”“若查询失败建议引导用户重新输入订单号,不要直接放弃”“本函数不适用于跨境订单”……这些约束加到最后,模型并不能稳定地遵守,反而因为描述中夹杂太多例外,导致它在字段匹配上频频出错。

另一个被忽视的问题是上下文污染。LLM的注意力不是无限的,工具定义越多,对用户当前真实意图的“注意力预算”就越少。我做过一次对比:同样的用户问题,挂载20个工具定义时,模型偶尔会把query_params里的order_no填成用户手机号;挂载4个相关技能时,这类错误几乎消失。这说明工具堆不只是工程问题,它直接影响模型输出的质量。

1.2 Agent Skills解决的三件事

我在重构agent-skills时,核心想解决三件事。

第一,让技能具备完整的“行为契约”,而不只是一个可调用函数。传统的工具定义通常只描述“这个函数接收什么参数、返回什么结果”,但不会描述“什么情况下该选它”“执行失败后该怎么办”“它依赖哪些其他能力”。技能包把触发条件、前置校验、执行动作、结果解析、兜底策略一并打包,模型或路由层看到的不是孤立的端点,而是一个有上下文的行为单元。

第二,按需装载。技能不是每轮都全部注入上下文,而是先由一个轻量的意图识别层判断当前对话可能涉及哪几个技能范围,再做选择。这样原始twenty几个工具只剩三四个真正进入模型可见范围,定位精确度大幅提升,token成本也随之下降。

第三,支持沉淀与复用。写好的技能包可以跨项目拷贝、迭代、灰度。比如order_query这个技能,在商城机器人里测过之后,稍微改一下数据源就能用到另一个售后项目中,不需要再把函数定义复制一遍,这也是我把仓库直接命名为agent-skills的原因——它本质上是智能体的“可复用能力资产”,而不是一次性编码。

2. agent-skills项目的核心结构与装载逻辑

2.1 一个技能包长什么样

我自己维护的agent-skills仓库,目录结构大概是这样:

agent-skills/ skills/ order_query/ skill.yaml actions.py examples.jsonl refund_apply/ skill.yaml actions.py examples.jsonl address_update/ skill.yaml actions.py loader.py router.py enricher.py tests/ test_descriptions.py test_requester.py fixtures/

每个技能目录下必须有三个文件:skill.yaml描述技能行为,actions.py提供实际执行的代码,examples.jsonl记录典型调用样例。这三个文件各有分工,yaml负责“模型能看懂什么”,actions负责“系统能执行什么”,examples负责“模型如何参照着做”。

以一个最常用的order_query技能为例,skill.yaml长这样:

name: order_query description: 查询订单当前状态,适合用户询问“我的订单到哪了”“帮我看看快递进度”等场景。 trigger: 用户提供订单编号或下单手机号,且意图为查询进度 required_fields: - order_no - phone_last4 actions: - type: function_call function: query_order_status args_schema: type: object properties: order_no: type: string description: "用户订单号,优先从会话历史中提取" phone_last4: type: string description: "下单手机号后四位,用于二次校验" required: - order_no fallback: - pattern: "order_not_found" response: "没有查到这笔订单,请确认订单号是否正确,或者提供下单手机号后四位再试一次。" - pattern: "network_error" response: "订单服务暂时不可用,请稍后再试或转接人工客服。" timeout: 15s

这份yaml里最容易被忽略的其实是fallback字段。很多工具接口失败后,模型会自己编一段“系统繁忙”的回复,但实际用户早就把“错误”当成了“订单不存在”。把常见错误模式写成fallback文案,相当于在技能层面预设了异常处理策略,至少保证机器人不会在订单服务抖动时乱说话。

actions.py的核心函数只做一件事,就是把order_no参数交给后端服务,然后解析返回结果。这里不写复杂判断,复杂判断全部交给上层技能编排逻辑,保证每个技能职责单一。

examples.jsonl则记录了几条真实对话样本,比如:

{"input": "我的订单20250101A还没到", "skill": "order_query"} {"input": "帮我查下尾号8080的订单", "skill": "order_query"}

这些样例不是模型训练数据,而是在技能选择阶段用于零样本分类的参照。对没有训练条件的小团队来说,这种“内置示例”比几千字的规则描述更好使。

2.2 技能装载器与按需提示拼装

技能装载的核心逻辑在loader.py里。它负责读取所有技能目录,建立索引,并在每一轮对话开始时根据用户最新消息挑选候选技能。这里我采用了两层筛选:

第一层是关键词与实体预筛。用正则、词库或轻量NER提取订单号、手机号、地址、物流公司等实体,把明显无关的技能直接过滤掉。这个步骤保证候选技能不会超过四个。

第二层是模型排序。如果预筛后仍有两个以上技能接近,就让LLM对候选技能做一次打分,依据是技能的description与当前用户消息的语义相关度。这个过程不需要调用大模型API,也可以用embedding相似度替代。

下面是loader里按需装载的逻辑参考:

def load_skills(user_message: str, available_skills: dict) -> list[str]: candidate = prefilter_by_entity(user_message, available_skills) if len(candidate) <= 2: return candidate ranked = rank_by_similarity(user_message, candidate) # 最多保留3个技能,避免上下文稀释 return ranked[:3]

值得强调的是,装载器并不直接向模型暴露完整技能包,而是拼装成一段紧凑的“技能说明”。例如,它会先把skill.yaml中的description和trigger压缩成一段话,再把args_schema转换成JSON Schema格式赋给function calling。fallback和examples默认不进入这轮上下文,除非模型真的调用了对应技能并触发了错误分支。

这套设计的直接收益是减少了上下文里的噪声。我测过一个典型场景:旧方案中每轮Prompt里的工具定义平均占用1800字;按需装载后平均降到300字左右,且模型在意图判断上的准确率从80%出头升到了93%上下。省token还是其次,关键是模型不再被无关字段干扰。

3. 把函数描述写明白:我在签名和提示上踩过的坑

3.1 描述里的两类经典错误

如果说技能架构决定了下限,那技能里的描述文字就决定上限。我在agent-skills项目里改得最多的不是代码,而是description。这里有两类错误特别常见。

第一类是“枚举式描述”。比如原版order_query的description写:“本函数用于查询订单状态,参数包括订单号、手机号、用户姓名”。模型看完只知道“能查订单”,但不知道“用户说'我的快递怎么回事'时该不该调用它”,也不知道“查不到时该怎么回应”。这类描述只能让模型把函数当成字典去查,而不是当作意图对应的行为。

第二类是“过度承诺式描述”。典型的例子是给refund_apply写“如果用户表达不满,建议引导申请退款”。结果模型一遇到用户抱怨物流慢,就直接调用退款申请,造成业务事故。技能的description必须克制,它只描述自己真实做到的事,不能把其他技能该做的事揽进来。

我后来格式化了一套书写模板,要求每个技能的description都包含三句话:第一句说“这个技能处理什么场景”,第二句说“不要用它处理什么场景”,第三句说“数据必要的前置条件”。以refund_apply为例:

description: >- 根据订单号和退款原因提交退款申请,适用于用户明确表达退货退款诉求。 不要用它处理物流投诉、商品咨询或优惠券问题,这些场景应分别路由到其他技能。 调用前必须确保订单已完成支付并在可退款期限内。

这样的描述既给了模型选择依据,也给了它拒绝理由。“不要用它处理什么”尤其重要,因为它能大幅减少误调用。各类模型对否定式约束的理解并不同,我实测下来,Claude系对这类约束比较敏感,GPT系偶尔会忽略,所以还不够,还要在路由层加一层规则兜底,这个后面讲。

3.2 参数Schema与结果校验的默契

参数Schema也是踩坑重灾区。agent-skills项目里所有函数参数都用JSON Schema维护,必须严格遵守几个约定:

  • 入参字段命名要有业务含义,禁止用拼音缩写。比如order_no不能写成ordno,phone_last4不能写成p4。字段名本身也是模型理解的一部分。
  • 每个字段的description必须说明“从哪来、格式是什么”。比如order_no字段的description写“从用户消息中提取的订单编号,若是纯数字,去掉分隔符”,这样模型在抽取参数时就不容易把“2025年1月1日”误当成订单号。
  • required字段宁少勿多。凡是能从历史里推理出来的字段,尽量在路由层自动填充,而不是要求模型从最后一句用户消息里硬抠。

结果校验上,我要求所有技能函数必须返回统一的数据结构:

{"status": "success", "data": {...}} {"status": "error", "error_code": "ORDER_NOT_FOUND", "message": "..."}

这个约定后面对接fallback非常方便。如果函数返回error且error_code能匹配skill.yaml里的fallback.pattern,则直接走预设回复。没有统一协议前,每个函数各回各的格式,有的返回带“code:0”表示成功,有的返回“success:True”,模型在总结回复时经常把message里的重试建议丢掉。统一之后,下游处理逻辑简化了,模型也极少再出现“复述错误信息”的情况。

4. 跑通后接二连三的意外:交互状态、依赖误配与失败兜底

4.1 JSON解析不再是“稳的”,兜底要分层

第一轮把技能包装好后,我以为最难的都过去了。结果第一个事故就出在解析上。当时模型调refund_apply,actions.py把退款申请发出去了,后端返回了成功码。但我从内部日志里发现,模型有时会返回一段带反引号的JSON,或者JSON后面跟了多余的文字。

标准JSON解析器直接抛异常,导致已成功的退款申请在技能层被判为失败,向用户播报“退款失败,请重试”。这比直接失败更危险,因为用户会反复提交,造成重复退款工单。

后来我把解析逻辑改成分层级:

def parse_response(raw: str): try: return json.loads(raw) except json.JSONDecodeError: pass # 提取第一个{到最后一个}的片段 start = raw.find("{") end = raw.rfind("}") if start >= 0 and end > start: return json.loads(raw[start:end+1]) raise ValueError("无法解析模型输出")

但这还不够。如果解析出来的data字段缺失关键信息,也一样要兜底。所以我在技能层加了一个“强校验”步骤:每个技能在actions.py里必须声明required_output_fields,执行完函数后校验返回的data里是否包含这些字段,缺失则触发出错分支。解析层兜底只能解决格式问题,强校验才能解决业务完整性。

4.2 技能之间存在隐性依赖,路由器也会踩雷

第二个意外是技能之间的依赖关系。前期的技能列表里,refund_apply需要先确认订单存在,而确认订单存在又需要order_query。按理说路由应该让模型先调order_query,再调refund_apply,但实际模型经常一言不合就调refund_apply,因为它的description里写了“适用于用户明确表达退货退款诉求”,用户一句“我要退款”就直接触发了。

这种问题不能只靠改描述,还得把依赖关系显式化。我在skill.yaml里增加了一个depends_on字段:

depends_on: - order_query

装载器在装载refund_apply时,会把order_query自动一并载入,并在技能路由规则里增加“前置技能未执行成功前,不触发后续技能”。这个改动是典型的“看着简单、做起来坑多”的事:一旦引入依赖链,就要处理循环依赖和级联失败。我的策略是禁止循环依赖,每个技能最多声明两个前置技能,并在启动时做一次全局拓扑检查。

依赖问题真正解决后,模型的行为不再是“随手调用”,而是一步步走流程,用户感知上机器人更像一个熟练客服,而不是一个乱点按钮的脚本。

4.3 上下文膨胀悄悄回来:状态记忆不能全塞给模型

第三个意外发生在多轮对话里。为了让模型在连续对话中记住用户已经完成了order_query,我一度把查询结果直接塞回上下文,结果上下文又开始膨胀,模型开始忽略技能签名,反而照着旧订单状态回答新问题。

这里的教训是:技能本身要“无状态”,状态应该放在会话管理器里。agent-skills项目里,我把每个技能执行后的关键结果写进一个独立的session_store,比如订单状态、退款单号,以结构化键值对保存。下一次模型需要这些信息时,路由层主动注入简洁摘要,而不是把完整JSON原样塞回去。

上下文膨胀不是一个函数就能解决的事,它需要持续监控。我给装载器加了一项统计:每一轮实际写入Prompt的字符数、技能定义字符数、历史摘要字符数。当技能定义占比超过25%时,触发告警,提示“技能说明过长或候选技能过多”。最终全程把上下文中技能定义比例控制在15%以内,模型稳定性和响应速度都明显提升。

5. 上线前的技能量产流程与评估边界

5.1 用pytest固定技能描述,防止“改字段改崩”

当技能数量从5个增长到20个,我开始遇到了另一个问题:改动一个技能的描述,可能会影响另一个技能的意图路由。因为模型在选择时会对比所有候选技能,一个描述被改得过于宽泛,别的技能就会失去曝光机会。

我引入了一套自动化基线测试,用固定数量的对话样例来回归每个技能的描述。比如tests/test_descriptions.py里就保持了这几条断言:

def test_order_query_not_overlap_with_address_update(): overlap = semantic_similarity(ORDER_QUERY_DESC, ADDRESS_UPDATE_DESC) assert overlap < 0.7 def test_refund_apply_requires_order_query(): assert "depends_on" in REFUND_SKILL.get("metadata")

这个测试不会依赖模型实时调用,而是用文本向量相似度和规则覆盖来及时暴露潜在冲突。虽然不能完全替代真实验证,但至少能保证团队在“顺手改一行描述”时不至于悄悄带崩其他技能。

技能包还需要配套一批“黄金样例”,来自线上真实对话,不经过AI生成。每个技能至少积累10条输入输出对,每次发布技能前,拿这些样例回归一遍。跑不过的就不发版,简单粗暴,但在团队规模较小时,这套规则比任何流程都管用。

5.2 评估什么,不评估什么

很多文章讲智能体评估会列出一堆指标,但实际上对agent-skills这类技能系统,我核心只看四个维度。

第一个是技能选择准确率,即每个用户问题是否被路由到正确技能。这可以通过人工标注一小批测试集来评估。第二个是参数抽取完整率,即函数调用时必要参数是否都被正确填充。第三个是失败兜底率,即技能执行出错后,是否走对了fallback分支而不是瞎编。第四个是端到端用户满意度,取对话后用户评价、工单关闭率等粗粒度指标。

前面两个指标是技术性的,出现问题时定位清楚;后面两个指标是结果性的,能否真正减少用户投诉。我见过团队过度聚焦前两个指标,把技能选择准确率调到99%,但用户满意度没变化,原因就是兜底文案太生硬,用户根本不愿意继续聊。评估绝对不能只看“技术整齐度”,要回到业务价值里看。

5.3 不要用技能硬套的场景

最后想说,技能包不是银弹。我梳理了一下,至少有三类场景不适合把逻辑全部交给agent-skills:

一是毫秒级响应的确定性流程。比如登录校验、短信验证码发送,这类操作不允许模型选择技能,必须走纯代码分支。技能包加进来只会增加延迟和不可控因素。

二是非常规长尾需求。如果一个技能包的触发场景一个月都出现不了几次,它的存在反而会持续干扰其他技能的决策。我会定期统计技能调用分布,超过30天零调用的技能直接标记为“未启用”,不做热装载。

三是强监管业务动作。比如支付、转账等涉及资金变动的操作,不能依靠模型自动决定执行,最好只让技能输出“动作建议”,由人工或规则引擎审批后放行。技能包负责沟通辅助,不可负责最终决策权。

这些边界不是一开始就清晰的,都是在线上出了大小事故后才一点点划出来的。前期宁可让技能少一点、窄一点,也要保障每个被装载的技能都足够可靠。

现在回头看我维护的agent-skills仓库,我最大的体会是:智能体的能力上限,很大程度取决于我们把“工程约束”以多优雅的方式交给模型。技能描述不是文案,而是模型行为的边界;技能路由不是简单的if-else,而是对用户意图的快速聚焦;失败兜底不是异常处理,而是体验的最后防线。如果你正在搭自己的智能体,又苦于工具越来越多、行为越来越不可控,我建议从最小技能集开始,先把两个技能的行为契约打磨透,再谈扩展。打磨过程中,多看看每一轮实际传给模型的技能定义和参数信息,你会比看任何指标报表都更快找到问题所在。

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

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

立即咨询