☰
Agent技能层实战:解决Function Calling选错与参数混乱
2026/9/26 9:57:02 网站建设 项目流程

最近在搞Agent项目的时候踩了不少坑,其中最大的一个就是:模型明明具备了调用工具的能力,但面对一堆函数定义时,经常选错、漏调,甚至直接把工具参数编造成不存在的字段。后来我把整个工具调用体系重构了一遍,拆出一层独立的“技能层”,也就是这次想聊的agent-skills。

这套东西说白了就是给Agent配备一套统一管理的技能库,把每一个能力点(比如“查询天气”“提取网页正文”“发送邮件”)封装成带标准描述、参数约束、执行逻辑和执行反馈的技能模块。你可以把它理解成给Agent做了一份“岗位说明书”,让它知道有什么活能干、怎么干、干完怎么汇报。这种方式有效解决了函数列表冗长、模型选择混乱、技能复用困难三个问题。如果你也在做Agent相关的开发,或者被function calling的稳定性折磨过,这篇文章应该能给你一些直接能用的思路和代码。

1. 项目整体设计与思路拆解

1.1 为什么需要独立技能层

很多Agent项目一开始都是这么干的:在系统提示词里塞一长串函数定义,每个函数JSON Schema写得密密麻麻,然后让模型自己决定调用哪个。原型阶段没问题,工具少、场景单一,模型不乱。可一旦工具超过十个、二十个,问题就来了。

  • 上下文被函数定义大量吞噬,留给对话和思考的token变少;
  • 模型在相似工具之间会出现选择混淆,比如把“发送邮件”和“保存草稿”搞混;
  • 工具逻辑散落在代码各处,新增一个功能需要改提示词、改代码、改测试,牵一发动全身。

把这些工具统一收敛为“技能”,本质上是在模型和底层实现之间加了一层“调度语义层”。Agent不再直接看到一堆函数,而是面对一份技能清单,每项技能都有明确的能力边界和输入输出约束。模型只负责“选技能、给参数”,真正执行由技能运行时完成,这样职责清晰,稳定性也上来了。

1.2 技能体系的设计目标

我在做agent-skills的时候定了几个核心目标,后面所有设计都是围绕这些目标展开的:

  • 标准化:每个技能必须遵循统一的描述格式,包括唯一名称、能力说明、参数Schema、返回结构,这样注册、检索、调用都能走同一套逻辑。
  • 可发现:Agent面对技能清单时,能根据用户意图快速匹配到正确技能,这要求技能描述不仅准确,还得会“自我推销”,把适用场景和边界说清楚。
  • 可扩展:新增技能不能动主流程,注册中心设计成插拔式,加一个技能就是加一个文件加一行注册的事。
  • 可观测:每次技能执行要有日志、耗时、参数快照,出了问题能回溯,而不是让Agent像个黑盒一样乱调一通。

2. 技能定义与Schema设计:先给能力立个“身份证”

2.1 技能描述的核心字段解析

技能定义是整个体系的地基。我一开始觉得这不就是个JSON嘛,随便写写就行,后来被坑了几次才明白,描述写得不好,模型就是选不对。一个合格的技能定义至少包含以下字段:

  • name:技能唯一名称,通常是动词+名词的格式,比如fetch_webpage、send_email,保证语义清晰。
  • description:自然语言描述,说明这个技能能做什么、在什么场景下使用。这部分特别关键,模型的意图匹配主要看它。写法上要包含触发条件、典型用途、注意事项,不要只写一句干巴巴的话。
  • parameters:JSON Schema格式的参数约束,定义每个参数的名称、类型、是否必填、描述和约束范围。
  • returns:返回结果的结构说明,包括成功时的数据格式和失败时的错误码约定。
  • tags:技能分类标签,便于按业务域做筛选和路由,比如“网页处理”“消息通知”“数据分析”。
  • enabled:开关状态,临时下架某个技能不用删代码,改个开关就行。

下面是我项目里一个技能定义的真实案例:

skill_schema = { "name": "fetch_webpage", "description": "抓取指定URL的网页正文内容,适用于需要从网页中提取文字信息、新闻详情、文章主体的场景。如果URL是PDF或图片,请先调用file_convert技能转换格式。", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "目标网页的完整地址,必须包含http或https协议前缀。" }, "max_chars": { "type": "integer", "description": "最大返回字符数,默认3000,超出部分会被截断。", "minimum": 100, "maximum": 20000 } }, "required": ["url"] }, "returns": { "type": "object", "properties": { "content": {"type": "string"}, "title": {"type": "string"}, "status_code": {"type": "integer"} } }, "tags": ["web", "content"], "enabled": True }

2.2 参数Schema的设计经验与误区

参数Schema是模型生成参数的“参考答案”,设计得好不好直接影响调用成功率。我踩过的几个坑值得单独说一下:

  • 必填字段能少就少。刚开始我习惯把所有可能用到的参数都设为必填,结果模型经常为了补全参数去编造值。后来改成只保留真正执行必需的字段,其余全部optional,并给默认值。
  • 约束范围要写清楚。比如最大重试次数,不写minimum和maximum,模型可能给你返回一个负数或者天文数字。虽然在运行时可以做二次校验,但提前在Schema层面约束能减少很多无效调用。
  • 枚举值要显式列出。比如排序方式只有asc和desc两种,直接在Schema里用enum圈死,模型基本不会跑偏。
  • 字段描述别用抽象词汇。写“用户希望查询的日期”比写“日期参数”好得多,描述越贴近自然语言,模型理解越准确。

2.3 技能与工具的边界:别把啥都当技能

还有一个容易混淆的点:不是所有函数都适合包装成技能。我见过有人把“字符串拼接”“日期格式化”这种基础工具也注册成技能,结果技能列表变得非常臃肿,模型反而更难选。

我的判断标准很简单:一个技能必须面向一个完整的目标能力,且具备独立的业务语义。“字符串拼接”是实现细节,不是目标能力;但“根据模板生成周报”就是一个完整能力,适合做成技能。技能是给模型看的“能力菜单”,不是代码函数库,这个思路一定要拎清。

3. 注册中心与调度核心:从技能目录到路由决策的实现

3.1 注册中心:用最小的代价管理技能清单

技能注册中心是整个体系的“总台账”,负责维护所有技能的定义、启停状态和运行时调用入口。我选择用Python实现一个轻量级的注册器,核心数据结构就是字典加装饰器,既不引入重量级框架,又能快速接入现有项目。

# registry.py from typing import Callable, Dict, Optional class SkillRegistry: def __init__(self): self._skills: Dict[str, dict] = {} self._handlers: Dict[str, Callable] = {} def register(self, name: str, schema: dict): def decorator(func: Callable): if name in self._skills: raise ValueError(f"技能 {name} 重复注册") schema.setdefault("name", name) self._skills[name] = schema self._handlers[name] = func return func return decorator def get_skill(self, name: str) -> Optional[dict]: return self._skills.get(name) def list_skills(self) -> list: return [ {"name": name, "description": skill.get("description"), "tags": skill.get("tags", [])} for name, skill in self._skills.items() if skill.get("enabled", True) ] def dispatch(self, name: str, **params): skill = self.get_skill(name) if not skill: raise KeyError(f"技能 {name} 不存在") if not skill.get("enabled", True): raise RuntimeError(f"技能 {name} 已停用") # 调用前统一记录日志和耗时 import time start = time.time() try: result = self._handlers[name](**params) return {"success": True, "result": result, "cost_ms": round((time.time() - start) * 1000, 2)} except Exception as e: return {"success": False, "error": str(e), "cost_ms": round((time.time() - start) * 1000, 2)} registry = SkillRegistry()

这套设计用装饰器把技能的注册和业务逻辑解耦,业务侧的调用方压根不需要关心注册中心内部怎么存、怎么调,只暴露register、list_skills、dispatch三个方法就够了。

3.2 路由选择策略:如何让模型选对技能

当技能数量多了之后,不可能把全部技能描述一股脑塞进系统提示词。我采用的策略是两阶段路由:

第一阶段是粗筛,根据用户输入的意图关键词或任务类型,从注册中心拉出一批候选技能。这里可以用简单的规则关键词匹配,也可以用向量检索。没有复杂基础设施的情况下,关键词映射表就够用,速度快、好调试。

第二阶段是精排,把候选技能的完整描述(包括参数Schema)发给模型,让它从中选择最合适的一个并生成参数。因为候选集被压缩到5个以内,模型的选择准确率显著提升,token消耗也大幅降低。

我专门做了个对比测试:全量技能塞进上下文时,模型的选错率大概在18%左右;换成两阶段路由后,选错率降到3%以下,响应时间也快了不少。这个提升主要不是因为模型变聪明了,而是因为决策空间变小了,干扰项少了。

3.3 路由失败与兜底逻辑

就算做了两层路由,模型还是有可能选错或者干脆不知道怎么选。这时候一定要有兜底逻辑,否则Agent就会卡在那里或者乱调一个技能。

我实现了一个简单的兜底策略:

  • 如果模型返回的技能名称不在注册中心,就触发“反问澄清”流程,告诉模型该技能不存在,并给出相近的技能名供选择;
  • 如果模型没有返回任何技能,但用户输入明显包含任务意图,就启动“默认路由”,将输入重新走一遍关键词粗筛,并把匹配度最高的前两个技能作为建议推给用户确认;
  • 连续两次路由失败,直接转人工会话,避免Agent陷入死循环。

这段逻辑听起来简单,但能挡住一大半线上问题。技能调度不能只考虑“选对”的路径,还得考虑“选错”“不选”“选了个不存在的”这三条异常路径。

4. 实操过程与核心环节实现:手写一个技能并接入Agent

4.1 从零实现一个“网页正文提取”技能

理论说够了,直接上手写代码。我以一个非常常用的技能fetch_webpage为例,完整演示从定义、注册到接入Agent的全过程。

第一步,写业务处理函数。我基于httpx和BeautifulSoup实现了一个简单的正文提取器,没有上复杂的正文抽取算法,但对大多数静态页面够用:

# skills/fetch_webpage.py import httpx from bs4 import BeautifulSoup from registry import registry def _extract_main_content(html: str, max_chars: int) -> dict: soup = BeautifulSoup(html, "html.parser") title = soup.title.string.strip() if soup.title else "无标题" # 优先选择文章容器,class和id使用常见命名 article = None for selector in ["article", ".article-content", ".post-content", "#main-content", "main"]: article = soup.select_one(selector) if article: break if not article: article = soup.body # 去掉无用的标签 for tag in article(["script", "style", "nav", "footer", "aside"]): tag.decompose() content = article.get_text(separator="\n", strip=True) if len(content) > max_chars: content = content[:max_chars] + "\n...(内容过长已截断)" return {"title": title, "content": content} @registry.register( "fetch_webpage", { "description": "抓取指定URL的网页正文内容,适用于提取新闻文章、博客详情、产品介绍等文本信息的场景。", "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "页面完整地址,必须以http或https开头"}, "max_chars": {"type": "integer", "description": "返回正文的最大字符数,默认3000", "minimum": 100, "maximum": 20000} }, "required": ["url"] }, "returns": { "type": "object", "properties": { "title": {"type": "string"}, "content": {"type": "string"}, "status_code": {"type": "integer"} } }, "tags": ["web", "content"] } ) def fetch_webpage(url: str, max_chars: int = 3000) -> dict: with httpx.Client(timeout=15, follow_redirects=True) as client: resp = client.get(url, headers={"User-Agent": "Mozilla/5.0"}) if resp.status_code != 200: return {"status_code": resp.status_code, "title": "", "content": f"页面返回异常状态码: {resp.status_code}"} data = _extract_main_content(resp.text, max_chars) data["status_code"] = resp.status_code return data

细心的读者可能注意到了,注册的时候我没有单独传name参数,而是在装饰器里第一个参数指定了名称,这就是上一节注册中心的用法,保证技能定义和业务逻辑就近存放。

4.2 把技能接入Agent主流程

技能实现好还不够,关键是怎么让Agent在对话过程中调起来。我这边采用的是比较传统的工具调用流程,做了一个简单的执行管理器:

# agent_executor.py import json from registry import registry SYSTEM_PROMPT_TEMPLATE = """ 你是一个智能助手。你可以使用以下技能帮助用户完成任务: {skill_list} 请根据用户的问题选择合适的技能并给出参数。你的回复必须是JSON格式,格式如下: {{"skill": "技能名称", "params": {{...参数...}}}} 如果不需要调用技能,直接回复用户即可。 """ def build_skill_list(): # 把技能描述和参数Schema格式化给模型看 lines = [] for skill in registry.list_skills(): lines.append(f"- {skill['name']}: {skill['description']}") if "parameters" in skill: lines.append(f" 参数: {json.dumps(skill['parameters'], ensure_ascii=False)}") return "\n".join(lines) def handle_user_message(user_input: str, history: list) -> str: system_prompt = SYSTEM_PROMPT_TEMPLATE.format(skill_list=build_skill_list()) # 这里替换成你自己的LLM调用 response = call_llm(system_prompt, history + [{"role": "user", "content": user_input}]) try: parsed = json.loads(response) except json.JSONDecodeError: return response # 模型没有调用技能,直接返回原文 skill_name = parsed.get("skill") params = parsed.get("params", {}) if skill_name: result = registry.dispatch(skill_name, **params) # 把技能执行结果交给模型生成最终答案 final_answer = call_llm( "根据技能返回结果回答用户问题: " + json.dumps(result, ensure_ascii=False), history ) return final_answer return response

这个主流程不复杂,但要注意几个细节:技能列表的格式化直接影响模型的理解质量,我建议把参数Schema用JSON格式拼进去而不是只给字段名;技能执行结果回传给模型的时候,最好带上成功/失败标记,模型才知道怎么组织自然语言回复。

4.3 实操现场记录:一次真实调用过程

我拿一个实际例子演示一下效果。用户提问:“帮我看看这篇https://example.com/blog/agent-systems的文章开头讲了啥。”系统经过两阶段路由后,选中了fetch_webpage技能,并把链路日志打了出来:

  • 粗筛阶段:输入文本包含“看看文章”“网址”,关键词映射命中技能标签web、content;
  • 精排阶段:候选技能为fetch_webpage、summarize_text、extract_keywords,模型选择fetch_webpage并生成参数{"url": "https://example.com/blog/agent-systems", "max_chars": 5000};
  • 执行阶段:业务函数发出HTTP请求,返回标题和正文摘要,耗时约768ms;
  • 汇总阶段:LLM拿到技能结果后,用自然语言向用户复述“文章开头先介绍了Agent设计中的几个关键问题,包括上下文长度限制、工具调用稳定性、任务拆解策略……”。

整个过程用时2.3秒,技能执行本身占大头,模型推理只有两次。这个数据说明,技能层的引入不会成为性能瓶颈,真正费时的往往是模型多轮推理和外部请求,所以技能执行器做好超时控制和并发管理就可以了。

5. 常见问题与排查技巧实录

5.1 模型总是选错技能,怎么办

这是个高频问题,几乎每个做Agent的朋友都会遇到。排查思路按优先级排列:

  • 先看技能的description是否具体。比如“发送邮件”和“发送企业微信消息”在描述里都是“发送通知”,模型当然容易混。改成“发送邮件,适用于需要将内容投递到对方邮箱收件箱的场景”和“发送企业微信消息,适用于团队内部即时通知场景”,混淆率立刻下降。
  • 再看参数Schema是否足够区分。比如两个技能都有content参数,但一个的字段名是email_body、一个是message_content,模型在生成参数时就能更清晰地对号入座。
  • 还要排查是不是技能数量太多、描述太长导致注意力分散。这种情况建议缩减list_skills返回的字段,只返回名称、一句话描述、必要参数,完整参数等到技能被选中后再加载。

最后还有一个土办法但很有效:给每个技能加一个“不适用场景”的说明。比如fetch_webpage的描述里写明“如果用户需要处理PDF文件,请优先选择file_convert技能”,这种反向排除法对模型非常友好。

5.2 技能执行超时和资源占用问题

技能执行器虽然包装简单,但底下的业务逻辑可能很重。比如网页抓取技能遇到一个响应极慢的站点,如果没设超时,线程就会一直挂着,Agent整体的并发能力会被拖垮。

我的做法分三层:第一层是HTTP客户端超时,统一设15秒,超过直接抛异常;第二层是技能执行总超时,用concurrent.futures包一层,超过30秒强制取消;第三层是信号量控制,限制同时执行的技能数量,防止某个技能把所有线程占满。

import concurrent.futures from functools import wraps def with_timeout(timeout_seconds): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(func, *args, **kwargs) return future.result(timeout=timeout_seconds) return wrapper return decorator @with_timeout(30) def fetch_webpage(url: str, max_chars: int = 3000): # 原有逻辑 ...

这个装饰器用起来很轻,给不放心的地方加上就行。注意ThreadPoolExecutor里如果任务真的超时,future所在的线程并没有被杀掉,只是调用方不再等待,所以最底层还是要做好连接超时,两层都设才能彻底兜住。

5.3 技能互斥与调用顺序怎么控制

有些技能不能同时调用或必须按顺序调用,比如“创建订单”和“支付订单”必须严格先后执行;再比如“读取数据库”和“写入数据库”如果并发执行容易出状态问题。

我的方案是给技能定义加一个conflicts列表,声明与哪些技能存在互斥关系;调度器在执行前先检查目标技能是否与当前正在运行的技能冲突,有冲突就排队等待。同时通过depends_on字段表达依赖顺序,在任务编排层保证调用顺序:

skill_schema = { "name": "create_order", "conflicts": ["update_inventory", "generate_invoice"], "depends_on": [], }

这个机制在复杂的业务Agent里特别重要。不要幻想着模型自己能控制调用顺序,模型不会替你维护状态机,这活必须得由调度器来做。

5.4 技能测试的独家技巧

技能开发得再多也要保证可用性,我强烈建议每个技能写一个自测脚本,直接把注册中心拉起来跑一遍极端输入。百试百灵的一组测试用例:

  • 缺必填参数时,执行器是否返回友好的错误提示;
  • 参数类型错误时,比如把max_chars传成字符串,Schema校验是否拦截;
  • 业务逻辑抛异常时,dispatch是否捕获并返回success: False;
  • 技能名称不存在时,是否给出相近技能的建议;
  • 高并发调用同一个技能,是否会出现状态污染。

我自己就因为在测试时漏了“并发调用同一技能”这个用例,上线后遇到过注册表里面的状态字段被多个请求互相覆盖的惨剧。从那以后,所有技能测试都强制加并发场景,这个教训值得分享出来。

最后分享一个我在实际使用中的小技巧

如果你也是从传统工具调用迁移到技能体系,不要急着把现有代码全部重写一遍,更不要指望一次设计就能覆盖所有场景。我的做法是先挑三五个最核心、最常被调用的工具,把它们包装成技能跑通全链路,再把剩余工具逐步迁移。技能库这个东西,边用边调才是常态,描述写得不好就改描述,参数设计不合理就改Schema,运行一段时间后,你会发现那套注册中心真正沉淀下来的,其实就是你对业务能力的结构化理解。

另外还有一个细节:技能的执行日志一定要留全,包括入参快照、出参快照、耗时、错误堆栈。这既是排查问题的依据,也是评估模型选技能质量的样本集。每次调完路由策略,我就会拉出一批日志重新标注一次,对比选技能的正确率,用真实数据代替拍脑袋决策。这套方法和agent-skills体系配合起来,算是目前我做过的最顺手的Agent开发架构了。

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

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

立即咨询