Agent Skills实战:让智能体工具调用稳定可复用

发布时间:2026/10/21 13:41:29
Agent Skills实战:让智能体工具调用稳定可复用
我最近把一个小助手从“能跑通Demo”升级成“同事愿意每天用”的内部工具期间绕了最多的弯路不在模型选型而在一个很容易被低估的环节怎么把能力稳定地暴露给模型。折腾一圈后真正让我把这件事捋清楚的就是 Agent Skills。简单说Agent Skills智能体技能就是一组结构化的、可复用的能力单元让智能体在运行时能自主发现、选择和调用。它解决的问题很朴素光靠提示词教模型做事复杂流程会迅速失控直接在代码里写死逻辑又失去了模型的灵活性。Skills 恰好站在中间——把“模型会什么”交给模型自己决定把“模型不会但代码能做”的能力封装成它能调用的接口。如果你正在搭智能客服、数据问询助手、多步骤自动化任务或者已经被工具调用 JSON 格式折腾到怀疑人生这篇就是给你写的。1. 先搞清楚 Agent Skills 到底解决了什么问题很多人以为智能体的上限由模型能力决定但我见过太多反例模型很强业务逻辑写得很辛苦最后卡在“模型根本不知道该在什么时候调用哪个函数”。这不是模型笨而是能力暴露的方式不对。Agent Skills 的核心价值就在于把“暴露能力”这件事从碰运气变成工程规范。1.1 从“提示词里教模型做事”到“给模型一套可调用工具箱”早期的做法是在提示词里把步骤一条条写死“如果你收到这样的请求先调用A接口再判断返回结果如果满足某条件就调用B接口”。听起来没问题实际用起来全是坑。提示词再长模型也会漏步骤上一句表述换个说法模型的决策路径就可能不一样。更别提你还要让程序解析模型输出的 JSON模型一旦在 JSON 前后多写一句解释的话解析直接崩掉。后来有了原生工具调用机制通常叫 Function Calling模型可以输出结构化的调用请求你的代码负责执行再把结果喂回上下文。这一下解决了“格式漂移”的问题但新的问题很快冒出来工具越来越多每个工具的命名、描述、参数规范全靠人肉维护没有统一约束工具之间的组合关系写在哪里也没有定论同一个业务能力在这个项目里叫get_data在另一个项目里叫fetch_dataset复制过来后发现描述风格完全不同模型识别效果忽上忽下。Agent Skills 就是在这层基础上做的一层工程化封装。它不改变底层调用机制而是把每个能力变成一个有名字、有描述、有参数约束、有执行体的“技能包”并且集中注册、统一管理。模型面对的不再是一堆零散函数而是一本结构清晰的操作手册。1.2 Agent Skills、工具调用与工作流三者的边界很多人会把这几个概念混在一起我用表格直接对比一下维度工具调用Function CallingAgent Skills工作流Workflow本质模型输出结构化调用请求的底层能力一组可复用能力单元的组织形式预先编排好的固定执行流程决策权模型主动发起调用模型主动发起调用程序按节点顺序执行复用性单个函数复用粒度低技能可跨项目、跨Agent复用流程级复用但改路径代价大组合性基本不关心组合支持技能编排、串行/并行组合关系写在流程里典型问题工具多了难以管理技能描述与参数如何设计无法应对未预见的输入所以它们不是替代关系。工具调用是地基Agent Skills 是把地基上的砖砌成墙Workflow 是直接照着图纸砌一整面墙。如果你想做一个完全固定流程的报表任务用 Workflow 更省事但如果你希望模型在面对五花八门的用户请求时能自己选择和组合能力Agent Skills 才是合适的中间层。1.3 谁最需要 Agent Skills典型场景判断我做过的几个项目里有三个方面特别适合引入 Agent Skills第一能力清单会经常变动。比如数据平台要接新的数据源、电商助手要支持新的促销玩法如果能力是散落的函数每次加一个都要改一大片调用逻辑如果是注册式技能新增一个技能类并注册即可主循环完全不用动。第二任务流程无法预判。用户问“这个月华南区的退货率有异常吗”模型可能需要先查订单、再按区域过滤、再算退货率、再对比上个月四步技能调用组合取决于问题本身不是代码能提前写死的。第三多个产品线需要共享同一批能力。不同 Agent 都需要“读取表格预览”“计算字段分布”“生成数据摘要”这几个能力把它们做成标准技能包哪个产品要就注册哪几个比各自维护一套实现靠谱得多。2. 拆开原理一个技能从定义到被调用完整经历什么Agent Skills 之所以好用是因为它把“能力调用”这件事拆成了清晰可观测的几个阶段。理解这条链路你才能知道模型哪一步跑偏了以及应该怎么修正。2.1 技能的铁三角名字、描述、参数 Schema一个技能最核心的三个字段是name、description、parameters。我习惯称之为“技能铁三角”因为这三个字段缺一个技能就没法正确工作。name是技能的机器标识必须全局唯一。模型在生成调用请求时会使用这个名字所以它应该像一个稳定的 API 函数名用snake_case或小驼峰不要有空格和奇怪的符号。比如read_csv_sample、list_files。description是模型决策的主要依据。它必须告诉模型两个信息这个技能在什么场景下用、在什么场景下不要用。很多人的 DESCRIPTION 写得特别像废话比如“这个技能可以读取文件”模型看了毫无感觉。好的描述应该是“读取 CSV 文件的前 N 行并返回字段名与样本记录。当用户需要快速预览表格、确认字段含义时使用如果用户只想要最终统计指标不要使用这个技能改用 summarize_data。” 这种描述不仅告诉模型“是什么”还教会它“什么时候别选我”。parameters是 JSON Schema 格式的输入约束。它定义了技能接受哪些参数、参数类型是什么、哪些是必填的、有哪些取值范围。这一步做得好不好直接决定模型传参准不准。2.2 调用循环模型如何感知、决策、执行并消化结果一次完整的技能调用拆开看有六步运行时把技能清单名字、描述、参数 Schema注入给模型模型读取用户请求判断当前对话是否需要调用某个技能模型输出一个结构化的调用请求包含技能名和参数运行时解析请求、校验参数、执行技能的execute方法把执行结果通常是一个结构化对象追加到对话上下文作为一次tool角色的消息模型看到执行结果后继续生成回复或者继续发起下一次调用。这个过程可以循环多次。一个复杂任务往往对应多次技能调用而模型会在第6步根据前一次的结果决定下一步做什么。这其实就是 Agent 的“自主性”来源。但这里有一个容易被忽视的细节模型能感知到的技能只有你注入给它的那一批。如果你的技能清单本身没有传给模型无论技能实现得多好都没用。系统提示词里放技能清单是标准做法但清单太长时模型会“注意力稀释”后面的技能描述基本被忽略。所以技能数量很多时要做路由这点我后面会展开。2.3 上下文控制与技能结果回流技能执行完毕后返回给模型的结果要怎么设计我见过最糟糕的写法是让技能返回一大段自然语言“我已经成功读取了文件文件路径是…文件总共有 1288 行其中包含的字段有…”这种结果看着友好但模型还要从中再“理解”一遍容易理解和转述出偏差。正确的做法是让技能返回一个紧凑、结构化的对象通常是 JSON。模型的理性能力本来就擅长处理符号化结构你给它{total_rows: 1288, fields: [date, sales, region]}它下一轮的推理会非常稳定。关键原则是技能返回给模型的是“现场报告”不是“最终答案”。真正的最终答复让模型看着报告自己组织语言就好。同时要注意上下文的 Token 消耗。技能如果返回一个 5000 行的完整数据集下一轮模型输入会爆炸还会导致响应变慢、成本变高。所以技能在设计时就要考虑输出裁剪返回前几十条样本、返回聚合摘要、超长内容只返回统计信息和缩略预览。3. 实战从零搭建一个可用的 Agent Skills 框架理论讲再多不动手等于白看。我直接用 Python 搭一个小而完整的框架不依赖任何重型框架主线清晰方便你自己扩展。3.1 先定框架技能基类、注册器与运行循环首先要定义一个技能基类和一个标准结果对象。所有技能都继承基类实现自己的execute方法结果对象负责把执行结果序列化成模型能读的 JSON 消息。# skill_base.py import json from typing import Any, Dict, List, Optional class SkillResult: def __init__(self, ok: bool, data: Any None, error: str ): self.ok ok self.data data self.error error def to_message(self) - str: if not self.ok: return json.dumps({ok: False, error: self.error}, ensure_asciiFalse) return json.dumps({ok: True, data: self.data}, ensure_asciiFalse) class AgentSkill: name: str description: str parameters: dict {} def execute(self, **kwargs) - SkillResult: raise NotImplementedError然后是技能注册器。它负责维护技能清单、查询技能、生成模型需要的工具定义列表。注册器的好处是业务代码只跟注册器打交道新增或下线技能都不影响主循环。# registry.py from typing import Dict, Optional class SkillRegistry: def __init__(self): self._skills: Dict[str, AgentSkill] {} def register(self, skill: AgentSkill): if skill.name in self._skills: raise ValueError(fskill {skill.name} already registered) self._skills[skill.name] skill def unregister(self, name: str): self._skills.pop(name, None) def get(self, name: str) - Optional[AgentSkill]: return self._skills.get(name) def list_defs(self) - list: return [ { name: s.name, description: s.description, parameters: s.parameters, } for s in self._skills.values() ]3.2 写三个能直接跑起来的技能我以一个“数据快速体检助手”为例写三个技能列出目录文件、预览 CSV 前几行、生成字段摘要。这三个技能可以覆盖“帮我看一眼这批数据”这类高频需求。第一个技能是列出目录下的文件# skills/file_skills.py from pathlib import Path from skill_base import AgentSkill, SkillResult class ListFilesSkill(AgentSkill): name list_files description 列出指定目录下的文件名与修改时间。当用户想快速查看一个目录里有哪些文件时使用。 parameters { type: object, properties: { directory: {type: string, description: 要列出的目录路径} }, required: [directory], } def execute(self, directory: str, **kwargs) - SkillResult: try: p Path(directory) if not p.exists(): return SkillResult(False, errorf目录不存在: {directory}) items [ {name: f.name, mtime: f.stat().st_mtime} for f in p.iterdir() ][:50] return SkillResult(True, dataitems) except Exception as e: return SkillResult(False, errorstr(e))第二个技能读取 CSV 前 N 行# skills/file_skills.py import csv class ReadCsvSampleSkill(AgentSkill): name read_csv_sample description 读取CSV文件的前N行返回字段名和样本记录。当用户需要快速预览表格字段时使用。 parameters { type: object, properties: { path: {type: string, description: CSV文件路径}, rows: {type: integer, description: 读取行数默认5, default: 5}, }, required: [path], } def execute(self, path: str, rows: int 5, **kwargs) - SkillResult: try: with open(path, newline, encodingutf-8) as f: reader csv.DictReader(f) sample [] for i, row in enumerate(reader): if i rows: break sample.append(row) if not sample: return SkillResult(False, errorf文件为空或没有表头: {path}) return SkillResult(True, data{fields: list(sample[0].keys()), sample: sample}) except Exception as e: return SkillResult(False, errorstr(e))第三个技能做字段摘要返回每列的非空数量、唯一值数量等数据量大时比读样本高效得多# skills/summary_skills.py import csv from collections import Counter from skill_base import AgentSkill, SkillResult class SummarizeCsvSkill(AgentSkill): name summarize_data description 对CSV数据每列做快速统计返回行数、非空数量、唯一值数量。当用户想了解数据整体质量时使用。 parameters { type: object, properties: { path: {type: string, description: CSV文件路径} }, required: [path], } def execute(self, path: str, **kwargs) - SkillResult: try: with open(path, newline, encodingutf-8) as f: reader csv.DictReader(f) fields reader.fieldnames or [] stats {field: {non_null: 0, unique: 0} for field in fields} total 0 for row in reader: total 1 for field in fields: if field in row and row[field] not in (None, ): stats[field][non_null] 1 result {total_rows: total, columns: stats} return SkillResult(True, dataresult) except Exception as e: return SkillResult(False, errorstr(e))这里有个实战细节技能返回的字段名、错误信息尽量稳定。比如所有技能出错时都返回{ok: false, error: ...}模型和下游代码都不需要判断五花八门的错误格式。3.3 让 Agent 动起来主循环与调用日志技能定义好了主循环负责把模型、注册器、消息历史串起来。这里为了演示我用一个伪接口call_model表示支持原生工具调用的模型 API你只需要替换成你实际使用的模型 SDK 即可。# agent.py from typing import Callable, List, Dict from registry import SkillRegistry from skill_base import SkillResult class AgentSession: def __init__(self, registry: SkillRegistry, max_rounds: int 5): self.registry registry self.messages: List[Dict] [] self.max_rounds max_rounds def run(self, user_input: str, call_model: Callable): self.messages.append({role: user, content: user_input}) tools self.registry.list_defs() for _ in range(self.max_rounds): resp call_model(self.messages, tools) self.messages.append(resp) tool_calls getattr(resp, tool_calls, None) if not tool_calls: return resp.content for tc in tool_calls: skill self.registry.get(tc[name]) if skill is None: result SkillResult(False, errorf未知技能: {tc[name]}) else: try: result skill.execute(**tc[arguments]) except Exception as e: result SkillResult(False, errorstr(e)) self.messages.append({ role: tool, tool_call_id: tc[id], content: result.to_message(), }) return 达到最大轮数任务未完成运行起来日志里应该能看到类似这样的调用轨迹User: 帮我看看这个目录下有什么文件 Model: tool_call - list_files(directory./data) Tool Result: {ok: true, data: [{name: sales.csv, mtime: 1760000000}]} Model: tool_call - read_csv_sample(path./data/sales.csv, rows5) Tool Result: {ok: true, data: {fields: [...], sample: [...]}} Model: 目录下只有 sales.csv字段包括...这就是一个最小可用的 Agent Skills 系统。你会发现整个主循环只依赖registry.get来找技能所以后面加新技能根本不用改主循环代码只需要往需要调用的目录里丢一个新的技能类并调用registry.register即可。4. 调优经验技能调不通八成问题出在这几个地方框架跑通只是第一步。真正折磨人的是上线后的表现波动。我总结下来90% 的问题集中在四个位置描述、参数 Schema、返回格式、上下文控制。4.1 技能描述写得太“文艺”模型该调不调模型选技能基本就是在读 description 做语义匹配。如果你把描述写成“本技能用于执行读取文件的操作”模型碰到“帮我看看这台机器的日志”这种请求时可能觉得自己应该用另一个技能或者干脆不调用。描述必须具体、带场景、带排除项。我用一组对照说明坏描述好描述读取CSV文件并分析数据读取CSV文件的前N行并返回字段名与样本记录需要快速预览字段或确认表结构时使用查询订单数据按时间和地区筛选订单返回订单数量与销售额合计用户询问订单量、销售趋势时使用通用文本处理对文本执行分词和词频统计需要做关键词提取或文本概要时使用好描述的共同点是包含“什么情况下用”“什么情况下别用”“能返回什么”。模型不是人你不能指望它靠常识推断技能意图把决策信息写进描述里比什么都重要。4.2 参数 Schema 的约束缺失模型开始自由发挥参数 Schema 是很容易偷懒的地方。有人图省事只写{type: object, properties: {...}}忘了标required结果模型偶尔漏传必填参数程序抛异常模型还一脸无辜。还有人不限制枚举值导致技能内部要写一堆兼容逻辑。举个例子。假设技能接收一个date_range参数预期值只能是7d、30d、90d三个枚举值。如果 Schema 只写{type: string}模型可能会传last seven days你的代码要么解析失败要么就得写模糊匹配。正确约束是{ type: object, properties: { date_range: { type: string, enum: [7d, 30d, 90d] } }, required: [date_range] }模型看到枚举值就只会在三个值里面选。不要低估枚举约束的价值它是成本最低的防呆设计。4.3 返回结果不规范下游程序连环报错技能执行结果既要给模型看也可能被程序进一步处理。如果技能返回的是排版漂亮的自然语言模型倒是能读懂但后续如果要“根据结果再调用其他技能”就多了一层理解误差。我自己的统一规范是所有技能返回SkillResult其中data必须是对象或数组不要返回散文。错误时必须有error字段。模型拿到{ok: true, data: {row_count: 1288}}下一步自然知道基于数字 1288 继续推理如果拿到“我一共找到了1288行数据哦”它还要先做一次文本转数字的隐性任务出错概率自然更高。4.4 上下文被技能输出撑爆截断、摘要与视图隔离技能输出过大的问题我在做数据类 Agent 时几乎天天遇到。读一个 200MB 的 CSV如果技能愣是把前 100 行原样返回模型下一次调用时输入长度直接超标。我的处理方式分三层限制返回条数。样本返回默认 5 到 10 条不让模型看全量数据。返回聚合摘要。能算统计量就返回统计量比如行数、均值、唯一值数。运行视图隔离。需要全量数据时技能先把结果写入临时文件或缓存返回的只是一条“数据已存为 /tmp/xxx.csv共 50000 行”的提示模型需要后续分析时再调用另一个技能去读缓存。这背后的思路是模型需要的不是“数据本身”而是“关于数据的关键信息”。数据操作交给代码代码懂全量模型只负责决策和表达。4.5 技能调试手段日志、统计与小样本回归技能调不通的时候不要靠直觉猜。我的调试三板斧第一打印完整调用日志。每一轮响应的tool_calls、每个参数、每次SkillResult都要能看到。模型是否调用了错误的技能、参数是否传错、执行是否抛异常全程可视化之后问题基本就暴露了。第二记录调用统计。给每个技能加一个计数器上线后看数据哪些技能被反复调用但从未完成任务哪些技能几乎没被调用过。前者可能执行逻辑有问题后者可能描述不友好或场景重叠。第三建一个小样本回归集。拿 5 到 10 条典型用户问题每次改动技能描述或参数 Schema 后跑一遍对比调用正确率。这个成本很低但能拦住大多数“改了 A 技能结果 B 技能不灵了”的回归问题。5. 进阶套路多技能协作、并行调用与技能路由单技能跑通之后真正的 Agent 价值体现在多技能组合上。这一节讲我常用的几个进阶套路。5.1 复杂任务拆解规划、执行、汇总的技能序列一个常见的大任务是“帮我看一下这个月的数据情况”。模型不会直接调用一个叫“分析数据”的大技能而是会把它拆成多个技能调用序列list_files找文件read_csv_sample看字段summarize_data算整体统计然后模型基于这些结果写总结。这件事不需要你在代码里写死路径模型会自己规划。但你要保证每个技能的描述足够清晰让模型能看出“下一个该调什么”。就像给一个新人配工具你不告诉他每一步做什么但你把每个工具的使用说明写得清清楚楚他自然会拼出一整套流程。如果任务太复杂、模型规划经常出错我会在系统提示词里加一句“请先列出你现在掌握哪些技能然后给出你的调用计划”相当于强制模型先把规划外显。这招在调试期非常有用能看出模型为什么绕路。5.2 并行调用能并行的技能就不要串行有些场景下模型会同时调用多个没有依赖关系的技能。比如用户问“对比一下华北区和华东区的订单情况”模型完全可以同时调用query_orders(region华北)和query_orders(region华东)而不是先查华北再等结果查华东。现在的模型 API 普遍支持返回多个tool_calls你的主循环需要过滤处理。上面的示例代码里for tc in tool_calls已经是顺序执行如果希望真正并行可以用线程池或异步任务池同时执行多个无依赖技能。但要小心技能如果会修改共享状态并行就可能引入竞态问题。我的原则是只读型技能放心并行写库型技能串行执行。5.3 技能多了怎么办标签体系与检索路由当你注册了 15 个以上技能时把它们全部塞进系统提示词会让模型“看不过来”。我的方案是给技能打标签按领域分组。假设你有这些技能list_files、read_csv_sample、query_database、send_email、search_web。可以划分出“文件操作”“数据查询”“通信协作”三个类别。每次请求进来先用一个规则或向量检索模型判断用户意图属于哪个类别再把对应类别的技能清单注入给模型。这样模型每次只需要在一小撮技能里做选择准确率明显上升。更进阶的做法是用向量检索。把每个技能的description和用户名下所有技能描述做 embedding取 Top-K 个相似技能注入上下文。这种“路由 动态技能注入”的组合足够支撑几十个甚至上百个技能的规模。5.4 技能版本管理与灰度发布技能不是写完就永不变更的。业务规则在变数据源在变技能的执行逻辑也要跟着变。我在注册表里会给技能加一个version字段比如summarize_data的 v1 和 v2。版本管理的意义在于灰度。v2 上线前先只让内部测试会话使用线上流量继续走 v1等观察几天调用成功率和用户反馈没问题再逐步切流量。万一 v2 有 bug回滚也只是改一下注册配置把summarize_data指向 v1 的实现即可不用重新发布整个 Agent。这个阶段的核心体会是技能系统运行一段时间后难点不再是写技能而是“如何安全地改技能”。没有版本意识和开关机制你迟早会因为一次技能改动把整个 Agent 拖下水。6. 我实测下来最值得记住的三件事第一技能贵精不贵多。我一度为了“能力完整性”注册了 20 多个技能结果模型选择困难关键技能反而不被调用。后来砍到 5 个核心技能每个都精修描述整体表现立刻提升。一个描述精准的技能抵得上十个描述含糊的技能。第二所有技能输出必须是机器可读的 JSON不要散文。这不仅是为了给模型看更是为了以后的系统集成。今天模型能读懂散文格式的返回不代表明天你的下游代码也能解析。统一结构化输出是所有技能设计的底线。第三调技能描述是性价比最高的优化。模型参数、提示词、少样本示例固然重要但很多时候技能调不灵就是因为 DESCRIPTION 写得太模糊。花一个小时打磨技能描述比换一个更大的模型省钱得多也有效得多。这是我踩过最深的一次坑换来的经验希望能帮你少走一段路。