ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent技能模块设计:从零构建可编排的智能体能力层

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, render=params.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 的技能体系,不应该是一堆静态的定义,而应该是一个能随着使用数据不断进化的能力库。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进