ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Skill与Tool的本质区别:Agent能力抽象与动态加载机制

Skill与Tool的本质区别:Agent能力抽象与动态加载机制

1. 从一个被问烂了的问题说起:Skill 和 Tool 到底差在哪

如果你最近在折腾 Agent 开发,大概率在某个群里见过这样的争论:有人说 Skill 就是 Tool 换了个马甲,有人说 Skill 是 Prompt 的变体,还有人干脆把两者混着用,代码里 Tool 和 Skill 的注册逻辑写得一模一样。我一开始也是这么想的,直到我在一个实际项目里踩了个不大不小的坑——我写了一个"生成周报"的 Tool,参数是content: string,结果模型每次调用都只塞进去一句"本周完成了若干工作",输出质量惨不忍睹。后来我把它改造成一个 Skill,内部封装了模板选择、数据拉取、语气调整三个子步骤,模型只需要说"帮我生成本周周报",剩下的它自己编排,效果立刻上了一个台阶。

这件事让我意识到,Skill 和 Tool 的区别不是命名上的,而是抽象层级上的。Tool 是原子能力,Skill 是能力的编排单元。这个区别听起来简单,但它直接决定了你的 Agent 能不能"自演进"、能不能"动态加载"、能不能在运行时根据上下文决定"我现在该会什么"。

这篇文章我想把这件事讲透。不是那种"Skill 是高级 Tool"的泛泛之谈,而是从注册机制、加载时机、执行边界、演进路径四个维度,把 Skill 和 Tool 的差异拆开来看。如果你正在做 Agent 框架、Agent 项目,或者只是想让自己的 Agent 更聪明一点,这篇内容应该能帮你少走一些弯路。我会尽量用实际代码和踩坑经验来说话,而不是堆概念。

先说结论,方便你带着框架往下读:Tool 解决的是"能不能做",Skill 解决的是"会不会做"。Tool 是动词的原子化,Skill 是动词的组合化加情境化。这个差别,在 Agent 规模小的时候看不出来,一旦你的 Agent 要处理几十种任务、要支持热更新、要让模型自己决定加载什么能力,它就是生死线。

2. Tool 的本质:一个被严格约束的函数签名

2.1 Tool 的契约模型:输入输出必须可枚举

Tool 的核心特征其实只有一个:它是一个契约明确的函数。你给它一组参数,它返回一个结果,中间发生了什么模型不需要知道,也不应该知道。这个契约的严格程度,直接决定了 Tool 能不能被可靠调用。

我见过太多人写 Tool 的时候犯一个错误:参数设计得太"自由"。比如写一个搜索 Tool,参数是query: string,然后指望模型自己构造出高质量的查询语句。实测下来,模型要么把整个用户问题原封不动塞进去,要么加一堆无关的修饰词。正确的做法是把参数拆细:keywords: string[]、time_range: string、source_filter: string,每个参数都有明确的枚举范围或格式约束。

这就是 Tool 的第一个本质:它的输入空间必须是可枚举、可校验的。因为 Tool 的调用是模型"一次性决策"的结果,模型没有机会在调用过程中调整策略。如果参数空间太开放,模型的选择就会发散,调用成功率会断崖式下跌。

维度好的 Tool 设计坏的 Tool 设计
参数数量3-5 个,每个都有明确语义1 个万能 string 参数
参数类型枚举、数字、结构化对象自由文本
返回值结构化 JSON,字段固定大段自然语言
错误处理明确的错误码和重试建议抛异常或返回空

2.2 为什么 Tool 不适合承载"流程知识"

Tool 的第二个特征是无状态、无编排。一个 Tool 被调用时,它不知道之前发生了什么,也不知道之后会发生什么。它就是一个纯粹的函数。

这个特性在简单场景下是优点——可测试、可复用、可并行。但在复杂场景下就是硬伤。举个例子,你要做一个"竞品分析"功能,流程是:搜索竞品列表 → 逐个抓取官网信息 → 提取关键指标 → 生成对比表格 → 输出分析报告。如果你把这五步都做成 Tool,模型需要连续调用五次,每次都要把上一步的输出正确传递给下一步。实测中,模型在第三步之后就开始丢上下文了,要么漏掉某个竞品,要么把指标张冠李戴。

问题不在于模型不够聪明,而在于Tool 的粒度太细,编排成本被转嫁给了模型。模型每次调用都要重新理解"我现在在哪一步、下一步该干什么",这个认知负担会随着步骤数量指数级上升。

2.3 Tool 的注册与发现:静态绑定的代价

大部分 Agent 框架里,Tool 是启动时静态注册的。你在代码里写一堆register_tool(),然后 Agent 启动,把这些 Tool 的描述塞进 system prompt 或者 function calling 的 schema 里。

这个机制的问题在于:Tool 集合是固定的,Agent 的能力边界在启动那一刻就锁死了。你想加一个新 Tool?重启服务。你想根据用户权限动态隐藏某些 Tool?得在 prompt 层面做过滤,但模型仍然"知道"这些 Tool 存在,只是被要求不用而已,这本身就是一种上下文污染。

更麻烦的是,当 Tool 数量超过 20 个以后,function calling 的准确率会明显下降。模型要在几十个 schema 里挑一个正确的,这个选择过程本身就消耗大量注意力。我实测过一个 35 个 Tool 的 Agent,简单任务的调用准确率从 95% 掉到了 78%,错误主要集中在"选错 Tool"而不是"参数填错"。

这就是为什么我们需要 Skill。

3. Skill 的抽象层级:把"流程知识"封装成可加载单元

3.1 Skill 不是 Tool 的包装,而是能力的语义单元

很多人第一次接触 Skill 的时候,会下意识地把它理解成"一组 Tool 的集合"。这个理解不算错,但太浅了。Skill 的真正价值不在于它包含几个 Tool,而在于它封装了"什么时候用、怎么用、用完怎么判断成功"这一整套流程知识。

我用一个具体例子来说明。假设你要做一个"合同审查"能力。

如果做成 Tool,你会写一个review_contract(contract_text: string) -> issues: string[]。模型调用它,拿到问题列表,结束。但实际合同审查远不止于此:要先识别合同类型,再匹配对应的审查规则库,然后逐条比对,最后按风险等级排序输出。这些步骤如果都塞进一个 Tool 内部,那这个 Tool 就变成了一个黑盒,模型无法干预中间过程,也无法在规则不匹配时换一套规则。

如果做成 Skill,结构是这样的:Skill 有一个manifest描述它的适用场景("当用户提供合同文本并要求审查时激活"),有一个workflow定义内部步骤(识别类型 → 加载规则 → 逐条比对 → 排序输出),每个步骤可以调用不同的 Tool,也可以调用子 Skill。模型看到的是一个语义单元"合同审查",而不是五个孤立的函数。

关键区别:Tool 暴露的是"我能做什么",Skill 暴露的是"我适合在什么情况下做什么,以及我内部怎么做的"。

3.2 Skill 的三层结构:元数据、工作流、资源

一个设计良好的 Skill,通常包含三层内容。

第一层是元数据(manifest)。这部分是给模型看的,用来做激活决策。包括:Skill 名称、一句话描述、适用场景关键词、输入输出契约、依赖的其他 Skill 或 Tool。元数据的设计直接决定了 Skill 能不能被正确激活。我见过最常见的错误是描述写得太泛,比如"处理文档相关任务",结果模型在任何涉及文档的场景都会激活它,包括那些本该用其他 Skill 的场景。

第二层是工作流(workflow)。这部分是给执行引擎看的,定义了 Skill 内部的步骤编排。可以是有向无环图,也可以是带条件分支的状态机。工作流的价值在于把"流程知识"从模型脑子里搬到了代码里,模型只需要决定"要不要用这个 Skill",不需要决定"这个 Skill 内部怎么跑"。

第三层是资源(resources)。包括提示词模板、规则库、示例库、校验脚本等。这部分是 Skill 的"知识沉淀",也是 Skill 能够自演进的基础——因为资源是可以被更新、被替换、被版本化的。

层级面向对象作用可变性
元数据模型激活决策低频变更
工作流执行引擎步骤编排中频变更
资源运行时知识供给高频变更

3.3 为什么 Skill 天然支持"动态加载"

Tool 的静态注册是因为它的契约简单,启动时全量加载成本可接受。但 Skill 不一样,Skill 的数量可能上百,每个 Skill 内部还有工作流和资源,全量加载既浪费内存又污染上下文。

更重要的是,Skill 的激活是上下文相关的。用户问"帮我写个周报",你不需要加载"合同审查"Skill;用户问"这段代码有什么问题",你不需要加载"PPT 生成"Skill。这种按需加载的能力,就是动态加载机制要解决的问题。

动态加载的核心思路是:启动时只加载 Skill 的元数据(轻量),当模型决定激活某个 Skill 时,再加载它的工作流和资源(重量)。这样既保证了模型能看到所有可用能力的概览,又避免了上下文被无关细节撑爆。

我实测过一个对比:50 个 Skill 全量加载,system prompt 大约 18000 token,模型激活准确率 82%;只加载元数据,system prompt 约 3000 token,激活准确率反而升到 91%。原因很简单,上下文越干净,模型的注意力越集中。

4. 动态加载机制:从"启动时绑定"到"运行时装配"

4.1 加载时机的三种策略

动态加载不是单一策略,而是一组策略的组合。根据我的实践经验,可以分成三种时机。

第一种是启动时预加载元数据。所有 Skill 的 manifest 在启动时全部读入,构建成一个"能力索引"。这个索引很小,通常每个 Skill 只占 50-100 token。模型在每一轮对话开始时都能看到这个索引,用来判断"当前任务可能涉及哪些 Skill"。

第二种是激活时加载工作流。当模型决定使用某个 Skill,执行引擎才去读取这个 Skill 的 workflow 定义。这一步通常涉及文件 IO 或数据库查询,所以需要做缓存。我的做法是用 LRU 缓存,保留最近使用的 20 个 Skill 的完整定义,命中率能到 85% 以上。

第三种是执行时加载资源。Skill 内部的提示词模板、规则库这些资源,往往体积较大,而且不是每次执行都需要全部加载。比如合同审查 Skill 有 200 条规则,但针对"劳动合同"只需要加载其中 30 条。这种细粒度的按需加载,能显著降低单次执行的延迟。

加载时机加载内容典型体积缓存策略
启动时元数据索引50-100 token/Skill常驻内存
激活时工作流定义500-2000 token/SkillLRU 缓存
执行时资源文件不定,可能很大按需加载+局部缓存

4.2 加载器的实现要点:别让 IO 拖垮响应

动态加载听起来美好,但实现不好会引入严重的延迟问题。我踩过的坑是:每次 Skill 激活都去读文件系统,结果一个涉及三个 Skill 的任务,光加载就花了 800ms。

后来我做了三件事来优化。第一,元数据索引在启动时构建成内存对象,激活决策阶段零 IO。第二,工作流定义做预编译,把 YAML 或 JSON 定义编译成可执行的步骤对象,缓存起来,避免每次解析。第三,资源文件做懒加载加引用计数,同一个资源被多个 Skill 引用时只加载一次,用完不立即释放,等引用归零再回收。

还有一个容易被忽略的点:加载失败的处理。Skill 文件可能被误删、格式可能写错、依赖的 Tool 可能不存在。如果加载失败直接抛异常,整个 Agent 就挂了。正确的做法是降级——加载失败的 Skill 从索引里标记为"不可用",模型激活它时返回一个明确的错误提示,让模型有机会换一个 Skill 或者用其他方式完成任务。

class SkillLoader: def __init__(self, skill_dir): self.skill_dir = skill_dir self.metadata_index = {} self.workflow_cache = LRUCache(capacity=20) self.resource_cache = {} def load_metadata(self): # 启动时调用,只读 manifest for path in glob(f"{self.skill_dir}/*/manifest.yaml"): try: meta = yaml.safe_load(open(path)) self.metadata_index[meta["name"]] = meta except Exception as e: # 单个 Skill 失败不影响整体 log.warning(f"Skill metadata load failed: {path}, {e}") def load_workflow(self, skill_name): if skill_name in self.workflow_cache: return self.workflow_cache[skill_name] meta = self.metadata_index.get(skill_name) if not meta: raise SkillNotFound(skill_name) workflow = self._compile_workflow(meta["workflow_path"]) self.workflow_cache[skill_name] = workflow return workflow

4.3 上下文注入:加载之后怎么让模型"知道"

加载完 Skill 之后,还有一个关键问题:怎么把 Skill 的信息注入到模型的上下文里,让它知道现在可以用这个 Skill。

这里有个反直觉的经验:不要把 Skill 的完整工作流塞进上下文。模型不需要知道 Skill 内部有几步、每步调用什么 Tool,它只需要知道"这个 Skill 能解决什么问题、需要什么输入、会产出什么输出"。工作流的细节应该由执行引擎处理,模型只负责激活和提供输入。

我的做法是分两段注入。第一段是能力索引,常驻 system prompt,列出所有可用 Skill 的名称和一句话描述。第二段是激活确认,当模型表达出使用某个 Skill 的意图时,把该 Skill 的详细输入输出契约注入到当前轮次的上下文里,执行完就移除。

这样做的好处是上下文始终干净。我见过有人把 Skill 的完整定义常驻在 prompt 里,结果 20 个 Skill 就占了 15000 token,模型还没开始干活上下文就满了。

5. 自演进:Skill 库怎么做到"越用越聪明"

5.1 演进的两个方向:横向扩展与纵向深化

Skill 库的自演进,说白了就是两件事:加新 Skill和改老 Skill。

横向扩展是加新 Skill。当 Agent 遇到一个现有 Skill 都搞不定的任务时,它应该有能力"提议"创建一个新 Skill。这个提议可以是自动的(Agent 自己生成 Skill 草稿),也可以是半自动的(Agent 记录下失败案例,人工审核后创建)。我倾向于半自动,因为全自动生成的 Skill 质量参差不齐,容易污染整个库。

纵向深化是改老 Skill。一个 Skill 用久了,会发现某些场景下效果不好。比如合同审查 Skill 对"电子合同"的识别率低,那就需要更新它的规则库或者调整工作流。这种演进是渐进的,需要有一套机制来收集反馈、定位问题、验证改进。

演进方向触发条件执行方式风险
横向扩展现有 Skill 无法覆盖的任务生成草稿+人工审核低质量 Skill 污染库
纵向深化Skill 执行成功率下降更新资源/调整工作流改动引入回归

5.2 反馈闭环:从执行日志到 Skill 改进

自演进的前提是有反馈。没有反馈,你根本不知道哪个 Skill 该改、该怎么改。

我的做法是在 Skill 执行层埋三个采集点。第一,激活准确率:模型激活了这个 Skill,但实际任务并不适合它,这是误激活;模型该激活却没激活,这是漏激活。第二,执行成功率:Skill 工作流跑完了,但输出被用户否定或者被下游校验拒绝,这是执行失败。第三,资源命中率:Skill 加载了 200 条规则,实际只用到 30 条,说明资源组织有问题,可以拆分。

这三个指标采集起来不难,难的是归因。一个任务失败,可能是 Skill 选错了,可能是工作流编排有问题,可能是资源不全,也可能是模型本身能力不够。我的经验是先用规则做粗筛,比如"激活后 3 秒内就报错"大概率是加载问题,"执行到第 N 步失败"大概率是工作流问题,"输出被拒但无报错"大概率是资源或提示词问题。粗筛之后再人工看 case,效率会高很多。

5.3 版本化:让演进可回滚

Skill 一旦开始演进,就必须有版本管理。我见过团队改 Skill 改出线上事故的——更新了规则库,结果老版本的合同审查全挂了,因为新规则和老数据格式不兼容。

版本化的最小要求是:每个 Skill 的元数据、工作流、资源都带版本号,执行时记录用了哪个版本,回滚时能一键切回旧版本。更进一步的做法是支持灰度,新版本先对 10% 的流量生效,观察指标没问题再全量。

这里有个实操细节:资源文件的版本要和 Skill 版本绑定。不能出现 Skill 是 v2 但加载了 v1 的规则库这种情况。我的做法是把资源路径写成skills/contract_review/v2/rules/,版本号在路径里,加载器按 Skill 版本去对应目录找资源,物理隔离,不会串。

6. 一个可复现的最小实现:把上面这些落地

6.1 目录结构设计

说了这么多原理,不如直接给一套能跑的目录结构。下面这个是我在实际项目里用过的,经过简化但核心机制都在。

agent/ ├── skills/ │ ├── contract_review/ │ │ ├── manifest.yaml │ │ ├── workflow.yaml │ │ └── resources/ │ │ ├── rules_ labor.json │ │ └── rules_ purchase.json │ ├── weekly_report/ │ │ ├── manifest.yaml │ │ ├── workflow.yaml │ │ └── resources/ │ │ └── templates/ │ └── code_review/ │ ├── manifest.yaml │ ├── workflow.yaml │ └── resources/ ├── tools/ │ ├── search.py │ ├── fetch.py │ └── format.py └── loader.py

manifest.yaml是元数据,workflow.yaml是工作流,resources/是资源。Tool 单独放在tools/下,Skill 通过名称引用 Tool。

6.2 manifest 与 workflow 的写法

manifest 要写得"对模型友好",关键是描述要具体,别写空话。

name: contract_review version: 2.1.0 description: 审查合同文本,识别风险条款并按等级输出 activation_hint: 当用户提供合同文本并要求审查、检查风险、找问题时激活 input: contract_text: string contract_type: enum[labor, purchase, service, unknown] output: issues: list[{clause, risk_level, suggestion}] dependencies: tools: [search, format] skills: []

workflow 定义内部步骤,用简单的步骤列表加条件分支就够了,不需要上复杂的 DSL。

steps: - id: detect_type tool: classify_contract input: {text: "{{contract_text}}"} output: contract_type - id: load_rules action: load_resource params: {path: "rules_{{contract_type}}.json"} output: rules - id: review tool: rule_match input: {text: "{{contract_text}}", rules: "{{rules}}"} output: raw_issues - id: rank tool: format input: {items: "{{raw_issues}}", sort_by: risk_level} output: issues

6.3 加载器与执行器的核心逻辑

加载器负责按需加载,执行器负责跑工作流。核心代码不复杂,关键是异常处理和缓存。

class SkillExecutor: def __init__(self, loader, tool_registry): self.loader = loader self.tools = tool_registry def execute(self, skill_name, inputs): workflow = self.loader.load_workflow(skill_name) context = dict(inputs) for step in workflow["steps"]: try: if "tool" in step: result = self.tools[step["tool"]].run( self._resolve(step["input"], context) ) elif step["action"] == "load_resource": result = self.loader.load_resource( skill_name, self._resolve(step["params"], context) ) context[step["output"]] = result except Exception as e: # 步骤失败,记录并决定是否中断 log.error(f"Step {step['id']} failed: {e}") if step.get("critical", True): raise SkillExecutionError(skill_name, step["id"], e) return context.get(workflow.get("output_key", "output"))

这段代码里有个细节值得说:critical字段。不是所有步骤失败都要中断整个 Skill。比如"加载规则"失败了,可以降级用默认规则;但"规则匹配"失败了,就必须中断。这个字段让 Skill 有了容错能力。

6.4 实测数据与调优建议

我用这套结构跑过一个包含 42 个 Skill 的 Agent,简单说一下实测数据,供你参考。

启动加载元数据耗时约 120ms,内存占用约 2MB。单次 Skill 激活平均耗时 15ms(命中缓存)到 80ms(冷加载)。工作流执行耗时取决于内部 Tool 调用,纯编排开销约 5-10ms。上下文占用方面,42 个 Skill 的元数据索引约 4200 token,相比全量加载的 21000 token 降低了 80%。

调优建议有三条。第一,元数据索引要精简,description 控制在一句话,activation_hint 控制在 30 字以内,别写小作文。第二,工作流缓存容量要匹配并发量,并发高就调大 LRU 容量,否则频繁冷加载。第三,资源文件要拆分,别搞一个 5000 行的规则库,按场景拆成多个小文件,按需加载。

7. 踩过的坑与几条硬经验

7.1 别把 Skill 做成"超级 Tool"

我早期犯的最大错误,是把 Skill 当成"功能更全的 Tool"来写。一个 Skill 内部塞了十几个步骤,什么场景都想覆盖,结果就是激活准确率极低,模型根本不知道什么时候该用它。

后来我学乖了:一个 Skill 只解决一类问题,场景描述要窄。"合同审查"就只管合同审查,别想着顺便把"合同生成"也做了。窄场景的 Skill 激活准确率高,维护起来也简单。需要组合能力的时候,用 Skill 编排 Skill,而不是把能力堆进一个 Skill。

7.2 动态加载的"冷启动"要提前预热

动态加载的代价是冷启动延迟。用户第一次触发某个 Skill 时,要等加载,体验会卡。我的做法是在 Agent 空闲时做预加载——根据最近的对话历史,预测接下来可能用到的 Skill,提前把工作流加载进缓存。

预测逻辑很简单:统计最近 10 轮对话涉及的 Skill,把它们的"关联 Skill"(在 workflow 里被引用的)也预加载。命中率大概能到 60%,冷启动延迟从 80ms 降到 15ms 左右。

7.3 自演进要有"刹车"

自演进听起来很美好,但如果没有约束,Skill 库会迅速膨胀到不可维护。我见过一个团队让 Agent 自动生成 Skill,一个月生成了 300 多个,其中 80% 是重复的或者质量极差的。

必须有的约束包括:新 Skill 必须通过人工审核才能进正式库、每个 Skill 必须有明确的负责人、连续 N 次执行成功率低于阈值就自动下线。演进是为了让库更好用,不是为了让它更大。

7.4 版本兼容是长期维护的关键

Skill 一旦上线,就会有下游依赖。改 Skill 的时候,必须考虑兼容性。我的经验是:元数据的变更要向后兼容(加字段可以,删字段不行),工作流的变更要版本化(v2 和 v1 并存,逐步迁移),资源的变更要可回滚(保留最近三个版本)。

还有一点:Skill 的输入输出契约一旦发布,就当成 API 来对待。别今天issues: string[],明天改成issues: object[],下游全挂。要改就发新版本,老版本继续跑一段时间。

8. 写在最后:Skill 是 Agent 的"肌肉记忆"

回到最开始那个问题:Skill 为什么不同于 Tool?

我的理解是,Tool 是 Agent 的"骨骼",定义了它能做什么动作;Skill 是 Agent 的"肌肉记忆",定义了它在什么情境下、按什么顺序、用什么力度去做这些动作。骨骼是固定的,肌肉记忆是可以训练、可以更新、可以遗忘的。

动态加载让 Agent 不必时刻背着所有肌肉记忆,需要哪块调哪块。自演进让肌肉记忆能随着使用越来越熟练。这两件事加起来,才让 Agent 从一个"会调函数的程序"变成一个"会干活的助手"。

如果你正在设计 Agent 框架,我的建议是:先把 Tool 层做扎实,契约清晰、错误可控;再在 Tool 之上做 Skill 层,把流程知识从模型脑子里搬出来;最后做加载和演进机制,让 Skill 库活起来。顺序别反了,反了就会像我早期那样,Skill 写得四不像,Tool 也没用好,两头不讨好。

这套东西我还在持续打磨,尤其是自演进那块,怎么在"自动"和"可控"之间找平衡,目前还没有特别满意的答案。如果你有更好的实践,欢迎交流。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进