ARTICLE DETAIL

资讯详情

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

Agent技能包设计与实践:从Function Calling到可复用技能封装

Agent技能包设计与实践:从Function Calling到可复用技能封装 最近在折腾 Agent 项目时我把大量工具用法、业务规则和模型调用经验沉淀成了一个个“技能包”也就是项目标题里的 agent-skills。这套玩法不复杂但设计得好不好直接决定 Agent 是“靠谱员工”还是“乱接话的实习生”。今天这篇文章就把我自己的整体思路、目录设计、接入方式和踩过的坑一次性讲清楚适合正在搭 Agent 应用、想把模型能力沉淀成可复用资产、或者被 function calling 模板写得头皮发麻的开发者。先解释一下 agent-skills 是什么。简单说它是把“模型需要掌握的一项完整能力”打包成一个自包含的模块既包含给模型看的指令和示例也包含给代码调用的工具函数还包含说明文档、参数定义和版本信息。模型通过读 skill 的说明就能知道“什么时候该用它、怎么用”工程代码则通过统一的加载器把 skill 暴露给运行时。它解决了几个很实际的问题技能复用、上下文压缩、多人协作时能力边界清晰以及让模型在复杂任务里更容易做对动作。1. 先搞清楚 agent-skills 到底在解决什么问题1.1 从“工具函数”到“技能包”的演进早期做 Agent最朴素的方式就是写一堆函数丢给 function calling 让模型选。但函数一多就乱套几十个工具塞进 system prompt模型选择准确率直线下降而且每个函数只有名字和参数说明模型并不清楚“什么时候该用、用了之后结果怎么理解”。我一开始就这么干工具超过二十个之后模型开始频繁选错甚至把两个无关工具串起来调用。后面我换了一种思路把多个相关函数、执行策略和触发条件打包成一个“技能”。比如“查询订单”这个技能里面不只含一个 get_order 函数还包含查询前的参数清洗、查询后的状态映射、未找到订单时的替代逻辑以及给模型看的几条使用示例。模型只需要理解“订单查询技能负责一切跟订单相关的检索动作”比面对一堆散装函数轻松得多。这就是 agent-skills 的核心价值把“能力调用”从函数级别提升到业务能力级别。开发者维护的是一组语义清晰的技能包而不是一张越写越长、谁也改不动的工具清单。1.2 一个 skill 应该包含什么以及为什么需要封装一个完整 skill 通常包含四部分说明文档SKILL.md、工具实现、示例数据、以及可选的校验或测试脚本。说明文档是给模型看的核心里面要写清楚技能的功能边界、前置条件、返回结构和使用注意事项工具实现是给代码用的真实函数示例数据用于少样本提示或者在评估阶段验证技能效果。我见过很多人只写一个函数加一行描述就当 skill 用这是最常见的误区。真正的 skill 封装要把“模型需要知道的知识”和“代码需要执行的逻辑”拆开模型知识放在自然语言指令里执行逻辑放在函数里两者通过参数和返回值衔接。这样做的直接好处是同一个工具函数可以被多个 skill 引用而同一个 skill 也可以更换底层实现上层 prompt 不用改。另一个容易被忽略的封装点是“上下文消化”。Agent 一次任务只能接收有限上下文如果一个技能需要很多背景知识比如行业术语映射表、业务规则直接塞给模型会浪费 token 还可能干扰判断。skill 的正确做法是把背景知识压缩进函数逻辑只在返回结果里给模型精炼后的结构化信息。模型不需要知道映射表全貌只需要看到映射结果。1.3 什么时候不适合上 skillskill 不是银弹。如果只是临时调用一个简单函数或者工具之间没有任何共享上下文硬套 skill 反而增加复杂度。我自己判断的标准是同一组函数是否会在三个以上场景复用、是否需要额外的使用约束、是否希望沉淀业务知识。三个条件一个都不满足就不要上。还有一类情况要警惕技能本身输出非常不稳定说明这个能力还没到“可沉淀”阶段。技能是经验的固化不是实验现场。你把一个还在频繁改逻辑的功能包装成 skill只会让团队更不敢动它慢慢变成一个需要兼容的历史包袱。2. 技能仓库的整体设计从目录结构到元数据协议2.1 一个能落地的目录结构长什么样技能仓库的目录结构直接影响加载器实现的复杂度和多人协作的体验。我目前用的结构比较稳每个 skill 独立成一个目录命名用中划线分隔、全小写目录内部统一四块内容。skills/ ├── order-query/ │ ├── SKILL.md │ ├── tool.py │ ├── examples.json │ └── tests/ │ └── test_tool.py ├── refund-check/ │ ├── SKILL.md │ ├── tool.py │ └── examples.json └── skill_loader.pySKILL.md 是这个目录的“门面”加载器启动时会把它读出来注入到 system prompt 里同时从中解析技能名称和触发条件。tool.py 放所有可调用函数每个函数必须有类型注解和完整的 docstring因为 docstring 会被提取出来作为函数描述喂给模型。examples.json 存放两到三组完整的调用示例用户问题、应该调用哪个函数、参数是什么、期望返回是什么。tests 目录放着冒烟测试我强烈建议每个 skill 至少有一个测试否则技能升级时你根本不知道哪次改动把原有能力干坏了。这个结构的核心思想是“一个技能的所有东西都集中在一个目录”新增技能不需要改动其他目录删除技能直接删目录加载器扫描时天然做到隔离。2.2 元数据、描述与入口定义SKILL.md 写得好不好直接决定模型能不能正确调用。我总结出一套固定模板每行都有用途不建议随意删减。--- name: order-query description: 查询订单状态、物流信息和历史订单。当用户询问订单详情、物流进度时使用。 version: 1.2.0 entry: tool.py functions: - get_order - list_orders_by_user --- # 使用说明 1. 查询订单前先归一化订单号去掉首尾空格与横线。 2. 若用户未提供订单号调用 list_orders_by_user 按用户 id 查询最近订单。 3. 订单状态字段返回的是状态码展示前需要转换为中文描述。 4. 查询结果为空时不要编造数据直接返回 NOT_FOUND。注意 description 字段不是给人看的是给模型的它决定模型在什么时机想到这个技能。写得越具体误触发越少。我之前写过很泛的描述比如“处理订单相关事情”结果模型在问退改规则时也会去查订单乱成一团。改成“查询订单状态、物流信息和历史订单当用户询问订单详情、物流进度时使用”之后准确率立刻上来了。入口定义也很重要。一个 skill 可以只暴露一个主函数也可以暴露多个相关函数。我建议入口函数越少越好内部逻辑尽量收敛。哪怕技能内部有十个辅助函数只要对外暴露一个 query_order 接口模型的决策负担就小很多。2.3 版本、依赖与兼容性技能包做多了自然要面对版本问题。我现在的做法是每个 skill 都带语义化版本号SKILL.md 里声明它依赖的其他 skill 或者公共库版本。比如订单查询技能依赖一个公共的订单号校验库那么它在元数据里就要写清楚最低版本。依赖关系要尽量简化。我发现技能之间一旦形成网状依赖加载顺序就成了新的复杂度来源。最好的设计是技能之间完全独立通过共享的公共依赖而不是互相调用。如果确实需要技能 A 调用技能 B优先把公共逻辑下沉到公共库而不是让两个 skill 直接耦合。这条规则帮我避免了很多循环依赖的诡异问题。加载器启动时还会做一次校验解析所有 SKILL.md 的元数据检查 entry 文件是否存在、声明的函数是否真的导出、依赖版本是否满足。校验不过就拒绝启动并给出明确报错而不是等运行到一半才炸。3. 让 Agent 真正“会”用技能注册、调用与上下文注入3.1 注册机制和工具描述的组织方式加载器扫描完技能目录后要生成两份东西一份是给模型看的工具描述列表一份是给运行时分发函数调用的路由表。工具描述列表必须一次性注入到 system prompt 或首轮 message 里所以长度控制非常关键。我采用的注册方式是从 SKILL.md 解析出技能名称和描述再从 entry 文件里用 inspect 模块反射读取每个入口函数的签名和 docstring组合成标准 JSON 格式。{ type: function, function: { name: order_query__get_order, description: 根据订单号查询订单详情。返回订单状态、商品明细、物流信息。, parameters: { type: object, properties: { order_id: { type: string, description: 订单号支持带横线或不带横线的格式 } }, required: [order_id] } } }函数名里我加了两段前缀技能名加上函数名这样即使两个技能暴露同名函数也不会冲突。这在工具数量增长后特别重要模型自己都不会搞混。3.2 指令模板与少样本示例的正确姿势工具描述只是让模型知道“有哪些函数可以调”真正教会模型“怎么调、什么时候调”的是少样本示例。examples.json 的作用就在这里每一条示例都是一个完整对话片段展示用户输入、Agent 思考可选、工具调用和最终回复。我写示例遵循“正反都要有”的原则至少两条正向示例应该调用该技能的场景至少一条负向示例看似相关但实际不该调用的场景。负向示例尤其有助于减少误触发比如用户问“我的订单怎么还没发货”应该调用订单查询但用户问“你们发货用什么快递公司”就不该调用因为这是常识问题而不是订单查询。注入示例时要克制一个技能两三条就够了。示例全部堆进 prompt 里反而会稀释模型对当前任务的理解。实测下来每个技能保持两条正向加一条负向准确率提升最明显。3.3 调用循环与多技能编排单次调用模型后拿到的结果可能有三种直接回复内容、请求调用某个工具、以及一次性请求调用多个工具。运行时的职责就是循环处理这些请求解析函数名、找到对应技能的函数、执行、把结果作为 tool message 返回给模型直到模型给出最终文本回复。多技能编排的难点在于并行工具调用之间的状态管理。模型可能在一个回合里同时请求订单查询和退款资格校验这两个调用之间没有依赖关系可以并行执行。但如果技能 B 依赖技能 A 的返回结果模型通常会分两步调用运行时不需要额外处理只保证每步调用的中间结果都正确回传给模型就行。我额外加了一层超时控制和错误兜底工具执行超过设定阈值直接返回超时错误给模型让模型决定是换个查询方式还是如实告诉用户稍后重试。这比在代码里硬抛异常体验好得多。4. 实战从零搭一个轻量 agent-skills 运行时4.1 定义技能格式与加载器骨架这里给一套可以直接抄走的最小实现语言我用 Python模型接口用 OpenAI 兼容格式。首先是加载器核心逻辑是扫描目录、读取 SKILL.md 元数据、反射解析函数签名。import importlib.util import inspect import json from pathlib import Path class SkillLoader: def __init__(self, skills_dir: str): self.skills_dir Path(skills_dir) self.skills [] self.tool_descriptions [] self.route_table {} def load_all(self): for skill_dir in sorted(self.skills_dir.iterdir()): if not skill_dir.is_dir(): continue skill self._load_skill(skill_dir) if skill: self.skills.append(skill) return self.skills def _load_skill(self, skill_dir: Path): meta_file skill_dir / SKILL.md if not meta_file.exists(): return None metadata self._parse_metadata(meta_file) entry_file skill_dir / metadata.get(entry, tool.py) if not entry_file.exists(): raise FileNotFoundError(f{skill_dir}: entry file {entry_file.name} not found) module self._import_module(fskill_{metadata[name]}, entry_file) tool_specs [] for func_name in metadata.get(functions, []): func getattr(module, func_name, None) if func is None: raise AttributeError(f{metadata[name]}: function {func_name} not found in {entry_file.name}) tool_specs.append(self._func_to_tool_spec(metadata[name], func)) self.route_table[f{metadata[name]}__{func_name}] func return {metadata: metadata, tool_specs: tool_specs} def _func_to_tool_spec(self, skill_name: str, func): signature inspect.signature(func) properties {} required [] for name, param in signature.parameters.items(): if name in (self, kwargs, args): continue properties[name] { type: string, description: self._extract_param_desc(func, name) } if param.default is inspect.Parameter.empty: required.append(name) return { type: function, function: { name: f{skill_name}__{func.__name__}, description: inspect.getdoc(func) or , parameters: { type: object, properties: properties, required: required } } } def _parse_metadata(self, meta_file: Path): # 实际上建议用 yaml 解析 frontmatter这里简化为行读取 metadata {} in_frontmatter False for line in meta_file.read_text().splitlines(): if line.strip() ---: in_frontmatter not in_frontmatter continue if in_frontmatter and : in line: key, value line.split(:, 1) metadata[key.strip()] value.strip() metadata[functions] [ f.strip() for f in metadata.get(functions, ).split(,) if f.strip() ] return metadata def _import_module(self, module_name: str, path: Path): spec importlib.util.spec_from_file_location(module_name, path) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def _extract_param_desc(self, func, name): doc inspect.getdoc(func) or for line in doc.splitlines(): line line.strip() if line.startswith(fArgs:) or line.startswith(f参数:): continue if line.startswith(name :): return line.split(:, 1)[1].strip() return name这块代码有两个地方容易被忽略一是 inspect 反射只对模块级函数有效如果函数是类方法需要额外处理实例化逻辑二是 docstring 里参数说明的解析格式要团队统一否则提取出来全是空描述。我建议从第一天就用统一 docstring 规范例如 Google style这样反射结果的稳定性好很多。4.2 将解析结果注入模型调用循环加载器跑完拿到 tool_descriptions 之后接下来的事就是把它交给模型接口并在每次返回 tool_call 时执行对应函数。这里给一个精简但完整的执行循环。from openai import OpenAI client OpenAI() def run_agent(user_message: str, loader: SkillLoader): messages [{role: user, content: user_message}] tool_descriptions loader.tool_descriptions for _ in range(6): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstool_descriptions, tool_choiceauto ) msg resp.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for tc in msg.tool_calls: func loader.route_table.get(tc.function.name) if func is None: result json.dumps({error: funknown function: {tc.function.name}}) else: try: args json.loads(tc.function.arguments or {}) result json.dumps(func(**args), ensure_asciiFalse) except Exception as e: result json.dumps({error: str(e)}, ensure_asciiFalse) messages.append({ role: tool, tool_call_id: tc.id, content: result }) return 达到最大调用轮次任务未完成轮次上限我设成 6是因为实测大多数业务场景模型在三轮内就能完成任务。超过六轮还转不出来基本是任务描述有歧义或者工具选择逻辑出问题与其让模型无限循环烧 token不如提前终止并让用户补充信息。4.3 用一个业务场景完整走一遍套一个最常见的电商售后场景来验证这套运行时。我准备了两个技能order-query 提供 get_order 查询订单详情refund-check 提供 check_refund_eligibility 判断订单是否满足退款条件。用户输入是“订单 2024512 想退款帮我看看能退吗。”运行过程的实际调用轨迹如下模型先调用 order_query__get_order 取订单状态和商品信息拿到返回后接着调用 refund_check__check_refund_eligibility 判断资格两次结果都回传后再组织语言给出最终答案。整套过程三轮完成每轮消耗的 token 大约在 1200 到 1800 之间。这个例子的关键是技能间没有硬依赖模型自己完成了步骤编排。你不需要在代码里写“先查订单再校验退款”的流程只要两个技能描述清楚各自的边界模型会基于用户意图自动串联。这也是 agent-skills 和传统工作流最大的区别流式决策由模型负责工程侧只提供能力底座。5. 常见问题与排查经验5.1 模型就是不调用你的技能这是最让人头疼的情况。模型宁可用自己的常识瞎猜也不用你辛苦写的工具。我踩过几次之后总结出三个排查方向。第一个方向是技能描述写得不够“触发友好”。描述里全是功能名词没有触发场景。模型是意图匹配在执行你要告诉它“当出现哪些词、哪些场景时调用我”。把“查询订单状态、物流信息和历史订单当用户询问订单详情、物流进度时使用”这种描述放进去触发率会明显改善。第二个方向是技能名和函数名风格太像模型没见过。模型在训练数据里见过大量命名规范如果你的函数名是 order_query__get_order 这种清晰风格它会更容易理解和调用。用无意义缩写虽然代码里好看但模型不认识。第三个方向是显式提示。如果上面两步都做了还是不行就在 system prompt 里写一句“当用户涉及订单查询必须使用 order_query 技能不要直接回答”。这种硬约束有时候能救场但不建议都靠它描述写好了大部分场景不需要这种霸王条款。5.2 参数幻觉与必填参数缺失模型经常生成的 arguments JSON 跟你定义的 schema 不一致少传必填参数、多传不存在的参数、把字符串类型的数字原样传成字符串这些问题我都遇到过。处理策略分两层。第一层是宽容入参函数内部做类型转换和默认值补齐不要因为 arg 类型不对就抛异常。比如 order_id 传成数字 123456就自动转成字符串再拼上需要的格式。第二层是强校验业务关键字段必须合法校验不过返回结构化错误信息给模型让它自己纠正。比如退款金额为负数返回 {error: INVALID_AMOUNT, message: 退款金额不能为负数}模型看到这个能自动调整参数再试。特别注意不要把 Python 异常直接抛给模型模型处理不了 Traceback。所有工具函数都要兜一层异常捕获转成清晰的业务错误码这是 Agent 工程里非常基础但非常重要的一条。5.3 技能之间彼此干扰、误触发技能库大了之后模型会把场景相近的技能搞混。比如同时有“退款申请”和“退款进度查询”两个技能模型很容易在用户问进度时去调起申请技能。我的解决思路是强化技能边界的负向描述。在 SKILL.md 里显式写一句“本技能不处理什么”这句话能有效降低误触发率。例如退款进度查询技能写“不负责发起退款用户要求退款时转给 refund-apply 技能”。这种分工描述比 listing 一堆正向功能更能帮助模型做区分。另外可以做一个全局的“技能路由表”注入到 system prompt 里用三行以内的文字列出所有技能及其适用场景。模型先在这里做粗粒度选择再去看对应 SKILL.md 的详细说明。技能超过十个之后这招几乎是必须的。5.4 上下文爆炸与描述过多技能数量多不是问题每个技能的 SKILL.md 都塞进 system prompt 才是问题。我有一次加载了十五个技能每个描述三百到五百字加上工具定义system prompt 直接冲到一万多 token模型响应变慢还容易丢细节。优化思路有两个。一是技能描述精简化功能重复的段落删掉只保留触发条件、关键步骤和返回结构目标是每个技能的描述控制在两百字以内。二是按需加载根据用户请求的关键词做一次预筛选只把可能用到的三到五个技能注入当前轮次其他技能保持待命状态。这个预筛选可以是简单规则也可以是一个轻量分类模型但规则往往已经够用。6. 踩坑后的一些实在建议agent-skills 看起来很像个“定义工具集”的技术活做深了才会发现它其实是个知识工程。真正花时间的不是写函数而是把业务知识、边界条件和模型行为模式反复揉进每一个技能包里。我现在的体会是技能描述宁可字字斟酌也不要一挥而就每加一个新技能之前先问一句“模型在这种情况下会不会困惑”会省掉后面大量调试时间。最后分享一个我一直在用的小技巧每个新技能上线前先人工准备十条覆盖“正常、边界、异常”三类情况的测试问题跑一遍完整的 Agent 流程把模型选错技能、传错参数的情况都记录下来反哺到 SKILL.md 里。这个循环跑三轮之后技能的稳定性会有一个质的飞跃。技能不是写出来的是调教出来的这话一点不夸张。
返回列表