☰
Agent技能系统设计:从注册到调用的完整工程实践
2026/10/13 5:08:26 网站建设 项目流程

1. Skill到底是什么:先搞清楚Agent技能系统的核心价值

1.1 技能在Agent架构里的位置

最近在重构一个Agent项目,原来自以为很稳的工具调用层,换成agent-skills的思路重新设计之后,效果提升非常明显,所以想把这一整套东西整理出来。很多人一听到"技能系统",第一反应是"不就是给模型加几个工具函数嘛",但真正动手做工程之后会发现,事情远没有这么简单。一个技能(Skill)不是一个孤立的函数,而是包含描述、参数Schema、执行逻辑、返回格式、权限约束和调用限制的完整单元,它卡在模型理解和实际动作中间,决定了Agent到底能做什么、能做到什么程度。

把视角拉高一点看,一个Agent从接收用户问题到输出最终答案,内部大致会经历意图理解、任务拆解、技能选择、参数绑定、执行反馈、结果整理这么几个阶段。技能系统正好承担了"技能选择"和"参数绑定"这两步的落地工作。模型本身不执行任何动作,它只负责决定"该调用哪个技能、传什么参数",真正动起来的是技能背后那一段真实代码。这种分工模式带来的好处非常直接:业务能力可以被单独扩展、单独测试、单独灰度,而不会动到模型和Prompt的主体结构。

我在实际项目里看到过不少团队,Demo阶段跑得很顺,一上业务就频繁翻车,翻车点几乎都集中在技能设计得太"薄"——只关注了"能调用",忽略了"调得对、调得安全、坏了能查"。这也就是为什么我想围绕一个最小可复用的agent-skills工程来展开,把技能定义、注册、调度、排查这一整条链路讲透。做这块内容的目标读者,一个是正准备给Agent加功能的开发者,一个是已经做了工具调用但总被各种边缘问题折磨的工程负责人,看完之后至少能对"技能该抽象到什么程度、怎么设计才不容易被模型打乱"有一个清晰的判断。

1.2 为什么不能把技能写死在Prompt里

先说一个非常多见的操作:为了省事,把工具说明直接用自然语言写进系统Prompt,比如"你可以调用天气查询工具,传入城市名,返回该城市天气"。这种方案在小范围验证时确实能用,但一旦技能数量超过五个、场景复杂化之后,问题会成倍放大。

第一个问题是token浪费。每轮对话都要把全部技能描述塞进上下文,技能越多,Prompt越长,响应变慢,成本变高。我见过一个项目同时挂了二十多个技能,Prompt光技能说明就占了两千多token,用户每问一句都要为这段固定文本付钱。更难受的是,模型读完一大段说明之后,真正选中的往往就那么两三个,其余技能描述纯粹是陪跑。

第二个问题是更新不灵活。技能说明写死在Prompt里之后,每次改动都意味着整个系统Prompt要重新生成、重新走一遍模型适配测试,没法单独对某个技能做灰度。生产环境中经常出现"新技能上线影响到了老技能调用"的诡异情况,本质上就是所有技能挤在同一个上下文里互相干扰,出了错又没法定位是谁带偏了谁。

第三个问题最要命:缺少统一执行入口。把技能说明放在Prompt里,模型只是"读到"了这个技能,并没有一个可靠的机制去执行它。参数格式靠模型自由发挥,返回值没有校验,异常没有兜底,权限没法控制。模型一本正经地编一个城市名、编一个日期,系统还真去查了,查到一半报错了,整个对话就这么断掉。换句话说,技能写死在Prompt里,等于把执行链路中最需要强约束的环节全部交给了模型的"临场发挥"。

所以独立出来的技能系统至少要解决三件事:一是结构化定义,让模型能稳定理解技能边界;二是集中式注册,让技能可以被统一管理、动态启停;三是统一调度,让每一次调用都经过参数校验、权限校验和异常处理。这三件事听起来不复杂,但每件展开都有不少细节。

2. 技能系统设计:注册、发现、调用的完整闭环

2.1 技能描述的结构化设计

技能描述是整个系统的门面,模型就是靠它来理解"你提供的这个能力到底是干嘛的、什么时候该用、参数该怎么填"。我在实践中总结了一套比较稳定的字段结构:name(唯一标识)、description(给模型看的自然语言说明)、parameters(JSON Schema格式的参数定义)、examples(典型调用案例)、permissions(权限级别)、constraints(超时、重试等执行限制)。

这里最容易被低估的是description。很多开发者随便写一句"天气查询",模型根本不知道怎么用。我的经验是,description里至少要包含三层信息:使用场景、触发条件、反面约束。使用场景写明"当用户询问某地当前天气、气温、是否需要带伞时使用";触发条件说明清楚"只有涉及实时天气才触发,不要用它查询历史气候";反面约束则直接告诉模型"什么情况下绝对不要选它",这一条实战中特别管用,能显著减少模型乱选技能的概率。

parameters部分建议直接用JSON Schema,不要自造格式。为什么?因为主流大模型API对JSON Schema的兼容性普遍稳定,模型能直接理解"type""enum""required""description"这些字段的含义。填参数时,给每个字段写清楚释义,并提供一个example值。我见过很多参数定义写得含糊,模型到了调用环节就瞎填,根源往往就是这里。

举一个实际的结构长什么样,这是我在项目里用过的简化版本:

{ "name": "query_weather", "description": "查询指定城市当前实时天气,适合用户询问天气、气温、穿衣建议等场景。如果用户问的是历史天气或气候统计,不要使用本技能。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名,如北京、上海", "example": "北京" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认celsius", "default": "celsius" } }, "required": ["city"] }, "permissions": ["read_public_data"], "constraints": { "timeout_ms": 3000, "retry_count": 1 } }

这种结构化做法的核心意图,是把"模型理解"和"后端执行"之间的信息差压到最小。模型看到的是一份语义清晰的说明,后端拿到的是带默认值、带枚举约束的参数,两头对齐了,调用自然就稳。

2.2 注册中心与动态更新

技能定义好了之后,不能是散落各处的代码片段,必须收拢到一个统一的注册中心里。注册中心本质上就是一个全局的技能表,维护着技能名到技能实体的映射,同时提供注册、注销、查询、更新几个基本操作。

为什么要单独做注册中心而不是直接写死在代码里?因为真实的Agent服务里,技能数量是会持续膨胀的。随着业务接入,可能几十个技能共存,这时候某个技能需要下线、某个技能需要切到新版本、某个技能只有特定租户才能用,如果全靠改代码重新发版,节奏完全跟不上。有了注册中心,就可以在运行时动态增删技能,甚至按租户或场景维度去隔离技能列表。

注册设计上两个关键点:幂等和版本。所谓幂等,就是同一个技能重复注册多次不能出问题,生产环境里服务重启、多实例部署都可能导致重复注册,技能表必须能正确处理"已经存在同名技能"的情况。我的做法是给每个技能加一个version字段,注册时如果版本号大于当前版本,就覆盖;否则保留旧版本并记录告警。这样既支持了热更新,又避免了误覆盖。

更新还有一个容易被忽略的细节:技能描述被模型读到的时机。在请求到来之前,技能列表会先被组装成Prompt的一部分,所以注册中心的任何变动,要确保在下一次组装时自然生效。这里我建议把"技能列表生成"做成一个独立的组件,而不是在业务代码里到处拼字符串。每次请求进来,从注册中心拉取当前启用的技能列表,渲染成模型需要的格式,用完即弃。这样动态更新、按租户隔离、按场景裁剪就都好做了。

注册中心看起来简单,但它其实是整个技能系统的"中控台",决定了后续所有能力能不能灵活铺开。不夸张地说,凡是往后想扩展Agent业务边界的,这一步都值得认真对待。

2.3 技能路由与意图匹配

技能路由回答的问题只有一个:当前这个用户需求,到底该触发哪个技能。主流的做法是让模型自己来做路由判断——把技能描述列表交给模型,模型根据用户意图选择技能并填充参数。这个过程很像一个服务员拿着菜单递给你点菜,菜单写得清楚,点菜就准确;菜单写得含混,顾客就只能瞎猜。

但"把选择权交给模型"不等于完全放手。我建议在模型判断之外加两层兜底:一层是结构校验,确保模型返回的技能调用格式合法、技能存在、参数能通过JSON Schema校验;另一层是保底策略,处理"模型没有调用任何技能但其实明显需要技能"以及"模型调用了一个不存在的技能"这两种情况。前者往往意味着技能描述没被模型看见或者描述不够明确,后者通常是模型在编造技能名,说明描述列表里缺少足够清晰的边界信息。

这里分享一个特别有用的实践经验:技能路由的准确性,七成取决于描述质量,三成才取决于模型本身。这也是为什么我前面反复强调description要写场景、条件、反面约束。实际操作中,我会准备一批典型用户问题,提前跑一遍路由验证,观察每个问题是否被路由到了正确的技能上。如果发现有稳定跑偏的情况,先不要怀疑模型,优先回头改技能描述。改到"用户问什么、该调用哪个技能"在人类视角下都一目了然时,模型自然不会选错。

路由层还需要处理好技能之间的重叠问题。两个技能描述都覆盖了同一类场景时,模型很容易随机选择一个。解决办法是在描述里划清边界,例如"查询天气"和"查询城市生活指数"看似相关,但一个聚焦实时气象信息,一个围绕交通、穿衣、运动等舒适度建议。边界写清楚之后,模型区分起来成本就低很多。

3. 一个最小可复用的技能系统Demo

3.1 工程结构

光讲理论不够,直接给一套可以拿走用的最小工程。这个Demo不依赖任何重型框架,用Python实现,核心只包含四个文件:skills.py(技能定义与注册中心)、scheduler.py(调度器)、demo.py(模拟模型调用入口)、skills.json(技能描述导出文件)。为了让你快速看到效果,我把所有技能定义直接写在代码里,实际工程中可以根据规模拆成独立模块。

工程目录长这样:

agent-skills-demo/ ├── scheduler.py ├── skills.py ├── demo.py └── skills.json

这套结构的分工是:skills.py负责技能实体和注册表,scheduler.py负责把模型返回的调用指令翻译成真实的技能执行,demo.py模拟一个模型输出驱动整个流程。这样一个最小闭环跑通之后,再往里挂真实的模型API就很容易了。

3.2 核心代码实现

先看技能定义部分,我用装饰器的方式注册技能,写起来很简洁,而且技能和注册逻辑放在一起,可读性比集中式注册表更好:

# skills.py from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional @dataclass class Skill: name: str description: str parameters: dict fn: Callable version: str = "1.0.0" permissions: List[str] = field(default_factory=list) class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] = {} def register(self, skill: Skill): old = self._skills.get(skill.name) if old is None or skill.version > old.version: self._skills[skill.name] = skill print(f"[registry] registered {skill.name}@{skill.version}") else: print(f"[registry] skip {skill.name}@{skill.version}, old={old.version}") def unregister(self, name: str): self._skills.pop(name, None) def get(self, name: str) -> Optional[Skill]: return self._skills.get(name) def list_skills(self) -> List[Skill]: return list(self._skills.values()) _registry = SkillRegistry() def skill(name: str, description: str, parameters: dict, version: str = "1.0.0"): def decorator(fn): _registry.register(Skill( name=name, description=description, parameters=parameters, version=version, fn=fn )) return fn return decorator @skill( name="query_weather", description="查询指定城市当前实时天气,适合用户询问天气、气温、穿衣建议等场景。如果用户问的是历史天气或气候统计,不要使用本技能。", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "城市中文名,如北京"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"} }, "required": ["city"] } ) def query_weather(city: str, unit: str = "celsius") -> str: # 真实环境这里会调天气服务 return f"{city} 当前温度 25°{unit.upper()[0]}, 天气晴"

这里有一个经验:注册中心要支持版本判断,这样同一个技能名可以被安全地重复注册。我在实际项目中就遇到过服务重启时误把新版本顶掉的情况,加了版本比较之后这个坑彻底消失。

调度器的核心逻辑,是把模型返回的"工具调用指令"转换成技能执行结果。真实模型API返回的结构各家略有差异,但本质都是"技能名+参数字典",所以这段代码可以平移到不同平台:

# scheduler.py import json from skills import _registry def dispatch(call: dict) -> dict: """call: {"name": "query_weather", "arguments": {"city": "北京"}}""" skill_name = call.get("name") arguments = call.get("arguments", {}) if isinstance(arguments, str): try: arguments = json.loads(arguments) except json.JSONDecodeError: return {"status": "error", "message": "arguments 不是合法 JSON"} skill_obj = _registry.get(skill_name) if skill_obj is None: return {"status": "error", "message": f"技能 {skill_name} 不存在"} try: result = skill_obj.fn(**arguments) return {"status": "success", "result": result} except TypeError as e: return {"status": "error", "message": f"参数校验失败: {str(e)}"} except Exception as e: return {"status": "error", "message": f"执行异常: {str(e)}"}

注意这里我故意先做了"技能是否存在"的判断,再做参数展开。顺序不能反,否则模型一旦调用了不存在的技能,代码会先报KeyError或者更隐蔽的错误,排查起来很费劲。调度器是技能系统唯一允许"完全不信任模型"的地方,每一次调用都要以异常处理的视角来守卫。

3.3 调度器与模型输出联动

有了技能定义和调度器,最后一步是把它们塞进Agent的主流程。实际调用链路上,模型返回的输出会包含一个"工具调用"结构,里面有一个技能名和一段参数文本,调度器拿到之后调用dispatch,再把dispatch的返回结果作为一条新消息回传给模型,让模型继续生成回复。

模拟主流程的代码:

# demo.py from skills import _registry from scheduler import dispatch def render_skill_list() -> str: lines = [] for s in _registry.list_skills(): lines.append(f"- {s.name}: {s.description}") return "\n".join(lines) def main(): # 模拟用户问题 user_query = "北京现在适合穿短袖吗?" print(f"[user] {user_query}") print("[model] 生成技能调用...") # 模拟模型输出 model_output = { "name": "query_weather", "arguments": '{"city": "北京", "unit": "celsius"}' } print("[model] 调用技能: ", model_output["name"]) response = dispatch(model_output) print("[executor] ", response) if response["status"] == "success": final_answer = f"北京现在温度 25℃,天气晴,适合穿短袖。" print(f"[assistant] {final_answer}") else: print("[assistant] 抱歉,查询失败了:", response["message"]) if __name__ == "__main__": main()

跑一下这个Demo,输出大致是:

[user] 北京现在适合穿短袖吗? [model] 生成技能调用... [model] 调用技能: query_weather [executor] {'status': 'success', 'result': '北京 当前温度 25°C, 天气晴'} [assistant] 北京现在温度 25℃,天气晴,适合穿短袖。

链路已经通了。真实环境里,把"模拟模型输出"这一段替换成真实的模型API调用即可,其余结构不用动。整个设计最核心的意图是:模型只负责"选择"和"填参",执行边界、异常兜底、权限控制全部收归调度器,这样即使模型今天表现得好、明天表现得差,系统整体的稳定底线是不会破的。

4. 我踩过的坑:技能系统落地的常见问题

4.1 描述写得太笼统,模型根本不触发

这是我在项目里遇到频率最高的问题,没有之一。技能明明已经注册了,描述也写了,但模型就是不调用,甚至用户问题里已经明确提到了相关关键词,模型还是自顾自地直接回答。最开始我以为模型不行,换了好几个模型都一个样,最后才发现问题出在description太笼统。

一个反例,描述只写了"查询天气,参数城市",模型必须在看到"天气"这个词时才勉强联想到它。一旦用户问的是"现在穿什么衣服合适"这样没有出现"天气"两个字的请求,模型就不知道要触发它。解决这个问题的办法,就是把所有可能触发该技能的表述方式都写进去。我自己会在description里加一长串场景关键词,比如"用户询问当前气温、实时天气、穿衣建议、是否需要带伞、体感温度、风力大小等,都适合调用查天气技能"。这样描述长度是增加了,但路由准确率提升非常可观。

另外一个补充技巧是,在description里加一句"当其他技能都不确定是否适用,且用户问题与天气相关时,优先选择本技能"。这相当于给技能设定了一个适度的默认优先级,防止模型因"过度谨慎"而一个都不选。但注意这句话不能滥用,每个技能都写"优先选我"就等于都没写。

4.2 技能冲突与优先级排序

技能数量少的时候冲突不明显,一旦超过十个,肯定会遇到"两个技能同时能回答用户问题"的场景。我之前做一个内容生成Agent时,有个"写邮件"技能,还有个"写周报"技能,用户说"帮我写一份给客户的邮件"时模型偶尔会抽风调用周报技能,生成的格式完全不对。

这类问题的根因,是描述里的边界信息不够。我给两个技能都补充了"不要让路由到XXX技能"的反向说明,写邮件技能里明确标注"这是面向外部客户的商务邮件,不是内部周报,如果用户提到周报、日报、工作汇报,请使用写周报技能"。修改之后,冲突出现的概率大幅下降。

还有一个更稳健的办法:给技能加一个priority字段,路由层在处理模型输出时,如果发现模型的调用结果明显与另一个高优先级技能更匹配,可以直接纠偏。不过这种干预需要慎用,它本质上是建立在规则引擎之上的修正逻辑,维护成本不低。我建议先把描述改清楚,priority作为一种最后兜底的手段。

4.3 参数校验与模型乱传参

模型乱传参的现象,主要发生在参数Schema写得不够严格的时候。举个例子,参数里"city"定义成字符串,但不告诉模型有哪些常见取值,模型生成时可能传一个英文拼写、一个简写、甚至一个不存在的城市名。更常见的坑是布尔类型参数,模型有时候传"true"字符串,有时候传True布尔值,执行层不做兼容就会炸。

我的做法是三层防线。第一层,JSON Schema级约束:把enum穷举、默认值写全,required字段严格声明;第二层,调度器级校验:在执行前用jsonschema库对模型返回的参数做批量校验,不合格的直接返回错误消息,不让它进入业务代码;第三层,函数内部防御:业务代码里对关键参数做类型转换和安全判断,哪怕前两层被绕过,也不会造成严重副作用。这三层叠加下来,模型再怎么发挥,系统都不至于崩。实测中最容易出问题的参数类型是布尔值和枚举值,写参数Schema时务必把这两类处理到位。

4.4 安全边界:给技能限权

技能系统一旦开放给真实用户使用,安全问题比稳定性问题更重要。一个能调数据库查询的技能、一个能发HTTP请求的技能、一个能读写文件的技能,如果权限不做限制,用户完全可以诱导模型调用技能去做越权操作。不要高估模型的安全性,它本质上只负责"执行指令",不会替你做安全判断。

我建议每个技能声明permissions字段,并在调度器里做一个粗略的权限校验:技能能访问哪些资源、允许哪些操作,都明确列出来,运行时检查当前用户或当前会话是否具备对应权限。对于高风险技能,比如发送邮件、执行删除操作、调用付费接口,还要加一道"用户确认"流程,在真正执行前向用户展示将要执行的动作,获得确认后才往下走。另外,技能内部涉及外部请求时,对目标地址做白名单校验也是一定要做的,不然模型被诱导填一个内网地址,再配合一个能发HTTP的技能,问题就大了。

顺带提示一下,技能的异常信息不要原样回传给模型。之前我踩过一个坑,某技能抛出的异常里包含了数据库表名和结构信息,模型拿到了之后,后面的对话里就一直在胡编SQL。异常信息应该在调度器里被拆成"能告诉模型的简化信息"和"进日志的详细堆栈"两部分。这也是安全边界里容易被忽略的一环。

5. 进阶建议:怎么把技能系统做得更扎实

5.1 技能版本管理与观测

技能系统跑起来之后,版本管理和可观测性是决定它能不能长期维护的关键。技能不是一次性写完就不动的,业务规则一变,技能逻辑就要调整,前端Prompt里的描述也要同步更新。如果不做版本管理,经常会出现"代码已经改了,注册中心还是旧的""某个请求走得还是线上一个已废弃版本"这种混乱局面。

我在实践中给每个技能都加了单独的日志空间,记录技能名、版本、调用时间、参数摘要、返回状态、耗时。这样任何一个请求调了哪个技能、表现怎么样,都能追踪到。版本号的比较逻辑前面代码里已经写了,这里再补充一个习惯:每次技能描述做了实质调整,就把version的minor位加一,同时把改动原因写进变更记录。下次排查"为什么同一个技能今天的选择率比昨天低"时,这份记录能直接帮你锁定是描述改动导致的还是外部环境导致的。

5.2 技能的组合与编排

单个技能的能力是有限的,实际业务中经常需要多个技能协作才能完成一个完整任务。举例来说,"穿衣建议"这个任务,一个好的处理流程是:先调天气技能拿到实时温度,再调生活技能拿到紫外线指数,最后让模型基于这些真实数据生成建议。这种多技能协作的形态如果完全交给模型的自由发挥,稳定性堪忧,因为模型可能调完第一个就不知道怎么串了。

我的建议是对于有固定流程的场景,显式定义"工作流"而不是让模型自由编排。在技能系统里可以区分两类能力:一类是直接暴露给模型的原子技能,另一类是内部使用的编排技能。编排技能的代码里会依次调用多个原子技能,对中间结果做汇总,最后输出一个整理好的答案给模型。这样模型只需要面对"穿衣建议"这一个入口,复杂度被封装在编排层里,链路好控制得多。等一个编排技能被验证稳定之后,可以从技能列表里把它隐藏掉,不让模型直接看到内部细节,减少干扰。

5.3 并发控制与性能开销

最后聊一个偏运维的话题:并发与性能。技能系统虽然看起来只是"调一个函数",但真实场景下,技能背后可能是外部API调用、大数据量查询、甚至写操作。如果模型的思维链里循环调用同一个技能几十次,系统压力会非常大。我之前就遇到过一次事故:模型在一个多轮对话里疯狂调某个统计接口,直接把下游服务打到限流。

解决办法是给调度器加上限流和熔断能力。具体来说,每个技能可以配置每分钟最大调用次数,超过之后执行层直接返回"技能太忙,请稍后再试",把保护动作放在所有请求的最前端。另外,对单个技能的执行做最大耗时限制,超时后中断并返回错误,避免一个技能的异常拖垮整个Agent响应。还有一个工程小细节:技能执行过程要设计成可以中断的协程或者带超时控制的线程,否则一个卡死的技能调用会阻塞后续所有对话。

性能开销这块,最耗时的往往不是技能本身的执行,而是"技能列表渲染进Prompt"这一步。每次请求都要遍历注册中心、格式化技能描述、拼接成模型能读的文本。如果技能数量多,建议对这一步做缓存,技能列表在短时间内没有变化时直接复用上一次的渲染结果,能省下不少处理时间和token。

我在实际使用中还有一个体会:技能系统的演进是一个持续打磨的过程,不要指望第一版就完美。先把注册、调度、校验这条主轴搭好,再在运行中通过日志和路由准确率不断调整技能描述,效果会越来越好。最后一个私藏的小技巧,给每个技能的description里都补一句"什么时候绝对不要用",这句话对阻止模型乱选技能的贡献,比任何优先级策略都大。

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

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

立即咨询