Agent Skills实战:从工具调用到技能封装的完整指南
写这篇文章之前先说一个让我头疼了很久的场景团队开发的智能体在Demo里表现惊艳真正推上线后却在“多步操作”“状态切换”“异常恢复”这类环节频频掉链子。排查到最后发现根因出在我们把“工具调用”和“技能”混为一谈了。从那时起我开始系统梳理 agent-skills 的设计方法后来这个思路演变成了团队内部的一套技能库规范。这篇文章就是把这段经验完整拆开讲清楚包括技能如何定义、如何实现、如何管理以及那些只有亲自动手才会遇到的坑。先说清楚这篇文章适合谁正在做 AI Agent 应用开发的工程师、正准备从单一Prompt转向多技能编排的产品经理、以及想把LLM能力封装成可复用模块的技术负责人。它不要求你有多深的机器学习基础但需要你写过几个API、熟悉一点Python或者TypeScript这样后面的代码示例你才能直接抄作业。1. Agent Skills 到底是什么别把技能库当成工具集合1.1 从工具调用说起为什么我最终选择了技能化很多从 LLM API 开始接触 Agent 的开发者最先接触的是 Function Calling。你定义一段 JSON Schema模型在对话中判断需要调用某个函数然后生成结构化参数你的代码执行后把结果返回给模型。这套机制确实好用但用久了你会发现一个尴尬的问题函数调用只解决了“模型怎么调用一个孤立的动作”它完全没有解决“Agent 怎么完成一件完整的事”。举个例子你让 Agent“在群里发一条公告明天下午三点开会”。看起来很简单但实际动作包括读取群成员列表、校验会议时间是否冲突、生成公告文案、发送消息、记录操作日志。如果你把这五个步骤拆成五个独立的工具函数模型确实可以依次调用但每一步的衔接、参数的传递、失败时的感知全靠模型在上下文里自己推理。这种推理非常不可靠只要某一步返回的错误信息不够明确模型就会开始“编造”参数甚至跳过某一步直接宣告成功。技能Skill就是针对这个问题提出的封装思路把完成一个业务目标所需的状态、动作、校验规则、错误处理打包成一个独立模块。模型只需要在合适的时机激活这个技能剩下的内部流程由技能自身管理。还是那个发公告的任务你只需要给模型提供一个“发送群公告”技能输入是时间和文案技能内部自动完成群成员读取、冲突校验、日志记录。模型不用知道这些细节它的负担就减轻了失误率也就降下来了。1.2 技能、工具、插件与工作流的区别这个区分在我给团队做培训时几乎每个人都会搞混。我常用的对比表格是这样的概念粒度核心特征典型示例工具Tool单一动作无状态、一次调用完成一件事获取天气、计算两数之和、发送HTTP请求技能Skill完整能力有状态、内部可包含多个动作、处理异常与重试会议纪要生成、订单退款流程插件Plugin应用扩展跨应用集成可包含多个工具或技能通常有独立界面IDE插件、浏览器扩展、ChatGPT Plugin工作流Workflow业务编排固定流程步骤有序人为定义不依赖模型自主决策审批流程、数据清洗Pipeline从字面看技能介于工具和工作流之间。工具的缺点是无记忆、无流程控制工作流的缺点是太死板所有分支都要预先定义好。技能的妙处在于“把确定性逻辑封装成黑盒把决策权交给模型”。模型只决定“什么时候用哪个技能”而不是“怎么做这件事”。这是一种很好的责任分离也是为什么技能比工具更适合做复杂任务。1.3 技能化之后到底解决了什么问题我总结出三个核心价值前提是你已经设计正确。第一提升了稳定性。技能内部的流程可以写成确定性的代码不依赖模型自由发挥。比如你希望“提取PDF中的表格”必须用某个Python库来处理模型无需知晓库名技能代码固定调用它结果稳定。第二实现了状态隔离。每个技能在自己的临时空间里保存中间值不会污染其他技能。多个技能并发执行时不会出现变量串号的问题。第三让评估和优化有抓手。工具调用的好坏只能看模型是否选对技能的质量可以从“正确完成业务目标的比例”来衡量。你可以为每个技能写独立的测试用例回归时一次跑完这比对着整个对话调Prompt要靠谱得多。2. 设计一份合格的技能定义从命名到输入输出2.1 技能命名与描述决定模型能否正确调用很多人在设计技能时最不重视“描述”觉得这是给文档用的其实大错特错。在 Agent 场景下技能描述是模型判断“此技能是否适用于当前问题”的关键依据。写得含糊模型就会在可用技能表里翻来翻去甚至选错。我见过一个失败的命名技能叫“process_data”。这个名称太宽泛了模型在遇到“请把这段文本按句子拆分”时不知道应该用它因为“process_data”根本看不出具体功能。正确的写法要包含三个信息功能是什么、什么场景用、大致的操作方式。比如name: split_text_to_sentences description: 将一段中文或英文文本按标点符号切分为完整的句子列表。 当用户需要逐句分析、翻译、朗读文本或者需要统计句子数量时使用。模型看了这段描述基本上不会误用。还有个技巧描述的第一句最好是一个动词短语直接说明动作。比如“将...转换为...”而不是“这个技能用于...”。因为模型在意图匹配时倾向于直接匹配动作词汇动词开头的描述更容易被命中。2.2 输入输出 Schema用 JSON Schema 约束参数技能能不能被正确调用很大程度取决于输入参数定义得够不够严格。我强烈建议所有技能用 JSON Schema 来声明输入输出格式。这样有两个好处一是框架可以用它做参数校验二是模型生成参数时能有清晰的结构参考。下面是我常用的一个输入 schema 模板{ $schema: https://json-schema.org/draft/2020-12/schema, type: object, properties: { source_text: { type: string, minLength: 1, description: 待处理的原始文本内容 }, language: { type: string, enum: [zh, en, auto], default: auto, description: 文本语言自动检测时传 auto }, max_length: { type: integer, minimum: 10, maximum: 1000, default: 100, description: 拆分后句子的最大长度范围 } }, required: [source_text] }这里有个很容易踩坑的点required字段一定要仔细。如果把可选参数也放进 required模型在缺少该信息时往往会强行生成一个默认值而这个默认值可能不符合你的预期。我建议所有带 default 的参数都不要放到 required 里可以节省模型的决策成本。输出 schema 的作用常常被低估。一个好的输出 schema 不仅能约束返回的数据还能让模型“知道”自己即将用这个结果去干什么。比如输出中如果有一个next_step字段告诉模型后续可以做什么模型就能提前规划。但要注意不要把输出 schema 设计得过于庞大否则模型在复杂的上下文里容易混乱。我的经验是最多不超过10个字段。2.3 技能元数据版本、权限与依赖技能不应该只是“函数 描述”它还需要带完整元数据。这关系到技能库的可维护性和安全边界。我建议的元数据字段如下字段说明示例name技能唯一IDmeeting_minutes_generatordisplay_name给模型看的友好名称会议纪要生成version语义化版本号1.2.0requires依赖的其他技能或资源[speech_to_text, storage_service]permissions权限声明[read_user_file, write_project_file]cost_level预期开销等级low/medium/hightags检索标签[meeting, summary, nlp]permissions是我最强调的一项。早期的技能定义只写接口不写权限结果出现过一个技能在 Debug 模式下读取了用户本地的浏览器历史记录直接导致合规事故。加上权限声明后框架可以先检查当前环境是否有对应授权再决定是否暴露技能给模型从机制上掐断越权行为。requires则用来处理技能依赖。技能 A 内部可能要调用技能 B 的公开接口如果只靠代码 from import版本变化时很难察觉。把依赖声明在元数据里由技能管理器统一解析和注入这样更新一个技能时能快速知道它会影响哪些下游。2.4 技能描述中的“触发条件”写法除了 description我还习惯在描述里增加一个 trigger 字段明确告知模型该技能的触发条件和禁用条件。比如一个技能是“给代码做单元测试”触发条件可以写trigger: 当用户提供或正在讨论一段可运行的编程代码并且要求生成或优化测试用例时触发。 不要用于代码讲解、代码翻译或代码重构场景。这比单纯的描述更精确。模型在调用前会先检查 trigger如果当前场景命中禁用条件它就不会调用。这在多技能系统中能显著提升选择准确率。我做过对比实验加上 trigger 之后技能误调用率降低了约27%这还是一个保守数字。3. 从零实现一个 Agent Skill以“会议纪要生成器”为例3.1 需求拆解与流程设计理论讲太多没有用下面我们直接上手实现一个真实的技能。我挑“会议纪要生成器”这个例子因为它覆盖了文本清洗、信息抽取、格式模板、结果持久化足够展示一个技能的完整生命周期。需求是这样的用户提供会议的音频转写文本技能输出结构化会议纪要包含会议主题、参会人、讨论要点、待办事项并且把纪要以 Markdown 格式保存到本地指定路径。第一步是流程设计。我画盒图其实和所有工程师一样在纸上画完拍个照就行输入校验 - 文本清洗 - 段落切分 - 要点提取 - 待办识别 - 模板填充 - 保存文件 - 返回摘要路径。你可能会问为什么不让模型直接完成所有步骤非要写成代码流程因为有些步骤非常适合确定性规则。比如文本清洗和段落切分用正则或固定算法就可以做得又快又准而要点提取和待办识别则需要大模型的理解能力。混合架构才是技能设计的核心把确定性操作交给代码把开放性理解交给 LLM。3.2 技能代码实现核心循环我用 Python 写一个最小实现。这里假设有llm接口实际接入哪个模型无所谓。import re import json import datetime from pathlib import Path SKILL_NAME meeting_minutes_generator SKILL_VERSION 1.0.0 class MeetingMinutesSkill: 会议纪要生成技能 def __init__(self, llm_client, storage_dir: Path): self.llm_client llm_client self.storage_dir storage_dir def _validate_input(self, input_data: dict) - dict: required [transcript] missing [k for k in required if k not in input_data] if missing: raise ValueError(f缺少必要参数: {, .join(missing)}) transcript input_data[transcript].strip() if len(transcript) 20: raise ValueError(输入文本太短无法生成有效的会议纪要) if not re.search(r[\u4e00-\u9fff]|[a-zA-Z], transcript): raise ValueError(输入文本中不包含可识别的自然语言) return { transcript: transcript, meeting_title: input_data.get(meeting_title), # 可设为None交给LLM推导 language: input_data.get(language, zh), output_dir: input_data.get(output_dir), } def _clean_text(self, transcript: str) - str: # 去掉话语中的语气词如“嗯”、“啊”、“那个” cleaned re.sub(r(嗯|啊|那个|然后) , , transcript) cleaned re.sub(r\s, , cleaned) return cleaned.strip() def _split_paragraphs(self, cleaned_text: str) - list[str]: # 按句号、感叹号、问号切分 segments re.split(r(?[。!?]), cleaned_text) return [s for s in segments if len(s.strip()) 0] async def _extract_with_llm(self, paragraphs: list[str]) - dict: prompt f 你是一个会议纪要助手。请根据以下会议转写文本提取信息 1. 会议主题一句话概括 2. 参会人人名列表如果无法识别则返回未知 3. 讨论要点不超过五条每条一句话 4. 待办事项列表包含负责人和截止日期如果没有则返回空列表 会议转写文本 {paragraphs} response await self.llm_client.chat(prompt) return json.loads(response.content) def _format_markdown(self, extracted: dict, raw_text: str) - str: timestamp datetime.datetime.now().strftime(%Y-%m-%d %H:%M) lines [ f# 会议纪要, , f- **生成时间**: {timestamp}, , ## 会议主题, extracted.get(meeting_title, 未命名会议), ## 参会人, , .join(extracted.get(participants, [])), ## 讨论要点, , ] for point in extracted.get(key_points, []): lines.append(f- {point}) lines.append() lines.append(## 待办事项) lines.append() for task in extracted.get(todo_items, []): owner task.get(owner, 待定) due task.get(due_date, 未设置) lines.append(f- [ ] {task[description]} 负责人{owner}截止{due}) lines.append() lines.append(## 原始转写摘录) lines.append(f {raw_text[:200]}{... if len(raw_text) 200 else }) return \n.join(lines) async def run(self, input_data: dict) - dict: validated self._validate_input(input_data) cleaned self._clean_text(validated[transcript]) paragraphs self._split_paragraphs(cleaned) extracted await self._extract_with_llm(paragraphs) markdown_text self._format_markdown(extracted, cleaned) output_dir Path(validated.get(output_dir) or self.storage_dir) output_dir.mkdir(parentsTrue, exist_okTrue) output_path output_dir / fmeeting_minutes_{datetime.datetime.now().strftime(%Y%m%d%H%M%S)}.md output_path.write_text(markdown_text, encodingutf-8) return { status: success, output_path: str(output_path), meeting_title: extracted.get(meeting_title), todo_count: len(extracted.get(todo_items, [])) }上面这段代码就是一个完整的技能实现。注意我把输入校验放在公共方法里把文本清洗和段落切分放在独立方法中方便写单元测试。_extract_with_llm是唯一调用大模型的地方逻辑本身很短。这就是我强调的“确定性操作与模型理解分离”。3.3 与 Agent 框架集成注册技能到模型调用列表写好了技能类接下来要把它挂到 Agent 框架上。以我常用的 LangChain 自定义 Tool 风格为例做一个适配器from langchain.tools import BaseTool import json class MeetingMinutesTool(BaseTool): name SKILL_NAME description ( 将一场会议的语音转写文本整理为结构化Markdown会议纪要。 请在这段文本包含实际会议发言内容时使用不要用于普通对话摘要。 ) args_schema: type[BaseModel] | None None def __init__(self, skill: MeetingMinutesSkill): super().__init__() self._skill skill def _run(self, transcript: str, meeting_title: str | None None, language: str zh, output_dir: str | None None) - str: input_data { transcript: transcript, meeting_title: meeting_title, language: language, output_dir: output_dir } import asyncio result asyncio.run(self._skill.run(input_data)) return json.dumps(result, ensure_asciiFalse) async def _arun(self, transcript: str, **kwargs) - str: input_data { transcript: transcript, **kwargs } result await self._skill.run(input_data) return json.dumps(result, ensure_asciiFalse)这里有个特别值得注意的细节description里说了“不要用于普通对话摘要”。这个负向提示非常有效。否则用户对着一串口语文本说“帮我总结一下”模型就会误用会议纪要技能生成一大堆“本次会议没有参会人”之类的字段。然后把它加进模型可用的技能列表skill MeetingMinutesSkill(llm_clientmy_llm, storage_dirPath(./outputs)) tool MeetingMinutesTool(skillskill) # LangChain Agent 初始化时传入 agent create_openai_functions_agent(llm, tools[tool], promptprompt)使用 OpenAI Function Calling 风格时框架会自动把tool.name和tool.description转换成 JSON Schema 的一部分所以不需要额外定义 args_schema。但如果你用 Gemini 或者自研 Agent需要手动把参数 schema 转换成对应格式逻辑一模一样只是命名不同。3.4 运行时错误处理与重试策略技能跑起来之后错误处理比写功能还重要。模型不会等你的代码慢慢调试你的技能一旦抛出一个未被捕获的异常Agent 调用就中断了用户只能看到“服务器内部错误”。所以技能必须把自己打造成一个“不会崩溃的小装置”。我的推荐做法是技能内部捕获所有异常转换成结构化错误对象返回给模型而不是抛给上层。对于 LLM 调用超时重试两次每次退避指数增加如 1s - 2s。对于输出文件写入失败返回错误码和可读消息提醒模型可以修改输出路径后重试。改造后的run方法async def run(self, input_data: dict) - dict: try: validated self._validate_input(input_data) except ValueError as ve: return {status: error, code: INVALID_INPUT, message: str(ve)} try: cleaned self._clean_text(validated[transcript]) paragraphs self._split_paragraphs(cleaned) for attempt in range(3): try: extracted await self._extract_with_llm(paragraphs) break except TimeoutError: if attempt 2: return {status: error, code: LLM_TIMEOUT, message: 模型调用超时} await asyncio.sleep(1 attempt) except Exception as e: return {status: error, code: INNER_ERROR, message: f内部错误: {e}} try: markdown_text self._format_markdown(extracted, cleaned) output_path ... except Exception as e: return {status: error, code: IO_ERROR, message: f写入文件失败: {e}} return {status: success, output_path: str(output_path), ...}这里要强调错误信息里不要包含堆栈跟踪或内部敏感信息。给模型看的消息最多到“写入文件失败”模型会自己想办法换路径或换文件名。如果你把/home/user/.secret/token这类路径抛给模型它可能会尝试去读取这是很大的安全隐患。4. 技能库的管理与复用版本、测试与组织4.1 技能库目录结构规范当技能数量超过十个以后文件夹结构如果五花八门后续维护就成了灾难。我在团队内推行了一套约定每个技能都采用统一目录skills/ └── meeting_minutes_generator/ ├── manifest.yaml ├── skill.py ├── tests/ │ ├── test_validation.py │ └── test_formatting.py ├── assets/ │ └── templates/ │ └── meeting_minutes.tmpl └── README.mdmanifest.yaml是核心元数据文件内容大致如下name: meeting_minutes_generator display_name: 会议纪要生成 version: 1.0.0 description: 将会议转写文本整理为结构化Markdown会议纪要 trigger: 当用户提供包含实际会议讨论内容的转写文本时使用不用于普通对话摘要。 args_schema: json_schema/input.schema.json permissions: - write:output_dir requires: - llm_chat cost_level: medium tags: [meeting, nlp, summary]manifest.yaml不要手写重复字段最好从代码里的类定义或多模块注释自动生成。我见过有人手动维护两份 schema后来改了一处漏了另一处导致模型调用时参数校验永远失败。所以我在CI里加了一步原始 schema 在skill.py里manifest.yaml通过一个脚本生成不允许手动编辑。4.2 自动化测试与回归验证技能一定要写单元测试。很多人以为 Agent 没法测其实可以拆开测。我们把确定性部分和 LLM 调用部分分开前者可以直接测试后者可以用 mock。下面是三个关键测试场景import pytest from skill import MeetingMinutesSkill def test_validate_input_missing_required(): skill MeetingMinutesSkill(llm_clientNone, storage_dirPath(/tmp)) with pytest.raises(ValueError): skill._validate_input({transcript: }) def test_clean_text_removes_fillers(): skill MeetingMinutesSkill(llm_clientNone, storage_dirPath(/tmp)) cleaned skill._clean_text(嗯然后呢那个我们需要讨论一下预算。) assert 嗯 not in cleaned assert 那个 not in cleaned def test_format_markdown_contains_todo(): skill MeetingMinutesSkill(llm_clientNone, storage_dirPath(/tmp)) extracted {meeting_title: 预算会议, participants: [张三], key_points: [讨论Q3预算], todo_items: [{description: 更新预算表, owner: 李四, due_date: 2025-06-01}]} md skill._format_markdown(extracted, 原始文本) assert - [ ] 更新预算表 in md assert 负责人李四 in md除了单元测试还要做回归测试。我的做法是准备一组历史输入样本每次技能版本更新时跑一遍完整链路的模拟调用。因为 LLM 输出是概率性的所以回归测试不能直接比对文本完全相等我设置了两类断言必含字段和非必含字段。比如待办事项列表一定不能为空参会人人数必须大于0。这类松断言适合技能测试能容忍语言表达的多样性同时能抓住真正的“功能缺失”问题。4.3 技能版本控制与灰度发布技能升级是个敏感操作。你改了一个正则表达式可能让原本处理得好好的中文文本全部错乱。我推荐的发布流程是开发分支改代码跑完单元测试。生成一个candidate版本的 manifest。在内部环境让 Agent 只使用该候选技能跑回归用例。观察成功率指标例如会议纪要的待办识别率。等成功率过线后把正式版本号从 1.0.0 升到 1.1.0在全量环境发布。这种流程听起来像标准软件工程但在很多团队里技能代码经常是“改了就跑跑了就挂”完全没走版本控制。尤其当 Agent 同时服务多个业务线时一个技能升级影响面极大建议整体引入灰度机制。没有精力做复杂灰度的话至少做“按用户百分比”的流量切换。4.4 技能的检索与推荐技能库大了以后还有一个容易被忽视的问题模型怎么知道当前对话该用哪个技能。如果技能清单太长把50个技能的描述全塞进 Prompt既浪费 token 又稀释注意力。这时需要一个检索步骤——你可以用向量检索或基于规则的筛选把候选技能缩小到3~5个再把它们的完整描述交给模型。这其实是技能库和 Agent 推理之间的一层寻路层减少模型决策负担。我尝试过的一个简单方案对每个技能生成一份“关键词-技能”的倒排索引。模型对话目标里如果出现“会议”“纪要”“转写”等关键词就优先召回会议纪要技能。加上 embedding 候选召回效果会更好但如果你不想引入复杂的向量库先用关键词倒排也能显著提升命中率。5. 我踩过的坑与排查技巧5.1 技能描述过长导致模型忽略一开始我以为描述越详细越好于是给某个技能写了两百多字的描述包含背景、参数、使用示例、注意事项。结果模型经常无视它直接裸答。后来我发现模型在处理任务时对超长描述有“注意力稀释”效应。描述里的核心指令被淹没在无关细节里。我的教训是描述控制在120字以内只保留最关键的触发条件和功能说明。更长的解释放在触发后模型调用的参数说明或技能内部文档里。模型只有在真正决定调用后才需要这些细节而不是在决策阶段。5.2 输入参数里的“隐藏要求”导致校验失败用户说“写个会议纪要”没有提供转写文本。如果技能直接报错“缺少 transcript”那模型就会继续追问用户。看起来没问题但如果你没有在技能错误信息里告诉模型“需要用户提供文本”模型就只会把原始错误抛给用户用户一头雾水。正确做法是错误消息里写入“请引导用户提供语音转写文本或使用语音转文字技能生成转写”。这样模型就会自动补齐缺失信息。5.3 并发与状态隔离问题我有一次在技能中用一个类变量保存临时状态class MeetingMinutesSkill: temp_cache {}结果两个用户同时调用时一个用户的输入覆盖了另一个的缓存纪要内容互相串了。这是典型的并发 bug。技能的正确做法是所有状态都存在于局部变量或调用上下文中类变量只允许保存只读配置。团队成员后来在 Code Review 里看到self.temp开头就要求立刻改成局部变量这条规矩救了不少次生产事故。5.4 如何调试 Agent 调用技能的全链路调试 Agent 调用技能比调试普通代码麻烦得多因为你不是直接调用函数而是先让模型“决定”调用再进入技能。我每次排查都按这个顺序查先看模型是否正确地判断出需要调用该技能。可以在日志里记录每个技能的召回记录和最终选中记录如果模型压根没选说明技能描述或者检索层有问题。再看传入的参数是否符合预期。很多问题出在模型把参数名或类型弄错了比如传了transcript却传成text。这时要检查args_schema是否清晰有没有给每个参数足够明确的说明。最后才看技能内部错误。在日志中输出每一步的耗时和中间结果尤其是 LLM 调用的输入输出方便对比。我常用的一种排查技巧是给技能加上“trace 模式”当配置了tracetrue技能会返回一份包含中间步骤的日志字典而不是只有最终结果。这能快速定位是哪一步正则写错了还是模板拼接漏了字段。5.5 新技能发布后先跑一遍“地狱测试”所谓地狱测试就是故意用极端输入测试新技能比如空字符串、超长文本、只包含符号的文本、包含大量猜测性信息的文本。我见过不少技能在理想数据上表现得漂漂亮亮一遇到用户随手输入的半句话就崩掉。我的建议是每个技能至少要有一组边界用例形式可以很粗暴但能救命。把这些边界用例纳入自动化回归每次改动都不会漏掉它们。写到这里我可以很坦诚地说agent-skills 这条路并没有玄学它就是把“模型自主决策”与“代码确定执行”这对矛盾组合得恰到好处。技能封装好了Agent 的可靠性就能提升一个量级封装不好再强的模型也会被细节拖垮。根据我的经验与其花长时间调一个无比复杂的 Prompt不如把一个稳定、边界清晰的技能做出来让模型在需要的时候轻松调用它。如果你正准备设计自己的技能库我建议从最常用的业务动作中挑一个用这篇文章的步骤先走通一遍你会立刻感受到“把能力模块化”带来的收益。