ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Agent技能体系搭建实战:从Function Calling到规范化技能库设计

Agent技能体系搭建实战:从Function Calling到规范化技能库设计 1. 从“会说话”到“能干活”Agent技能体系到底在解决什么问题这几年做大模型应用一个感受特别深模型本身再聪明不接上“手脚”也干不了实事。你让GPT-4o写一首诗、总结一篇文章它做得不错但你要是让它去查一下数据库里昨天的订单量或者把一份PDF转成Excel再发到指定邮箱它就傻眼了——因为它只有“脑子”没有“手”。Agent-skills说白了就是给大模型装“手”的这套工程方案。它不是某一个具体的模型也不是某个API而是一整套用来定义、注册、调用、管理Agent外部能力的方法论和代码结构。我们团队内部叫它“技能库”每个技能就是一件“工具”比如“查数据库”、“发邮件”、“解析PDF”、“调接口”Agent收到用户请求后自己决定调用哪几件工具、按什么顺序调最终完成一个端到端的任务。这个项目适合谁看两类人。第一类是刚接触Agent开发、被“Function Calling到底怎么设计”折磨过的工程师第二类是已经在做Agent但觉得技能越加越乱、不知道怎么管理的后端或全栈开发者。看完这篇文章你会知道怎么从零搭一套可扩展的Agent技能体系怎么设计技能接口踩过哪些坑以及怎么排查那些让人抓狂的调用问题。我先把结论放在前面一套合格的Agent技能体系核心不是“模型多聪明”而是“接口多规整”。技能的原子性、参数描述的清晰度、错误处理的可预期性这三件事做好了Agent的稳定性直接上一个台阶。下面我按实际开发顺序把每个环节掰开讲。2. 整体思路拆解为什么不能直接堆Function Calling2.1 技能体系与传统API网关的差别很多人第一次做Agent脑子里浮现的方案是“把函数塞给模型让模型选着调用”——确实OpenAI的Function Calling、Anthropic的Tool Use都在做这件事。但当你真的往生产环境放的时候会发现几个痛点痛点一技能膨胀。上线三个月技能从5个涨到50个每个技能的描述文档写得参差不齐模型经常把意思相近的技能搞混。比如你有“获取今日天气”和“获取本周天气趋势”两个技能描述如果不刻意区分模型可能随机选一个。痛点二参数地狱。技能参数一多模型就开始乱填。有个很典型的场景让Agent调用“发送邮件”技能参数里有cc抄送和bcc密送模型经常把收件人填到抄送里或者把抄送留成空字符串导致接口报错。痛点三错误透传。技能内部报错直接抛给模型模型一脸懵回用户一句“抱歉我遇到了错误”相当于把底层异常裸露给终端用户体验很差。所以agent-skills这个项目的第一原则是技能不是“函数”而是“服务”。每个技能背后是一个独立的、可测试的服务单元对外暴露统一的协议对内屏蔽实现细节。2.2 设计上的三个关键选择我们在设计agent-skills时做了三个关键决策这几个决策直接影响后续所有开发效率第一个决策用JSON Schema统一描述技能参数。模型天生适合读结构化描述JSON Schema是目前兼容性最好的参数描述格式。OpenAI、Claude、本地部署的Qwen、GLM都原生支持不需要为不同模型写适配层。我们规定每个技能必须有一个parameters.json用JSON Schema描述所有入参、类型、必填性、枚举值。这一步能解决80%的“模型乱填参数”问题。第二个决策技能内部状态隔离。每个技能运行在独立的执行环境中不共享内存变量只通过标准输入输出通信。这么做牺牲了一点点性能但换来了巨大的维护便利——一个技能崩了不影响其他技能升级一个技能不用重启整个Agent。第三个决策技能描述要有“触发条件”字段。除了给模型看“这个技能是什么”还要告诉模型“什么时候用、什么时候不要用”。这个字段特别有用比如“查天气”技能的描述里写“仅当用户询问天气时使用不要用于询问日期或时间”模型调用准确率能提升一大截。经验之谈描述写得越具体模型选错技能的概率越低比你在提示词里反复强调“请谨慎选择工具”管用得多。3. 实操拆解如何从零搭建一套Agent技能库3.1 技能库的目录结构与注册机制我们的技能库长这样每个技能一个目录自包含、可插拔skills/ ├── weather/ │ ├── SKILL.md # 技能描述给模型看 │ ├── parameters.json # 参数Schema给模型看 │ ├── handler.py # 核心执行逻辑 │ └── requirements.txt # 依赖声明 ├── database_query/ │ ├── SKILL.md │ ├── parameters.json │ ├── handler.py │ └── requirements.txt └── registry.json # 全局注册表核心是registry.jsonAgent启动时扫描它把每个技能的描述、参数Schema加载进上下文供模型选择。注册表里存的不只是技能名还包括版本号、启用状态、超时时间、权限级别。这个设计让技能可以做到“热插拔”——注册表里把某个技能禁用Agent立刻就不会调用它了不用改一行代码。每次新增技能我们要求提交者必须同步更新SKILL.md和parameters.json否则CI直接拒绝合并。一开始团队觉得这个流程繁琐但跑了两个月后所有人都认可了——技能的可维护性完全靠这两个文件撑起来。3.2 SKILL.md怎么写才不会被模型误解这是整个项目里我认为最有价值的部分。很多团队的技能描述写得很敷衍比如“获取天气信息”模型确实能看懂但遇到边界情况就抓瞎。我们总结了一套SKILL.md的模板核心是六个部分--- name: weather_query description: 查询指定城市当前天气信息包括温度、湿度、风力、天气状况 trigger: 当用户询问某地现在天气、今天会不会下雨、适不适合出门时使用 not_trigger: 不要用于查询空气质量、紫外线指数、未来天气预报 version: 1.2.0 timeout: 10s permission: public ---trigger和not_trigger是精华。模型在多个技能间做选择时本质上是在做“意图匹配”你把正向、负向的边界画清楚它的选择准确率会显著提升。我们做过一个对比实验在同一批500条测试请求上加了not_trigger之后技能选择准确率从87%提升到94%。还有一个细节description不要写太长模型上下文窗口有限每个技能的描述都写成小作文上下文很快就被塞满反而影响其他技能的表现。控制在50字以内说清楚“做什么”和“什么时候做”就够了。3.3 parameters.json设计中的门道参数Schema是另一个重灾区。很多人直接照搬函数签名导致模型在参数映射上频频出错。我们迭代了几版后总结出几条硬性规则规则一能枚举的就别开放自由文本。比如“单位”参数如果只有摄氏度和华氏度两种选择一定要写成enum: [celsius, fahrenheit]不要写成type: string然后祈祷模型填对。规则二必填参数不要设默认值。有些开发图省事把必填参数也设了默认值结果模型忽略用户输入直接用了默认值返回了错误信息。Agent场景下参数默认值只能用于非核心的、可推测的字段比如“返回条数”默认10条没问题但“查询关键词”绝不能有默认值。规则三参数之间要写dependentRequired。有些参数有强关联比如“发送邮件”技能里to和content必须同时出现缺一不可。JSON Schema的dependentRequired字段可以约束这种关系模型在生成参数时会被强制遵循。一个简化的parameters.json示例{ type: object, properties: { city: { type: string, description: 城市名称使用中文全称比如北京、上海 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位默认摄氏度, default: celsius } }, required: [city], additionalProperties: false }这里有一个小点很多人会忽略additionalProperties: false。如果不加这个字段模型有时候会脑补出Schema里不存在的参数塞进去导致执行端报“未知参数”错误。加上之后模型会被强制限制在Schema定义范围内。3.4 handler执行层的统一接口约定每个技能的handler.py都遵循同一个接口def execute(params: dict, context: dict) - dict: params: 模型生成的参数字典已经过Schema校验 context: Agent传过来的上下文包括用户ID、会话ID、超时设置等 返回值统一为: { success: bool, result: dict | str | None, error: str | None } 这个约定的关键点是返回结构固定。无论技能内部做了什么返回给Agent的永远是三个字段成功标志、结果数据、错误信息。这样一来Agent侧的代码可以写得很简洁不用为每个技能单独适配。上下文context也很重要。它让技能有了“感知”能力——比如database_query技能可以根据context里的用户ID控制查询权限send_email技能可以根据context里的企业配置决定走哪个SMTP服务器。4. 核心场景实操三个典型技能的完整实现理论说了一堆下面挑三个有代表性的技能从参数设计到执行逻辑完整走一遍。这三个技能覆盖了Agent开发中最常见的三类场景外部API调用、数据库操作、文件处理。4.1 技能一天气查询外部API调用类这个技能最简单但它能展示整个链路如何跑通。SKILL.md里我们把description写成“实时查询天气返回温度、天气现象、风力等级”not_trigger写成“不用于查询空气质量”。parameters.json我们设计了一个city字段加了一条description“城市中文全称如‘杭州’不要写拼音或缩写。”handler的核心逻辑import requests def execute(params: dict, context: dict) - dict: city params[city] api_key context[env][WEATHER_API_KEY] url fhttps://api.example.com/v1/weather?city{city}key{api_key} try: resp requests.get(url, timeout5) resp.raise_for_status() data resp.json() return { success: True, result: { city: city, temperature: data[now][temp], condition: data[now][text], wind_level: data[now][wind_class] }, error: None } except requests.Timeout: return {success: False, result: None, error: 天气服务超时请稍后重试} except Exception as e: return {success: False, result: None, error: f天气服务异常: {str(e)}}这段代码有两个刻意的地方。一是超时时间设定为5秒——Agent场景下模型等太久会急而且超时后我们返回的是一个用户可读的提示而不是堆栈信息。二是错误信息做了脱敏str(e)只用于内部日志返回给模型的是一个友好提示。从经验看错误信息一定要让模型“能理解、能转述”否则模型会把技术术语直接抛给用户体验很糟糕。4.2 技能二数据库查询数据操作类数据库查询技能是所有技能里最危险也最实用的。危险在于如果让模型自由发挥写SQL它可能写出全表扫描或者误操作实用在于一旦跑通Agent能直接把“查数据”这件事自动化省掉无数人工报表。我们的parameters.json是这样设计的{ type: object, properties: { query: { type: string, description: 用户的查询意图使用自然语言描述例如查询昨天订单总数 }, table: { type: string, enum: [orders, users, products], description: 要查询的数据表名 } }, required: [query, table], additionalProperties: false }注意这里我们没有让模型直接写SQL只让它输出“查询意图”和“目标表名”真正的SQL生成逻辑在handler里通过规则模板完成。这是Agent开发里的一个重要安全策略——不让模型直接控制数据库操作语句只让它提供参数SQL由代码拼接。import sqlite3 def execute(params: dict, context: dict) - dict: query_intent params[query] table params[table] sql build_sql_from_intent(query_intent, table) conn sqlite3.connect(context[env][DB_PATH]) try: cursor conn.execute(sql) columns [desc[0] for desc in cursor.description] rows cursor.fetchmany(20) result [dict(zip(columns, row)) for row in rows] return {success: True, result: result, error: None} except Exception as e: return {success: False, result: None, error: f查询失败: {str(e)}} finally: conn.close()build_sql_from_intent是一个基于规则的小函数用关键词匹配把“昨天”“上周”“总数”“平均”等意图翻译成SQL。它不智能但胜在可控——模型只能影响表名和少量条件永远写不出DROP TABLE这种语句。这种“模型提需求、代码执行细节”的模式是我做Agent以来最推崇的方式。有人会说这样限制了模型的自由度但在生产环境可控性永远比自由度重要。4.3 技能三Markdown转HTML文件处理类文件处理技能是Agent应用里另一大类需求——用户丢给你一个文件让你转换格式、提取信息或生成摘要。这个技能的核心难点不在转换本身而在于文件怎么传进来、转换结果怎么传回去。我们的做法是Agent收到用户上传的文件后先把文件存到临时目录把文件路径作为参数传入技能。技能处理完后把结果文件路径返回Agent再负责把文件呈现给用户。import markdown def execute(params: dict, context: dict) - dict: input_path params[input_path] output_path f{context[tmp_dir]}/output_{context[session_id]}.html try: with open(input_path, r, encodingutf-8) as f: md_content f.read() html_content markdown.markdown(md_content, extensions[tables, fenced_code]) with open(output_path, w, encodingutf-8) as f: f.write(html_content) return {success: True, result: {output_path: output_path}, error: None} except Exception as e: return {success: False, result: None, error: f转换失败: {str(e)}}这里有一个很实用的技巧文件的传递全程用路径而不是二进制内容。一开始我们尝试过把文件内容Base64编码后塞进参数结果上下文窗口直接爆炸。后来统一改成传路径Agent侧在需要的时候从路径读取文件效率和稳定性都提升了。5. 集成部署与调试实录5.1 技能库与Agent主程序的对接流程技能库搭好了怎么接到Agent主程序上我们走的是标准的三步第一步启动时加载注册表。Agent主程序读取registry.json把每个技能的名、描述、参数Schema拼成模型需要的tool格式OpenAI的tools数组或Claude的tools数组格式不同但内容一致。第二步模型决策。用户提问后模型根据意图从所有技能里选出要调用的技能输出一个结构化调用请求包含技能名和参数。第三步执行与回传。Agent主程序把调用请求转发给对应技能的handler拿到返回结果后再回传给模型模型根据结果组织最终回复给用户。这个流程看起来简单但实际跑起来会有很多细节问题。最典型的模型的工具调用是异步的有时候它会连续调用多个技能再汇总结果。这时候如果技能A的结果是技能B的输入Agent需要一个状态管理机制来暂存中间结果。我们用的是简单的会话级缓存以session_id为Key暂存每个技能的返回结果模型可以按需引用。5.2 多技能协同的一个完整示例拿“给领导发一封昨日销售数据邮件”这个需求举例完整走一遍链路模型判断需要三个技能database_query查数据、data_format整理成表格、send_email发邮件。第一步调用database_query参数是{query: 查询昨日销售总额, table: orders}返回结果包含一个数字和几条明细。第二步调用data_format参数是{data: 上一步返回的原始数据, format: html_table}返回一段HTML表格字符串。第三步调用send_email参数是{to: 领导邮箱, subject: 昨日销售数据, content: 包含HTML表格的邮件正文}返回发送成功标志。模型汇总一个最终结果给用户“已发送请查收。”手动编排这个流程要写大量胶水代码但在Agent技能体系里模型自己就能完成调度。我们要做的是确保每个技能接口稳定、返回结果结构清晰让模型能“看懂”上一步的结果并作为下一步的输入。5.3 本地调试环境的推荐配置调试Agent技能比调试普通后端接口复杂一些因为你面对的是一个不确定的“中间人”模型。我们的标准流程是先在隔离环境单独测handler。给handler写一套单元测试直接用字典传参不去管模型先把技能本身的逻辑验证通过。然后做“伪模型调用”测试。写一个脚本硬编码几个典型的模型调用请求模拟模型选择技能和生成参数的过程验证全链路通不通。最后才接真模型联调。拿一个开发环境的模型跑十到二十条种子问题观察技能选择准确率、参数生成质量、错误处理表现。这套流程下来至少能拦截90%的问题。不要一上来就接模型联调不然出了问题你分不清是模型的问题还是技能的问题。6. 常见问题与排查技巧实录6.1 典型故障对照表现象可能原因排查方向模型选了错误技能技能描述语义重叠检查SKILL.md的trigger和not_trigger参数生成缺字段JSON Schema描述不明确给参数description补充示例如“北京、上海”技能执行超时外部依赖响应慢缩短handler里的超时时间改为异步提示返回结果模型看不懂结果结构太复杂简化result结构加一层summary字段技能偶发调用失败上下文里技能描述被截断统计技能描述token占用精简描述模型重复调用同一技能缺少结果缓存给幂等技能加session级缓存6.2 一个让我印象深刻的故障排查过程有一次线上Agent突然频繁报错日志显示某个技能超时。我们第一反应是查外部API但API服务商那边说一切正常。后来仔细看日志发现超时请求有一个共同特征——都发生在同一个城市、同时请求了大量数据。排查到最后才找到原因技能参数里有一个page_size字段模型在用户没指定数量的情况下填了一个很大的值比如1000导致API响应极慢。我们的parameters.json没给page_size设上限模型就放飞自我了。从那之后我们的JSON Schema里凡是数值类型的参数都强制要求写minimum和maximum。模型确实聪明但你要是不给它设边界它一定会给你一个“惊喜”。参数的边界约束看起来是小事但线上事故往往就是这种小事引发的。6.3 给出三条避坑指南第一技能描述要“版本化”。技能改版后旧描述可能还在模型上下文里存活一段时间导致新旧行为不一致。我们的做法是给每个技能描述加version字段Agent定期刷新技能列表时做一次清理。第二权限控制不要放在模型层。有些团队尝试在提示词里写“你只能调用有权限的技能”但模型是不可靠的执行者。权限校验要放在handler层通过context里的用户信息做判断这样即使模型被绕过底层也有兜底。本质上和“不让模型直接写SQL”是同一个思路。第三日志里一定要记录“模型当时是怎么想的”。我们每次调用都会记录模型的原始输出包括它选中的技能、生成的参数、以及可选中的技能候选列表。这样做的好处是线上出问题后你能看到模型是“没选对”还是“参数填错”还是“故意调了不该调的技能”定位问题能快很多。6.4 持续优化的数据闭环技能库上线只是开始持续优化才是核心。我们每个季度会做一次技能调用分析哪些技能被高频调用哪些技能一次都没被用上哪些技能的调用经常失败。没被用上的技能只有两个原因冗余或者描述不够准确。根据调用数据反向优化描述是我认为Agent落地中最难但最值得做的事。我记得第一次做这个分析发现有个“汇率换算”技能上线一个月调用量为0模型从来没选过它。查了描述才发现描述里写的是“汇率换算服务”但用户习惯问“人民币换美元多少钱”我们把这个口语化的触发方式加进trigger之后第二个季度它的调用量就上来了现在已经是高频技能了。7. 最后分享一点个人体会做agent-skills这套体系前后迭代了三版踩过最大的坑就是把技能做“大”了。早期我们希望一个技能能覆盖很多场景结果参数越来越多描述越来越长模型的选择准确率反而越来越差。后来想明白一个道理技能粒度越细参数越少模型越容易用对。和一两个必备参数、几十字清晰描述、一个明确触发条件的技能相比一个什么都能干但干什么都需要一堆可选项的“万能技能”在实际运行中的表现差很多。如果你正准备启动一个Agent项目不要急着写业务代码先在技能定义上花足够时间。把SKILL.md和parameters.json当做一个产品来打磨想清楚每个技能的边界、参数的约束、错误的兜底。这套基础打好了后面所有的事情都顺了基础没打好光靠调提示词救不回来。这些经验教训是我在这个项目里最值钱的收获。
返回列表