☰
Agent技能系统实战:从零搭建标准化技能单元提升多步任务自动化
2026/10/13 5:51:12 网站建设 项目流程

在折腾了大半年Agent相关的东西之后,我越来越笃定一个观点:Agent好不好用,拼的不是模型有多聪明,而是你给它配了什么样的技能系统。这里的“技能”不是说让模型学会某个动作,而是指一套能被模型识别、触发、执行、校验的标准化能力单元。社区里管这套东西叫agent-skills,本质上是把模型之外的工具、流程、知识通通打包成可复用的模块,让Agent在遇到具体任务时能想得起来、拿得出手、跑得完。这篇文章,我就围绕agent-skills把自己从零搭建这套系统的过程、踩过的坑和改进思路完完整整写一遍,适合正在做Agent应用开发、或者研究多步任务自动化的开发者参考。

先说清楚它能解决什么问题。单纯用Prompt塞指令,Agent面对两三个步骤以内的简单任务还行,一旦任务链路变长——比如“读取配置、解析日志、定位异常、生成报告”——模型就开始发挥想象力,步骤跳来跳去,执行结果经常是“看起来对”但根本跑不通。技能系统的作用,就是把这种不确定性收拢到一个个明确、可测试的单元里,让模型只做“决定调用哪个技能”这件事,而不是让它自己去猜测怎么操作文件、怎么调接口、怎么拼SQL。

文章里没有故弄玄虚的架构图和框架包装,只有我实际写过的代码、实际翻车的场景和后续的修补方案。如果你也在做Agent应用,尤其是要让Agent处理真实业务数据而不是陪聊,这篇文章应该能帮你省掉不少试错时间。

1. 为什么给Agent加一套技能系统

1.1 从低成功率翻车说起

我最初做Agent的方式很朴素:写一段很长的System Prompt,把所有工具说明、使用限制、输出格式全部塞进去,然后让模型自己决定怎么干。在Demo阶段确实能跑通,模型会照着说明调用工具函数。但放到真实场景里,问题立刻暴露。

第一是描述膨胀。工具一多,Prompt里光工具说明就占了小一万字,模型的处理能力被大量无关信息稀释。我试过同时暴露二十多个工具,模型在二选一时经常选错,甚至把两个不相关的工具参数交叉使用。第二是上下文污染。每次工具调用返回的完整结果直接塞回对话,下一次调用时模型要在一片混乱的输出里找关键信息,准确性肉眼可见地往下掉。第三是维护地狱。工具函数和Prompt描述是两份东西,改一个函数忘记改描述,模型就会照着旧描述调用,参数对不上,异常处理逻辑只能靠外部兜底。

这些问题的根源都一样:能力没有结构化。工具、描述、参数、执行逻辑全部混在“文本+函数”的松散组合里,模型只能靠语义硬猜。某开发者跟我交流时打了个比方,说这就像让一个新人同时记住一百条工作流程,还不给他看制度手册,全靠悟性。技能系统的本质,就是给Agent一份“制度手册”,每条技能都有清晰的触发条件、输入标准、输出格式和校验规则。

1.2 技能系统到底在解决哪些具体问题

把技能系统拆开看,它真正解决的是四件事。

第一件事是上下文压缩。模型不需要看到工具的全部实现代码和冗长文档,只需要看到一份简短的技能描述。我在实测中把二十多个工具的说明压到了三千字以内,每项技能平均一百字左右,模型的选择准确率反而提升了。

第二件事是能力复用。同一个技能可以被不同任务调用,也可以被编排进不同的流程。比如“读取文件”这个技能,既能在日志分析任务里用,也能在代码审查任务里用,还能在配置检查任务里用。不需要为每个任务重写一遍文件读取逻辑。

第三件事是确定性回归。每个技能都是独立的函数,可以单独测试、单独加日志、单独做异常处理。模型调用出错时,你能立刻看出是模型选错了技能,还是技能本身执行失败,而不是在一个黑盒里猜。

第四件事是扩展性。新增加一个能力,只需要注册一份新技能;移除一个能力,只需要在注册中心摘掉它。Agent的主流程不用动,Prompt也不用动,整个系统可以像插积木一样持续演进。

2. 技能系统的核心概念与架构选型

2.1 技能的最小定义:一份能说清楚的说明书

我最早踩的坑是把技能定义得太“函数化”,光是函数签名和参数类型,模型根本看不懂什么时候该用。后来我逐步摸索,一份合格的技能说明书至少要有六个部分:技能名称、一句话描述、触发场景、参数JSON Schema、执行函数、示例对。

技能名称要短且有区分度。不要用read_file_util_2024这种名字,就用read_file,模型一眼能看出用途。描述是给模型看的,要写清楚“什么时候用、输入是什么、输出是什么”。我见过很多描述写成了功能说明书,比如“读取文件内容并返回字符串”,这等于没写。真正有用的描述是“当用户需要查看或获取磁盘上某个文件的内容时使用,传入路径,返回文件全部内容”。触发场景一定要具体,抽象描述会让模型宁可猜也不调用。

参数用JSON Schema定义,这是技能系统里最容易被忽略的一环。模型需要明确的参数结构,包括每个参数的类型、是否必填、取值范围。我试过让模型自由传参,结果它把路径参数塞进了超时参数里。JSON Schema能极大降低这种错误,因为模型在推理时会按结构去组织参数。示例对是给模型做few-shot参考的,一个“成功调用示例”加上一个“失败调用示例”,模型对技能边界的理解会清晰很多。

2.2 注册中心、调度器、执行器:三个核心组件各管什么

一个最小可用的技能系统,至少要有三个组件配合。

注册中心负责维护技能清单。它是一个字典结构,键是技能名,值是对应的技能对象。技能启动时通过装饰器或手动注册的方式登记进去,运行中还能动态增删。调度器负责接收模型的调用指令,解析出技能名和参数,然后分发到具体技能。它还要处理“技能不存在”“参数校验失败”“技能执行超时”这些异常分支,把错误原样返回给模型,让模型有机会修正调用。

执行器是真正干活的部分,也就是技能背后的普通函数。它不应该感知模型的存在,输入是参数,输出是结构化结果。我在设计执行器时加了一层统一封装,把返回值统一包装成“状态+数据”的结构,调度器只看状态就能判断成功还是失败,不用关心具体业务逻辑。

表格:三个组件的职责边界

组件核心职责错误处理方向
注册中心技能登记、查询、动态增删技能不存在时返回明确错误码
调度器解析调用指令、分发参数、控制重试参数异常时返回可读错误信息
执行器执行具体逻辑、返回结构化结果业务异常时包装成状态码返回

这套结构的好处是边界清晰。模型只与调度器打交道,调度器只与注册中心打交道,注册中心只与技能对象打交道。任何一层出问题,都能快速定位,不会互相牵扯。

3. 从零实现一个Mini技能系统

3.1 技能定义与注册的落地代码

我直接展示一个可以跑的示例代码,这个代码我是在一个某文件管理Demo项目里反复迭代出来的,核心逻辑非常简洁。

from dataclasses import dataclass, field from typing import Any, Callable, Optional @dataclass class Skill: name: str description: str parameters: dict handler: Callable[..., Any] examples: list = field(default_factory=list) tags: list = field(default_factory=list) SKILL_REGISTRY: dict[str, Skill] = {} def register_skill( name: str, description: str, parameters: dict, handler: Callable[..., Any], examples: Optional[list] = None, tags: Optional[list] = None, ): skill = Skill( name=name, description=description, parameters=parameters, handler=handler, examples=examples or [], tags=tags or [], ) SKILL_REGISTRY[name] = skill return skill

注册中心就是一个字典,没有复杂设计。真正需要注意的是参数校验。模型传给调度器的参数经常是字符串类型,和JSON Schema里声明的int类型不一致,所以我在注册中心之外加了一个参数清洗器,把能强转的类型统一转一遍,转不了的报错返回。

def clean_params(schema: dict, raw: dict) -> dict: cleaned = {} errors = [] for prop_name, prop_schema in schema.get("properties", {}).items(): if prop_name not in raw: if prop_name in schema.get("required", []): errors.append(f"missing required param: {prop_name}") continue value = raw[prop_name] expected_type = prop_schema.get("type") if expected_type == "integer": try: value = int(value) except (ValueError, TypeError): errors.append(f"param {prop_name} should be integer") elif expected_type == "string": value = str(value) elif expected_type == "array": if not isinstance(value, list): errors.append(f"param {prop_name} should be array") cleaned[prop_name] = value if errors: raise ValueError("; ".join(errors)) return cleaned

这段代码不复杂,但它解决了一个真实问题:模型输出的参数值常常带着引号、逗号或者多余空格,不清洗就直接执行函数,轻则拿到错误结果,重则执行器内部直接抛类型异常。清洗器把所有参数统一校正后再交给处理函数,异常率明显下降。

3.2 调度器:让Agent按JSON协议调用技能

模型怎么告诉系统它想调用哪个技能?我用的方案是让模型输出一段JSON,格式固定为两个字段:技能名和参数。这种结构化输出比让模型直接执行代码安全得多,也容易做日志审计。

def dispatch(raw_call: str) -> dict: import json try: call = json.loads(raw_call) except json.JSONDecodeError as e: return {"status": "parse_error", "message": f"invalid JSON call: {e}"} skill_name = call.get("skill") params = call.get("params", {}) skill = SKILL_REGISTRY.get(skill_name) if skill is None: return {"status": "skill_not_found", "message": f"unknown skill: {skill_name}"} try: cleaned = clean_params(skill.parameters, params) except ValueError as e: return {"status": "param_error", "message": str(e)} try: result = skill.handler(**cleaned) return {"status": "success", "skill": skill_name, "result": result} except Exception as e: return {"status": "exec_error", "message": str(e)}

调度器返回的状态码,我一开始只用了success和error两种,后来发现排查问题太费劲。加了skill_not_found、param_error、exec_error三种细分状态后,定位问题的时间至少缩短了一大半。日志里扫一眼状态码,就知道问题出在调度环节还是执行环节。

这里还要提醒一个关键点:模型调用技能后,调度器返回的结果会重新进入模型上下文,所以返回内容千万不能太大。如果技能返回一个很大的列表,模型下一轮推理会被无关信息带跑偏。我会在技能执行结果外套一层摘要,比如返回文件时只返回前几行和行数,完整内容写到临时文件,把文件路径作为结果的一部分返回。这样上下文里永远只保留“小而关键”的信息。

3.3 一个可直接用的文件管理技能包

理论讲太多容易飘,我直接给一个可以抄作业的文件管理技能包。它包含三个技能:读文件、写文件、目录搜索。这三个技能在自动化任务里使用频率非常高,而且踩坑案例典型。

import os from pathlib import Path def read_file(path: str, max_lines: int = 50, tail: bool = False): p = Path(path) if not p.exists(): return {"error": f"file not found: {path}"} if p.is_dir(): return {"error": f"path is a directory: {path}"} with open(p, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() total = len(lines) if tail: lines = lines[-max_lines:] else: lines = lines[:max_lines] return {"total_lines": total, "showing": len(lines), "content": "".join(lines)}

读文件这个技能,我的经验是永远不要无限制全量读。大文件读进来,模型上下文直接爆炸。默认最多返回50行,完整信息通过额外参数控制,这算是一种防御式设计。

写文件的技能也需要做边界控制。我规定只能写项目根目录下的文件,路径必须先从相对路径转绝对路径,再用Path.resolve校验前缀,防止越权写系统目录。这个保护不能省,因为模型一旦被注入恶意指令,写文件技能就是最大的破坏入口。

BASE_DIR = Path(__file__).resolve().parent def write_file(path: str, content: str): p = (BASE_DIR / path).resolve() if not str(p).startswith(str(BASE_DIR)): return {"error": "path outside allowed base dir"} p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content, encoding="utf-8") return {"written": str(p), "length": len(content)}

目录搜索技能实现也不复杂,但注意要限制搜索深度和最大返回条数,防止模型在超大目录树下递归到卡死。

三个技能注册后,我在测试里让模型执行“在项目里找一个包含error关键字的日志文件,然后复制到output目录”。模型先调用目录搜索,返回候选文件列表,再调用读文件查看内容,最后用写文件完成复制。整个流程不需要任何硬编码脚本,完全靠技能系统跑通。这就是agent-skills的价值,模型只负责决策,每个决策都落在一个确定性执行单元上。

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

4.1 Agent就是不调用技能,怎么救

这个问题出现频率极高。技能注册得很完整,函数都能单测通过,但模型在对话里就是不用,非要自己硬编一段答案。我排查这类问题的经验顺序是固定的。

先看技能描述是否足够具体。描述里如果只写“读取文件”,模型不知道什么时候该用。我改成“当用户需要查看文件内容、检查日志、读配置项时使用”,触发场景清晰后,调用率明显上升。再看参数结构是否简单。参数越多、越复杂,模型越倾向于回避。能合并的参数尽量合并,能设置默认值的尽量设置默认值。

然后看是否有示例。一个技能哪怕描述写得好,模型初次见到还是容易犹豫。我给每个技能配一组示例后,调用率提升了三成以上。示例要让模型直接模仿,所以格式要和真实使用的输入输出完全一致。最后检查技能列表长度。单轮对话暴露给模型的技能不要超过十五个,超过后选择准确率会断崖式下跌。如果技能很多,就采用分组策略,先让模型选“能力域”,再在里面选具体技能。

4.2 执行结果浪费上下文,怎么压缩

技能执行成功后,返回值如果直接全部进入上下文,相当于把一段外部世界的数据原样塞进了模型的短期记忆。短期记忆容量有限,塞满之后模型就会“忘记”最初的任务目标。

我的解法是分级结果摘要。第一层,技能内部做裁剪,比如列表只返回前十条;第二层,调度器再做包装,将结果转成简短的JSON结构,删除所有不影响语义的字段;第三层,在Prompt层面告诉模型“技能结果只看关键字段,不要重复全量内容”。三层下来,上下文占用能减少百分之六七十,任务完成率和稳定性都会提升。

另一个技巧是把大块内容放到外部存储,技能只返回一个引用ID。模型需要时再通过引用技能按需取回。这种“浅引用”模式非常适用于处理代码库、文档集、日志文件这些数据密集型场景。

4.3 技能太多之后,调度准确性怎么保证

技能数量从几个增长到几十个之后,即使每个技能的描述都写得很好,模型的选择也会开始飘。我试过在一个Demo里注册四十多个技能,模型的选择准确率下降到惨不忍睹。

解决办法是给技能分组,引入两层调度。第一层只向模型暴露“能力域”,比如文件操作、数据处理、网络请求、流程控制,每个域下一组技能。模型先选域,选完后再展示域内技能。这样每次的候选技能数量都控制在合理范围内,准确率锐减问题基本消失。

我还会给技能加权重和别名。常见的技能权重调高,命中的优先排序;容易混淆的技能加上别名提示,让模型在看到描述时更容易做区分。比如“search_files”和“find_in_files”容易混淆,我把后者改名为“grep_in_files”,同时别名字段写明“如果是查找文件名称用search_files,如果是查找文件内容用grep_in_files”,模型就不容易搞错了。

表格:技能调度准确率优化的关键调整

调整手段适用场景实测效果
精简单轮暴露数量技能总数30个以上准确率从不足六成恢复到八成左右
分组两级调度技能跨多个能力域单轮决策压力显著降低
示例对补充新技能首次接入时调用率提升约三成
重名和别名校验功能相似的技能并存时混淆调用基本消失

5. 进阶玩法:技能编排与让Agent自己写技能

5.1 把多个技能串成Pipeline

当技能数量到一定规模,我开始不满足于“让模型随机应变地调用”,而是希望它能按固定流程执行重复性任务。这就引入技能编排,也就是Skill Pipeline的概念。

最简单的编排方式是顺序执行:技能A的返回结果作为技能B的输入参数。我实现了一个轻量的编排器,用规则文件描述流程,模型只负责填充流程中的参数,不负责跳步。比如“日志分析”流程,固定是先搜索文件,再解析内容,最后生成摘要。

{ "pipeline": "log_analyze", "steps": [ { "skill": "search_files", "output": "matched_files" }, { "skill": "read_file", "input_from": "matched_files", "output": "file_content" }, { "skill": "summarize_log", "input_from": "file_content", "output": "report" } ] }

编排的好处是确定性大幅提升。任务哪怕涉及十几个技能,每一步都执行在可控的轨道上,不会出现模型天马行空的跳变。坏处是灵活性降低,所以我只对高频固定流程做编排,其他场景仍然让模型自由调度。实测下来,固定流程用编排,探索性任务用自由调度,整体效果最好。

5.2 让Agent自己写技能

进阶玩法里最让我兴奋的部分,是让Agent具备“自定义新技能”的能力。它不是人类预先写好的,而是Agent在运行过程中,通过分析用户需求,动态生成并注册一个新技能。

实现思路是给Agent暴露一个“创建技能”的特殊技能。它的执行逻辑是接收一个符合规范格式的Python函数源代码和描述,将源码保存到技能目录,然后在注册中心动态加载并注册。整个过程加上一层校验:新技能必须通过一个非法参数测试和一个边界输入测试,校验不通过就自动回滚。

这个功能在真实场景中非常实用。比如某次用户想让Agent生成一个特定格式的报表,内置技能没有覆盖。Agent可以先分析报表格式,生成一段处理函数,注册成新技能,之后再次遇到同类任务就能直接调用,不再需要从头推理。这相当于Agent拥有了可持续进化的能力。

但要注意,动态生成代码的风险必须严格管控。我的经验是四条底线不能破:只能在沙箱目录里创建文件,不允许访问网络;技能源码必须经过本地AST静态检查,禁止导入危险库;注册前必须跑一遍单元测试;所有自动生成的技能都打上“auto_generated”标签,方便后续审计。

我在实际使用中发现,让Agent自动写技能不能一上来就放开权限。最早我让它注册技能,它直接把一段有文件删除逻辑的代码注册成功了,还好沙箱目录隔离了真实文件系统,不然就出大问题。从那之后,我坚持所有自动注册技能必须经过校验流程,宁可让创建失败多一点,也不能让不可控代码混入技能库。

跨界扩展一下,这个机制用到业务报表自动化上效果很夸张。有一次我让Agent分析一份销售数据,它发现需要“按地区聚合后计算环比变化”的能力,然后自动生成并注册了这个技能。之后所有同类报表需求,它都直接调用新技能完成,速度和准确性比之前靠Prompt临时推理好太多。让Agent具备可持续进化的技能库,是我做这套系统以来觉得最大值的投入方向。

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

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

立即咨询