恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从函数调用到技能管理:agent-skills让AI Agent用对工具
首页
资讯中心
/
从函数调用到技能管理:agent-skills让AI Agent用对工具
从函数调用到技能管理:agent-skills让AI Agent用对工具
发布时间:2026/10/21 18:24:14
自从大模型开放了函数调用function calling之后做AI Agent这件事的门槛一下降了很多。但门槛降了不代表没有坑。我自己在整理agent-skills这个项目的时候最深的体会是让模型“能调用工具”很容易让模型“可靠地用对工具”很难。agent-skills本质上是一套面向AI Agent的技能管理框架核心解决三件事技能怎么注册、怎么被模型发现、怎么在执行时不出乱子。如果你正在做一个需要调用工具、读写文件、查数据的Agent应用或者你被模型乱选工具、参数老对不上、执行到一半崩溃这些问题折磨过这篇文章值得花十分钟看完。我会从设计思路讲起给出一套可以直接照搬的Python最小实现最后把我在实际调试中踩过的坑全部摊开。1. 先把问题说清楚Agent为什么需要“技能”而不是“函数”1.1 “技能”和普通函数的区别在哪很多人第一次接触函数调用时会把工具调用想得很简单给模型一个函数列表它挑一个传入参数程序执行完事。真去做了就会发现这个“函数列表”里的每一项含义完全不同于你在代码里写的Python函数。模型是纯文本进、纯文本出的它根本不知道你函数体里做了什么事。它唯一能感知的就是你喂给它的那份说明文字。所以模型接收到的不是可执行代码而是“调用意图的说明书”。模型要做的事情是根据用户的请求从你提供的说明书中挑一条最匹配的条目然后按说明书里约定的格式输出一个JSON给你。这就解释了为什么不能把一个技能简单等同于一个函数。一个技能应该至少包含三段信息第一它叫什么名字负责在模型面前和其他工具区分开第二它做什么、什么时候该用这是模型做判断的唯一依据第三它需要哪些参数每个参数是什么类型、什么约束这是模型填表的规范。我把这段信息称为“技能定义”。agent-skills的核心思考就是把技能定义当作一个显式的、结构化对象来管理而不是让它散落在各个业务文件里变成裸函数。裸函数的声明、文档、参数约束都写在程序员的注释里模型看不见自然也就谈不上“可靠调用”。1.2 典型翻车现场问题都出在“裸函数”我曾经在某个客服问答项目里做过一次代码评审那个项目的功能很简单让模型帮用户查订单、查物流、算运费。开发同学为了省事直接在代码里写了七八个async函数逐个塞进模型接口的tools参数里。结果联调的时候问题不断。现象很典型模型动不动就选错函数把“按订单号查订单”写成“按用户ID查用户信息”有时候参数传成字符串后端函数明确要求int一执行就报错更头疼的是函数执行抛了异常没有兜底整个对话回合直接死掉用户看到的回答是空白的。这些问题的根子不在模型能力而在“技能”这个概念的缺失。裸函数没有统一的名称规范、没有描述规范、没有参数校验、没有异常边界模型只能靠猜。靠猜就一定会错。agent-skills要解决的就是把这些约束标准化让技能从“一个函数”变成“一条有完整自述信息的服务”。2. 整体架构注册表、描述、执行器怎么分工2.1 核心组件拆解技能库的架构拆成三个核心组件外加一个集成层。第一个是SkillDefinition技能元数据对象把技能名字、描述、参数schema、函数引用全部集中到一处。第二个是SkillRegistry注册表维护所有技能定义负责注册、查询、去重也负责把全部技能转换成模型能识别的tools格式。第三个是SkillExecutor执行器接收模型传回的skill_name和arguments找到技能定义做参数校验然后执行真正的函数最后把返回值包装成统一的结果格式。集成层是框架和模型API之间的桥梁核心是两个方法to_tools_schema()把注册表里所有技能转成模型接口能认的function schemahandle_tool_call()处理模型返回的tool_call消息把执行结果写回会话上下文。这个分层最大的价值是技能可以独立测试。注册一个技能以后完全不经过模型也能通过Executor直接调用它单元测试写起来非常舒服。另一个好处是换模型厂商时只需要改schema转换这一层技能定义和执行逻辑完全不用动。我自己在后面加技能、调描述时从来没动过主流程代码靠的就是这套解耦。2.2 技能描述是给模型看的说明书在项目里迭代最久、收益最大的其实是技能描述的写法。技能描述不是给程序员看的功能注释而是给模型看的说明书。模型在回答用户问题时会把用户请求和每个技能的描述做语义匹配描述越精准命中率越高。先看一个反例“获取用户信息”。这个描述的问题在于没有说清用户信息从哪里来、适用什么场景也没有和“查询用户订单”“查询用户等级”这类技能做区分。模型如果拿不准要么不调用要么随机挑一个结果就是问答助手表现得非常不稳定。正例应该这么写“根据用户ID从客户系统中查询用户的注册信息包括昵称、手机号、会员等级。当用户询问‘我的资料’‘我的账号信息’时使用。注意查询订单记录请使用另一个技能query_user_orders不要混淆。”两者对比一下技能名反例描述正例描述get_user_info获取用户信息根据用户ID查询注册信息明确字段范围与触发场景并指出和相似技能的边界技能命名也要有约定。我的习惯是动词开头、下划线连接一眼能看出动作和对象比如fetch_weekly_reports、summarize_text。避免用handle、do、process这类抽象动词模型没法从抽象动词里读出具象动作。3. 核心实现把agent-skills的最小可用版本跑起来3.1 技能元数据用结构约束散乱这一章直接上代码。依赖尽量少Python标准库加一个dataclass就能跑起来。先定义技能元数据from dataclasses import dataclass, field from typing import Callable, Dict, Any dataclass class SkillDefinition: name: str description: str parameters: dict func: Callable required: list field(default_factorylist)每个字段都有明确职责。name是模型识别的唯一标识description是模型做选择的依据parameters是参数说明的JSON Schemarequired标记哪些参数必填func是真正执行时调用的函数引用。把parameters设计成dict而不是直接用函数签名是因为模型接口的tools参数期望的就是JSON Schema风格的结构直接透传最省事。3.2 装饰器注册新增技能只动一个文件注册表用一个简单的类来管理class SkillRegistry: def __init__(self): self._skills: Dict[str, SkillDefinition] {} def register(self, definition: SkillDefinition): if definition.name in self._skills: raise ValueError(f技能 {definition.name} 重复注册) self._skills[definition.name] definition def to_tools_schema(self): tools [] for name, skill in self._skills.items(): tools.append({ type: function, function: { name: skill.name, description: skill.description, parameters: { type: object, properties: skill.parameters, required: skill.required } } }) return tools注册过程用一个装饰器封装让技能定义和使用现场放在一起registry SkillRegistry() def skill(name: str, description: str, parameters: dict None, required: list None): def decorator(func): definition SkillDefinition( namename, descriptiondescription, parametersparameters or {}, requiredrequired or [], funcfunc ) registry.register(definition) return func # 返回原函数不改变原有调用方式 return decorator装饰器返回原函数而不是wrapper这样技能函数在项目其他地方的普通调用完全不受影响。新增一个技能只需要在任意模块里用装饰器标记然后确保模块被加载过就行主流程代码一行都不用改。这就是“注册集中、实现分散”的好处。3.3 执行器把异常挡在会话之外执行器负责把模型的调用意图变成真实结果。这一步的异常处理是整个框架安全性的关键。import json class SkillExecutor: def __init__(self, registry: SkillRegistry): self.registry registry def execute(self, skill_name: str, arguments: dict): definition self.registry._skills.get(skill_name) if definition is None: return {error: f没有找到技能 {skill_name}} missing [k for k in definition.required if k not in arguments] if missing: return {error: f缺少必填参数: {, .join(missing)}} try: result definition.func(**arguments) return {result: result} except TypeError as e: return {error: f参数类型或个数不匹配: {e}} except Exception as e: return {error: f技能执行失败: {e}}这里把所有异常都捕获住并转成结构化错误目的不是为了吞错误而是为了防止一条坏消息污染整个会话上下文。模型发出的tool_call本身不可控参数内容可能是字符串、缺字段、甚至非法JSON如果不在执行层拦一道异常会向上扩散最终把整个对话搞崩。3.4 完整调用链路让模型真正“用”起来下面用两个实际技能演示完整链路。第一个技能读取某一周的周报文件列表第二个技能做文本摘要skill( namefetch_weekly_reports, description获取指定周的周报文件列表并返回文件路径。当用户说汇总本周周报看看这周的周报时调用。, parameters{ week_offset: { type: integer, description: 周偏移量0表示本周1表示上周默认0 } }, required[] ) def fetch_weekly_reports(week_offset: int 0): # 实际场景这里会扫描文件系统这里用模拟数据 return [freports/第{week_offset}周_team.md, freports/第{week_offset}周_plan.md] skill( namesummarize_text, description对一段文本做摘要。当用户需要快速了解长文本的核心内容时调用。, parameters{ content: {type: string, description: 待摘要的文本}, max_length: {type: integer, description: 摘要最大长度默认150} }, required[content] ) def summarize_text(content: str, max_length: int 150): if len(content) max_length: return content return content[:max_length] ...组装tools并执行多轮对话messages [{role: user, content: 帮我把本周周报的核心内容整理一下}] tools registry.to_tools_schema() # 假设你已经初始化了大模型客户端变量名为 client resp client.chat.completions.create( modelmodel-name, messagesmessages, toolstools ) msg resp.choices[0].message if msg.tool_calls: # 第一轮模型只给调用意图不含执行结果 messages.append(msg) for call in msg.tool_calls: try: arguments json.loads(call.function.arguments) except json.JSONDecodeError: arguments {} result executor.execute(call.function.name, arguments) if error in result: result_text f技能执行失败: {result[error]} else: result_text json.dumps(result[result], ensure_asciiFalse) messages.append({ role: tool, tool_call_id: call.id, content: result_text }) # 第二轮模型基于真实结果组织最终回答 resp2 client.chat.completions.create( modelmodel-name, messagesmessages, toolstools ) print(resp2.choices[0].message.content)真实场景下模型可能需要连续调用多个技能才能完成一个任务比如先列出文件再读内容再摘要。这种多轮工具调用建议用循环包住def run_agent(user_query: str, max_iterations: int 5): messages [{role: user, content: user_query}] for _ in range(max_iterations): resp client.chat.completions.create( modelmodel-name, messagesmessages, toolstools ) msg resp.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for call in msg.tool_calls: try: arguments json.loads(call.function.arguments) except json.JSONDecodeError: arguments {} result executor.execute(call.function.name, arguments) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) }) return 达到最大迭代次数已停止max_iterations这个参数一定要有。模型偶尔会陷入“调用技能—拿到结果—发现不对—再调用”的循环没有轮次上限token会被白白耗光而且用户等服务超时。4. 实测中踩过的坑与排查方法4.1 模型不调用或选错技能这是我在项目里遇到最多的现象。排查顺序基本固定第一步确认tools有没有真的传进请求里很多奇怪问题其实是代码里少传了一个参数。第二步检查技能描述是否把触发场景写清楚了反例“获取信息”这种模糊描述几乎必然导致模型不敢调用。第三步看是否存在两个技能描述高度重叠如果同一个用户问题可以匹配两个技能模型只能靠猜。第四步确认技能名称本身语义清晰get_user_by_id和get_user_profile放在一起模型很难分辨该哪一个。还有一个小的经验如果模型完全不调用任何技能可以在系统提示里明确加一句“当用户需求与工具功能匹配时请优先使用工具获取真实数据而不是凭记忆回答”。这句话在实测中非常有效很多模型默认倾向于直接回答不会主动碰工具。4.2 参数schema写得太随意参数schema写得越严格运行时问题越少。我见过最省事的写法是parameters里只写字段名不写type和description结果模型传进来的参数五花八门类型全靠猜。注意两点第一模型可填可不填的参数也要在description里说明默认值否则模型不会主动填第二required列表必须和函数签名保持一致函数定义里带默认值的参数就不要放进required。类型问题也值得多说一句。模型输出经常把数字格式化成字符串比如输出“28.5”而不是28.5。我的处理是在执行器里做一层宽松转换对int和float参数调用类型构造器包一下同时捕获ValueError。如果参数结构复杂直接引入pydantic做校验会更省心。4.3 执行失败后陷入重试循环执行器把异常包装成错误文本返回给模型后模型可能会自作主张地“修复”参数再来一次。如果修复方向不对就形成“调用—失败—再调用”的死循环。我在实际运行中见过连续七次调用同一个技能、每次都报同样错误的记录。解决办法有两个一是在错误结果里明确写“参数已校验请停止重试此技能”把模型的注意力转移到询问用户或换一个技能上二是在run_agent的循环里加同技能连续失败计数超过阈值就返回终止信息。两招一起用实测没有再出现过长时间循环。4.4 技能多了以后注册表先乱掉技能数量到几十个之后一个新的问题浮出水面注册表变得不可控。技能散落在不同模块里重复名称、相似名称冒出来改一个描述不知道会不会影响其他技能。我的处理方式有三个第一按业务域拆模块文件每个模块只负责注册自己领域的技能第二给SkillDefinition加一个source字段标记技能来自哪个模块便于追溯第三提供一个list_all_skills()调试接口每次更新后先看一眼全量技能清单。这个阶段也可以考虑开始写回归测试。我的做法是给每个技能准备一组固定输入和期望输出跑一次回归就能发现“描述改完之后技能突然不被选中了”这类问题。5. 让技能库更好用的几个扩展方向5.1 给只读类技能加结果缓存查询类技能比如查用户资料、查订单状态结果在一小段时间内不会变化可以加一层缓存。缓存的key用“技能名 参数JSON的hash”有效期根据业务数据更新频率来定。注意只能给幂等技能加缓存也就是相同输入必定产生相同输出的技能否则会拿到过期数据。我在一个小知识库项目里给文档查询技能加了五分钟缓存整体调用量直接降了三分之一。5.2 让技能可组合单个技能能力太弱时组合是最好的出路。比如先调用list_documents再调用read_document最后调用summarize_text三个技能配合才能完成“总结某份文档”的任务。组合逻辑不用在框架里写死交给模型自己编排就好。框架要做的是保证每一步的执行结果都能成为下一步的输入这就要求所有技能返回统一结构——这也就是为什么执行器要把结果包装成固定格式的原因。5.3 用回归测试锁住“选错技能”的回归技能描述是文本文本改动的影响很难提前预料。可能你只是想补充一句说明结果模型从此不再触发某个技能了。我的办法是建立一个简单的回归测试集每条用例包含一个用户提问、一个期望触发的技能名、一组期望参数。每次改完描述或新增技能后跑一遍全部用例哪个技能选错了一眼就能看出来。这个测试集不需要引入复杂的测试框架几十行脚本就能完成但它是整个技能库的护城河。最后分享一个我在调试时反复用到的土办法把tools schema打印出来然后假装自己是个完全不理解代码的模型读一遍每个技能描述问自己“如果用户说这句话我会不会选这个技能”。如果连你自己都想了一下才拿定主意那模型八成也会犹豫。描述这种东西写到“不用思考就能选对”才是合格状态。agent-skills这个项目最终带给我的不是那几千行框架代码而是把这个判断标准刻进了后续所有工具开发的日常里。