ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent Skills 实战:从 Prompt 堆砌到技能体系重构

Agent Skills 实战:从 Prompt 堆砌到技能体系重构 如果你最近在折腾 AI Agent 开发那大概率绕不开agent-skills这个话题。我在过去半年里把一个原本 3000 多行、全靠 system prompt 硬撑的 Agent 脚本彻底重构成了基于 Skills 体系的模块化架构。整个过程踩了不少坑也沉淀了一些实打实的经验。这篇文章想把这些东西完整地分享出来从 Skills 到底解决什么问题、怎么设计一套可用的技能体系到具体的代码实现、命中率优化、常见故障排查再到几个能直接拿去做参考的真实案例一次讲透。先说个结论Skills 不是给 Agent 加几个函数那么简单它本质上是在重构你和模型之间的协作方式。如果只是把工具函数堆给模型Agent 依然是脆弱的只有把技能当成一等公民来设计Agent 才真正具备稳定执行多步任务的能力。1. Agent Skills 到底在解决什么问题1.1 先理解什么是 Agent 的 Skills在聊agent-skills之前得先对齐一个概念Agent 和 Skills 分别是什么。Agent 是一个能自主感知、规划、行动的大模型应用它不只是聊天而是能调用工具、操作数据、完成任务。而 Skills 就是 Agent 对外暴露出的能力单元每个 Skill 代表 Agent 能执行的某一类具体操作比如查天气解析PDF提交工单。你可以把 Agent 想象成一个新入职的员工Skills 就是他的岗位技能条目。你不需要在入职时把所有工作细节都讲一遍只需要告诉他你会哪些技能、每项技能负责什么、遇到什么情况应该用哪项技能。模型本身决定了这个员工聪不聪明但 Skills 体系决定了他在实际工作中顶不顶用。在 agent-skills 这个框架下技能不是一个简单的函数签名而是一套完整的、自描述的、可调用的能力封装。它至少包含三部分技能声明这个技能叫什么、是用来干什么的以自然语言和结构化字段的形式表达参数契约调用这个技能需要哪些输入参数每个参数是什么类型、什么含义执行入口真正干活的函数或工具实现负责把参数变成结果这三部分缺一不可。声明让模型知道有这个技能参数契约让模型知道怎么调用执行入口让技能真正产生效果。1.2 没有 Skills 体系时Agent 开发有多痛苦我不知道你有没有经历过这种场面。早期我做的 Agent 是一切靠提示词的朴素方案所有工具说明、使用规则、边界条件全部堆在一个巨型 system prompt 里。刚开始还行工具只有三五个模型还能勉强应付。等工具数量上到十几个、任务链路变长之后问题就全面爆发了。首先是prompt 膨胀。一份 system prompt 动辄几千字模型在上下文中读取这些信息本身就在消耗额度。同样的内容每轮对话都重复计算成本肉眼可见地往上涨。其次是工具选择混乱。工具多了之后模型经常分不清哪个工具是用来干什么的。我遇到过最离谱的一次模型在处理把文件从 /tmp 移动到 /data这种任务时居然调用了文件搜索工具而不是文件移动工具。根本原因就是工具描述写得太笼统模型根本没有足够的信息去区分它们。第三是复用性极差。换个项目按钮都要重新写一遍。在 A 项目里调好的一套工具到了 B 项目几乎没法原样搬过去。最后每个新项目都是重新开始堆 prompt团队内部也没有沉淀出任何可复用的能力。这些问题其实指向同一个本质我们把智能过度押注在了模型身上却忽略了能力边界的系统化设计。模型当然要足够聪明但如果你把三五十个工具毫无章法地扔给它再聪明的模型也会犯迷糊。Skills 体系解决的就是这个问题——让 Agent 的能力变得有结构、有边界、可管理。2. Skills 体系的核心设计技能怎么定义才算好2.1 技能描述给 Agent 一份说明书一个技能能不能被正确触发七成靠描述写得好不好。很多人在设计技能时描述写得极其敷衍比如输出今天的日期就真的只写了这一句。但你仔细想想模型是怎么理解这句话的它根本不知道你期望的是GET 方式调用 /date 接口并返回 JSON还是用 Python 的 datetime.now() 格式化输出。技能描述不只是给人类看的注释它是给模型看的使用手册。一份合格的技能描述至少应该包含四个要点技能定位说清楚这个技能是干什么的触发它的典型场景是什么输入说明参数各代表什么格式要求是什么可接受的值域是什么输出说明返回什么结构的数据成功后是什么样失败时是什么样边界条件什么情况下应该拒绝执行什么情况下需要向用户澄清举个例子你设计一个解析并统计文档关键词的技能描述如果只写解析文档关键词那模型完全无法判断这个文档是 PDF 还是 Word统计的是词频还是 TF-IDF结果需要分条列出还是表格汇总。但如果描述里写清楚了这些信息模型就能在第一次就做出正确的调用决策。我从实际项目中总结出的经验是技能描述宁可长一点也不要省字。你省掉的每个字都可能变成模型在线上的一次错误调用。2.2 技能粒度太大不好命中太小碎片化技能粒度这个问题是我在重构时花时间最多、也最有体验感的一个点。粒度过大一个技能里塞了二三十个功能模型很难判断这个技能到底能不能完成当前这一步。粒度过小连读取文件写入文件删除文件都拆成三个技能整张技能列表长得吓人模型在选技能的时候反而更混乱。我摸索出来的一个相对靠谱的参考标准是一个技能应该对应一个人类可以直接理解的原子任务。比如读取指定文件的内容是一个原子任务对文件内容做摘要并保存就不算原子任务它其实是三个任务读取、摘要、保存的串联。前者应该做成一个技能后者应该拆成多个技能或者靠上层的工作流编排来组合。原子任务拆好了之后技能数量不会太少也不会爆炸。我当前这个项目里大约维护了三十个核心技能覆盖从数据获取、内容处理到外部系统交互的完整链路这个量级对模型来说是比较友好的。2.3 技能注册让模型知道你有这项技能技能定义好之后还要解决一个问题模型怎么知道你有这些技能在 agent-skills 体系里这靠的是技能注册Skill Registration。注册的过程就是把技能的元信息名称、描述、参数 Schema以结构化的方式暴露给模型。工程上常见做法是在服务启动时扫描技能目录自动收集所有技能的定义然后注入到模型每次请求的 system prompt 或者 function calling 的 tools 参数里。我目前用的是 function calling 的方式。每个技能注册成一个 function tool名称、描述、参数严格按照 OpenAPI Schema 声明。模型在生成回复时会自动决定是否需要调用某个技能、传什么参数。这套机制的好处是不需要自己实现复杂的意图识别模型的语义理解能力直接帮你完成了技能路由。不过也有需要注意的地方function calling 模式下所有技能的参数描述仍然是关键。我见过有人把技能定义写好了参数 Schema 却写得很随意required 字段不标、类型不写结果模型凭空编参数调用时直接报错。参数 Schema 就是技能调用协议的合约合约写得模糊执行必然出问题。3. 从零搭建一个 agent-skills 系统3.1 先定好目录和技能文件结构搭建这套体系我建议先把物理目录结构定清楚。一个干净、可扫描的技能目录不仅方便自己维护也让自动注册变得简单可靠。我在项目中用的是一个扁平化的 skills 目录每个技能单独一个文件夹agent-skills/ ├── core/ │ ├── registry.py # 技能注册器 │ └── skill_base.py # 技能基类 ├── skills/ │ ├── file_reader/ │ │ ├── __init__.py │ │ ├── skill.yaml # 技能元信息 │ │ └── executor.py # 技能执行逻辑 │ ├── date_query/ │ │ ├── __init__.py │ │ ├── skill.yaml │ │ └── executor.py │ └── web_search/ │ ├── __init__.py │ ├── skill.yaml │ └── executor.py └── run.py # 入口负责加载技能并启动 Agent每个技能文件夹里skill.yaml是技能的身份证executor.py是技能的手脚。你完全可以把skill.yaml看成给模型看的说明书把executor.py看成真正干活的后端实现。这种声明与实现分离的做法好处是描述一旦写好不需要动代码就能调整模型对技能的理解。3.2 Skill 基类和技能注册器的实现接下来是代码层面。我先定义了一个 Skill 基类目的是把技能的公共协议统一起来让所有技能都遵循同一套接口规范。# core/skill_base.py from abc import ABC, abstractmethod from typing import Any, Dict, Optional class BaseSkill(ABC): 所有技能都必须继承这个基类。 # 技能名必须唯一建议用下划线命名 name: str # 技能描述给模型看的关键文本 description: str # 参数 Schema遵循 JSON Schema 规范 parameters: Dict[str, Any] {} # 技能是否默认启用用于灰度开关 enabled: bool True abstractmethod def execute(self, **kwargs) - Any: 执行技能的具体逻辑参数必须与 parameters 严格对应。 pass然后写注册器。注册器负责扫描skills目录读取每个技能的skill.yaml加载对应的执行器并最终组装成一个模型可以消费的 tools 列表。# core/registry.py import importlib import yaml from pathlib import Path from typing import Dict, List class SkillRegistry: def __init__(self, skills_dir: str): self.skills_dir Path(skills_dir) self._skills: Dict[str, BaseSkill] {} def load_all(self): for skill_dir in self.skills_dir.iterdir(): manifest_path skill_dir / skill.yaml if not manifest_path.exists(): continue manifest yaml.safe_load(manifest_path.read_text(encodingutf-8)) module importlib.import_module(fskills.{skill_dir.name}.executor) skill_cls getattr(module, manifest[class_name]) skill_instance skill_cls() self._skills[skill_instance.name] skill_instance return self def to_tools(self) - List[Dict]: tools [] for skill in self._skills.values(): if not skill.enabled: continue tools.append({ type: function, function: { name: skill.name, description: skill.description, parameters: skill.parameters, } }) return tools这段代码不算复杂但有一个设计点值得你注意skill.yaml里我指定了一个class_name字段这样技能执行器的类名可以灵活定义不需要强行叫Executor。注册器通过动态 import 加载类并统一实例化为技能对象。这个动态加载的设计让我在不重启服务的情况下也能热加载新技能只需要重新扫描注册表即可。注意动态 import 一定要处理好异常分支。加载单个技能失败时不要在启动阶段直接崩溃应该跳过该技能并打印日志。我之前因为一个技能文件缺依赖整个 Agent 启动失败排查了好几个小时才定位到是一行 import 写错了。3.3 实际写一个可运行的技能纸上谈兵没什么意思下面用一个查询服务器实时状态的技能完整走一遍从声明到执行的流程。先看skill.yamlname: server_status_query description: 查询指定服务器的实时运行状态包括 CPU 使用率、内存使用率、 磁盘空间和当前连接数。当用户希望了解某个服务器的负载情况、健康状态、 或者排查性能瓶颈时使用该技能。 parameters: type: object properties: server_ip: type: string description: 需要查询的目标服务器 IP 地址必须是合法 IPv4 格式。 timeout: type: integer description: 查询超时时间单位秒默认 5 秒。 default: 5 required: - server_ip然后是executor.py# skills/server_status_query/executor.py from core.skill_base import BaseSkill import psutil import socket class ServerStatusQuerySkill(BaseSkill): name server_status_query description 查询指定服务器的实时运行状态包括 CPU、内存、磁盘和连接数。 def execute(self, server_ip: str, timeout: int 5) - dict: # 这里省略了真实的远程采集实现仅做示意 return { server_ip: server_ip, cpu_percent: psutil.cpu_percent(interval1), memory_percent: psutil.virtual_memory().percent, disk_percent: psutil.disk_usage(/).percent, connections: len(socket.socket()) }在实际项目中execute方法里会做真正的远程 SSH 调用、API 请求或者数据库查询我这里用 psutil 做简化演示方便你理解整体流程。关键点在于execute的签名参数要和skill.yaml里的 Schema 保持一致否则模型按照 Schema 传参执行器却接不住报错就很莫名其妙。3.4 把技能接入 Agent 运行主循环注册器把技能变成了 tools 列表接下来就是接入模型调用循环。我用的框架是 LangChain 风格的 Agent 循环核心逻辑就是一个 while 循环# run.py from core.registry import SkillRegistry from openai import OpenAI registry SkillRegistry(skills).load_all() client OpenAI() def run_agent(user_message: str, max_iterations: int 5): messages [{role: user, content: user_message}] tools registry.to_tools() for step in range(max_iterations): response client.chat.completions.create( modelgpt-4o, messagesmessages, toolstools, tool_choiceauto, ) assistant_message response.choices[0].message messages.append(assistant_message) # 如果模型没有调用任何技能就结束循环 if not assistant_message.tool_calls: return assistant_message.content # 逐个执行技能调用 for tool_call in assistant_message.tool_calls: skill registry.get_skill(tool_call.function.name) if skill is None: messages.append({ role: tool, tool_call_id: tool_call.id, content: f错误未找到技能 {tool_call.function.name} }) continue try: arguments json.loads(tool_call.function.arguments) result skill.execute(**arguments) content json.dumps(result, ensure_asciiFalse) except Exception as e: content f技能执行异常{str(e)} messages.append({ role: tool, tool_call_id: tool_call.id, content: content }) return 达到最大迭代次数任务可能未完成。这个主循环的逻辑很直白每一轮把用户消息和历史记录发给模型模型决定要不要调技能如果调就解析参数并执行执行结果回填到消息里再继续下一轮推理直到模型觉得任务完成。有一点我想特别提醒max_iterations 一定要设上限。没有上限的情况下模型在某个边缘场景可能陷入调用技能-报错-再调用-再报错的死循环不仅浪费 token还极容易把上下文撑爆。我一般设 5 到 8 轮既能完成复杂任务也能兜住异常。3.5 让技能命中率提上去的三个技巧技能定义和主循环都搭好了接下来最大的痛点就是模型能不能选对技能。我实测下来有三个方法对命中率提升非常明显。第一描述里对齐用户意图。你在写 description 时不要只写功能要写用户以什么方式表述时应该触发这个技能。比如日期查询技能的 description 里加一句当用户问今天几号、明天星期几、当前时间时使用该技能。这相当于给模型画了触发条件命中率会有肉眼可见的提升。第二给相似技能做差异化锚点。如果你的技能池里存在功能和场景相近的技能比如获取股票实时价格和获取股票历史行情两者很容易被模型搞混。这时候我会在两个 description 里都用上明确的对比锚点一个写明实时价格用于当前交易时刻的报价快照另一个写明历史行情用于过去某段时间的 K 线数据。模型在选的时候就能靠这些锚点做区分。第三多做一次技能预筛选。这不是必须的但对技能数量超过三十个的场景非常有用。在调用大模型之前先用一个轻量的关键词匹配或 embedding 相似度检索把技能候选集从三十个缩小到三五个再把候选技能传给模型做正式选择。这一步本质上是从让模型海选变成先海选再让模型精挑准确率会稳很多。4. 落地过程中踩过的坑与排查实录4.1 模型隐身技能重复出现执行失败有一次我写了一个发送企业微信通知的技能逻辑很简单就是对内部 API 发一个 POST 请求。但上线之后模型经常在任务快结束时突然不调这个技能而是直接输出一段我可以帮您发送通知的文字。仔细看日志才发现模型其实已经返回了 tool_calls但参数里target_user缺失API 要求这字段必填最终被我的异常处理捕获技能执行失败。这类问题的根源几乎都在参数 Schema 和实际业务约束不一致。skill.yaml里把target_user标为可选但执行器的调接口逻辑又要求必填。这种不一致造成了模型按 Schema 生产参数执行器不认账。排查思路很直接用黑盒测试脚本把每个技能的全部分支测一遍然后单独校验skill.yaml里的 required 字段与 execute 代码里实际需要的参数是否一一映射。我把这个检查写成了单元测试每次改技能都会自动跑省了很多线上排障的时间。4.2 两个技能同时被选中Agent 行为混乱还有一类问题很刁钻模型在一轮回复中同时调用了两个技能但这两个技能在语义上是冲突的。有一次在处理帮我整理项目文档这个任务时模型同时调用了文件压缩和文件读取两个技能结果先执行了压缩再执行读取读取到的就是压缩包里的二进制乱码。这种问题本质上不是模型太蠢而是技能职责边界画得不够清晰。我后来把文件处理这一类技能重新梳理了一遍明确了压缩技能只在用户明确要求生成压缩包时触发且输出明确说明返回的是压缩文件路径读取技能只处理文本文件遇到二进制压缩包直接报错提示。通过把技能的输入输出边界写死模型再乱调的概率就低了很多。4.3 上下文过长导致的技能失忆这里要分享一个容易被忽视的坑当对话轮次比较长、上下文里塞满了历史信息和工具调用结果时模型对技能的关注度会明显下降。我实测过一个场景大约 15 轮对话之后模型开始不按照技能名称走而是凭着记忆自己生成调用导致参数格式完全走样。排查下来发现是早期我把每一轮的工具调用结果原样、完整地塞进对话历史有些结果动辄几千字。上下文被大量非核心的噪音占据技能说明反而被淹没。解决方法有两个方向。第一做上下文压缩Context Compression把过长的工具返回结果做摘要只保留关键字段。第二每轮把技能列表再注入一次并且放在系统消息的最前面确保技能说明不会被历史消息挤到后面。我采用的是工具调用结果摘要 技能列表重置的组合策略对话在 30 轮以内基本都能保持技能选择的稳定性。4.4 模型选择的工具能调通但结果是错的这是最让人头疼的一类问题技能没有报错执行器也正常返回但结果根本对不上用户的真实诉求。比如用户问最近一周的服务器可用率模型调用了查询监控数据技能返回了原始指标数值却完全没有计算可用率最终给用户的回答是一串毫无上下文说明的数字。这个问题的关键在于技能的输出和用户的诉求之间有语义鸿沟。技能本身设计成返回原始数据而 Agent 需要把原始数据转化成对用户有意义的答案。解决方案是在技能描述里写明该技能返回原始指标可用率需要进一步计算或者在技能执行结果里直接给出加工后的结论。我的做法是对这类需要数据加工的技能直接在技能内部完成加工逻辑让返回结果本身就是答案级的。比如上面这个查询可用率的技能我在 execute 里直接计算出可用率百分比并附上一句结论文本。这样模型拿到结果后不需要再做额外的推导直接组织语言就能回复用户。这个调整看起来很小但用户体验提升非常明显。5. 用 Agent Skills 搭起来的几个真实案例5.1 文档分析 Agent把分析拆成四个技能我最早落地的案例是一个文档分析 Agent任务是对用户上传的 PDF、Word 文件进行结构分析、摘要提取和关键信息抽取。一开始我设计了一个巨无霸技能analyze_document里面啥都有模型反而经常不知道该从哪一步入手。后来我按照原子任务把它拆成四个技能document_type_detect检测文件格式document_content_extract提取全文内容document_summary_generate生成摘要document_entity_extract抽取特定实体模型现在的处理路径非常清晰先检测类型再提取内容再根据用户的附加要求调用摘要或实体提取。四个技能每个的职责都非常单一模型选错的情况大大减少。这个案例也验证了我在 2.2 节提到的那条原则技能拆分到原子任务级别Agent 的规划能力才能充分发挥。5.2 代码审查 Agent把静态分析和人工建议分开另一个案例是代码审查 Agent。这个场景下用户往往丢进来一段代码或一个 PR希望 Agent 帮忙识别潜在的 Bug、风格问题和安全漏洞。如果只做一个code_review技能模型既要读代码又要做静态分析还要给建议逻辑混在一起很容易乱。我拆成了三个技能code_diff_parse解析 diff 或者读取指定文件内容code_static_scan执行静态检查工具如 pylint、eslint拿到规则命中列表code_risk_analyze基于扫描结果和上下文生成最终的风险报告这里有个巧妙的地方code_static_scan是真正调用外部工具的code_risk_analyze其实是一个纯推理型技能它不调用外部工具只负责把前两个技能得到的数据综合分析。让 Agent 的分析也变成可以编排、可以复用的技能你会发现整个系统的灵活性上了一个台阶。5.3 客服工单处理 AgentSkills 之间的编排艺术第三个案例是客服工单处理。这个场景的特点是任务链路长、分支多而且每一步都可能调用不同的内部系统。我设计的技能链条大致是这样ticket_intent_classify判断工单属于咨询、投诉还是故障报修customer_info_query查询客户基础信息和历史工单order_info_query查询订单状态和物流信息knowledge_base_search检索常见问题知识库ticket_reply_generate生成回复方案ticket_escalate判断是否需要人工介入并升级工单这六个技能组合起来覆盖了一个标准工单从进来到处分的完整链路。这里我想强调的是Skills 体系的价值不仅仅是单个技能好用更在于你能像搭积木一样编排它们。比如当ticket_intent_classify判断是投诉时Agent 自动跳过knowledge_base_search直接调用ticket_reply_generate并附带升级提醒。这种编排逻辑可以写在 Agent 的规划提示词里也可以由模型自主决策两种方式我都在不同项目中试过效果都还不错。6. 关于 agent-skills我的几条实操心得项目做到这个阶段我对 skill 的体会已经超出了技术架构本身。这半年里我越来越觉得设计技能体系的本质是在给 Agent 塑造一套能力边界和协作方式。你希望模型在遇到什么需求时做什么、不做什么、做到什么程度都要通过一个个 skill 的描述和约束来传达。这个认识直接影响着我把 agent-skills 用于生产系统的底气。几条心得简单说一下当作给后来者的一些参考。第一永远把 skill 描述当作产品文案来写。它不是你写给同事看的注释而是写给模型看的说明书。多花十分钟把描述写细能省掉后面大量线上调试时间。第二技能不是越多越好。我见过有些团队炫技式地堆了上百个技能最后模型选都选不过来。合适的技能数量取决于你的业务半径宁少勿滥保持每个技能都能被清晰区分才是关键。第三善用本地评测集。我每周都拿一批真实用户问题跑一遍 agent然后人工看技能命中率、执行成功率、最终回复质量。这个评测集不需要很大五十条左右就足够发现问题。持续积累下去你会看到自己的技能设计在迭代中越来越稳。最后再分享一个小技巧我后来在每个技能的execute返回结果里都会额外加一个skill_note字段内容是该技能已完成返回结果为 XXX 格式如需进一步处理请按原计划继续。这个字段看着不起眼但它在多技能串联场景里帮了模型大忙。模型不再需要靠猜测判断这个结果是不是最终答案而是沿着技能链路清晰推进。这个小改动让最终任务完成率提升了差不多十几个百分点算是我这段实践里最划算的一笔投入。
返回列表