前一阵子我在重构手头的智能体项目时,agent-skills这个词频繁出现在我面前。一开始我以为它只是又一组工程命名,真正动手把技能模块拆开重写之后,我才意识到它背后藏着一个被大多数人忽略的核心命题:Agent到底靠什么稳定完成复杂任务——是模型本身的聪明,还是一套能被复用、被验证、被组合的“技能系统”?
我的答案是后者。单次对话里靠提示词让大模型临场发挥,短期看没问题;一旦任务链路变长、工具变多、调用场景变复杂,模型的“临场发挥”就开始暴露不稳定、难追溯、不可复用的问题。于是我把项目里所有工具调用和任务步骤全部重新抽象成可注册、可检索、可编排的技能组件,这几个月跑下来,效果比预期好不少。这篇文章不是学术论文,是我在实际项目中把agent-skills落地、踩坑、调优的全过程记录。
1. agent-skills要解决的真实问题:单次提示词不够用
先说一个我在实际项目里遇到的典型场景。最早我做的智能体是“一段系统提示词 + 一堆 function calling 定义”,每个工具函数在调用时把参数塞给大模型,让模型决定调哪个、参数怎么填。
表面看没有任何问题,demo 跑得飞快。但一旦进入真实业务,三件让人头疼的事就来了。
第一,上下文被工具定义塞满。十几个工具的 JSON Schema 加起来有几千 token,这些 token 是每轮请求都固定占用的。模型输入窗口越大,留给历史对话和业务数据的空间就越小。等到任务需要连续阅读多轮文档、多轮搜索结果时,上下文已经拥挤得不行。更麻烦的是,每新增一个工具,所有历史会话的 token 成本都跟着涨,模型还会因为工具描述之间互相干扰而出现“选择困难”。
第二,技能的复用和沉淀几乎为零。同一个“总结当日项目进展”的能力,在周报场景要用,在晨会场景要用,在跨团队同步场景还要用。但我当时的实现方式是把这段逻辑分别复制进不同的提示词分支里,每次改动都得同步改好几个地方。更别说“先查日历再安排会议”“先读文档再写纪要”这种固定套路,完全依赖模型每次碰运气似的自己走对流程。
第三,失败不可追踪、结果不可复验。模型调了工具,参数对不对、返回结果有没有真正满足业务条件,这些判断都散落在对话逻辑里。一旦出问题,翻日志只能看到“模型返回了函数调用”,却看不到当时模型认为的上下文是什么、为什么会选这个参数。排查效率低到让人怀疑人生。
agent-skills的解法在我看来很直接:把“能力”从对话里抽出来,变成独立、有名字、有输入输出契约、有验证规则、可组合的模块。模型不再需要“看懂”所有工具的细节,它只需要根据自己的任务目标,从技能注册表里“选中”一个技能,把参数交给技能去执行。
这个转变最核心的收益不是 token 变少了,而是责任边界变清晰了——模型负责决策“做什么”,技能模块负责执行并保证“做到什么标准”。决策和执行解耦之后,我才能在上面叠加缓存、审计、回退、人工介入这一整套工程保障。
1.1 一次重构后的效果对比
我把重构前后同一个任务的调用链路放在一起做过一次对比。重构前,智能体要完成“读取今天的所有项目邮件,提取任务项,按项目分组生成待办清单”这个动作,大致流程是:
- 模型从系统提示词里知道有“邮件查询”工具和“文本分析”能力;
- 模型自己决定顺序:先调邮件接口拉取列表,然后逐封读取内容;
- 模型在对话上下文里临时生成摘要和分类结果;
- 用户如果追加一句“去掉已完成事项”,模型又要重新理解一遍历史消息再补一次输出。
重构之后,我把“读取今日邮件并提取任务项”整体设计成一个技能,命名为extract_tasks_from_emails。它内部封装了邮件 API 调用、正文清洗、关键词过滤、任务项结构化输出这几个步骤。模型在决策层只需要看到一行技能描述:“提取今日邮件中的任务项并按项目分组”,然后传入日期即可。至于这个技能内部怎么调用邮件接口、怎么清洗文本,对决策模型完全不可见。
输出结果由技能模块自己保证格式,返回的是一个符合{ project: string, tasks: string[], priority: number }[]契约的 JSON。业务侧不需要再让模型“看一遍结果再总结一遍”,下游直接消费结构化数据就行。整个调用链路的 token 消耗大概压缩了四成,最关键的是结果稳定性好了一截——因为核心逻辑不再依赖模型每次随机应变。
所以我的第一个结论是:agent-skills不是一个组件,而是一套设计原则——把临时性的“提示工程”沉淀成永久性的“能力资产”。这个原则贯穿了后面所有设计和实现。
2. 技能的定义与结构设计:把“会做什么”变成可验证的契约
技能系统能不能用,定义准不准是最要命的一环。定义得太粗,模型选不准;定义得太细,技能数量爆炸,检索和维护成本都失控。我最终把技能的“定义”拆成两个层面:元信息层和执行层。
元信息层是给决策模型和检索系统看的,执行层是给技能引擎跑的。绝大部分人只关注执行层怎么写代码,忽略了元信息层的设计,结果技能做出来像个黑盒:模型知道有这个技能,但完全不知道什么情况下该用它。
2.1 技能元信息:我建议至少包含这些字段
以我项目里的一个真实技能为例。这是“调取指定客户的历史订单并计算累计消费”的技能,YAML 形式的元信息如下:
name: get_customer_lifetime_value version: 2.1.0 description: > 查询指定客户的历史订单数据,并按时间范围聚合计算累计消费金额。 适合在“评估客户价值”“生成客户画像”“筛选高价值用户”等场景使用。 输入客户ID和可选的时间范围,输出聚合后的金额与订单列表。 input_schema: type: object required: - customer_id properties: customer_id: type: string description: 客户唯一标识,CRM系统中的ID start_date: type: string format: date description: 可选,开始日期,默认不限制 end_date: type: string format: date description: 可选,结束日期,默认不限制 output_schema: type: object required: - total_amount - order_count - orders properties: total_amount: type: number description: 累计消费金额,单位元 order_count: type: integer description: 订单总数 orders: type: array items: type: object description: 订单简要信息 preconditions: - "customer_id 必须存在于 CRM 中" - "如果调用方未提供 end_date,默认按当前时间为止" side_effects: - "只读操作,不修改任何业务数据" - "会调用 CRM 订单查询接口,注意接口限流" verification: - "返回的 total_amount 必须 >= 0" - "orders 数组为空时 total_amount 必须为 0" models: ["gpt-4o", "claude-3.5-sonnet"]这里我觉得最容易被忽略的是description字段。它不是写给人类看的注释,而是写给我们选型用的检索器以及决策模型看的“路标”。它需要回答三个问题:这个技能解决什么场景、何时不该用、输入参数里最容易填错的点是什么。
在早期版本里,我把 description 写得特别短,比如“查询客户订单”。结果模型经常在“只需要查单笔订单金额”的场景去调用这个聚合技能,传错了参数,导致下游计算逻辑全部出错。后来我把 description 扩写成“查询历史订单并计算累计消费”,并且明确补充了“如果需要查询单笔订单详情,请调用 get_order_detail 技能”,误用率立刻降下来。
2.2 技能粒度:太大太小都是坑
技能粒度是我踩过最深的坑之一。刚开始重构时,我倾向于把流程拆得极细,一个“发送告警通知”的功能都被我拆成了“查询告警规则”“匹配告警条件”“加载通知模板”“调用通知渠道”四个技能。
结果模型在决策链路里频繁在这些技能之间跳来跳去,每一步都要做一次路由判断。跳转次数越多,累计误差越大。有一次模型在执行时先是正确选择了“匹配告警条件”,然后在下一次路由时把一个本应传给“加载通知模板”的参数传给了“查询告警规则”,任务直接断链。
粒度设计的平衡点在哪儿?我在反复尝试后的经验是:一个技能应该是一个人类能直接描述清楚的、有明确业务结果的原子动作。判断标准就一条——如果你需要写两句话才能向同事说明白这个技能是干嘛的,大概率粒度不合适的。
比如“发送告警通知”就不是“查询规则”“加载模板”“调用渠道”的集合,它本身就够原子。内部可以做模板渲染、渠道熔断、重试,但这些是实现细节,决策模型不需要感知。反过来,“生成周报并发送给团队并同步到知识库”这个动作太大,它应该是由“生成周报”“获取团队成员列表”“发送消息”“写入知识库”四个技能编排出来的任务,而不是一个孤独的大技能。
2.3 执行层:技能不是“函数”,是带契约的模块
执行层的设计也藏着一个关键思路。我不建议把一个技能直接实现成一个裸函数——裸函数只是“能跑”,但不带任何自我校验和日志能力。
我最终为技能执行定义了统一接口,大致如下:
class BaseSkill: name: str version: str def validate_input(self, params: dict) -> dict: """校验输入参数,返回清洗后的参数""" raise NotImplementedError def run(self, params: dict, context: SkillContext) -> SkillResult: """执行技能核心逻辑""" raise NotImplementedError def validate_output(self, result: SkillResult) -> SkillResult: """校验输出结果,确保符合 output_schema 与 verification 规则""" raise NotImplementedError def rollback(self, context: SkillContext) -> None: """失败时执行回滚,默认空实现""" raise NotImplementedError这个接口的核心意图是把“执行”和“校验”剥离开。run()里只关心业务逻辑,不要让执行代码既算结果又判断结果对不对;validate_output()负责对照契约做最终把关。业务逻辑和校验规则分离之后,技能的可测试性大大增加。我可以对同一个技能传入正常参数、边界参数、非法参数,分别验证三者的表现,而不用去 mock 一大堆内部状态。
# 简化示例:单个技能的实现骨架 class GetCustomerLifetimeValueSkill(BaseSkill): name = "get_customer_lifetime_value" version = "2.1.0" def run(self, params, context): customer_id = params["customer_id"] start_date = params.get("start_date") end_date = params.get("end_date", datetime.utcnow().isoformat()) raw_orders = context.tools.fetch_orders(customer_id, start_date, end_date) total = sum(o["amount"] for o in raw_orders) return { "total_amount": total, "order_count": len(raw_orders), "orders": [{"id": o["id"], "amount": o["amount"]} for o in raw_orders], }这里我特意不把业务数据源写死在技能内部,而是通过context.tools注入。这样技能和具体的数据存储解耦,测试时我可以很容易把fetch_orders替换成 mock 方法,跑一套预置订单数据来验证聚合逻辑。
3. 注册表与路由检索:让Agent在海量技能里快速选对
技能定义好了,下一步就是让 Agent 能“找到”对的技能。我见过很多项目把技能堆在一个目录里,靠决策模型读一遍所有技能描述来硬选。技能一多,这种方法准不准完全看运气。
3.1 技能注册表:元信息加索引
我做了一个轻量级的技能注册表,本质上就是一个技能元信息的集合。注册表负责技能的上架、下架、版本管理和检索索引更新。修改技能元信息、调整技能版本,都必须经过注册表,不能绕过它直接改文件。
class SkillRegistry: def __init__(self): self._skills = {} self._embedder = None def register(self, skill: BaseSkill) -> None: if skill.name in self._skills: raise DuplicateSkillError(skill.name) self._skills[skill.name] = skill llm_skill = skill_metadata(skill) if not self._embedder: self._embedder = Embedder() self._index = self._embedder.index(llm_skill)注册表里每一份技能元信息都会被解析成两部分:结构化字段(name、version、input_schema、preconditions 等)和语义向量(description 的 embedding)。结构字段用于精确检索,语义向量用于模糊匹配。两者最后在路由阶段做一次融合打分。
实际运行时,路由流程是这样的:
- 拿到用户当前的任务描述,提取关键词与意图;
- 先用结构化字段做一次粗筛,比如任务里出现“客户”“订单”,就锁定向
get_customer_lifetime_value这类技能的输入标签; - 同时计算任务文本与技能 description 的语义相似度;
- 综合粗筛结果与语义相似度,按分数排序,取 Top-K 作为候选技能集;
- 候选技能集连同各自的关键元信息一起送进决策模型,由模型最终决定调用哪个技能。
这个流程最大的价值是让决策模型从“从几十个技能里大海捞针”变成“从两三个高度相关的技能里挑一个”。候选越少,模型选准的可能越大。实测下来,技能数量从十几个涨到六十几个之后,路由准确率没有明显下降,这在过去靠模型硬读全部描述时是做不到的。
3.2 路由打分要注意的坑
打分这件事听起来简单,实际操作里有个很容易犯的错——只计算任务文本与技能描述之间的相似度。真实场景中,用户的任务描述往往高度模糊,比如“帮我看看这个客户最近的情况”,这个任务既可能是要查订单,也可能是要查聊天记录,还可能涉及工单。此时如果只做语义匹配,候选技能之间分数差距会非常小,模型仍然容易混淆。
我的补充策略是让技能描述里加上“不适用场景”的负面示例,作为负样本参与检索和下游提示。例如get_customer_lifetime_value的描述里明确写:“仅用于聚合统计,不适用于查询单笔订单详情;如需单笔详情请用 get_order_detail”。语义检索时可以把它单独抽取出来,作为过滤条件。这样即使任务描述模糊,只要包含“单笔”“一笔订单”这类信号,该技能的分值都会被压低,模型自然偏向正确选项。
| 场景信号 | 命中技能(降序) | 未加负面描述时的表现 |
|---|---|---|
| “查这个客户总共花了多少钱” | get_customer_lifetime_value 优先级远高于 get_order_detail | 两个技能分数接近,随机性高 |
| “看看去年这一笔退了多少钱” | get_order_detail 优先级远高于 get_customer_lifetime_value | lifetime 技能经常被误选,导致报错 |
这个表基本就是我的真实踩坑记录。负面描述是技能路由里性价比最高的优化手段,没有之一。
3.3 检索之外:路由缓存与固定路径
另一个值得说的是“路由缓存”。对同一类任务,模型的选择结果其实是可以沉淀的。用户在项目里高频执行的“生成每日项目进展报告”这条链路,第一次跑通后,后续出现的相似任务可以直接命中原有的技能组合路径,省掉整套检索 + 模型判断的过程。
我在注册表外面又加了一层路径缓存,记录“任务指纹 → 技能调用序列”。任务指纹的特征包括模块ID、参数结构、用户意图分类三个维度。命中缓存时,直接执行已经验证过的技能组合,不再次调用决策模型。这不仅节省了 token,还让高频任务的响应时间稳定下来,不再波动。
这个机制也隐含一个原则:Agent 不是每次都要重新思考同样的问题。系统设计者应该主动为高频路径创造快速通道,而不是要求模型反复做同样的判断。毕竟“思考”的成本很高,而且容易出偏差。
4. 技能的嵌套、编排与上下文传递:从单个能力到复杂工作流
单个技能能解决的问题始终有限。真实任务往往需要多个技能配合,比如“根据销售数据生成季度复盘报告并推送给团队成员”,至少涉及查询数据、生成可视化、撰写文案、发送消息四个能力。技能编排就是把这些能力串成一条可控的执行链。
4.1 两种编排模型:链式与图式
我先后试过两种编排方式。第一种是链式编排,技能 A 的输出直接作为技能 B 的输入,一条线走下去。这种模型的好处是简单、容易理解、容易 debug。坏处是不够灵活,一旦中间出现分支(比如根据数据结果决定是否发送告警),写起来就很别扭。
第二种是图式编排,技能之间通过共享上下文交互。每个技能从上下文里读取自己需要的输入,处理后把结果写回上下文,再由引擎根据预设的流程决定下一步走哪个分支。业务流程复杂、存在条件跳转时,图式的表达力强很多。
以“销售数据复盘报告”这个场景为例,我的编排配置长这样:
workflow: quarterly_sales_report steps: - id: fetch_sales skill: query_sales_data input_mapping: quarter: workflow.quarter department: workflow.department - id: generate_chart skill: create_chart_from_data input_mapping: data_ref: steps.fetch_sales.output branch: if: steps.fetch_sales.output.total_revenue < 100000 then: skip - id: draft_report skill: generate_report_text input_mapping: chart_ref: steps.generate_chart.output data_ref: steps.fetch_sales.output - id: send_report skill: send_team_message input_mapping: content_ref: steps.draft_report.output channel: "#sales-review"配置驱动的编排,好处是流程透明。我看一眼配置就知道每一步调用了什么技能、输入从哪来、条件分支往哪走。相比硬编码在 Python 里,配置的可维护性高非常多。业务人员不需要动代码也能调整流程。
4.2 上下文传递的边界控制
说到图式编排,最核心的调优点就是上下文如何传递。早期版本我图省事,让所有技能共享同一个大 context 字典,技能 A 写进去的内容技能 B 能直接读。结果就出了问题——技能 A 写了一个date字段,技能 B 也写了一个date字段,覆盖得无声无息,最后下游拿到的日期完全不对。排查了两个小时才发现是两个技能的字段名撞了。
后来我引入“命名空间”机制。每个技能写入上下文的键都必须带技能名前缀,例如get_customer_lifetime_value.output.total_amount。读取时只能读自己声明依赖的字段。同时引擎在技能执行前会把上下文里允许该技能读写的字段声明出来,执行完成后校验代码是否越权写入。这样虽然写起来啰嗦了一点,但基本杜绝了字段污染的问题。
如果你现在的技能系统规模还没大到需要完整上下文框架,也至少请记住一条:给每个技能的中间产物一个独立的命名空间,不要共享裸字段名。
4.3 编排中途失败的恢复策略
编排越长,中途失败的概率就越大。技能链一旦在某一步报错,前面已经执行完成的技能结果怎么处理?是全部作废重来,还是在失败点重试,或是从断点继续?
我的处理方式是给每个技能声明side_effects元信息。如果技能的副作用只是内存里的数据计算,那失败后无脑重试即可。如果技能带外部副作用(比如已经发了邮件、已经写了一条数据),那必须实现回滚逻辑。side_effects字段在元信息里早就定义了,这里才真正派上用场。
实测中,面对外部 API 偶发超时、网络抖动这类错误,最简单的有效策略是带退避的重试,而不是立刻失败。我通常配置最多三次重试,第一次失败后等 500 毫秒,第二次等 2 秒,第三次等 5 秒。超过三次才真正判定失败,并触发整条链路的回滚。不要小看这个笨办法,它把我在真实业务里的编排失败率降了大概七成。
5. 我在实战中踩过的坑:探测失效、描述污染和技能漂移
到了这一节,我把项目落地过程中最值得讲的三类坑摊开说。这些都是文档里不会写、网上也很少有人系统总结的经验。
5.1 坑一:“会调工具”不等于“技能真的可用”
有一段时间,我把技能在单元测试里跑通作为“可用”的唯一标准。后来上线第一天就被打了个措手不及:技能查询接口用的 API Key 因为是测试环境的,在线上环境里没有权限,一连串调用全部 401。
技能“存在”和技能“在当前环境可用”是两码事。完整技能体系里,光有注册、检索、执行还不够,还必须有持续的可探测性验证。
我做了一个周期性的技能探针任务:每隔几分钟,从注册表里随机抽取一批技能,用模拟参数发起一次执行,校验返回结果是否符合output_schema。探针任务把所有技能标识成三个状态:正常、异常、降级。异常技能会在路由阶段被剔除,降级技能会在候选集里被压到最低优先级。
这个探针看起来简单,但价值极大。它把技能可用性从“碰到问题时被动发现”变成了“在产生影响前主动拦截”。上线探针之后,我经历过某一数据源认证过期导致二十多个技能集体异常的事故。探针在用户真正调用前三分钟就发现了异常,技能自动从注册表隐藏,前端用户完全没感知到故障。
5.2 坑二:技能描述被 LLM 输出污染
这个坑我印象很深。技能描述最开始是我手工写的,风格基本统一。后来为了加快迭代速度,我尝试用大模型批量生成技能描述,再从其中挑合适的收入注册表。模型生成的描述细节丰富、看起来逻辑严密,但用的时候问题不断。
问题出在描述内容不干净。模型会把“上个版本是这么实现”这类语料写进描述,还时不时冒出一句“注意:如果数据为空,请提醒用户确认 CRM 系统是否配置了正确的客户 ID”。此类信息残渣进入 embedding 和路由检索之后,产生了两个后果:语义向量聚焦到无关信息上,检索排名出现偏差;决策模型读到描述里的特殊情况,频繁选择该技能却没有处理对应的数据条件,反而增加了挫败的调用路径。
最后我的处理方式是花大力气清洗描述,并把描述分成标准字段让模型逐个填充,而不是整段自由生成。我对每个技能的描述字段固定为四段:技能用途、适用场景、不适用场景、使用限制。模型只能按这个模板补全,不允许自由发挥。这四段内容保证每种信息各归其位,检索和提示都能提取到结构化信息。描述生成可以用 LLM,但必须有强约束,否则是在给系统埋雷。
5.3 坑三:技能漂移——老技能悄悄变了味儿
项目迭代半年后,我开始收到一类反馈:“以前这个技能挺好用的,最近怎么老出错?”查来查去发现,某个人改了技能内部的某个参数阈值,另一个人为了兼容新业务改了输出字段的命名,还有一次是技能的底层 API 换了新版本,返回结构变了,但技能代码没跟上。
这类问题我统称为“技能漂移”——技能定义、实现、行为在持续不受控地变化。漂移直接切断了“技能名称”与“技能行为”之间的稳定性,而稳定性恰恰是技能体系的基础。
应对漂移的办法是版本锁定和回归测试,缺一不可。每个技能在注册表里都有版本号。部署时锁定版本,更新必须走升级流程,不能直接改正在用的技能文件。同时为关键技能建立“行为指纹”——固定输入对应的固定输出。每次技能升级,CI 自动跑一遍行为指纹回归,输出差异超过阈值就阻止合并。
skill: get_customer_lifetime_value version: 2.1.0 fingerprint: input: [{customer_id: "C001", start_date: "2024-01-01"}] expected_output_hash: "a94f8c..."这套机制加上前面的可用性探针,让我终于不再担心“某个技能在没人注意的时候悄悄变了一个样子”。技能的每一次变化都有记录、有校验、有回退依据,系统整体重新进入可控状态。
最后再分享一个实际的体会。agent-skills表面上是一堆文件、接口、注册表和路由逻辑,但它的核心其实是把“让模型临场发挥”转变成“让系统稳定发挥”。每次你在某个 Agent 任务里发现同样的错误反复出现,问一下自己:这个问题是应该靠提示词再去“叮嘱”模型注意,还是应该把这个步骤抽成一个带输入输出契约、带校验、带版本、带可用性探测的技能?只要做过一次改造,你就能感觉得到两者的差别。技能的抽象听起来是个工程名词,但放在智能体这个语境里,它就是把不可控的智能变成可控的基础设施。这也是我现在对agent-skills最直接的理解。