ARTICLE DETAIL

资讯详情

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

Agent技能系统设计指南:从Function Calling到技能库落地

Agent技能系统设计指南:从Function Calling到技能库落地 做AI Agent开发这一年多我越来越确定一件事模型本身的差距在缩小真正拉开产品体验差距的是Agent外围那层技能系统也就是常说的 agent-skills。它跟传统的插件封装、API对接完全是两码事核心在于让模型自己判断“什么时候该调用哪个能力、参数怎么填、结果如何使用”形成一个可闭环的自主执行链路。我最近把整套技能库从零重新搭了一遍这个过程里踩了不少坑也沉淀出一套相对稳妥的做法。这篇文章就把技能库的设计思路、实现细节、踩坑经历完整拆开正在做Agent产品落地、被工具调用不稳定折磨、或者刚接触技能封装的朋友应该都能在里面找到自己需要的东西。1. 项目概述与核心需求解析1.1 agent-skills 到底在解决什么问题先聊清楚概念。裸的LLM本质上只是一个“会说话的模型”它能跟你聊天、写代码、做推理但没法真正去查天气、查数据库、发邮件、操作文件系统。想要让Agent做事就得给它接上“手和脚”。早期大家普遍的做法是把所有能力塞进一个巨大的System Prompt里要求模型以特定JSON格式输出命令然后自己在代码里解析执行。这个方案在demo阶段看着还行一旦任务复杂起来就崩模型输出格式稍微一飘解析逻辑就报错参数填错也没人把关能力一多prompt越改越长模型根本记不住边界在哪里。技能系统就是针对这些痛点来的。它的核心是把每一项能力定义成一个结构化的“技能包”包含名字、描述、入参Schema、执行逻辑、返回规范这几部分然后通过模型原生支持的Function Calling机制让模型在对话过程中自主选择技能并填充参数。整个链条是用户提问 → 模型分析 → 根据技能描述决定调用哪个技能 → 生成结构化参数 → 你的代码执行技能 → 把结果喂回模型 → 模型继续推理或给出最终回答。这是一条有反馈、可纠错的闭环而不是一次性生成的死命令。所以这个项目要解决的本质上是一个工程问题怎么把模型“会想”和程序“会做”这两件事用一种稳定、可扩展、可维护的方式接起来。我见过不少人一上来就追求技能数量几十个技能堆进去结果模型连该用哪个都分不清。真正该先想清楚的是技能如何定义、如何注册、如何让模型精准命中、执行结果如何结构化地回流。这套地基打好了后面加技能只是往仓库里多放一个目录的事情地基打不好每加一个技能都是在给系统埋新的雷。1.2 为什么说技能设计直接决定Agent质量我审计过不少团队做坏的Agent项目最后问题几乎都出在同一个地方技能定义写得含糊。同样一个模型技能描述写得讲究与写得随意工具调用的准确率可以相差40个百分点这个数字在真实项目里一点不夸张。用生活里的例子类比技能描述就是给新同事的“岗位说明书”。有新同事入职你给他一本四十页的规章制度不如给他十张卡片每张写清楚“你负责什么、什么情况下需要你出面、输入什么、输出什么、遇到异常怎么处理”。模型面对几十个技能也是如此。技能的描述字段是模型判断“要不要调用它”的唯一依据写不好模型就会张冠李戴。比如“date_diff”这个技能描述里只写“计算两个日期之间的差异”模型遇到“上周到今天有几天”这种问题就可能会犹豫但如果你写清楚“适用于计算两个日期字符串之间的天数差支持自然语言日期表达如‘上周五’、‘三天前’”模型就能很确定地用上它。技能设计还直接影响三个硬指标任务完成率、Token消耗、失败恢复能力。任务完成率好理解技能选错、参数填错都会直接失败Token消耗上技能描述写得好模型一次就能选对不需要反复试错一次工具调用省下几百个Token放大到整个系统就是真金白银失败恢复能力则体现在错误返回的设计上返回信息写得足够结构化、足够“可读”模型就能在下一轮自动修正继续执行而不是直接甩给你一句“抱歉我做不到”。2. 方案选型与整体设计思路2.1 三种主流实现方式对比实现技能系统的路线大致有三条我分别试过说下实际体感。第一种是直接用模型厂商的原生Function Calling。OpenAI、Anthropic、国产几家大模型都提供这个能力你在API请求里把工具列表传进去模型就会在需要时返回一个工具调用指令包含函数名和参数JSON。这套方案的好处是链路短、兼容性最好模型原生理解这个协议基本不会在“要不要调用工具”这个层面犯傻。坏处是完全依赖厂商协议换模型厂商要改代码而且它对参数的校验是完全放权的模型填了错误参数你只能自己在执行端拦截。第二种是借框架的Tool机制比如LangChain的tool装饰器、CrewAI的Tool类、AutoGen的函数封装等。框架帮你做了很多脏活比如格式转换、错误重试、与Agent循环的集成。如果你团队里全是新手、或者想快速出MVP这个路线能省不少时间。但用久了会发现问题框架抽象了一层又一层出问题了排查链路特别长而且框架会限制你自定义行为比如我后面要做“技能协商”“技能编排”在框架里就很别扭。第三种是自己实现注册表加统一加载器也就是我这个项目最终选择的路线。本质上是自己写一个极简的“技能管理中间层”每个技能一个目录目录里有元信息文件YAML和执行逻辑文件Python由一个注册表统一管理再写一个适配层把技能定义翻译成各家模型能识别的Tool格式。这样一来底层协议可以随时切换但技能本身的定义方式始终不变。代价是这部分代码必须自己维护没有现成的轮子可抄。我最终选第三条路线核心理由有三个一是可控性出问题我能沿着自己写的代码一路查到根因二是技能定义与模型协议解耦以后模型厂商怎么变技能资产不会打水漂三是方便做动态技能管理比如按用户权限过滤技能、按成本路由技术栈这些在框架里很难优雅地实现。2.2 技能库的目录结构与注册机制一个好的技能库从文件结构上就要“一眼看懂”。我最终沉淀下来的目录结构是这样的agent-skills/ ├── skills/ │ ├── date_utils/ │ │ ├── skill.yaml │ │ └── run.py │ ├── sql_query/ │ │ ├── skill.yaml │ │ └── run.py │ ├── web_search/ │ │ ├── skill.yaml │ │ └── run.py │ └── email_send/ │ ├── skill.yaml │ └── run.py ├── registry.py ├── loader.py └── agent/ └── loop.py每个技能一个独立目录skill.yaml存元信息run.py存执行逻辑。这套结构看起来朴素但有两个好处第一新增技能不需要改任何全局配置往skills目录下丢一个新文件夹代码里会自动扫描注册第二技能是独立可交付的单元可以单独测试、单独加版本号甚至可以用Git Submodule分享给别的项目。注册机制这块我走了几轮弯路。最初是写死一个字典登记所有技能加一个技能要改两处代码后来改成自动扫描目录启动时遍历skills下的所有子目录解析YAML、加载Python模块统一放进一个SkillRegistry对象。扫描注册这种方案对新增技能零负担换来的复杂度是必须处理加载异常。比如某个技能目录缺文件、YAML格式错了、Python模块导入失败都不能影响其他技能正常加载。我在这一步的把关是加载失败的技能单独打日志并在注册表里标为unavailableAgent侧根本看不到它等修好了再热加载线上不至于被一个坏技能拖死。2.3 四个必须守住的底层设计原则第一是单一职责。一个技能只做一件事并且把事情说清楚。“查询订单信息”是一个技能“根据订单信息发送催单邮件”就应该拆成两个技能。原因很简单技能是给模型做选择题的选项选项边界越清晰模型选得越准混在一起相当于给选项加了一堆模棱两可的描述模型一定会选错。第二是描述即接口。代码里的函数签名是给程序员看的技能描述是给模型看的模型只能通过描述理解功能所以描述的质量直接等于接口质量。第三是Schema严格化。入参必须用JSON Schema描述并且把enum、minimum、pattern这类约束尽量用足模型填参数时能被约束逼到合法区间内。第四是结果可诊断。所有技能统一返回结构带上success标志、错误信息和耗时这样不仅模型能读懂人排查问题也能一眼看到根因。这四个原则里我花最多时间调整的是第二条。你写技能时很容易用人脑的“理解”去脑补模型的“理解”比如你觉得“查库存”这个词够直白但模型可能把它和“查订单”“查价格”搞混。正确的做法是站在模型的角度思考如果我是模型我手上有这二十个技能用户问我这个问题我凭什么选中你这个视角一转很多描述问题就暴露了。3. 技能定义与实现细节拆解3.1 技能描述怎么写模型才真正读懂技能描述是整套系统里最容易被低估的环节。我见过太多人把“description”字段当成注释随便写两句结果模型调用准确率上不去第一反应是换大模型很少怀疑是描述的问题。一套有效的描述我建议至少覆盖四件事功能边界、适用场景、参数语义、常见误用提醒。来看一个实际对比。差的描述长这样“计算两个日期相差的天数参数为date1和date2。”好的描述则要写成这样“计算两个日期之间的天数差。当用户询问‘从某日到某日有多少天’、‘距离某日期还剩几天’时使用。date1为起始日期date2为结束日期均支持ISO格式2025-06-01或常见中文表达如‘昨天’、‘上周五’。注意本技能只处理日期差计算不做日期格式化格式化请使用format_date技能。”后面半段其实已经把“什么时候别用我”也写清楚了这种“反向边界”能让模型在技能选择上少犯很多错。另外一个技巧是给描述里的关键词做“同义词覆盖”。模型的语义理解虽然强但不同的提问习惯会带来匹配差异。比如日期计算这个技能描述里就有必要拆出十几组说法“相隔几天”“间隔多少天”“哪个日期早”“倒计时多少天”。把这些自然语言变体都融进描述等价于提高了这个技能在模型眼里的“曝光度”。实测下来这个动作能把技能命中率拉高十几个点。代价是描述变长、Token变多收益和成本之间要自己权衡我一般控制在150个汉字以内。3.2 入参Schema设计的四个要点入参Schema是技能系统的“保险丝”。模型生成参数的过程本质上是“在约束下的填空”你给的约束越紧它瞎填的余地越小。我总结了几个实践要点。参数数量宁少勿多。一个技能超过五个入参模型出错的概率会指数上升。真要那么多参数想办法合并或拆技能。其次能用枚举就用枚举。比如“排序方式”这个参数定义成enum: [asc, desc]比让模型自由填字符串安全得多。第三每个参数都写description告诉模型这个参数的含义和取值范围。比如“limit”这个参数写“返回的最大条数默认10最大100”模型填超界的概率就小很多。第四required字段要谨慎设置。非必要参数尽量设成可选模型漏填了系统还能用默认值兜底一旦设成必填模型一旦漏填整个调用就失败了还得走重试链路。这里还有一个反直觉的经验给参数默认值不只是给用户方便更是给模型减负。能由系统自动推断的值就不要交给模型填写。比如“时区”参数与其让模型去猜用户在哪不如默认取服务器时区模型根本不用看见这个参数。你仔细想一想每个交给模型的参数都是它犯错误的一个潜在入口能不暴露就不暴露。3.3 返回值与错误约定的统一规范技能执行完要把结果交还给模型这个“交还”的动作比很多人想的更讲究。模型不是人它不会“看到”一段代码日志它只能读取返回的文本内容并基于此继续推理。所以返回值格式必须统一并且包含足够明确的语义。我用的统一结构是这样的{ success: true, data: { days: 7, diff: 2025-06-01 到 2025-06-08 }, error: null, meta: { duration_ms: 12, skill_version: 1.2.0 } }data里的内容尽量用结构化字段而不是一段话这样模型能直接引用数值。error字段平时是null出错时给出可读的错误描述。重点说一下错误描述怎么写——它不能是纯技术日志而要写成“模型能接住并自我修正”的提示语。例如不要写“ValueError: invalid date format”而要写“date1参数解析失败期望格式为YYYY-MM-DD或中文日期例如‘2025-06-01’或‘昨天’请检查后重试”。模型看到后就会在下一轮自己修正参数再调一次这就把一次失败变成了可恢复的交互。为了尽量少出错我还在执行端做了一道“兜底校验”。正式执行技能函数之前先用JSON Schema校验模型传进来的参数校验不通过就当场返回错误提示而不是把错误参数丢进函数里跑。这两个环节分开的好处是职能清晰Schema校验管“参数对不对”函数执行管“事情办没办成”排查问题时能快速定位故障层级。4. 实操从零搭建一套技能库4.1 环境与基础框架准备下面进入可复现的实操部分。我的主要开发环境是Python 3.10以上模型侧用OpenAI兼容的Function Calling接口。如果你用的是其他厂商协议虽有差异但下面的设计思路完全一样只要在适配层改一下格式映射即可。依赖的库很少核心就一个pip install openai pyyaml我建议在一开始就建好下面三个基础文件它们是整个技能库的地基loader.py扫描skills目录、解析YAML、动态导入执行函数。registry.py维护技能注册表提供增删查和“转成模型工具格式”的接口。agent/loop.pyAgent主循环负责多轮调用、把技能结果回填给模型。不要把Agent循环和技能库耦合在一起。技能库只负责“有什么技能、如何执行”Agent循环只负责“如何决定调哪个、如何继续推理”。这两个模块耦合了后面扩展任何一个都会牵连另一个拆开的成本很低收益很大。4.2 第一个技能日期计算我习惯用日期计算作为第一个技能练手原因很实际它逻辑足够简单、但边界足够多时区、闰年、各种日期表达方式都能测出问题而且它几乎是任何Agent日常都会用到的能力。先建立技能目录mkdir -p agent-skills/skills/date_utils然后写skill.yamlname: date_utils version: 1.0.0 description: 处理日期相关的计算和转换。当用户询问日期差、倒计时、 某日期是星期几、或需要将中文日期表达转换为标准日期时使用。 支持昨天、上周五、三天前等自然语言日期。注意 本技能不处理时间格式化展示那属于format_date技能。 parameters: type: object properties: op: type: string enum: [date_diff, date_weekday, date_parse] description: 要执行的操作类型 date1: type: string description: 起始日期支持ISO格式或中文日期表达 date2: type: string description: 结束日期date_diff操作时必填其余情况可省略 required: [op, date1]skill.yaml里定义的parameters就是JSON Schema后面会原样映射到模型工具的parameters字段。它既是给模型看的参数说明也是我执行前做校验的依据一份定义两个用途。再写run.pyfrom datetime import datetime, date, timedelta import re CN_MAP {昨天: -1, 前天: -2, 今天: 0, 明天: 1, 后天: 2} def _parse_date(raw: str) - date: raw raw.strip() if raw in CN_MAP: return date.today() timedelta(daysCN_MAP[raw]) m re.match(r(\d{4})-(\d{2})-(\d{2}), raw) if m: return date(int(m[1]), int(m[2]), int(m[3])) match re.search(r(\d)\s*天前, raw) if match: return date.today() - timedelta(daysint(match[1])) raise ValueError(f无法解析日期: {raw}) def run(params: dict) - dict: op params[op] d1 _parse_date(params[date1]) d2 _parse_date(params[date2]) if params.get(date2) else date.today() if op date_diff: result {days: (d2 - d1).days} return {success: True, data: result} if op date_weekday: return {success: True, data: {weekday: d1.strftime(%A)}} if op date_parse: return {success: True, data: {iso_date: d1.isoformat()}} return {success: False, error: f未知操作: {op}}这个run函数就是技能的“执行器”入参是模型填好的params字典出参严格遵循前面说的统一返回结构。代码本身不复杂但注意我故意把日期解析函数做成可失败并抛出带提示的异常这样在校验和错误处理环节能演示一套完整的失败恢复路径。4.3 注册表与自动加载机制写loader.py实现自动扫描加载import importlib.util, pathlib, yaml, sys def load_skill(skill_dir: pathlib.Path): yaml_path skill_dir / skill.yaml run_path skill_dir / run.py if not yaml_path.exists() or not run_path.exists(): raise FileNotFoundError(f技能目录缺文件: {skill_dir}) meta yaml.safe_load(yaml_path.read_text(encodingutf-8)) spec importlib.util.spec_from_file_location( fskill_{meta[name]}, run_path) mod importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return {meta: meta, run: mod.run} def scan_all(skills_root: str skills): skills {} for skill_dir in pathlib.Path(skills_root).iterdir(): if not skill_dir.is_dir(): continue try: skill load_skill(skill_dir) skills[skill[meta][name]] skill except Exception as e: print(f[loader] 技能加载失败 {skill_dir.name}: {e}) return skillsloader.py里最重要的设计是错误隔离。某个技能加载失败只影响它自己其他技能正常注册。这在技能数量超过十个之后非常关键否则一个语法错误能拖垮整个Agent启动。然后写registry.py负责把技能转成模型能理解的工具格式并提供Schema校验import json from jsonschema import validate, ValidationError class SkillRegistry: def __init__(self, skills: dict): self.skills skills def list_tools(self): tools [] for name, skill in self.skills.items(): meta skill[meta] tools.append({ type: function, function: { name: name, description: meta[description], parameters: meta[parameters], } }) return tools def invoke(self, name: str, params: dict): if name not in self.skills: return {success: False, error: f未知技能: {name}} try: validate(instanceparams, schemaself.skills[name][meta][parameters]) except ValidationError as e: return {success: False, error: f参数校验失败: {e.message}请检查参数后重新调用} try: return self.skills[name][run](params) except Exception as e: return {success: False, error: f执行异常: {str(e)}}invoke这个方法是整个技能库的“安全关口”。它先校验参数再执行函数两层夹击。上面这段代码里用到jsonschema库属于额外依赖但这一层校验非常值得。4.4 跑通Agent调用闭环技能库本身已经就绪接下来把它接到Agent主循环里。核心逻辑不复杂给模型传工具列表模型若决定调用工具就返回一个工具调用请求我们执行registry.invoke再把执行结果作为tool消息回填继续让模型推理。一个简化可跑的版本如下import json from openai import OpenAI client OpenAI() registry SkillRegistry(scan_all(skills)) def run_agent(user_msg: str) - str: messages [{role: user, content: user_msg}] system {role: system, content: 你是一个能处理日常事务的助手需要工具时主动调用。} messages.insert(0, system) for _ in range(5): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolsregistry.list_tools(), tool_choiceauto, ) msg resp.choices[0].message if msg.tool_calls: messages.append(msg) for tc in msg.tool_calls: args json.loads(tc.function.arguments or {}) result registry.invoke(tc.function.name, args) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse), }) else: return msg.content return 达到最大轮数仍未完成这里有个关键点模型可以一次性请求多个工具调用所以tool_calls是一个列表循环里面必须逐个执行并把结果按tool_call_id回填。tool_call_id是模型与工具消息的关联凭证漏了它模型会分不清哪个结果对应哪个调用。跑一个例子验证链路“今天是2025年5月10日那上周五到今天间隔了多少天”模型应当先尝试解析“上周五”如果底层解析没覆盖到这个表达会校验失败并返回错误描述模型再修正表达重试。这就是前面说“错误信息要可恢复”的价值——模型有了一次自我修正的机会整体任务完成率会明显提升。4.5 技能质量如何评估与回归技能库不是写完就完事它跟代码一样需要回归测试。我的做法是给每个技能配一组“验收用例”每个用例是一个问题加上预期技能名和预期关键结果然后用脚本批量跑Agent循环统计三个指标技能命中准确率、参数合法率、任务完成率。cases [ {question: 今天是2025年5月10日上周五到今天间隔多少天, expect_skill: date_utils, expect_data: {days: 4}}, {question: 2025-06-01是星期几, expect_skill: date_utils, expect_data: {weekday: Sunday}}, {question: 今天过期的订单有多少, expect_skill: sql_query}, ]这个评估脚本很值得尽早写因为技能的每次描述改动都可能引起连锁反应。比如你改了date_utils的描述可能让它更容易覆盖原本属于sql_query的场景这种“技能争抢”问题全靠回归用例暴露。我习惯把用例集固化到仓库里每次改完技能跑一遍跟CI一样当门槛。5. 常见问题与排查技巧实录5.1 高频问题速查表技能库运行过程中我遇到的高频问题基本可以归纳成下面几类直接给表现象根本原因解决方案模型就是不调用技能描述与问题语义匹配度不够工具列表里该技能被其他技能“抢占”重写描述补充同义词和反向边界精简同域技能数量参数乱填、必填漏填Schema约束不够required设置过多缩减参数数量、多用enum、必填只留最核心字段同一个技能反复被调用执行结果没达到模型预期或返回值无法支撑下一步推理检查返回结构把关键数值字段化错误信息要给出下一步建议技能执行报错频繁参数校验缺失或执行函数未做输入防御在invoke层加Schema校验执行函数内对异常兜底加载技能时一个报错拖垮全部扫描加载没有做错误隔离参考loader.py每个技能单独try-catch并标记不可用换模型后工具偶尔不识别各家协议细节有差异函数名或参数字段格式不同在注册表层做协议适配映射技能定义层保持统一这张表看着简单但每一条都是真实线上事故换来的。尤其第一条“技能争抢”等你技能库超过十五个技能一定会遇到。两个技能描述相似模型随机翻牌结果完全不可控只能靠描述边界和用例回归来治。5.2 排查三板斧日志、单技能回放、降级对比遇到线上问题我有一套固定的排查顺序分享出来供参考。第一步是查全链路日志。我会把每次Agent循环的完整消息序列记录下来包括模型每次返回的工具调用请求、参数原文、技能执行结果。很多问题一看日志就明白了比如“模型选了A技能但用户问的其实是B技能的领域”这种要按技能争抢处理。第二步是单技能回放。把出问题的用户问题固定住只让模型看到一个技能看它能不能正确调用。如果单技能下模型表现正常、多技能下就出错那一定是技能之间相互干扰如果单技能下模型也乱来那就是这个技能定义本身有问题。这一步能快速缩小问题范围。第三步是降级对比。换一个小参数或者不同系列的模型跑同一批用例对比技能命中率。有时不是你的技能写得差而是当前模型的工具调用能力确实弱。老板问起来有这组对比数据你也能有理有据地说服他换模型而不是自己瞎调技能。5.3 几条独家心得最后说几条其它文章里不太会讲、但我实际用下来特别有帮助的经验。技能版本要标在返回结构里。我在meta字段里加skill_version刚开始只是顺手后来发现价值很大线上模型缓存了旧格式的技能返回数据比对对不上时一看版本号就知道是不是缓存问题。给技能打“成本与风险”标签。有些技能调用一次很贵比如联网搜索、大模型二次生成有些技能有写操作不可轻易执行。我会给这类技能在描述里加一句“仅当用户明确要求时使用”并且可以在调用层加权限控制。模型是没法理解成本的但你可以在描述里把使用门槛写清楚把决策权交给模型的同时加上一层护栏。定期做技能“体检”。技能描述不是写完就一劳永逸的。模型厂商更新模型版本后同一批技能表现会发生漂移用户使用习惯变了也会让技能命中率下降。我现在保持一个习惯每周跑一遍验收用例集指标低于阈值的技能单独拉出来重写描述。这套“体检”机制看着笨但它是整个技能库长期稳定最有效的一道保险。在这个项目里我还有一个体会很深的点技能库的真正价值不在于“封装了多少个工具”而在于它把Agent的决策过程变得可观察、可控制、可改进。每次模型选错技能、填错参数背后都对应一个可以修正的设计漏洞。把每一个漏洞补上你的Agent就会肉眼可见地变聪明。这也是为什么我一直建议团队在早期就把技能库当独立产品来做而不是作为Agent项目的一个边角料。后续如果再扩展我会在技能协商和动态技能编排两个方向上继续深入把多个技能串成复合流程同时把技能使用的反馈数据回流到描述优化里让整个系统自己越用越准。但那已经是下一个项目的故事了。
返回列表