Agent技能层实战:解决Function Calling选错与参数混乱
发布时间:2026/9/26 9:57:03来源:尧图网络
最近在搞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自然语言描述说明这个技能能做什么、在什么场景下使用。这部分特别关键模型的意图匹配主要看它。写法上要包含触发条件、典型用途、注意事项不要只写一句干巴巴的话。parametersJSON 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. 实操过程与核心环节实现手写一个技能并接入Agent4.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, stripTrue) 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(timeout15, follow_redirectsTrue) 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_asciiFalse)}) return \n.join(lines) def handle_user_message(user_input: str, history: list) - str: system_prompt SYSTEM_PROMPT_TEMPLATE.format(skill_listbuild_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_asciiFalse), 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_workers1) as executor: future executor.submit(func, *args, **kwargs) return future.result(timeouttimeout_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开发架构了。
网站建设高端定制企业官网