ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent技能系统设计与工具调用优化:从Function Calling到动态技能编排的实践指南

Agent技能系统设计与工具调用优化:从Function Calling到动态技能编排的实践指南 上个月我在排查一个Agent项目的稳定性问题时发现了一个特别有意思的现象同样一个任务换一套技能组织方式成功率能从68%直接干到91%。不是模型变聪明了也不是prompt写得更多了而是我给Agent配的“技能包”变了。很多人一开始接触Agent开发都是从“给大语言模型一个API Key”开始的。跑通一个ReAct循环感觉万事大吉。但真当你开始做带状态、涉及多来源工具调用、还要应对各种边界情况的Agent你会发现瓶颈根本不是模型的推理能力而是你对“技能”的建模方式。到底是把工具列表一股脑塞进system prompt还是把技能拆成独立模块按需加载技能参数是写死还是让模型自己给技能之间的依赖和冲突怎么处理这篇文章我想把“agent-skills”这件事从头到尾捋一遍包括我踩过的坑、实测下来的参数配置、以及一套可以直接抄走的组织思路。你不需要有非常深的背景但如果你在做一个稍微像样点的Agent这篇文章能帮你少走很长一段弯路。1. 大模型Agent为什么需要一套“技能系统”先把话说到底大模型本身不具备任何执行力。你往对话框里敲“帮我查一下这个城市的实时天气”模型再聪明也不知道确切的温度数据。所以你需要给它工具也就是技能。但问题在于技能的粒度怎么定、接口怎么暴露、描述怎么写、由谁来决定调用时机这些在项目初期看着都是小事拖到后期全都是事。1.1 核心需求解析Agent的“能力边界”到底由什么决定我在实际项目里的体感是一个Agent的能力边界绝不仅仅取决于底层大模型的参数量而是取决于“模型 工具集 技能编排”这个三元组的整体配合水平。工具集很好理解就是你接了多少个API、多少种外部服务。技能编排则是另一层功夫哪些技能是默认激活的、哪些是按需动态拉取的、技能之间是否存在先后依赖关系。如果这一层做得糙哪怕你接了一百个API模型也会在选错工具、反复试错、参数幻觉上把可用性和成本双双拖垮。打个比方。你把一个普通人扔进一个装备齐全的厨房他大概率能做出一顿饭但你要是给他一本写满一千道菜谱的册子让他每次做饭前都从头翻他做菜的速度和成功率一定感人。技能系统做的事情就是让这个人手上随时只有当下最可能用到的三五件工具而且每个工具的说明书清晰到不需要第二次思考。1.2 “技能”和“工具”的传统实现方式社区里最常见的实现方式是把所有工具的信息写成一个JSON数组塞进system prompt。我见过一个极端案例对方接了四十多个工具光tools定义就有上万token。每次请求光工具描述的传输就是一个不小的开销而且模型在长上下文里对工具的注意力会被严重稀释。实测下来当工具数量超过十五个时模型选择错误工具的概率会开始明显爬升。另一种传统做法是“代码即工具”就是你在Agent的executor里预先定义好多段可执行逻辑通过if-else去判断用户意图。这个方法前期很顺手但每加一个新功能就得改主逻辑维护成本高到让人头大。我在团队里见过一个跑了一年的Agent项目主文件三千多行一半的代码都在做意图判断和参数搬运看着就想重构。2. 深入拆解Agent Skill的定义与组织方式既然技能系统这么重要那就得回到一个根本问题到底怎么给Agent定义一个“好技能”2.1 定义方式对比JSON Schema、docstring还是自然语言指令目前主流大模型平台对工具的描述方式基本可以分为三类。第一类是JSON Schema。这种方式的优势是结构严谨、类型明确、机器可读性极强OpenAI、Anthropic、Google等大模型接口层面的function calling基本都是这个路子。但它有个隐蔽的坑写复杂嵌套schema时容易把模型绕晕尤其是在定义anyOf、oneOf这类高级约束时模型反而更容易生成不合格的参数。第二种是docstring风格也就是把工具的说明写在函数注释里由解析器自动提取。这种方式最贴近Python的开发生态写起来很自然迭代速度也快。但缺点是描述信息的密度和表达能力受docstring语法限制复杂约束表达起来很憋屈。第三种是自然语言指令。这种方式多见于ReAct型Agent把技能描述直接写成一大段话比如“当用户提到天气时调用get_weather接口注意城市名需要转换成拼音”。它的优势是表达空间大可以把非常复杂的边界情况都描述清楚但代价是消耗上下文token且容易产生描述歧义。我在生产项目里最常用的方式是“JSON Schema为主、自然语言补充”的混合模式工具的调用签名交给schema去规范但工具的描述字段里除了说明功能再补一段供模型参考的“判据”说清楚“什么情况下你该用我什么情况下你不该用我”。这个小技巧是我自己摸索出来的能显著降低模型乱点工具的概率。2.2 技能的组织形态独立模块、技能树与按需加载技能定义好之后还涉及到一个组织形态的问题。是把所有技能平铺在一个列表里还是组织成一个有层级关系的结构平铺列表最简单但前面已经说过一旦技能数量增加模型的决策质量会下滑。按需加载的“技能树”策略是我目前最推荐的做法。具体思路是这样的把技能分成两个层级顶层是“路由器”技能比如“查询信息类工具”“执行操作类工具”“计算分析类工具”。Agent在收到用户请求后先通过一个简短的意图判断决定该激活哪一组子技能再把这一组的详细定义填入上下文。这个做法带来的收益是立竿见影的。我把一个电商客服Agent的五十多个技能拆成商品咨询、订单售后、物流查询、营销活动四棵技能树后单轮任务调用的平均token消耗下降了差不多三分之一工具选择准确率也提升了。3. 实操过程与核心环节实现从零搭建一套可用的技能体系讲了半天理论还是得落到现实。接下来我带着你过一遍搭建一个基础Agent技能系统的完整流程。为了能被直接参考我会以OpenAI系接口的function calling为蓝本因为这套接口的生态资料最全也最容易做对比实验。3.1 环境准备与基础接口设计环境部分不需要特别复杂。Python版本3.10以上装上openai这个库就够了。如果本地要跑测试提前准备一个不带敏感信息的API Key。先定义一个最朴素的技能注册函数我习惯用装饰器模式来实现技能的收集和注册from typing import Callable, Dict, Any SKILL_REGISTRY: Dict[str, Dict[str, Any]] {} def register_skill(name: str, description: str, parameters: dict): def decorator(func: Callable): SKILL_REGISTRY[name] { name: name, description: description, parameters: parameters, func: func, } return func return decorator def dispatch_skill(name: str, arguments: dict) - str: skill SKILL_REGISTRY.get(name) if not skill: return fError: skill {name} not found. try: result skill[func](**arguments) return str(result) except Exception as exc: return fError executing {name}: {exc}这个设计的核心意图有两个。第一技能的定义和实现是热插拔的新增一个技能只需要写一个函数加一个装饰器不用动主循环第二错误处理被统一收口在dispatch_skill里不会因为技能本身抛异常就把整个Agent进程搞崩。3.2 用代码示例搭建一个带技能注册和调用的最小Agent接下来定义两个简单的技能演练一下。第一个是获取用户输入的时间第二个是算数计算。这两个技能没有实际的API依赖适合跑通全链路。import datetime import json register_skill( nameget_current_time, description获取当前的日期和具体时间精确到秒。当用户询问今天几号、现在几点、当前时间时使用。, parameters{ type: object, properties: {}, required: [] } ) def get_current_time(): now datetime.datetime.now() return now.strftime(%Y-%m-%d %H:%M:%S) register_skill( namecalculator, description执行基本的四则运算。适用于任何数值计算场景包括加减乘除和幂运算。特别注意如果用户描述中涉及优惠10%、折后价这类间接计算需求也应该调用本技能完成实际计算。, parameters{ type: object, properties: { expression: { type: string, description: 需要计算的数学表达式例如234 * 1.1。 } }, required: [expression] } ) def calculator(expression: str): # 在生产环境使用 eval 有安全风险这里仅为演示 # 真实项目建议用 ast 或 numexpr 等安全方案 return eval(expression)到这里大部分教程就停住了。但我还想再多做两步一是把注册的技能自动拼装成大模型能识别的函数列表二是做一个循环调用的核心逻辑。def build_function_specs(): specs [] for skill in SKILL_REGISTRY.values(): specs.append({ type: function, function: { name: skill[name], description: skill[description], parameters: skill[parameters] } }) return specs def run_agent(user_input: str, system_prompt: str 你是一个智能助手。请根据用户需求选择合适的技能并正确传参。) - str: from openai import OpenAI client OpenAI() messages [{role: system, content: system_prompt}] messages.append({role: user, content: user_input}) for _ in range(5): # 限制最多5轮工具调用防止死循环 response client.chat.completions.create( modelgpt-4o, messagesmessages, toolsbuild_function_specs(), tool_choiceauto, temperature0.2, max_tokens1024, ) msg response.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for tool_call in msg.tool_calls: func_name tool_call.function.name func_args json.loads(tool_call.function.arguments) result dispatch_skill(func_name, func_args) messages.append({ role: tool, tool_call_id: tool_call.id, content: result, }) return 已达到最大调用轮数仍未完成任务。这段代码本身不复杂但有几个细节值得说清楚。tool_choiceauto意味着让模型自己决定要不要用工具、用哪个工具不强制。temperature0.2是我做工具调用场景的固定值因为函数调用的核心是参数精确而不是创造性发散温度越高越容易让模型生成一些“看起来合理但格式跑飞”的参数。还有那个5轮限制没有这层保护遇到模型陷入死循环的时候你的账单会很难看。3.3 关键参数选择与计算如何配置模型参数更稳很多人在配工具调用时只关注tools参数忽略了全局参数对工具调用成功率的影响。我基于自己在多个项目里的实测列一个参数配置表给你做个参考。参数推荐值区间说明temperature0.1 ~ 0.3工具调用追求确定性过高会增加参数格式错误max_tokens512 ~ 2048给模型留出足够的额外推理空间尤其是工具参数较长时top_p0.9 ~ 0.95与temperature协同一般不同时大幅调整tool_choiceauto 或 requiredrequired可以强制调用工具但会损失灵活性慎用presence_penalty0工具调用场景下惩罚项通常不需要开启frequency_penalty0同上先说max_tokens。很多人以为它就是限制输出长度其实在工具调用场景里还牵涉到一个隐藏问题如果max_tokens设得特别小模型在生成工具参数的过程中就被截断了返回的是一个半截JSON直接导致解析失败。这个坑我也踩过排查了好久才明白是token不够引发的畸形输出。再说tool_choicerequired。这个参数能让模型必须调用工具但它也有副作用当用户平凡地回一句“你好”模型也会硬着头皮调一个技能。所以除非你的场景明确要求“这一轮一定要调用函数”否则别用required。还有temperature和top_p的关系。很多文档会说这两个不要同时调其实在工具调用场景更严格一点直接把temperature设置在0.2附近top_p别动是性价比最高的做法。4. 技能集成的进阶路径API调用与动态技能库有了一个最小可用的Agent骨架接下来要往生产方向演进。真实项目里的技能必然要涉及外部API调用还有大量技能需要按条件动态启停。这一部分我讲几个实操层面的方案。4.1 外部API技能封装不要把密钥写进技能函数先把最基础的问题说了任何一个技能只要有外部网络调用需求都要单独做一层凭证管理。我见过太多初创项目把API Key直接写死在技能函数的代码里后面换密钥的时候翻遍全项目的每个角落风险非常大。我的做法是为每一个外部API技能配一个独立的配置入口运行时从环境变量读取密钥import os import requests register_skill( namefetch_stock_price, description查询指定股票代码的当前价格。当用户询问股价、行情、涨跌时使用。, parameters{ type: object, properties: { symbol: { type: string, description: 股票代码例如AAPL或600519。 } }, required: [symbol] } ) def fetch_stock_price(symbol: str): api_key os.environ.get(STOCK_API_KEY) if not api_key: return 错误未配置行情API密钥。 url fhttps://api.example.com/quote/{symbol} resp requests.get(url, headers{Authorization: fBearer {api_key}}, timeout5) if resp.status_code ! 200: return f行情查询失败HTTP状态码{resp.status_code} data resp.json() # 返回给模型的文本尽量提炼关键字段而不是甩一堆原生JSON return f{data[symbol]} 当前价格 {data[price]}涨跌幅 {data[change_percent]}%注意看最后那个return我把返回给模型的内容预先做了“提纯”。这是个非常重要的细节大模型和你一样不太喜欢看没经过整理的原始JSON。给它的信息越干净、越直接它做后续判断的准确性就越高。4.2 动态技能加载根据用户意图实时决定给模型哪些技能前面提到的技能树思路落地的时候就要解决一个动态加载问题每次请求都要从技能库里筛选出当前需要暴露给模型的那部分。我用的方案是给每个技能打上分类标签然后维护一个轻量级的意图分类函数。分类函数本身不复杂它不替代大模型做决定只是做一个粗粒度的预筛选SKILL_CATEGORIES { datetime: {get_current_time}, math: {calculator}, market: {fetch_stock_price}, } def narrow_skills(user_input: str) - list: text user_input.lower() active set() if any(k in text for k in [几号, 时间, 今天, 几点, 日期]): active.update(SKILL_CATEGORIES[datetime]) if any(k in text for k in [计算, 多少, 乘以, 除以, 价格, 优惠]): active.update(SKILL_CATEGORIES[math]) if any(k in text for k in [股票, 股价, 行情, 涨跌, 大盘]): active.update(SKILL_CATEGORIES[market]) return [SKILL_REGISTRY[name] for name in active if name in SKILL_REGISTRY]表面上看这个函数就是一组关键词匹配不怎么高大上。但它带来的收益是非常实在的。每次大模型请求的上下文容量是有限的而且过量信息会干扰工具选择。你把它当作一个“预选赛过滤器”把明显不相关的技能先挡在接口外面大模型再去做精细判断时压力会小非常多。当然你也可以用大模型来做分类这一步用一个二阶段调用第一阶段的模型专门做技能预选输出技能子集第二阶段的主模型在子集上执行任务。效果会更好但成本会翻倍。一般用户量不大的项目关键词初筛已经够用。5. 常见问题与排查技巧实录我踩过的那些坑无论技能系统设计得多合理实际跑起来一定会出各种幺蛾子。这一节我不讲理论只把自己在真实项目里遇到过的问题和排查过程拿出来聊。5.1 工具参数幻觉模型编了一个不存在的参数先说一个非常典型的问题模型会自己“加戏”。我在技能定义里只要求三个参数但模型经常在返回的JSON里塞第四个、第五个参数比如给日期技能传一个奇怪的时区参数。这个问题的根源在于模型对技能的理解不是字面上的schema而是结合了训练数据里类似工具的记忆。我试过两种有效干预手段。第一种是在技能描述里明确标注“仅支持以下参数不要额外添加”实测能减少一部分乱加参数的情况。第二种是更硬核的方案在解析工具参数后用一层参数白名单校验凡是schema里没声明的参数一律丢弃或报错。生产环境我推荐两种都做。还有一种情况容易被人忽略工具参数类型错误。比如schema里声明symbol是string模型却返回一个数组。这种情况多半是让模型参考了某些非结构化内容的输出格式。我的经验是模型返回的参数越接近你schema里写的example出错的概率越小。所以给每个参数都配上清晰、简洁的示例值能显著降低格式错误率。5.2 技能选择冲突多个技能描述相似模型选错了另一个高频坑是技能之间的“描述打架”。举个例子你同时有“查询订单物流”和“查询快递公司物流”两个技能功能很接近模型就很容易选错。我处理这类冲突的经验是不要让两个技能的描述存在大面积的公共词汇。每个技能的描述里要明确划出自己和其他类似技能的边界。比如前者强调“查我们自家系统里的订单”后者强调“查第三方快递单号的轨迹”。即使底层调用的API相同只要面向用户的语义边界清晰模型的选型准确率就会高很多。如果技能实在太多、边界又难切分另一个思路是合并把多个细分技能合成一个大技能内部用参数区分动作。这种做法会让单个技能变得复杂但能有效减少模型在多个相似技能之间来回横跳的频率。5.3 上下文污染上一次调用的结果干扰了下一次调用还有一个我印象极深的问题Agent连续多轮对话后每轮的工具调用历史都被保留在messages数组里导致后续轮次里模型可能被历史工具结果“带偏”。打个比方第一轮用户问“温度多少”Agent调了天气技能得到“25度”。第二轮用户问“那湿度呢”模型可能受历史影响继续尝试调用天气技能但参数schema对不上导致整个任务卡壳。我的解决办法是把每轮工具调用的关键结果做一个“压缩摘要”只把上一轮任务的核心结论注入下一轮上下文而不是把原始工具返回内容一股脑全留着。这个思路和RAG技术里的上下文压缩策略有些相似都是为了让模型专注在当前任务的必要信息上。6. 进阶思考让Agent技能系统更健壮的一些设计技巧最后这部分内容写给那些已经跑通基础链路、想进一步把系统做得更稳的读者。这些技巧不一定被文档记载但都是我长期项目里沉淀下来的有效实践。6.1 技能描述里的“不该做什么”同样重要很多技能的description文档写满了“这个技能能做什么”但完全没写“这个技能不该做什么”。模型的判断逻辑里正例和反例同样重要。我在生产项目里开始尝试在描述里加负向表达以后技能误调用的概率又往下走了一个台阶。比如天气技能的描述里写“当用户询问历史气候数据时不要使用本技能应使用climate_data技能”这个效果比在system prompt里反复强调规则好得多。描述优化的优先级顺序我自己的体感是先说清楚触发场景何时用再说清楚不触发场景何时不用最后才补参数说明。6.2 缓存与批处理降低技能调用的整体成本技能系统一旦上线跑量成本会迅速成为焦虑来源。两个方向值得投入第一结果缓存。对于天气、股价、汇率这类低频变化数据在技能层做一个带TTL的缓存层能减少大量重复的外部API调用。第二批处理。有些技能本身支持批量参数比如一次查询多只股票你可以把schema设计成接收数组让模型一次调用完成多个目标而不是反复单点请求。这两个优化都对系统架构侵入性不大但收益往往不需要等到大规模并发才显现。哪怕只是日请求量几百次的小项目也能省出一笔可观的费用。6.3 走向多技能协同Agent开始具备“组合技能”的能力最后我想聊一个更有趣的方向单一技能通常只能解决一个原子问题但真实世界的任务几乎都是多技能组合的结果。比如用户问“这个股票的市盈率和同行比算高吗”Agent需要先查行情、再查行业数据、最后做比较分析至少涉及三个技能。复合技能的设计思路是在技能内部再挂一个子Agent或一个子工作流实现技能层面的复用。我最近一个项目里就把“竞品对比分析”做成了一个复合技能内部依次调用搜索、行情、数据分析和报告生成四个子技能。这个复合技能一旦上线用户侧只需要一句话整个链路自动编排完成。技能系统的演进路径说到底就是从一个“工具箱”到一个“工作流编排器”的升级过程。你在前期多花点时间把技能的组织结构设计好后面扩展新能力的时候就能体会到“加一个技能像加一块积木”而不是“动一根柱子动全身”的差别。
返回列表