1. 从"skills"这个热词说起:它到底在解决什么问题
最近一段时间,"skills"这个词在技术社区里出现的频率高得离谱。不管是在讨论 Google Cloud 上的 Agent 构建,还是在聊 GKE 集群里的自动化运维,甚至是在 Genkit 这类 AI 应用框架的语境下,大家都在谈"skills"。但如果你真的去翻官方文档,会发现一个很尴尬的事实:没有一个统一的、权威的"skills"定义。它更像是一个在社区实践中逐渐沉淀下来的概念,而不是某个厂商拍板定下的标准术语。
我自己第一次认真接触这个概念,是在给一个基于 Genkit 的客服 Agent 做能力扩展的时候。当时的需求很朴素:让 Agent 能查订单、能改地址、能触发退款流程。按照传统的做法,我会把这些能力写成一个个独立的函数,然后在 prompt 里告诉模型"你有这些工具可用"。但问题很快就来了——函数一多,prompt 就爆炸;模型选错工具的概率直线上升;更麻烦的是,每加一个新能力,都要重新调一遍整个 prompt 的措辞。那种感觉就像你每次给手机装个新 App,都得把整个操作系统重装一遍。
"skills"这个概念之所以能火起来,本质上是因为它回应了一个非常具体的工程痛点:如何让 Agent 的能力扩展变得模块化、可插拔、可复用。你可以把它理解成给 Agent 准备的"技能包"——每个 skill 封装了一类特定的能力,包含它自己的描述、触发条件、执行逻辑和输出格式。Agent 在运行时,根据当前任务动态加载和调用相关的 skill,而不是把所有能力一股脑塞进上下文里。
这个思路听起来简单,但落地的时候有一堆细节要处理。比如 skill 的粒度怎么定?一个 skill 是"查订单"还是"处理所有订单相关操作"?skill 之间怎么组合?多个 skill 同时被触发时怎么仲裁?skill 的元数据用什么格式描述,才能让模型准确理解它的用途?这些问题没有标准答案,但社区里已经积累了不少值得参考的实践。
这篇文章不打算给你一个"标准答案",因为这东西本来就没有标准答案。我想做的是,把我在实际项目里踩过的坑、试过的方案、以及那些文档里不会写的经验,系统地梳理一遍。不管你是刚听说"skills"这个词想搞清楚它是什么,还是已经在用 Agent Skills 但遇到了扩展性和可维护性的问题,应该都能从下面这些内容里找到对你有用的东西。
提示:本文讨论的"skills"特指 AI Agent 能力扩展场景下的技能模块化方案,不涉及其他领域的同名概念。如果你关注的是 Google Cloud、GKE、Genkit 这些技术栈下的 Agent 构建,那方向是对的。
2. 拆解一个 skill 的解剖结构:从元数据到执行体
2.1 为什么 skill 不能只是一个函数
很多人第一次接触 Agent Skills 的时候,会下意识地把它等同于"工具函数"。这个理解不能说错,但太窄了。一个函数只关心"输入什么、输出什么",而一个 skill 要解决的问题远不止这些。它需要回答的是:在什么情况下我应该被调用?调用我的时候需要哪些前置条件?我执行完之后,结果应该以什么形式反馈给 Agent?如果执行失败了,Agent 应该怎么处理?
我举个具体的例子。假设你有一个 skill 叫"查询物流状态"。如果只把它写成一个函数,大概是这样的:
def query_logistics(order_id: str) -> dict: # 调用物流 API,返回状态 ...但模型怎么知道什么时候该调这个函数?它需要看到一段描述,比如"当用户询问订单的配送进度、预计到达时间、或者物流异常时,使用此 skill"。这段描述就是 skill 的元数据的一部分。再进一步,如果用户问的是"我的包裹到哪了",模型需要先知道要提取 order_id,而 order_id 可能来自对话历史、用户输入、或者另一个 skill 的输出。这些依赖关系,函数签名里是体现不出来的,但 skill 的定义里必须说清楚。
所以一个完整的 skill,至少包含四个部分:
- 标识与描述:skill 的名字、用途说明、适用场景。这部分是给模型看的,措辞直接决定了模型能不能正确触发它。
- 输入契约:需要哪些参数、参数的类型和来源、哪些是必填哪些是可选。
- 执行逻辑:实际干活的代码,可能是一个 API 调用、一段本地计算、或者对另一个系统的操作。
- 输出契约:返回什么格式的数据、成功和失败分别怎么表示、是否需要附带下一步建议。
把这四部分拆开看,你会发现"执行逻辑"其实是最简单的部分——写个函数谁都会。真正难的是描述和契约的设计,因为它们直接和模型的语义理解能力打交道。
2.2 元数据描述:模型能不能用对 skill,全看这一段
我见过太多项目,skill 的执行逻辑写得漂漂亮亮,但描述字段就随便填了一句"查询订单信息"。结果模型要么该调的时候不调,要么不该调的时候乱调。这里面的核心问题是:模型的决策完全依赖于你给的文字描述,它不会去读你的代码。
那什么样的描述算是合格的?我的经验是,一个好的 skill 描述应该包含三个层次的信息:
第一层是功能定义,用一句话说清楚这个 skill 做什么。比如"根据订单号查询订单的当前状态和物流信息"。
第二层是触发条件,列举用户可能用什么方式表达这个需求。比如"当用户询问订单进度、配送状态、预计送达时间、物流异常等问题时使用"。
第三层是边界说明,明确哪些情况不应该用这个 skill。比如"如果用户询问的是退换货政策而非具体订单状态,不要使用此 skill"。
这三层信息加起来,通常控制在 100 到 200 个 token 之间比较合适。太短了模型理解不够,太长了占用上下文还容易引入噪声。我实测下来,把触发条件写得具体一点,比堆砌同义词更有效。比如与其写"查询、查看、获取订单信息",不如写"用户提供了订单号并询问该订单的配送情况"。
还有一个容易被忽略的点:skill 描述的语言风格要和 Agent 的整体 prompt 保持一致。如果你的系统 prompt 是中文的,skill 描述也用中文;如果系统 prompt 是英文的,skill 描述最好也用英文。混用语言会增加模型的理解负担,尤其是在 skill 数量多的时候,这种负担会累积。
2.3 输入输出的契约设计:别让模型猜
输入契约的设计,核心原则是能明确就不要模糊。我见过一个 skill,输入参数只有一个query字符串,然后让 skill 内部去解析这个字符串里到底包含什么信息。这种做法在 skill 数量少的时候勉强能用,但一旦 skill 多起来,模型很容易把不同 skill 的参数搞混。
更好的做法是把参数拆细,每个参数有明确的名称和类型。比如查询物流的 skill,输入应该是order_id: string和可选的detail_level: enum,而不是一个笼统的query。这样模型在调用的时候,只需要从对话里提取出订单号填进去就行,不需要它去理解整个查询语句的结构。
输出契约同样重要。模型需要知道 skill 返回的数据长什么样,才能决定下一步怎么做。如果 skill 返回的是一个嵌套很深的 JSON,模型很可能解析出错。我的建议是,输出尽量扁平化,关键字段用明确的名称,并且附带一个自然语言的摘要字段。比如:
{ "order_id": "12345", "status": "in_transit", "current_location": "上海分拨中心", "estimated_delivery": "2024-01-15", "summary": "订单 12345 目前正在运输中,最新位置是上海分拨中心,预计 1 月 15 日送达。" }那个summary字段看起来有点冗余,但实际用起来非常香。模型可以直接把这段话转述给用户,不需要自己去拼接各个字段。尤其是在多 skill 串联的场景下,summary 能大幅降低模型出错的概率。
2.4 执行体的隔离与容错
skill 的执行体应该尽可能独立,不要依赖全局状态。这一点在 GKE 这类容器化环境里尤其重要,因为你的 Agent 可能同时处理多个会话,如果 skill 之间共享了可变状态,很容易出现串数据的问题。
容错方面,我建议每个 skill 都明确区分三类结果:成功、可预期的失败、不可预期的异常。可预期的失败比如"订单号不存在",这种应该返回结构化的错误信息,让模型能据此给用户一个合理的回复。不可预期的异常比如 API 超时,这种应该被捕获并记录日志,同时给模型返回一个通用的"暂时无法处理"的提示,避免模型拿着一个堆栈信息去编造回复。
注意:千万不要让 skill 的异常直接抛到 Agent 的主循环里。一个 skill 的崩溃不应该导致整个对话中断,这是模块化设计的基本要求。
3. 在 Genkit 和 GKE 上落地 Agent Skills 的完整路径
3.1 环境准备:那些文档里不会提的依赖细节
如果你打算在 Google Cloud 的技术栈上构建带 skills 的 Agent,Genkit 是目前比较顺手的选择。它的插件体系天然适合承载 skill 的注册和调用,而且和 GKE 的部署链路衔接得比较自然。但环境准备阶段有几个坑,我挨个说一下。
首先是 Genkit 的版本问题。Genkit 的迭代速度很快,不同版本之间 API 差异不小。我在项目里锁定的是 0.9.x 系列,因为从 0.9 开始,工具注册的接口才比较稳定。如果你用的是更早的版本,可能会遇到工具描述字段被截断的问题——这个 bug 在 0.8 里存在了很久,表现是 skill 描述超过一定长度后模型就看不到了,排查起来非常隐蔽。
其次是 GKE 集群的配置。如果你只是本地开发,用 Genkit 的 dev 模式就够了。但一旦要部署到 GKE,就需要考虑几个额外的问题:skill 执行体的超时设置、并发调用的资源限制、以及日志的采集方式。我的经验是,给每个 skill 的执行体设置一个独立的超时时间,默认 10 秒,对于调用外部 API 的 skill 可以放宽到 30 秒。这个超时不要依赖 GKE 的默认配置,因为默认值往往太长,会导致一个卡住的 skill 拖垮整个对话。
还有一个容易忽略的点是服务账号的权限。如果你的 skill 需要访问 Firestore、Cloud SQL 或者其他 GCP 服务,记得给 GKE 的节点池配置合适的服务账号,并且遵循最小权限原则。我见过一个项目,因为图省事给了 Editor 权限,结果一个 skill 的 bug 导致误删了生产数据。这种教训一次就够了。
3.2 skill 注册:从静态列表到动态发现
在 Genkit 里注册 skill,最直接的方式是在初始化的时候把所有 skill 都注册进去。这种做法在 skill 数量少于 20 个的时候完全够用,而且调试起来最方便。但 skill 一多,问题就来了:每次对话都要把所有 skill 的描述塞进上下文,token 消耗大不说,模型的选择准确率也会下降。
这时候就需要考虑动态发现。动态发现的核心思路是:根据当前对话的上下文,只加载相关的 skill 子集。实现方式有好几种,我试过比较有效的是基于向量检索的方案。具体做法是,在启动时把所有 skill 的描述做 embedding 存起来,每次用户发消息时,用消息的 embedding 去检索最相关的 N 个 skill,只把这 N 个注册到当前对话的上下文中。
这个方案的效果取决于几个参数:检索的 top_k 设多少、相似度阈值怎么定、以及是否要做二次排序。我的经验是 top_k 设在 5 到 8 之间比较合适,太少容易漏掉需要的 skill,太多又失去了动态加载的意义。相似度阈值不要设太高,因为用户表达和 skill 描述之间往往存在语义鸿沟,设太高会导致该召回的没召回。
还有一个更轻量的方案,是基于规则的分类。比如先用一个轻量级的分类模型判断用户意图属于哪个大类,然后只加载该大类下的 skill。这个方案实现简单,但灵活性不如向量检索,适合 skill 分类边界比较清晰的场景。
3.3 多 skill 协同:串联、并联与冲突仲裁
单个 skill 跑通不难,难的是多个 skill 协同工作。我遇到过的典型场景有这么几种:
串联场景:用户说"帮我查一下订单 12345 的物流,如果还没发货就取消掉"。这个需求需要先调用查询 skill,根据返回结果决定是否调用取消 skill。这种串联的逻辑,最好在 Agent 的编排层实现,而不是让 skill 之间互相调用。因为 skill 之间直接调用会形成隐式依赖,后期维护很痛苦。
并联场景:用户说"帮我看看最近三个订单的状态"。这时候可能需要同时查询三个订单,然后汇总结果。并联调用的关键是控制并发数,避免瞬间打爆下游 API。我在 GKE 里用的是一个简单的信号量机制,限制同时执行的 skill 数量不超过 5 个。
冲突仲裁:当多个 skill 都声称自己能处理当前请求时,需要一个仲裁机制。最简单的做法是给每个 skill 设一个优先级,冲突时选优先级高的。但更优雅的做法是让模型自己选,前提是你的 skill 描述足够清晰,模型能区分它们的适用场景。我实测下来,如果两个 skill 的描述有重叠,模型选错的概率会显著上升。所以与其事后仲裁,不如事前把 skill 的边界划清楚。
3.4 部署到 GKE 后的可观测性建设
skill 上线之后,你一定会遇到"为什么这个 skill 没被触发"或者"为什么模型选了这个 skill"这类问题。没有可观测性,排查这些问题就是盲人摸象。
我的做法是在三个层面埋点:skill 注册层记录每次对话加载了哪些 skill;调用层记录每个 skill 的输入、输出、耗时和结果状态;模型决策层记录模型在选择 skill 时的原始输出。这三层日志关联起来,就能还原出完整的决策链路。
在 GKE 上,我用的日志方案是 Cloud Logging 加自定义的 structured log。每个 skill 调用生成一条 JSON 日志,包含 trace_id、skill_name、input、output、latency、status 这些字段。然后在 Cloud Logging 里建几个 dashboard,监控 skill 的调用频率、成功率、平均耗时。一旦某个 skill 的成功率突然下降,或者耗时突然飙升,就能第一时间发现。
提示:skill 的输入输出日志可能包含用户敏感信息,记得在记录前做脱敏处理。尤其是涉及订单号、地址、联系方式这类字段,该哈希的哈希,该截断的截断。
4. 那些让我熬夜排查的坑:skill 开发中的真实故障记录
4.1 描述字段的"隐形截断":一个查了三天的 bug
这个坑我在前面提过一嘴,但值得展开说,因为它太隐蔽了。当时的情况是,我们有一个 skill 叫"处理退款申请",描述写得比较详细,大概有 300 多个字符。上线之后发现,模型几乎从来不调用这个 skill,即使用户明确说"我要退款",模型也会去调一个不相关的查询 skill。
排查过程是这样的:先看日志,发现模型确实没有选择这个 skill。然后我把 skill 描述打印出来,发现是完整的。接着我怀疑是模型的问题,换了个模型试,还是一样。最后我把描述逐段删减测试,发现当描述缩短到 150 个字符以内时,模型就开始正常调用了。
结论是:Genkit 在某个版本里,对工具描述字段有一个隐式的长度限制,超过部分会被静默截断,而且截断发生在注册阶段,你在应用层打印出来的描述是完整的,但实际传给模型的是截断后的版本。这个 bug 后来在更新版本里修了,但如果你用的是老版本,一定要自己检查一下描述长度。
这个经历给我的教训是:skill 描述不是越长越好,简洁准确才是王道。现在我写描述,都会刻意控制在 150 个字符左右,把最关键的触发条件放在最前面。
4.2 参数提取失败:模型不是万能的
另一个高频问题是参数提取。比如用户说"帮我查一下昨天那个订单",模型需要从对话历史里找到"昨天那个订单"对应的订单号。如果对话历史里没有明确的订单号,模型就会编一个出来,或者干脆留空。
这个问题的根源在于,模型在提取参数时,倾向于"填满"所有必填字段,即使它并不确定。我的解决方案是在 skill 的输入契约里,把不确定的参数标记为可选,并且在描述里明确说明"如果无法确定订单号,不要猜测,直接向用户询问"。
另外,对于关键参数,可以在 skill 执行体里加一层校验。比如订单号必须是 10 位数字,如果模型传进来的不符合格式,直接返回一个明确的错误信息,让模型重新提取。这种防御性编程在 skill 开发里非常必要,因为模型的输出本质上是不确定的。
4.3 并发调用下的状态污染
这个坑发生在一次压力测试中。我们有一个 skill 会缓存一些中间结果,用的是模块级的全局变量。单会话测试的时候一切正常,但一上并发,不同会话的数据就开始串了。用户 A 查到的订单信息出现在了用户 B 的对话里,这在生产环境是致命的。
修复方案很简单:把所有全局状态改成请求级别的局部状态,或者用上下文对象来传递。但排查过程很痛苦,因为这个问题不是必现的,只有在特定并发时序下才会触发。后来我养成了一个习惯:任何 skill 的执行体,都不允许读写模块级的可变变量。需要共享的状态,要么通过参数传递,要么存在外部存储里。
4.4 skill 之间的循环依赖
这个坑比较少见,但一旦踩上就很难受。当时有两个 skill,A 的描述里说"如果需要补充信息,可以调用 B",B 的描述里说"如果信息不完整,可以调用 A"。结果模型在两者之间反复横跳,一个简单的查询请求触发了十几次 skill 调用,最后超时失败。
解决方法是明确禁止 skill 之间的直接调用。skill 只负责自己的那一段逻辑,需要组合的时候,由 Agent 的编排层来决定调用顺序。这样虽然牺牲了一点灵活性,但换来了可预测性和可维护性。在 skill 数量超过 10 个之后,这种约束带来的收益远大于成本。
5. skill 粒度与组合策略:从"能用"到"好用"的关键决策
5.1 粒度选择的三个判断标准
skill 的粒度是设计阶段最重要的决策,没有之一。粒度太粗,一个 skill 干太多事,模型很难准确触发,而且复用性差;粒度太细,skill 数量爆炸,模型选择困难,编排逻辑复杂。
我总结下来,判断粒度是否合适,可以看三个标准:
第一,单一职责。一个 skill 应该只做一件事,而且这件事能用一句话说清楚。如果一句话说不清楚,说明它该拆了。比如"处理订单"就太粗,它至少应该拆成"查询订单""修改订单""取消订单"三个 skill。
第二,独立可用。一个 skill 应该能在不依赖其他 skill 的情况下独立完成一次调用。如果它必须依赖另一个 skill 的输出才能工作,那这两个 skill 可能应该合并,或者它们之间的依赖应该由编排层来管理。
第三,触发边界清晰。一个 skill 的触发条件应该能和相邻 skill 明确区分开。如果你发现两个 skill 的描述有大量重叠,用户说什么话的时候你分不清该用哪个,那说明粒度划分有问题。
按照这三个标准,我通常会把一个业务领域的 skill 数量控制在 5 到 15 个之间。少于 5 个,说明粒度太粗;多于 15 个,说明可能拆得太细,或者需要引入分层结构。
5.2 分层 skill 架构:应对大规模能力扩展
当 skill 数量超过 20 个的时候,扁平结构就开始吃力了。这时候可以考虑分层架构:把 skill 按业务领域分成若干组,每组有一个"入口 skill",负责接收请求并路由到组内的具体 skill。
比如电商场景下,可以有"订单组""支付组""售后组"三个大组。用户说"我要退款",先触发"售后组"的入口 skill,由它判断具体是"退款申请"还是"退货申请",再调用对应的子 skill。这种分层结构的好处是,模型在每一层只需要面对有限的选择,决策准确率会高很多。
分层的代价是增加了一次调用开销,而且入口 skill 的路由逻辑需要精心设计。我的经验是,当 skill 数量在 20 到 50 之间时,两层结构比较合适;超过 50 个,可能需要三层。但说实话,大多数项目的 skill 数量不会超过 30 个,两层足够了。
5.3 skill 的版本管理与灰度发布
skill 一旦上线,就会面临迭代的问题。你改了一个 skill 的描述,可能会影响模型的触发行为;你改了执行逻辑,可能会影响输出格式。如果没有版本管理,每次改动都是一次冒险。
我的做法是给每个 skill 加一个版本号,并且在注册的时候支持指定版本。新版本上线时,先在小流量上灰度,观察触发率和成功率的变化。如果指标正常,再逐步扩大流量。如果指标恶化,一键回滚到旧版本。
在 GKE 上实现灰度,可以用 Istio 或者 GKE 的 Traffic Director,但更简单的做法是在应用层做。比如在 skill 注册的时候,根据请求的 header 或者用户 ID 的哈希值,决定加载哪个版本的 skill。这种应用层的灰度虽然粗糙,但胜在简单可控,适合中小规模的项目。
注意:skill 的版本切换要保证幂等性。同一个对话里,不要出现前半段用旧版本、后半段用新版本的情况,否则模型的行为会变得不可预测。
6. 从零搭建一个可复用的 skill 模板:以"订单查询"为例
6.1 定义 skill 的元数据与契约
说了这么多理论,最后用一个完整的例子把前面的内容串起来。假设我们要实现一个"订单查询"skill,下面是它的完整定义。
元数据部分,描述控制在 150 字符以内:
name: query_order description: 根据订单号查询订单状态、物流信息和预计送达时间。当用户提供订单号并询问配送进度时使用。如果用户没有提供订单号,先向用户询问。输入契约定义三个参数:
class QueryOrderInput(BaseModel): order_id: str = Field(description="订单号,10位数字") detail_level: str = Field(default="summary", description="返回详细程度,可选 summary 或 full") include_logistics: bool = Field(default=True, description="是否包含物流信息")输出契约保持扁平,附带自然语言摘要:
class QueryOrderOutput(BaseModel): order_id: str status: str summary: str logistics: Optional[dict] = None error: Optional[str] = None6.2 执行体的实现与容错
执行体的核心逻辑是调用订单服务 API,然后组装返回结果。这里的关键是容错处理:
async def execute(input: QueryOrderInput) -> QueryOrderOutput: try: order = await order_service.get(input.order_id) if not order: return QueryOrderOutput( order_id=input.order_id, status="not_found", summary=f"未找到订单 {input.order_id},请确认订单号是否正确。", error="ORDER_NOT_FOUND" ) logistics = None if input.include_logistics: logistics = await logistics_service.get(input.order_id) summary = build_summary(order, logistics) return QueryOrderOutput( order_id=input.order_id, status=order.status, summary=summary, logistics=logistics ) except TimeoutError: return QueryOrderOutput( order_id=input.order_id, status="timeout", summary="订单服务暂时无法响应,请稍后重试。", error="TIMEOUT" ) except Exception as e: logger.exception("query_order failed") return QueryOrderOutput( order_id=input.order_id, status="error", summary="查询订单时出现异常,请稍后重试。", error="INTERNAL_ERROR" )注意这里所有的异常都被捕获并转换成了结构化的输出,模型拿到的是一个明确的 status 和 summary,而不是一个堆栈信息。这样模型就能根据不同的 status 给出不同的用户回复。
6.3 注册与测试:确保模型能正确触发
注册的时候,把 skill 的描述和参数 schema 一起传给 Genkit:
genkit.defineTool( name="query_order", description=QUERY_ORDER_DESCRIPTION, inputSchema=QueryOrderInput, outputSchema=QueryOrderOutput, fn=execute )测试环节,我通常会准备一组测试用例,覆盖正常触发、边界触发和不应触发三种情况:
| 测试输入 | 预期行为 | 检查点 |
|---|---|---|
| "帮我查一下订单 1234567890" | 触发 query_order | 参数 order_id 正确提取 |
| "我的包裹到哪了" | 触发 query_order 或先询问订单号 | 不编造订单号 |
| "退款政策是什么" | 不触发 query_order | 不误触发 |
| "查一下订单 ABC" | 触发但返回格式错误提示 | 参数校验生效 |
这组用例跑下来,基本能覆盖 80% 的常见问题。剩下的 20% 需要在真实流量里慢慢发现和修复。
6.4 上线后的监控指标
skill 上线后,我重点盯三个指标:触发率、成功率、平均耗时。触发率突然下降,通常是描述被改坏了或者模型版本变了;成功率下降,多半是下游服务出了问题;耗时飙升,可能是并发量上来了或者有慢查询。
这三个指标在 Cloud Logging 里建一个 dashboard,设置好告警阈值,基本就能覆盖大部分线上问题。剩下的那些疑难杂症,就得靠 trace_id 去捞完整的调用链路了。
我个人在实际操作中的体会是,skill 这个东西,设计阶段多花一小时,上线后能省十小时。尤其是描述字段和输入契约,值得反复推敲。我现在的习惯是,每写一个 skill,先不写代码,先把描述和参数定义写出来,找同事看一眼,确认没有歧义了再动手实现。这个习惯帮我避免了很多返工。