☰
从函数调用到技能管理:agent-skills让AI Agent用对工具
2026/10/21 18:24:13 网站建设 项目流程

自从大模型开放了函数调用(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 schema;handle_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_factory=list)

每个字段都有明确职责。name是模型识别的唯一标识,description是模型做选择的依据,parameters是参数说明的JSON Schema,required标记哪些参数必填,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( name=name, description=description, parameters=parameters or {}, required=required or [], func=func ) 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( name="fetch_weekly_reports", description="获取指定周的周报文件列表并返回文件路径。当用户说'汇总本周周报''看看这周的周报'时调用。", parameters={ "week_offset": { "type": "integer", "description": "周偏移量,0表示本周,1表示上周,默认0" } }, required=[] ) def fetch_weekly_reports(week_offset: int = 0): # 实际场景这里会扫描文件系统,这里用模拟数据 return [f"reports/第{week_offset}周_team.md", f"reports/第{week_offset}周_plan.md"] @skill( name="summarize_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( model="model-name", messages=messages, tools=tools ) 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_ascii=False) messages.append({ "role": "tool", "tool_call_id": call.id, "content": result_text }) # 第二轮:模型基于真实结果组织最终回答 resp2 = client.chat.completions.create( model="model-name", messages=messages, tools=tools ) 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( model="model-name", messages=messages, tools=tools ) 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_ascii=False) }) 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这个项目最终带给我的,不是那几千行框架代码,而是把这个判断标准刻进了后续所有工具开发的日常里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询