做Agent开发最早期的那段日子,我整个人是被“if-else”淹没的。用户说“查天气”,我就调天气接口;说“算价格”,就写死一个价格计算函数;说“发邮件”,再补一个SMTP调用。最开始只有三五个能力,这种写法勉强能跑。等技能数量冲到十几个、任务类型开始交叉组合时,代码直接失控——Agent的逻辑越来越厚,每加一个能力就要动一轮主流程,改一次崩三次。后来我才意识到,问题根本不在代码写得烂,而是架构里缺了一个关键的东西:技能层。
这个技能层,就是近几年大家在Agent开发里反复强调的“Skills”。它的核心思路很简单:把每一个能力封装成独立的、可被大模型识别的模块,让Agent通过描述、参数、返回值来自主判断“什么时候该用哪个能力”,而不是靠开发者写死调用路径。本文不聊概念,就聊落地。我会从技能系统的演化动机、技能描述怎么写、注册调度怎么实现、多技能怎么编排,以及我实际跑了30多个技能后踩过的坑这五个维度展开,给出一套可以直接抄走的工程实践。无论你是正在搭个人Agent,还是准备给团队做一个多技能工作流平台,这篇都能帮你少走几周弯路。
1. 从“写死函数”到“技能注册表”:Agent架构的一次必然演化
1.1 没有技能层的时候,我遇到了什么麻烦
最早做那个叫模拟项目X的Agent时,我是这么干的:在主流程里维护一个大字典,key是任务类型的名字,value是对应的处理函数。模型输出一段带意图标记的文本,我正则匹配一下,命中哪个key就调哪个函数。听起来挺正常,对吧?一旦技能数量上了两位数,问题就全冒出来了。
首先是“组合爆炸”。用户说“把上周的销售数据整理成周报发到群里”,这个任务至少涉及数据读取、表格生成、邮件推送三个能力。我不可能把所有组合都写成if-else,更不可能让模型每次只调一个函数。其次是“微调地狱”。模型选错函数了,我得去翻prompt;函数返回值格式变了,我得去改解析逻辑;同一个函数被两个任务复用,改一处就影响另一处。整个系统变成一团互相拉扯的意大利面。
这其实就是缺少“技能抽象”的典型症状。技能层和普通函数的区别在于:函数是给人调的,技能是给模型调的。人调用函数靠记忆,模型调用技能靠的是对技能名称、描述、参数结构的语义理解。所以技能系统要做的第一件事,就是把“模型怎么发现能力”这个问题彻底解决掉。
1.2 技能不是函数,是“模型可发现的能力单元”
我后来把技能重新定义成四个组成部分:能力标识、能力描述、入参约束、出参结构。能力标识是一个稳定的字符串名字,比如fetch_sales_data;能力描述是用自然语言写清楚这个技能干什么、什么时候用、不干什么;入参约束是JSON Schema,告诉模型需要哪些参数、各自是什么类型;出参结构则约定返回值是JSON还是文本,字段有哪些。
这个定义看似简单,但它把“函数调用”和“技能调用”的本质区别点出来了。函数调用是确定的,调用方知道传入什么参数;技能调用是概率性的,模型需要通过描述来“猜测”这个技能是否适合当前任务。猜测就会出错,所以技能系统还要负责校验、纠错和反馈。比如模型传进来一个没有定义的参数名,系统要能捕获并提示模型重新生成,而不是直接把异常抛给用户。
想清楚这一层后,我的Agent架构就变成了:模型永远只面对“技能列表+用户意图”,所有的实际执行都在技能层完成。主流程不再写任何具体业务的if-else,只做一个“翻译官”:把用户表达翻译成技能调用序列,把技能结果翻译回用户能理解的语言。这才是一个能承载复杂任务的Agent该有的样子。
2. 技能描述是Agent的“API文档”,写不好模型就会用错
2.1 名字要唯一、可读、可预测
我一开始给技能起名非常随意,什么do_stuff、handle_data、process_it全都上过。结果就是模型经常在几个语义重叠的技能里犹豫不决。名字这件事,看似是小事,实则是技能发现的第一道关卡。一个名字应该让模型在读到的瞬间就大致判断出“这是不是现在该用的工具”。
我的建议是三个原则:动词开头、对象明确、避免缩写。比如get_user_info比userInfo好,generate_daily_report比report_maker好。缩写要看场景,行业内公认的可以保留,否则一律全拼。还有一点,同一个技能如果可能被多个领域复用,可以在名字里加领域前缀,比如sales_get_summary和hr_get_summary,这样模型更容易区分“数据源”不同的同类操作。
2.2 description决定调用率:菜鸟级与熟练级描述对比
description是技能系统里性价比最高、也最容易被忽视的字段。我见过太多人随便写一句“查询用户信息”就交差了,这种描述模型不选错才怪。description的核心任务,是帮模型建立“何时该用我”的完整判断,至少要包含四个维度:能力边界、典型触发时机、不适用场景、结果形式。
我拿自己项目里一个“生成周报”的技能举例。菜鸟级描述是“生成周报”,熟练级描述我写成这样:
根据提供的业务数据与总结要点,生成结构化周报文本。适合用户表达“写周报”“汇总本周情况”“整理周度汇报”等意图时调用。周报包含本周完成、下周计划、风险与建议三个板块。当用户要求的是日报或月报时不要调用本技能,应使用generate_daily_report或generate_monthly_report。
你仔细品一品,后面这版描述做的其实是两件事:把“用户可能怎么说”翻译给模型听,同时把“不该调用的边界”也划得清清楚楚。模型对技能描述的匹配本质上是语义匹配,描述里覆盖的触发说法越多,命中率就越高;边界写清楚了,误调率就越低。
2.3 parameters要按JSON Schema严格定义
参数部分是我踩坑最深的一块。最早我习惯用“参数名+类型说明”的自由格式,模型确实也能生成个大概,但一旦参数有枚举值、有必填可填之分、有嵌套对象,自由格式就完全顶不住了。模型会漏传、错传、把字符串传成数字、把数组传成对象,解析层直接崩溃。
我现在的做法是统一用JSON Schema定义。举个例子,一个“发送会议邀请”技能的参数定义长这样:
{ "type": "object", "properties": { "title": { "type": "string", "description": "会议标题", "maxLength": 100 }, "attendees": { "type": "array", "items": { "type": "string", "format": "email" }, "minItems": 1, "description": "参会人邮箱列表" }, "start_time": { "type": "string", "format": "date-time", "description": "会议开始时间ISO8601格式" }, "duration_minutes": { "type": "integer", "enum": [15, 30, 45, 60, 90], "default": 30, "description": "会议时长(分钟)" } }, "required": ["title", "attendees", "start_time"] }手写这种Schema难免出错,我现在都是直接让模型辅助生成,再由一个统一校验层跑一遍。校验层不通过时,把具体的校验错误原样返回给模型,让它自行修正参数。这个“校验不通过→反馈→重新生成”的循环,是整个技能调度里最有价值的一环。
3. 技能注册、发现与调度的落地实现
3.1 一套装饰器搞定技能注册
工程上我们要解决的第一个问题是:技能怎么“进系统”。我选择的是装饰器+注册表模式,原因很简单,新增技能时不需要改任何既有代码,只需要在一个新文件里写一个普通类或函数,然后打上装饰器标记,系统启动时就会自动把它收集进技能清单。
简化后的核心代码长这样:
# skill_registry.py from typing import Callable, Dict, Any, Optional class SkillRegistry: def __init__(self): self._skills: Dict[str, Dict[str, Any]] = {} def register(self, name: str, description: str, parameters: dict): def decorator(func: Callable): self._skills[name] = { "name": name, "description": description, "parameters": parameters, "func": func } return func return decorator def get_skill(self, name: str) -> Optional[Dict[str, Any]]: return self._skills.get(name) def list_skills(self) -> list[Dict[str, Any]]: return [ {k: v for k, v in s.items() if k != "func"} for s in self._skills.values() ] registry = SkillRegistry()然后在任意技能文件里,这样注册:
# skills/send_invite.py from skill_registry import registry @registry.register( name="send_meeting_invite", description="根据参会人和时间生成会议邀请,并投递到指定日程系统。", parameters={ "type": "object", "properties": { "title": {"type": "string"}, "attendees": {"type": "array", "items": {"type": "string"}} }, "required": ["title", "attendees"] } ) def send_meeting_invite(**kwargs): # 这里写实际的邀请发送逻辑 return {"status": "sent", "invite_id": "xxxx"}装饰器的好处是,技能的元信息和实现放在一起,维护的人扫一眼就能看懂。修改描述或参数时不用再跑到中央配置里去翻,直接在技能文件里改,启动后自动生效。
3.2 目录扫描式动态加载,新增技能不用改调度代码
注册表有了,接下来要解决的是“谁把技能文件加载进来”。我项目里的做法是目录扫描+动态导入:约定所有技能都放在skills目录下,每个文件里至少有一个被registry注册的函数,启动时用importlib遍历这个目录,把所有.py文件逐个导入。
动态加载这一段也分享给大家:
import importlib import pkgutil import skills def load_skills_from_package(package_name: str = "skills"): package = importlib.import_module(package_name) for _, module_name, _ in pkgutil.iter_modules(package.__path__): importlib.import_module(f"{package_name}.{module_name}")这个函数一跑,skills包下的所有子模块都会被导入,每个模块内部的registry.register调用自动完成注册。后续新增技能,开发者只需要多放一个.py文件,其他什么都不用动。这里还顺带解决了团队协作的问题:每个人开发自己的技能模块,避免了集中式修改带来的合并冲突。
3.3 从“模型JSON”到“技能调用”的中间层
注册和发现只是前置工作,真正决定系统稳不稳的,是调度层的实现。调度层接收的是模型输出的“调用意图”,通常是一个JSON对象,包含两个关键字段:要调用的技能名和参数。调度逻辑要处理的边界情况非常多:技能不存在、参数校验失败、执行超时、执行抛异常、返回格式不是合法JSON……每一个都要有兜底。
我这个项目里的核心调度函数简化后是这个样子:
def dispatch_skill(model_json: str) -> dict: import json, jsonschema try: intent = json.loads(model_json) except json.JSONDecodeError: return {"error": "invalid_intent_format", "hint": "请返回合法JSON"} skill_name = intent.get("name") arguments = intent.get("arguments", {}) skill_def = registry.get_skill(skill_name) if not skill_def: return {"error": "skill_not_found", "available": [s["name"] for s in registry.list_skills()]} try: jsonschema.validate(instance=arguments, schema=skill_def["parameters"]) except jsonschema.ValidationError as e: return {"error": "invalid_arguments", "detail": e.message, "hint": "请修正参数后重新调用"} try: result = skill_def["func"](**arguments) return {"success": True, "result": result} except Exception as e: return {"error": "skill_execution_failed", "detail": str(e)}注意我对“提示”的处理。很多Agent死在“模型一次调用失败后就蒙圈”这个问题上,所以我在每个错误返回里都加了hint字段,指导模型下一步怎么修正。这个反馈闭环,是让Agent具备“自我纠错”能力的最小实现,实测下来能把成功调用率从六成拉到九成以上。
4. 多技能编排:把技能串成工作流的三种模式
4.1 顺序模式:前一个技能的输出喂给下一个技能
单个技能会调了,但Agent真正干活往往是多步的。比如“整理上周销售数据并生成周报”,至少是查数据→算汇总→生成文案→推送通知。这时候就需要编排层。最简单的编排就是顺序执行:把上一个技能的结果作为下一个技能的部分输入,中间由系统做格式转换,而不是把这一步的决定权交给模型。
顺序执行的关键是每个技能的输出要“规整”。我统一约定技能返回必须是JSON对象,且核心数据放在result字段里。这样一个技能的result,可以直接映射到下一个技能的输入参数。我会在编排配置文件里写清楚这些映射关系。
4.2 条件分支与并行:看结果决定下一步
复杂任务还会遇到分支。比如“先检查数据是否完整,完整就生成报告,不完整就发邮件通知管理员”。这种场景靠模型自由发挥不可控,我的做法是把“决策”和“执行”分开:模型只负责理解用户意图并生成一个“步骤序列”,具体的分支判断由编排引擎用规则来做。
我还支持“并行执行”,适合那些彼此独立的分支。比如周报需要同时拉销售数据和拉库存数据,两者互不依赖,就让Agent并行调用这两个技能,都返回后再进入下一步。并行能显著缩短任务整体耗时,但前提是技能必须是只读或者写入互不干扰的,否则会出现状态覆盖的问题。
4.3 技能间数据流转的统一上下文设计
技能串起来后,另一个大麻烦就是“数据怎么传”。我不建议通过函数返回值去逐层传递,那样编排逻辑会越来越复杂。我用的方案是引入一个共享上下文对象,所有技能都从context里读需要的数据,把结果写回context。编排器只负责控制流程顺序,不直接搬运数据。
上下文在设计时要区分“会话级字段”和“技能级字段”。会话级字段存用户身份、项目编号、全局配置等贯穿全流程的数据;技能级字段存单个技能产生的结果。为了防止命名冲突,技能级字段统一用“技能名.输出字段名”作为key。这样即使两个技能输出有同名概念,也不会互相覆盖。
5. 实测30多个技能后踩过的六个坑
5.1 description写太长,模型反而选错技能
我一度以为描述越长越好,把技能的所有细节都塞进去。结果模型开始出现一种怪异的“过拟合”:用户说“给张三发一封邮件”,模型放着专门的send_email不用,非要去调一个description里提到了“邮件”两个字的客户关系管理技能。后来不得不做压缩和精简,把核心触发场景放前面,辅助细节放后面。现在经验是,描述最好控制在50到150个汉字之间,把“何时用”和“何时不用”说清楚就够了,其他细节放到参数描述里。
5.2 模型参数幻觉,必须靠强枚举校验兜底
大模型填参数时经常“一本正经地胡说”。它可能给一个不存在的部门ID,可能把日期格式写成中文“2025年1月1日”,也可能是把枚举值写成同义的另一个词。我的对策有两个。一是所有有枚举约束的参数,一律在Schema里写死enum,不允许自由文本;二是所有业务编码类参数,在执行前先做一次“翻译层校验”,比如把“销售一部”映射到部门ID,映射不到就反馈给模型重新生成,绝不硬着头皮执行。
5.3 外部接口必须做超时和降级,否则Agent会卡到天荒地老
技能一旦依赖外部服务,就一定会遇到慢接口、挂掉的接口、限流的接口。有一次项目里的OCR技能调了一个公共服务,对方服务直接超时90秒不返回,Agent整个对话卡死,用户等得没脾气。从那以后我给自己定了一条铁律:凡是外部调用,必须设置超时上限并准备降级方案。超时时间根据技能特性设成3到15秒不等,一旦超时立即返回错误,提示模型换一条路径完成目标,而不是干等。
5.4 不能忽略技能幂等性,重复执行会制造脏数据
Agent执行中出现网络抖动、模型重试、编排器重启,都可能导致同一个技能被调用两次。如果技能本身没有幂等性,就会产生重复订单、重复消息、重复扣费。给写操作类技能引入一个requestId参数,后端根据requestId做去重处理,是成本最低的幂等方案。我现在所有非查询类技能都强制要求传入requestId,执行前先查这个请求是否已经处理过,处理过就直接返回上一次结果。
5.5 并发场景的共享状态被污染
并行执行多个技能时,共享上下文就变成了一块“公共广场”。有一次两个技能同时往context里写同一个key,后写的把先写的覆盖了,导致其中一个分支的数据链断裂。排查了两天才找到问题。解决方法是引入“写时禁止并发”的锁机制:并发分支可以同时读,但写的时候按分支维度锁定key前缀。更稳妥的做法是让每个技能只写自己的命名空间,从源头上避免交叉污染。
5.6 日志和回放是排查问题的唯一抓手
技能数量一多,问题定位的难度会指数上升。用户说“这个任务失败了”,但你不知道是哪一步失败的、模型在哪个环节选错了技能、参数被改成了什么。我现在每个技能调用都会记录一条结构化日志,包含:技能名、输入参数、返回结果、耗时、错误堆栈。排查问题时,我可以按照requestId把一次任务的完整轨迹回放出来,一眼看到断点位置。这个习惯在技能少的时候看不出价值,技能多起来之后就是救命稻草。
6. 写到最后
这套技能系统在我这边的项目里已经稳定运行了不短时间,目前承载的技能数量超过了30个,涉及数据查询、文档生成、消息推送、外部接口对接等不同类型。相比最早那版堆满if-else的架构,维护成本确实降了一个量级,新技能的接入时间从按天算缩到了按小时算。
如果说有什么最值得分享的心得,那就是:技能系统的建设,永远不要等到技能多到失控才开始。哪怕你现在只有三五个能力,也值得按“技能描述→参数约束→注册表→调度层→日志”这个路径把地基打好。后面每一次接入新技能,都会省下几倍的时间。另外,如果你跟我一样遇到“模型老选错工具”的问题,先不要急着改模型提示词,先回头检查技能描述和参数定义——大多数时候,问题出在“说明书”写得不够清楚。