☰
AI Agent技能系统设计实战:从工具调用到可治理的契约
2026/10/14 8:33:20 网站建设 项目流程

如果你最近在折腾AI Agent,大概率会遇到一个绕不开的问题:模型怎么知道在什么场景下该调用哪个能力?调用完之后,结果怎么反馈给模型,让它决定下一步?这两个问题,本质上不是模型能力的问题,而是你给Agent搭的那套“技能系统”设计得好不好的问题。

我接触过不少团队,第一版Agent都是把“工具清单”直接塞进系统提示词里,提示词写得比论文还长,模型一旦遇到参数拼错、结果解析失败,基本就只能瞎编。后来做多了才发现,真正能让Agent稳定干活的办法,是把能力做成一套可注册、可编排、可审计的“技能系统”。这篇博文,我想用自己的实操经验,把agent-skills这套系统的设计思路、核心实现和踩坑记录拆开讲一遍,希望能给正在搭Agent底层能力的人省掉几个通宵。

1. 为什么要给Agent单独建一套技能系统

1.1 技能不是“函数列表”,而是模型与世界的契约

很多人对Agent技能的第一个误解,就是觉得技能系统无非是把函数注册一下、让模型能调用。但实际上,技能系统的服务对象有两个:一个是模型,一个是执行环境。模型需要知道“有什么可用、参数怎么填、结果怎么理解”;执行环境需要知道“这段调用是否安全、有没有权限、日志怎么留”。

我用一句话来定义:技能,是模型与外部世界之间的一份结构化契约。它不只告诉模型“你有个工具可以用”,还要告诉模型“在什么场景下用、用了之后你能得到什么”。如果你的技能定义里缺少“场景说明”和“结果语义”,模型就会靠猜来调用,猜错了就给你抛一堆莫名其妙的参数。

在实际项目里,我见过最典型的现象是:同一个动作,有人把“获取订单详情”这个技能描述写成了“查询订单信息”,另一个技能“查看订单物流”的描述里又写了“获取订单最新状态”。结果模型在两个技能之间反复横跳,三十轮对话都定位不准用户要的是什么。后来我们把每个技能的描述改成了“在什么意图下优先使用、输入里必须包含哪些信息、输出会包含哪些字段”,准确率立刻上来了。

1.2 把能力做进提示词,为什么不可持续

有不少同学会问:既然模型有推理能力,我为什么不直接把所有功能说明塞进系统提示词里,让它自己挑?原因有三个:

第一,提示词长度是硬约束。一个稍微完整点的业务系统,至少几十个技能,每个技能的参数说明、用例、注意事项写全了,加起来轻松超过模型上下文约束。强行塞进去,不仅费用飙升,模型还会“注意力稀释”,越后面的技能越容易被忽略。

第二,提示词没有执行语义。你在提示词里写了“调用OrderApi.GetOrderById”,模型可以照抄这个名字,但它不知道这个调用的返回值是JSON还是纯文本,不知道字段名是什么,更不知道失败时该重试还是该换技能。说白了,提示词里的工具,对模型来说只是“名字”,不是“能力”。

第三,提示词没有办法做鉴权、限流、审计。你很难在提示词层面控制某个技能“只在用户授权后调用”,也没法在模型幻觉式调用时拦住它。技能系统则可以把这些约束收敛到统一入口里,形成一个可控的边界。

2. 技能系统的整体设计与思路拆解

2.1 技能的三层抽象:描述层、执行层、治理层

我在设计技能系统时,喜欢把整个架构拆成三层,对应三个不同关注点。理解这三层,后面写代码时思路会非常清晰。

第一层是描述层,面向模型。它解决“模型如何感知技能”的问题。每个技能要有一个唯一的标识、一段精准的用途说明、一套参数Schema,以及一个结果结构的样例。这层的数据最终会转成模型能读懂的格式,比如通过工具调用协议里约定的JSON结构传给模型。

第二层是执行层,面向系统。它解决“技能如何跑起来”的问题。每个技能背后是一个可调用的函数或接口,接收参数、执行逻辑、返回结果。这一层需要关注的是:参数校验、超时控制、异常捕获、结果格式化。

第三层是治理层,面向开发者和运维。它解决“技能如何使用才安全可靠”的问题。包括技能注册、版本管理、权限控制、调用审计、动态启停。这一层在原型阶段容易被忽略,但到生产环境就会反复找补。

这三层之间用接口隔离,描述层不直接依赖执行层的内部实现,治理层也不侵入执行层逻辑。这样设计的好处是:你可以替换某个技能的执行逻辑,而不影响模型端的感知;也可以临时下架一个有问题的技能,而不需要重新发版。

2.2 设计目标:可插拔、可观测、可容错

我在设计技能系统时,给自己定了三个硬性目标,所有方案取舍都围绕这三个词展开。

可插拔:技能和技能之间不互相依赖,新增一个技能只需要注册一份描述和对应执行体,不改动已存在的其他技能。实现这个目标,要靠统一的技能接口和注册机制,避免“为了调用B技能,必须先调A技能取token”之类的隐式耦合。

可观测:每一次技能调用都能追溯到“谁在什么上下文里调用了什么、传了什么参数、返回了什么、花了多久”。这个目标直接决定了日志结构的设计。我会把每次调用生成一个TraceID,连同模型推理ID、会话ID一起记录下来,否则排查“用户说问题复现不了,但日志里都是一堆抽象报错”时,你会非常痛苦。

可容错:技能调用必然会有失败,设计上要默认失败是常态,而不是例外。容错不是简单的try-catch,而是要有明确的失败语义:是参数错了,是依赖服务超时了,还是技能本身逻辑出错了?不同失败类型,模型端的应对策略完全不同。

3. 技能定义:让模型“看得懂、调得准”

3.1 一段合格的技能描述长什么样

技能描述是给模型看的说明书,它直接决定模型在“多个技能都能干类似的事”时会不会选错。我总结过一套写法,核心是四个要素:触发场景、输入约束、输出语义、边界声明。

给你看一个我常用的JSON Schema式技能定义:

{ "name": "query_order_status", "description": "查询订单当前状态。当用户想了解订单是否发货、是否签收、拦截或退款进度时使用。查询前必须能从对话中提取订单编号,若用户未提供订单号,应向用户索要。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单编号,格式通常为18位字符,必填。", "pattern": "^[A-Z0-9]{18}$" } }, "required": ["order_id"] }, "result": { "type": "object", "properties": { "status": { "type": "string", "enum": ["created", "paid", "shipped", "signed", "cancelled"] }, "latest_event_time": { "type": "string" } }, "example": { "status": "shipped", "latest_event_time": "2024-06-01T10:00:00Z" } } }

这段描述里,最关键的是description字段。我见过太多人把描述写成“查询订单状态”,完事。这种写法等于没写,因为模型不知道什么场景该用它,也不知道参数信息不完整时该怎么处理。我的经验是,描述里至少要包含“触发条件”和“参数不足时的处理方式”这两类信息,命中率会有质的提升。

另外,result字段里尽量放一个“输出示例”。模型看到实际返回结构的例子,比看抽象字段描述要可靠得多。很多模型在解析返回结果时,会优先参考示例结构来理解字段语义。

3.2 参数Schema设计:命名、校验与默认值

参数Schema是整个技能系统里最容易出错的地方。模型会在推理时尝试把对话信息填入这个Schema,如果Schema写得含糊、类型不对、必填逻辑与描述矛盾,模型就会开始“发挥创造力”。

我踩过几个比较典型的坑,列出来供参考:

第一,参数名尽量用全称、少用缩写。order_id比oid好,customer_phone比cp好。模型不傻,它更倾向于把自然语言里的词映射到参数名上,参数名越直白,抽取准确率越高。

第二,给每个参数加正则或枚举约束。很多内置模型接口对参数格式的约束很弱,你不写pattern,它就给传五花八门的格式。比如日期,模型可能传“2024-06-01”,也可能传“2024年6月1日”,还可能传“June 1, 2024”,没有约束,你的执行层就得收三份格式的烂摊子。

第三,区分“模型必须抽取”和“允许模型自行推导”的参数。有些参数不能从对话直接得到,比如当前时间、用户ID这种上下文信息,我会在描述里写明“由执行层自动注入,模型无需填写”,否则模型会自作主张编一个时间或ID出来。这属于很隐蔽的坑,我刚开始做时经常因为模型乱填时间戳导致查询错数据。

参数校验不只是否能过,还要校验“业务关系”。比如order_id和customer_id,单独看都合法,但订单不属于该用户,这属于业务冲突,需要在执行层做绑定校验,否则就会产生越权数据查询。

4. 注册、装载与编排

4.1 技能注册表:从静态配置到动态装载

技能注册表是整个系统的中枢。我第一版做的是把所有技能写在一个Python字典里,死代码,每次上线都要发版。后来技能多了之后发现不对:你不可能因为修正一个技能描述就整个重发一次服务,太蠢了。

后来我改成“技能注册表 + 零散技能模块”的结构。每个技能独立成一个文件或一个目录,注册表负责扫描、加载、校验、索引。流程大概是:

  1. 启动时扫描技能目录,加载每个技能的元信息(描述、参数Schema、执行体入口);
  2. 对技能元信息做合法性校验,比如name是否唯一、parameters是否是合法JSON Schema;
  3. 把合法技能写入内存索引,同时把描述信息丢给模型接口做初始化解析;
  4. 支持运行时热加载:检测到新技能文件或配置变更,增量更新索引,不中断现有会话。

这里有一个我在生产中总结的经验:技能名要纳入版本管理。同一个技能如果描述改了,模型端的感知会话和已下发缓存可能会不一致。我会在技能名后面跟上版本号,比如query_order_status_v2,改版时新增而不是覆盖。虽然看起来冗余,但能省掉大量“模型还在按旧描述调用,新逻辑已经变了”的疑难杂症。

4.2 技能编排:先串行,再并行,最后回退

单技能调用是基础,但真实业务里,Agent很少只调一个技能。更多情况是“先查订单状态,再判断可不可以发起拦截申请”(串行),或者是“同时查价格和库存两个独立系统”(并行)。

我的编排设计分三步走。

第一步,把所有技能调用包在一个标准执行单元里,单元输入是(skill_name, params, trace_id),输出统一是SkillResult。SkillResult里包含ok、data、error_code、error_message,无论底层实现是什么,上层都只看这一个结构。

第二步,实现串行编排。前一个技能的结果作为后一个技能的输入条件。这里要注意:模型通常不擅长精确处理超大JSON,所以串行时,我会把前一个技能结果中的关键字段先抽取出来,转换成结构化参数再传给下一个技能,而不是把整个大JSON丢给模型让它自己挑。

第三步,并行编排和回退。多个独立技能可以一次性发起并行调用,哪个先返回都可以用;当主技能失败时,我可以把备用技能作为回退目标。这套逻辑不能全丢给模型做决策,模型做“是否重试”这种判断是可以的,但让它处理“并发超时”“依赖降级”这种系统级问题就不太理智了,应在编排层明确写死策略。

5. 实操:最小技能系统的完整实现

5.1 核心代码结构

我给你看一个我实际用过的精简版实现,尽量保留关键逻辑。用的是Python伪代码风格,你换成任何语言思路都是一样的。

# skill_interface.py from dataclasses import dataclass from typing import Any, Optional, Callable @dataclass class SkillMeta: name: str description: str parameters: dict version: str = "v1" auth_level: str = "normal" # read_only / normal / admin @dataclass class SkillResult: ok: bool data: Optional[Any] = None error_code: str = "" error_message: str = "" class BaseSkill: meta: SkillMeta def execute(self, params: dict, context: dict) -> SkillResult: raise NotImplementedError def validate_params(self, params: dict) -> None: # 这里可以用jsonschema库做参数校验 pass
# registry.py class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: BaseSkill): meta = skill.meta if meta.name in self._skills: raise ValueError(f"duplicate skill: {meta.name}") skill.validate_params.__annotations__ self._skills[meta.name] = skill def get(self, name: str) -> BaseSkill: return self._skills.get(name) def all_metas(self): return [s.meta for s in self._skills.values()] def reload(self, skill: BaseSkill): # 热更新:同名技能直接替换,不重启 self._skills[skill.meta.name] = skill
# executor.py def execute_skill(registry, name, params, context): skill = registry.get(name) if not skill: return SkillResult(ok=False, error_code="SKILL_NOT_FOUND", error_message=f"skill {name} not found") try: skill.validate_params(params) result = skill.execute(params, context) return result except ParamsValidationError as e: return SkillResult(ok=False, error_code="PARAM_INVALID", error_message=str(e)) except TimeoutException: return SkillResult(ok=False, error_code="TIMEOUT", error_message="skill timeout") except Exception as e: return SkillResult(ok=False, error_code="UNKNOWN", error_message=f"{type(e).__name__}: {e}")

这个结构看起来不复杂,但它把几个关键问题都解决了:

  • 技能通过SkillMeta暴露给模型,模块内部实现完全隔离,方便并行开发和测试;
  • 参数校验统一放进validate_params里,不依赖模型侧自觉;
  • 执行层统一捕获异常,并在SkillResult里明确错误码。模型看到SKILL_NOT_FOUND、PARAM_INVALID、TIMEOUT时的处理策略,跟你设计技能系统的兜底机制直接挂钩。

5.2 执行上下文与参数注入

一个经常被忽略的点:很多技能需要的参数并不来自用户对话,而来自会话上下文。比如用户身份ID、当前会话的语言、用户所在时区等。如果这些参数也靠模型抽取,结果一定是灾难,因为模型根本不知道当前登录用户是谁。

我的做法是,在执行前把context对象传进去,由执行层自动填充这些隐式参数。注意,自动填充的字段不能出现在模型要处理的参数Schema里,否则模型会试着填一份。要让模型把精力集中在它真正该抽取的内容上。这就是“参数分层”:对话参数由模型抽取,系统参数由执行层注入。

我给这段逻辑配了个断言:技能执行的params里,凡是标记为source: context的参数,若不在context里,直接返回PARAM_MISSING_CONTEXT,绝不让模型撒谎补上。

5.3 技能返回结果的结构化

技能执行完,返回的数据不能是“裸数据”。模型也好,编排引擎也好,都需要一个统一的结果视图。我强烈建议在SkillResult.data里固定两层结构:第一层是content,放业务上要给模型看的信息;第二层是biz_data,放执行层内部需要但模型不需要的原始数据。

举个小例子:一个查询库存的技能,content是“当前库存仅剩3件”,biz_data里是原始库存记录列表。模型读content就够了,后续如果要走“低库存预警”逻辑,编排层也能直接从biz_data取数,不用再解析自然语言结果。

6. 权限、审计与安全设计

6.1 技能分级与调用白名单

技能系统上线之后,安全边界比功能本身更重要。我现在会在SkillMeta里加一个auth_level字段,分成read_only、normal、admin三级。

只读技能(查天气、查百科、查订单状态),任何对话语境下都能调用。普通技能(发站内信、领优惠券),需要用户已登录,而且限制频次。管理类技能(改价格、删除数据、导出用户信息),除了要登录,还要校验当前会话是否有对应权限令牌。这层校验不是模型该管的,模型只会生成“调用该技能”的意图,真正拦不拦得住,完全是执行层和治理层说了算。

有个很实用的细节:白名单除了按技能级别拦,还要按“技能组合”拦。比如“导出用户信息”这技能本身可能没问题,但如果同时调用了“查询用户最近订单”和“按手机号查询用户”,组合起来就是一次隐私数据拼装。我的做法是在编排引擎里加了个简单的组合规则检查,发现敏感技能组合就阻断并告警。

6.2 审计日志:必要字段与抽样策略

每次技能调用,都应该记一份结构化日志。我必记的字段有这些:

  • trace_id(一次对话或多个技能调用的串联ID)
  • session_id、user_id(谁调用的)
  • skill_name、skill_version(调用了什么)
  • params概要(只记录非敏感参数,手机号、地址这类要脱敏或只记录哈希)
  • result状态、耗时、错误码
  • 模型推理ID(可以回查到模型当时的完整推理过程)

日志不用全量保存原始参数,但至少要保留足够定位的摘要。我遇到过一个线上问题:某个用户反馈Agent答非所问,排查时发现某个技能被连续调用了八次,参数几乎一样,返回全是错误。光看日志里的错误码不够,还得能通过trace_id把八次调用的时间线拉出来,才发现是重试逻辑写成了死循环。没有trace_id,这种问题只能两眼一抹黑。

还有一个经验:日志的检索索引不要建立在业务字段上,而应该建立在skill_name + error_code + trace_id上。因为排障时你往往是从“某技能报错”或“某次会话异常”切入的,不会先知道用户ID。

6.3 并发与一致性的坑

技能系统一旦并行调用,就会遇到共享状态问题。尤其是“读改写”类技能,比如先查库存、再扣库存、再更新订单状态。这类技能如果支持并行编排,很容易出现超卖或状态覆盖。

我的建议是:技能本身默认无状态,有状态更新的技能要么串行执行,要么在技能内部加分布式锁。最方便的做法是给技能接口增加一个lock_key属性,编排引擎看到这个属性时,会自动给相同lock_key的技能调用排队,而不是并行执行。这个机制实现成本很低,但能提前规避大量并发脏数据问题。

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

这片我直接放一个速查表,都是实际踩过的坑,欢迎对号入座。

现象常见原因处理技巧
模型总是选了错误的技能技能描述过短或重叠度高在描述里写清触发场景;重叠技能之间增加排他性说明
参数总是多传一些奇怪字段自定义的JSON Schema约束不够,模型在“发挥”为每个参数写pattern和enum,必填字段收严
技能执行失败后模型反复重试同一次调用超时后错误码不明确,模型不知道是该换技能还是重试用TIMEOUT和PARAM_INVALID区分错误语义,并在描述里写明“超时后请告知用户稍后重试”
返回结果被模型理解错没有给输出示例,字段含义模糊在result里加example字段,尽量让字段名为全称
热加载新技能后模型还是调不到技能索引更新了,但模型端的协议缓存没刷新设计技能版本号,下发技能清单时强制刷新
并行编排时A技能结果传给B技能时字段对不上直接把原始JSON塞给模型让它转参数由编排层先把A结果的关键字段抽取成B需要的结构化参数
审计日志里模型调用次数远超业务实际模型在循环执行一个失败技能且没有中止策略编排层设最大重试次数,超过后进入兜底话术流程

7.1 排查技能调用异常的黄金流程

线上出问题时,我习惯按这个顺序排查,效率还挺高。

第一步,看TraceID。先确认是单技能失败还是整条编排链失败,再看失败发生在哪一跳。如果是单技能失败,直接看该技能的错误码和耗时。

第二步,看参数。对某个技能报PARAM_INVALID时,把实际收到的params和Schema约束拉出来对比。很多时候是模型把时间字段传成字符串,本地测试没事,线上全是格式问题。

第三步,看上下文。把当时模型拿到的那份技能元信息导出来,和当前线上的注册表做一次diff。很多诡异问题都是因为线上技能描述和模型配置文件不一致导致的。

第四步,看返回结果。技能执行成功但模型理解错,多半是返回结构太复杂。解决方法是简化content字段,只把核心信息明文拼成自然语言段落。

7.2 技能系统的压力测试怎么做

技能系统的压力测试,不只测每秒能调多少次,更要测“模型同时发来100个技能调用请求时,系统会不会被拖垮”。这里我建议关注四个指标:执行并发数、超时熔断阈值、错误码分布、模型端响应耗时。

我做过一个比较实用的压测脚本思路:模拟一批用户在对话中连续触发同一技能,在编排层故意让该技能返回TIMEOUT,观察整个系统有没有进入“雪崩”。如果压测时发现某个技能超时后,等待队列被占满,那就要做两件事:给技能调用线程池设独立上限;超时后的重试使用指数退避,而不是固定间隔。

第五步,加防御性中断。我通常会为每个技能执行器加一个全局熔断开关,当某个技能错误率超过50%时自动熔断,降级回退话术“暂时无法查询,请稍后再试”。这个开关单独设一个接口,不用重启服务就能掰回来。

8. 最后分享一点实际经验

技能系统这东西,说实话,刚开始做时谁都嫌麻烦:明明写个函数让模型直接调用就行,为什么非要包一层注册表、加一堆Schema、搞什么审计日志?但等你真正把一套技能系统跑上生产,经历了几次线上事故之后,就会明白这层“麻烦”的价值。

我做第一版技能系统时,技能描述写得很随意,连参数校验都懒得加,结果线上最多的报错不是“模型不会调用”,而是“模型瞎调用”:该填订单号的地方填了手机号,该用日期范围参数的地方传了个字符串。后来花了一个周末把所有技能的Schema补全、描述重写、加上严格校验,调用成功率肉眼可见地从七成提到了九成五以上。

如果你想从零开始做自己的技能系统,我建议别一上来就追求大而全的架构,先写五个技能跑通全链路,把注册、执行、失败回传这三角色练熟,再逐渐加编排、加治理。这套系统的核心价值不在于“复杂”,而在于“稳定”:让模型在可控范围内发挥,而不是把不确定性全部丢给生产环境。

最后提一个小技巧:每次新增技能前,先跑一遍你自己的模型测试用例,确认模型能根据描述正确选择到这个技能。如果连你自己都觉得描述模棱两可,模型大概率也会选错。技能描述不是写给同事看的,是写给模型看的,这句话你写几次技能就会深有体会。

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

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

立即咨询