Agent技能模块设计:从零构建可编排的智能体能力层
1. 先搞清楚Agent Skills 到底是个什么东西1.1 没做技能模块之前Agent 是怎么裸奔的刚开始做 Agent 的人大概都经历过这么一段拿一个大模型的对话接口拼命往 system prompt 里堆指令让它帮我查天气、帮我算账、帮我整理会议纪要。指令少的时候没问题等场景一多prompt 越拉越长模型开始选择性失忆今天能跑通的逻辑明天换个问法就崩了。这就是典型的没有技能模块的 Agent。裸奔式 Agent 的核心问题有三个。第一个是指令与实现强耦合所有能力都靠 prompt 文字描述模型理解成什么样全看缘分第二个是没法复用同一个逻辑换个项目就要重新写一遍 prompt第三个也是最致命的错误不可控模型一本正经地输出一个错误结果你还很难定位是它理解错了还是底层逻辑本来就错了。我做 agent-skills 这个项目其实就是想解决这三件事。把 Agent 能执行的每一个动作从prompt 里的一段描述抽出来变成一个个独立、可注册、可发现、可调用的技能模块。每个技能有明确的入参、出参、执行逻辑和错误返回。模型只负责决定用哪个技能、参数填什么至于技能内部是怎么实现的模型不关心也不需要关心。1.2 加了技能模块之后架构发生了什么变化加了技能层之后Agent 的整体结构会变成一条清晰的流水线输入需求 → 模型决策 → 技能路由 → 执行器调用 → 结构化结果返回 → 模型组织最终回复。这里最关键的转变是模型从执行者变成了调度者。之前模型的职责是直接生成最终答案现在它要先决定这单活儿该交给谁然后把用户的自然语言翻译成技能能接受的参数。这个转变看着小实际影响非常大——模型不再需要把业务逻辑背下来它只需要理解每个技能是干嘛的、该怎么调用业务细节全部下沉到技能代码里。举个我印象很深的例子。早期我做一个数据分析 Agent直接在 prompt 里写如果用户问销售额就用这个公式算如果问增长率就那样算结果一问到边缘情况就翻车。改成技能模块之后每个计算逻辑是一个独立函数prompt 里只保留一句话描述准确率反而上来了。原因很简单代码里的逻辑永远比 prompt 里的描述可靠模型只需要做它擅长的意图理解不需要做它不擅长的逻辑执行。1.3 什么场景下你才会真正需要这套东西不是所有 Agent 都需要技能模块单轮问答、简单的文本总结直接调模型就完了。但只要你碰到下面任一种情况就值得考虑上技能层Agent 需要调用外部系统比如数据库、文件系统、第三方服务接口同一个能力要在多个项目里反复使用涉及多步操作比如查完用户再查订单查完订单再算优惠需要审计和测试希望记录每次执行了什么动作、参数是什么、结果是什么这个项目最初就是面向第四种场景去设计的。我在实际跑业务时发现非技能化的 Agent 几乎没法做回归测试——同一个问题这次答对了下次可能答错你根本说不清是 prompt 的问题还是模型抽风。技能化之后每个技能可以单独写单元测试Agent 的整体稳定性才有办法量化。2. 技能模块设计接口、结构与编排2.1 技能接口的契约怎么定最稳妥技能模块能不能用第一步取决于接口契约定得好不好。我踩过不少坑之后沉淀下来一套比较稳的字段设计核心就五个部分技能名称、技能描述、参数定义、执行函数、返回结构。技能名称要短、要唯一、要像变量名一样规范比如query_order、calc_revenue不要用一句话描述当名字。技能描述是给模型看的要写清楚这个技能在什么场景下用、能解决什么问题但不要写实现细节否则模型容易被无关信息干扰。参数定义我强烈建议直接用 JSON Schema不要自己发明格式——模型对 JSON Schema 的理解已经很成熟而且后续做参数校验、做类型转换都有现成的工具链。返回结构是很多人忽略的点。技能执行完之后返回给模型的必须是一个结构化、带状态的结果而不是一个裸字符串。我自己常用的结构是这样{ status: success, # success / error / retry data: {...}, # 业务结果 message: 查询成功共返回12条记录, meta: {elapsed_ms: 230} }status 字段极其重要。模型拿到结果后首先要判断的就是这步成没成没有这个字段模型就只能靠猜一旦技能内部出错又没暴露出来后续整个流程都会在错误的上下文里继续。2.2 一个技能模块的内部结构拆解一个技能模块从代码组织上我会分成四层声明层、校验层、执行层、兜底层。声明层就是把技能的名称、描述、参数 Schema 写清楚校验层负责把模型输出的参数做类型检查和必填项检查这一层一定要独立不能混在执行函数里执行层才是真正干活的逻辑兜底层负责把异常统一包装成上面那个返回结构。我用 Python 实现的时候技能就是一个继承自基类的对象每个技能文件里只做一件事。典型的结构长这样from agent_skills import Skill, SkillResult class QueryOrderSkill(Skill): name query_order description 根据订单号查询订单详情适用于用户询问订单状态、金额、物流信息等场景 parameters { type: object, properties: { order_id: {type: string, description: 订单号通常以字母开头} }, required: [order_id] } def execute(self, params): try: order self._fetch_order(params[order_id]) if order is None: return SkillResult.error(未找到该订单请确认订单号是否正确) return SkillResult.success({ order: order.to_dict() }) except Exception as e: return SkillResult.error(f查询订单接口异常: {str(e)})注意这里有个细节execute内部不抛出异常而是全部吞掉转成 error 结果。这是我在实际使用中形成的习惯。因为技能执行器通常跑在 Agent 循环里异常往上抛的话要么打断整个 Agent 流程要么被外层框架当成系统错误处理。把错误转成结构化的 error 结果返回模型就能读到原因并尝试修正比如改一下参数再调一次。2.3 多技能之间的编排与冲突处理技能多了之后躲不开两个问题模型选错技能和多个技能相互冲突。选错技能绝大多数原因是描述写得不到位。我见过一个典型的 case一个查询天气的技能和一个查询空气质量的技能两个 description 里都写了查询天气情况模型直接蒙了。后来我把描述改成查询今日及未来7天温度、降水、风力另一个改成查询PM2.5、AQI指数及污染预警模型几乎不再选错。多技能冲突指的是两个技能都能完成同一件事但结果口径不一样。最稳妥的办法是收敛入口——在技能注册时做一次冲突检测同名技能不允许注册描述高度相似的技能在注册阶段就给出警告。另外我建议给每个技能加上权限级别的概念有些技能是只读的有些是写操作的Agent 在处理高风险操作时优先选择只读技能这个约束在注册时写死比在 prompt 里反复强调要小心靠谱得多。3. 实操记录从零搭一个可用的 agent-skills 框架3.1 目录结构与基础代码我实现 agent-skills 的时候目录结构刻意保持得很简单因为这类项目最怕过度设计agent-skills/ ├── core/ │ ├── registry.py # 技能注册与发现 │ ├── schema.py # 技能基类与结果定义 │ └── runtime.py # Agent 调用入口 ├── skills/ │ ├── web_extract.py │ ├── date_parse.py │ └── data_query.py ├── tests/ └── examples/核心就三个文件。registry.py是一个全局注册表技能在导入时自动注册运行时可查可调。schema.py定义技能基类和结果结构。runtime.py是模型与技能之间的桥它的职责是把模型输出的函数调用意图解析出来匹配技能、校验参数、执行、包装返回。注册表的实现不复杂但有一个细节值得注意——注册时机。我见过不少项目把技能注册藏在一个隐藏的__init__.py导入逻辑里结果排查问题的时候根本不知道哪些技能被加载了。我的做法是显式地维护一个技能列表在应用启动时逐个导入并注册配合日志打印已加载技能: xxx这样一眼就能看出环境里到底有哪些能力。3.2 模型调用与技能注册打通Agent 循环里模型输出一般有两种形态一种是直接输出自然语言另一种是输出结构化的函数调用意图。和技能模块对接核心是处理第二种。现在主流大模型都支持函数调用机制做法是把每个技能的name、description、parameters转成工具描述列表随系统消息一起发给模型。模型觉得该用技能时会在回复里带出一个结构化的调用声明格式大致是我想调用某个技能参数如下。runtime 拿到这个声明之后走一遍校验和调用。这里最容易出错的一个环节是参数里的值格式。模型输出的参数是文本比如order_id: A12345但技能内部可能需要的是整数或者时间戳。我在校验层里写了一个轻量的类型转换器把 JSON Schema 里声明的类型映射到 Python 类型转换失败就直接返回 error而不是让错误在业务逻辑里炸出来。def convert_params(params: dict, schema: dict) - dict: converted {} props schema.get(properties, {}) for key, value in params.items(): spec props.get(key, {}) vtype spec.get(type, string) try: if vtype integer: converted[key] int(value) elif vtype number: converted[key] float(value) elif vtype array: converted[key] list(value) else: converted[key] str(value) except (ValueError, TypeError): converted[key] value return converted这段代码看着简单但它在实际使用中帮我挡掉了至少三成以上的运行时错误。模型在生成参数时偶尔会自作聪明地把数字加上单位、把日期写成自然语言转换器加了一层兜底之后至少错误是可控且可反馈的。3.3 完整案例网页内容提取技能拿我最常用的网页内容提取技能举例完整过一遍。这个技能的典型应用场景是用户丢过来一个链接Agent 需要把页面正文抓取下来做总结。声明层class WebExtractSkill(Skill): name web_extract description 提取指定网址的正文内容适用于用户提供链接并要求总结、翻译、分析页面内容的场景 parameters { type: object, properties: { url: {type: string, description: 以 http 或 https 开头的完整网址}, max_chars: {type: integer, description: 最多提取多少字符默认5000} }, required: [url] }执行层我用的是请求 解析 清洗三步。请求时设置合理的超时和 User-Agent避免被目标站点拦截解析时先用元信息判断页面类型再抓取主要正文区块清洗时去掉脚本标签、样式标签和导航噪音。踩过的一个坑是有些页面正文是通过 JavaScript 动态渲染的直接请求拿不到内容。这个我做了个策略——如果解析出来的正文长度低于阈值就改用无头浏览器方案再抓一次。但这种方案开销很大不适合每个请求都走所以我在技能参数里加了一个render字段让 Agent 根据用户意图来决定是否需要。其实绝多大数情况下用户只是想让 Agent 读一下文章内容静态请求就够了。def execute(self, params): url params[url] max_chars params.get(max_chars, 5000) if not url.startswith((http://, https://)): return SkillResult.error(url 必须以 http 或 https 开头) try: html self._fetch(url, renderparams.get(render, False)) text self._extract_main_content(html) if len(text) 50: return SkillResult.error(页面内容获取为空可能是需要登录或被反爬拦截) return SkillResult.success({ text: text[:max_chars], chars: len(text[:max_chars]) }) except Exception as e: return SkillResult.error(f网页请求失败: {str(e)})这个技能的关键点在错误反馈。当页面抓取失败时我会尽量给出为什么失败的线索——超时、需要登录、还是内容为空。因为模型拿到这个 error 后可能转而询问用户是否需要其他方案而不是傻傻地重试同一个 URL。有朋友问过我为啥不直接在技能内部做重试我的回答是重试策略应该由 Agent 上层来决定而不是技能内部闷头重试两层职责混在一起后面很难调优。4. 常见问题与排查技巧实录4.1 模型选错技能先查描述再查样例我在项目里维护了一个选错技能的问题记录表每当模型把 A 技能当成 B 技能调用就记一条。统计下来90% 的问题出在技能描述上剩下的 10% 是用户输入本来就模糊。怎么改描述才能让模型真正看懂三个原则说清触发场景、说清能力边界、不给无关信息。举个例子date_parse 这个技能第一版描述是解析日期模型经常在用户问明天天气怎么样的时候调用它来算日期明显用错了。改成将自然语言日期解析为时间戳例如后天、下周一、2024年最后一个星期五当用户询问具体天气或日程时不要使用本技能之后误调用率降了很多。给模型划清不要做什么比只告诉它能做什么更管用。还有一种排查手段在技能描述里附带 1-2 个典型调用样例。模型阅读理解样例的能力很强有时候你咬文嚼字写一大堆规则不如一个示例参数直观。4.2 技能报错但模型不知道错在哪这是新手最常见的问题。技能内部抛了异常返回给模型的却是一串堆栈信息或者干脆是空。模型拿到这种结果既不知道下一步该怎么办也不知道要不要跟用户解释。解决这个问题的核心是错误信息的结构化。我在 SkillResult.error 里面强制要求填写一个给模型的提示这个提示要能指导下一步动作。比如订单查询失败返回的 message 不能只是查询失败而应该是查询失败订单号不存在请向用户确认订单号是否正确或提示用户检查是否有拼写错误。这样模型就能根据提示组织后续对话而不是卡死。另一个常见情况是技能执行超时。模型在等技能返回时如果等了 20 秒还没结果整个 Agent 体验就很差。我给技能执行器加了一个统一的超时控制默认 15 秒超时直接返回 error技能执行超时请提示用户稍后重试或换一种表达方式。这里记住超时错误一定要在前端或接口层捕获不能在业务代码里放任连接挂着。4.3 性能与并发技能不该写成阻塞式技能模块跑起来之后紧接着遇到的是性能问题。最典型的就是多个技能被顺序执行整体响应时间被拉得很长。举一个实际例子某次处理一个需要同时查天气、查航班、查酒店的分析任务三个技能串行执行每项要 2 秒总共 6 秒用户直接等得不耐烦。我的处理方式是在 runtime 层加了一个简单的并行执行器对于互不依赖的技能调用用并发方式同时执行再把结果汇总给模型。改了之后同样的任务总耗时从 6 秒降到 2 秒左右。但要注意不是所有技能都能并行有依赖关系的必须先让模型规划好执行顺序所以我在执行器里加了一个依赖标记声明了依赖的技能强制串行。还有一个容易被忽略的性能点技能执行完返回给模型的结果要控制体积。我之前有个技能会把整张表的数据都塞进返回结构模型输入直接撑爆。现在所有技能返回数据都做了截断大数据量的场景只返回聚合结果或抽样数据让模型按需进一步查询。这一步对 token 消耗和响应延迟的改善非常明显。4.4 技能变更导致的连锁反应技能不是写完就不动的。我踩过最惨的一次坑是改了一个底层查询技能的内部逻辑没有改动接口但把返回数据里的某个字段从字符串类型改成了整数类型。结果下游所有依赖这个字段的自然语言总结全部出错用户看到的汇报要么漏了单位、要么格式错乱。从那之后我定了一条规矩技能接口变更必须走版本化流程。技能名后面带版本号比如query_order_v1、query_order_v2新版本上线后保留旧版本一段时间让 Agent 的 prompt 逐步切换。虽然看着笨但在生产环境里这是最稳的做法。另外每个技能的返回结构变化一定要在技能的 description 里同步更新否则模型还按旧格式解析结果迟早出问题。5. 我会记在项目文档首页的几条经验这套 agent-skills 框架从最早的一个临时脚本迭代到现在我沉淀下来几条最关键的经验写在这里权当给打算做类似项目的人一点参考。第一技能越薄越好。一个技能只做一件事做得快、做得稳。我早期写过一个全能文档处理技能能总结、能翻译、能提取关键词、还能转格式结果就是什么都能干但什么都不精模型调用它的频率反而比拆分后的单个技能低。薄技能还便于单独测试和替换重活应该交给流程编排而不是堆在一个技能里。第二给模型看的描述要当成产品文案来写。这个认识花了我很长时间。技能描述不是给程序猿看的注释是给模型做决策用的信息同样的技能描述写得好不好直接决定调用准确率。我在写描述时会反复问自己如果我不懂这个技能的代码光看这段文字我知不知道什么时候该用它、什么时候不该用它答不上来就继续改。第三先让技能可观测再谈智能。技能系统里的每一条调用记录、每一次错误返回、每一个超时事件都应该被记录和统计。只有手里有数据你才能回答模型为什么选错了哪个技能拖慢了整体响应。我现在的做法是给每次技能调用打一个 trace id从模型决策到技能执行到最终回复全程串起来排查问题的时候事半功倍。我最近还在琢磨的一个方向是让技能具备自我描述更新能力——根据历史调用成功率动态调整技能的描述文案让模型越来越懂什么时候该找它。这个想法还没有完全跑通但我觉得方向是对的。Agent 的技能体系不应该是一堆静态的定义而应该是一个能随着使用数据不断进化的能力库。