ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent技能库设计:从散装函数到可插拔Skills的工程实践

Agent技能库设计:从散装函数到可插拔Skills的工程实践 1. 为什么我把 Agent 的“手脚”拆成了一堆可插拔的 skills先交代一下背景。最近我在梳理一个用来做信息整理和轻量数据分析的 Agent 项目代码量不大但让我最头疼的不是模型选型也不是 Prompt 怎么调而是工具函数越写越乱今天加一个读文件的明天加一个算指标的后天又加一个查天气的全都堆在同一个 tools 列表里。每次给模型发请求光工具定义就要占掉几百上千个 token而且模型偶尔还会选错工具、传错参数。后来我把这一层彻底重构改成了 agent-skills 的结构整个项目一下子清爽了很多。这里说的 agent-skills说白了就是给智能体准备一套“可插拔的技能库”。它不像传统编程里的函数库那样只给开发者调用而是专门面向大语言模型设计的一组能力单元每一个技能有自己的名字、说明、参数约束和执行逻辑Agent 根据任务在多个技能之间做选择然后调用对应的执行器完成任务。你可以把它理解成给一个头脑聪明但手脚生疏的新人配了一整套带标签的工具箱他不需要重新发明螺丝刀只需要知道什么场景拿哪一把。这篇文章我想把这个实践中沉淀下来的设计思路、目录结构、代码骨架和踩坑记录一次讲清楚。内容主要面向正在做 Agent 应用、想给项目加一层可复用能力层的开发者也适合那些刚开始接触 Agent 工程化、想知道“工具函数到底该怎么组织”的朋友。2. skill 组件化设计一个技能拆开看是什么样2.1 为什么不能把 Function 一股脑塞给模型很多初做 Agent 的朋友会有个疑问OpenAI 的 function calling 不是已经支持工具调用了嘛我直接把所有函数定义丢给模型不就行了我一开始也是这么干的直到一个场景让我彻底改主意。有一次我做了一个内部用的日报生成 Agent需要读取数据文件、计算环比、生成标题、查部门通讯录。四个功能我写成了四个函数加上 JSON Schema 一起塞进了 tools 参数。开起来没问题但用了两天就发现了几个毛病第一个是模型有时会用错函数明明该算环比它却去调了生成标题的接口第二个是函数定义多了以后每一轮对话都要把这些定义重新传给模型上下文开销大响应变慢第三个最坑不同函数之间的边界是模糊的有一次模型把两个函数的参数混在一起传构造出了一个既不合法也没意义的请求。问题不在模型本身而在于我提供的工具“没有边界感”。大模型本质上是一个模式匹配器它靠函数名和描述来决定调用谁你要是给它一大堆长得差不多的入口它自然会懵。而 agent-skills 的思路是给每个能力建立清晰的边界每个 skill 有唯一的职责有严格定义的输入输出有自己的触发条件和验证逻辑。这样模型在面对任务时是先从一列“能力清单”中挑选合适的技能再按清单约定调用而不是在一堆散装函数里猜。2.2 一个标准 skill 的五个组成部分在我最终定型的结构里每个 skill 由五个部分组成我列成了一张表组成部分作用说明name技能唯一标识用短横线分隔的小写单词例如>读取指定路径下 CSV 或 Excel 文件并返回结构化的表格数据。 当用户要求分析报表、查看数据、统计信息时使用。 仅用于读取本地数据文件不负责修改文件内容不处理图片和 PDF。注意最后加了“不负责”“不处理”这是给模型划红线能大幅降低误调用。这个技巧我用下来效果非常明显技能数量超过十个之后路由准确率从最初的八成不到提到了九成五以上。3. 实操从零搭一套轻量的 skills 执行骨架3.1 先把技能打散到目录里别用单文件堆动手写代码之前要先定目录结构。我见过不少人把所有技能塞进一个 skills.py看着方便一旦技能数量上来了改一个技能要翻几百行代码而且多人协作时冲突不断。我的建议是一个技能一个目录目录名就是技能名。我实际用的骨架长这样agent-skills/ ├── core.py # 技能注册器与执行调度 ├── skills/ │ ├── __init__.py │ ├── data_loader/ │ │ ├── __init__.py │ │ └── skill.py # data_loader 技能实现 │ ├── calc_expression/ │ │ ├── __init__.py │ │ └── skill.py # 表达式计算技能 │ └── text_summary/ │ ├── __init__.py │ └── skill.py # 文本摘要技能 ├── main.py # Agent 入口 └── requirements.txt这种结构的好处是每个技能可以独立测试、独立演进后续想给某个技能加依赖也不会影响别人。而且如果你想把这个技能库打包成 pip 包发布或者做成插件机制动态加载这个结构也能无缝迁移。3.2 核心代码注册器与执行调度先看 core.py这里定义了两个核心组件一个是 Skill 基类一个是技能注册器。Skill 基类的设计参考了一些 Agent 框架的做法但去掉了重依赖纯标准库可跑。# core.py from abc import ABC, abstractmethod from typing import Any, Dict, Optional class Skill(ABC): 所有技能必须继承的基类 name: str description: str parameters: Dict[str, Any] {} abstractmethod def execute(self, **kwargs) - Any: 执行技能核心逻辑返回结果 def verify(self, result: Any, **kwargs) - Any: 结果校验默认原样返回子类可覆盖 return result class SkillRegistry: 技能注册表维护所有可用的技能 def __init__(self): self._skills: Dict[str, Skill] {} def register(self, skill: Skill) - None: if skill.name in self._skills: raise ValueError(fduplicated skill name: {skill.name}) self._skills[skill.name] skill def list_manifest(self) - list[Dict[str, Any]]: 生成模型可见的技能清单 manifest [] for skill in self._skills.values(): manifest.append({ name: skill.name, description: skill.description, parameters: skill.parameters, }) return manifest def get(self, name: str) - Optional[Skill]: return self._skills.get(name)register 里做了重复名校验这看起来简单但很实用。技能多了以后你很容易在复制粘贴时忘记改名结果两个技能共用了一个 name模型调用的时侯永远命中的是第一个排查起来特别费劲。有了这个校验启动时就会直接报错。接下来看一个具体技能是怎么写的。我用“计算表达式”做个例子。这个技能的任务是安全地计算用户给的数学表达式比如“24*73”。注意我这里没有用 eval而是用 ast 库做白名单式解析这是为了避免任意代码执行的风险。# skills/calc_expression/skill.py import ast import operator from core import Skill class CalcExpression(Skill): name calc-expression description ( 计算数学表达式的值支持加减乘除、乘方和括号。 当用户要求计算结果、解算术题时使用。 仅接受纯数学表达式不接受包含变量赋值或函数调用的内容。 ) parameters { type: object, properties: { expression: { type: string, description: 数学表达式例如 (35)*2 } }, required: [expression] } # 白名单节点处理 _allowed_ops { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.Mod: operator.mod, ast.USub: operator.neg, } def _safe_eval(self, node): if isinstance(node, ast.Expression): return self._safe_eval(node.body) if isinstance(node, ast.Constant): if isinstance(node.value, (int, float)): return node.value raise ValueError(funsupported constant: {node.value}) if isinstance(node, ast.BinOp): op self._allowed_ops.get(type(node.op)) if op is None: raise ValueError(funsupported operator: {type(node.op).__name__}) left self._safe_eval(node.left) right self._safe_eval(node.right) return op(left, right) if isinstance(node, ast.UnaryOp): op self._allowed_ops.get(type(node.op)) if op is None: raise ValueError(funsupported operator: {type(node.op).__name__}) return op(self._safe_eval(node.operand)) raise ValueError(funsupported node: {type(node).__name__}) def execute(self, **kwargs) - str: expression kwargs.get(expression, ).strip() if not expression: raise ValueError(expression is empty) try: tree ast.parse(expression, modeeval) result self._safe_eval(tree.body) except SyntaxError as exc: return f表达式语法错误: {exc} except ValueError as exc: return f表达式包含不支持的语法: {exc} return f{expression} {result}这段代码的核心在于用 ast 把输入转成语法树然后只允许白名单内的运算符节点其余一概拒绝。这样用户输入__import__(os).system(rm -rf /)这种恶意表达式时会直接抛“不支持的类型”而不会执行。Agent 技能直接对接大模型大模型会被提示注入影响所以这个防护不算过度设计是必要的底线。3.3 Agent 主循环让模型在技能清单里做选择有了注册器和技能之后剩下就是怎么让 Agent 用起来。我这里的思路是先让模型基于任务和技能清单做一次选择产出“技能名参数”代码里拿到结果后执行然后把执行结果交回给模型做最终回答。# main.py import json from core import SkillRegistry from skills.calc_expression.skill import CalcExpression from skills.data_loader.skill import DataLoader def build_registry() - SkillRegistry: registry SkillRegistry() registry.register(CalcExpression()) registry.register(DataLoader()) return registry def ask_llm_for_skill(model, registry, task: str) - dict: 向模型询问应该调用哪个技能返回 skill_name 和 arguments manifest json.dumps(registry.list_manifest(), ensure_asciiFalse) prompt f 当前可用的技能如下 {manifest} 用户任务是{task} 请从技能清单中选择一个最合适的技能并以 JSON 格式返回调用信息。 格式{{skill_name: ..., arguments: {{...}}}} 不要编造不存在的技能。 response model.chat(prompt) # 这里假设模型返回的是可直接 json.loads 的字符串 return json.loads(response) def run(task: str, registry: SkillRegistry, model) - str: call_info ask_llm_for_skill(model, registry, task) skill registry.get(call_info.get(skill_name)) if skill is None: return f没有找到对应技能: {call_info.get(skill_name)} result skill.execute(**call_info.get(arguments, {})) # 结果校验这里简单透传 result skill.verify(result) return f调用技能 {skill.name} 的结果是{result}注意 ask_llm_for_skill 这个函数里我把技能清单 JSON 化后直接塞进了 Prompt这是最朴素的实现方式。如果要省 token可以把 description 精简成一行摘要但第一次先保证效果再谈优化。这个主循环的好处在哪它把“任务理解”和“任务执行”分开了。模型不需要真的会算数它只需要知道该用 calc-expression 这个技能技能也不依赖模型它可以被单测覆盖可以被别的 Agent 复用。这是一个很清晰的关注点分离。3.4 一次真实的技能调度过程演示写代码光说不练不行我拿一个具体任务走一遍完整流程。假如用户输入是帮我算一下 128 除以 4 再乘以 6 等于多少Agent 收到的任务就是这个字符串。模型看到技能清单里有 calc-expressiondescription 明确写着“计算数学表达式的值”于是返回如下 JSON{ skill_name: calc-expression, arguments: { expression: 128 / 4 * 6 } }主循环拿到这个值去注册表里取出 CalcExpression 实例调用 execute得到输出128 / 4 * 6 192.0最后模型把它组织成一句自然语言回答计算结果是 192.0也就是 128 除以 4 再乘以 6 等于 192。整个过程不需要预先写死“如果用户提到除法就走 A、提到乘法就走 B”这种规则模型基于语义自己做路由这就是 Agent 比传统业务流程灵活的地方。而技能拆得越干净、描述写得越清楚这个路由的准确率就越高。4. 让 Skill 真正“好用”参数设计、上下文裁剪与验证闭环4.1 参数 Schema 要写到“模型看了就懂怎么填”有了基本骨架之后接下来是打磨阶段。这里我想重点聊聊参数 Schema 怎么写。很多人的参数定义只写了类型比如 type: string、type: number但没写这个字段是什么、有什么限制。模型填参数的时候全靠猜填错是大概率事件。我建议每个参数至少包含三个字段type、description、enum如果有固定取值。如果某个参数有格式要求也尽量写进 description。比如日期参数你可以写“日期格式为 YYYY-MM-DD例如 2025-06-01”模型就不会给你传一个“明天”或者“6月1号”之类的值。另一个技巧是给必填参数做语义上的“别名”映射。比如你的技能里有个参数叫 file_path但用户可能说的是“文档”“表”“数据”等词汇模型未必能自动对应到 file_path。你可以在 description 里加一句“用户提到的文件、文档、表格都对应此参数”相当于做了名词归一化。这个是我在无数次看到模型传错参数后总结出来的非常管用。4.2 上下文裁剪技能多了之后怎么办技能数量少的时候把全部技能清单塞进 Prompt 没问题。但技能数量到了三五十个光清单可能就要占两三千 token模型的选择准确率也会下降。我见过一个团队硬塞了八十多个技能定义最后模型经常挑一个八竿子打不着的技能来用因为它的注意力被大量相似描述稀释了。解决思路是分两步第一步在注册阶段给技能打标签比如数据类、文本类、工具类、外部服务类第二步在调度阶段先用一个轻量分类器可以由模型自己完成也可以是一组关键词规则判断用户任务属于哪个类别只把该类别下的技能清单传给模型。我自己的实践是维护一个 category 字段然后在 ask_llm_for_skill 之前先用一个 embedding 模型做召回选 top k 个相关技能再让模型做最终选择。这样既省 token又能提高准确率。如果用不上 embedding退一步按关键词匹配也能解决大部分问题至少比一股脑全塞进去强。4.3 验证闭环执行结果不能“听天由命”技能执行完结果一定就正确吗不一定。模型参数生成有概率性外部 API 有波动文件路径可能不存在。所以每个技能都要有 verify 这一步。前面代码里 verify 默认透传但在实际项目中每个技能应该覆盖它。举一个例子data_loader 技能执行后verify 里检查返回的 DataFrame 是否为空列名是否齐全如果为空则返回“文件为空或列名不匹配请检查文件路径和表头”。这样 Agent 拿到 verify 后的结果能直接感知到问题而不是拿着一个空表格硬着头皮生成答案。verify 还可以做重试逻辑。比如调用外部 HTTP 接口的技能第一次超时了verify 发现 result 里 code 不是 200可以触发第二次调用设置不同的超时时间。这个能力放在技能内部而不是放在主循环里是为了保持主循环的简洁同时让每种技能具备符合自身需求的重试策略。5. 常见问题与排查技巧实录这套架构我跑了几个月迭代了很多版也帮朋友排查过他们自己搭的 Agent 工具层。我把高频问题和排查路径整理成一个速查表遇到类似情况可以直接对着查。现象可能原因排查思路与解法模型总选错技能description 不够具体或两个技能边界重叠检查 description 是否包含触发场景与排除场景给技能加 category 标签做预过滤调用技能时报参数缺失parameters Schema 没写必填字段或字段名不易理解检查 Schema 的 required 列表给参数 description 补充别名与示例技能执行成功但结果质量差技能本身逻辑有缺陷或验证环节缺失为 execute 补单测单独测试技能不经过 Agent加 verify 检查结果上下文 token 消耗大技能清单过长或技能描述过于啰嗦压缩 description 为一行摘要使用 embedding 召回 top k 技能再路由模型被提示注入影响绕开技能直接输出指令主循环未对模型输出做格式校验或技能 execute 未做安全防护对模型返回的 JSON 做严格校验execute 内不使用裸 eval限制可执行指令类型除了速查表还有一条排查思维想重点提醒当 Agent 行为不对的时候不要第一时间怀疑模型能力先怀疑“输入给模型的信息是否正确”。也就是把技能清单、参数 Schema、用户任务这三样打出来自己先看一遍如果你是模型你能不能选出正确技能如果连人看着都模棱两可那模型选错完全正常。这时候该改的是描述和边界不是换更大的模型。6. 最后想分享的三条经验整个 agent-skills 实践做下来我最大的体会是Agent 的智能上限由模型决定但可靠下限由工程和结构决定。技能层就是那个把“模型可能犯错”的地方用结构化方式兜住的关键层级。你不一定要把我这套代码原样抄走但这三个原则我觉得值得带走。第一条是技能的粒度宁小勿大。每个技能只做一件事做到极致。一个大而全的技能看着方便但会让描述很难写清楚边界也很难划明白模型调用时反而更容易出错。第二条是描述不只是给人看的注释它是给模型看的“说明书”值得花时间反复打磨。第三条是验证环节绝对不能省没有 verify 的技能就等于没有保险丝偶尔的异常能把整条链路烧掉。如果你也正在做 Agent 应用还在为工具层凌乱、模型调用不准发愁不妨试试把功能拆成 skills从一两个技能开始跑通之后再逐渐扩充。这个方向后续还能延伸出很多玩法比如技能的热插拔、按用户权限动态加载技能、技能的 A/B 测试等等。等你真正跑起来会发现 Agent 的开发方式会从一个“不断堆 Prompt”的事情变成一个“像搭积木一样搭能力”的事情那种感觉还是相当值得体验一下的。
返回列表