☰
AI Agent技能体系设计:从Function Call到可复用技能编排实战
2026/10/7 4:20:42 网站建设 项目流程

我在做AI Agent落地的时候,最头疼的问题往往不是模型效果不够好,而是:同一个Agent,今天要查数据库,明天要调内部API,后天又要处理文档。每接一个新场景,就给Agent加一堆if else逻辑和prompt片段,最后代码乱七八糟,Agent的意图判断也越来越飘。后来我意识到,问题的核心不在模型,而在技能(skills)——Agent到底能做什么、怎么做、什么时候做,这些都应该被系统化管理。这篇文章想聊聊我在构建“agent-skills”体系时踩过的坑、沉淀下来的方法,包括技能如何定义、如何注册、如何编排,以及调试中的血泪经验。内容偏工程实践,适合正在做Agent应用开发、或者想把Agent能力沉淀成可复用资产的团队,也适合想深入理解Agent工作原理的算法工程师。

1. 为什么需要技能体系而不是一堆Function Call

1.1 从Prompt拼接走向能力模块化

早期的Agent实现很简单:你给模型一个system prompt,里面塞上“你可以调用以下工具:get_weather(city)、get_stock_price(code)……”,然后模型自己决定调哪个。Demo阶段完全没问题,但一到生产环境就暴露了。任务一多,prompt里工具列表越来越长,token占用飙升,模型在长列表里选错工具的几率也在上升;更关键的是,每个工具背后的逻辑、参数校验、权限控制、日志埋点都散落在代码里,改一个工具要动好几处地方,根本没法维护。

我后来把思路从“给模型一堆工具”转成“给Agent一套技能”。一个技能不只是函数签名,它包含:技能名称、用途描述、参数Schema、执行逻辑、触发条件、依赖关系,以及可选的验证和回退策略。Agent收到用户请求后,先理解意图,再从技能库里检索最匹配的技能来执行。这套思路的核心价值是把“模型能干什么”从模型配置里解耦出来,变成一份独立于模型、可以被测试和复用的资产。

沿用这个思路,我建了一个技能注册中心,所有技能都集中登记,Agent启动时自动加载。每新增一个能力,不需要改Agent的主循环代码,只需要在技能库里加一个模块,写好描述和参数说明。这让整个系统的扩展成本大幅降低。实际项目里,我们曾经在两周内接入了十几个新技能,Agent主代码一行没动。

1.2 Agent技能和普通函数调用的本质区别

很多人觉得,不就是一个函数加一个装饰器吗?和RPC框架注册接口有什么区别?区别很大。普通函数调用是确定性的代码路径,但Agent技能是不确定性语义和确定性逻辑的混合体。同一个技能,模型可能在完全不同的语境下选中它,你必须保证它在这些语境下都表现得合理。

举个例子,一个“查询订单状态”的技能,用户说“我东西到哪了”要能命中;说“帮我看看快递”也要命中。这里靠的就是技能的description写得是否精准、有没有覆盖用户的各种表达方式。模型不是靠读你的代码来理解技能的,它靠的是那段描述文本。所以技能描述本身就是产品的一部分,像写搜索索引一样去写描述,核心命中的关键词要前置,别让模型猜。

这引出一个关键设计原则:技能描述是给LLM看的,不是给人看的。我们团队现在写技能描述有一套固定格式,分为三块:

  • This skill is used when:什么时候用这个技能;
  • It can handle:它能处理哪些类型的请求,举两三个典型例子;
  • It should NOT be used when:什么时候不要用这个技能。

别小看第三块,它专门负责“消歧”。没有反例描述,模型很容易在一个含义模糊的请求上同时选中三四个技能。加了这个反例说明,命中准确率能提升一大截,后面我还会细说。

2. 技能定义与设计思路

2.1 技能的粒度:拆到多细才算“一个技能”

这是整个体系里最容易因为拍脑袋而出问题的地方。粒度太粗,一个技能内部塞了几种完全不同的操作,模型选对了技能但你不知道它具体想干哪个子操作,返回结果还得二次解析,又回到if else的老路;粒度太细,技能库膨胀到几百上千个,模型检索负担变大,相互之间的边界也变得模糊,更容易选错。

我现在的经验是按“用户意图原子度”来拆分,一个技能只解决一个意图,且这个意图的结果可以被清晰验证。比如,“获取天气”是一个技能,“获取未来五天天气预报”又是一个技能,第一个是实时数据,第二个是预测数据,接口不同、返回结构不同、业务语义也不一样,混在一起会让后续编排很痛苦。

拆分时我还会追问自己一个问题:这个技能会不会被其他技能以不同参数重复调用?如果会,就说明它更适合作为“基础原子技能”下沉,而不是一个独立的业务技能。比如“发送HTTP请求”算原子技能,“调用物流查询API”是业务技能,“根据订单号查物流再推算送达时间”是组合技能。三者分开,才能让上层编排有充分的灵活性。

一个实用的判断方法:技能的description如果必须出现“或者”两个字,大概率粒度太粗了,拆开吧。

2.2 描述与参数Schema:决定Agent会不会用

技能描述是给模型看的说明书,参数Schema是给模型看的“填空规则”,两者共同决定了技能被调用的成功率。先说参数Schema,我强烈建议用JSON Schema标准来写,而不是手写一个“参数说明”字符串。JSON Schema可以声明字段类型、是否必填、枚举值、字段含义,很多Agent框架原生支持把JSON Schema转成模型可读的格式,校验层也能直接复用。

这里有个细节:每个字段的description也要认真写。模型在填参数的时候,会参考字段描述来理解用户的话。比如一个“城市名”字段,只写“city”模型可能把“北京”填成“beijing”,但你在description里注明“使用城市中文名,如‘北京’,不要使用拼音或英文缩写”,准确率立刻就上来了。别嫌啰嗦,模型不读你的注释,它只读字段描述。

描述还有一个优化点:给每个参数提供示例值。JSON Schema里可以用examples字段。示例值会在模型不确定时起到极强的引导作用。我在一个查询日期范围的参数上加了示例“2025-01-01”,模型的日期格式错误率降低了将近一半。

还有一点是关于枚举的:能用枚举就尽量用枚举,不要开放自由输入。比如“时间单位”这个参数,你只给description说“请输入时间单位”,模型可能填“天”“天?(工作日)”“days”各种变体。但如果你声明"enum": ["day", "week", "month"],模型会严格按这几个选项来,后续代码也就不用写一堆字符串解析了。

3. 技能实现与工程化落地

3.1 从零手写一个技能模块

下面是我在实际项目中常用的一个技能模块写法。我会把技能的核心逻辑、参数定义、附带的验证逻辑都封装在一个类里,保持结构统一,便于后续注册和测试。

# skills/retrieve_kb.py from typing import Any from dataclasses import dataclass, field @dataclass class RetrieveKBSkill: """在内部知识库中检索相关文档块。""" name: str = "retrieve_kb_docs" description: str = ( "This skill is used when the user asks about internal policies, " "product manuals, or technical docs in the company knowledge base. " "It can handle queries like '报销流程是什么', 'API鉴权怎么做'. " "It should NOT be used for open-ended Q&A beyond the knowledge base." ) parameters: dict = field(default_factory=lambda: { "type": "object", "properties": { "query": { "type": "string", "description": "检索关键词,使用用户原始提问的核心语句。", "examples": ["报销流程"] }, "top_k": { "type": "integer", "description": "返回的文档块数量。", "default": 5, "examples": [5] } }, "required": ["query"] }) def __call__(self, query: str, top_k: int = 5) -> dict[str, Any]: # 实际逻辑:调用向量检索服务或内部搜索API # 返回结构统一为 {"code": 0, "data": [...]} results = search_service.search(query, top_k=top_k) return {"code": 0, "data": results}

这段代码的核心不在检索本身,而在类上的三个字段:name、description、parameters。它们是技能对外暴露的“语义接口”,决定了模型是否调用、如何调用。我在所有技能里保持这个结构,就是为了让注册和测试工具能统一处理。

3.2 技能注册与动态加载机制

有了技能类,接下来要解决的问题是:Agent怎么找到它?我建了一个轻量的技能注册表,项目启动时扫描特定目录,把技能全部加载进字典。这个做法的好处是,新同学加技能只需要新建一个文件,不需要改任何注册逻辑。

# skill_registry.py import importlib import pkgutil import skills SKILL_REGISTRY: dict[str, object] = {} def register(cls): instance = cls() SKILL_REGISTRY[instance.name] = instance return cls def auto_load_skills(): for mod_info in pkgutil.iter_modules(skills.__path__): if mod_info.name.startswith("_"): continue module = importlib.import_module(f"skills.{mod_info.name}") # 约定:每个技能模块里必须有 `Skill` 类 if hasattr(module, "Skill"): register(module.Skill)

这里我用了约定优于配置:每个技能模块里统一导出一个Skill类,注册器扫到就自动加载。如果哪天需要把技能库拆成多个目录,也可以用配置文件显式列出技能模块的路径。两种方式我都用过,最终还是选了协议式注册,因为它最直观,而且IDE可以跳转,比读配置省事。

技能加载完之后,还需要一个“技能选择器”来给Agent用。选择器负责把用户问题转成检索请求,从注册表里挑出最相关的几个技能供模型排序。最简单的实现是,把所有技能的description拼成一个索引,用向量检索召回TopK,再交给LLM做最终选择。这一步是性能瓶颈,也是准确率关键,后面在调试部分我会展开讲。

3.3 技能执行器与统一返回协议

技能在执行时,最好统一走一个执行器。执行器处理参数校验、权限校验、超时控制、日志埋点等横切逻辑,技能本身只做自己的业务操作。不要在每个技能内部自己去处理这些事,否则一百个技能就有一百种超时策略和错误格式,调试起来会非常让人抓狂。

返回协议我统一采用{"code": 0, "data": ...}的结构。执行成功code为0;业务失败用非零错误码;系统性异常(比如API超时)也返回非零,并在data里附带必要信息。这个模式的一个意外好处是,模型能“读懂”返回结构。当技能返回一个非零码时,模型会根据data里的信息决定是换个参数重试,还是告知用户无法完成。如果你让异常直接抛到Agent外层,模型就接不到了,只能得到一句“执行失败”,毫无补救能力。

4. 技能编排与组合

4.1 多技能协作的三种典型模式

单技能调用只解决“用户问A就给A”的场景。真实业务里,大部分请求需要多个技能按顺序协作。根据我的观察,协作模式大致有三种:管线式、选择式、复合式。

管线式最直观:上一个技能的输出是下一个技能的输入。比如“会议纪要总结”,先调用“音频转写”,再调用“文本摘要”,再调用“待办提取”。每个环节独立成技能,单独测试,单独替换。编排层只需要定义顺序即可。

选择式则是在一组技能中根据条件动态选一个执行。比如用户要开发票,系统先判断是“个人发票”还是“企业发票”,然后走不同的开票逻辑。这个判断本身也可以做成一个技能,输入是用户请求,输出是分支选择的决定。

复合式最复杂,但也是价值最高的:Agent根据用户目标动态规划一串技能调用,中途可能根据中间结果调整计划。这本质上就是当前热门的Agent规划能力。我通常不会让模型完全自由发挥,而是给定一个“技能调用模板”,模板里规定了合法技能组合序列,模型只能在模板内做选择。这样既有灵活性,又能避免模型在几十个技能里“画蛇添足”。

4.2 技能之间出现冲突和重复时怎么办

技能越多,冲突越难避免。我遇到过最典型的冲突是:两个技能都对同一个用户问题高置信匹配。比如,“帮我改一下会议记录里的错别字”,“文本编辑技能”和“文档重写技能”都可能命中。解决办法不只靠描述优化,还需要在编排层引入优先级机制。

我在每个技能上增加了一个priority字段,默认值是50。规则很简单:当多个技能都命中且模型犹豫不决时,优先执行优先级高的技能。但这不能硬来,模型有自己的判断,强制优先级可能在上下文不匹配时产生错误。更优雅的做法是,把优先级作为技能描述里权重信息,让模型在做最终选择时能看到类似“如果要处理的是增量修改,优先选择editing_skill”的提示。

还有一个更朴素但有效的方法:用训练好的小型NLU做技能预筛,再让LLM在预筛结果中做微调。比如用户说“查天气”,普通文本分类器就能先过滤掉80%无关技能,留给LLM的候选只剩三四个。这个预筛器和技能描述可以一起维护,当技能库规模大了以后,这一步几乎变成必需品。

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

5.1 技能不触发:描述问题排查清单

Agent项目上线初期,最常被测试同学提的bug是:“这个技能根本没被调用,模型直接自己回答了。”我排查这类问题的顺序一直是固定的:

  • 第一步,看技能的description里有没有覆盖用户的典型表达。我会收集一版线上用户实际提问,人工看一下有没有哪类问法被漏掉了。经常发现用户说的是“我上次买的东西呢”,而技能描述里写的是“查询订单状态”,语义上对得上,但措辞差异大,模型就是不敢选。解决办法是在描述里加“It can handle queries like '我上次买的东西呢'”。
  • 第二步,看候选召回TopK是否足够。如果向量检索只召回Top3,而相关技能排在第5,模型根本看不到它。这是最容易被忽略的坑。
  • 第三步,检查是否有“同义技能”抢占命中。两个技能太像,模型选了另一个,然后执行结果完全不对。这种情况要合并技能或者细化边界。

我把这几点整理成了一张排查表,新项目直接照着查,效率高很多:

现象可能原因排查动作
技能未被调用描述未覆盖用户措辞补充典型提问表述到description
技能未被调用召回TopK截断调大候选数或优化向量索引
技能被调用但参数错参数描述与用户表达不对齐补充字段描述和示例值
技能被错误调用多技能边界不清晰加“should NOT be used”说明
技能抛异常后Agent乱编异常未规范化统一返回协议,异常信息转data

5.2 技能调试中“看不见的失败”:参数幻觉与上下文污染

最让人头疼的问题不是技能报错,而是模型生成了不存在的参数值。比如技能只要求传“订单编号”,模型在用户没给的情况下自己编了一个“20250801001”。这类幻觉很难通过日志关注到,因为技能执行后可能返回“查无此单”,但模型会进一步编一个“可能是延迟,请稍后再试”来圆场。

针对参数幻觉,我做了两个改进。第一,参数Schema里把不存在的字段全部禁止掉,理想状态是不允许模型自由发挥“extra fields”。第二,在技能内部增加前置守门员:如果关键参数来自用户输入而用户实际上没提供,技能直接返回{"code": 422, "data": {"reason": "missing_order_id"}},并且不允许模型自行补全。这个守门员的逻辑不复杂,但要和Agent主循环约定好——技能返回422时,模型应该反问问用户索取信息,而不是自己编。

上下文污染是另一个高发问题。尤其是在管线式编排里,上一个技能返回的长文本被原样塞给下一个技能,下一个技能的Prompt里既有用户原始问题又有上一轮的输出,模型很容易分不清哪些是“用户说的”哪些是“系统返回的”。我在技能执行器里加了一个简单的技巧:给每一轮技能输入输出打上明确的角色标签,比如[技能输入]、[技能输出],中间用分隔符隔开,并在系统提示里写明“只有带[用户]标签的内容才是用户真实意图”。这个方法治标不治本,但确实让模型的上下文理解准确了不少。

5.3 技能版本管理与回归测试

当技能库超过几十个之后,你会发现自己最大的成本不再是写技能,而是改技能引起的回归问题。你可能只是改了“查询订单”的返回字段格式,结果另一个依赖它的“客服工单生成”技能全挂了。所以我强烈建议,在技能注册表里给每个技能标上版本号,并且依赖关系不能只靠“约定”,要在发布前做一个离线回归测试。

回归测试我一般用一套标准的测试用例集,每个用例包含:用户请求、期望调用的技能、期望的参数值、期望的返回语义。跑的时候不真正执行外部API,而是Mock掉技能内部的外部依赖,只看技能选择和参数生成是否正确。这套用例集可以每天在CI里跑,模型或者技能描述改动后,看看影响面有多大。数据不会说谎,往往你以为的小改动,测试会帮你发现七八个意外破坏点。

一些收尾的实操心得

这个体系做下来,我最大的体会是:技能设计不能追求一次性完美,它是一个不断迭代的语言工程。你写的description、参数Schema、反例说明,本质上是你在帮模型补全它“理解世界”所需的上下文。第一次写不好很正常,关键是线上数据要回流,定期看模型选错、参数生成错的案例,一条条改进技能定义。我们团队现在每个月都会开一次“技能体检会”,翻日志找“模型该调但没调”和“不该调却调了”的case,然后统一更新技能库。

最后分享一个调试的小技巧:给技能库加一个“旁路评估模式”。在这个模式下,别人的调用不会真正执行外部API,而是把模型选择的技能和参数全部打出来,对比一个专家系统给出的预期结果。只要开着这个模式跑一周,你就能积累大量高质量的训练与评测数据。我靠这招把Agent首次调用正确率从70%出头稳定拉到了90%上下,可以说这个数据就是整个Agent技能体系最大的资产。

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

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

立即咨询