Agent技能体系实战:从硬编码到可复用技能层的完整指南
关于“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(encodingutf-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(keylambda x: x[0], reverseTrue) 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_asciiFalse, indent2), encodingutf-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_asciiFalse)[: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(ftimeout {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字段不是装饰品出问题能第一时间找到维护人。技能长期无人维护且使用量为零的标记废弃下个版本移除。清晰简单、能直接落地的经验大概就是这些。技能体系的建设不是一次性工程更像是一个持续养成的过程——开始可以只有三五个技能跑通核心链路、跑顺版本更新机制再慢慢扩充。早期别追求技能数量多而是要把每个技能的质量打磨到“描述准确、参数完备、容错可靠”的基准线上。等这个基准建立起来后续的扩充和维护都会顺畅得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →