Agent Skills 实战:从最小项目到生产级编排
Agent Skills 是当前 AI Agent 开发中绕不开的一个概念。吴恩达在多门课程和演讲里强调过 AI Agent 的发展潜力也确实带动了大量开发者关注 Agent Skills 这类能力封装方式。它不是某个平台的专有名词而是一种工程实践把某类任务所需的指令、工具调用逻辑、参数约束、示例和校验规则打包成一个可复用模块让 Agent 面对同一类问题时走同一套稳定流程而不是每次从头临场发挥。这篇教程会从概念、最小项目、编排设计、故障排查到进阶学习路径完整走一遍 Agent Skills 的落地链路。1. 为什么需要 Agent Skills从“会聊天”到“能干活”单次调用大模型只能得到一段文字回答但真实项目里的需求往往是“读取一份文档并生成摘要”“从用户问题里提取结构化参数”“把一批消息按主题分类再汇总”。这些任务的关键不在于一次回答有多流畅而在于流程是否稳定、能否复用、能不能被测试。1.1 一次性指令和可复用技能的区别没有 Skills 的时候常见写法是把同一段指令复制到多个脚本里。# 不推荐每个脚本都重复维护同一段 prompt def generate_summary(text: str) - str: prompt 你是一个文档摘要专家。请为下面的文本生成摘要。\n text return call_llm(prompt)如果摘要要求从 200 字改成 100 字所有复制过这段 prompt 的地方都要改一遍漏掉一处就会出现线上行为不一致。把这段逻辑收进一个 Skill 后业务侧只需要按照技能名和参数调用说话方式、输出格式、边界处理都收敛在同一个模块里。1.2 Agent Skills 的典型形态在常见实践中一个 Skill 不是一段 prompt而是一个包含说明文件、可执行脚本和测试用例的目录。agent-skills-demo/ skills/ doc_summary/ SKILL.md entry.py tests/ test_summary.py其中SKILL.md负责描述这个技能解决什么问题、输入输出是什么、有哪些使用限制entry.py提供真正可执行的函数tests目录用来验证函数行为。这样设计之后Agent 可以先读取技能说明再决定是否调用以及如何传参工程师也可以在脱离 Agent 的情况下单独验证这个技能是否正确。1.3 Skills 与 Prompt、Tool、Workflow 的关系初学者很容易把这几个概念混在一起先看一张关系表。概念典型形态是否可独立执行设计目标Prompt 模板一段文本指令否约束模型输出风格和格式Tool / Function代码函数或 API是完成确定性操作Skill指令 代码 参数约束 示例是面向一类完整任务Workflow多个 Skill / Tool 的有序组合是完成一个业务场景需要说明的是它们不是互斥关系。一个 Skill 内部可以调用 Tool也必然包含 Prompt 或说明文本一个 Workflow 则可以把多个 Skill 串起来。核心区别在于“复用粒度”Skill 的边界是一类任务而不是一次底层调用。2. 入门之前先建立 Agent 与 Skill 的心智模型在写代码之前需要先想清楚 Agent 是如何工作的。否则很容易把“调用模型接口”当成“构建 Agent”最后只是写了一批散乱的函数。2.1 Agent 的最小运行闭环一个最小可运行的 Agent 通常包含五部分。感知接收用户输入、环境变量或外部事件。规划决定任务如何拆解以及下一步调用哪个 Skill。行动调用已经注册的 Skill 或 Tool。观察获取行动结果判断是否满足目标。记忆保存中间结果供后续步骤使用。Skills 在“行动”环节发挥作用。Agent 的规划层负责输出“调用哪个技能 传什么参数”Skills 负责真正完成工作。如果底层模型支持函数调用规划层可以由模型直接生成如果不支持也可以用规则路由。2.2 Agent Skills 的输入、输出与状态一个 Skill 必须对输入输出做出明确约束否则 Agent 无法稳定调用。常见做法是用 JSON Schema 描述输入。{ name: doc_summary, description: 对一段文本生成结构化摘要, input_schema: { type: object, properties: { text: { type: string, description: 需要摘要的原始文本 }, lang: { type: string, default: 中文, description: 输出语言 } }, required: [text] } }这里的关键是把“技能做什么”和“怎么传参”说清楚。模型或路由模块看到这段描述后才知道该在什么场景调用这个技能以及需要提供哪些字段。还要注意状态问题。单个 Skill 最好保持无状态不把用户级别或请求级别的数据存在模块全局变量里。状态应该由 Agent 的上层 Session 管理这样同一个 Skill 可以在不同请求之间并发复用不会互相污染。2.3 技能粒度怎么设计粒度太粗技能就变成“什么都能做但什么都做不精”的黑盒粒度太细又退化成普通工具函数失去了任务封装的价值。过粗handle_all_documents内部塞满摘要、翻译、分类、格式转换难测试也难改。过细call_llm只是一个模型调用封装没有任务语义。合适invoice_info_extract明确接收发票图片或 OCR 文本输出金额、发票号、日期。判断标准很简单一个技能是否对应一类有明确输入、明确输出、可独立验收的任务。如果回答“是”就值得封装。3. 从零搭建一个最小 Agent Skills 项目这一节会创建一个不依赖 LangChain 等框架的最小项目用本地模型完成两个 Skill文本摘要和关键词提取。选择本地模型作为示例是为了让链路更清晰也避免外部 API 配置分散对核心机制的注意力。3.1 环境准备与依赖建议环境如下。依赖版本建议用途Python3.10 及以上运行示例代码Ollama最新稳定版本地运行大模型模型qwen2.5:7b 或更小文本生成安装完 Ollama 后先拉取模型。mkdir agent-skills-demo cd agent-skills-demo python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install pytest ollama pull qwen2.5:7b首次拉取模型体积较大需要耐心等待。如果本机资源有限可以换用 qwen2.5:3b 等更小的模型代价是输出稳定性会下降。3.2 技能注册中心的实现注册中心负责保存所有已注册技能并提供按名称查找的能力。先创建registry.py。from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Callable dataclass class SkillSpec: name: str description: str input_schema: dict handler: Callable[..., Any] tags: list[str] field(default_factorylist) class SkillRegistry: def __init__(self) - None: self._skills: dict[str, SkillSpec] {} def register(self, spec: SkillSpec) - None: if spec.name in self._skills: raise ValueError(fskill {spec.name} already exists) self._skills[spec.name] spec def get(self, name: str) - SkillSpec: if name not in self._skills: available , .join(self._skills) raise KeyError(fskill not found: {name}. available: {available}) return self._skills[name] def list(self) - list[dict[str, str]]: return [ {name: spec.name, description: spec.description} for spec in self._skills.values() ] skill_registry SkillRegistry()注册中心的核心价值是“解耦”。业务代码不需要知道技能内部如何实现只需要持有技能名和参数新增技能时也不需要改动既有调用方。3.3 实现两个可复用技能先写一个统一的模型调用模块llm.py。这里使用ollama命令行工具概念上最简单。import logging import subprocess logger logging.getLogger(__name__) def call_ollama(prompt: str, model: str qwen2.5:7b) - str: cmd [ollama, run, model, prompt] logger.info(run ollama: model%s, model) try: proc subprocess.run(cmd, capture_outputTrue, textTrue, timeout120) except subprocess.TimeoutExpired as exc: raise TimeoutError(follama call timeout: {model}) from exc if proc.returncode ! 0: raise RuntimeError(follama failed: {proc.stderr.strip()}) return proc.stdout.strip()接着创建skills包。目录结构agent-skills-demo/ registry.py llm.py skills/ __init__.py doc_summary.py keyword_extract.py main.pyskills/doc_summary.pyfrom registry import SkillSpec, skill_registry from llm import call_ollama def doc_summary(text: str, lang: str 中文) - str: prompt f 请对以下文本生成摘要。 要求 1. 使用{lang}回答。 2. 先给一句话概括再列3个要点。 3. 不要输出多余解释。 文本 {text} return call_ollama(prompt) skill_registry.register( SkillSpec( namedoc_summary, description对一段文本生成结构化摘要, input_schema{ type: object, properties: { text: {type: string}, lang: {type: string, default: 中文}, }, required: [text], }, tags[document, summary], handlerdoc_summary, ) )skills/keyword_extract.pyfrom registry import SkillSpec, skill_registry from llm import call_ollama def extract_keywords(text: str, top_k: int 5) - str: prompt f 请从文本中提取前 {top_k} 个关键词。 要求 1. 只输出 JSON 数组。 2. 不要输出解释。 文本 {text} return call_ollama(prompt) skill_registry.register( SkillSpec( namekeyword_extract, description从一段文本中提取关键词输出 JSON 数组, input_schema{ type: object, properties: { text: {type: string}, top_k: {type: integer, default: 5}, }, required: [text], }, tags[text, keyword], handlerextract_keywords, ) )这里的重点是每个技能都定义了input_schema。参数不通过**kwargs无限透传调用方必须知道技能期望什么输入这样才能做校验和日志追踪。3.4 Agent 入口与路由逻辑main.py作为最小 Agent 入口先从用户输入中匹配技能名再执行对应技能。import logging from registry import skill_registry import skills.doc_summary # noqa: F401 import skills.keyword_extract # noqa: F401 logging.basicConfig( levellogging.INFO, format%(asctime)s %(name)s %(levelname)s %(message)s, ) def match_skills(user_input: str) - list[str]: matched [] if any(word in user_input for word in (摘要, 总结)): matched.append(doc_summary) if 关键词 in user_input or keyword in user_input.lower(): matched.append(keyword_extract) return matched def run_skill(name: str, payload: dict) - None: spec skill_registry.get(name) logger.info(executing skill: %s, name) result spec.handler(**payload) print(f[{name}]) print(result) def main() - None: text ( Agent Skills 是一种把指令、工具调用逻辑、参数约束和示例 打包成可复用执行单元的方法。 ) user_input 请对这段文本做摘要并提取5个关键词 for name in match_skills(user_input): if name doc_summary: run_skill(name, {text: text}) elif name keyword_extract: run_skill(name, {text: text, top_k: 5}) if __name__ __main__: main()运行python main.py示例输出可能如下实际结果会因模型不同而变化。[doc_summary] 一句话概括Agent Skills 是把任务指令和工具执行逻辑封装为可复用单元的做法。 要点 1. 强调可复用性。 2. 强调输入输出约束。 3. 强调工程化落地。 [keyword_extract] [Agent Skills, 工具调用, 参数约束, 可复用, 执行单元]到这里一个最小 Agent Skills 链路已经跑通。可以看到路由选择技能、技能执行、模型返回三个步骤是清晰分开的。4. 进阶设计编排、状态与生产化最小示例能跑通但离生产还有距离。真实 Agent 通常不会只调用一个技能而是需要把多个技能组合成完整流程。4.1 技能编排串行、并行与条件分支串行编排最容易理解一个技能的输出作为下一个技能的输入。def workflow_report(text: str) - dict: summary skill_registry.get(doc_summary).handler(texttext) keywords skill_registry.get(keyword_extract).handler(texttext, top_k5) return {summary: summary, keywords: keywords}如果两个技能之间没有依赖理论上可以并行。但要注意本地模型并不一定能稳定处理并发请求盲目使用线程池可能打爆模型服务。更稳妥的做法是先串行跑通确认模型服务的并发能力后再决定是否并行。from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers2) as pool: future_summary pool.submit( skill_registry.get(doc_summary).handler, texttext ) future_keywords pool.submit( skill_registry.get(keyword_extract).handler, texttext, top_k5 ) summary future_summary.result() keywords future_keywords.result()条件分支通常交给路由层或 Agent 的规划层完成。规则简单时用关键词匹配规则复杂时可以让模型输出结构化的下一步动作。4.2 状态管理一次请求内的技能上下文多个技能在同一个业务请求里执行时需要共享中间结果。建议用 Session 对象保存状态而不是把状态塞进技能全局变量。from dataclasses import dataclass, field dataclass class Session: context: dict field(default_factorydict) def set(self, key: str, value) - None: self.context[key] value def get(self, key: str): return self.context.get(key)技能 handler 保持无状态。这样写有两个好处一是同一个技能可以在多个请求中并发复用二是测试时不需要清理残留状态。4.3 从代码注册到目录热加载上面的示例通过import skills.doc_summary触发注册。技能数量变多后可以用目录扫描自动加载。from pathlib import Path import importlib.util SKILLS_DIR Path(skills) def load_skill_entry(entry_path: Path) - None: module_name fskill_{entry_path.parent.name} spec importlib.util.spec_from_file_location(module_name, entry_path) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) for entry in SKILLS_DIR.glob(*/entry.py): load_skill_entry(entry)这个方案需要谨慎处理安全边界只允许加载经过审查的目录不要从不可信路径动态导入代码。否则技能模块一旦被执行就等同于在服务进程里运行了未知代码。4.4 生产环境需要补齐的能力技能能在本地跑通只是第一步。生产环境至少要补齐下面这些能力。输入输出校验每个技能调用前校验参数输出后校验结构。超时与重试模型调用要设置超时并对瞬时错误做有限次重试。日志与追踪记录技能名、参数摘要、结果长度、耗时和错误信息。版本管理技能逻辑变更后要能对应到代码版本方便回滚。权限控制技能如果调用内部系统或数据库需要独立的权限模型。模型配置模型名、温度、最大 token 等参数要外部可配置不要硬编码在技能里。5. 运行验证与问题排查很多 Agent 项目不是“写不出来”而是“跑起来之后出了问题不知道怎么查”。建议把验证和排查提前到开发阶段。5.1 分层验证清单不要直接拿完整用户请求做测试而是像单元测试一样逐层验证。层级验证内容操作建议技能函数给定固定文本断言输出字段使用 pytest 编写用例技能注册注册后能否通过skill_registry.list()查到启动时打印技能列表路由匹配不同用户输入是否映射到预期技能准备一组输入输出用例端到端完整请求能否返回可用结果运行main.py观察输出5.2 典型错误与排查链路出现问题时按“输入 - 路由 - 注册 - 模型调用 - 输出解析”的顺序排查。现象常见原因检查方式解决建议ollama: command not foundOllama 未安装或不在 PATHwhich ollama安装 Ollama 并补全 PATHmodel not found本地没有目标模型ollama listollama pull qwen2.5:7b技能未注册技能模块没有被导入python -c from registry import skill_registry; print(skill_registry.list())在入口模块显式导入技能模块KeyError: skill not found路由返回了错误技能名在路由函数中打印匹配结果统一技能命名并增加约束调用超时模型推理慢或参数过大查看日志中 timeout 信息增大超时换小模型或把任务拆分输出不是 JSONPrompt 对输出格式约束不足打印模型原始返回值强化输出模板加入解析失败重试排查时要先确认第一步是否异常。如果用户输入根本没有走到相应路由后续所有问题都无从谈起。5.3 用日志还原一次 Agent 调用链生产环境里日志是还原现场的主要手段。建议至少记录三段关键日志。logger.info(route matched: %s, skill_name) logger.info(executing skill: name%s payload_keys%s, name, list(payload.keys())) logger.info(finished skill: name%s result_len%