Agent技能系统设计:从零搭建可复用的智能体技能库

发布时间:2026/10/28 16:30:24
Agent技能系统设计:从零搭建可复用的智能体技能库

手头这个 agent-skills 项目,从能跑通到稳定上线,前后折腾了快一个月。简单说,agent-skills 就是给智能体预装的一批可复用能力:查订单、算指标、生成周报、同步外部系统……它们不埋伏在大段提示词里,而是以带名字、带参数、带返回结构的“技能包”形式存在,让大模型在任务过程中按需挑选、调用、组合。这套设计解决的核心问题,是智能体不能每次都被逼着从零推理完整动作流程,而是能直接调用已经验证过的专业动作,把通用智能和专用能力分开。写这篇文章,是想把设计思路、最小可运行代码、以及上线后踩过的坑一次讲清楚。如果你是正在做 Agent 功能的工程师,或者想沉淀技能库但还没找到落地路径的同学,应该能直接拿去参考。

不过在聊代码之前,得先把一个基本问题说透:技能到底是什么。我最早也把它当成花哨的“插件”,直到第一次在真实项目里翻车才彻底改过来。

1. 先搞懂技能层:为什么普通工具函数不够用

1.1 一次真实翻车:没有技能系统的 Agent 有多难用

第一版智能体没有技能系统,所有动作都写在提示词里,告诉模型“你可以执行步骤一、步骤二、步骤三”,模型每走一步输出一段 JSON action,代码再去解析执行。听起来很灵活,实际用起来全是坑。

第一个坑是动作不固定。同一个“查询订单”动作,模型一周里生成过 12 种字段写法,有叫 order_id 的,有叫 id 的,还有叫 orderNumber 的。解析层只能把所有可能的字段名都兼容一遍,每加一个新场景,解析规则就膨胀一次。

第二个坑是流程脆弱。一次客服对话里,模型要生成“查询订单”的 JSON,紧接着还要生成“确认订单状态”的 JSON,只要中间某个字段写错,整条链路就断。当时的日志显示,超过三成的失败发生在动作解析环节,也就是模型想做的事情是对的,但动作描述是错的。第三个坑是 token 消耗,同一个取数步骤在每段会话里重复生成,平均每个会话多消耗七八百 token,成本高还不稳定。

后来我把这些动作收敛成固定接口,模型不再“生成动作”,而是“选择动作”。这就是 agent-skills 最早的原型。那次重构让我明白一件事:通用模型擅长的是做决策,不是每次都从头发明操作过程。

1.2 Skills、Tools、Plugin 的边界到底在哪

网上聊 Agent 概念时,Tool、Skill、Plugin 经常混着用,我一开始也分不清。后来按自己的工程口径做了区分。

Tool 是原子能力,比如 send_email、query_order_status,一个函数对应一件事,没有内部流程。Skill 是行为单元,内部可能组合多个 Tool,比如“生成月度复盘报告”需要拉数据、算指标、生成摘要、发邮件,这四步拼在一起才算一个完整技能。Plugin 更像一种业务集成形态,通常包含多个 Skill,还会带配置界面、权限模型和生命周期管理。

用表格看更清楚:

维度ToolSkillPlugin
粒度原子操作复合行为单元业务功能包
触发方式模型直接调用模型按意图调用按场景安装/启用
典型例子发送单封邮件生成并发送周报CRM 集成套件
是否关心业务语义不关心必须关心天然绑定业务域

我的观点是,名字怎么叫不重要,但“原子层”和“行为层”必须分开。如果直接把 Tool 当 Skill 用,模型在复杂任务里就会把决策压力全扛在自己身上,组合动作时反复出错。如果反过来,把太大太重的流程塞进一个 Skill,模型又很难判断什么时候该触发它。Skill 卡在中间这一层,是最值得投入精力的。

1.3 我坚持的三条设计原则:原子性、可发现性、可组合性

定义技能之前,我先定了三条原则,后面所有设计都围绕它们展开。

原子性,是指一个技能只做一件完整的事。“生成周报”和“发送周报”必须拆开,不能合并成一个“生成并发送周报”的技能。原因很现实:用户可能只想生成不想发送,合并之后模型会犹豫是否触发。判断原子性的简单办法是看技能名字里的动词能不能再拆,能拆就拆。

可发现性,是指技能对模型而言是“找得到”的。模型不是靠代码指针发现技能,而是靠名字和描述。描述写得含糊,技能就等于不存在。这就像超市货架,商品再好,标签写错顾客也拿不到。

可组合性,是指一个技能的输出可以被另一个技能继续消费。如果每个技能返回值格式都不一样,编排层只能写一堆 if-else 来适配,组合能力基本为零。

为什么是这三条?因为我观察到一个规律:技能系统的大部分失败,落在触发错误、参数错误、结果无法继续传递这三类问题上。三条原则恰好各自对症。后面写代码、写描述、做编排时,凡是不符合任一原则的做法,我都会再想一遍是否值得。

2. 一个技能系统的三层设计:定义、注册、执行

2.1 技能定义层:名字、描述、参数契约这样写才不翻车

技能定义层是最容易糊弄、也最容易返工的部分。一个技能至少要有 name、description、parameters、func 四样东西,前三样是给大模型看的,func 是真正干活的。

名字的规范是动词开头、语义唯一。query_order_status 比 order 好,generate_daily_report 比 report 好。要特别警惕近义命名,比如 query_order 和 fetch_order 同时存在,模型会随机选一个,而且你很难在测试里复现这种随机性。

描述是重头戏。我的模板是三层:什么场景用、什么场景不用、依赖什么前置信息。写一个好的示例:

{ "name": "query_order_status", "description": "当用户询问订单状态或物流信息时使用。必须提供订单号 order_id;如果用户只给了手机号,请先调用 lookup_customer_order 获取订单号,不要直接调用本技能。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,格式如 SO2024001" } }, "required": ["order_id"] } }

这段描述里写了两条关键信息:一是触发条件,二是反例,直接告诉模型什么情况别来。很多技能描述只写“查询订单状态”,模型在用户只是抱怨物流慢的时候也会莫名其妙触发一次。

参数契约我直接用 JSON Schema。经验是 required 字段越少越好,能设枚举就设枚举。一个查询接口如果让模型填十几个筛选条件,模型一定会幻觉出几个不存在的值。正确做法是只暴露高频参数,低频参数合并成一个 options 对象,需要时由更上层的路由技能决定怎么给。

返回结构也在这里定下来。我统一用 status、data、error、meta 四段式,任何技能都返回这个结构。status 只有 ok、empty、error 三种,empty 表示“逻辑正确但没有结果”,error 表示异常。data 是真正要输出的内容,error 是给模型看的错误描述,meta 里放耗时、来源这些旁路信息。之所以不直接裸返回结果,是为了让编排层和模型都能快速判断下一步该干什么。

2.2 注册层:从字典表到技能发现

技能定义好了,需要一个地方存它们,这就是注册层。最小实现是一个注册表类,内部用 dict 按名字存技能,提供 register、get、all 三个方法。

注册表真正要解决的,是“哪些技能该暴露给模型”的问题。工具列表会占用上下文窗口,技能数量少的时候全量暴露没问题,技能一多,模型就开始挑花眼。我自己的经验阈值是 30 个以内可以直接进提示词,超过 30 个就要做技能发现。

技能发现有两个层次。第一层是场景分组,把技能按业务域分成客服、运营、数据分析等组,每个会话只挂载相关组。第二层是向量检索,把技能 name 和 description 一起做 embedding,运行时拿用户任务向量召回最相似的 Top-K 个技能。实测下来,Top-K 取 8 到 10 效果比较稳,召回太少模型没得选,召回太多又回到“选择困难”的问题。

注册表还应该存些旁路元数据,比如 version、author、tags、usage_count。这些字段不会直接发给模型,但对后续的版本管理和调用统计很有用。注意不要把所有技能都做成全局可见,能做白名单就做白名单,避免模型在非相关场景里乱翻。

2.3 执行层:把技能塞进 Agent 主循环

执行层是最容易写崩的一层。标准做法是 ReAct 循环:模型思考,决定调用某个技能,执行器执行技能,把结果作为 observation 返回,模型看到结果后继续推理,直到完成任务。

循环代码不复杂,但有两个细节必须注意。

第一,技能内部异常不能直接抛出,要捕获后转成结构化错误返回。模型的能力之一是从错误信息里自愈,你把异常吞掉、把 error 字段塞回对话,模型往往能自己修正参数再试一次;如果你直接让整个 Agent 崩溃,用户看到的就是一句冷冰冰的“系统错误”。

第二,必须设超时。一个技能如果内部调用了外部 HTTP 接口,可能会卡住几秒甚至几十秒。执行器要单独给技能调用加 timeout,超时结果也按 error 返回。否则一个技能卡住,整个循环的空转轮次就没了。

执行层还要区分不同模型的能力差异。有些模型原生支持 function calling,可以直接传 tools;有些模型不支持,只能用文本形式把技能列表塞进 system prompt。后者的提示词里要写明严格格式,比如“只允许输出以下动作的 JSON”,否则模型会自由发挥。

3. 手写一套最小可用的智能体技能系统

3.1 技能类定义:十行代码搭建基础骨架

先定义一个最朴素的 Skill 数据类,把设计层的字段固化下来:

# skill.py from dataclasses import dataclass from typing import Callable, Any, Optional @dataclass class Skill: name: str description: str parameters: dict func: Callable[..., Any] version: str = "1.0.0" author: str = "platform"

parameters 这里存的是 JSON Schema 字典,func 是真正执行的回调函数。之所以用参数对象而不是普通函数签名,是因为要直接生成模型可读的工具描述,而 function calling 的标准输入就是 JSON Schema。

定义两个最简单的技能做示例,一个是查询订单,一个是计算订单金额汇总。查询订单函数内部可以对接数据库,这里只做占位演示:

def query_order_status(order_id: str) -> dict: # 真实项目里这里会查数据库或调用订单服务 if not order_id.startswith("SO"): return {"status": "error", "data": None, "error": "订单号格式不正确", "meta": {}} return { "status": "ok", "data": {"order_id": order_id, "state": "shipped"}, "error": None, "meta": {"source": "order_service"}, }

注意返回值里我用的是规范四段式,这样后续编排和模型读取都不需要额外适配。

3.2 注册中心与 LLM 工具描述生成

注册中心的核心能力是把 Skill 对象转成大模型能理解的 tools 格式:

class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: Skill) -> None: if skill.name in self._skills: raise ValueError(f"duplicated skill: {skill.name}") self._skills[skill.name] = skill def get(self, name: str) -> Skill: return self._skills.get(name) def all(self) -> list[Skill]: # 按名字排序,保证生成工具列表的顺序稳定 return [self._skills[k] for k in sorted(self._skills)] def for_llm_tools(self) -> list[dict]: return [ { "type": "function", "function": { "name": s.name, "description": s.description, "parameters": s.parameters, }, } for s in self.all() ]

代码里有一个我踩过的坑:遍历 dict 时如果不排序,工具列表顺序每次可能不同。模型对列表前面的工具更敏感,顺序不稳定会导致同样的任务有时触发这个技能、有时触发那个技能。一次修复方式是按技能名排序,简单有效。

另外一个细节是注册时不允许重名。重名往往是复制粘贴造成的,如果不拦截,后面排查问题时你会面对两个不同行为却同名技能,完全无法定位。

3.3 Agent 主循环:让模型学会按需调用

主循环是整个系统的引擎,逻辑只有几步,但每一步都有讲究:

def run_agent(user_task: str, registry: SkillRegistry, llm, max_steps: int = 8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_task}, ] for step in range(max_steps): response = llm.chat( messages=messages, tools=registry.for_llm_tools(), tool_choice="auto", ) if not response.tool_calls: return response.content messages.append(response) for call in response.tool_calls: skill = registry.get(call.function.name) try: result = skill.func(**json.loads(call.function.arguments or "{}")) observation = {"status": "ok", "data": result, "error": None, "meta": {}} except Exception as exc: observation = {"status": "error", "data": None, "error": str(exc), "meta": {}} messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(observation, ensure_ascii=False), }) return "已达最大轮次,任务未能完成"

这段代码最关键的思路是:把所有信息都通过 messages 传回给模型,让它自己消化。出错信息也不例外,我故意把异常字符串放回 observation,模型通常会在下一轮修正参数重新调用。自己亲眼见过模型在拿到“订单号格式不正确”后,主动把传入的 id 改成标准 SO 前缀并重试。如果你把异常直接吞掉,模型就失去了一次“自愈”的机会。

max_steps 必须设上限。我线上常用 8 到 10,太大会让无意义的循环持续很久,太小会让复杂任务完不成。另外,跟模型约定好:完成用户任务后不要做多余动作,直接输出结论。这样可以避免模型在已得出答案后又忍不住调用一个不相关的技能。

3.4 技能评测:只跑通不算数

很多项目做到“能跑通”就停了,这等于没做质量保障。我的做法是三层评测。

第一层是函数级测试,直接对技能函数做单元测试,验证输入输出是否稳定。第二层是接口级测试,模拟真实参数给 LLM 的 function calling API,检查模型是否在正确场景下触发了正确技能。第三层是任务级评测,准备十来条典型用户请求,记录每次调用的技能名、参数、是否完成任务。

一个最简单的评测集可以这样记录:

测试任务期望触发技能实际触发是否正确参数是否正确任务完成
用户查询订单 SO2024001 的状态query_order_status是是是
用户抱怨物流太慢但没给订单号不应触发技能未触发无是
用户要求做本周订单金额汇总generate_order_summary是是是

每个模型版本升级后,这个评测集都要完整跑一遍。我遇到过模型版本更新后,同一个描述下技能触发率从 90% 掉到 60% 的情况,如果没有回归评测,问题会直接漏到线上。

4. 技能编排与组合:从单一技能到完整工作流

4.1 三种最基本的编排模式:顺序、并行、条件路由

单个技能能解决单点问题,但真实任务往往是流程。编排最常用的有三种模式:顺序执行、并行执行、条件路由。

顺序执行也叫 pipeline,适合步骤之间有依赖关系的场景。比如“生成运营日报”,必须先拉销售数据,再聚合统计,最后生成摘要,每一步的输出是下一步的输入。实现时不要写死每一步,而是给编排器一个技能序列,由上一技能返回结果决定下一技能的参数。

并行执行适合互不依赖的动作。比如用户问“我的订单 SO2024001 发货了吗,同时最近三个月有没有售后记录”,查询物流和查询售后是两个独立技能,可以并发执行再合并结果。并发能显著缩短响应时间,但要注意共享状态别被并发写坏,我一般只做只读并行。

条件路由是让决策逻辑和动作逻辑分离。用一个 router 技能,根据输入内容判断走哪条分支。比如客服工单进来,先做意图分类,再决定调用“自动退款技能”“人工介入技能”还是“发送安抚话术技能”。路由本身也可以是大模型决策,但动作仍然落到具体技能上。这个模式能避免把一堆 if-else 写在主循环里。

4.2 数据流与状态:技能之间的信息怎么传

技能编排最容易被忽略的是数据流设计。几种编排模式跑起来后,技能 A 的输出如何变成技能 B 的输入,是决定可组合性的关键。

我建议在编排层维护一个 skill_result_pool,按技能名和调用序号存储上次结果。比如订单查询技能输出了 data 里的订单对象,下一步要算金额汇总,就从 pool 里取 order_id,不需要让模型重新生成一遍参数。

还一个容易犯的错:把超大结果直接塞回 messages 传给模型。比如拉取全量订单明细后,如果直接把几百行记录塞进上下文,模型会浪费大量 token,还容易看漏关键信息。正确做法是存到会话级内存对象里,只给模型返回一个引用 ID 或者更精简的摘要。这就像中央厨房备菜,备好的菜放在工作台,厨师只需要知道“菜已备好、编号是 03”,不用每次重新读一遍菜谱。

记录状态时别忘了清理。会话结束后 pool 要释放,否则长期运行的内存会越来越大。我当时就是没做清理,跑了三天后内存占用接近翻倍,查了半天才发现是历史技能结果全堆在内存里。

4.3 技能调技能:高阶组合的甜区与坑

技能可以调用其他技能。实现上很简单,把 registry 传进技能函数里,内部按名字取子技能执行:

def generate_order_report(order_id: str, registry: SkillRegistry) -> dict: order = registry.get("query_order_status").func(order_id) if order["status"] != "ok": return order summary = registry.get("calc_order_summary").func(order["data"]) return summary

这个能力很强,可以把一条高频稳定路径固化成新技能。比如“生成订单报告”,内部包含查询订单、计算汇总、格式化三个子技能,外层模型只需要调用 generate_order_report 一个入口。

但这里有两个大坑。第一个是组合深度失控。技能调技能再调技能,一旦超过两层,错误定位会非常痛苦。我的经验是内部组合最多两层,超过两层就重构,把更深层逻辑直接揉进底层技能里。第二个是隐式依赖。如果技能 B 依赖技能 A 先被调用,但编排器没有显式保证顺序,结果就会不稳定。解决方法是把这种依赖关系写进技能描述,或者在编排层显式校验前置条件。

组合也不是越多越好。过度封装会让模型变成“被动的字典查找”,它不知道内部发生了什么,一旦用户需求稍微偏离既定流程,模型就不知道该怎么调整了。我现在只在子流程足够稳定时才封装组合技能,新场景里更多的还是让模型在原子技能上自由编排。

5. 常见问题与排查实录:我踩过的四个大坑

5.1 模型死活不调用技能,先查这三样

技能列表都传进去了,模型就是无视,直接凭记忆回答问题,这是最常见的问题。排查时我按以下顺序查。

第一个是描述不清晰。技能描述里没有触发场景的关键词,模型判断不出来。比如一个负责查询会员积分的技能,描述只写“查询积分 ”,模型在其他话题下就会忽略它。改成“当用户询问会员积分、积分抵扣、积分过期时间时使用”,触发率立刻上升。

第二个是工具列表过长。技能超过几十个时,模型对后面的技能基本视而不见,甚至整个 tools 参数被截断。解决办法是按场景分组挂载,或做向量召回。

第三个是缺少调用示例。有些模型对“什么时候该调用工具”的理解比较弱,给出一个完整的用户提问加工具调用示例,比在描述里反复强调有效得多。我在 system prompt 里固定放一组示例对话,实测触发率和参数准确率都有明显提升。

还有一类特殊情况,模型本身不支持 function calling,这时要把工具转成文本格式塞进提示词。如果用了不支持 tool calling 的模型却仍传 tools 参数,API 不会报错,但模型完全不会触发。

5.2 技能被错误调用:名称撞车与参数幻觉

技能被误触发比不触发还难修。最常见的成因是两个技能名字或描述太接近。比如 query_order_status 和 query_after_sale_record,描述里都提到“订单”,模型在用户问“订单什么时候到”时可能错误触发售后记录查询。解法是名字差异拉大,描述里写清楚各自专属场景,必要时加否定语句:“如果用户询问物流进度,请使用 query_order_status,不要使用本技能。”

参数幻觉则是模型在调用时自动补全了用户没提供的信息。最危险的情况是缺 required 参数时,模型凭空填一个看似合理的 order_id。解法是执行层先做参数校验,和 required 字段严格比对,缺失就返回参数错误。还可以用枚举约束可选值,把模型发挥的空间压缩到安全范围。

另一个我现在才补上的防线是语义校验。比如 order_id 有固定 SO 前缀,哪怕模型填了完整字段,格式不对也要进去查一遍,查不到就返回 empty 而不是 ok。绝不在空结果上返回成功状态。

5.3 “假成功”:执行成功但结果是错的

假成功比报错隐蔽得多。技能函数返回 status=ok,模型基于这个结果写出一段漂亮但错误的结论,用户察觉不到,却会被错误数据坑到。

典型场景是报表分析。拉当月销售数据,结果表里为空,但函数返回的是空列表,外层模型继续写“本月销售额为零,建议加强推广”。解决方案是定义完备状态,把“无数据”和“数据正常”分开:查询没拉到任何行,就返回 status=empty,模型看到 empty 就不会生成空洞结论。我在所有查询类技能里都强制加了这个判断,空结果必须显式表达出来。

另一个防御手段是结果断言。技能返回前,用几行代码校验结果是否符合预期,比如金额汇总必须大于等于零、图表数据长度不能为空,不满足就抛异常转成 error。我把断言当成技能的“出厂质检”,宁可让模型看到错误,也不能让它看到假象。

5.4 失控循环:Agent 在一个技能里打转

Agent 在同一个技能上反复调用,每次参数几乎一样,结果也一样,白白消耗 token 和轮次。这类问题在高自由度编排里很容易出现。

一个真实案例:模型反复调用查询技能十几次,每次传的订单号都一样,返回也相同,但模型就是不肯往下走。原因不是模型蠢,而是返回信息没有给它带来“新东西”,它不知道下一步该做啥,只能原地重复。

我的修复方案分两步。第一步是重复动作检测,记录最近 N 步的“技能名 + 参数哈希”,发现完全重复时,在 tool message 里追加一句话:“你已经用相同参数调用过该技能,结果为 X,请不要再重复调用。”模型看到这句话后通常会改变策略。第二步是提供“收尾技能”,给模型一个 stop_agent 技能,允许它在觉得任务完成时主动结束循环。模型有能力收尾之后,就不需要靠无限调用外部技能来拖延决策了。

5.5 问题速查表

症状可能原因排查与解法
模型不调用技能描述含糊、列表过长、缺少示例重写描述、场景分组、向量召回、补充调用示例
模型调用错技能名称相似、描述范围重叠差异化命名、描述加否定场景、执行层加白名单
技能成功但结果错误空结果未显式表达、无校验增加 empty 状态、返回值断言、语义校验
Agent 重复调用同一技能信息不足、模型在兜圈子重复动作检测、提供 stop_agent 技能、压缩上下文
工具列表顺序不稳定dict 遍历无序按技能名排序生成 tools

6. 真正落地后,我沉淀下来的工程习惯

系统上线容易,长期稳定运行难。这里分享几个我后来一直坚持的工程习惯,能帮你少走很多弯路。

技能一定要做版本管理。注册表里每个技能带 version,每次行为变化都要升版本并在 changelog 里记一笔。不要小看这件事,一个技能描述被悄悄改过,模型触发率可能浮动十几个百分点,如果没有版本记录,连改动来源都查不到。模型对技能描述的依赖程度远超预期,描述即行为。

衡量技能价值时,别只看“跑通次数”,要看调用率、参数错误率、空结果率。我在执行层加了埋点,每个技能被调用时都会记录会话 ID、动作名、参数摘要、返回状态、耗时。日志格式类似这样:

{"session_id": "s_123", "action": "query_order_status", "params": {"order_id": "SO2024001"}, "status": "ok", "latency_ms": 120}

这些日志是排查问题的第一手材料。有一次线上反馈订单状态显示错误,我直接按会话 ID 拉出整条技能调用链,几分钟就定位到是某个子服务返回字段变更,而不是模型出了问题。日志里尽量不要记录敏感业务数据,参数可以做哈希或脱敏,保护用户隐私这件事怎么强调都不为过。

最后说一点我个人体会。做 agent-skills 最难的其实不是写代码,而是定义技能边界。封装过头,模型变成被动查字典的工具,决策能力被架空;封装太细,模型又要花大量轮次在组合动作上,频繁出错。拿捏这个度没有捷径,只能靠持续观察真实会话记录,看模型在哪些场景触发了不该触发的技能、又在哪些场景迟迟不触发。每次优化技能描述和边界后,再跑一遍评测集确认效果。如果让我给新人一个建议,我会说:从二三个高频技能开始,跑稳了再扩,不要一开始就铺一个大而全的技能库。技能库的膨胀速度远比你想象中快,而每一个失效技能都是要还的技术债。