ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent技能体系设计:从工具堆到可编排的技能管理框架

Agent技能体系设计:从工具堆到可编排的技能管理框架

在接触 agent-skills 之前,我一直是用“工具函数”的方式在带 Agent 项目:给模型塞一堆 function 定义,然后在回调里写 if-else 分发。直到有一次,接入第三方数据源时,单次对话里挂了二十多个工具,模型开始频繁选错函数、参数格式错乱、返回结果还要再清洗一遍,我才意识到问题不在模型,而在能力组织方式。后来我们把代理的所有能力收敛成一套可声明、可注册、可调度的技能体系,也就是 agent-skills 的雏形。这篇文章就把这套东西的设计思路、落地步骤、踩坑记录一次性说清楚。

agent-skills 本质上是一个面向 AI 代理的技能管理框架。它的核心主张是:不要把代理的能力直接写成碎片化的“工具调用”,而是把能力封装成结构化的技能,每个技能包含清晰的说明、参数约束、执行逻辑和反馈格式。代理负责理解任务、检索技能、按约束调用,而不是大海捞针一样在几十个函数签名里碰运气。

适合谁看?两类人:一类是做 LLM 应用、Agent 产品、RPA 自动化的开发者,这类人最容易被模型乱选工具折磨;另一类是刚接触 Agent 生态,想搞明白“技能”和“工具”到底有什么区别的新手。我会从整体设计讲到代码落地,尽量让没有 Agent 经验的人也能照着写出一套自己的技能体系。

1. 整体设计——为什么代理需要“技能体系”而不是“工具堆”

1.1 从“堆工具”到“建技能”

大多数人第一次做 Agent 时,思路都很直接:模型需要什么能力,我就给模型加一个函数。今天加个查天气,明天加个读数据库,后天加个调内部 API,函数列表越来越长,提示词里塞满了 json schema。

这套做法在一两个工具时没问题,但工具一多,问题就暴露了。最典型的是“选错工具”:模型把本该调用订单查询的地方调成了用户画像查询;其次是“参数幻觉”,模型自己编造了根本不存在的订单编号传进去;还有就是“返回值理解错”,工具返回一段文本,模型把它当成最终答案直接甩给用户,完全没有二次加工。这些本质上是同一个问题:工具的描述和参数约束太“薄”,模型只能靠猜。

技能体系的第一个思路,就是把工具的“厚度”补上。一个 skill 不只是函数签名,它由五部分组成:技能名称、功能描述、参数模式、执行逻辑、结果反馈。其中功能描述不仅告诉模型“我能做什么”,还明确“我什么时候该被调用”“什么时候不该被调用”“调用后你会拿到什么”。参数模式则用严格的 JSON Schema 约束,防止模型自由发挥。结果反馈则定义最后返回的规范化结构,让代理侧无需再次清洗。

用一个比喻来理解:工具是一台没有任何说明书的机器,模型只能对着按钮猜;技能则是一台带操作手册、带接口标准、带状态灯的机器。同样一件活,前者靠运气,后者靠流程。

1.2 技能的边界思维

设计技能体系时,最容易走偏的是两个方向:技能拆得过细,或者技能包得过大。

拆得过细的典型例子:一个“发送邮件”的能力,有人会拆成“获取邮件服务器配置”“读取收件人列表”“构造邮件体”“发送邮件”四个技能。听起来很“原子化”,但对模型来说,多一个选择就多一分误判率。模型可能绕了半天,先从配置读起,再拼邮件,过程一旦中断就得重来。

包得过大则是另一个极端:一个“处理用户订单”的技能,内部包含了查库存、算价格、生成订单、发通知四件事。看起来减少了模型决策次数,但参数会非常复杂,可能一个入参里既要传商品 ID 又要传优惠券码还要传用户手机号,模型一次传错全盘失败,而且用户根本不知道哪一步出错。

我的实践经验是:一个技能最好对应一个可以被用户感知的结果。比如“查询订单并返回状态”“计算订单金额”“发送物流通知”各自独立,而“处理订单”这种复合能力交给代理层去编排多个技能完成,而不是做成一个庞大的黑盒。技能负责“把事情做对”,代理负责“把事想清楚”。边界就是:一个 skill 只回答一个问题,只改变一个业务状态,只输出一种类型的结果。

2. 核心细节解析与实操要点

2.1 技能声明的三层结构

一个技能在代码里怎么表达?我用过 JSON 文件、Python 数据类、YAML 配置三种方式,经历了几轮迭代后,最终固定在“元信息层、参数层、执行层”三层分离的结构。

元信息层是给模型看的“自我介绍”。技能名要短、描述要具体,并且避免使用含糊表述。比如“获取订单数据”就不够好,更好的写法是“根据订单编号获取订单基础信息,包含商品明细、金额、状态、物流单号。仅适用于查询状态为已完成或待收货的订单”。描述越长,模型越容易理解该技能的适用边界,但同时也要避免废话——模型对冗长描述会产生注意力稀释,所以描述要控制在能说完“做什么、何时用、返回什么”这个范围内。

参数层是参数 JSON Schema。这里我会给每个参数加上严格类型约束和必填标记,并且尽量限制枚举值。比如状态字段用 enum ["pending", "paid", "shipped", "completed"],而不是让模型自由填字符串。数据库分页参数也是典型问题:不约束整数类型时,模型能给你填个 "all" 或者负数进去。

执行层则是实际的后端逻辑,通常是调用某个内部函数、数据库查询或第三方接口。我的建议是,这一层不要直接用裸函数,最好包一层异常捕获和标准化输出。技能执行无论成功还是失败,返回结构都应该统一,例如 always 返回 status、data、message 三段式。这样代理侧处理结果时不用每次判断结构不同。

2.2 参数设计是技能的命门

我在这上面吃过最大的亏就是参数设计得不严谨。有一次做一个用户查询类技能,入参只定义了 userId 一个字段,类型是 string。看似没问题,结果模型为了“更精确地定位用户”,自己加了一个 extra_phone 参数传了进来。我的执行层只按 userId 取数,忽略了多传入的参数,最后返回了错用户的信息。这个问题排查了很久,最后发现是因为技能没有要求模型“严格按 schema 传参”,而且执行层也没有做多余参数过滤。

参数设计有几个可复用的原则。第一,能枚举的不自由输入,例如订单类型、支付渠道、用户等级,直接用 enum 收窄范围。第二,能数字化的不要字符串化,比如金额、数量、页码,一律用 number 或 integer,避免模型传“大约 100”这种无法解析的值。第三,需要传复合结构时,用嵌套对象而不要用拼接字符串。比如查询条件放在 filter 字段下,而不是传一个 "status=paid&date=2025-01-01" 让后端自己解析。第四,对可选参数设置 default 语义,模型不传时后端要有明确兜底行为,不能出现“空值导致查全表”这种事故。

参数校验这块,我建议在技能执行层入口处做两道校验:第一道是 JSON Schema 的严格校验,拒绝非预期字段;第二道是业务校验,例如日期范围是否合法、ID 是否存在。前者防住模型的参数幻觉,后者防住真实业务边界。

2.3 技能与模型之间的交互机制

技能要真正被模型使用,得通过某种“可见”的方式暴露给模型。目前最主流的是两种:

第一种是 function calling 机制。模型在对话时,系统会对所有注册的技能进行预扫描,选出可能与用户意图匹配的技能,并生成一次 function_call 请求。这种方式效率高、开销低,尤其适合技能数量小于几十个的场景。

第二种是把技能描述注入到提示词上下文里。适合技能数量很大、无法全部塞进上下文的情况。你可以建一个技能索引,让模型先在索引里检索相关的几个技能,再在后续请求中把对应技能的详细 schema 注入上下文,类似“两段式检索 + 动态加载”。

我在项目里常用的是第二种的动态加载方式,因为当技能库增长到几百个时,全部注入 function calling 会导致模型注意力明显下降,选错概率指数级增加。我的方案是:先有一个总索引列表,模型第一轮根据用户意图选出 3 到 5 个候选技能,然后我重新拼装一次请求,只把这几个候选技能的完整 schema 放进去,再让模型生成最终的调用参数。这样既降低了上下文占用,也减少了误选率。

3. 实操过程与核心环节实现

3.1 从零搭建一个轻量技能注册表

抛开复杂框架,直接用一个 Python 装饰器就能实现技能注册表。核心目标有三个:注册方便、查询容易、执行路径清晰。

先说注册。技能就是一个普通的类,通过一个 register 装饰器把自己挂到一个全局注册表里。注册表是字典,key 是技能名,value 是技能对象。查询时通过 get_skill(name) 返回对应技能,执行时通过 execute(name, params) 走统一入口。

代码示例:

# skill_registry.py from typing import Callable, Dict, Any, Optional import json class Skill: def __init__(self, name: str, description: str, parameters: dict, handler: Callable): self.name = name self.description = description self.parameters = parameters self.handler = handler def execute(self, params: dict) -> dict: # 标准化执行入口,后续可在这里增加校验、日志、超时控制 try: result = self.handler(params) return {"status": "success", "data": result} except Exception as e: return {"status": "error", "message": str(e)} class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def register(self, name: str, description: str, parameters: dict): def decorator(handler: Callable): skill = Skill(name, description, parameters, handler) self._skills[name] = skill return skill return decorator def get(self, name: str) -> Optional[Skill]: return self._skills.get(name) def list_skills(self) -> list[dict]: return [ { "name": s.name, "description": s.description, "parameters": s.parameters, } for s in self._skills.values() ] def execute(self, name: str, params: dict) -> dict: skill = self.get(name) if not skill: return {"status": "error", "message": f"skill not found: {name}"} return skill.execute(params)

这个注册表非常轻,但是已经具备核心结构。后面对接模型时,我会把 list_skills() 的结果转成模型需要的 function schema,再把 execute() 作为回调函数传给模型 API。

3.2 定义第一个可复用技能

用上面的注册表,实现一个“根据城市名查天气”的技能。参数 Schema 我故意做得严格一点,防止模型乱传:

registry = SkillRegistry() @registry.register( name="get_weather", description="根据城市名获取当前天气和未来24小时状态,返回温度、天气现象、风力。当用户询问天气、气温、是否下雨、适不适合出门时使用。", parameters={ "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名,例如:北京、上海、广州" }, "date": { "type": "string", "description": "查询日期,格式为 YYYY-MM-DD,默认当天", "enum": ["2025-03-01", "2025-03-02", "2025-03-03"] } }, "required": ["city"], "additionalProperties": False } ) def get_weather(params: dict) -> dict: city = params["city"] date = params.get("date", "2025-03-01") # 实际项目里这里会调用真实天气 API # 这里用一个模拟数据做示例 weather_map = { "北京": {"temp": "12°C", "condition": "晴", "wind": "3级"}, "上海": {"temp": "15°C", "condition": "小雨", "wind": "4级"}, "广州": {"temp": "20°C", "condition": "多云", "wind": "2级"}, } data = weather_map.get(city, {}) if not data: raise ValueError(f"未找到城市 {city} 的天气数据") return {"city": city, "date": date, **data}

注意这里有个细节:我把 date 参数用 enum 限定为三个具体日期,这在真实场景里当然不合理,但在一开始调试时非常有用。因为模型在生成参数时,对开放式日期容易自由发挥,而限定枚举值可以快速判断“到底是技能描述没说清楚,还是模型乱填”。等稳定了之后,再放开为 pattern 校验,比如 pattern: "^20\d{2}-\d{2}-\d{2}$"。

3.3 书写模型侧的技能调度逻辑

有了注册表,接下来要做的就是把注册表里的技能暴露给模型。以 OpenAI 函数调用风格为例,会经历三个步骤:生成 schema 列表、让模型返回函数调用请求、根据请求执行并回传结果。

第一步是把注册表的技能列表转换成模型可识别的 function 参数:

def build_function_schemas(registry: SkillRegistry) -> list[dict]: schemas = [] for s in registry.list_skills(): schemas.append({ "type": "function", "function": { "name": s["name"], "description": s["description"], "parameters": s["parameters"], } }) return schemas

第二步是在对话循环里加入判断:如果模型返回了 function_call,就执行技能并把结果返回到对话上下文中,再让模型继续生成最终回复。核心代码如下:

def run_agent_with_skills(user_message: str, registry: SkillRegistry): messages = [ {"role": "system", "content": "你是一个智能助手,根据需要调用技能来回答问题,技能执行完成后根据结果用自然语言回复用户。"}, {"role": "user", "content": user_message} ] functions = build_function_schemas(registry) # 请求模型返回答案或函数调用(此处假设已有 client 实例) response = get_model_response( model="your-model", messages=messages, functions=functions ) # 如果模型请求调用函数 if response.get("function_call"): fc = response["function_call"] skill_result = registry.execute(fc["name"], fc["arguments"]) messages.append({ "role": "function", "name": fc["name"], "content": json.dumps(skill_result, ensure_ascii=False) }) # 将结果回喂给模型,生成最终自然语言回答 final_response = get_model_response( model="your-model", messages=messages, functions=functions ) return final_response return response

这段代码是最小可用的 Agent 调度逻辑。它没有处理多轮技能调用、没有做技能并发、也没有做超时重试,但已经跑通了“模型选技能、技能执行、结果回传、模型总结”的完整闭环。实际项目里我会在此基础上增加循环上限,防止模型陷入反复调用同一个得不出结果的技能。

3.4 一个完整的多技能场景演练

为了让流程更直观,假设我的技能库里还有两个技能:一个用于计算订单金额,一个用于查询用户优惠券。用户输入“帮我看看我这个订单用了优惠券后要付多少钱”。代理的执行序列应该是:

第一轮,模型判断需要两个技能。这里又分两种调度策略:串行和并行。如果是串行,模型会先调用查询订单金额,拿到结果后再调用查询优惠券,最后计算。如果是并行,模型会同时发出两个 function_call,分别拿到金额和优惠券信息。支持并行的模型在 function calling 返回值中会带多个 choices,每个 choice 对应一次调用,代码上要做循环处理。

我一般优先选择并行,因为用户的问题大多可以拆成多个独立查询,并行后明显减少模型交互轮次。但并行也会带来问题:如果两个技能都修改同一个业务状态,可能出现竞争状态。所以我的原则是“查询类技能并行,写操作类技能串行”。读写混合的场景下,宁可多花一次调用,也不能让数据出错。

4. 常见问题与排查技巧实录

4.1 模型老选错技能

这是做 agent-skills 时最常遇到的问题,几乎没有之一。排查时我会先看模型的完整响应,确认它到底选了什么、为什么选。常见原因有三类:

第一,技能描述太泛。比如描述写了“获取用户信息”,结果用户同时有“获取购物车”“获取订单”两个相关请求,模型就分不清该调用哪个。解决方式是把描述里的“触发条件”写明确,改成“获取已登录用户的基本资料,仅在用户询问昵称、手机号、头像、会员等级时使用”。

第二,技能名相似度太高。get_order_by_id 和 get_order_detail 这种命名,模型选错的概率会大幅上升。我的教训是要把名字做成语义上有明显差异的,比如 get_order_base_info 和 get_order_timeline,同时在描述里互相补充一句“如果需要订单状态流转信息,请使用 get_order_timeline”。

第三,上下文里技能太多。哪怕描述写得好,一次性给模型 30 个技能,模型的后几轮决策还是会退化。解决方案就是我前面说的“两段式检索”:先给模型一个精简索引,让它选出候选,再加载完整 Schema。

4.2 技能执行超时或卡死

技能里要是调用了外部 API,经常遇到 30 秒超时这类问题。模型侧等待时间一长,用户那边体验非常差。我的处理方式是把技能执行统一放到异步任务里,先返回一个“任务处理中”的状态给模型,然后通过回调或轮询获取结果。在技能代码层面,我会给每个接口调用加上带 timeout 的 HTTP 客户端,并设置重试策略:

import requests from requests.adapters import HTTPAdapter session = requests.Session() adapter = HTTPAdapter(max_retries=2) session.mount("https://", adapter) session.mount("http://", adapter) try: resp = session.get(url, timeout=(3.05, 10)) resp.raise_for_status() except requests.exceptions.ConnectTimeout as e: return {"status": "error", "message": "连接超时,请稍后重试"} except requests.exceptions.ReadTimeout as e: return {"status": "error", "message": "读取响应超时"}

这个例子看起来简单,但大多数技能不可用,根源都出在没做超时和重试。技能执行失败后,模型就会把错误堆栈原样返回给用户,专业形象瞬间归零。所以我的规范是:技能层绝对不能把底层异常直接抛给模型,所有异常都转成可读的自然语言消息。

4.3 执行成功但结果不符合用户期望

这个坑特别隐蔽。技能没报错,参数也校验通过了,但用户说“不对,我要的不是这个”。大部分时候是因为技能返回的数据模型虽然结构好,但含义不清晰,模型在总结时“过度发挥”,把数据里没有的内容脑补了出来。

比如用户问“上周订单总额是多少”,技能返回了一个订单列表,模型自己加总并说“上周订单总额为 5000 元”,但实际上列表里有取消状态的订单被一起算进去了。解决方式有两个层面:一是查询技能直接返回汇总结果,不让模型自己加总;二是技能返回结果里必须附带必要的业务语义,比如把已取消订单单独用 status 字段标出来,并在描述里写明“汇总时仅统计已支付订单”。

我踩过这个坑之后定了一条规矩:只要涉及计算、统计、判断,技能层直接给最终结论,不要让模型在回复里做二次运算。模型的强项是语言组织和意图理解,不是高精度计算。

4.4 参数校验把合法输入也拒绝了

有段时间我为了防模型乱传,加了很严的 JSON Schema 校验,结果合法输入也被拦截了。典型场景是用户提交一个手机号,模型把它转成 number 类型,但首位是 0,结果被解析成了少一位。后来我在参数层统一改成 string + pattern 校验,才解决类似问题。

再有一个是时间参数。模型可能会把“下周一”解析成一个具体日期,但时区处理不当,转换出来的日期差了 8 小时。我的经验是:时间类参数尽量用时间戳或 ISO 8601 字符串,并要求模型直接透传用户原文语义,不做额外换算,换算逻辑放在技能内部完成。

参数校验还遇到过一个有意思的问题:我给某个技能加了 additionalProperties: false,禁止模型加额外字段。结果有一版模型在 function calling 时固执地要把一个描述性字段塞进 arguments,被校验拦截后一直重试,产生了几十次无效请求。后来我在代码里做了一层“宽松模式”,当校验连续失败 3 次,就丢弃多余的未知字段,只保留 schema 定义的字段再执行。这算是个折中方案,既防住了幻觉字段,又不至于让流程卡死。

5. 进阶能力与运维经验

5.1 技能版本管理

技能不是写好了就不变的。业务接口升级、参数调整、描述优化,都会让技能逻辑变化。如果技能没有版本,模型在上下文里存的还是旧 schema,调用时就会发生参数不匹配。

我的做法是在 Skill 对象里加一个 version 字段,并在注册表 list_skills() 时带上版本号。当模型上下文里的技能 schema 与当前注册表版本不一致时,代理侧会重新加载最新版本,并让模型重新规划调用参数。版本管理不需要很重,用一个自增整数或者 git 提交标识就够了。

还有一个细节:同一个技能的不同版本,最好不要在同一个注册表里同时存在,除非你想做灰度对比。因为模型看到两个同名技能时会混乱。我的方案是注册表里只保留当前线上版本,旧版本通过 git 历史回溯。

5.2 技能调试与单体测试

技能的调试和传统接口调试还不太一样:除了验证输入输出,还要验证“描述和 schema 是否被模型正确理解”。我常用的测试组合是:

def test_skill_schema_and_execution(): registry = build_registry() # 1. 校验 schema 合法性 schemas = build_function_schemas(registry) assert len(schemas) > 0 # 2. 校验执行层 result = registry.execute("get_weather", {"city": "北京"}) assert result["status"] == "success" # 3. 校验错误场景 error_result = registry.execute("get_weather", {"city": "不存在的城市"}) assert error_result["status"] == "error"

但只测代码是不够了,我还会用一组“意图样本”来做回归测试。意图样本就是真实用户会说的话,比如“北京明天冷不冷”“上海今天适合穿什么”,然后把样本喂给模型,检查模型选中的技能和参数是否符合预期。这样做一次能发现大量描述歧义和 schema 设计问题。我每次新增或修改技能,都要跑一遍意图样本回归,否则不敢上线。

5.3 技能的中台化管理

当技能数量到几百个时,再把技能逻辑散落在各个服务里,会变成一场灾难。我后期的项目里会把技能注册表独立成一个技能中台服务,提供统一的注册、查询、执行、版本管理 API。各个业务线只需要上传技能定义和 handler,中台负责接收模型调用并把请求路由到对应后端。

这个阶段要注意性能问题。几百个技能列表每次都要构建 Schema,消耗较慢,我用的优化手段是:在服务启动时构建一次 Schema 缓存,技能变更时通过消息通知刷新缓存。执行层则尽量用异步请求,避免技能 A 的慢查询阻塞技能 B 的执行。

一些实际操作中的体会

做 agent-skills 最大的收获,不是代码跑通的那一刻,而是让我想明白了一个问题:要真正提升 Agent 的效果,重点不该放在“更聪明的模型”上,而应该放在“更清晰的能力边界”上。模型要是能准确知道每个技能“是什么、何时用、怎么用”,很多幻觉和不稳定其实可以不治而愈。

如果你也打算在自己项目里落地这套体系,我的建议是从小做起:先挑三个业务上最频繁被问到的需求封装成技能,把描述和参数调顺,再逐步扩张。技能库不是越大越好,每一个没用的技能都在悄悄增加模型的决策噪音。把技能当作产品一样维护,每次改动都配上意图样本测试,你手里的 Agent 会稳定得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进