AI Agent技能网络SkillNet:从检索到编排的智能体能力管理实战
2026/9/9 23:40:07 网站建设 项目流程

有没有遇到过这种情况:你用 AI Agent 做一个稍微复杂一点的业务,比如“查一下今天的天气,如果下雨就给相关同事发一条企业微信通知”,发现 prompt 变得又长又乱,工具的调用逻辑只能靠硬编码写死在代码里,技能一多,整个系统就变得难以维护。

这其实就是当前很多智能体项目共同面临的痛点:模型能力越来越强,但“技能”的组织方式还停留在脚本时代。单个工具可以跑通,多个工具一起协作时,缺少一套标准化的检索、组合、调用机制。本文要讲的 SkillNet,正是针对这个问题的一种解决思路——把技能做成可注册、可检索、可编排的网络结构,让 AI Agent 能像查字典一样找到技能,像搭积木一样组合技能,像调接口一样稳定地调用技能。

这篇文章适合两类读者:一类是正在做智能体开发、被工具调用和 prompt 管理折磨的工程师;另一类是对 AI Agent 底层运行逻辑感兴趣,想知道技能网络到底怎么设计的学习者。读完你不仅能理解 SkillNet 的核心原理,还能跟着文章实现一个简化版本,在自己的项目中落地使用。

1. 为什么 AI Agent 需要技能网络

1.1 从“工具”到“技能”的演化

我们在聊 AI Agent 的时候,经常听到两个词:工具(Tool)和技能(Skill)。早期大家习惯叫工具,比如搜索工具、计算器工具、数据库查询工具。工具的粒度通常比较单一,一个函数对应一个能力。后来项目越来越复杂,大家发现单靠“工具”这个抽象层级不够用了,于是开始提倡“技能”。

技能和工具的区别在哪里?技能是围绕一个完整任务目标封装的、可组合的、带描述和参数约束的能力单元。比如“天气查询技能”不仅仅是一个查询接口,它还会告诉你这个技能适用于什么场景、需要什么参数、返回什么结构、能不能和其他技能串联。这种带有“自我描述”的能力单元,本质上是在为模型服务——让模型知道什么时候该用它,怎么用它,用完之后结果怎么处理。

SkillNet 所倡导的技能网络,就是把多个技能组织成一张可检索、可编排的网络。它不再把每个能力当作孤岛,而是当作网络里的节点。节点之间可以组合调用,形成复杂的任务处理链路。

1.2 技能网络解决的核心问题

在实际的智能体项目里,随着技能数量增长,会碰到三类典型问题:

第一是发现难。技能一旦超过十个,靠手工把所有技能描述塞进 prompt 里就不现实了。一方面大模型的上下文窗口有限,另一方面过多的技能描述会稀释模型的注意力,导致明明有某个技能,模型却“视而不见”。

第二是组合难。真实业务很少是一次单工具调用能搞定的。拿“天气通知”这个场景来说,你需要先查天气,再判断是否下雨,再调通知接口。这中间涉及多个技能的顺序编排、条件判断、参数传递。没有组合机制,就只能靠写死工作流,失去智能体的灵活性。

第三是维护难。技能与技能的边界不清晰,参数格式不统一,错误处理各写各的。新同学接入团队时,光看代码很难搞清楚这个智能体到底具备哪些能力。

SkillNet 要解决的,正是这三个问题:通过检索机制解决“发现难”,通过编排机制解决“组合难”,通过统一的技能抽象和注册规范解决“维护难”。

1.3 SkillNet 与普通 Tool Calling 的区别

很多人会问:我现在用的大模型本身就支持 Function Calling,为什么还需要 SkillNet?

这里要区分两个层面。Function Calling 是模型与工具之间的“通信协议”,它解决了模型如何输出调用请求的问题。但 SkillNet 解决的是“整个技能体系如何组织”的问题,层级更高。

可以这样理解:Function Calling 相当于每个技能都有了电话,可以直接和模型通话。但当技能数量变多、任务变复杂时,谁来帮模型决定拨哪个号码、按什么顺序拨、通话失败之后怎么处理?这就是 SkillNet 要做的事。

所以 SkillNet 不是要替代 Function Calling,而是建立在它之上的一层管理、检索和编排机制。包括哪些技能应该参与候选、多个技能的执行顺序是什么、技能执行结果如何汇总、执行失败如何回退,这些都是 SkillNet 需要考虑的内容。

2. SkillNet 的整体设计思路

2.1 技能的抽象与元信息

要构建技能网络,第一步是定义技能的数据结构。一个技能节点通常包含以下元信息:

  • 技能名称:全局唯一标识符,比如weather_query
  • 技能描述:说明这个技能是做什么的、适合什么场景。描述质量直接影响检索效果,后面会专门讨论。
  • 参数 Schema:定义技能运行需要哪些参数,参数类型是什么,哪些必填哪些可选。
  • 调用入口:真正执行技能的函数或 HTTP 接口。
  • 返回结构:技能输出数据的格式,方便后续技能或汇总逻辑使用。
  • 标签与分类:比如“办公协作”“数据处理”“外部API”,用于多路召回时的粗筛。

在 SkillNet 里,技能名和技能描述是检索的主要依据,参数 Schema 和调用入口是执行的基础。为了让网络可编排,通常还会给技能增加一个属性:是否能作为组合的一部分被中间调用。

下面是一个简化的技能数据结构定义,用 Python 的表现形式。

# 文件路径:skillnet/models.py from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional @dataclass class SkillSchema: """参数 Schema,描述技能需要的输入""" name: str type: str # string, integer, boolean, object required: bool = False description: str = "" @dataclass class Skill: """技能节点定义""" name: str description: str parameters: List[SkillSchema] handler: Callable[..., Any] # 实际执行函数 tags: List[str] = field(default_factory=list) category: str = "general" is_composable: bool = True # 是否允许作为组合技能的一部分

这段代码虽然简单,但已经勾勒出了技能节点的核心要素。实际项目中,你还可以扩展version字段做版本管理,增加timeout字段控制超时,增加permission字段做权限控制。

2.2 检索、组合、调用三段式链路

SkillNet 的核心链路可以抽象成三个步骤:

  1. 检索(Retrieval):根据用户的问题或当前任务上下文,从技能网络中找到最相关的一个或多个候选技能。
  2. 组合(Composition):对候选技能进行编排,决定是单个技能直接执行,还是多个技能按顺序、按条件组合执行。
  3. 调用(Execution):按编排结果调用具体技能,处理参数绑定、结果解析、异常回退。

这三个步骤与人类解决复杂问题的思维方式非常接近。你先判断“我现在需要什么能力”,再想“这几个动作之间是什么顺序”,最后“动手去做,出错就换一种方式”。

在完整实现中,检索负责缩小候选范围,组合负责生成执行计划,调用负责稳定执行。每一环都有自己的难点。

2.3 “可编排”的含义

SkillNet 强调“可编排”,到底编排的是什么?

简单说,编排的是技能之间的依赖关系和数据流。技能 A 的输出可以作为技能 B 的输入,技能 B 的执行需要满足某个条件才运行,多个技能执行完的结果需要合并成统一回复。这些都是编排要描述的内容。

从实现方式看,编排有两种极端:一种是用代码写死流程,直接skill_a()skill_b();另一种是完全交给大模型自由发挥,让模型自己决定调用顺序和次数。SkillNet 的设计介于两者之间——技能的调用顺序可以预先定义规则,也可以由模型动态规划,但无论哪种方式,技能网络都提供一层“轨道”来约束和引导执行,避免模型随意发挥导致失控。

3. 技能检索:如何找到最匹配的技能

3.1 检索输入与索引结构

技能检索的输入通常不只是用户当前这句问题,还包括对话历史、已抽取出的关键实体、当前业务上下文。比如用户问“明天开会需要订个会议室”,检索系统应该既能命中“会议室预订”技能,也能接受对话历史中提到的时间和人数作为潜在参数。

检索系统的核心是一个索引结构,把每个技能的描述、名称、标签转换为可比较的向量。最常见的做法是使用向量数据库或简单的内存索引,对技能描述做 embedding 向量化。查询时把用户问题同样做向量化,用余弦相似度计算相关性。

但这里有一个容易被忽视的问题:技能描述是静态文本,而用户的表达千变万化。用户很可能问“明天天气咋样”,而技能的描述是“查询指定日期的天气情况”。虽然语义相近,但字面差异大。因此检索必须建立在语义向量之上,而不是简单的关键词匹配。关键词匹配只能作为辅助召回手段。

下面是一个内存版技能检索引擎的示例,先用一个简化向量化函数演示思路。

# 文件路径:skillnet/retrieval.py import math import re from typing import List, Tuple from .models import Skill def _simple_vectorize(text: str, vocabulary: dict) -> List[float]: """简化版 TF 向量化,真实项目请替换为 embedding 模型""" vec = [0.0] * len(vocabulary) tokens = re.findall(r"[\w\u4e00-\u9fff]+", text.lower()) for token in tokens: if token in vocabulary: idx = vocabulary[token] vec[idx] += 1.0 return vec def _cosine_similarity(vec_a: List[float], vec_b: List[float]) -> float: dot = sum(a * b for a, b in zip(vec_a, vec_b)) norm_a = math.sqrt(sum(a * a for a in vec_a)) norm_b = math.sqrt(sum(b * b for b in vec_b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) class SkillRetriever: def __init__(self, skills: List[Skill]): self.skills = skills self.vocabulary = self._build_vocabulary(skills) def _build_vocabulary(self, skills: List[Skill]) -> dict: vocab = {} for skill in skills: text = f"{skill.name} {skill.description} {' '.join(skill.tags)}" for token in re.findall(r"[\w\u4e00-\u9fff]+", text.lower()): if token not in vocab: vocab[token] = len(vocab) return vocab def retrieve(self, query: str, top_k: int = 3) -> List[Tuple[Skill, float]]: query_vec = _simple_vectorize(query, self.vocabulary) scored = [] for skill in self.skills: skill_text = f"{skill.name} {skill.description} {' '.join(skill.tags)}" skill_vec = _simple_vectorize(skill_text, self.vocabulary) score = _cosine_similarity(query_vec, skill_vec) scored.append((skill, score)) scored.sort(key=lambda x: x[1], reverse=True) return scored[:top_k]

需要特别说明的是,_simple_vectorize只是用来演示检索流程的简化逻辑。在实际项目中,建议使用正式的 embedding 模型生成向量,配合向量数据库做 ANN 检索。上述代码的核心价值在于展示检索链路的结构——构建索引、计算相似度、取 Top-K。

3.2 多路召回与排序

单靠语义向量检索会漏掉一些情况,比如用户提到了某个技能独有的名词,或者某个技能只能通过标签匹配。所以工业级实现通常会做多路召回,包括:

  • 语义召回:对技能描述做 embedding 检索。
  • 关键词召回:对技能名称和标签做 BM25 或 TF-IDF 检索。
  • 规则召回:根据业务场景,某些技能固定参与候选。比如用户处于会议室相关场景时,强制召回订会议室、查会议室状态等技能。

多路召回之后需要排序,把不同来源的结果融合成一个候选列表。常见的做法是加权融合,比如语义相似度权重 0.6,关键词匹配权重 0.3,规则命中权重 0.1。排序的目标不是找到唯一技能,而是筛选出 3 到 5 个最值得进入组合编排阶段的候选技能。

这里有一个工程经验:召回阶段宁可多召回一些,也不要因为阈值设置太严格而漏掉关键技能。真正减少误召回,可以在组合编排阶段用大模型再做一次精细选择,这样系统更稳定。

3.3 阈值与召回数量控制

检索环节有两个参数非常关键,一个是相似度阈值,一个是 Top-K 数量。

阈值决定了“低到什么程度就不算候选了”。设置太高,技能召回率下降,智能体经常说“我找不到可用的技能”;设置太低,候选池里全是无关技能,影响后续组合的准确性。常见的做法是设置一个动态阈值,比如综合评分低于 0.25 时直接丢弃,而不是固定死一个数值。

Top-K 数量需要结合后续组合环节的模型能力来定。如果组合环节是规则引擎,候选技能越少越可控;如果组合环节是大模型自主规划,候选技能可以适当放宽到 5 到 8 个,让模型有足够的选择空间。另外,检索系统还应该返回每个候选择技能与查询的相关度得分,方便组合环节做参考。

4. 技能组合:多个技能如何协作

4.1 顺序执行

技能组合最简单也最常用的模式是顺序执行。顺序执行适用于有明显先后依赖关系的任务,比如“查询天气 → 判断是否下雨 → 发送通知”。前一个技能的输出经过处理后作为后一个技能的输入。

顺序执行的实现核心是数据流管理。你需要一个上下文对象,保存每一步的执行结果。每个技能执行完后,把输出写入上下文,供后续技能按需读取。

下面是一个简化示例:

# 文件路径:skillnet/composer.py from typing import Any, Callable, Dict, List from .models import Skill class Context: """组合执行时的上下文,保存中间结果""" def __init__(self, initial: Dict[str, Any] = None): self.data = initial or {} self.history = [] def set(self, key: str, value: Any): self.data[key] = value def get(self, key: str, default: Any = None): return self.data.get(key, default) def run_sequential(skills: List[Skill], args_provider: Callable[[Skill, Context], Dict]) -> Context: ctx = Context() for skill in skills: params = args_provider(skill, ctx) result = skill.handler(**params) ctx.set(skill.name, result) return ctx

这段代码里的args_provider是一个回调函数,负责根据当前上下文为每个技能准备参数。这样设计的好处是,技能与技能之间不直接耦合,每个技能只需要知道自己需要哪些参数,参数的来源由编排层统一解决。

4.2 条件分支与选择

顺序执行解决不了所有场景。有些任务的执行路径是分叉的,需要根据某个中间结果决定走哪条分支。比如“查询天气 → 如果下雨就发通知,如果晴天就不发”。

在 SkillNet 里,条件分支可以有两种实现方式:一种是由代码显式编写分支规则,另一种是由大模型根据中间结果做决策。后者更灵活,但可控性差;前者更稳定,但写起来繁琐。

工程上建议采用混合策略:对于关键路径和风险较高的操作,用代码显式规则保证不偏离;对于非关键路径,允许模型根据上下文灵活决定。下面的简化代码演示了基于上下文的分支选择:

# 文件路径:skillnet/composer.py def run_with_condition( skill: Skill, condition_func: Callable[[Context], bool], args_provider: Callable[[Skill, Context], Dict], ctx: Context ): if condition_func(ctx): params = args_provider(skill, ctx) result = skill.handler(**params) ctx.set(skill.name, result) else: ctx.set(skill.name, None) return ctx

在真实的技能网络实现中,条件分支通常需要可视化配合,因为当技能数量超过一定规模后,纯代码维护分支逻辑会比较吃力。

4.3 组合结果汇总

多技能执行完,各自返回的结果需要汇总成统一的回复。汇总逻辑有两种常见形态:

  • 模板汇总:把每个技能的输出填入固定模板。适合输出格式稳定的场景,比如“当前温度是 22 度,空气质量优”。
  • 大模型汇总:把多个技能的结果和用户问题一起交给大模型,让模型生成自然语言的完整回答。适合开放性场景,比如让模型分析对比多个数据源的结果。

在实现中,技能返回的数据结构越是标准化,汇总逻辑就越简单。这也是为什么一开始定义技能时就要强调返回结构。如果每个技能返回的数据都是自由文本,汇总阶段将非常吃力。更合理的做法是技能返回结构化 JSON,上层统一决定如何呈现。

5. 技能调用:从参数绑定到结果校验

5.1 参数 Schema 与自动补全

技能调用的第一步是参数绑定。大模型决定了要调用哪个技能之后,通常会输出一组参数,但这些参数经常不完整。比如技能要求传入citydate,模型只输出了city,没有date。这时就需要参数补全机制。

参数补全的思路是:先检查用户对话上下文里有没有缺失参数的信息,如果有就自动提取填充;如果没有,主动追问用户;如果是可选参数,按默认值填充。一些更完善的设计还允许技能注册“默认参数提供者”,比如地理定位服务可以自动提供当前城市,时间服务可以自动提供今天日期。

# 文件路径:skillnet/executor.py from typing import Any, Dict, List from .models import Skill, SkillSchema def resolve_arguments( skill: Skill, model_params: Dict[str, Any], context_data: Dict[str, Any] ) -> Dict[str, Any]: """把模型传来的参数与上下文数据合并,补全缺失参数""" resolved = dict(model_params) for schema in skill.parameters: if schema.name not in resolved or resolved[schema.name] is None: if schema.name in context_data: resolved[schema.name] = context_data[schema.name] elif not schema.required: if schema.type == "string": resolved[schema.name] = "" elif schema.type == "integer": resolved[schema.name] = 0 elif schema.type == "boolean": resolved[schema.name] = False else: raise ValueError(f"缺少必填参数: {schema.name}") return resolved

这里需要注意,自动补全默认值有可能掩盖一些错误。工程上建议在日志里记录哪些参数是补全的、补全来源是什么,方便排查。参数补全的逻辑越透明,系统越容易调试。

5.2 调用链与上下文传递

当多个技能按顺序调用时,上下文传递的准确性往往决定整个任务能否成功。上下文可以理解为一个共享的“便签本”,前面技能写入的内容,后面技能可以读取。但直接让所有技能共享一个全局上下文会有问题:技能 A 写入了一个result字段,技能 B 也写了一个result字段,后者覆盖前者,数据就丢失了。

更可靠的做法是用技能名做命名空间隔离。每个技能的执行结果存放在ctx[skill_name]下,跨技能引用时明确指定“从哪个技能拿哪个字段”。这种方式一目了然,也便于调试时查看每一步产生了什么数据。

5.3 异常回退与幂等

真实工程中,技能调用一定会发生异常。网络超时、接口返回错误、参数类型不对,这些都是常态。SkillNet 的调用层必须考虑异常回退策略。

回退策略至少包括几个层级:第一层,重试同一个技能,限制最多重试次数,加上指数退避;第二层,调用备选技能,例如主技能是“航班查询 V1”,失败了可以降级到“航班查询 V2”;第三层,跳过该技能,把异常情况记录到上下文,让后续技能或汇总逻辑决定如何向用户解释。

另外,涉及写操作的技能(发送消息、创建订单、修改数据库)必须考虑幂等性。给每个调用生成一个 request_id,技能内部用这个 request_id 做去重判断。这样即使一次请求被重试多次,业务侧不会产生重复数据。

6. 完整实战:从零实现一个简化版 SkillNet

6.1 项目结构与数据模型

下面我们动手做一个演示项目。为了便于读者复现,我把多个相关文件合并到一个可运行的demo_skillnet.py中,先在这个文件中定义技能模型、检索引擎、组合编排和调用执行。

项目要实现的演示场景是:用户问“明天上海适合户外跑步吗?如果天气好就通知大家去公园”。系统通过技能网络检索到weather_queryoutdoor_advicesend_notification三个技能,然后根据天气结果决定是否发送通知。

创建demo_skillnet.py

# 文件路径:demo_skillnet.py import math import re from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Tuple # ---------- 1. 技能数据模型 ---------- @dataclass class SkillSchema: name: str type: str required: bool = False description: str = "" @dataclass class Skill: name: str description: str parameters: List[SkillSchema] handler: Callable[..., Any] tags: List[str] = field(default_factory=list) # ---------- 2. 三个演示技能 ---------- def handle_weather(city: str, date: str) -> Dict[str, Any]: # 演示环境,使用固定数据模拟天气服务返回 fake_weather = { "city": city, "date": date, "weather": "rain", "temperature": 18, "humidity": 85, } return fake_weather def handle_outdoor_advice(weather: Dict[str, Any]) -> Dict[str, Any]: if weather.get("weather") == "rain": return {"suitable": False, "reason": "下雨天路面湿滑,不适合户外跑步"} return {"suitable": True, "reason": "天气良好,适合户外跑步"} def handle_send_notification(message: str, channel: str = "team_group") -> Dict[str, Any]: # 演示环境,不真正发送消息,只打印并返回结果 print(f"[send_notification] 通过 {channel} 发送消息:{message}") return {"sent": True, "channel": channel, "message": message} weather_skill = Skill( name="weather_query", description="查询指定城市和日期的天气情况,包括天气现象、温度、湿度", parameters=[ SkillSchema(name="city", type="string", required=True), SkillSchema(name="date", type="string", required=True), ], handler=handle_weather, tags=["天气", "出行", "查询"], ) advice_skill = Skill( name="outdoor_advice", description="根据天气情况给出是否适合户外运动的建议", parameters=[ SkillSchema(name="weather", type="object", required=True), ], handler=handle_outdoor_advice, tags=["天气", "运动", "建议"], ) notify_skill = Skill( name="send_notification", description="向指定渠道发送通知消息,常用于提醒同事或家人", parameters=[ SkillSchema(name="message", type="string", required=True), SkillSchema(name="channel", type="string", required=False), ], handler=handle_send_notification, tags=["通知", "消息", "协作"], )

6.2 技能检索与组合编排实现

接着实现检索器。为了让演示代码不依赖外部包,这里用一个简化版本的字符向量表示。真实项目中可以直接替换为 embedding 模型加向量数据库。

# 文件路径:demo_skillnet.py(续) def _simple_vectorize(text: str, vocabulary: Dict[str, int]) -> List[float]: vec = [0.0] * len(vocabulary) tokens = re.findall(r"[\w\u4e00-\u9fff]+", text.lower()) for token in tokens: if token in vocabulary: vec[vocabulary[token]] += 1.0 return vec def _cosine_similarity(vec_a: List[float], vec_b: List[float]) -> float: dot = sum(a * b for a, b in zip(vec_a, vec_b)) norm_a = math.sqrt(sum(a * a for a in vec_a)) norm_b = math.sqrt(sum(b * b for b in vec_b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) class SkillRetriever: def __init__(self, skills: List[Skill]): self.skills = skills self.vocabulary: Dict[str, int] = {} for skill in skills: text = f"{skill.name} {skill.description} {' '.join(skill.tags)}" for token in re.findall(r"[\w\u4e00-\u9fff]+", text.lower()): if token not in self.vocabulary: self.vocabulary[token] = len(self.vocabulary) def retrieve(self, query: str, top_k: int = 3) -> List[Tuple[Skill, float]]: query_vec = _simple_vectorize(query, self.vocabulary) scored = [] for skill in self.skills: text = f"{skill.name} {skill.description} {' '.join(skill.tags)}" skill_vec = _simple_vectorize(text, self.vocabulary) score = _cosine_similarity(query_vec, skill_vec) scored.append((skill, score)) scored.sort(key=lambda x: x[1], reverse=True) return scored[:top_k]

接下来定义上下文类和组合执行器:

# 文件路径:demo_skillnet.py(续) class Context: def __init__(self, initial: Dict[str, Any] = None): self.data = initial or {} self.history = [] def set(self, key: str, value: Any): self.data[key] = value self.history.append((key, value)) def get(self, key: str, default: Any = None): return self.data.get(key, default) def build_and_run(query: str): # 1. 检索阶段 retriever = SkillRetriever([weather_skill, advice_skill, notify_skill]) candidates = retriever.retrieve(query, top_k=3) print("=== 检索到的候选技能 ===") for skill, score in candidates: print(f" {skill.name}: {score:.4f}") # 2. 组合阶段(使用简化规则:先查天气,再出建议,最后决定是否通知) ctx = Context({"city": "上海", "date": "明天"}) # 调用天气技能 weather_result = weather_skill.handler( city=ctx.get("city"), date=ctx.get("date"), ) ctx.set("weather", weather_result) # 调用建议技能 advice_result = advice_skill.handler(weather=weather_result) ctx.set("advice", advice_result) # 3. 调用阶段:根据建议结果决定是否通知 if advice_result["suitable"]: notify_result = notify_skill.handler( message=f"{ctx.get('date')} {ctx.get('city')}天气适合户外跑步,已通知大家。", channel="team_group", ) ctx.set("notification", notify_result) else: print(f"[skip] 不发送通知,原因:{advice_result['reason']}") ctx.set("notification", None) return ctx if __name__ == "__main__": result = build_and_run("明天上海适合户外跑步吗?天气好就通知大家") print() print("=== 执行结果 ===") print("天气:", result.get("weather")) print("建议:", result.get("advice")) print("通知:", result.get("notification"))

6.3 运行结果与分析

在命令行中运行:

python demo_skillnet.py

演示环境里模拟的天气是下雨,所以预期输出大致如下:

=== 检索到的候选技能 === weather_query: 0.4714 send_notification: 0.3849 outdoor_advice: 0.3333 === 执行结果 === [skip] 不发送通知,原因:下雨天路面湿滑,不适合户外跑步 天气: {'city': '上海', 'date': '明天', 'weather': 'rain', 'temperature': 18, 'humidity': 85} 建议: {'suitable': False, 'reason': '下雨天路面湿滑,不适合户外跑步'} 通知: None

这个输出验证了技能网络的基本流程:检索器能根据自然语言问题找到相关技能,组合执行器能按业务规则编排技能顺序,调用层能在中间结果不满足条件时跳过后续步骤,而不是盲目执行。

你可以把模拟天气数据改成sunny,再次运行,观察“通知”路径生效的情况。这种“可切换路径”的结构,正是技能编排的价值所在——不用改动整体代码,仅通过上下文数据就能改变执行链路。

6.4 如何接入真实大模型

上面演示的是规则驱动方式。在实际项目中,你可能希望由大模型来决定调用哪些技能,以及技能的参数。这时需要把示例中的组合阶段替换为模型调用逻辑。

接入思路如下:把检索到的候选技能转换成模型能理解的工具定义,拼接用户问题,请求模型返回结构化的调用意图。大模型当前对话的完整流程大致如下:

# 伪代码,展示接入大模型的思路 def llm_plan_and_call(query: str): candidates = retriever.retrieve(query, top_k=5) tools = [ { "type": "function", "function": { "name": skill.name, "description": skill.description, "parameters": { "type": "object", "properties": { p.name: {"type": p.type, "description": p.description} for p in skill.parameters }, "required": [p.name for p in skill.parameters if p.required], }, }, } for skill in candidates ] # 调用大模型,传入 tools 和 query # 解析模型输出,获取选中的技能和参数 # 按返回结果执行技能调用链 pass

这里不展开具体模型 API,因为各家接口差异较大。核心思路是:SkillNet 的检索器负责缩小候选范围,避免把几十个技能全部塞给模型;模型负责在有限候选中做决策;调用器负责参数校验和异常回退。三层各司其职。

7. 常见问题与排查思路

在实现技能网络的过程中,下面几个问题出现频率很高。

问题现象常见原因解决思路
检索召回不到正确技能技能描述太泛或与用户表达差异大重写技能描述,加入更多业务同义词;增加标签
候选技能太多,组合经常选错Top-K 设置过大,或排序权重分配不合理调小 Top-K;提升语义相似度权重;增加规则兜底
技能调用缺少必填参数参数补全逻辑不完善,或大模型输出参数名不一致加强参数补全;在工具定义中明确参数名和类型
组合链路执行到一半失败数据流命名冲突或依赖技能异常按技能名做命名空间隔离;增加异常回退
同一请求重复调用写操作技能上游多次重试,技能未做幂等用 request_id 做幂等控制
技能描述太长,检索效果反而变差描述中包含大量无关信息精简描述,把扩展说明放到技能内部文档中

排查技能网络问题时,我建议按下面的顺序做:

  1. 先看检索结果。打印用户问题与技能候选列表及得分,确认有没有召回正确技能。
  2. 再看组合流程。确认编排规则是否符合预期,是顺序问题还是分支条件问题。
  3. 最后看参数和调用日志。确认参数是否完整、类型是否正确、异常有没有被吞掉。

日志是关键。技能网络每个环节都应该打日志,至少包括:检索到了哪些技能、每个技能得分多少、选中了哪些技能、参数怎么补全的、每个技能执行耗时和结果、异常发生在哪一层。没有日志,技能网络从外面看就是个黑盒,排查起来全靠猜。

8. 最佳实践与工程建议

8.1 技能设计规范

技能是网络中的节点,节点的质量决定了整个网络的可靠性。我在实践中有几点体会。

技能描述要面向检索优化。描述里应该出现用户最容易使用的自然语言表达,而不是内部术语。比如技能内部叫intent_regression_detect,但描述里一定要写清楚“判断用户这句话是不是有情绪、是否包含负面反馈”,这样检索器才能通过“情绪”“负面”等词命中它。

技能粒度要适中。太粗,一个技能做太多事情,复用性差,坏一处影响全部;太细,一个技能只做一个动作,网络节点太多,编排复杂度急剧上升。建议一个技能聚焦一个完整能力边界,比如“查询会议空闲时间”和“预订会议室”拆成两个技能,避免“查询并预订”这种混杂技能。

参数 Schema 要严格,但执行逻辑要宽容。参数校验严格是为了尽早发现错误,但执行函数内部应该对输入做容错,比如时间格式不统一时先做标准化,字符串去空格,长度截断等。

8.2 可观测性与安全边界

技能网络一旦接入生产,必须有完整的可观测性。

每个技能的调用都应该有独立的 trace ID,从检索到组合到执行,同一个 trace ID 贯穿始终。这样用户在反馈“智能体做了个奇怪操作”时,你能通过 trace ID 还原整个决策链路。在安全方面,要特别注意技能的执行权限边界。不同的技能可能需要不同的权限等级,比如查询类技能可以放开,写操作类技能必须做二次确认。建议为每个技能配置permission_level字段,由编排层统一校验,不能在技能内部自行判断。

同时,涉及外部 API 的技能需要做输出校验。外部接口返回的数据可能包含恶意内容或异常结构,直接喂给大模型或下一个技能前,应该做基本的清洗和校验。

8.3 新技能接入的评审流程

技能网络是一个持续演进的系统,新技能不断加入。接入流程如果没有约束,很快会乱。

建议每个新技能接入时经过如下步骤:

  • 注册:填写技能名称、描述、参数 Schema、负责人。
  • 检索体检:准备 20 到 30 个可能触发该技能的问题,验证检索命中率。
  • 安全评审:明确该技能的数据来源、权限等级、是否有写操作、是否涉及敏感信息。
  • 灰度上线:先把技能加入网络但标记为experimental状态,只对部分流量生效,观察检索命中率、误召回率、调用成功率。
  • 正式发布:数据稳定后再全量开放。

这套流程看起来重,但对于一个持续演进的技能网络来说,前期的规范化能省下后期大量排错时间。

在落地 SkillNet 的过程中,我越来越觉得,技能网络本质上是一种系统设计的“秩序感”。它没有复杂的算法,也没有高深的理论,但它通过一层统一的抽象,把模型的能力边界、工具的调用方式、业务的编排规则组织成了一个清晰的结构。先让技能被找到,再让技能被组合,最后让技能被稳定调用——这三点做好,AI Agent 离生产可用就足够近了。

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

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

立即咨询