☰
把技能当作第一公民:Agent结构化技能管理实战解析
2026/9/25 17:25:46 网站建设 项目流程

2. 项目定位:为什么我把“技能”当成Agent的第一公民

先说个我自己的观察。我做过好几个基于大模型的Agent项目,早期最头疼的问题不是模型不够聪明,而是“模型拿着40个工具,却经常选错、乱调、甚至把参数塞得乱七八糟”。后来我把注意力从“堆工具”转到“管技能”,事情才有了质的变化。

“agent-skills”这个项目就是干这件事的:它是一套结构化的Agent技能管理方案,核心思想是把每个能力(比如查天气、读PDF、写SQL、发邮件)从零散的函数调用,封装成带有名称、描述、输入输出Schema、调用约束、示例的“技能包”,再通过一个轻量级调度层让大模型按需加载、按约束执行。

这套方案解决的核心问题有三个:

  • 选择困难:工具一多,模型分不清“查天气”和“查温度趋势”有什么区别,技能描述写清楚后,选择准确率明显提升。
  • 上下文爆炸:把几十个工具的完整说明全塞进系统提示词,几千个token就没了。技能库只把“当前任务相关的3~5个技能”注入,省下的空间全留给上下文。
  • 复用性差:以前每个项目里都有一份“处理时间格式”的函数,换个项目又要重写。技能包可以独立维护、跨项目复用,这才是“资产”该有的样子。

适合看这篇内容的人包括:正在用LangChain、AutoGen、Claude或自研框架搭Agent的开发者,被“工具调用不稳定”折磨的AI应用工程师,以及想给自己的Agent做一套可扩展能力体系的架构师。如果你想做的只是“调一次API的demo”,那这篇文章对你可能偏重,但如果你想让Agent真正稳定地干活,这套思路值得看完。

3. 整体架构:分层、解耦、可插拔

3.1 技能库的三层结构

我把整个agent-skills拆成三层,每层只干一件事,层与层之间用标准接口连接,这样替换任何一层都不会影响其他层。

第一层是技能定义层。这一层是一堆目录和文件,每个技能对应一个文件夹,里面包含SKILL.md(技能说明书)、schema.json(参数规范)、run.py或run.js(执行脚本)、examples/(示例集)。这一层是给人看的,也是给模型“读说明书”用的。它的关键点是“自包含”——一个技能文件夹从另一台机器clone下来就能跑,不依赖全局状态。

第二层是技能装载层。这层是一个服务,负责扫描技能目录、解析元数据、建立索引,并根据当前任务把相关技能注入到模型的上下文里。你可以理解成“技能版的搜索引擎”:输入是用户请求,输出是3~5个最匹配的技能包。它还要做版本管理,比如某个技能升级了参数格式,旧调用方不必立刻改代码。

第三层是执行与编排层。模型决定调用哪个技能后,这一层负责校验参数、执行脚本、处理超时和错误,并把结果格式化后回传给模型。如果任务需要多个技能协作(比如“读取邮件里的PDF,提取表格,然后生成周报”),还由这一层做顺序编排。

我之所以坚持分层,是因为在实际项目里吃过“一锅端”的亏:最早把所有工具逻辑写在一个Agent类里,结果每次加一个新能力都要动核心代码,回归测试跑一轮,心累。分层之后,“加技能”变成“丢一个文件夹进去”,核心调度逻辑几个月都不用改一次。

3.2 为什么不用“全量注入”而用“动态装载”

有人会问:直接把所有技能描述拼进system prompt不就行了,何必搞一个装载层?我在一个中等复杂度的项目里实测过:40个技能,每个技能描述平均200字,加上参数Schema和示例,光工具说明就接近1.2万token。模型每次请求都要“读”一遍这些内容,响应时间增加30%以上,而且因为信息过载,工具选择准确率反而会掉5~8个百分点。

动态装载就是针对这个问题做的优化。它分两步:先用轻量级规则或embedding把100个技能缩小到5个候选,再把5个候选的完整说明注入上下文。这一步在工程上像“检索增强生成(RAG)”,但检索的不是知识文档而是“能力说明书”。

具体做法:每个技能在元数据里维护一组关键词和场景标签(比如["pdf", "表格提取", "OCR"]),任务进来后先做一次关键词匹配,再取embedding相似度Top K,两张榜单做加权融合。这个策略我用了很久,实测能把工具选择准确率从87%提到96%左右,代价只是增加了一次向量检索,耗时约20毫秒,完全可以忽略。

3.3 目录结构的落地参考

下面是我在实际项目里用的目录结构,你可以直接抄:

agent-skills/ ├── skills/ │ ├── pdf_table_extractor/ │ │ ├── SKILL.md │ │ ├── schema.json │ │ ├── run.py │ │ └── examples/ │ │ ├── input_sample.pdf │ │ └── expected_output.json │ ├── sql_generator/ │ │ ├── SKILL.md │ │ ├── schema.json │ │ ├── run.py │ │ └── examples/ │ └── email_sender/ │ ├── SKILL.md │ ├── schema.json │ ├── run.py │ └── examples/ ├── loader/ │ ├── indexer.py │ ├── retriever.py │ └── schemas.py ├── executor/ │ ├── runner.py │ ├── validator.py │ └── error_handler.py └── tests/

skills/目录每一级都比较浅,“一个技能就是一个小项目”的体感非常舒服。loader/和executor/是通用代码,不感知具体技能,这也是“可插拔”的保证。

4. 技能元数据设计:让模型“看懂”技能的关键

4.1 元数据字段与作用

技能说明书SKILL.md是整个技能库的灵魂,我建议至少包含以下几个字段,缺一个都可能在实际调用时出问题:

字段作用我的建议
name技能唯一标识用蛇形命名,如pdf_table_extractor,不要用带空格的短语
description技能功能的自然语言描述写“能做什么、不能做什么、典型场景”,控制在150字以内
input_schema参数的JSON Schema严格定义类型、必填项、取值范围,模型靠它做参数补全
output_format输出结构定义明确返回的是JSON、文本还是文件路径
error_codes常见错误码说明让模型在技能失败后能自己决定“重试”还是“换方案”
permissions需要的权限声明如network: yes、file_read: whitelist、email_send: confirm
examples1~3个典型调用示例这是模型学习的“少样本样例”,强烈建议写

description和examples的重要性经常被低估。我见过不少项目,description只写一句“处理PDF”,结果模型在需要“PDF转图片”时也去调它,因为转图片的诉求被模型“脑补”成了PDF处理。把边界写清楚,反而能减少错误调用。

4.2 Schema定义的两个原则

第一原则是参数宁少勿多。模型在生成JSON时,参数越多越容易出错,尤其是嵌套对象,很容易少个括号或多一个没定义的字段。我通常把一个技能的参数控制在5个以内,超过5个就考虑拆成两个技能。实在拆不掉的,用properties里的default值兜底,而不是全部做成必填。

第二原则是枚举值写清楚。比如“输出格式”参数,如果允许的值是["json", "markdown", "csv"],那就必须在Schema里写死枚举,而不是在描述里写“根据用户需要输出”。模型对自由发挥的字段往往把握不准,枚举能帮它锁定选择。

下面是一个简化版的schema.json示例,字段不长,但关键信息都在:

{ "name": "pdf_table_extractor", "description": "从PDF文件中提取表格数据,支持扫描件OCR,返回结构化JSON。仅适用于表格型PDF,不适合提取正文段落。", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string", "description": "PDF文件的本地绝对路径" }, "page_range": { "type": "string", "description": "页码范围,如'1-3'或'2',默认全部" }, "ocr": { "type": "boolean", "description": "是否启用OCR识别扫描件", "default": false } }, "required": ["file_path"] }, "output_format": { "type": "array", "items": { "type": "object", "properties": { "page": { "type": "integer" }, "table_index": { "type": "integer" }, "headers": { "type": "array" }, "rows": { "type": "array" } } } }, "examples": [ { "request": "提取 report.pdf 第2页的表格,并识别扫描内容", "response": { "file_path": "/data/report.pdf", "page_range": "2", "ocr": true } } ] }

这里有个细节:examples里的request是“用户原话的样子”,response是“技能应该接收到的参数”。模型的少样本学习恰恰就是靠这种配对学到的——它会把“用户口语”映射成“结构化参数”。如果少了这一步,模型经常把用户原话直接塞进file_path。

4.3 技能描述的“边界写作法”

写description这件事,我踩过不少坑,总结出一个“边界写作法”:先写能做什么,再写不能做什么,最后写典型场景。

举个对比:

  • 差:处理PDF文件
  • 好:从PDF中提取表格,支持OCR。不适用于纯文本提取、不适用于图片转PDF。典型场景:财务报表、学术论文数据表、扫描件表格提取。

前一种描述下,模型遇到“帮我把这个PDF的文字提取出来”也可能调它,然后返回一个空结果;后一种描述里,模型知道“提取正文文字”应该找别的技能,即使库里没有,它也会明确告诉用户“没有这个能力”,而不是硬调一个做错的。

这个写法损耗很小,收益却很大。我在一个客服场景的Agent里应用后,工具误调用率降低了约60%。

5. 核心实操:从零搭建一个可用的技能库

5.1 环境准备与基础框架选择

我实际用的技术栈是Python 3.11 + FastAPI + LangChain(仅用它的工具调用协议),但这里要强调:agent-skills的核心是协议设计,不是某个框架。你用Node.js、Go也能做,只要把SKILL.md和schema.json的解析、注入、执行逻辑理顺就行。我用FastAPI主要是图它的类型校验和OpenAPI文档生成,省不少事。

依赖安装用pip就行:

pip install fastapi uvicorn pydantic pyyaml openai

如果技能里涉及PDF处理,再按需加pypdf、pdfplumber、pytesseract这类库。不建议在一开始就把所有可能的依赖都装上,技能库的依赖应该跟技能走,而不是跟主程序走。

5.2 技能装载器的实现思路

装载器就是那一层“把技能从目录变成索引,再按需检索”的服务。我用两个文件实现:indexer.py负责启动时扫描目录、解析元数据、建立内存索引;retriever.py负责运行时根据任务文本召回Top K技能。

indexer.py的核心逻辑分三步:遍历skills/目录下的所有子目录,逐个解析SKILL.md里的YAML头(我习惯在SKILL.md顶部放一小段YAML元数据,正文放给模型看的长描述),然后用Pydantic模型校验字段。校验不通过的技能直接跳过,并输出warning——这一步很关键,避免某个技能格式写错导致整个服务启动失败。

retriever.py是我最常调优的部分。一开始我用纯关键词匹配,效果比较差,因为用户表达可能跟技能描述用词不一致。后来改成“关键词匹配 + embedding相似度”的双通道,才稳定下来。embedding模型我用的是text-embedding-3-small,单次调用成本忽略不计。两个通道的打分规则很简单:

  • 命中一个关键词标签加1分;
  • 语义相似度的具体数值,我会按0.6的权重转换;
  • 总分从高到低排序,取前5个技能。

这里有个容易忽略的点:检索结果的排序不需要特别精确,只要把“真正可能需要的技能”召回进来就行。因为最终决定调用哪个技能的是大模型,装载器只负责缩小选择范围。所以检索策略宁可召回多一点,也不要漏掉可能相关的技能。实测中Top 5的召回率能到96%左右,已经够用。

5.3 执行器与参数校验

执行器是技能实际跑起来的地方,也是最容易出问题的地方。我踩过最典型的坑是:模型生成了参数JSON,但类型不对——比如page_range本来是"1-3"的字符串,模型传成了1。Pydantic的校验在这里帮了大忙,它能在执行前拦截掉明显错误的数据,返回给模型一个“参数校验失败,请修正”的提示,而不是让脚本带着错误参数跑一半崩掉。

执行器的流程我设计成四步:解析参数 → 校验Schema → 执行run脚本 → 格式化输出。执行run脚本时用subprocess隔离,设置超时时间(默认120秒),防止某个技能死循环拖垮整个服务。错误处理也很重要:技能的错误码要能回传给模型,否则模型不知道为什么失败,也就无法自行修复。

这四步看起来简单,但在真实场景里能挡住很多问题。我把参数校验放在执行之前,而不是执行之中,就是因为“让模型重新生成参数”比“让技能脚本自己处理脏数据”要可靠得多。

5.4 最小可用的调用流程

写一个最小Demo,让你直观感受整个流程。假设用户说“把report.pdf第2页的表格提取出来,那个页面是扫描件”。

from app.loader.retriever import retrieve_skills from app.executor.runner import execute_skill user_task = "把report.pdf第2页的表格提取出来,那个页面是扫描件" correlated = retrieve_skills(user_task, top_k=5) for skill in correlated: if skill.name == "pdf_table_extractor": result = execute_skill( skill, { "file_path": "/data/report.pdf", "page_range": "2", "ocr": True, } ) print(result) break

实际项目中,这里不是“遍历技能”而是“把候选技能交给大模型做函数调用决策”。但局部看,流程就是这三步:检索候选、锁定目标、执行并返回。整体上,装载器、执行器、模型决策各司其职,调试时也能快速定位问题出在“没召回”还是“调错参数”还是“脚本报错”。

6. 技能编排:让多个技能协作完成复杂任务

6.1 顺序编排与条件编排

单个技能只能解决单一问题,真实业务往往是“组合拳”。比如“读取邮件附件里的PDF表格,汇总数据,生成周报并发邮件”,这里至少涉及pdf_table_extractor、sql_generator、email_sender三个技能。

我做编排的原则是:能靠模型做决策的,就交给模型;节奏控制、失败回退这类确定性的逻辑,交给代码。也就是说,编排层不为“下一步该调哪个技能”做硬编码,而是把前一步的输出整理成上下文,让模型决定下一步调用谁。但“最多执行几步”“超时怎么办”“某个技能失败后是否继续”这类边界条件,由编排层强制约束。

顺序编排最简单的实现是“循环决策”:模型每一步选择一个技能,执行器执行,把结果追加到对话上下文,再让模型决定下一步。条件编排稍微复杂一点,比如“如果PDF提取的结果为空,则不执行SQL生成,而是直接回复用户”,这种分支逻辑我建议显式写出来,不要指望模型自己判断。

6.2 技能间数据传递的格式约定

技能之间协作,最难的是数据格式统一。早期我遇到过:pdf_table_extractor输出的表格是列表嵌套字典,sql_generator却接收CSV字符串,导致中间的转换代码比技能本身还复杂。

后来我强制规定:技能之间的数据传递统一用JSON,且每个技能在output_format里声明自己的输出结构。任何技能需要消费上游数据时,第一件事是写一个“适配器”把上游输出转成自己能接受的格式。虽然多写几行代码,但每个技能保持了解耦,不会因为另一个技能改了输出格式而跟着改。

举个实际的链接方式:

pdf_table_extractor -> { "tables": [{ "headers": [...], "rows": [...] }] } sql_generator -> { "sql": "CREATE TABLE ...", "execution_plan": "..." } email_sender -> { "status": "sent", "message_id": "..." }

这样链式调用时,每个技能消费上一环的JSON,产出下一环需要的JSON,数据流一目了然。排查问题时也能直接看哪一环产出的JSON不符合预期,不用翻遍代码。

6.3 编排中的约束与权限控制

技能编排时还要考虑一个容易被忽略的点:技能不是无条件可调用的。比如email_sender这种涉及对外发送的敏感操作,不能模型一选就执行,得有人工确认或权限校验。

我在每个技能里加了permissions字段,编排层在执行前检查当前会话的权限范围。比如普通用户的会话允许调用pdf_table_extractor和sql_generator,但调用email_sender时必须弹人工确认;只有管理员会话能直接发送。这个设计在内部工具场景尤其重要,能避免Agent“好心办坏事”。

更细一点的约束是“资源配额”。比如某个技能单次执行耗时很长,或者依赖外部API,配额控制能防止Agent因为循环调用把账单打爆。我在编排层加了一个简单的计数器:单次任务中每个技能最多调用3次,超过后必须向用户说明原因。这个逻辑写起来就十行代码,但真的能省不少钱。

7. 踩坑实录与性能调优实战

7.1 典型报错、定位思路与解决方案

这节是真实项目里踩出来的“血泪史”,每条都值得反复看。我把高频问题和排查思路整理成了速查表:

现象根因排查方法解决方案
模型选错技能技能描述边界不清、命名太像看模型调用日志,确认候选技能列表用“边界写作法”重写description,改名拉开差异
参数偶尔多一个字段Schema没有枚举限制查看模型生成的原始JSON用Pydantic严格校验,拒绝未知字段
技能执行成功但结果不对上游技能输出格式没对齐打印每步输出JSON引入适配器,强制JSON格式传递
检索召回不到相关技能标签太宽泛或embedding维度不对测试几条不同表达的任务扩充标签,换用更合适的embedding模型
系统提示词超长技能描述太长或注入个数太多检查token用量的统计日志压缩描述,Top K从5降到3
固定时间点超时某技能依赖外部API变慢分技能记录执行耗时单独调大超时时间,或加缓存

其中“模型选错技能”是出现频率最高的问题,而且改Schema无法彻底解决——因为模型是根据语义而不是字段来理解的。唯一有效的办法就是反复打磨每个技能的description和examples。这块没有银弹,只能靠测试集积累。

第二个高频问题是“检索召回不到”。我早期用纯关键词匹配时,用户说“把扫描件里的表格转出来”,技能描述里只有“OCR”,就是匹配不上。后来把tags扩充成一组同义词(["OCR", "扫描件", "图片文字", "文本识别"]),召回率立刻好看了。扩展同义词这事,可以一边用一边积累,不用一次性做全。

7.2 技能加载与执行性能优化

性能调优要区分“加载时”和“运行时”。加载时,最耗时的是初始化embedding模型和扫描磁盘上的技能文件。embedding模型我建议做成懒加载——服务启动时不加载,第一次需要检索时才加载,这样冷启动时间从8秒降到1秒以内。扫描技能目录也可以用增量索引:只记录文件hash,启动时对比hash,没变动的技能跳过解析。

运行时,主要优化点是“减少不必要的重计算”。比如:同一个技能在同一个会话里被反复调用时,上次执行的结果可以缓存;embedding检索结果也可以做短期缓存,用户连续追问相似问题时能直接命中。我实测加了一个简单的LRU缓存后,检索呼应的平均延迟从50ms降到了10ms左右。

还有个容易被忽视的优化:技能执行结果里的大字段(比如提取出的整张表格)没必要全量回传到模型上下文。可以精简成“前N行 + 统计信息”,让模型知道结果概要就够了,需要明细时再按需读取。这样能大幅降低token消耗,响应速度也明显提升。

7.3 上下文窗口管理的实用经验

上下文窗口满了,是所有Agent应用都会遇到的问题。技能库方案能缓解但不能根治,核心思路还是“只放必要信息”。

我的做法是三级上下文策略:第一级是会话级,放系统角色说明和用户核心意图;第二级是技能级,放当前候选技能的描述和Schema;第三级是结果级,放最近一次技能执行的精简结果。每级都有自己的“过期策略”——会话级的能留多久留多久,技能级的每轮任务结束后清空,结果级只保留最近两轮。

这个策略在长会话场景里效果很明显。我在一个“数据分析助手”项目里,以前跑到第20轮对话时系统提示词加历史消息已经占了2.6万token,模型经常开始胡言乱语。改造后,每轮固定只保留“1条用户意图 + 3个技能说明 + 2条执行结果”,token占用稳定在6000左右,模型到第40轮依然稳定。

8. 测试与发布:怎么保证技能库越改越稳

8.1 技能级测试与回归测试设计

技能库跟普通代码库一样需要测试,但测的对象有点不一样。除了常规的“脚本能跑通”,还要测“模型的调用决策是否稳定”。

我给每个技能建了一个测试集,包含三类样例:

  • 正向样例:应该调用该技能的请求,比如“把扫描版报销单里的金额提出来”应该命中pdf_table_extractor。
  • 负向样例:不该调用该技能的请求,比如“把这段文字翻译成英文”不应该命中pdf_table_extractor。
  • 边界样例:描述模糊、需要模型判断的请求,比如“处理一下这个文件”,模型应该追问而非乱选技能。

回归测试的逻辑就是:改任何一个技能的描述或Schema后,跑一遍全量样例,看调用决策有没有变化。这个测试我在本地用pytest加一个断言“模型输出的工具名是否符合预期”来完成。操作上,我先用脚本记录当前版本对每个样例的决策,改动后再跑一遍做diff,差异列表一眼就能看出来。

这块测试的价值在于,它能让你“放心改描述”。没有测试的时候,每次改技能描述都提心吊胆,怕影响其他场景;有回归测试兜底后,迭代速度明显加快。

8.2 灰度发布与技能版本管理

技能库的发布节奏不像业务功能那么严格,但它有自己的问题:同一个技能在不同项目里可能依赖不同版本的依赖库。我推荐给每个技能加一个requirements.txt,并在技能目录里记录“依赖锁定版本”。发布新版本时,先让新的技能包在测试项目里跑几天,确认没问题再全量同步。

因为技能包本质上是“代码+文档+数据”的混合体,我用Git管理并给每个技能打tag(v1.0.0、v1.1.0),发布时用CI构建成可部署的zip包。主程序只引用技能版本号,不直接依赖具体文件内容,这样回滚也方便——只需要把版本号指回上一版。

另外一个实操经验:给技能加一个“状态”字段,取值是stable、beta、deprecated。检索时优先返回stable技能,只有用户明确要求“使用新功能”时才注入beta技能。这能防止不稳定的新技能拖垮整体体验。

9. 最后再分享几个小经验

项目做到后面,真正决定体验好坏的往往不是模型本身,而是“技能资产”的整理功底。我最后想说的是:不要一上来就追求技能数量,先把手头最常用的5个技能做到高质量,比堆100个粗糙技能有用得多。

另外,技能描述一定要做定期复查。业务术语会变,用户说法也会变,三个月前写的描述可能已经跟不上实际使用了。我习惯每个季度做一次技能描述审计,拿上一季度的真实用户请求回放一遍,看哪些技能的命中率下降了,及时修订。

如果你打算自己搭一套agent-skills,大胆试,但记住两件事:第一,技能库的协议设计比实现代码重要,先把SKILL.md和schema.json的规范定好,再谈功能;第二,多花时间在测试集上,那是你迭代的底气。希望这些踩坑经验能帮你少走几条弯路。

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

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

立即咨询