1. 我为什么要把 Agent 的能力拆成 "skills"
在跑一个真正面向业务的 Agent 项目之前,我一直以为 Prompt 写得好、策略想得多,Agent 就能稳定输出。后来发现,提示词技巧能解决模型的"表达问题",解决不了"行为组织问题"。同一个 Agent,跑一个月后,你会看到它时而聪明得像资深工程师,时而蠢得像刚接手的新人——不是模型变了,是它的行为没有边界,什么任务都往一个巨大的上下文里塞。
"agent-skills"这个名字,就是我后来整理这个问题的入手点。它本质上是一个思路,也是一个配套的工程骨架:把 Agent 能够执行的复杂行为,拆成一个一个带有明确触发条件、输入输出契约和执行步骤的"技能",而不是把所有指令都堆在系统提示词里。
一个典型的反例是这样的。你给 Agent 配了读数据库、发邮件、做数据分析、生成图表四个能力,然后把每个能力的说明写进系统提示词。初期效果很好,但随着业务需求增多,提示词越来越长,模型开始分不清什么时候该先查数据再分析,什么时候该直接发邮件。有一次它甚至把发给客户的周报内容当成了数据库查询语句的一部分。这类问题出现得多了,你会意识到:Agent 缺的不是能力,而是对能力的封装和调度。skills 就是解决这个问题的。
这篇文章我把自己整理 agent-skills 的过程完整过一遍,包括技能卡怎么设计、运行时怎么注册和调度、上线后踩过的几类故障、以及后续扩展方向。适合正在做 Agent 应用、尤其是已经开始被"提示词过载"困扰的团队参考。
1.1 同一个输入,两种完全不同的表现
如果你做过带工具的 Agent,肯定见过这种场景:用户说"帮我看看上个月销售数据怎么样,挑重点写一封邮件给销售负责人",Agent 有时候能正确拆解成"读数据 → 生成摘要 → 写邮件"三步,有时候却只回了一句"我建议查看销售看板"。
我第一次遇到这种不稳定的时候,第一反应是换更强的模型。换完之后能撑两周,然后又出问题。后来我发现,问题不在推理能力,而在"行为规划"没有落到一个可检验、可干预的结构上。Agent 把用户的请求映射到内部行动时,如果只有一个巨大的抽象系统提示词,它每次都在做"开放性的即兴规划",结果自然不稳定。而如果把拆解后的行动固化成一个个 skills,每个技能负责一个明确的子目标,模型的规划负担就小了很多,输出也变得可预期了。
这里要明确:这些技能不是简单的"工具函数"。工具函数通常解决一个原子操作,比如执行一段 SQL、调用一次 API;而一个技能是"带状态的完成一个目标",它可能包含多个工具调用、中间判断和输出整理。打个比方,工具是螺丝刀,技能是"把这张桌子组装好"的完整流程,既包含螺丝刀的使用时机,也包含先装哪块板子的顺序。
1.2 技能、工具、提示词之间到底差在哪里
很多团队在开始做 Agent 时会纠结一个词:我到底该把能力做成工具类,还是做成技能类?这个问题问多了,我发现可以拿一张表格说清楚区别。
| 维度 | 提示词片段 | Tool(工具) | Skill(技能) |
|---|---|---|---|
| 粒度 | 行为建议 | 原子操作 | 组合流程 |
| 是否含决策 | 含 | 不含 | 含 |
| 可控性 | 弱 | 强 | 较强 |
| 复用性 | 低 | 中 | 高 |
| 典型例子 | "记住,用户问数据时先读数据库" | execute_query(query) | 月度销售分析(查数→分析→出报告) |
| 出错影响 | 只有引导作用 | 单步失败 | 整体结果异常,需追踪多步 |
我并不是说提示词不重要。提示词仍然是定义 Agent 人设和价值观的地方,但它不适合承载"流程性知识"。一份好的技能定义,应该能让你读它的时候就知道什么条件下调用、输入是什么、输出是什么、内部步骤大约有几条。这更像一份内部 Wiki,而不是一句叮嘱。
还有一个容易混淆的词是"plugins"。插件通常是技术产品形态,决定你给 Agent 接入哪一组外部能力。而 agent-skills 更偏行为编排层,决定 Agent 在一个具体业务场景中应该以什么顺序调用这些能力。同一个插件,可能被多个技能复用;一个技能,也可能跨越多个插件。
2. 给技能定边界:设计一份能落地执行的技能卡
agent-skills 的核心单元是"技能"。但它不能只是一个自然语言描述,因为自然语言描述既没法被程序稳定解析,也没法方便地做测试和版本管理。我最后采用的方案是:每个技能对应一个结构化文件,业内一般叫技能卡(Skill Card),内容包含元信息、触发条件、输入槽位、执行步骤、输出契约和误区提醒。把技能信息做成结构化文件,是整套系统的地基。
2.1 技能卡的基本字段和写法
我第一次做技能卡的时候,写得像一篇小作文,把能想到的业务背景都写进去了。结果模型在判断是否调用该技能时,反而被无关信息干扰。后来我精简成一套固定的字段,原则只有一个:让模型以最少的阅读量理解"什么时候用、传什么、怎么跑、出什么"。下面是实际使用的一份技能卡示例:
name: monthly_sales_report version: 1.3.0 description: 生成某产品线的月度销售报告,包括销量趋势、地区占比和异动提醒。 trigger: - 用户要求“月度销售报告” - 用户提到“上月销售情况”且需要正式报告 inputs: product_line: type: string required: false default: all description: 产品线名称,空默认全产品线 month: type: string required: true description: 报告月份,格式 YYYY-MM recipients: type: list[string] required: false description: 收件人,可为空 steps: - call: metrics_service.query_sales args: product_line: ${inputs.product_line} month: ${inputs.month} - call: metrics_service.calculate_change_ratio args: prev_month: ${input.prev_month} - call: report_writer.compose_summary args: charts: ${steps.result.charts} - call: mailer.send_report args: to: ${inputs.recipients} output: format: markdown schema: summary: string trend_table: list[row] abnormal_metrics: list[item] mistakes: - 不要把邮件正文和报告正文混淆 - 计算同比时先用上一个月的完整数据,不能用当月至今的数据替代这套结构里面有三个很关键的地方,我分别展开说。
- description 要短。它主要是给模型的"快速检索卡"。太长的描述会让技能选择的准确性下降。descriptions 一般控制在 40 个汉字左右,宁可模糊一点,也不要把触发细节写进去。
- trigger 是给"预路由"用的,不是给模型自由发挥用的。可以设计成一组正则或语义规则,当用户请求命中 trigger 时,直接把该技能列为候选。
- mistakes 是很多人会忽略的字段。它记录历史上模型在执行这个技能时犯过的错,相当于给这个技能加了"局部经验缓存"。模型在执行技能时读这个字段,能显著降低重复出错率。
2.2 以一个技能只解决一类问题为原则
技能拆分粒度可以理解为:如果某个技能的执行步骤超过 8 步,或者内部包含多个独立的决策分支,它就应该被拆成两个技能。为什么要这么做?因为技能调度的最大敌人是"高输入噪声"——当技能内部要做的判断太多了,模型在每一步都可能产生偏移,正确的概率会被多次相乘。
举个例子,"生产周报"这个需求,表面上可以拆成一个"周报生成"技能,内部同时做数据拉取、异常检测、文字总结三件事。但这种设计有个问题:如果用户只想看异常检测结果,不想要完整周报,这个技能就被迫走完整流程,浪费了时间和上下文。更合理的方式是拆成"数据拉取"、“异常检测"、"报告渲染"三个独立技能,再加一个编排层来处理它们之间的关系。每个技能都尽量是一个输入到输出的清晰映射,内部没有太多复杂分支。
我在实践里会定一个简单的拆分检查表:第一,这个技能是否总被一起调用?如果是,可以不拆;第二,这个技能内部的某一段逻辑是否会单独被用户要求使用?如果是,拆;第三,这个技能的输入参数中是否存在大量"如果……则……"的情况?如果是,拆成不同技能分别应对。
2.3 输入槽位设计:宁可给默认值,也不要让模型自由发挥
Agent 技能经常卡在一个点上:用户说"帮我看看销量"——到底是看哪条产品线?哪个时间段?很多时候模型会自行脑补一个参数,然后吐出一份用户根本没要的东西。解决这个问题的办法不是指望模型猜对,而是在输入槽位上设置合理的默认值,并让默认值出现在技能描述的显眼位置。
我在每个输入字段上会加 required 标记,并为非必填字段设计一个明确的默认行为。比如 product_line 默认是 "all",month 则必须由用户指定,如果用户没有给,模型必须启动询问流程,而不是直接假设成当前月份。这样设计的原因很简单:时间类参数的错误会直接污染结果,而"全产品线"这类范围类参数就算猜错了,也能让用户快速纠正,不会产生严重的语义错误。
输入槽位还有一个技巧是"参数预填"。如果系统能从上文对话里提取出用户所在部门、常用产品线等信息,就预先填进槽位,而不是让模型从零开始解析。这样既减少了模型的负担,也保证了参数的一致性。比如用户登录信息里有当前部门,那月报技能里的"部门"槽位就可以直接从用户态里拿。
3. agent-skills 的运行时核心:注册、路由、串行调用
技能卡写好了,只是一堆存在磁盘上的 YAML 文件,真正要让 Agent 用得起来,还需要一个运行机制把这些静态定义变成可调用的能力。agent-skills 的运行时做三件事:技能注册、技能路由、技能执行编排。下面我按启动 Agent 的完整流程来拆。
3.1 技能注册表:把零散技能变成可查询的目录
注册表的设计目标,是让所有技能在内存里形成一个可以按名称、标签、触发条件快速检索的索引。每次服务启动时,加载器会扫描指定目录,逐个读取技能卡文件,做基本格式校验,然后构建为 RuntimeSkill 对象。下面是一个简化版的注册逻辑:
class SkillRegistry: def __init__(self): self._index = {} def load_from_dir(self, path): for file in Path(path).glob("*.yaml"): skill = parse_skill_card(file) self._index[skill.name] = skill for tag in skill.meta.get("tags", []): self._tag_index[tag].append(skill) def get_skill(self, name: str): return self._index.get(name) def query(self, text: str, limit=3): # 先用 trigger 规则做粗筛 candidates = [s for s in self._index.values() if s.match_trigger(text)] # 再按语义相关度排序 return sorted(candidates, key=lambda s: s.similarity(text), reverse=True)[:limit]这里我建议不要把所有技能卡都丢给模型去读。注册表会维护一个"精华索引",每个技能只暴露 name、description、trigger 三字段给路由层。模型只需要通过这三个字段判断用哪个技能,详细步骤在执行该技能时才展开注入。这样做的好处是大幅减少上下文消耗,也让技能选择的准确率明显提升。
构建注册表的过程中,有一个很容易被忽略的细节是技能卡里 name 的命名规范。不能只用中文名,也不能只用拼音缩写,最好采用 snake_case 加业务前缀的形式,比如 financial_weekly_report 或者 ops_alert_distribution。如果技能名可以是一个合法函数名,后续做自动化测试和脚本调用都会方便很多。
3.2 技能路由:关键词匹配与语义路由的混合方案
技能路由是整个运行时里最有意思的部分。纯靠规则匹配无法覆盖用户的各种表达方式;完全靠语义路由又会出现不稳定和难调试的问题。我实际跑的方案是级联式:先用规则粗筛,再用语义精排。
具体说,用户输入会先过一层关键词和正则规则,比如用户说到"周报"、"报告"、"邮件"这些词,就能把候选技能缩小到三五个。这一步速度很快,基本是毫秒级。接下来,如果候选只有一个,直接选它;如果有多个,再对候选技能的 description 向量化,和用户输入做相似度排序,取 top 1。如果语义相似度都低于某个阈值,就让模型来选。
这个混合方案最大的优势是"可诊断"。如果路由错了,你可以先看看是不是关键词规则配少了,还是语义排序有问题。纯语义方案则很难追溯,因为向量空间的结果往往说不清。这个方案还可以做"路由回退":当模型在选择技能时犹豫不决,可以引导它向用户确认,而不是默认选一个。
3.3 一个请求从进入到完成的执行链路
把注册和路由说清楚,我再用一个具体请求串一遍完整执行链。假设用户说:给我出一份上个月的华东区销售日报。
执行链路大体分六步:
- 意图解析:请求进入路由层,"上月"与"日均"等词命中 daily_sales_report 技能的 trigger。
- 槽位抽取:用小型解析器或轻量模型把"华东区""上个月"填入 region、month 两个槽位。这一步的参数提取是可以单独调试的。
- 技能加载:运行时从注册表取出 daily_sales_report 技能卡,把详细步骤注入当前上下文。
- 子任务执行:技能内部按照 steps 列表依次调用数据服务、计算服务、报告组件。每次调用都会有独立的 Trace ID。
- 中途校验:在执行完数据查询之后,技能会做一次结果合理性检查,比如查询结果是否为空、日期范围是否符合预期。校验失败则进入修复分支,而不是继续往下走。
- 输出提交:步骤完成后,按输出契约生成 Markdown 报告,返回给对话层。
这条链路能这样顺畅跑起来,离不开一个前提:每个技能在执行过程中产生的中间结果,都要能被持久化和回放。这样一来,如果第 4 步出了问题,你只需要重放该技能链路,而不必让用户重新说一遍需求。这个设计思路与后端服务里的"请求链路追踪"非常像。
4. 上线后我踩过的四类坑,每个都值得记录
任何一个系统,设计和真实上线之后的差距,才是真正值钱的教训。agent-skills 也一样,第一次上线跑通并不难,难的是之后在真实流量下的稳定性。我把自己踩过的坑归成了四类,每类都附上排查过程,希望能让你少走一段弯路。
4.1 技能描述太长,直接把上下文塞爆
上线初期,为了让模型"更懂"技能,我在技能卡里塞了很多业务背景,比如采购流程的详细说明、历史数据的口径定义。结果发现,每次会话只要同时装载两个技能,上下文里的系统提示词部分就占了三四千 token,留给用户对话和推理的空间大幅缩水。更尴尬的是,技能选择准确率没有因此上升,因为模型被过多信息干扰了。
排查过程:我先用 token 统计脚本查看每个请求的上下文分配,发现问题集中在技能加载阶段。然后我做了一轮"技能描述瘦身",把业务背景全部从触发字段里抽离,只放在技能执行步骤里按需读取。cutoff 之后,每个技能的固定上下文开销平均降了 60%,技能选择的准确率反而回升了 8 个点左右。这是个很反直觉的结果:给模型的信息越少,它反而越能选对。
4.2 技能之间来回引用,形成死循环
有一次,Agent 在做"生成门店巡检报告"的时候,突然调用了"客服反馈汇总"技能,然后又从"客服反馈汇总"技能里触发了"门店巡检报告"技能,两个技能互相等待对方的输出,导致整个请求超时。这种问题在单体提示词里不常见,但在技能化之后就很容易出现,因为每个技能对"上游依赖"的感知不完整。
排查过程:我让技能注册表记录了每次技能调用的父子关系,生成调用树之后,发现两个技能之间形成了环。修复分两层:第一层,在技能卡里显式声明 dependencies,在运行时构建 DAG,执行前先做环路检测;第二层,在路由阶段,如果发现用户请求可能同时命中多个技能,且这些技能互相引用,就强制折成一个主技能来编排,而不是让它们各自执行。这个规则后来救了我很多次,凡是技能间需要嵌套调用,都先做依赖关系检查,别等出问题了再去追。
4.3 技能更新后悄悄回归,旧用例不通过
技能卡是文本,文本最大的问题就是"看起来没改,行为全变了"。有一次我只是在技能卡里调整了一下 trigger 的措辞,把"查询上月销售"改成了"获取上月销售数据",结果导致某条业务用例的调用路径彻底变了,输出格式也不匹配。
排查过程:一开始完全没有定位到问题,因为代码逻辑没有改动。后来我养成了一个强制习惯:每次技能卡变更,都必须跑一遍该技能的回归测试集。这个测试集不依赖模型做断言,而是直接验证技能卡的调用路径是否满足预设路径,比如是否先调用数据服务,再调用计算服务。路径校验通过之后,再跑一条端到端的真实用例确认输出格式。把技能卡纳入版本管理和 CI 检查,是这套系统走向稳定的重要一步。
4.4 技能使用的工具权限过大,出了安全事故
这个问题比较隐蔽,但一旦发生就是大事。我给某个技能配置了数据库读取权限,本意是让它读 view,结果由于数据库账号权限控制不够细,技能在实际执行中访问了不该访问的表。查日志时发现,查询请求里出现了用户表的名字。
排查过程:问题的根源是我把"技能权限"和"用户权限"混为一谈。用户本身只有普通权限,但技能运行时使用了一个功能更强的服务账号,相当于越权了。修正方案是给每个技能配置独立的最小权限凭证,并且在下发 SQL 前加一层字段级白名单。这里如果只做事后拦截,风险还是大,最好从一开始就给技能建立一个"数据边界"清单,明确它能碰哪些域。
我把这四类坑汇总到一个表格里,方便对照检查:
| 问题 | 表现 | 根因 | 解决办法 |
|---|---|---|---|
| 上下文爆掉 | 会话变慢,后续推理质量下降 | 技能描述太长 | 瘦身描述,按需注入 |
| 循环调用 | 请求超时 | 技能间隐式循环依赖 | DAG 存根 + 环路检测 |
| 回归异常 | 旧用例输出变化 | 技能卡文本被改动 | 技能卡进 CI,跑路径回归 |
| 权限越权 | 访问了无关数据表 | 技能共享超权限凭证 | 技能级最小权限 + 数据边界清单 |
5. 怎么让技能体系更稳:健康检查、预算机制与缓存
技能体系跑顺了之后,我开始关注一个更大的问题:如何让它长期稳定,而不是靠一次上线时的运气。稳定性的核心可拆成三件事:这个技能现在能不能用,用起来要花多少预算,结果能不能少算一次就不算一次。
5.1 给技能做健康检查,而不是监听接口
传统的健康检查是检查服务是否在监听端口,但技能的"健康"比这复杂。比如,某个技能依赖的数据表可能已经存在但结构变了;或者技能依赖的外部 API 返回正常但数据语义已经不对。这些都不是端口能反映出来的。
我给技能体系做了一套主动健康检查:每个技能定义里带一个 probe_steps 字段,内容是"执行一次极简流程并验证输出"。比如月报技能的健康检查是查一条最近的订单,确认返回结构里包含 amounts 字段;邮件技能的健康检查是调一次 API 的 ping 接口,并确认返回 token 有效。这套机制会让每次发布前都跑一遍所有技能的健康检查,检查结果会进入发布报告。这样技能的服务等级就被量化了。
5.2 给技能加时间预算和步骤上限
在实际运行中,最容易失控的不是模型本身,而是技能的调用链长度。有时候一个技能内部会自作主张循环拉数据,把 3 步流程跑成 12 次调用。针对这一点,我给每个技能设置了两个上限:时间预算和调用次数上限。
时间预算默认为 30 秒,如果超时,就终止当前技能执行并返回部分结果。调用次数上限通常设为步骤数的 1.5 倍,超出就要触发告警。这两个上限还可以按技能类型细分。比如查询类技能时间预算可以短一点,而报告生成类技能因为要等外部 API,预算就要放宽。设置上限的价值不只是保护资源,更重要的是让"异常执行"显性化。以前超时就一次,原因是模糊的;现在超时会记录一个中断指纹,后续可以直接定位是某一步调用卡住了。
5.3 中间结果缓存,省下重复计算
技能的很多步骤都存在"重复计算"的情况。比如用户先问了一次月度销售数据,隔两分钟又问了一次"那占比呢",这时候如果技能重新拉一次全套数据,显然浪费。agent-skills 的缓存层设计并不复杂,但收益非常明显。我会给每个技能步骤的入参计算 hash,把出参存进带 TTL 的缓存。第二次遇到相同入参时,直接返回缓存结果。
这里有一个关键的坑:缓存 key 必须包含数据版本号,否则数据源变更后,缓存还会吐出旧数据。我的办法是在技能卡里加了一个 data_version 依赖字段,比如依赖的数据库表更新到第几版。任何数据迁移之后,数据版本号会自增,那么对应缓存也自然失效了。这套机制跑下来,部分高频技能的重复调用率降低了三成左右,而正确率没有下降。
6. 后续扩展空间:技能微调、评价体系和团队共享
agent-skills 做到中期,会自然出现三个扩展方向:一是让技能在执行中能学习,二是让技能的好坏可以被度量,三是让技能可以跨团队分享复用。这三个方向如果展开做,每个都可以是一个独立项目,我这里只讲自己已经试过的入口。
6.1 技能微调:把高频技能的执行路径固化成模型参数
如果你有一个技能的调用频率特别高,而且它的步骤非常稳定,那么你可以考虑用一批真实的"用户请求-技能调用路径-输出结果"三元组去微调一个轻量模型,让模型在用户提出请求时直接输出"该技能的参数",而不是依赖外部的槽位解析模块。这样做的好处是延迟更低,鲁棒性也更强。代价是需要维护高质量的训练集和持续的评测集。不是所有技能都值得微调,我建议只对 top 5 高频技能考虑这个方向。
6.2 技能评价体系:不只看成功率,还要看"纠偏成本"
很多人评价技能好坏只看成功率,这太粗了。真正需要衡量的指标应该包括:触发准确率、执行成功率和平均纠偏成本。纠偏成本的含义是,如果技能输出让用户不满意,用户需要说多少句话才能纠正它。一个成功率不错但纠偏成本很高的技能,往往是槽位设计不合理导致的。把纠偏成本纳入评价体系后,你才会去认真优化那些"每次都要用户再补充一次时间范围"的槽位。
6.3 团队内共享技能:标准化是前提,而标准化本身就是收益
技能一旦沉淀下来,就会形成一个组织内部的"能力资产"。后续团队会发现,做新的 Agent 功能时,不需要再从零写一个技能,只需要在现有技能的基础上做组合和微调。这个过程会让团队逐步形成一套自己的技能标准,比如技能卡字段规范、测试用例规范、权限设计规范。这些标准本身就是很大的收益,因为它会让新成员更快上手,也会让 Agent 的每个功能都可追溯、可回滚。
以上只是我从 "agent-skills" 这个思路出发整理出来的一套实践经验。实际跑到现在,最深的感受是:技能化真正的价值并不是让 Agent 瞬间变聪明,而是让 Agent 的每次行为都可以被定位、被测试、被改进。当你把"聪明"从模型参数里转移一部分到工程结构里,稳定性才会真正掌握在自己手里。