如果最近你也在折腾大模型应用,多半和我一样绕不开“agent-skills”这个词。我最早看到“agent-skills”时还以为是某个炫酷的模型能力包,后来动手做了一整套基于技能库(skills)的AI助手才发现,它真正解决的是大模型“只会说话、不会干活”的尴尬:模型负责判断和拆解任务,真正操作文件、请求接口、读写数据库的,都是挂载在Agent上的一个个技能模块。没有技能库,Agent只是一个加强版聊天框;有了技能库,它才像一支真正有“手脚”的执行团队。
这组内容想做的是把一个实用的Agent技能库工程从思路到代码完整拆开,包括技能的定义方式、注册机制、调度策略、权限控制,还有我在实际落地过程中踩过的坑。不管你是刚开始接触Agent开发,还是已经在做自己的自动化工具,这份拆解都能帮你少走不少弯路。
1. Agent技能库的定位:为什么不能只靠Prompt硬扛
1.1 从“生成文本”到“生成行动”:Agent缺的是执行层
先想一个问题:你让模型“帮我把当前目录下的日志文件按时间排序,整理成报告”,它该怎么做到?模型本身不接触文件系统,它只能输出文字。过去大家用Prompt硬扛,在系统提示词里写“你可以调用这些接口:1. 查询文件列表 2. 读取文件内容 3. 写文件”,然后期望模型自己编一段类似函数调用的文本出来。这个玩法在Demo阶段没问题,但真到复杂任务就开始失控。
模型生成文本的天性决定了它很难稳定遵循一套“手写协议”。你可能遇到过模型把参数名写错、把接口路径拼接错、把“调用”和“假装调用”混在一起。本质上,模型是一个“决策器”,不是“执行器”。技能库就是夹在决策器和真实系统之间的执行层:把能力注册成标准模块,让模型以“选技能、填参数”的方式发起动作,并由代码负责真正干活。
用生活里的例子说:模型像大脑,它想“我要喝水”,但倒水这个动作必须由手来完成。技能库就是连接大脑和手的神经接口。你给大脑的不是“你怎么倒水”的废话指令,而是一张清单:“你的手可以执行的动作有:拿起杯子、拧开瓶盖、倒水”,每个动作标注适用场景、参数要求。大脑负责选动作,手负责执行,接口负责把结果传回给大脑。
1.2 纯Prompt方案撑不起Agent的三个原因
我踩过最深的坑就是过度依赖Prompt“万能话术”,最终项目复杂度大到不可收拾。具体说,纯Prompt方案有三个硬伤:
第一,行为不稳定。模型对工具的描述理解是概率性的,今天它可能老老实实把参数写成JSON,明天同样的Prompt下它就开始自由发挥,参数类型全错。尤其当技能数量超过10个之后,模型选错工具的概率直线上升。
第二,能力不可复用。你在这个Agent里写的“发送邮件”Prompt,换个Agent又要重新写一遍。技能逻辑(比如SMTP连接、附件处理)已经写好,但描述、约束、错误处理全杂揉在Prompt里,根本没法拆分打包。
第三,无法观测和管控。模型在对话里“模拟调用”接口时,调用是否成功、参数是什么、耗时多久、用户有没有权限,这些全都不可控。真正生产环境需要的审计日志、白名单、并发隔离,纯Prompt方案完全给不了。
技能库的解法是把“技能的描述文件”和“技能的执行代码”完全分离。模型只看一份结构化说明(技能名、用途、参数Schema、返回说明),而代码负责校验、执行、异常处理、日志记录。这样模型的行为边界被刻意收缩:“你只能从清单里选技能,参数必须符合Schema”,实现了对Agent的工程化约束。
2. 技能库的整体设计与核心机制
2.1 一个技能的本质:三段式结构
我在实际工程里,每个技能都由三部分组成:元信息描述(让模型知道何时该用)、参数Schema(让模型知道怎么填参)、执行函数(真正干活的地方)。三者缺一不可。
元信息描述最重要的是“触发条件”。只写“查询天气”远远不够,要写清楚“当用户需要获取某城市当前或未来几天的天气情况时,使用该技能。该技能返回温度、风力、降水概率。”模型的技能选择很大程度依赖描述文本和用户问题的语义匹配度,描述写得越精确,选错率越低。
参数Schema建议直接用JSON Schema标准。它是一份机器可读的“参数说明书”,既能约束模型填参,还能在代码里做校验。有个额外的好处:很多模型厂商的工具调用接口原生支持JSON Schema,你定义一份Schema就可以直接透传过去,不用重复维护。
执行函数不用什么花哨设计,保持“入参清晰、出参结构化、异常可控”即可。出参最好也固定为JSON格式,方便模型解析后继续推理。如果是图片、文件之类的大块数据,别直接塞回上下文,传一个可访问的路径或者预生成的摘要就够。
2.2 技能注册表:为什么不用一大串if-else
技能多了以后,最忌讳的就是在业务代码里写一长串if-else:“如果是weather就调weather函数,如果是email就调email函数”。这种写法改一个技能要动主流程,加一个技能要改核心代码,排查问题更是灾难。
我用的方案是把技能注册成一个字典映射。启动时扫描技能目录,每个技能文件用装饰器注册自己的名字和执行函数,最后形成一个统一的路由表。模型说“我要调用skill_email_send”,路由表直接定位到对应执行器,把参数传进去就行。整个过程只有“注册”和“查询”,主流程完全不关心某个技能内部如何实现。
这样做有三个直接收益。扩技能时不动主流程,新人加一个技能文件、写一个装饰器、写一个处理函数就能接入;隔离性变好,某个技能出现问题只影响它自己,不至于拖垮整个Agent;权限控制更容易,可以在路由表这一层统一做用户级校验,而不是在每个技能里重复判断。
2.3 调度策略:模型每轮都能看到所有技能吗
这是技能库设计里最容易被忽视的问题。技能数量少的时候,把全部技能描述塞给模型没问题,但技能库一旦扩展到三五十个,描述本身就会占去大量上下文,模型的选择准确率也会显著下降。
我的做法是加一个“技能路由层”:先把技能按领域分组,比如“信息查询类”“文件操作类”“通信通知类”“数据处理类”。当用户请求进来,先由一个大模型或者轻量规则模块判断请求属于哪个领域,再只把该领域的技能描述注入给主Agent。这相当于先给模型划定了“你在这组场景下可以从这些技能里选”,减小选择面,提升稳定性。
另外调度时要注意“技能返回结果”的二次加工。很多技能执行完返回的是原始数据结构,模型需要基于它继续回答用户。所以我在调度循环里做了约定:技能执行完成后,结果会作为一条“工具消息”重新送回模型,让模型用自然语言总结给用户。这里的顺序是“用户提问 -> 模型决策选技能 -> 执行技能 -> 返回结果给模型 -> 模型总结回复”,整个链路由一个循环控制,而不是一次调用搞定。
3. 实操:从零搭建一个可直接用的Skills工程
3.1 项目目录结构与基础基类
我建议按下面的目录结构来组织技能库工程,这套结构我自己用了很久,扩展性很好:
agent-skills/ ├── core/ │ ├── skill_base.py # 技能基类与装饰器 │ ├── registry.py # 技能注册表 │ ├── schema_validator.py # 参数校验器 │ └── executor.py # 技能执行器与调度循环 ├── skills/ │ ├── __init__.py # 技能包入口,自动注册 │ ├── file_search.py # 文件搜索技能 │ ├── web_fetch.py # 网页抓取技能 │ └── database_query.py # 数据库查询技能 ├── routers/ │ └── skill_router.py # 领域路由,选择候选技能集合 ├── config/ │ └── settings.py # 技能白名单、超时、模型配置 └── logs/ └── agent_trace.log # 技能调用流水核心基类skill_base.py里,我定义了一个轻量的技能描述数据结构:
from dataclasses import dataclass, field @dataclass class SkillDefinition: name: str description: str parameters_schema: dict # 可选的示例参数,帮助模型理解 examples: list = field(default_factory=list)然后是装饰器reigster_skill,它做的事是把当前技能加入全局注册表。注册表本质就是一个普通字典,key是技能名,value是SkillDefinition加执行函数。这个设计简单直接,不用引入重量级框架,也方便后续加中间件(日志、权限、限流)。
3.2 手写一个“文件搜索”技能
拿最常用的“文件搜索”技能演示完整流程。先定义描述和Schema:
file_search_definition = SkillDefinition( name="file_search", description="在指定目录下按文件名或扩展名搜索文件。" "当用户需要查找某个目录里的文件、确认文件是否存在、" "或者按类型枚举文件时使用。返回文件路径列表。", parameters_schema={ "type": "object", "properties": { "root_dir": { "type": "string", "description": "搜索的根目录绝对路径" }, "keyword": { "type": "string", "description": "文件名包含的关键词,可为空字符串" }, "extension": { "type": "string", "description": "文件扩展名,例如 .md,可为空字符串" }, "max_results": { "type": "integer", "description": "最多返回多少条结果,默认20", "default": 20 } }, "required": ["root_dir"] }, examples=[ {"root_dir": "/workspace", "extension": ".md", "max_results": 10} ] )接着实现执行函数:
import os def file_search_executor(params, context=None): root_dir = params["root_dir"] keyword = params.get("keyword", "") extension = params.get("extension", "") max_results = params.get("max_results", 20) # 注意:不要用os.walk直接遍历过大目录,设置深度限制 results = [] for dirpath, _, filenames in os.walk(root_dir): # 跳过隐藏目录和虚拟环境目录 if "/." in dirpath or "/node_modules" in dirpath: continue for fname in filenames: if extension and not fname.endswith(extension): continue if keyword and keyword not in fname: continue results.append(os.path.join(dirpath, fname)) if len(results) >= max_results: return {"file_list": results, "truncated": True} return {"file_list": results, "truncated": False}这个执行函数有几个细节值得注意:遍历目录时跳过虚拟环境和隐藏目录可以省掉大量无效IO;返回结果里带上truncated字段,模型看到这个字段就知道结果可能被截断,可以引导用户精准搜索;参数是有默认值的,这样模型偶尔漏填不致命。
注册入口也非常简单:
from core.registry import registry from core.skill_base import SkillDefinition @registry.register(SkillDefinition(...)) def file_search(params, context=None): return file_search_executor(params, context)整个文件写完后,任何技能文件只要被导入过一次,就会自动出现在全局注册表里。主流程完全不知道file_search内部怎么实现,只用注册表查询入口。
3.3 与模型的调用链路打通
技能库落到Agent,最核心的是和模型交互的循环。我现在用的方案是标准的工具调用接口:第一轮把用户问题加上可用的技能描述一起发给模型,模型如果决定需要调用某个技能,会在返回里带有结构化工具调用请求。代码解析这个请求,从注册表里取出对应执行函数,传入参数,拿到结果,再把结果拼进消息列表发给模型继续生成回复。
一个简化版的调度循环如下:
from core.registry import registry def run_agent(user_input, available_skills, model_client): messages = [{"role": "user", "content": user_input}] # 将可用技能描述注入模型请求 tool_defs = [ registry.to_tool_schema(skill_name) for skill_name in available_skills ] for turn in range(5): # 最多允许5轮工具调用 response = model_client.chat(messages, tools=tool_defs) if not response.tool_calls: return response.content for tool_call in response.tool_calls: result = registry.execute( tool_call.function.name, tool_call.function.arguments ) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) return "执行步骤过多,已自动停止"这里的循环上限5轮是为了防止Agent陷入无限调用,实际使用中建议3到8轮之间,具体看任务复杂度。每一轮工具调用都是模型的一次新决策,轮数太高既费token又容易跑偏。
调用链路中一个很重要的细节:工具结果必须以结构化字符串回传给模型,且最好带上“当前是第几步”或者“这个结果正常/异常”的标记。我在很多案子里看到结果是返回了,但模型没意识到自己已经执行过某个工具,于是重复发起同一个调用,浪费大量时间。
3.4 权限与资源控制:技能库能跑起来的底线
技能库只要一暴露给真实用户,权限问题和资源控制就绕不开。我总结下来,至少要做三件事。
第一,技能白名单。用户角色不同,可用技能集合不同。普通用户只能调“文件搜索”“信息查询”,管理员才允许调“删除文件”“执行脚本”。这个控制不是在技能内部做,而是在路由表层面做:根据当前会话的用户级别,过滤一遍可注入的技能描述。
第二,执行超时与资源限制。某些技能可能因为网络问题或数据量过大卡住。我在执行器里统一加了超时控制,单个技能执行上限比如10秒,超过就返回“执行超时”给模型,让模型告知用户或换一种方式。同时记录每次调用的token成本,跑完一个任务可以汇总成本,方便分析Agent是不是过度调用工具了。
第三,不可逆操作二次确认。删除、写入、发送类技能,在定义描述时明确要求“执行前必须向用户确认参数”,并在执行函数里先返回一个“待确认状态”,等用户确认后真正执行。最初我没有这个设计,结果模型根据用户一句“把那些文件都删了吧”真的把所有文件删掉,后来花了一整天才恢复数据。从那以后,所有破坏性技能一律强制二次确认,模型侧也增加相应的Prompt约束。
4. 技能库落地中的高频问题排查实录
4.1 模型总选错技能或者虚构技能名
这是技能库上线后遇到最多的一个问题:请求明明是查天气,模型却调用了一个“获取地理位置”的技能,甚至有时会调用一个注册表里根本不存在的技能名。这个问题的根源通常有三个。
第一是技能描述太笼统,和其他技能的描述边界模糊。比如“get_weather”和“get_location”都提到了“城市”这个词,模型就容易混淆。解决方法是把每个技能的“适用场景”和“不适用场景”都写清楚。给get_weather加一句“只负责查询天气,不要用于查询城市名称或坐标”。
第二是技能数量太多,模型选择空间过大。此时需要加重技能路由层,先把任务分到领域,再让模型在几个候选技能里选。我实测过,同一批30个技能不分组准确率只有70%,分组后准确率能提到95%左右。
第三是模型幻觉,直接编了一个“log_read”这种不存在的技能。这种情况不能只靠Prompt约束,应该在调度循环里做一层“工具名校验”,请求的工具名不在注册表内时,直接返回“未知工具”给模型,并附上当前可用工具名单,让模型重新决策。有了这层保护,幻觉工具名不会产生任何实际动作。
4.2 参数解析失败:模型填了Schema不认的数据
另一类高频问题是参数格式对不上。比如Schema要求max_results是integer,模型填了字符串“二十”;或者漏掉必填字段,导致执行函数直接崩溃。纯靠模型自我纠正很费劲,更可靠的是参数校验加自动重试。
我通常在执行器外层套一个校验函数:
def validate_args(args_json, schema): try: params = json.loads(args_json) except json.JSONDecodeError: return None, "参数不是合法JSON" # 这里用jsonschema库校验 errors = list(jsonschema.Draft7Validator(schema).iter_errors(params)) if errors: return None, "参数不符合要求: " + str(errors[0].message) return params, None校验失败时,把具体错误信息作为工具结果返回给模型,让模型根据错误信息修正参数。我在Prompt里加了一句“认真参考参数Schema,字段名和类型必须以Schema为准”。配合这套做法,参数类错误能减少八成以上。
还有一个实战技巧:给每个技能加上示例参数。模型对示例的依赖比对纯Schema的依赖强得多,示例里出现过的填法,模型更容易模仿。尤其是枚举值字段,比如状态只能是“open/closed”,给一个示例会让模型少犯很多错。
4.3 工具返回结果太大,上下文被撑爆
技能执行结果回传给模型后,模型要把它和用户问题一起处理。如果工具返回的是一份几十万字符的日志,上下文窗口迟早被撑爆。这个问题我在做“代码库分析Agent”时体会特别深。
解决思路是“结果瘦身”:回传给模型的不是原始结果,而是摘要或元数据。文件列表太长就只传前10条加总数;数据库查询结果太大就分页,每页只传必要的字段;网页抓取则用提取算法先去掉导航、广告等噪音,只传正文内容。
更彻底的方案是引入一个“结果摘要技能”。工具执行完原始结果不出来,先进入一个专门的摘要模型,生成一段紧凑的总结,再传给主Agent。这相当于给所有技能加了一层信息压缩。需要保留细节的场景(比如代码修复),则改成把结果写入临时文件,回传文件路径,模型需要详细内容时再调一个“读取文件片段”的技能。
4.4 多用户并发下的状态污染
如果Agent跑在服务端,同一个技能可能同时被多个用户的会话调用到。最容易踩的坑是在技能实例里保存“当前用户”或者“当前任务”的状态。比如一个技能里定义了self.current_dir,A用户设置了路径,B用户再调用时读到的却是A的路径。
这个问题要从两个层面根治。一是技能本身做成无状态,所有输入都靠params传入,执行完不留任何内部状态。二是引入一个上下文对象session,里面存放user_id、当前工作目录、权限级别等信息,执行函数需要情境时从context参数里取,而不是从自身属性里取。我改造后的执行函数签名统一为executor(params, context=None),context由调度循环每次调用时注入,天然隔离不同会话。
另外并发场景要注意日志追踪。我给每次任务生成一个trace_id,所有技能调用日志都挂上这个id。排查线上问题时,只需按trace_id拉出完整的调用链,就能看清哪一步参数填错、哪一个调用超时、模型为什么走偏。没有trace_id之前,排查一个问题要在各个日志文件里来回翻,效率极低。
4.5 技能内部报错怎么反馈给模型
技能执行函数难免抛异常,比如文件不存在、接口返回500、数据库连接失败。最忌讳的是把异常堆栈直接抛给上层,导致整个Agent中断。我在执行器里统一把异常捕获,转换成结构化的错误信息回传模型:
{ "error": "FILE_NOT_FOUND", "message": "目录 /tmp/xxx 不存在", "suggestion": "请检查路径拼写,或先调用 dir_list 查看当前目录内容" }错误信息里附带“suggestion”字段,相当于给模型一个纠偏指引。模型看到之后能直接根据建议调整计划,而不是一脸茫然地让用户“尝试换一个说法”。这个设计极大提升了多步任务的成功率:工具出错不再是终点,而是一个可以被模型消化并修正的中间状态。
5. 技能库维护的经验沉淀与后续方向
技能库用久了,我发现最需要花心思的不是实现,而是治理。技能数量膨胀得很快,一不当心就会积累出一堆描述含糊、功能重叠、连作者自己都不确定该怎么用的技能。我在自己的技能库里沉淀了一个原则:每个技能必须有一个“负责人场景”,宁可让两个场景共用一套底层函数,也不要拆成两个描述相似但边界模糊的技能。加新技能前先跑一遍现有技能列表,能复用旧能力就复用,不能复用再新建。
另一个心得是迭代周期里一定要看“技能调用失败日志”,而不只是看用户反馈。模型经常在悄悄尝试某个技能但调不到,日志会清楚记录这类情况。我定期统计各个技能的调用次数、失败率、平均参数错误数,把这些指标作为技能优化的依据。很多“用户说Agent不太好用”的问题,根源其实是某个技能描述陈旧,功能已经更新了,描述还停留在旧版。
后续我打算在技能库基础上做两层扩展。一层是技能编排能力,让一个技能内部可以调度其他技能,比如“生成周报”技能内部去调用“读取本周任务”和“汇总统计”两个技能,而不是在主循环里硬写流程。另一层是基于用户反馈的自动技能修正,通过收集用户对某一类回答的满意度,反向调整对应技能的描述权重或者参数约束。这条路还很早,但技能库的注册表和观测机制会给它留好接口。技能库从“能跑”到“好用”,差距往往就在这里:一堆技能的堆积和一套被精心维护的能力体系,本质上是两种工程。