☰
Agent技能体系实战:从硬编码到可复用技能层的完整指南
2026/9/25 19:36:32 网站建设 项目流程

关于“agent-skills”,我觉得有不少东西可以聊。做了快两年的Agent项目,从最早用大模型写死Prompt链,到现在逐步把技能体系抽象出来独立管理,这条路踩了太多坑。今天这篇就把“agent-skills”这种技能化组织方式的来龙去脉、数据结构和实操经验一次讲清楚,希望给正在做Agent落地的朋友一些实际参考。

1. 内容整体设计与思路拆解

1.1 从硬编码到技能化:为什么Agent需要独立技能层

先聊一个最核心的问题:为什么Agent的能力不能继续靠“把工具函数写在代码里+让模型调Prompt”这种方式实现?

我早期做的Agent项目,工具函数直接硬编码在代码库中,Agent要用什么,就在System Prompt里用千把字描述一遍,再加上一堆Few-shot样例。刚开始Demo跑通还行,一旦遇到真实业务,痛点立刻暴露:

  • Prompt越写越长,经常超出上下文窗口,模型反而抓不住重点。
  • 每加一个工具都要重新调试Prompt,工具多了之后互相干扰。
  • 同一个能力在不同项目里实现方式五花八门,没法复用。
  • 完全没法让非开发人员参与Agent能力的维护。

agent-skills的思路是把Agent的每一项能力,从“一段描述+一个函数”升级为“一套结构化的技能定义”。每个技能自带名称、功能描述、参数Schema、执行逻辑、返回值格式、调用约束。Agent运行时不直接面对脑洞大开的工具清单,而是面对一套语义清晰、结构完整、可以动态装卸的技能集合。

这就像把散落的乐高零件按图纸分类装箱。你要拼复杂的模型时,不用在一堆零件里大海捞针,直接按索引挑对应分箱即可。技能层的价值就在这里:把底层能力和上层智能解耦。

1.2 技能化解决了什么核心问题

我把技能化带来的收益总结为四个方面:

其一,能力可复用。一个写好的技能,比如“PDF表格提取”,可以同时服务于报表分析Agent、合同审查Agent和学术文献整理Agent。开发一次,处处生效。

其二,上下文大幅度缩减。技能描述可以压缩成“技能名+一句话用途+参数要求”,大模型不需要每次读长篇工具说明。节省出来的上下文空间可以用来放更多实际业务数据。

其三,能力可动态插拔。运行中按需加载技能,类似浏览器的插件机制。不需要重启服务,不需要重新部署,加个技能注册动作就能让Agent获得新能力。这在生产环境的价值是实打实的。

其四,便于评测和沉淀。每个技能独立测试,坏了一个不影响其他功能。随着时间推移,技能库越来越厚,Agent的战斗力同步提升。

1.3 技能系统的分层架构

从整体架构看,一个完整的agent-skills体系通常包含四层:

第一层是技能定义层,用JSON Schema或类似格式声明每个技能的元信息、参数结构和返回值规范。

第二层是技能注册与发现层,负责技能的统一登记、索引、检索和按需加载。Agent拿到用户指令后,通过这层找出最匹配的技能组合。

第三层是技能执行层,真正跑技能逻辑的地方。可能是直接调函数,可能是调用外部API,也可能是组合多个原子技能形成复合技能。

第四层是技能编排层,处理多个技能之间的流程衔接、数据传递和异常兜底。这层通常和Agent的规划能力绑定,由大模型根据任务动态编排,也可以预置固定流程模板。

这四层不一定需要搞成独立的微服务,按模块拆分即可,核心是逻辑边界要清晰。我见过不少项目初期图省事把四层揉成一团,后面加功能时几乎寸步难行。

2. 核心细节解析与实操要点

2.1 技能定义的结构长什么样

技能定义是整个体系的地基。以我常用的技能描述格式为例:

{ "skill_id": "webpage_extract_skills", "name": "网页结构化抽取", "description": "从任意网页中按XPath或CSS选择器提取结构化内容", "version": "1.2.0", "author": "ops-team", "tags": ["web", "spider", "extraction"], "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "目标网页URL地址" }, "extract_rules": { "type": "object", "description": "字段名与CSS选择器的映射关系" }, "wait_seconds": { "type": "number", "description": "页面加载等待时间,默认2秒", "default": 2 } }, "required": ["url", "extract_rules"] }, "returns": { "type": "object", "properties": { "status": {"type": "string", "enum": ["success", "failed"]}, "data": {"type": "object"}, "error": {"type": "string"} } }, "execution": "python_function", "timeout": 30, "constraints": ["仅支持静态页面", "目标站点robots协议需允许抓取"] }

注意几个容易被忽略的细节:

description字段建议控制在50字以内,重点说清楚“什么场景下用这个技能”,而不是大段解释实现原理。大模型做技能匹配时主要读这段文字,写得太长反而稀释关键信息。

parameters里的每个字段都要有description,大模型根据这些描述来生成合理的参数值。字段描述缺失或含糊,参数幻觉率直线上升。

constraints字段建议保留,用来声明技能的边界和限制。比如“仅支持静态页面”这种约束写在里面,模型就不会拿动态渲染的页面硬调这个技能。

returns要定义清晰的成功/失败状态位。Agent拿到返回值后要据此决定下一步动作。状态位含糊,决策链路就容易断。

2.2 选型时最容易踩的坑

技能定义格式选型,我建议直接上JSON Schema,不要自创格式。原因有三:兼容性好,主流模型对JSON有天然的理解能力;校验工具链成熟,Python的jsonschema库、Node的ajv都能直接复用;扩展方便,从简单校验到复杂逻辑都能覆盖。

还有一部分项目为了追求所谓“轻量”,把技能描述写成纯文本。这在玩具项目里可以跑,一旦技能数量超过20个,纯文本描述没法做结构化校验,参数传递全靠模型自觉,出错率相当感人。

另一个常见问题是技能粒度。粒度过粗,一个技能塞了十多个参数,模型调用时容易漏参、错参;粒度过细,一个简单任务要编排五六个技能,链路加长,中间任何一环出错都要重新规划。

我的经验是:一个技能只做一件事,参数控制在五个以内。如果参数超过五个,先停下来思考是不是该拆技能了。

2.3 技能描述如何写给大模型看

这一节内容纯属实践心得。技能描述虽然是给人维护的,但主要是给大模型看的。写得好不好,直接决定模型的调用准确率。

写技能描述要遵循三个原则:场景触发优先、动词开头、拒绝营销话术。

第一个原则,描述里要写清楚“什么情况下用我”。比如“当用户需要从网页中抽取结构化字段时使用本技能”,模型一看就知道匹配场景。反过来,“本技能功能强大,支持多种网页解析模式”这种描述,模型看了基本等于没看。

第二个原则,描述用动词开头。“提取”“转换”“聚合”“对比”这些动作词能让模型快速建立印象。

第三个原则,别在描述里堆形容词。什么“高效”“强大”“智能”都是噪音,浪费上下文窗口,还可能误导模型选错技能。

我见过一份写得质量比较高的技能描述,给大家参考:

当用户请求涉及从多页文档中抽取关键字段、并汇总为结构化表格时使用本技能。支持PDF、Word、扫描件三种格式,可通过正则规则或标注示例两种方式指定抽取目标。

一句场景触发,一句能力边界,模型很容易做匹配判断。

3. 实操过程与核心环节实现

3.1 技能注册与发现机制实现

技能写好后,要有一个地方统一管起来。技能注册表我用的是最简单的方案:一个JSON索引文件加上几个Python函数。

# skill_registry.py import json import hashlib from pathlib import Path from typing import List, Dict, Optional class SkillRegistry: def __init__(self, index_path: str = "./skills/index.json"): self.index_path = Path(index_path) self.skills: Dict[str, dict] = {} self.skill_versions: Dict[str, str] = {} self.load() def load(self): """加载技能索引,索引里记录每个技能的元信息和文件路径""" if not self.index_path.exists(): print(f"警告: 技能索引文件不存在,路径={self.index_path}") return index_data = json.loads(self.index_path.read_text(encoding="utf-8")) for item in index_data["skills"]: self.skills[item["skill_id"]] = item self.skill_versions[item["skill_id"]] = item.get("version", "0.0.0") def register(self, skill_meta: dict) -> str: """注册新技能或更新已有技能,返回技能ID""" skill_id = skill_meta.get("skill_id") if not skill_id: raise ValueError("skill_id不能为空") version = skill_meta.get("version", "0.0.0") # 简单版本比较,示例逻辑仅支持数值版本 if skill_id in self.skills: old_ver = self.skill_versions[skill_id] if version == old_ver: return skill_id print(f"更新技能: {skill_id} {old_ver} -> {version}") self.skills[skill_id] = skill_meta self.skill_versions[skill_id] = version self._persist() return skill_id def discover(self, task_desc: str, top_k: int = 5) -> List[dict]: """技能发现:根据任务描述返回最相关的技能列表。 这里用最简单的关键词加权打分,生产环境可替换成embedding向量检索。 """ from collections import Counter words = set(task_desc.lower().split()) scored = [] for skill_id, meta in self.skills.items(): desc_text = f"{meta.get('name', '')} {meta.get('description', '')} {' '.join(meta.get('tags', []))}" desc_words = set(desc_text.lower().split()) overlap_score = len(words & desc_words) # 加一点场景关键词的权重,命中description中“当...时”表述的额外加分 if "当" in meta.get("description", "") or "当" in desc_text: overlap_score += 1 scored.append((overlap_score, skill_id, meta)) scored.sort(key=lambda x: x[0], reverse=True) return [meta for score, _, meta in scored[:top_k] if score > 0] def _persist(self): index_data = {"skills": list(self.skills.values())} self.index_path.write_text( json.dumps(index_data, ensure_ascii=False, indent=2), encoding="utf-8" )

简单说下这个注册表的设计思路:

  • 技能元信息持久化为JSON索引,每次注册或更新都重写索引文件。技能数量少、更新频率不高时完全够用。
  • discover函数用关键词重叠打分,粗糙但实用。技能库大了之后,可以换成向量检索,把description和tags做embedding,用余弦相似度匹配。示例里保留了替换空间。
  • 版本字段做简单比较,支持技能热更新。生产环境建议用语义化版本号并增加兼容性检查,避免新版本参数不兼容导致线上任务失效。

3.2 技能执行器的异常处理与兜底

技能执行器负责真正跑技能逻辑。这里最大的教训是:永远不要假设参数是对的,永远不要让技能逻辑直接暴露给模型调用。

我在生产环境用接一个执行中间层的方式,每个技能调用前做参数校验,执行中做超时保护,执行后做结果规整。

# skill_runner.py import time import json import inspect from typing import Any, Callable, Dict class SkillTimeoutError(Exception): pass class SkillRunner: def __init__(self): self._skills: Dict[str, Callable] = {} self._skills_meta: Dict[str, dict] = {} def register_executor(self, skill_id: str, func: Callable, meta: dict): """绑定技能ID与执行函数""" self._skills[skill_id] = func self._skills_meta[skill_id] = meta def run(self, skill_id: str, params: dict, timeout: int = 30) -> dict: if skill_id not in self._skills: return {"status": "failed", "error": f"技能 {skill_id} 未注册"} # 1. 参数合法性检查 required_params = self._skills_meta[skill_id].get("parameters", {}).get("required", []) missing = [p for p in required_params if p not in params] if missing: return {"status": "failed", "error": f"缺少必要参数: {', '.join(missing)}"} # 2. 执行前日志 start_ts = time.time() print(f"[SKILL_EXEC] 调用技能={skill_id} params={json.dumps(params, ensure_ascii=False)[:200]}") # 3. 带超时执行 try: result = self._run_with_timeout(self._skills[skill_id], params, timeout) elapsed_ms = (time.time() - start_ts) * 1000 print(f"[SKILL_EXEC] 技能执行成功 skill={skill_id} 耗时={elapsed_ms:.1f}ms") return {"status": "success", "data": result} except SkillTimeoutError: elapsed_ms = (time.time() - start_ts) * 1000 print(f"[SKILL_EXEC] 技能执行超时 skill={skill_id} 耗时={elapsed_ms:.1f}ms") return {"status": "failed", "error": f"技能执行超时(>{timeout}s)"} except Exception as exc: elapsed_ms = (time.time() - start_ts) * 1000 print(f"[SKILL_EXEC] 技能执行异常 skill={skill_id} 异常={exc}") return {"status": "failed", "error": str(exc)} def _run_with_timeout(self, func: Callable, params: dict, timeout: int) -> Any: """用信号实现超时控制,注意仅适用于主线程场景。 多线程/异步场景需要换成threading.Timer或asyncio.wait_for方式。""" import signal def handler(signum, frame): raise SkillTimeoutError(f"timeout {timeout}s") signal.signal(signal.SIGALRM, handler) signal.alarm(timeout) try: return func(**params) finally: signal.alarm(0)

实践中三个环节最容易出问题:

参数校验不能只做“缺不缺”,还要做类型和边界校验。比如一个技能要求“limit在1到50之间”,模型传了100,直接执行可能拉爆下游服务。所以参数Schema里建议把minimum、maximum这类约束写全,执行前再做一次严格校验。

超时控制是刚需。大模型调用技能时,模型在等待结果的过程中会“脑补”执行进度。技能挂起三五分钟,模型可能已经自行编造了一个结果并继续往下走。更要命的是,技能并发执行时,一个卡死的技能会一直占用资源,量大了之后整个Agent吞吐量跟着崩。

返回值必须标准化。无论技能内部多复杂,对外只输出success/failed两种状态,加上data或error字段。老实的做法是在执行器层面把异常全部捕获转成错误字符串,不要给Agent抛原始堆栈,一是浪费token,二是暴露内部实现细节。

3.3 技能编排:让多个技能协同工作

单技能调用只是基本功,真正的Agent能力来自多个技能的编排。

我用的编排模式有三种:

线性编排,按固定顺序依次执行。典型场景:抓取网页→抽取正文→生成摘要→发送到指定邮箱。每一步依赖上一步的输出,容错靠重试机制。

条件编排,根据中间结果走不同分支。比如“先判断文件类型再决定用哪个解析技能”,状态分流逻辑写在编排描述里,模型根据实际返回值选择后续动作。

并行编排,多个独立技能同时执行。比如要同时从三个不同渠道采集数据再合并,每个技能独立跑,最后汇总。这里要注意并发控制,别把下游接口打挂了。

我封装过一个极简的编排执行器,核心逻辑是给Agent一个“技能执行计划”的结构化框架,让模型逐步输出下一步要执行的技能和参数,而不是一次性规划完。

# skill_orchestrator.py from typing import List, Dict, Any class SkillOrchestrator: def __init__(self, runner): self.runner = runner self.execution_history = [] def execute_plan(self, plan: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """按顺序执行技能计划,记录每步结果。 plan格式示例: [ {"skill_id": "webpage_fetch", "params": {"url": "https://example.com"}}, {"skill_id": "content_extract", "params": {"html": "{上一步输出}"}} ] """ results = [] for step in plan: skill_id = step["skill_id"] params = step.get("params", {}) # 支持用 {0} {1} 占位符引用前序步骤的结果 resolved_params = self._resolve_param_refs(params, results) result = self.runner.run(skill_id, resolved_params) self.execution_history.append({ "step_index": len(results), "skill_id": skill_id, "params": resolved_params, "result": result }) results.append(result) if result["status"] == "failed": # 如果关键步骤失败,直接终止后续编排 print(f"[ORCHESTRATOR] 步骤 {len(results)-1} 执行失败,终止后续步骤") break return results def _resolve_param_refs(self, params: dict, history_results: List[dict]) -> dict: """将params中的 {0} {1} 引用替换为对应步骤结果中的data字段""" import re resolved = {} for key, value in params.items(): if isinstance(value, str): ref_pattern = r"\{(\d+)\}" match = re.search(ref_pattern, value) if match: step_idx = int(match.group(1)) if step_idx < len(history_results): resolved[key] = history_results[step_idx].get("data") continue resolved[key] = value return resolved

编排层最核心的决策点是“要不要把编排控制权交给模型”。全让模型自由编排,灵活但不可控;全用固定模板编排,稳定但呆板。我目前的折中方案是:高频、标准化的流程用预置模板,特殊情况再由大模型动态调整。简单说就是70%预置,30%自由发挥,这个比例可以根据业务稳定性要求上下浮动。

4. 常见问题与排查技巧实录

4.1 模型总是不调用技能怎么办

这是问得最多的一个现象:技能清清楚楚写在系统提示里,参数说明也给了,但模型就是不触发,非要自己编答案或者尝试用聊天能力硬答。

排查路径通常是这样的:

先确认技能匹配机制是不是出了问题。早期用关键词匹配时,经常出现任务描述和技能描述“字面不重叠但语义一致”的情况,比如任务说“把这份PDF里的发票金额汇总一下”,技能描述写的是“PDF表单提取并计算总和”,关键词匹配就凉了。换成向量检索后命中率提升明显。

再检查技能描述是否足够具体。一个典型的反面例子是“分析文本情绪”这个描述,模型压根不知道“什么情况下用它”。改成“当用户输入一段评论、反馈、评价类文本,需要判断其情感倾向(正面/负面/中性)时使用本技能”,命中率立刻上去了。

还要看调用示例是否给了。在技能定义的examples字段里放两个输入输出对,模型学习成本大幅降低。类比一下,给程序员API文档和给一段可运行的示例代码,效果完全不同,模型也是这个道理。

4.2 技能返回了错误数据,模型照单全收

更隐蔽的问题是:技能执行本身成功了,但返回数据质量有问题,模型不做校验就直接用作最终答案。比如抽取算法漏了一行数据,模型没发现,直接把残缺结果交给用户。

这个问题的根源是模型默认“技能返回=事实”。要解掉它,我加了两个机制:

一是所有技能返回的data里附带confidence字段,低于阈值时模型必须走“重新执行”或“向用户说明不确定性”的分支。

二是在Agent的决策提示里明确写一句:如果技能返回结果与用户请求明显不符,必须重新调用技能或如实反馈失败原因。别小看这一句,它能明显降低“模型对错误结果将错就错”的概率。

4.3 技能之间的参数传递对不上

多个技能串联时,经常出现前一个技能输出JSON结构,后一个技能期望输入字符串的情况。字段对不上,模型在中间做转换时容易出错。

解决办法是引入“数据契约”的概念:每个技能的returns字段必须注明最终输出结构,下一个技能在parameters里声明这个结构来自上游。我在技能元信息里增加了一个upstream字段,标注这个技能预期从哪些技能取数。

{ "skill_id": "invoice_formatter", "upstream": ["invoice_extractor"], "parameters": { "invoice_data": { "type": "object", "description": "发票结构化数据,来自 invoice_extractor 技能的输出" } } }

编排引擎在组装技能链路时,先静态检查upstream依赖是否满足,不满足就直接报错,而不是等跑起来才发现数据对不上。这一步省了我大量测试时间。

4.4 技能数量膨胀后的维护难题

技能库超过50个以后,管理成本指数级上升。同名技能、旧版本失效、重复实现等问题接踵而至。

我现在做的三个管理动作:

技能命名规范收紧。格式统一为“领域_动作_对象”,如finance_extract_invoice、web_fetch_page、database_query_sales。宁可名字长一点,也要一目了然。

定期做技能去重分析。用embedding把技能描述全部向量化,算两两相似度,相似度超过0.85的技能进review列表,人工判断是合并还是删除。

所有技能必须有负责人。每个技能元信息里的author字段不是装饰品,出问题能第一时间找到维护人。技能长期无人维护且使用量为零的,标记废弃,下个版本移除。

清晰简单、能直接落地的经验大概就是这些。技能体系的建设不是一次性工程,更像是一个持续养成的过程——开始可以只有三五个技能,跑通核心链路、跑顺版本更新机制,再慢慢扩充。早期别追求技能数量多,而是要把每个技能的质量打磨到“描述准确、参数完备、容错可靠”的基准线上。等这个基准建立起来,后续的扩充和维护都会顺畅得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询