1. 为什么把工具改成技能:从函数堆积到能力抽象的转折点
1.1 第一版:每个能力一个函数的日子
做智能体开发这件事,绕不开一个环节——给模型接工具。我最初在内部一个代号为"模拟项目X"的助手类智能体里,为了让它能处理用户的各种请求,写了一大堆互相独立的工具函数:查天气、查库存、算运费、生成报表、发消息、改订单状态……每个函数配一段意图描述,模型根据用户问题判断要不要调用哪个函数。
在工具数量少于二十个的时候,这套方案用得非常舒服。加一个能力,无非就是写个函数、写段描述、注册进列表,三步搞定。模型的选择也基本准确,因为不同函数之间的语义差异够大,不容易搞混。比如"查询天气"和"生成报表",正常人也不会把它们弄混。
但那段时间我忽略了一个隐患:函数是无状态的、扁平的、彼此独立的。它只定义了"能被调用"这件事,却完全没有描述"什么场景下该用"、"用之前需要什么前置条件"、"用失败了之后怎么办"。这些信息我全都塞在了注册时那段描述文本里,任由模型自由发挥。
1.2 当函数数量突破某个临界点后暴露的问题
随着功能越加越多,函数数量慢慢爬到四五十个,问题开始成片出现。
第一个爆发的是描述冲突。两个能力边界一旦模糊,模型就会随机抽风。举一个真实例子:项目里同时有"查询订单状态"和"查询物流轨迹"两个函数。用户问"我的货到哪了"时,模型一会儿选订单查询,一会儿选物流查询。更麻烦的是,很多情况下正确做法是把两个函数串联起来——先查订单状态,再根据运单号查物流轨迹——但当时的函数列表根本没有表达这种组合关系的能力。
第二个问题是复用性崩溃。有些能力的内核非常相似,只是数据源不同。比如"查询本地门店库存"和"查询仓库库存",计算逻辑几乎一样,但因为一开始就各写各的,代码重复率越来越高。后来库存口径要加上"锁定库存"这个维度,我不得不手动同步改了六七处,漏掉一处就出线上事故。那段时间我最大的感受是:这玩意儿不是在写功能,是在给未来的自己攒技术债。
第三个问题最隐蔽,也最致命:函数无法表达"能力边界"。模型只能通过一段几百字的描述去猜测函数能干什么,但没有任何机制告诉它"这件事不该你做,你应该拒绝或者转交"。结果就是模型经常拿着一个函数硬套,用户问"附近哪家店有货",它直接调用门店库存查询但完全不传位置参数,返回一堆无意义数据,然后煞有其事地编一个答案。
正是在这个节点上,我下定决心做一次彻底重构。方向不是继续往函数列表里加东西,而是引入一层更高的抽象——技能(Skill)。把"函数"升级为"技能",意味着每个能力单元都要带上完整的元信息:它能解决什么问题、在什么条件下可用、需要什么前置数据、依赖哪些其他技能、失败后怎么降级。这听起来像是把简单问题搞复杂,但实际做完之后,智能体的整体稳定性提升了一个量级。
2. 技能的数据结构设计:一份能被LLM理解又能被代码执行的契约
2.1 元信息、触发条件与执行体三部分
做技能体系的第一步,是设计数据模型。我参考了团队里之前做过插件系统的经验,把每个技能拆成了三部分:元信息(Meta)、触发条件(Trigger)和执行体(Executor)。
元信息负责让模型和检索系统理解"这个技能是什么",包括技能名称、版本号、一句话简介、详细说明、作者、更新时间。触发条件负责让路由系统判断"什么时候该选这个技能",可以是关键词列表,也可以是语义向量化的描述索引。执行体则是真正干活的代码入口,它接收符合参数契约的数据,执行具体逻辑,返回结构化结果。
我用 JSON 作为技能的载体格式,因为它在各种语言和服务端框架里都有原生支持,而且能直接喂给大模型做函数调用。下面是当时第一个正式版本的示例:
{ "name": "query_store_inventory", "version": "1.2.0", "description": "查询指定门店或仓库的实时可用库存数量。适合用户询问商品是否有货、库存多少、何时补货等场景。", "trigger_keywords": ["库存", "有货", "现货", "还剩", "什么时候补货", "库存量"], "negative_keywords": ["物流", "快递", "配送时间", "运费"], "parameters": { "type": "object", "properties": { "sku": { "type": "string", "description": "商品编码,如 SKU-10023" }, "location_type": { "type": "string", "enum": ["store", "warehouse"], "description": "库存位置类型,门店或仓库" }, "location_id": { "type": "string", "description": "具体的门店编号或仓库编号,不传则查询所有可用位置" } }, "required": ["sku"] }, "executor": "inventory_service.check", "depends_on": ["resolve_location_id"], "timeout_seconds": 5, "fallback": "inventory_service.search_nearby" }这份结构里,最关键的是description和negative_keywords这两个字段。description不是给程序员看的,是给模型看的,所以不能写"本技能用于系统内部库存数据查询"这种话,而是要写用户视角的"什么时候会用到它"。negative_keywords是我后来加上的,用来显式告诉路由系统"哪些词看着相关但绝对不属于这个技能",这招在降低误触发上非常有效。
2.2 技能描述怎么写才能被模型准确选中
写技能描述这件事,看着简单,实际是个手艺活。我总结了一套还算实用的写法,核心原则是:用用户会说的人话,而不是工程师的接口文档语言。
举例说明。早期版本里,某个技能的描述是"根据订单编号获取订单主表信息,包括商品明细、金额、收件人信息",后来我改成"用户想查自己买的东西到哪一步了、想知道订单里有什么商品、想确认收货地址和联系人时使用"。前后效果差别很明显,改完之后意图路由的准确率明显上升。
另一个做法是"正面场景 + 易混淆反面场景"双写。也就是描述里不但写"这个技能处理哪些问题",还要主动说明"和它相似的 X 问题不该用这个技能"。比如库存技能里我会写:"本技能只回答库存数量和补货时间,不回答物流配送问题;配送时效请交给物流查询技能。"这种负面约束能让模型在多个相似技能之间做选择时更有把握。
参数描述也同样重要。我踩过一个大坑:某次给参数写描述时只写了"商品 SKU",结果模型经常不传,因为它不知道去哪里拿这个参数。后来补上"SKU 可以从商品名称映射获得,如果用户只给了商品名,请先调用查询商品编码技能",问题立刻解决。参数描述本质上是在教模型"怎么凑齐调用条件",而不只是声明字段类型。
2.3 参数Schema的设计教训
参数Schema这块我吃过不少亏,最核心的一点是:不要为了灵活性放弃约束性。
第一版设计里,几乎所有参数都是可选的,想着让模型自由发挥。结果就是模型经常漏传关键参数,程序收到半截数据,做不了任何有意义的事。后来我改成"必须参数明确限定、可选参数给出默认值语义",情况才好转。比如上面示例里sku是必填,location_type虽然是可选的,但如果不传就默认查所有位置,代码逻辑里要明确处理这个默认行为。
枚举值能限定就限定。location_type设成["store", "warehouse"]之后,模型不再传一些乱七八糟的自造词,比如"门店端"、"大仓",这类非标准值以前经常让解析器崩溃。
另外,我建议参数描述里写上单位或取值范围。比如"timeout_seconds"要写"单位:秒,0-30之间","quantity"要写"正整数,默认1"。模型虽然不会真的算数,但描述写得清晰,它照着给的参数质量就会高很多。这个规律我用在好几个项目里,几乎每次都有效。
3. 技能注册中心的实现:加载、校验与热更新
3.1 目录结构和加载器
数据结构设计好之后,下一步是实现一个能把这些 JSON 技能定义加载进运行时的注册中心。当时定的目录结构很简单:
skills/ query_store_inventory/ skill.json executor.py resolve_location_id/ skill.json executor.py generate_report/ skill.json executor.py每个技能一个文件夹,skill.json是定义文件,executor.py是执行体。加载器启动时扫描整个目录,把每个skill.json解析出来,根据executor字段动态导入对应的执行函数,注册到内存里的一个字典中。
import json import importlib from pathlib import Path class SkillRegistry: def __init__(self, skills_root: Path): self.skills_root = skills_root self._skills = {} self._executors = {} def load_all(self): for skill_dir in self.skills_root.iterdir(): manifest_path = skill_dir / "skill.json" if not manifest_path.exists(): continue with open(manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) if self._validate(manifest): self._skills[manifest["name"]] = manifest self._executors[manifest["name"]] = self._load_executor( manifest["executor"] ) return len(self._skills) def _load_executor(self, executor_path: str): module_name, func_name = executor_path.rsplit(".", 1) module = importlib.import_module(module_name) return getattr(module, func_name) def _validate(self, manifest): required = {"name", "version", "description", "executor", "parameters"} missing = required - set(manifest.keys()) if missing: raise ValueError(f"技能缺少字段: {missing}") return True这个加载器的思路很朴素:目录即服务,一个技能一个包,删掉文件夹就是下架技能,加个文件夹就是上架技能。对于单体服务来说够用了,而且天然适合用 Git 做管理,每个技能包可以有自己的提交历史。
3.2 运行时校验机制
校验分两层:加载时校验和运行时校验。
加载时校验检查的是"定义是否合法",包括必填字段是否存在、参数Schema是否符合 JSON Schema 规范、执行函数能否成功导入、依赖的技能是否存在。这一步做扎实了,运行时就不太会遇到"技能加载到一半,函数找不着"的尴尬情况。
运行时校验则要检查"实参是否符合契约"。我实现了一个非常轻量的校验函数,只做三件事:检查必填参数是否齐全、检查枚举值是否合法、检查类型是否匹配。不做什么高级的东西,但它能挡掉大量因为模型幻觉产生的脏参数。
def validate_call(skill: dict, args: dict) -> list[str]: errors = [] schema = skill["parameters"] required = set(schema.get("required", [])) missing = required - set(args.keys()) if missing: errors.append(f"缺少必填参数: {missing}") for key, value in args.items(): prop = schema["properties"].get(key) if not prop: continue if "enum" in prop and value not in prop["enum"]: errors.append(f"参数 {key} 的值 {value} 不在枚举范围内") if prop.get("type") == "integer" and not isinstance(value, int): errors.append(f"参数 {key} 需要整数类型") return errors如果校验有错误,我不会直接把错误抛给用户,而是把错误信息返回给模型,让它决定补参还是换技能。这样模型就知道"我刚才那个调用没成功,因为少了参数",它会尝试补全而不是直接瞎编答案。这个设计大大减少了用户看到荒谬回复的概率。
3.3 热更新与灰度发布的取舍
一开始我想做热更新,就是服务跑着的时候改了技能目录,能自动重新加载。后来测试了几轮,放弃了,原因很实际:热更新会带来不可控的中间状态。比如正在执行中的请求还在用旧版技能,成功后的响应却是按新版逻辑算的,一旦更新涉及数据结构变更,新老请求的数据格式不一致,连锁问题很难排查。
替代方案是"平滑发布":每次发布新技能或新版本时,先构建一个新的技能包目录,启动一个独立进程或服务实例做验证,验证通过后切换流量入口。对于单体智能体应用来说,更简单的做法是直接重启服务——技能目录本身加载很快,几百毫秒就能完成,重启一次的成本完全可以接受。
实测下来,我现在的流程是:本地加载新技能目录,跑一遍预置的测试用例,通过后提交代码,服务发布时自动会重新加载技能目录。这个流程虽然土,但非常稳,尤其适合团队规模不大、没有专门运维平台的场景。
4. 让Agent学会选技能:意图路由与多技能协作
4.1 基于描述的语义路由
技能注册好之后,真正让整套体系活起来的是路由层。也就是说,当用户抛来一句话,系统怎么决定该调用哪个技能。
我第一版用的是关键词匹配,速度极快,但准确率很一般。因为用户说话不会规规矩矩地带着关键词来,比如"这双鞋还有大码吗"里没有"库存"两个字,但显然应该走库存查询。于是第二版切换成语义路由:把所有技能的description字段用嵌入模型转成向量,用户输入也转成向量,然后计算余弦相似度,选得分最高的技能。
def route_to_skill(user_query: str, skills: list[dict], threshold: float = 0.55): query_vec = embed(user_query) best_skill = None best_score = 0.0 for skill in skills: desc_vec = skill.get("_embedding") if desc_vec is None: continue score = cosine_similarity(query_vec, desc_vec) if score > best_score: best_score = score best_skill = skill if best_score < threshold: return None, best_score return best_skill, best_score这个方案的效果比关键词好很多,但也有它的局限。最大的问题是阈值怎么定。阈值太高,用户问什么都找不到技能,全部走兜底;阈值太低,不相关的问题也会硬匹配到某个技能上。我调了一段时间,发现 0.55 到 0.6 之间比较合适,具体还得看你的技能描述写得到不到位。
4.2 技能链与依赖关系
技能体系比函数列表强的地方在于,它可以表达"技能之间的协作"。我在数据结构里加了depends_on字段,用来声明前置技能。例如"生成销售报表"这个技能依赖"查询销售明细"和"获取门店列表"两个技能,调用时路由层会先把依赖技能的返回结果作为上下文,再执行主技能。
实现上,我写了一个简单但足够用的执行编排器:
def execute_skill(name, user_args, registry): skill = registry.get(name) if not skill: return {"error": "技能不存在"} context = {} for dep_name in skill.get("depends_on", []): dep_result = execute_skill(dep_name, user_args, registry) context[dep_name] = dep_result executor = registry.get_executor(skill["executor"]) # 依赖结果是构建好的上下文,不直接暴露给用户 args = build_args(skill["parameters"], user_args, context) return executor(args, context=context)用递归的方式处理依赖,代码写起来最简单,但也需要小心循环依赖——两个技能互相声明依赖就会出现无限递归。我在注册中心加载的时候做了个简单的拓扑检查,有环直接拒绝加载。
技能链真正的威力在于用户只看得到最终结果,但系统内部已经完成了一次流水线。比如"帮我查一下最近一周各门店的销售概况",内部实际执行的可能是:解析门店列表 → 查询销售明细 → 聚合统计 → 生成表格。这个能力在函数列表时代几乎没法优雅实现,因为每次组合逻辑都要手写一个新的"大函数"。
4.3 失败时的技能降级策略
技能一定会失败。网络超时、上游接口返回异常、模型给错参数,各种情况都会遇到。失败怎么处理,直接决定了用户体感。
我设计的降级策略分三级。第一级是重试,对超时类错误,最多重试一次,因为大部分超时是偶发的。第二级是降级到相近技能,比如用户要查"物流轨迹"但物流查询技能挂了,就降级到"订单状态查询",至少能给用户一个"订单已发货"这类粗略信息。第三级是兜底话术,所有技能都不匹配或失败时,返回一段明确的"我暂时处理不了"响应,但会给出人工客服入口。
比较关键的一个原则是:不要把技能内部的错误堆栈直接返回给用户。用户不需要看"连接数据库超时"这种东西,他只需要知道"稍后再试"或者"帮你转人工了"。把错误信息记录到日志里供开发者排查就好,给用户的一定是经过翻译的友好话术。
这套降级策略实现起来不算复杂,但对稳定性的提升非常大。以前是"模型瞎编 + 用户一脸懵",现在是"系统有序降级 + 用户至少知道发生了什么"。
5. 实战中反复踩到的一组坑:命名、描述与上下文污染
5.1 技能命名撞车问题
技能体系运行一段时间后,最常遇到的问题就是命名撞车。团队里不同人加的技能,起名风格不统一。有人叫check_stock,有人叫query_inventory,其实都是查库存,但注册中心里它们是两个独立技能,路由层不知道该选哪个,选哪个都对又都不完美。
后来我定了一个命名规范:技能名统一采用"动词_对象_维度"的格式,动词必须是 query、create、update、delete、resolve、generate 这些标准化动作,对象用业务实体名,维度是可选的限定词。比如query_store_inventory、resolve_location_id、generate_sales_report。同时建了一个技能清单表,新增技能前先查一下,避免重复造轮子。
这个规范坚持了两个月之后,技能库的质量明显改善。新技能和已有技能之间的关系也更容易梳理了。
5.2 描述中的语义陷阱
技能描述的语义陷阱,是运行过程中最隐蔽的问题。
举个真实例子。我在一个"查询商品信息"的技能描述里写了"适合用户询问商品详情、规格、材质、产地",结果用户问"这个手机是哪里生产的",模型直接选了商品信息服务,而不是"查询品牌产地"技能。原因就在于我把"产地"这个词放到了描述里,而描述又没绑定具体的业务对象。
从那以后我学到一个原则:描述里要写完整用户意图,而不是堆名词。不要写"适合问商品信息、规格、材质、产地",要写"当用户想知道某个商品的参数、材质、尺寸、产地等详细属性时使用",并且明说"物流、价格、库存问题请交给对应技能"。
语义路由还有个大坑是描述过于简短。一个技能的描述如果只有一句话,向量化之后的信息量非常有限,和其他技能的区分度不够。我现在的做法是至少写三句话:第一句说这个技能最核心的职责,第二句给一个典型用户问题的例子,第三句做边界说明。这三句话组合成一个描述块,嵌入效果比单句话好太多。
5.3 上下文窗口被多余技能占用的优化
另一个不能不提的问题是上下文窗口。一开始我把所有技能的description和parameters全部拼进系统提示里,想着"多给模型点选择",结果五十个技能的描述加起来有几千个 token,模型被大量不相关信息干扰,回答质量明显下降。
后来我做了"按需注入"。路由层先根据用户当前问题选出最相关的前三个技能,只把这三份技能的描述和参数Schema注入上下文。其他技能的信息不进提示,但留在注册中心里,用户一旦问到,路由层再动态切换。
这个优化做完,模型回答的准确率和相关性都上了一个台阶。更意外的是响应速度变快了,因为传给模型的 token 少了,首字延迟也降了下来。实际数据集上,每次对话的提示词体积平均下降约三成。
按需注入唯一要注意的是,技能之间的依赖关系要在注入时一并算好。比如最终选中的主技能依赖两个前置技能,那这三个技能都得注入上下文,否则模型调用到一半发现没有相关函数可用,会卡住。
6. 技能库的沉淀与复用:从单项目走向团队共享
6.1 技能版本化
技能做到后面,一定要引入版本管理。否则你今天改了某个技能的描述,明天模型选技能的行为就变了,但你说不清是哪个改动导致的。
我采用的是跟语义化版本一样的规则:major.minor.patch。patch级别的改动是非破坏性的,比如修正错别字、补充说明;minor是新增参数、扩展枚举值,不破坏已有调用;major是改变参数结构或执行逻辑,可能导致旧调用失败。每个技能包的skill.json里都有版本号,Git 提交信息里也强制带上技能名和版本号,方便回溯。
发布流程上,我推荐"先影子验证再全量切换"。影子验证就是新版本技能跟老版本同时加载,但新版本不实际处理用户请求,只记录它"本该怎么选、本该怎么调",跑一段时间之后对比新老版本的表现,确认没问题再正式切换。对于高并发的生产环境,这个步骤值得做;对内部项目,可以简化为人工抽查几轮。
6.2 为每个技能准备一组"金标测试用例"
这是我认为整套体系里最有价值但最容易被忽略的部分:技能测试集。
每个技能我都会伴随一份测试用例文件,里面放着十几条典型的用户问题,以及期望命中的技能名。例如:
| 用户问题 | 期望命中的技能 |
|---|---|
| 附近的门店有这双鞋吗 | query_store_inventory |
| 我的订单什么时候能到 | query_logistics_tracking |
| 这个杯子是玻璃的还是塑料的 | query_product_specification |
路由层每次改版之后,我会跑一遍全量测试集,看技能选择的准确率变化。这个准确率就是技能体系健康度的核心指标。改进描述、调整阈值、新增技能,都会反映到这个分数上。没有这套测试集,你根本不知道自己改坏了什么。
测试用例的积累要趁早,不要等到技能上了几十个才开始。每新增一个技能,顺手写十条用例,成本极低;但等到技能多了再补,工作量会大得让人不想动手。
6.3 下一步扩展想法
严格来说,我这套方案还称不上完美,但已经足够支撑一个中型智能体项目的日常迭代。接下来有几个方向我在尝试,也分享出来给大家参考。
第一个方向是技能自动发现。现在技能需要人工写描述和关键词,我想试试用历史对话日志来自动总结某个技能的高频触发模式,生成描述草稿,人工审核后生效。第二个方向是技能的市场化共享。不同项目之间可以复用技能,比如"发邮件"这类通用技能,做一次之后就不需要重复开发了。这需要解决好参数映射和权限隔离的问题。第三个方向是给技能加上成本与质量预算,比如记录每个技能的平均耗时、平均失败率、平均消耗的 token,然后基于这些指标做路由优化,优先选择性价比更高的技能路径。
这些方向目前都还在探索阶段,没有成熟到可以写教程的程度,但方向我认为是对的。智能体工程化到了一定阶段,拼的不再是谁的模型更强,而是谁手里积累的、被验证过的技能更多、更可靠。
最后再分享一个小技巧:技能描述里加"negative keywords"(否定关键词)这个做法,我强烈建议大家都试一下。它能用极低的成本大幅降低相似技能之间的误触发率,比在描述里反复强调边界要实用得多。我在几个项目中实践下来,误触率普遍能降一半以上。