ARTICLE DETAIL

资讯详情

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

Agent技能化改造:从零构建可复用的Skill体系

Agent技能化改造:从零构建可复用的Skill体系 三月份在做一个多轮对话的AI助手项目时我踩了不少跟“Agent能力复用”相关的坑。当时团队里每个人都在各自的Prompt里塞大段工具说明调用的逻辑散落在各个文件里换一个场景就要复制粘贴一堆代码。后来我花了两个晚上把所有能力抽成了一套统一的模块就是围绕“agent-skills”这个思路做的技能化改造——把给大模型用的每一项能力都变成一个带描述、带参数约束、带执行逻辑的独立Skill。那两周的迭代速度明显上来了新增一个能力只需要写一个类然后注册进去主流程完全不用动。这篇文章就把这套技能体系的拆解思路、代码实现和踩坑记录完整写出来希望能帮到正在做Agent工程化、或者正准备把零散的工具调用整理成体系的朋友。1. 项目整体设计思路为什么Agent需要一套“技能系统”1.1 从“指令式编码”到“能力编排”的转变很多刚开始接触Agent开发的同学写出来的代码长这样在Prompt里塞一段“当你需要查询天气时调用http://xxx/api/weathercity北京”然后在主循环里用if判断用户消息里有没有“天气”两个字有就触发某个函数。这种写法在demo阶段没问题一旦技能超过三五个麻烦就来了——大模型经常不按你教的格式来if判断漏掉同义表达函数一多路由逻辑就像蜘蛛网。agent-skills这类项目要解决的就是把“让模型学会用能力”这件事从硬编码中解放出来。核心思路是每一项能力都变成结构化描述喂给大模型的是“有哪些技能可用、每个技能需要什么参数”模型自己决定什么时候调用哪个技能、参数怎么填代码只负责注册和分发。这背后的关键词是“可发现性”——技能不是藏在代码里等着被if命中而是明明白白摆在大模型面前让它挑。1.2 Skill在Agent架构中的定位我实际跑下来最顺手的分层结构是三段式上层的Agent核心负责对话管理、上下文维护、推理循环它不关心具体能力怎么实现。中间的Skill层解决“Agent知道自己有什么能力”的问题每个技能自带描述、入参定义、执行逻辑。底层的工具/服务层真正干活的代码比如调数据库、调API、执行算法。这种分层最大的好处是职责单一。Agent核心不感知业务细节Skill层不关心用户怎么说底层服务不关心大模型怎么调用。哪一层出了问题就只改哪一层测试也好写。我之前的项目里Prompt、工具调用、业务逻辑全都搅和在一起每次调一个参数都要全局搜索“天气”两个字到哪里去了改完还提心吊胆怕别的地方用到同一段逻辑。Skill层在这套结构里相当于一个“中间人”既要做向上对接——把技能翻译成大模型能理解的Schema格式又要做向下对接——把模型填的参数翻译成底层服务能执行的调用。这个翻译动作看起来简单但要做好需要非常细致后面讲细节的时候会展开。1.3 边界划分Skill、Tool、Workflow三者什么关系这是我在做技能化改造时反复被问到的问题Skill和Tool有什么区别Skill和Workflow又有什么区别按我的理解这三者其实是不同粒度的东西。Tool是最小的原子操作比如“查询天气API”“计算两个日期之间的天数”“调用一个大模型接口做文本分类”。它不关心业务上下文只负责执行单一功能。Skill是围绕一个业务目标组织的“能力包”。比如一个“数据分析”Skill内部可能包含“查数”“算指标”“画图”三个Tool的调用并且定义了这些Tool之间怎么串联。Skill是有状态、有流程的。Workflow是更高层的编排把多个Skill串成一个完整的业务流程。比如“定时生成销售周报”这个Workflow先调用“数据查询”Skill拿数再调用“指标计算”Skill算环比再调用“报告生成”Skill输出文档。画成图就是Tool在最底层Skill在中间层做组合与路由Workflow在最上层做流程编排。agent-skills这类项目主要管的是中间那层——把Tool包装成携带语义信息的Skill并且提供注册、发现、调度的基础设施。实际做的时候不要把边界抠得太死关键是你团队的人能看懂、好维护就行我的经验是定义清楚“什么算Skill”比纠结“Skill和Tool的本质区别”更有用。2. 核心细节解析Skill的标准结构应该长什么样2.1 Metadata层让大模型“知道”这是什么技能我拆解了不少开源的skills实现也自己写过两版最终沉淀下来的Skill结构包含三层Metadata、Input Schema、Execute。Metadata是给大模型看的“身份说明”是整个结构里最容易做烂的部分。一个合格的Metadata至少包含三个字段name技能的唯一标识。命名要短、要直观比如query_sales_data、send_email不要用fn_20240315_a这种。description技能功能的自然语言描述。这条最关键大模型全靠它判断“这个技能是否适合当前任务”。写description的窍门是说明技能做什么、在什么场景下用、什么情况下不要用它。比如“计算两个日期间的间隔天数适用于日期差计算、倒计时、账龄分析等场景不要用于需要精确到分钟的时间差计算”。tool_call_id或版本信息等辅助字段用来追踪调用链路方便排查问题。很多开源项目里还会加tags、author、version这些字段我建议保留version尤其是技能数量超过20个后模型端的Schema缓存和代码端的部署版本会经常对不上有版本号能快速定位是哪个环节出了错。2.2 Input Schema层给大模型的“使用说明书”Input Schema就是告诉大模型“调用我这个技能需要填哪些参数每个参数什么类型、什么含义”。现在主流做法是直接复用JSON Schema标准OpenAI、Claude这些大模型的function calling/tool use接口都支持这种格式。直接拿JSON Schema的好处是标准成熟、各大平台兼容不需要自己造轮子。写Input Schema时有几个容易出问题的点必须写description并且要写清楚参数的取值范围、单位、格式。比如temperature要注明“摄氏度”date要注明“格式为YYYY-MM-DD”不然模型经常填出奇怪的值。required字段要写准。我见过很多把必填参数漏掉的模型没传代码跑到一半报错。反过来有些参数不是每次都需要就别放required里降低模型理解成本。枚举值尽量写全并且每个枚举值加注释说明含义。比如analysis_type枚举[descriptive, correlation, trend]要解释correlation是计算列与列之间的相关系数矩阵。这里有一个很多人没意识到的细节Input Schema的定义质量直接决定大模型第一次调用技能的成功率。Schema写清楚模型传参的准确率能从七八成提升到九成以上。这不是玄学而是因为大模型做工具选择时实际上是在做“文本匹配”你的描述跟用户的意图越贴合匹配就越准。2.3 Execute层技能真正的执行逻辑Execute层是技能的核心逻辑所在也是跟具体业务耦合最深的地方。我的习惯是Execute层只做三件事解析参数、调用底层服务、格式化返回结果。参数解析的关键是防御性编程。大模型填的参数不一定符合Schema定义的格式比如把数组填成了字符串、把数字填成了带单位的中文文本所以Execute层开头要做一层清洗和强制转换。我会写一个硬校验函数把枚举值外的输入全部丢掉把字符串数字转成int/float日期格式不规范的尝试用常见格式解析。底层调用逻辑就是塞给具体的Tool的这里不赘述。重点说一下返回结果的格式化。模型需要的不只是“执行成功”还需要结构化、可理解的结果。我的做法是优先返回JSON结构并且外层包一个{status: success/failed, data: ...}统一格式。对数据量大的结果做裁剪或汇总比如只返回前100条明细再加一个total_count字段避免把上下文窗口撑爆。附上人类可读的一句话摘要比如“共查询到1,230条记录其中华东区占比45%”方便Agent组织自然语言回复。2.4 注册与发现机制技能仓库怎么管理技能多了以后“注册与发现”就成了一个工程问题。最简单的方案是写一个Registry类所有技能在项目启动时实例化并注册进去用一个字典存起来name - skill_instance。复杂一点的可以做成动态导入、按需加载但那一般是技能数量超过50个之后才需要考虑的事。我目前的实现是给每个Skill配置一个register_to(registry)方法或者用装饰器直接标记register_skill class QuerySalesSkill(BaseSkill): name query_sales_data ...启动时扫描skills目录下所有模块自动收集带register_skill的类并实例化。这样新增一个技能就只需要新建一个文件、写一个类注册和发现全自动完成。对于中小型Agent项目这个方案简单、可控、易排查没必要一上来就上个插件系统那么重。Registry里还需要一个方法能把所有技能的Schema一次性导出成列表喂给大模型的tools参数。我这版的实现逻辑是def get_llm_schemas(self) - list[dict]: return [skill.to_llm_schema() for skill in self._skills.values()]3. 实操过程从零构建一个带Skills的Agent3.1 环境准备与项目结构规划下面我们直接动手做一个可以用起来的Agent技能系统。我以Python为例因为生态全、写Agent框架最顺手。需要的基础依赖就两个openai或者其他兼容的LLM SDK甚至纯HTTP请求也行pydantic用于参数校验非必须但我强烈建议加项目建议按下面的结构组织agent_skills_demo/ ├── agent/ │ ├── core.py # Agent主循环 │ └── prompt.py # 系统提示词 ├── skills/ │ ├── base.py # BaseSkill基类 │ ├── registry.py # 技能注册中心 │ ├── query_sales.py # 示例技能1查询销售数据 │ └── analyze_trend.py # 示例技能2分析趋势 ├── services/ # 底层服务被技能调用 │ └── db.py # 模拟数据库操作 └── main.py # 入口事先规划好目录边界非常值得因为Agent项目很容易变成“所有代码都扔到core.py里”的一坨。按“技能独立一个文件、底层服务独立一个目录”的方式拆开后面加技能、改逻辑都清晰很多。BaseSkill基类是整个技能体系的骨架。我建议定义一个抽象类把Schema生成、参数校验这些通用逻辑收拢到基类里子类只需要写自己的name、description、parameters和execute方法。from abc import ABC, abstractmethod from typing import Any, Optional import pydantic class BaseSkill(ABC): 所有技能必须继承的基类 name: str description: str parameters: dict {} # JSON Schema格式的参数定义 version: str 1.0.0 abstractmethod def execute(self, **kwargs) - dict: 执行技能逻辑返回统一格式的结果字典 ... def to_llm_schema(self) - dict: 生成喂给大模型tools参数的格式 return { type: function, function: { name: self.name, description: self.description, parameters: self.parameters, } }3.2 实现一个具体的Skill以“每日销售数据查询”为例我们来实现一个具体的技能——query_sales_data功能是从模拟数据库里查每日销售记录。这个技能比较有代表性因为它有参数、有业务校验、有结果格式化。先看底层服务这里用一个简单函数模拟数据库查询# services/db.py from datetime import date SALES_DATA [ {date: 2024-03-01, region: 华东, amount: 23500}, {date: 2024-03-01, region: 华北, amount: 18200}, {date: 2024-03-02, region: 华东, amount: 27100}, {date: 2024-03-02, region: 华南, amount: 12900}, ] def query_sales_by_region(date_str: str, region: Optional[str] None) - list[dict]: 按日期和区域查询销售数据 return [row for row in SALES_DATA if row[date] date_str and (region is None or row[region] region)]然后定义技能类# skills/query_sales.py from typing import Optional from .base import BaseSkill from services.db import query_sales_by_region class QuerySalesSkill(BaseSkill): name query_sales_data description 查询指定日期的各区域销售数据。适用于今天卖了多少3月1号华东区业绩这类问题。 parameters { type: object, properties: { date: { type: string, description: 查询日期格式为YYYY-MM-DD }, region: { type: string, description: 区域名称可选不填则返回所有区域, enum: [华东, 华北, 华南, 西南] } }, required: [date] } def execute(self, **kwargs): date_arg kwargs.get(date) region kwargs.get(region) if not date_arg: return {status: failed, message: 缺少必填参数date} # 防御性处理确保日期格式正确 date_str str(date_arg).strip() rows query_sales_by_region(date_str, region) if not rows: return {status: success, data: [], message: f{date_str}未查询到数据} return { status: success, data: rows[:100], total_count: len(rows), message: f共{len(rows)}条记录总金额{sum(r[amount] for r in rows)}元 }这里有一个容易忽视的点message字段。很多技能只返回data大模型拿到一堆原始数据后自己算“总共多少钱”又慢又容易算错。直接在技能返回里把汇总信息算好大模型直接引用就行响应质量和速度都会提升。3.3 编写Agent主循环打通“意图识别-技能路由-执行”技能定义好了核心的主循环也非常重要。它的职责是不断把“用户消息历史上下文可用技能列表”发给大模型让模型决定是直接回复还是调用某个技能然后把技能执行结果再喂回去直到模型不再调用技能。# agent/core.py import json from openai import OpenAI from skills.registry import SkillRegistry class Agent: def __init__(self, registry: SkillRegistry, client: OpenAI, system_prompt: str): self.registry registry self.client client self.system_prompt system_prompt def run(self, user_input: str) - str: messages [{role: system, content: self.system_prompt}] messages.append({role: user, content: user_input}) for _ in range(10): # 限制最大循环次数防止无限调用 response self.client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolsself.registry.get_llm_schemas(), tool_choiceauto, ) msg response.choices[0].message if not msg.tool_calls: # 模型不再调用技能直接回复 return msg.content or # 把模型的工具调用请求加入上下文 messages.append(msg) # 逐个执行被调用的技能 for tool_call in msg.tool_calls: result self.registry.execute( tool_call.function.name, **json.loads(tool_call.function.arguments) ) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) return 已达到最大调用轮数请简化问题后重试关键点有两个。一个是messages.append(msg)这一步把模型的函数调用请求完整放回历史这是大模型上下文连贯的基础少了这步模型后面就“失忆”了。另一个是技能执行结果的role必须是tool而且tool_call_id要和模型给的对应上这两点不做好接口直接报错。3.4 技能组合与多轮调用完成一条完整的业务闭环单个技能的调用不难真正的价值在技能组合。举个例子用户问“3月1号到3月3号逐日的销售趋势怎么样”。这个需求涉及两个技能——先query_sales_data把三天数据拉出来再调用analyze_trend做一个趋势判断。我的做法是再实现一个趋势分析技能它接收一个二维数据、时间字段、数值字段内部做简单的线性回归或者环比计算# skills/analyze_trend.py from .base import BaseSkill class AnalyzeTrendSkill(BaseSkill): name analyze_trend description 分析时间序列数据的趋势方向支持环比计算与变化幅度判断。 parameters { type: object, properties: { data: { type: array, description: 包含日期和数值的记录列表, items: {type: object} }, date_field: {type: string, description: 日期字段名}, value_field: {type: string, description: 数值字段名} }, required: [data, date_field, value_field] } def execute(self, **kwargs): data kwargs.get(data, []) date_field kwargs.get(date_field, date) value_field kwargs.get(value_field, amount) if len(data) 2: return {status: success, trend: 数据量不足无法判断趋势} values [float(item[value_field]) for item in data if value_field in item] if len(values) 2: return {status: success, trend: 有效数据不足} changes [(values[i] - values[i-1]) / values[i-1] * 100 for i in range(1, len(values))] avg_change sum(changes) / len(changes) direction 上升 if avg_change 0 else (下降 if avg_change 0 else 持平) return { status: success, trend: direction, avg_change_percent: round(avg_change, 2), period: [data[0][date_field], data[-1][date_field]] }这样大模型就可以先查数据再把查到的数据传给analyze_trend。从用户视角看他发一句话模型内部连续调两个技能最后给一段自然语言的趋势总结——这就是技能可组合性带来的效果。多轮调用要顺畅要求是前一个技能的输出格式要保持稳定尤其是字段名因为后一个技能依赖它来解析。我在设计技能时固定的返回结构会被当成一条铁律来执行。4. 常见问题与排查技巧实录4.1 技能命中率低模型老选错技能怎么办这是几乎所有Agent项目都会遇到的第一个坎。技能少的时候还好技能到了五六个以上模型就开始频繁选错查天气去调了查日历、分析销售去调了生成报告。排查思路按优先级来先看技能description写得好不好。这是最常见的原因。很多新手写“用于查询销售数据”这种一句话描述模型很难判断边界。要写成“用于查询历史销售数据适合回答各类与销售额、订单量、区域业绩相关的问题不适合查询库存和供应链数据”。再看输入参数的描述是不是足够明确。如果参数描述含糊模型可能会因为不知道参数怎么填而放弃调用。最后检查不同技能的description是否有语义重叠。两个技能都是“查询数据”模型很难区分。要么把描述拉开要么合并成一个技能用参数区分。有个非常有效的调试技巧把所有技能的Schema打印出来你自己扮演大模型对着用户问题选技能。如果你都觉得模棱两可那模型也会选错。4.2 参数传错、类型不符技能执行报错大模型填参数出错是家常便饭。最常见的错误有把region: 华东传成了region: east把数字传成字符串把日期格式传成各种你不认识的样子。应对策略分两层。第一层是在Schema定义时把enum、format、description写到位从源头减少出错的概率。第二层是Execute层做防御性解析我在技能基类里加了一个_parse_params方法对必填参数逐一做存在性检查和类型矫正def _parse_params(self, kwargs, str_fields[], int_fields[], enum_fields{}): parsed {} for field in str_fields: val kwargs.get(field) parsed[field] str(val).strip() if val is not None else None for field in int_fields: val kwargs.get(field) parsed[field] int(float(val)) if val is not None else None for field, allowed_values in enum_fields.items(): val kwargs.get(field) if val is not None and str(val) not in allowed_values: return None, f参数{field}取值不合法必须是{allowed_values}之一 parsed[field] str(val) if val is not None else None return parsed, None还有一类更隐蔽的问题大模型会把上一个技能返回的data整个当作参数传进来导致数据量爆炸。我一般会在Schema里注明“仅传需要的字段不要传全部数据”同时在技能里也加上对数据量的上限保护。4.3 技能执行超时和异常没有反馈到模型技能执行是有失败概率的。你调一个第三方API可能超时你跑一段数据分析可能因为数据格式不对崩掉。很多Agent实现里技能抛异常后直接把错误字符串返回给模型结果模型被一段堆栈信息搞懵回复质量断崖式下降。我的做法是对技能执行做统一的异常捕获把底层异常包装成业务上能理解的信息def safe_execute(self, name, **kwargs): skill self._skills.get(name) if not skill: return {status: failed, error: f技能{name}不存在} try: result skill.execute(**kwargs) return result except Exception as e: return {status: failed, error: f技能执行出错{str(e)[:200]}}同时要给技能设置超时。Python里可以在技能函数外层包一个ThreadPoolExecutor超时就返回值指导模型“技能执行超时请稍后重试或换一种方式”。这一步对生产级的Agent系统非常重要不加的话一个卡死的API调用会让整个Agent挂在这轮循环里。4.4 上下文被技能返回结果撑爆我自己最早犯的错是技能把1000条销售记录全部返回给模型没跑几轮上下文就满了既浪费token又让模型抓不住重点。后来学乖了所有技能返回数据时都遵循“先汇总、后明细、限量返回”的原则。具体做法有几种技能内部做聚合再返回比如只返回总金额、环比、TOP10品类。明细数据截断最多返回50条然后附带总数。敏感或大块数据不直接返回模型而是返回一个标识符模型需要时再通过另一个技能按ID查询。如果必须把大块数据给模型处理记得及时用上下文压缩机制把对话早期的工具调用结果折叠成摘要。这属于优化层面的操作但越早考虑越好。5. 实操心得技能体系的演进路线agent-skills这套思路真正落地时是从小规模开始迭代的。我自己的经验是不要一上来就设计一个宏大无比、支持几十种技能的框架那样大概率会陷入过度抽象做出来的东西看着很完整用起来各种不适应。先挑两三个核心的高频能力定义成Skill跑通“定义-注册-调用”的完整链路。这一步的核心目标是验证Schema设计和技能返回结构的可用性。跑稳之后再慢慢把更多能力迁移进来。到了技能数量超过二十个时开始考虑技能分类、按模块加载、权限控制这些工程问题。有一个我特别想强调的技能返回的结果结构一旦用了就不要随意改。大模型在上下文中已经“见过”了你之前的返回格式如果某天突然变了它组织回答时会非常别扭甚至产生幻觉。我遇到过的最典型的情况是为了加一个字段顺手把字段名从total_amount改成了amount_sum结果后面好几天模型的回答里偶尔还会出现total_amount这个不存在的字段。改结构要慎重要改就同步清掉历史上下文别让新旧格式混在一个会话里。最后分享一个调试技能集的小工具。我写了一个简单的CLI可以在终端里输入一句话程序打印出“哪些技能被选上、每个技能填了什么参数、执行结果是什么样、模型最后怎么回答”。整个链路一目了然比看日志推测高效太多。真去定位技能选错、参数传错的问题时这个工具能省一半时间。技能体系的可维护性很大程度就体现在这些观察和调试的便利程度上。
返回列表