1. 为什么Agent需要一套独立的技能库
1.1 从一次四处打补丁的开发说起
年初我在某个内部项目里负责给多个LLM Agent叠加功能,做着做着就发现不对劲。三个不同的Agent都在处理同一种“从网页中提取结构化字段”的需求,但每个Agent的提示词里都塞了一长串互相矛盾的指令,参数格式也完全不一致。A Agent用JSON返回,B Agent用自然语言拼接,C Agent干脆直接报错。每次加新功能,我都要同时改动三四处代码,测试用例还总是漏掉其中一两个。最气人的是,提示词越长,模型输出的稳定性越差,很多规则在长上下文里被模型选择性遗忘。
后来我实在忍不了,把所有通用能力从Agent的提示词里剥离出来,单独做成一个叫agent-skills的技能库项目。思路很简单:每个Agent不再重复写功能细节,而是声明自己需要哪些技能,技能库负责技能的注册、加载、调用和评估。这个项目后来在我们内部演变成了一个事实上的标准,你也可以把它理解成给Agent准备的“工具箱”和“使用说明书”的合集。
1.2 技能和提示词的根本区别
很多人觉得技能不就是一段提示词模板吗?我在做agent-skills之前也这么想,但实际跑下来发现区别很大。
提示词是写给模型看的,它没有固定的执行边界,模型可以自由发挥。而技能应该是一个“可执行的独立单元”,它至少要包含:元信息(名字、描述、版本)、参数定义(要什么输入)、执行逻辑(怎么处理)、测试用例(怎么验证)。这四样缺一不可。提示词只是技能内部执行策略的一部分,真正的技能应该像调工具一样被调用,输入合法参数,返回结构化结果,出错时还能抛出让调用方理解的异常。
举一个生活化的例子:你把“计算快递运费”直接写进客服系统的提示词里,模型会根据自己的理解乱算;但如果你把“计算快递运费”做成一个技能,定义清楚起止地、重量、体积、折扣参数,然后让模型去调用这个技能,结果就可复现多了。agent-skills的核心主张就是这样:可复用的能力不应该埋在提示词里,而应该抽出来单独管理。
2. agent-skills的技能四件套:从定义到验收的每个环节
2.1 元信息:让Agent和人都能一眼看懂
技能元信息是整个技能库的索引,也是后面自动路由的基础。我在项目里给每个技能定义了一个稳定的头部结构,类似这样:
name: fetch_web_fields description: 从网页HTML或Markdown中提取指定字段,返回结构化JSON。 version: 1.2.0 author: internal-platform-team tags: [web, extraction, scraping]这里有个容易被忽略的点:description必须写清楚这个技能“能做什么”和“不能做什么”,最好带上一到两个典型使用场景。比如我见过有人写“处理网页数据”,这种描述太模糊,Agent根本无法判断该不该调用它。后来我们规定description至少包含“输入来源、输出格式、典型场景、边界限制”四个要素,比如:
description: > 从网页HTML或Markdown中提取字段列表指定的内容。 输入要求:原始的网页文本或干净的HTML。 输出:JSON对象,key为字段名,value为提取结果。 典型场景:商品价格抓取、新闻标题提取。 不能处理:需要登录态的页面、动态渲染的JS内容。这样的描述,模型在few-shot情况下能用,关键词检索也能命中,后续接embedding语义检索效果也好得多。
2.2 参数定义:先定契约再写代码
我的第二个习惯是:一个技能在写执行逻辑之前,先把参数契约用JSON Schema定义好。agent-skills里每个技能都有一个schema.json,描述它允许哪些参数、必填哪些、值的类型和范围。
{ "type": "object", "properties": { "url": { "type": "string", "format": "uri", "description": "目标网页地址" }, "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "需要提取的字段名称列表" }, "timeout_sec": { "type": "integer", "default": 10, "minimum": 1, "maximum": 60 } }, "required": ["url", "fields"] }参数定义的过程,其实是把模型的“自由发挥空间”压缩到可控范围。有了这个schema,模型在选择调用技能时,就能按照格式生成参数。我们训练Agent时还发现,参数定义越细,模型第一次调用成功的概率越高,因为模型不需要去猜参数名和单位。如果技能自己内部还要做单位转换,那也要在schema里明确说明默认单位。
2.3 执行逻辑:把任务拆成可测试的小函数
我习惯把每个技能的执行逻辑分成两个部分:预处理器和执行器。预处理器负责把LLM传来的杂乱参数规整成标准格式,执行器只负责做真正的计算。
比如一个“调用内部汇率接口”的技能,预处理器可能要去掉货币符号、统一币种代码;执行器只负责请求接口、解析响应、返回固定结构。预处理器里我会写很多防御逻辑,因为LLM生成的参数总有不按常理出牌的时候。有一次模型把超时时间传成了字符串"10s",预处理器没处理好,整个技能报错,Agent也坏了。从那以后,所有数值参数都要经过类型校验和白名单校验。
执行器本身也要遵守单一职责。不搞一个大函数塞满全部逻辑,而是拆成几个内部私有方法。且每个私有方法最好都能单独测试,因为技能库越往后越庞大,不拆分根本没法维护。
2.4 测试用例:没有验证的技能等于定时炸弹
agent-skills下的每个技能都要求至少带三个测试用例:正常输入、边界输入、异常输入。正常输入验证主流程,边界输入验证极端情况,异常输入验证错误处理。
我举个例子。技能“计算折扣价”的正常输入是原价100、折扣0.8,期望输出80;边界输入是原价0、折扣1,期望输出0;异常输入是折扣大于1或小于0,期望抛参数错误。这些测试用例除了用于开发联调,更重要是接入CI/CD做回归测试。每次有新的技能版本,都要跑一遍所有依赖方的测试,防止改坏别的Agent。
测试用例还有个作用——作为Agent的few-shot示例。我经常从测试用例里挑一两个典型输入输出拼进技能描述里,帮助模型快速理解怎么调用。这比单独写一大段调用说明文档有效得多。
3. 搭建agent-skills的目录结构与加载机制
3.1 目录即知识库:技能如何组织才能不失控
技能多了以后,最头疼的就是找不到东西。我在agent-skills里采用了按领域分目录、按技能名建包的方式:
agent-skills/ ├── registry.json ├── skills/ │ ├── web/ │ │ ├── fetch_web_fields/ │ │ │ ├── METADATA.yaml │ │ │ ├── schema.json │ │ │ ├── execute.py │ │ │ └── tests/ │ │ └── html_to_markdown/ │ ├── math/ │ │ ├── calculate_discount/ │ │ └── currency_convert/ │ └── nlp/ │ ├── summarize_text/ │ └── extract_keywords/registry.json是一个总的索引文件,记录每个技能的名称、路径、版本、支持的语言运行时。加载器启动时读这个索引,然后按需导入对应技能模块。这样技能库启动很快,没有“加载所有技能”的浪费。
3.2 技能注册:两种方式我都踩过
技能注册我试过两种方案。第一种是自动扫描目录,通过METADATA.yaml里的name字段识别技能。好处是新增技能不用改配置,坏处是一旦有格式错误,整个扫描可能失败,排错很痛苦。第二种是显式在registry.json里登记,新增技能必须手动加一行。好处是可控,坏处是人容易忘。
最后我选择了一个折中:扫描目录生成候选技能列表,但在启动时和registry.json做diff,不一致就给出warning。如果是正式环境,diff结果会写入日志,让负责人确认。这种“半自动注册”在团队协作里最省心。
3.3 动态调用与上下文注入:怎么让Agent觉得技能是“原生能力”
加载机制搞定以后,核心问题变成:Agent怎么调用技能?尤其是多个Agent共享同一套技能库时,不能让Agent记住所有技能的细节。
我在agent-skills里实现了一个简单的技能代理网关,暴露统一的接口:
async def invoke_skill(skill_name: str, params: dict, timeout: float = 10.0): skill = loader.load(skill_name) validated = skill.schema.validate_and_normalize(params) if hasattr(skill, "preprocess"): validated = skill.preprocess(validated) result = await skill.execute(validated) return skill.postprocess(result)Agent这边,只需要在系统提示词里放一份“技能清单”,每项技能包含名称和一句话描述。真正要执行时,模型通过function calling机制选一个技能名,填入参数,然后网关负责真正调用。这个设计让Agent和技能库之间彻底解耦,新增技能完全不用改Agent的提示词——只要把技能清单更新一下就行。
上下文注入方面,我建议不要把整个技能文档塞给模型。你可以把“运行时环境信息”(比如用户所在时区、业务线ID)放在一个全局上下文中,技能执行时自动从上下文里取。比如汇率转换技能需要知道基准币种,全局上下文里有默认值。这样既减少了参数传递的复杂度,也让跨技能共享数据变得容易。
4. 让Agent自动选择技能:路由策略与冲突处理
4.1 从关键词匹配到语义路由:小步快跑的迭代过程
技能库只有个位数技能时,靠函数调用里的description就能让模型选对。等技能数量超过三十个,单靠description就经常选错。尤其当两个技能描述相似时,模型容易混淆。
我先尝试了基于关键词的检索路由。给每个技能挂几个关键词标签,然后把Agent的意图做简单的文本匹配。比如用户问“把这段文字摘要成三句话”,命中“摘要”“文本概括”等词,就路由到summarize_text技能。这个方法上限很低,用户换个说法就失配了。
后来我换成了embedding语义向量。把所有技能的description离线编码成向量,存到向量数据库里,实时把Agent的当前任务描述也编码成向量,计算余弦相似度,取top5作为候选技能,再交给LLM最终选择。这招很稳,即使用户表达跟技能描述字面完全不同,也能找对。
4.2 冲突消解:两个技能都想接同一个任务怎么办
没有冲突管理的路由一定会出事故。比如“查询订单状态”和“查询物流轨迹”这两个技能,在语义上高度接近,很多用户的表述两者都沾边。我的处理策略是给每个技能增加一个权重和适用范围字段,当候选技能相似度差距小于0.05时,优先使用有更高先验权重的技能,并且会向Agent返回一个备选列表让模型判断。
更重要的是,在技能本身写清楚边界。物流轨迹技能的description里明确写“仅处理快递运输节点信息,不含订单支付状态”;订单状态技能里也写“不管物流详情”。冲突自然减少了很多。还是要靠清晰的契约去消除模糊。
4.3 回退与人工兜底:不能让Agent卡死在一个错误选择上
路由不可能100%正确。agent-skills里我设计了一个回退机制:当某个技能执行失败连续两次时,网关会自动重新路由到候选技能列表中的下一个。如果所有技能都失败,就会把原始请求转交给一个兜底策略,可以是人工客服队列,也可以是默认回复模板。
回退机制的关键是记录失败原因。每个技能的异常都会转成标准化的错误码,比如PARAM_INVALID、EXECUTION_TIMEOUT、REMOTE_SERVICE_UNAVAILABLE。有了标准错误码,路由层才能正确决定是重新尝试、换技能还是转人工。我见过太多人忽略这层设计,最后Agent一报错就露出满屏堆栈,用户体验极差。
5. 技能的评估与持续迭代:不是写完就完了
5.1 构建基准测试集:把高发场景固化成测试样本
技能库做了一半时,我发现最缺的不是代码,而是评估标准。没有评估标准,你根本说不清“更新了技能后它是不是变聪明了”。我借鉴传统软件测试的思路,构建了一个基准集,里面包含三类样本:
- 真实历史请求:从生产日志里脱敏抽取的成功/失败请求
- 人工构造的对抗样本:故意用模糊、口语化、带错别字的表达
- 边界组合样本:参数越界、缺失必填项、超长文本
这个基准集每个季度做一次更新,补充新出现的请求模式。在agent-skills里,每个技能目录下的tests子目录就是基准集的一部分,运行pytest即可一键回归。某个技能改动后,必须通过全部相关测试才能合并到主线。
5.2 性能指标:不只盯着准确率
准确率当然重要,但评估Agent技能不能只看这个。我重点关注三个指标:
| 指标 | 含义 | 常用阈值 |
|---|---|---|
| 首次调用成功率 | Agent第一次调用技能就成功拿到合法结果的比例 | ≥ 90% |
| 平均调用延迟 | 从Agent发出调用到收到结果的时间 | ≤ 2s |
| 无效参数率 | 参数校验未通过的比例 | ≤ 5% |
第一次调用成功率这个指标特别能反映技能描述写得清不清楚。如果模型经常漏传必填参数,说明技能参数设计太复杂或描述不够准确。无效参数率高,就要重新审视schema里的字段命名——我曾经把字段名从product_quantity改成qty,无效参数率立刻涨了10个百分点,后来又改回来了,这给我一个教训:字段名保持一致性和可猜测性比省几个字符重要得多。
5.3 版本管理:技能更新不能影响正在运行的Agent
技能的版本管理我用了语义化版本号,主版本号变更代表不兼容的更新。在registry.json里,每个技能记录当前版本和兼容版本范围。Agent调用时会声明它接受的版本范围,比如fetch_web_fields>=1.0.0,<2.0.0。这样技能库升级时,老Agent不会立刻被破坏。
同时,我坚持给每个技能更新写CHANGELOG文件。大到一个执行逻辑重构,小到一个description措辞优化,都记录下来。因为Agent的决策非常依赖描述文本,某个词语变化可能影响路由效果,所以必须能追踪到哪次改动导致哪个指标发生了变化。
6. 做agent-skills这一路踩过最深的几个坑
6.1 技能参数设计得过于“聪明”,反被聪明所误
第一版技能库里,我把“计算快递运费”的参数设计得特别灵活,允许传package_info对象,里面可以嵌套重量、体积、件数、是否加急等等。本意是让Agent能自由表达,结果模型根本不会正确嵌套,经常漏掉关键字段。后来我把参数拍扁成扁平结构:weight_kg、length_cm、width_cm、height_cm、urgent。奇怪的是,模型的表现立即提升了。
这让我意识到:给LLM使用的参数schema,越简单越好。不要用深层嵌套对象,不要用复杂的条件必填逻辑。每多一层嵌套,多一个条件组合,模型出错的概率就指数上升。宁可多定义几个并列参数,也不要用对象套数组套对象。
6.2 技能执行结果没有结构化,连锁反应很严重
初期有些技能直接返回模型的原生文本,比如“计算成功,结果为100”。下游解析就靠字符串匹配,一换说法就断。后来我统一要求所有技能返回JSON,结构如下:
{ "status": "success", "data": { ... }, "meta": { "skill_name": "calculate_discount", "version": "1.2.0", "latency_ms": 34 } }这一步是最值得做的工程投入。有了统一的返回结构,路由层可以读取meta做监控,调用方可以安全解析data。更重要的是,Agent拿到结构化返回后,能直接把结果转化为用户友好的回复,比自己二次解析可靠得多。
6.3 错误信息写得太隐晦,Agent看不懂也没法转述
技能调用失败时,错误信息不能只给工程师看,Agent也需要读懂。早期我直接抛异常“IndexError: list index out of range”,Agent拿到后只能干瞪眼。后来我强制每个错误都包成可读的英文短语,比如:
SkillError: Field "price" not found in the provided HTML. Check if the page structure has changed or if the field name is correct.原因写得具体,还带建议。Agent拿到这个信息,能自己决定是换参数重试还是换个技能,也能给用户一句合理的解释。这一步对体验的提升超出预期,因为用户看到的是“没有找到价格字段,可能是页面结构变了”而不是“系统报错”。
6.4 权限和审计:技能库越大,越需要管控
最后说说安全和权限。技能库里有些技能涉及内部数据或写操作,不能给所有Agent用。我在技能元信息里增加了一个access_level字段,取值可以是public、internal、restricted。网关在invoke_skill时校验调用方的权限token。这一步看起来简单,但没有它,等到技能库超过五十个技能时,你会完全失控。
审计日志也是必做的。每个技能调用都记录下Agent的ID、调用的技能名、参数摘要、返回状态和延迟。这些日志在排查问题时非常有价值,也直接反哺了基准测试集的构建——跑得越多,评估样本越丰富。
回到开头那个问题:Agent需要独立技能库吗?我现在可以坚定地回答,需要,而且非常需要。把能力和提示词分离后,Agent的稳定性明显提升,迭代速度也快了。后续我计划在agent-skills里加入技能依赖关系的声明,让一个技能能显式地调用另一个技能,形成技能组合。这条路还很长,但方向是对的。如果你也在做Agent应用,不妨从最小化的技能库开始,把公共能力沉淀下来,你一定会感谢这个决定。