ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从工具到技能:AI智能体技能体系设计与工程实践

从工具到技能:AI智能体技能体系设计与工程实践 最近在折腾一个项目代号就叫“agent-skills”核心是给AI智能体设计一套可复用的技能体系。搞了大半个月踩了不少坑也总结出一些可复用的思路今天就把这套东西完整拆开讲讲。我见过太多人做Agent上来就往Prompt里堆工具描述五个工具还可以靠提示词硬撑工具一多模型就开始瞎选、串场、反复调用失败。我自己的项目里最开始接了十几个API然后在system prompt里用一整页描述每个工具是干嘛的结果模型经常把两个相似工具的调用参数搞混还有大量无效请求。后来我才意识到问题不是模型不够聪明而是我把“工具”直接暴露给了模型但缺少一个中间层把这个工具在什么场景下用、怎么用、前置检查是什么、结果怎么处理打包成一个完整的“技能”交出去。“agent-skills”这个项目做的东西本质上就是这样一套技能体系把散落的工具调用升级成语义完整、可编排、可观测的Agent技能模块让模型只面对少量高聚合度的能力入口而不是一堆毫无逻辑的API函数。今天这篇文章我会把这套体系的设计思路、目录规范、实操代码、调试技巧全部展开希望能给正在做Agent工程的同行一些参考。1. 为什么Agent需要“技能”而不是一堆“工具”1.1 从工具到技能概念上差在哪里我在项目里对“工具”和“技能”做了明确区分。工具是单一的函数调用比如get_user_by_id(user_id: int)技能则是一组围绕某个业务目标聚合的能力包它内部可以有多个工具调用、有前置校验、有异常分支、有标准化的输出格式。举个例子。如果你的Agent需要帮用户查订单、退换货、开发票直接在工具列表里堆query_order、apply_refund、create_invoice这三个工具模型确实能调但很容易在参数上犯错查询订单时传了退款单号或者发票金额算错。但如果把这三个操作封装成一个“订单售后处理技能”技能内部自己去识别输入是订单号还是售后单号自己校验金额再决定走哪个流程模型只需要说“用户要退款”技能就接管了后续所有细节。这就是两者最大的区别工具是“你告诉模型每一步操作”技能是“你告诉模型目标操作过程交给封装好的模块”。对模型来说面对的接口数量变少了每个接口的语义边界更清晰了决策准确率自然就上来了。1.2 技能体系解决的核心问题仔细复盘一下我这套技能体系主要解决了三个问题第一是上下文污染。工具描述写得越详细占用的上下文窗口越大写得越简略模型越容易误用。技能体系把冗长的工具说明拆成两层模型在决策层只看到精简的触发条件和使用场景完整的执行细节放在技能内部调用时才加载。这相当于给Prompt做了瘦身。第二是错误处理与容错。过去model → tool这种直连模式下工具报错了模型要自己理解错误信息再重新尝试经常陷入死循环。现在技能内部就做了重试、降级、参数修正模型拿到的永远是技能消化过的结果而不是一堆乱七八糟的原始异常。第三是组合与编排。有些复杂任务需要多步工具协作比如先查库存、再锁库存、然后生成订单。如果这三步暴露给模型去编排不确定性太大了。封装成“下单技能”后编排逻辑是代码写死的模型只需要决定“现在该调用下单技能了”内部怎么跑它不用管。我把工具和技能的区别整理成了一张表方便对照理解。维度工具Tool技能Skill粒度单一函数一步操作多步骤、带逻辑的完整能力模型感知度暴露全部细节参数模型自己填只暴露触发条件与目标错误处理模型现场判断容易出错技能内部预置重试与容错上下文开销每个工具都要完整描述只有触发层占上下文执行细节后加载可复用性跨场景反复声明易冲突一次封装全局可复用1.3 什么时候该上技能体系不是所有项目都需要技能体系。如果你的Agent只有两三个工具模型也不可能选错那直接平铺就好。但当你在代码里开始写if tool_name xxx的特殊处理或者发现两个工具的description越来越长、越来越像甚至开始出现模型把A工具的参数传给B工具的情况那就说明该引入技能层了。我这个项目触发改造的临界点是工具数量超过15个、Prompt中工具描述超过3000字符。顺着这个思路重构之后工具的决策准确率从改造前的82%左右提升到了94%无效调用次数下降了接近60%这个提升幅度足以说明问题。2. “agent-skills”技能体系的设计与目录规范2.1 技能目录怎么组织最顺手项目里我采用的目录结构参考了市面上几种主流Agent技能仓库的约定最终形成了一套比较稳定的模板。每个技能独立一个目录目录名就是技能名内部包含声明文件、描述文档和代码脚本三大部分。agent-skills/ ├── skills/ │ ├── order_after_sales/ │ │ ├── manifest.yaml │ │ ├── SKILL.md │ │ └── scripts/ │ │ ├── query_order.py │ │ ├── apply_refund.py │ │ └── utils.py │ ├── database_query/ │ │ ├── manifest.yaml │ │ ├── SKILL.md │ │ └── scripts/ │ │ └── query_mysql.py │ └── ... ├── runtime/ │ ├── loader.py │ ├── session.py │ └── logger.py └── config.yaml这套结构的核心原则是“一个技能一个家”。manifest.yaml负责声明技能元数据和参数SchemaSKILL.md写给模型看scripts里才是真正的执行代码。三者职责分离谁负责什么一眼就能看明白不会出现技能一多就乱成一锅粥的情况。2.2 manifest.yaml给解析器看的技术说明manifest.yaml是技能的“身份证”主要给运行时加载器读里面记录了技能的名称、描述、参数定义、依赖关系和超时设置。我通常这样写name: order_after_sales description: 订单售后处理技能支持查询订单状态、提交退款/退货申请、开具发票。 version: 1.2.0 timeout: 30 depends_on: - database_query parameters: type: object properties: intent: type: string enum: [query, refund, invoice] description: 用户售后意图类型 order_id: type: string description: 订单号支持数字与字母组合 reason: type: string description: 退款或退货原因 required: - intent allowed_tools: - query_order - apply_refund - create_invoice有一点要注意manifest里的description要短最好一句话讲清楚这个技能是干嘛的因为这段描述会直接拼接进模型的System Prompt写得越长污染越严重。详细的使用说明放进SKILL.md模型决定调用技能后才会去读两边的信息密度要做差异化设计。参数Schema的定义也别偷懒。模型在做函数调用时会严格按照这个Schema生成参数所以枚举值、必填项、格式约束写得越明确模型生成越准确。我把所有参数都加上了description实测下来参数生成准确率能涨不少。2.3 SKILL.md写给模型看的操作手册SKILL.md是技能体系里最有价值的东西。它相当于给模型的一份“岗位说明书”里面提供了这个技能在什么场景下触发、内部怎么处理、输出什么格式。我整理了一个技能描述写作的模板实际用下来效果不错# 订单售后处理技能 ## 触发条件 - 用户提到“我的订单怎么样了”“我要退款”“订单有问题”“开发票” - 用户提供了订单号或询问最近订单 ## 技能行为 1. 先调用查询接口确认订单状态 2. 如果订单已发货且超过售后期引导用户走售后单流程 3. 退款金额不能超过订单实付金额否则拒绝操作 4. 开发票需要企业抬头个人用户只开电子普通发票 ## 输出格式 - 成功返回订单状态操作结果 - 失败返回错误码可读的失败原因并给出下一步建议 ## 注意事项 - 不要直接修改数据库订单状态 - 如果订单号不存在提示用户核对后再试 - 所有操作必须记录操作日志别小看这份文档我强烈建议在“触发条件”里写足典型问法在“注意事项”里把容易踩雷的规则写清楚。模型在技能内部做多步决策时读到的上下文越结构化行为越稳定。另外说个规律SKILL.md的总长度控制在800~1500词比较合适。太短了模型信息不够太长了会拖慢调用时的上下文加载。我测试过超过2000词的技能描述模型在关键信息召回上反而会变差。2.4 技能之间的依赖与编排设计当技能多起来之后技能之间还会产生依赖关系。比如售后技能需要查订单查询逻辑已经封装在数据库查询技能里了就没必要重复写一套直接在depends_on字段里声明即可。运行时加载器读取depends_on后会自动把依赖技能的执行函数挂到当前技能的执行环境里内部可以互相调用。这种设计让技能不只是一个孤立的模块而是一张能力网络复用性非常高。不过我也踩过依赖过深的坑。早期我把技能依赖链拉到三层以上结果出了bug之后排查链路特别痛苦。后来立了个规矩技能依赖深度不超过两层超过两层就引入独立的服务层接口直接暴露给技能调用。3. 从零实现一个可复用的Agent技能3.1 宿主框架怎么选实现技能的时候先得选一个宿主框架。我当时对比了直接用LangChain工具类、基于Anthropic的Claude Skills机制、以及纯自研三种方案最终选择了以自研为主、参考Claude Skills设计思路的方式。选自研的核心原因是灵活性。LangChain的工具抽象虽然生态好但它的核心抽象还是偏向“工具”而非“技能”错误处理、参数校验、技能编排都要自己额外写很多胶水代码。Claude Skills的思路很超前尤其它的“轻描述、重文档”设计非常适合做技能体系但如果项目的交互链路比较特殊或者需要兼容不同的模型厂商接口还是需要自己封装一层。对于大多数项目我的建议是没有特殊需求先用现成框架把技能描述写作和目录规范跑通等真正遇到框架瓶颈了再考虑自研。我之所以自研是因为这个项目的需求确实比较复杂定制化场景多。3.2 实操案例写一个“数据库查询技能”下面就按照我之前实战的过程手把手带你写一个最常用的“数据库查询技能”。这个技能的需求很简单Agent接到一个自然语言问题后技能负责把问题转成SQL、查询数据库、把结果整理成模型可读的格式。第一步创建技能目录mkdir -p agent-skills/skills/database_query/scripts第二步写manifest.yamlname: database_query description: 将自然语言问题转换为SQL并查询数据库返回结构化的查询结果。 version: 1.0.0 timeout: 15 parameters: type: object properties: query_question: type: string description: 用户希望查询的原始自然语言问题 table_name: type: string description: 需要查询的数据表名称可选缺省时自动识别 required: - query_question第三步写SKILL.md# 数据库查询技能 ## 触发条件 - 用户询问数据情况、报表指标、订单数量、用户统计等 - 用户问题中包含“统计”“多少个”“平均”“占比”等字眼 ## 技能行为 1. 根据问题理解表结构选择合适的表和字段 2. 生成SQL查询语句先通过EXPLAIN检查索引使用情况 3. 禁止在查询中使用DELETE、UPDATE、DROP等写操作 4. 如果查询结果为空返回空结果并解释可能的业务原因 ## 输出格式 - 返回markdown格式的表格 - 每列附带字段说明 - 附上SQL语句方便人工复核第四步实现scripts/query_mysql.pyimport os import mysql.connector from typing import Any, Dict, List def query_mysql(query_sql: str, params: tuple ()) - Dict[str, Any]: 执行MySQL查询并返回结构化结果。 只支持SELECT语句禁止写操作。 try: conn mysql.connector.connect( hostos.getenv(MYSQL_HOST), portint(os.getenv(MYSQL_PORT, 3306)), useros.getenv(MYSQL_USER), passwordos.getenv(MYSQL_PASSWORD), databaseos.getenv(MYSQL_DATABASE), connection_timeout3, ) cursor conn.cursor(dictionaryTrue) cursor.execute(fEXPLAIN {query_sql}) explain_result cursor.fetchall() for row in explain_result: if row.get(type) ALL and where in row.get(Extra, ): return { success: False, error: 查询未命中索引已终止执行请添加索引或修改条件 } cursor.execute(query_sql, params) rows cursor.fetchall() columns [desc[0] for desc in cursor.description] if cursor.description else [] cursor.close() conn.close() return {success: True, columns: columns, rows: rows} except Exception as e: return {success: False, error: str(e)}这里有个小细节就是查询前先执行EXPLAIN这个设计是为了防止模型生成的SQL是全表扫描。模型对数据量没概念一个三千万行的表它敢写不带WHERE的查询有了索引检查能拦住绝大多数低质量SQL。第五步实现技能主入口文件from typing import Any, Dict from .scripts.query_mysql import query_mysql import json def execute_skill(parameters: Dict[str, Any], context: Dict[str, Any]) - Dict[str, Any]: 技能统一入口负责接收模型意图参数完成查询并格式化输出。 if query_question not in parameters: return {success: False, error: 缺少query_question参数} question parameters[query_question] # 生成SQL的逻辑通常交给模型这里通过session把问题传出去 # 更稳妥的方式是在技能内部再调用一次模型做text2sql sql context.get(sql_generator, lambda q: )(question) if not sql.strip(): return {success: False, error: 无法从问题中生成SQL} result query_mysql(sql) if not result[success]: return {success: False, error: result[error]} rows result[rows][:50] # 限制最多返回50行避免上下文爆炸 if len(result[rows]) 50: rows.append({warning: 结果过多仅显示前50行}) return { success: True, data: rows, sql: sql, row_count: len(result[rows]), }技能里用了一个context参数这是我在设计里坚持的一个点。它专门用来传递运行时信息比如模型生成SQL的回调函数、当前用户身份、会话上下文等这样技能就不需要自己去猜测外部环境只依赖显式传入的上下文可测试性会好很多。3.3 技能注册与调用链路解析技能写好了接下来是运行时加载。我的加载器是这样工作的import yaml from pathlib import Path from typing import Dict, Any SKILLS_ROOT Path(skills) def load_skill(skill_name: str) - Dict[str, Any]: skill_dir SKILLS_ROOT / skill_name manifest_path skill_dir / manifest.yaml with open(manifest_path, r, encodingutf-8) as f: manifest yaml.safe_load(f) skill_md_path skill_dir / SKILL.md skill_doc skill_md_path.read_text(encodingutf-8) # 动态导入技能入口模块 module_path fskills.{skill_name}.entry entry __import__(module_path, fromlist[execute_skill]) return { name: skill_name, manifest: manifest, documentation: skill_doc, execute: entry.execute_skill, }加载进内存后runtime会维护一个技能注册表。每次会话开始时根据用户当前任务把注册表里可能相关的技能注入到System Prompt里而不是一次性全注入。这个动态加载机制是控制上下文开销的关键。调用链路的完整流程大概是这样的用户提问进入Agent主循环主模型根据当前Prompt和技能触发描述决策调用哪个技能加载器从注册表中取出技能对象并把参数Schema给模型让模型生成结构化参数模型生成参数后runtime做参数校验校验通过后调用execute_skill技能内部执行完返回标准化结果主模型拿到结果组织成自然语言回复用户3.4 技能的观测与评估这里说一个我特别想强调的点技能体系做得好不好一定要有可观测性。我在每个技能执行时都会记录这样几条信息技能名、技能版本号触发时的完整参数执行耗时内部每一步的中间结果尤其是SQL、API响应等最终输出结果的摘要这些日志的价值很大。有一次模型在订单查询技能上反复失败回看日志才发现模型经常把一个比较长的字母数字混合订单号截断成短数字根源是参数描述里没有注明订单号长度范围。把参数描述改成“订单号格式为13位字母数字组合”之后问题立刻消失了。我自己还会在开发环境跑一组固定的回归测试集包含50个不同类型的查询问题每次改版都跑一遍观察技能调用准确率的变化。这个习惯帮我挡住了不少回归bug。4. 常见问题与排查技巧实录4.1 模型就是不调用技能怎么办这是最常见的一类问题。技能明明写得没问题手动调接口也能跑通但模型聊天时就是不触发总是用泛泛的方式回答。排查顺序一般是这样排查点检查方式常见根因技能描述是否进入Prompt看一次实际请求的完整system message动态加载逻辑有bug描述没拼进去技能描述是否足够短检查描述字数描述过长被其他内容挤占或截断触发条件是否明确对比典型用户问题与描述用词描述太抽象模型无法对齐到具体语义技能名是否容易误解检查技能名与描述的一致性名字暗示了错误的能力范围是否存在竞争技能检查其他技能的触发词多个技能描述相似模型选择了错误那个我碰到最多的就是触发词写得太抽象。一开始写的是“当用户有信息查询需求时”结果模型觉得什么都能触发反而什么都不触发。改成“当用户询问订单状态、物流轨迹、退款进度时触发”之后命中率立竿见影地上升。另外如果主模型是支持function calling的需要确认一下触发的优先级。我见过有些场景里模型宁可自己有一个模糊答案也不调用技能去拿准确数据。这种情况可以通过在System Prompt里加一句强约束来缓解比如“当你需要准确数据时必须先调用对应技能禁止猜测”。4.2 技能执行成功但模型无视结果这个问题的表现是技能返回了正确的数据但主模型回复时完全没用或者还在自己编数据。大部分时候是因为结果格式对模型不友好。我早期是把技能返回的JSON直接丢给主模型模型读是能读但字段一多它就抓不住重点了。后来我规定所有技能返回的结果必须带上三层结构summary: 一段话总结执行结果直接给模型读data: 原始结构化数据meta: 执行时间、耗时、告警信息等元数据主模型优先读summary这样即使数据再复杂它也能快速把准确信息组织进回复里。还有一种情况是返回数据太长模型读到后面把前面的关键信息忘了。所以我规定技能返回结果默认不超过50行或2000个token超过部分在summary里做聚合统计这样既保住了关键信息又不会超限。4.3 技能数量膨胀之后互相干扰怎么破技能做多了之后会面临一个新问题技能之间描述相似模型不知道该选谁。比如我做了“订单查询”和“物流查询”两个技能在某个边界场景上模型经常选错。我的解法有两个。第一个是给每个技能在manifest里加一个trigger_keywords字段把高频触发词和同义词全部列出来注册的时候生成一份关键词索引表主模型决策前先用轻量规则匹配缩小候选技能范围。这相当于在模型之前加了一道规则路由能明显降低选错概率。第二个是给相似技能增加“排他性说明”。比如在订单查询技能的SKILL.md里写明“物流状态查询请使用物流查询技能”在物流查询技能里也反向注明。这种交叉说明看起来有点笨但对模型判断边界非常有效实测冲突场景的错误率能降低一半以上。4.4 技能内部调用外部API时的超时与重试有段时间技能频繁出现超时但单独测API明明是好的。后来发现是并发场景下技能同时发起了大量外部请求把下游服务打挂了。后面我在技能层做了一套统一的重试策略首次请求超时时间5秒重试次数最多2次采用指数退避1秒、2秒请求间加入并发限制技能粒度统一走信号量控制连续失败超过5次技能主动熔断直接返回降级提示采用这套策略后外部接口抖动对会话的影响小了很多。重点在于技能封装的不只是正常流程还要封装好异常降级否则它和裸调API就没有本质区别了。5. 写在最后的一点体会项目做到现在我最大的感受是Agent技能体系的难点不在代码而在对模型行为的理解你设计的每一个字段、每一段描述都是在引导模型的概率分布走向你想要的那一侧。技能描述写得清晰模型的行为就稳定技能描述含糊模型就给你展示各种让人血压升高的操作。如果你也是正在做Agent相关项目我的建议是先别着急写一堆复杂框架从一两个真正高频使用的技能开始把目录规范、描述写作、日志观测这三点跑通再逐步扩展。这比一开始就搭一个庞大的技能编排引擎要实际得多。对了最后再分享一个小技巧技能目录里记得加一个examples/文件夹把每次测试中比较好的“用户问题-技能参数-执行结果”三元组存下来。时间长了这会变成你优化技能描述最好的数据资产比任何文档都有说服力。
返回列表