构建智能体的第一步,不是写Prompt,而是先设计好技能系统。
做AI Agent这半年,我最大的感受是:大模型本身只是个"大脑",真正让它"能干活的",是挂在上面的那一堆技能。所谓agent-skills,说白了就是给智能体装上一套可注册、可调用、可组合的能力模块——可以是查询天气的工具函数,可以是操作数据库的接口封装,也可以是一段复杂的工作流。没有技能系统的Agent,就像只有大脑没有手脚的残疾人,什么都懂,什么都做不了。
这篇文章不聊玄乎的理论,直接把我做技能系统的完整思路、架构设计和踩坑经验拆开来讲。不管你是刚接触Agent开发,还是已经在做生产级应用,这套方案都能直接拿去参考。
1. 为什么智能体非要有"技能"不可
1.1 大模型的边界:知识≠能力
先说一个很多人容易混淆的点:知识不等于能力。
GPT这类大模型通过海量训练数据掌握了大量"知识",但它本身不具备执行能力。让它写一首诗,它能写;让它去查你所在城市的实时天气,它只能编一个——因为它根本没法联网获取真实数据。它天然具备的能力边界就是"生成文本",其他一切能力都需要外部系统补充。
技能系统就是干这个的。把"查天气""发邮件""操作数据库""调用第三方API"这类真实世界的操作封装成标准化的函数模块,然后让模型根据用户意图去选择合适的模块、填好参数、触发执行。这样一来,Agent就从"一个会聊天的机器人"变成了"一个能解决问题的助手"。
我见过不少团队在做Agent时,把全部精力花在调Prompt上,结果效果始终不稳定。归根结底,是因为他们没有把能力结构化。Prompt能约束模型的表达方式,但没法凭空创造模型不具备的执行能力。技能系统解决的问题,正是"能力从哪来"这个根本性问题。
1.2 技能、工具、插件、工作流,这些概念怎么区分
开始动手之前,我建议先把几个常用词理清楚。很多人被这些术语绕晕,做着做着就乱了。
- 工具(Tool):最底层的能力单元,通常是一个函数或API封装,比如"发送HTTP请求""读取文件""执行SQL"。粒度最小,不具备业务语义。
- 技能(Skill):面向特定业务场景封装的能力集合,可能包含多个步骤,比如"完成一次订单查询"可能需要鉴权、调接口、格式化结果。技能可以调用工具,也可以调用其他技能。
- 插件(Plugin):通常指一组相关技能的打包分发形式,比如"企业微信插件"包含联系人查询、消息发送、群管理等多个技能。
- 工作流(Workflow):多个技能按固定顺序编排成的流程,强调"编排"而非"调用",通常由系统预设而不是模型动态决策。
agent-skills项目的定位,是下面这三层中的中间层:定义技能的标准格式和生命周期,向上对接模型调度,向下封装具体执行逻辑。工具层和执行细节不用技能系统操心,工作流层也不是它要管的事,但技能系统必须为工作流提供可编排的基础单元。
1.3 这个技能系统到底解决什么问题
我在设计初期给自己定了几个目标,后面所有架构决策都是围绕这些目标展开的:
- 可扩展:团队里任何人都能在不改核心代码的前提下,新增一个技能。新能力即插即用,不用改动调度逻辑。
- 可观测:每个被调用的技能都有完整的日志链,参数是什么、结果是什么、耗时多少、有没有报错,全部可追溯。
- 可管控:技能具备独立的开关、权限标识和资源配额,管理员可以在线禁用某个有问题的技能,不用发版。
- 对模型友好:技能的描述信息要能让模型一眼看懂,知道什么时候该用、参数怎么填,从而提升工具调用的准确率。
这个清单看起来简单,但真落地的时候每个点都有不少细节。下面我把架构设计完整展开。
2. 技能系统的整体设计思路
2.1 三个核心设计原则
设计技能系统之前,我逼自己先想清楚三个原则,后面每个模块的设计都回到这三个原则上做取舍。
第一个原则是描述驱动。模型是通过技能描述来理解"这个技能是干什么的",而不是通过函数名。写技能描述就像写产品说明书,要把使用场景、触发条件、参数含义、调用样例全部讲清楚。很多技能系统效果不好,问题不在于模型,而在于技能描述写得稀烂。我还见过有人把整个函数源码塞进Prompt里的,效果差不说,token开销也高得吓人。
第二个原则是调度与执行分离。模型只负责"决定调用哪个技能、填什么参数",实际的执行在沙箱/运行时中完成。这样做的直接好处是:如果你想换底层的模型,技能代码一行都不用改;如果你想在某个技能执行前插入日志、限流、鉴权等逻辑,只要在调度层加拦截器就行。
第三个原则是Fail Loud(大声失败)。技能执行出错时,必须返回结构化错误信息,显式告诉模型"刚才的调用失败,原因是X,建议尝试Y"。最怕的就是技能内部吞掉异常,返回一段半截结果,模型拿到之后一本正经拿错误数据继续回答用户,那就是灾难。项目上线之后,我会定期翻技能失败日志,九十成以上都是错误信息不够明确导致的二次错误。
2.2 整体架构:注册、调度、执行三层分离
整个技能系统我拆成了三层,每层各司其职:
- 注册层:负责技能的登记、校验、存储和发现。技能开发者把写好的技能注册到中心,注册中心做格式校验、元数据索引、版本管理,然后形成一个技能目录供上层查询。
- 调度层:负责接收模型传过来的技能调用请求,做参数校验、权限检查、负载控制,找到对应的技能实例,触发执行,并处理超时和重试。
- 执行层:真正跑技能代码的地方。它在隔离的运行时里执行技能逻辑,捕获结果和异常,格式化成固定结构返回给调度层。
这三层分离之后,整个系统的边界就清晰了。调度层完全不需要关心某个技能内部是查数据库还是调外部API,执行层也不需要关心模型是怎么选中它的。后续就算把单机版升级成分布式服务,也只需要把调度层和执行层拆成独立进程,注册层换成共享存储就行。
graph TD A[LLM] -->|tool_call| B(调度层) B --> C{校验与鉴权} C -->|通过| D(执行层) D --> E[技能实例] E -->|结构化的结果/错误| B B -->|完整反馈| A(这是架构示意图,不是必然的技术栈要求。单机阶段我用的是Python的asyncio来组织这三层,代码量不大,但逻辑非常清晰。)
2.3 技能描述是"写给模型看的说明书"
这是整个系统里最容易被低估、却最能影响效果的部分。我把它单独拎出来说。
一个技能向模型暴露的信息,至少包含这几项:技能名称、一句话的功能摘要、详细的用途说明、参数定义(含类型、是否必填、枚举值、示例)、返回值说明、触发这个技能的典型场景示例。其中每一项都有讲究。
技能名称用动词开头,比如send_emailquery_weathercreate_todo,别用utils_func_01这种名字,模型根本理解不了。一句话摘要控制在50字以内,把"什么场景下用什么技能"说清楚,比如"查询指定城市未来3天的天气预报,支持中文城市名"。用途说明里可以写使用限制和注意事项,比如"仅支持国内主要城市的天气查询,数据来自XX平台"。参数定义里最容易被忽略的是示例值,模型在不确定参数格式时,会优先模仿示例值来填。
还有一个细节是技能描述总长度。如果注册了50个技能,每个描述300字,那就是一万五千字。把这些全部塞进Prompt,每次对话都要重新计算一遍token,成本高不说,模型反而会因为信息过载而降低调用准确率。我的经验是,常用技能的描述控制在200字以内,冷门技能的描述控制在80字以内,优先保证精准触达。
3. 核心实现:把技能框架搭起来
3.1 技能基类与参数Schema定义
落地到代码,第一步是定义技能的标准接口。我用Python实现,核心数据结构只有三个:ParameterSpec、SkillResult和Skill。
参数Schema这块,我强烈建议直接复用JSON Schema标准,而不是自己发明一套格式。原因有两个:第一,所有主流模型提供商的function calling接口都支持JSON Schema格式,直接用能省掉一层转换工作;第二,JSON Schema的生态很成熟,有现成的校验库、文档生成工具和可视化编辑器。
一个参数的Schema定义通常长这样:
parameter_spec = { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海、广州", "examples": ["北京"] }, "days": { "type": "integer", "description": "查询天数,取值范围1~7", "minimum": 1, "maximum": 7, "default": 3 } }, "required": ["city"] }注意required数组只声明必填参数。如果某个参数有默认值,就别塞进required里,否则模型每回都得填,增加出错概率。
Skill的基类我设计得比较简单,核心是一个execute方法加一个metadata属性:
class Skill: def __init__(self): self.metadata = { "name": "", "description": "", "parameters": {}, "category": "", "version": "1.0.0" } async def execute(self, context, **kwargs): raise NotImplementedErrorcontext对象干什么用的?它携带当前会话的信息,包括用户ID、会话ID、历史消息、配置项等。技能在执行过程中如果需要读当前用户的语言偏好、时区、权限等级,都可以从context里拿。这里我特别强调一点:技能接口里不要塞一堆全局变量,所有外部依赖都通过context显式传入,这样每个技能的执行才是可隔离、可测试的。
3.2 注册中心与自动发现机制
有了技能定义,下一步就是注册。最笨的办法是每个技能写一行手动注册代码,项目小的时候没问题,技能上到几十个就开始痛苦了——新增技能要改注册文件、改依赖注入、改上线清单,老忘。
我在agent-skills里推荐的做法是装饰器 + 自动扫描。每个技能文件里通过一个@skill装饰器标注技能类,启动时扫描指定目录下的所有Python文件,自动加载并注册。
from agent_skills.core import skill, SkillRegistry @skill("query_weather", category="system", version="1.0.0") class WeatherSkill(Skill): ...Registry内部就是字典,键是技能名,值是技能实例:
class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill): name = skill.metadata["name"] if name in self._skills: raise ValueError(f"技能 {name} 重复注册") # 启动时进行schema校验,不合法直接拒绝注册 validate_parameter_schema(skill.metadata["parameters"]) self._skills[name] = skill def get(self, name): return self._skills.get(name)自动扫描这块,我用的是标准库的pkgutil.iter_modules加上指定目录遍历。注册的时候有两个隐藏问题要特别注意:一是技能名冲突,如果两个文件注册了同名技能,启动时直接抛异常,宁可启动失败也不要跑到运行时才炸;二是技能文件加载顺序,技能之间可能有依赖关系,后加载的技能可以引用先加载的,所以我在扫描完所有文件之后再做一次依赖校验,确保每个技能声明的依赖都能在注册表里找到。
自动发现机制带来的直接收益是:新增一个技能 = 写一个Python文件,放到技能目录。不用改配置、不用动注册中心代码、不用重启调度服务的前端界面(如果有的话),只需重启Agent服务让扫描逻辑重新跑一遍就行。开发效率提升非常明显。
3.3 调度引擎:从模型决策到技能执行
调度层是技能系统和模型之间的桥,核心职责是处理一次"工具调用"的完整生命周期。流程如下:
- 接收模型返回的
tool_call结构,解析出技能名和参数 - 从注册表取出技能实例,判断技能是否启用(是否在禁用清单里)
- 用JSON Schema对参数进行二次校验
- 校验通过后执行技能,设置超时时间
- 把执行结果包装成标准结构,返回给模型
第二次校验非常关键。模型填的参数从语法上来说是合法的JSON,但语义上可能是错的,比如把日期格式从2024-01-01写成了01/01/2024,或者传了一个超出枚举范围的值。只靠模型自带的function calling能力,这个错误是拦不住的。在调度层的校验器里拦截,能避免很多执行时的尴尬。
校验环节我直接用jsonschema库:
from jsonschema import validate, ValidationError def validate_arguments(arguments, schema): try: validate(instance=arguments, schema=schema) return None except ValidationError as e: return { "error_type": "INVALID_ARGUMENTS", "message": str(e), "hint": "请根据参数定义重新生成参数" }执行过程用异步超时控制:
async def run_skill_with_timeout(skill, context, arguments, timeout=10): try: result = await asyncio.wait_for( skill.execute(context, **arguments), timeout=timeout ) return {"status": "success", "result": result} except asyncio.TimeoutError: return { "status": "error", "error_type": "TIMEOUT", "message": f"技能执行超过 {timeout} 秒,已终止" } except Exception as e: return { "status": "error", "error_type": "EXECUTION_ERROR", "message": str(e) }值得提醒的是,技能执行失败不一定是坏事。只要错误信息组织得清晰,模型拿到之后完全可以根据错误信息自我修正——换个参数值再试一次,或者换一个技能。所以在错误信息里,我会尽量把"为什么会失败""建议怎么改"写进去。
3.4 会话上下文与技能间数据传递
技能之间经常需要共享数据。比如用户先让Agent查询了天气,然后说"帮我根据天气安排明天的行程",第二个技能需要用到第一个技能的结果,数据怎么传?
我的方案是给系统增加一层会话存储(SessionContext)。它是一个以会话ID为键的存储空间,技能在运行时可以往里面写入中间结果,也可以读取之前的结果。结构大概是:
class SessionContext: def __init__(self, session_id): self.session_id = session_id self._store = {} def set(self, key, value): self._store[key] = value def get(self, key, default=None): return self._store.get(key, default)同时,我会把模型上一轮的完整对话历史也塞进context里,这样技能可以根据上下文决定怎么处理。比如用户问"那上海呢",如果context里已经有城市切换的上下文,技能就能自动识别这是对之前"查天气"意图的延续。
这里有一个关键设计决策:上下文只在单轮会话内有效,不要跨会话持久化。除非你明确在做一个需要长期记忆的应用,否则别把对话历史全部存进技能系统——隐私风险和存储成本都不划算。如果需要持久化,建议只存提炼出来的摘要和关键实体,不要存原始对话。
4. 实操:从0到1写一个真实可用的技能
4.1 第一个技能:天气查询
纸上谈兵太多,直接实操。我用"天气查询"做第一个技能,因为它依赖一个外部API,能完整展示注册、参数校验、执行、异常处理的全部流程。
import httpx from agent_skills.core import skill, Skill, SkillResult @skill("query_weather", category="system", version="1.0.0") class WeatherSkill(Skill): def __init__(self): super().__init__() self.metadata["description"] = ( "查询指定城市当前的天气情况,包括温度、天气现象和风力。" "用户询问'今天天气怎么样'或'北京天气'时使用此技能。" ) self.metadata["parameters"] = { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海、广州", "examples": ["北京"] } }, "required": ["city"] } async def execute(self, context, **kwargs): city = kwargs.get("city") if not city: return SkillResult.error("缺少参数: city") # 这里用一个公开API演示,实际项目里换成你自己的数据源 url = f"https://api.example.com/weather?city={city}" async with httpx.AsyncClient() as client: resp = await client.get(url, timeout=5.0) resp.raise_for_status() data = resp.json() return SkillResult.success({ "city": city, "temperature": data["temp"], "condition": data["weather"], "wind": data["wind"], "updated_at": data["update_time"] })写完这个技能,放到技能目录,重启服务,模型就能调用它了。整个过程大概五分钟。
我在这里踩过一个坑:外部API的异常没有充分处理,结果就是API一挂,技能直接抛异常,模型拿到的错误信息是ConnectionError,它完全不知道该怎么办。后面我加了一个权衡:如果是用户参数问题(比如城市名不存在),明确返回"没有查询到该城市,请确认城市名称";如果是API内部错误,返回"天气服务暂时不可用,请稍后再试",让模型知道这不是参数问题,换参数也没用。
4.2 第二个技能:待办事项管理
第二个技能我选"待办事项管理",它需要操作内存数据结构并且可能要跨技能共享,能更好地展示技能之间的协作方式。
@skill("create_todo", category="productivity", version="1.0.0") class CreateTodoSkill(Skill): def __init__(self): super().__init__() self.metadata["description"] = ( "添加一条待办事项,支持记录任务名称、截止时间和优先级。" "用户说'帮我记一下明天上午10点开周会'时使用此技能。" ) self.metadata["parameters"] = { "type": "object", "properties": { "task": { "type": "string", "description": "待办事项的具体内容" }, "due_time": { "type": "string", "description": "截止时间,ISO格式,例如 2024-06-20T10:00:00", "format": "date-time" }, "priority": { "type": "string", "enum": ["high", "medium", "low"], "default": "medium" } }, "required": ["task"] } async def execute(self, context, **kwargs): todo_list = context.get("todos", []) new_todo = { "id": f"todo_{len(todo_list)+1}", "task": kwargs.get("task"), "due_time": kwargs.get("due_time"), "priority": kwargs.get("priority", "medium"), "done": False } todo_list.append(new_todo) context.set("todos", todo_list) return SkillResult.success({"todo": new_todo, "total": len(todo_list)})这个技能展示了两点:一是技能可以读写会话上下文(context.get/context.set),让数据在多个技能之间流动;二是参数的枚举和默认值设计,priority字段如果不给默认值,模型每次都会纠结选哪个,给了默认值之后决策负担小很多,准确率明显上升。
真实生产里,这里的todo_list应该来自持久化存储(Redis或数据库),内存只是为了演示。
4.3 技能组合的真实场景演练
单技能都会写,组合才见功力。我模拟一个用户连续对话场景:
用户:"帮我查一下北京的天气,如果明天下雨,就提醒我出门带伞"
这其实涉及两个技能的配合:query_weather负责拿天气数据,然后一个schedule_reminder技能负责创建提醒。模型如果被设计成可以分步决策,它可能会这样做:
- 先调用
query_weather,参数city="北京" - 拿到结果,发现明天下雨
- 调用
schedule_reminder,参数content="出门带伞"due_time="明天早上8点"
要实现这种多步调用,调度层必须支持循环:模型第一次返回tool_call,执行完把结果塞回对话,再让模型决定下一步动作,直到模型不再请求工具调用为止。伪代码如下:
while True: response = await llm.chat(messages, tools=all_schemas) if response.tool_calls is None: break for tool_call in response.tool_calls: result = await scheduler.execute(tool_call, context) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result) })有个容易忽略的细节:多步调用时,每轮循环都要把之前所有的tool_call以及对应的tool执行结果全部保留在messages里,否则模型会失去上下文。我之前偷懒,只保留最后一轮的结果,模型第二轮调度直接丢失了第一轮的天气查询结果,闹了不少笑话。
4.4 测试与调试技巧
技能写多了之后,手工测试显然不够用。我搭了一套基于pytest的测试框架,每个技能配套一个测试文件,核心覆盖四类场景:
- 正常路径:参数合法,返回结果正确
- 参数边界:缺参数、超范围参数、类型错误
- 异常路径:外部API超时、数据为空、权限不足
- Token与耗效:技能执行的耗时和返回结果大小,防止有技能偷偷跑了几十秒才超时
调度层的调试,我强烈建议加一个"重放日志"机制。每次请求都会记录完整的输入输出,整理成可读的JSON日志,出问题的时候直接重放这个日志,就能复现问题。
比如我遇到过一个问题:同一段用户输入,上午能正确触发技能,下午就不触发了。排查半天发现是因为服务重启之后,新加载的技能描述和之前不同,描述里多了一个换行符,直接导致整个prompt的结构变了。重放日志缩小了排查范围,不然这种问题真的要靠猜。
5. 常见问题与排查技巧实录
5.1 模型就是不调用技能,怎么办
这是最常被问的问题,通常有三个原因。
第一个原因是技能描述没写好。模型看不懂这个技能是干什么的、什么时候该调用。解决方法是把描述里的触发场景写得更具体:不要只写"查询天气",而写"当用户询问当前天气或未来几天的天气情况时使用,支持中文城市名,如'北京今天热吗'或'上海明天会下雨吗'”。
第二个原因是技能太多,模型看不过来。如果注册了80个技能而每个技能描述都很长,模型反而会"选择困难"。解决方案是给技能做分类分桶,比如根据用户意图先粗筛出大类,再在大类内部给模型推送候选技能列表,而不是一下子全塞给模型。
第三个原因是模型版本本身函数调用能力偏弱。不同模型的tool calling能力差距很大,有的模型在复杂对话里经常漏调或调错。如果确认描述和数量没问题,建议升级到更强的新模型,或者换一个函数调用能力更好的模型。
5.2 参数校验总是失败
参数校验失败,最常见的是日期格式问题。模型填的日期有很多种格式:2024/06/20、6月20日、明天上午十点……而你的schema要求的是ISO格式。缓解方法是在描述里给参考示例,并在failure message里明确告诉模型"日期必须是ISO 8601格式,例如2024-06-20T10:00:00"。
还可以在调度层加一个轻量的参数预处理器,把常见的日期说法转成标准格式。比如"明天"通过dateutil.parser解析后对应当前时间加一天,处理好之后再做JSON Schema校验。注意预处理器只处理字符串和格式,不做业务逻辑判断。
5.3 技能执行超时或卡死
技能超时有两种情况:一种是外部API慢,比如某家天气服务偶尔响应五秒钟才回来;另一种是技能代码里用了阻塞式IO,比如用了requests.get但事件循环在asyncio里被卡住了。
前者好办,调大超时时间或者做缓存。后者是新手最爱踩的坑。注意在asyncio的async代码块里,绝对不能用requests这种同步库调外部接口,它会把整个事件循环卡住,其他技能全部集体超时。必须用httpx.AsyncClient、aiohttp这类异步客户端。
如果线上已经出现了阻塞问题,最简单的应急方案是给每个技能配置一个线程池执行器,让同步代码跑在线程池里,避免阻塞事件循环:
import asyncio from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=8) def run_sync_blocking(func, *args): loop = asyncio.get_event_loop() return await loop.run_in_executor(executor, func, *args)当然,这只是兜底方案,根治办法还是全部迁移到异步代码。
5.4 技能多了之后模型反而变傻
技能数量到20个以上,模型调用准确率就开始往下掉。这个现象很典型,我给它起名叫"技能信息过载"。内核原因是模型的上下文窗口和注意力都是有限资源,塞进去一百个技能描述,每个技能都分不到足够的注意力。
解决思路有几个:
- 技能分类与意图预筛:入口先做一次意图分类(可以是一个小模型,也可以是规则引擎),判断用户当前请求属于哪类,再只把这类相关的技能描述拼进prompt。
- 技能描述精简:把描述压到最低必要信息,示例尽量短,用短语而不是长句。
- 技能优先级:高频技能放在prompt靠前的位置,模型对靠前的内容分配更多注意力。
我在生产环境中的实际参数是:全量技能100+,但每次请求实际暴露给模型的最多只有15个技能描述。效果比全量塞要好得多,准确率从75%左右提升到93%。
5.5 安全边界:别让技能变成脱缰野马
技能系统给Agent赋能,同时也扩大了攻击面。哪怕只在自己项目里用,我也建议尽早建立安全边界,后面再补很痛苦。
核心要控制的三件事:技能能访问什么、能执行什么、能返回什么。
- 能访问什么:给技能设置权限标签,比如
network/filesystem/database。没有权限标签的技能,默认不能访问外部网络、不能读写文件。调度层在执行前检查权限,没有的直接拒绝。 - 能执行什么:给技能加运行资源配额,包括超时时间、内存上限、并发数上限。一个技能最多跑10秒,超过就杀。这个配额在注册时设置,调度层强制执行。
- 能返回什么:对技能返回值做长度限制和敏感信息过滤。防止某个技能不小心把内部密钥、完整数据库权限信息返回给了模型,模型又直接当成回答输出给用户了。
第五类问题虽然放在最后,但它是最重要的。技能系统上线得越早,安全基座就越重要,别等技能调度已经乱成一锅粥了再回头加固。
写在最后的实操心得
整个agent-skills项目做下来,我最大的感受是:技能系统真正难的,不是写那几行注册和调度的代码,而是长期演化中的取舍和规范。技能描述怎么写才让模型不困惑?技能数量多了之后怎么维护?安全和性能怎么平衡?这些问题没有标准答案,全靠在实际项目里一遍遍打磨。
最后分享一个小技巧:每个技能上线前,我习惯先喂给它一批"魔鬼测试项"——故意用模糊的说法描述需求,看看模型能不能正确映射到技能和参数。比如对天气技能,我会试"上海那边冷不冷""明天要不要穿秋裤"这类说法。这比任何单元测试都能更快暴露技能描述的盲区。技能描述本质上是人机之间的接口文档,文档质量决定了系统的最终上限。