构建智能体的第一步不是写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, categorysystem, version1.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(instancearguments, schemaschema) return None except ValidationError as e: return { error_type: INVALID_ARGUMENTS, message: str(e), hint: 请根据参数定义重新生成参数 }执行过程用异步超时控制async def run_skill_with_timeout(skill, context, arguments, timeout10): try: result await asyncio.wait_for( skill.execute(context, **arguments), timeouttimeout ) 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, defaultNone): 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, categorysystem, version1.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 fhttps://api.example.com/weather?city{city} async with httpx.AsyncClient() as client: resp await client.get(url, timeout5.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, categoryproductivity, version1.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: ftodo_{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, toolsall_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_workers8) 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项目做下来我最大的感受是技能系统真正难的不是写那几行注册和调度的代码而是长期演化中的取舍和规范。技能描述怎么写才让模型不困惑技能数量多了之后怎么维护安全和性能怎么平衡这些问题没有标准答案全靠在实际项目里一遍遍打磨。最后分享一个小技巧每个技能上线前我习惯先喂给它一批魔鬼测试项——故意用模糊的说法描述需求看看模型能不能正确映射到技能和参数。比如对天气技能我会试上海那边冷不冷明天要不要穿秋裤这类说法。这比任何单元测试都能更快暴露技能描述的盲区。技能描述本质上是人机之间的接口文档文档质量决定了系统的最终上限。
