首页 / 资讯中心 / 文章详情

从函数调用到技能系统:Agent工具调用的重构实践

从函数调用到技能系统:Agent工具调用的重构实践 ★ FEATURED ARTICLE
上个月我被自己做的Agent气笑了。接了一个供应链助手的需求核心功能很简单查库存、查订单、开补货单、生成周报外加几个供应商维度的统计。我一开始的思路也很“标准”——把每个能力写成一个函数塞到Function Calling里让大模型自己去选。结果30多个函数注册进去之后模型开始在“该查库存明细”时调用“生成报表”在“该看总库存”时把参数拼得乱七八糟甚至编出了一个不存在的仓库编码。后来我停了所有业务开发花了一周时间把这一摊整个重构成了基于技能系统的Agent体系也就是项目名里的 agent-skills。这次重构让我想明白一件事Agent能不能落地很多时候不取决于模型强不强而取决于你把“它能干什么”这件事描述得有多清楚、组织得有多齐整。这篇文章就把我这次重构的全过程、技能系统的设计结构、几个典型技能的实现细节和踩坑链路完整写出来适合正在做Agent应用但被工具调用折磨过的朋友参考。1. 先聊一个麻烦为什么函数堆得越多Agent越“智障”先说那个让我崩溃的现场。当时我维护着大概30个函数包括query_stock、query_order_status、create_order、generate_stock_report、calculate_arrival_time等等命名本身没毛病每个函数也写了一两行描述。模型在绝大多数情况下只需要从中选一个然后填上参数。听起来是不是挺简单实际跑起来完全不是那么回事。1.1 三种高频失败模式交互式的Agent是一个多轮对话环境不是一次性的单轮任务。模型手里握着用户的真实意图外加20多个候选函数最常出现的是这三种情况函数混淆generate_stock_report和query_stock功能上有重叠用户说“给我导一下库存数据”模型有时候选生成报表有时候查明细完全看概率。参数幻觉函数明确要求商品编码格式是SKU-加四位数字模型会自作主张生成一个SPU-2024-001出来直接导致下游系统解析失败。上下文挤占每个函数定义加参数说明大约500到800个token30个函数就是2万多个token。模型每次决策都在很长的上下文里去“找那个最像的”找到的概率自然越来越低。后来我做了个对比测试把函数数量压到10个以内同样的对话样本准确率能高15个百分点以上。问题并不是模型傻了而是这种把所有工具平铺给模型的方案在规模稍微上来之后就必然撑不住。1.2 函数缺的不是一个“马甲”是一套行为规范函数调用其实就是让模型填空选function填arguments。但一个真实的业务能力不应该长成“选一个函数再填一个参数”这么单薄。以“补货”为例它不是一个函数就能完成的需要查库存、算缺货数量、看供应商交期、生成补货单中间还要做数据校验、阈值判断、异常分支处理。这些逻辑放在一个函数里函数会膨胀成几百行的“上帝函数”拆成多个函数模型就要顺序调用多次每次都有可能选错。我当时意识到我需要的不是更好的函数而是一个“技能”层。技能的本质是把“一个完整的工作流单元”打包成一个模型可理解、可调用、可校验的独立对象。它和函数的区别我后来用一张表说明白维度普通函数Agent技能Skill粒度单个操作一个可独立交付的任务流程输入形参经过Schema校验的结构化参数内部结构一段代码可能包含步骤、子技能、多个LLM调用失败处理抛异常结构化错误信息可回传模型用于重新决策对模型的呈现函数签名触发条件、边界说明、使用示例、参数说明可观测性一般只有出入参有执行日志、耗时、token消耗、结果摘要技能可以理解为“给模型提供的一个完整的做事流程”模型不需要关心技能内部怎么做只需要知道“什么情况下用、传什么参数、会得到什么结果”。2. 把“会做的事”变成可注册技能一个最小技能注册中心技能系统落地时我首先设计了一个注册中心SkillRegistry。它解决的核心问题是把散落在代码里的各种能力统一登记变成模型可以在运行时发现和调用的资产。2.1 技能的四要素一个技能在注册中心里必须包含四个部分技能名称和ID稳定、唯一代码里用它路由。技能描述写给模型看的说明书。这部分决定模型能不能在用户提问时想到它。参数Schema描述技能调用需要哪些参数采用JSON Schema规范限制类型、枚举、必填项。执行体实际运行的代码逻辑可能是一个函数也可能是一个小的流程编排。我把描述定义成一种“行为说明书”强调三件事什么时候用触发条件、用户意图特征。什么时候不用明确排除容易混淆的场景。边界与前置条件比如“必须先追问用户仓库”“返回结果最多5条”。反面教材是我之前写的这个查询商品库存。正面教材是这样的当用户询问某款商品还剩多少件、是否缺货、能不能采购时使用。 需要先明确商品编码如 SKU-1001如果用户只给了商品名称 不要调用本技能先通过 search_product 获得编码后再来。 支持按仓库查询仓库枚举值仅限“华东仓”“华南仓”。 返回最多5个可销售SKU的实时库存若无库存则返回缺货标记。很多工程里的问题都不是模型不会选而是描述里根本没给模型用来判断的信息。2.2 最小实现一个装饰器搞定注册在Python里我实现了一个最简版的设计核心思路是把注册过程做成装饰器让业务代码和注册逻辑解耦。class SkillRegistry: def __init__(self): self._skills {} def register(self, name, description, parameters, handler, tagsNone): if name in self._skills: raise ValueError(fduplicated skill: {name}) self._skills[name] { name: name, description: description, parameters: parameters, handler: handler, tags: tags or [] } def get(self, name): return self._skills.get(name) def snapshot(self): return [ { name: s[name], description: s[description], parameters: s[parameters], tags: s[tags] } for s in self._skills.values() ] def snapshot_slim(self): return [ {name: s[name], description: s[description]} for s in self._skills.values() ] registry SkillRegistry() def skill(**meta): def decorator(fn): registry.register( namemeta[name], descriptionmeta[description], parametersmeta[parameters], handlerfn, tagsmeta.get(tags) ) return fn return decorator这个注册器只有纯Python逻辑没有依赖框架。生产环境中我建议增加一个配置源比如启动时从YAML或者数据库里加载技能定义这样新增技能不用改主代码、发新版本这也是“动态装配”的核心优势。2.3 参数Schema的写法和一个容易忽略的坑参数Schema我用JSON Schema的子集。刚开始写得比较粗糙比如{ type: object, properties: { product_id: {type: string}, warehouse: {type: string} } }这个结构有个明显的隐患warehouse没写枚举。模型有时候填“杭州仓”有时候填“hz_wh”有时候填“hzc”同一个仓库三种写法。我后来给参数加上严格约束{ type: object, properties: { product_id: { type: string, pattern: ^SKU-\\d{4}$, description: 商品编码格式为 SKU-四位数字 }, warehouse: { type: string, enum: [华东仓, 华南仓], description: 仓库名称只支持华东仓、华南仓 } }, required: [product_id] }加了pattern和enum之后模型“自由发挥”的空间被锁住了。这里有一个经验必填参数别贪多。真正必须由模型从对话里抽出来的一定要填能从上下文默认推断的就写进执行体让执行体自己补默认值。必填越多模型抽错的概率越大。3. 技能调用链路模型怎么找到技能、怎么把话说对技能系统的运行链路比原来直接调Function Calling多了一层“意图选择参数抽取校验回填”。整体上我把它简化成四个阶段把可用技能的快照名称描述和用户消息一起发给模型让模型返回“技能名参数”。框架解析模型输出校验参数Schema。执行技能拿到结构化结果。把技能执行结果回填给模型让模型生成最终回复。3.1 一次调用、两次模型交互注意这个链路里模型至少参与两次第一次是决定调用哪个技能第二次是生成最终回复。设计上要接受这个代价因为技能执行结果通常需要被模型“翻译”成用户能看懂的答案。第一次交互的System Prompt大概长这样你是一个供应链助手。根据用户问题在以下技能中选择并给出参数。 技能列表 {skill_snapshot} 只输出结构化调用不要自由发挥。解析模型输出我会写一个严格的parse_tool_call而不是让框架自动把字符串当字典用。因为模型返回的内容可能带Markdown代码块也可能在JSON前后加了多余说明不处理一定会炸。def parse_tool_call(model_output: str): text model_output.strip() text re.sub(r^json|^|$, , text).strip() try: data json.loads(text) except json.JSONDecodeError: # 尝试从文本中截取第一个 { 到最后一个 } start, end text.find({), text.rfind(}) if start -1 or end -1: raise ValueError(no json object found in model output) data json.loads(text[start:end1]) assert name in data and arguments in data return data[name], data[arguments]3.2 校验失败的自我纠正模型第一次给出的参数不一定合法。以前我直接报错让用户重新说一遍体验很糟。后来我在链路里加了一个“纠正循环”校验失败后把具体的错误信息作为工具返回内容回传给模型让它重新输出。def run_agent(user_query: str, max_turns: int 3): messages [build_system_msg(registry.snapshot_slim()), build_user_msg(user_query)] for _ in range(max_turns): decision llm_completion(messages) if not is_tool_call(decision): return llm_completion(messages, add_more_contextFalse) skill_name, args parse_tool_call(decision) skill_meta registry.get(skill_name) if not skill_meta: messages.append(tool_error_result(skill_name, 该技能不存在请重新选择)) continue errors validate_schema(args, skill_meta[parameters]) if errors: messages.append(tool_error_result(skill_name, f参数校验失败: {errors})) continue try: result skill_meta[handler](**normalize_args(args)) messages.append(tool_success_result(skill_name, result)) except SkillExecError as e: messages.append(tool_error_result(skill_name, str(e))) continue return llm_completion(messages) return 抱歉我在多次尝试后仍然无法处理这个请求。简单理解就是模型输出错误不可怕把错误信息原样喂回去让它自己改。实测下来商品编码这种格式错误第二轮的纠正成功率在九成以上。3.3 技能数量超过50的时候别再把全部快照塞进去了我早期一把梭把注册表里所有技能都给模型。技能少还行技能超过50个之后光技能描述就能吃掉大几千token模型选择准确率也肉眼可见地下降。后来我做了两级路由给技能打上领域标签如“库存”“订单”“供应商”“报表”先用一个轻量级模型判断用户query属于哪个域。只把该域的技能快照发给第二个模型做精确选择。如果不想维护标签也可以用embedding召回算出用户query和每个技能描述向量的余弦相似度取TopK。我实践下来先按域过滤再在域内做精确选择效果稳定很多而且token开销能省下一大半。4. 四类技能的实现细节与常见翻车点技能不是同一种形态。不同能力的技能内部的执行逻辑、需要注意的坑完全不同。我按业务场景把技能分成了四类分别说一下实现细节和踩过的坑。4.1 查询类技能关注数据权限与返回截断查询类技能最好写输入条件返回结构化数据。但它有两个隐藏的坑。第一个坑是权限。同一个查询技能供应链管理员能看到采购价普通销售员工只能看到库存量不能看成本。技能执行体在接收参数时必须同时接收“当前用户身份”不能只依赖模型传上来的参数。也就是说技能执行体内部要注入用户上下文在查询SQL或API请求里加上强制过滤条件例如WHERE tenant_id ?。这个是不能图省事的。第二个坑是返回数据的token量。商品维度的实时库存明细动辄几百行直接回填给模型一次对话的token消耗会暴涨回复也会变慢。我在技能内部做了一层摘要返回给模型的只有汇总行数、合计库存、缺货SKU数明细数据另外存到缓存里用户明确说“我要看明细”时再由另一个技能去读取。实测下来同样的查询需求token消耗能少一半以上而且模型回复更快。4.2 操作类技能幂等、二次确认、操作日志操作类技能包括创建订单、自动补货、发送消息、修改配置这类会改变系统状态的技能。这类技能翻车的代价是实打实的我的经验是必须做三件事。第一幂等控制。大模型的重复调用风险不只是模型自身多想了一步还有用户多轮发送导致的重复请求。我给每次操作生成一个幂等键Idempotency Key例如“补货单 SKU 仓库 日期”的哈希值后端在10分钟内接受到相同幂等键的操作直接返回上一次的结果不重复执行。第二二次确认。不是所有操作都需要确认但涉及资金、库存、发送外部消息的必须加上确认识别。我的实现方式是把操作类技能拆成两个阶段生成“待确认操作单”包含操作类型、对象、数量、影响范围先把操作单展示给用户用户确认之后再执行真实写入。模型可以多次生成操作单但只有人类确认的那一步能真正触发写操作。第三操作日志。每个操作类技能执行时除了记录技能名、出入参外还要记录操作者身份、幂等键、执行结果、执行耗时。这样后续如果出问题可以快速复盘。4.3 生成类技能模板先定框架模型再填内容生成类技能最容易踩的坑是“生成结果长得不像结构化产物”。比如周报生成模型生成的正文很流畅但统计口径和表格结构不稳定。我后来改成“先定框架、再填内容”的两段式第一个内部调用负责生成结构化大纲包括“本周新增需求数、完成数、待处理问题清单”。第二个内部调用基于大纲逐段生成自然语言描述。这样做有两个好处统计字段可以在大纲阶段就对齐系统数据避免模型胡编数字生成的结果天然结构化下游直接解析不需要做太多清洗。我曾经把两步合成一步让模型“直接给我一份完整周报”结果表头字段经常变后来彻底放弃了让LLM自行决定结构的写法。4.4 组合类技能上下文隔离与结果摘要组合类技能是内部包含多个子调用的技能它的核心问题是信息怎么在子步骤之间传递。我的经验是步骤之间用结构化数据传递不要传一堆模型自然语言输出。举例来说“补货建议”这个组合技能内部要执行三件事调用“查询当前库存”技能拿到每个SKU的实时库存和销量趋势。调用“计算安全库存”的内部函数算出每个SKU的建议补货量。汇总成建议清单返回给模型。在这个流程里步骤1输出的“464件”“缺货”这类结构化字段直接传入步骤2的代码不经过模型转述。因为一旦中间的模型多“润色”了一层数值就可能被改掉。只有最终结果才需要模型用自然语言包装。这个原则延伸出去就是技能内部能用代码完成的计算绝不交给模型。模型只负责两件事理解用户意图、输出自然语言结果其他都交给确定性的程序逻辑。5. 从单技能到编排让Agent学会按顺序做事技能注册完成之后真正让Agent看起来“智能”的是当任务需要多步完成时技能如何被编排成一条完整的行动链路。5.1 三种编排方式按稳定性和成本排序我先后尝试过三种方式第一种让主模型在一个循环里自己决定下一步调哪个技能这是最灵活的但也是最不可控的。模型经常绕圈子重复调用同一个技能、在失败后不改变策略继续硬试。这种方式适合探索不适合生产。第二种先规划后执行Plan-and-Execute。先让模型产出一份可执行计划罗列技能调用顺序和参数然后用一个确定性的执行器按顺序执行。这种方式稳定很多而且计划本身是可审计的。我最终就是采用的这种。第三种固定流程模板。针对完全确定性的场景比如“日报生成查询指标数据组装模板”直接用代码写好调用链不经过模型的计划决策环节。这种最稳定、最便宜但只适用于高频重复的固定业务。5.2 一个最小可用的PlanExecutor这里给一个极简实现核心是让规划器只输出步骤列表执行器负责每个步骤的调度和结果存储。def plan_and_execute(user_query: str, max_replans: int 2): plan llm_generate_plan(user_query, registry.snapshot_slim()) # plan 示例: # [{skill: query_stock_level, arguments: {warehouse: 华东仓}}, # {skill: calc_replenish_qty, arguments: {}}, # {skill: create_replenish_doc, arguments: {}}] executed_steps [] for step in plan: skill_meta registry.get(step[skill]) if not skill_meta: return f计划里第{len(executed_steps)1}步{step[skill]}不存在已中止 try: result skill_meta[handler](**step[arguments]) except SkillExecError as e: if max_replans 0: plan llm_replan(user_query, executed_steps, str(e), registry.snapshot_slim()) return plan_and_execute_with_plan(user_query, plan, max_replans - 1) return f技能执行失败: {e} step[result] summarize_result(result) # 每次只存摘要防止上下文膨胀 executed_steps.append(step) return llm_generate_final_answer(user_query, executed_steps)这个执行器最关键的一环是summarize_result。每执行一步都把这一步的结果压成摘要再交给下一步或最终回答避免中间步骤的海量数据一次性灌进上下文。上下文越长模型越容易忽略关键信息Token成本也会失控。5.3 编排失败时的兜底逻辑Plan-and-Execute最大的风险是“计划很好现实跟不上”。规划器说第一步查库存、第二步下补货单结果查库存时发现仓库编码失效了。我的兜底逻辑是执行失败时把“执行到哪一步、失败原因是什么”回传给规划器让它重新规划而不是让整个任务直接中断。在极端情况下最多允许重新规划两次再重试就主动止损让用户参与决策。这也引出一个经验规划器的输出一定要带上步骤原因。比如“我先查库存是因为需要先知道当前是否有货”这句话能让重规划时更清楚地知道前一步的目标是什么不会因为状态变化而迷失方向。6. 落地前检查清单与我这几个月的经验技能系统落地期间我把踩过的坑沉淀成了一份检查清单。每次新增技能时对照一遍能挡掉大部分低级问题。6.1 技能上线检查清单描述里是否写了“触发条件”“不触发条件”“使用边界”参数Schema是否有枚举、正则、格式约束必填参数是否已经精简到最少查询类技能是否注入了用户身份做数据权限过滤操作类技能是否有幂等键和二次确认机制返回给模型的结果是否经过摘要或截断技能执行过程中是否有结构化错误返回能否被纠正循环读取是否记录了技能调用日志技能名、耗时、token、成功失败、操作者这八条看起来都是小事情但每一条背后都对应我在生产环境里踩过的一次事故。6.2 一项关于技能数量与调用质量的实测对比我对技能数量做了一次小规模的对照测试在同一份包含80个测试问题的样本上结果如下注册技能数参数正确率平均单次决策耗时技能定义token开销10个94%0.4s约6k30个82%0.7s约20k80个全部平铺61%1.3s约55k80个按域路由Top10候选89%0.5s约7k结论很直接技能不是越多越好怎么组织技能比堆数量重要得多。按域分组、技能检索、候选截断这些手段在技能数量上来后就是生死线。6.3 技能命名与可观测性的一些细节最后提两个容易被忽视的点。技能的命名要做到“一眼知道边界”比如query_stock_level和query_stock_trend就有明确的边界感但如果叫get_data这种通用名后期排查的时候根本看不出是谁在调用。日志方面我给每个技能加了一个统一的装饰器自动输出技能名、入参、耗时、token消耗、执行结果。这个习惯帮我省了非常多的时间。有一次线上用户反馈“Agent答非所问”我翻日志发现是生成类技能在一次请求里输出了超过3000个token对话上下文被强行占满导致后续问答质量雪崩。没有日志这类问题几乎无法定位。个人体会是技能系统的设计本质上是在“给模型的自由度”和“工程的可控性”之间找平衡。模型负责理解、规划和表达一切涉及状态变更、数据准确性、权限控制的环节都要用确定性的代码锁死。到现在我的Agent跑着的技能不到70个新增技能时团队都会用那份清单自查一遍整体稳定性维持在一个可接受的水平。如果你也正在被各种工具调用的随机性折磨不妨先停下来把“把能力重构成技能”这一步认认真真做完。
阅读完成 · 觉得有帮助?
咨询建站