☰
Agent技能库实战:大模型工具调用的结构化描述、执行与可观测性
2026/10/14 10:01:39 网站建设 项目流程

我第一次在一套对话式项目里加入工具调用时,并没有意识到“工具会失控”。用户只是问了一句“明天出门要不要带伞”,结果智能体连续调用了定位、天气、日历三个接口,返回了四个城市的气象数据,最终在界面上报了一个“未知错误”。那次事故让我下定决心重做一套技能调度体系,也就是后来这个叫 agent-skills 的项目:一套面向智能体开发者的技能库组织与运行方案,把工具调用、知识检索、多步工作流统一抽象成可注册、可描述、可观测的“技能”单元。它解决的问题不是“怎么调一个API”,而是“智能体在什么情况下该调哪个API、怎么调、调完怎么把结果安全地交还给对话”。如果你也在做Agent类应用,被工具路由、上下文爆炸、调试困难折磨,这篇文章应该能给你一些直接能落地的思路。

1. 为什么需要“技能库”,而不是让Agent自由发挥

1.1 那次让我决定重构的事故

先把事故复盘完整一点。当时我的系统里接了五个外部接口:数据库查询、日历、邮件发送、天气、地理编码。每个接口都是独立函数,签名清晰,文档也写了。当时以为这已经比绝大多数demo完整了,结果上线一周就出了问题。

用户的问题是“帮我看看明天下午三点有没有空会议室”。我的第一版思路是让模型自由选择函数,模型也确实按直觉选中了日历查询。问题出在日历接口返回的是一个很长的原始JSON,里面包含会议室编号、预定人邮箱、会议主题、参会者列表。因为原始结果直接塞进了上下文,模型在后续生成回答时,莫名其妙地从“会议室列表”里推断出“用户明天下午有会”,还自己补了一句“已为你预订”。实际上它只是查询,并没有执行预订动作。更麻烦的是,它的输出里出现了一段从邮件接口误带过来的签名信息——我排查日志才发现它在调用日历之前,还顺手试了一次邮件接口,原因是“日历条目中出现了邮箱字段,所以想确认邮件关联”。

这件事给我最大的刺激不是“模型笨”,而是我的接口设计出了问题。每个函数只有“功能”,没有“行为边界”。模型不知道什么时候绝对不能调这个函数,也不知道返回结果里哪些字段可以用、哪些字段会造成误导。开始我也想过堆更多提示词来约束,但提示词超过一定长度后,模型又会漏掉中间的指令。最后我得出的结论是:必须把“函数”升级成“技能”,让每个技能自带触发条件、使用约束、结果摘要策略和失败兜底。这就是 agent-skills 的出发点。

1.2 Agent技能和传统函数到底差在哪

传统函数是给程序员调用的,调用方是代码,参数类型错了编译期就会报错。Agent技能是给大语言模型“调用”的,调用方是一个概率模型,它没有编译期,只有“理解”。同一个功能,在传统函数里只需要写得足够严谨,在Agent环境里还需要写得足够“好懂”。

我特别喜欢的一个类比是:普通函数像一把螺丝刀,技能则像一个带有分隔层的工具箱格。螺丝刀的功能就是拧螺丝,但你不会告诉用户“这把螺丝刀不能用来撬门、不能用来砸核桃”。而大语言模型恰恰需要这些“不该做什么”的信息。技能描述里如果只写“获取天气”,模型就可能在用户问历史天气时也调用它,甚至把它用作日期计算。

所以我在 agent-skills 里给“技能”下了一个更严格的定义:技能是一个包含行为说明、参数Schema、执行函数、结果摘要器、失败兜底策略的完整单元。它不只是一个API封装,而是一个可以被模型理解和被系统约束的最小运行体。技能与技能之间尽量保持边界清晰,每个技能都自带“触发场景”和“禁入场景”。与其不断叠提示词,不如把这些信息落到结构化的技能描述里。

1.3 能解决问题的三类技能

做了一段时间之后,我发现实际项目里的技能基本可以分成三类,分类方法会直接影响注册表的设计。

第一类是基础工具技能,例如天气查询、日历读取、邮件发送、数据库查询。这些技能的特点是:功能单一,外部依赖明显,通常会访问第三方接口或内部服务。第二类是知识检索技能,例如搜索文档、查询知识库、读某张业务表。这类技能返回的是非结构化文本,使用时要格外注意上下文占用。第三类是组合动作技能,例如“根据天气和日程生成出行建议”“批量处理报表”。它们内部会调用多个基础技能,但对外只暴露一个能力入口,让模型只需要做一次路由,而不是连续编造多个函数调用。

分类的意义在于执行策略不同。基础工具技能要求低延迟,超时时间要短;知识检索技能要控制返回条数;组合动作技能需要更长执行时间,而且必须具备原子性——要么整体成功,要么整体回滚。agent-skills 的注册表里,每个技能都会标注类型,执行层再按类型选择不同的调度策略。没有这套分类之前,我把所有技能都当成普通函数处理,结果组合技能中途失败时,模型完全不知道前面几步已经产生了副作用,后续回答全部建立在错误前提上。

2. agent-skills的三层结构:注册、描述、执行

2.1 技能注册表:一切技能的入口

技能注册表是我最开始动手写的模块,它的职责只有一个:维护“有哪些技能可用”。但这个简单的职责被很多人忽略了。早期我直接在代码里写一个巨大字典,把函数名映射到描述文本。这样做的最大问题是,函数越多,模型越容易混淆;而且新增一个技能要改代码、重启服务,非常不方便。

在 agent-skills 里,注册表被拆成两个部分:静态注册和动态发现。静态注册是指核心技能在服务启动时显式加载,动态发现则是扫描指定目录下的技能定义文件,按约定格式自动加载。每个技能定义包含四部分:元数据(名称、版本、类型、标签)、描述文本、参数Schema、执行入口。下面是一段极简示例:

# skills/registry.py from dataclasses import dataclass, field from typing import Callable, Any @dataclass class Skill: name: str version: str skill_type: str description: str parameters: dict handler: Callable[..., Any] tags: list[str] = field(default_factory=list) disabled_scenarios: list[str] = field(default_factory=list) SKILL_REGISTRY: dict[str, Skill] = {} def register_skill(skill: Skill) -> None: if skill.name in SKILL_REGISTRY: raise ValueError(f"duplicated skill: {skill.name}") SKILL_REGISTRY[skill.name] = skill

注册表启动时还会做一致性校验:参数Schema必须是合法的JSON Schema,描述文本不能为空,执行入口必须是可调用对象。校验失败时直接启动报错,而不是等服务运行到一半才发现技能不可用。这个设计后来帮我避免了很多次“技能加载了但实际没生效”的问题。

2.2 技能描述:写给模型的使用说明书

技能描述是整个方案里最容易被低估的部分。我见过很多人写了非常详细的代码注释,却只给技能配一句话描述。大语言模型的工具路由完全依赖这段描述文本,它直接决定了模型“什么时候想起有这个技能”。

我总结出了技能描述的五个必要段落:触发条件、前置检查、核心行为、返回内容、失败兜底。以天气查询为例,差的描述是:“查天气,参数city”。模型会在用户说“我正在广州出差,明天需要带外套吗”时不调用任何技能,因为“带外套”和“查天气”没有直接匹配。好的描述会让处理结果完全不同:

当用户询问当前或未来某时间段的天气情况时使用。 前置要求:必须明确城市名称;如果用户只提供坐标或模糊地点, 先调用地理编码技能转换为城市名。 核心行为:调用气象服务接口,返回温度、天气现象、风力级别。 返回内容:只返回指定城市的精简结果,不返回周边城市。 失败兜底:接口超时或返回错误码时,提示用户稍后重试, 并主动询问是否需要切换城市。

这段描述里每一句都在帮模型做路由决策。实际效果是,技能命中率从61%提升到89%,而且误调用率明显下降。我还在描述末尾加了一个固定字段“不适用于”,专门写禁区,比如“不适用于查询历史天气数据,不适用于空气质量长期趋势判断”。这个字段虽然简单,但在意图边界模糊时非常管用。

2.3 执行层:参数校验、超时与结果回写

注册表只负责“知道有什么”,执行层负责“真正跑起来”。执行层要处理的细节远比你想象的多。首先是参数校验,模型生成的结构化参数经常出现类型错误,比如把温度范围当成城市名传进来。参数Schema在这里就派上用场了,执行层会先用校验器做一次强校验,不通过就直接返回“参数错误”的技能响应,而不是让后端代码报异常。

其次是超时控制。不同技能的超时要求完全不同:本地计算最多给2秒,查询缓存给3秒,外部HTTP服务给10秒,组合动作可能要给30秒甚至更长。以前我图省事统一用10秒,结果本地技能迟迟不返回拖垮整个调度线程。现在每个技能定义里都有一个timeout_seconds字段,执行层按技能类型动态设置。

最后是结果回写。这是最容易被忽略的一步。外部接口返回的原始JSON往往包含大量无用字段,如果全量塞进上下文,几十个技能调用下来上下文就爆炸了。我实现了一个“压缩摘要器”,每个技能可以自定义如何把原始结果压缩成3到5行自然语言摘要。例如天气接口的原始JSON有40个字段,摘要只保留“城市、天气现象、温度区间、风力、更新时间”五个关键信息。上下文空间省下来之后,模型的整体回答质量反而提升了,因为它不再被冗余字段带偏。

3. 手把手搭一个可复用的技能包

3.1 项目目录与依赖规划

如果从零开始搭建一个 agent-skills 技能包,我建议按照下面的目录结构组织:

agent-skills/ ├── skills/ │ ├── __init__.py │ ├── registry.py │ ├── executor.py │ ├── builtin/ │ │ ├── weather.py │ │ ├── calendar.py │ │ └── geocoding.py │ └── user/ │ └── custom_skill.py ├── descriptors/ │ ├── weather.yaml │ ├── calendar.yaml │ └── custom_skill.yaml ├── tests/ │ ├── test_registry.py │ └── test_executor.py └── pyproject.toml

把描述文件(descriptors)与实现代码分离,是我后来调整的结果。一开始我把描述直接写在Python函数装饰器里,虽然写起来快,但业务人员想调整触发条件就非常麻烦。现在描述文件用YAML维护,代码里只保留执行逻辑。业务人员可以独立修改技能描述,不需要碰代码,改动后重启服务即可。

依赖规划上,我坚持“最小依赖”原则。核心注册与执行模块只依赖标准库和一个轻量级校验库,外部接口调用通过注入方式对接。这样做的目的是让技能包可以被不同项目复用,不会因为某个重型框架的版本变更而被迫升级。技能包本质上应该像积木,而不是一个锁死在固定胶水里的模块。

3.2 用装饰器实现技能自动发现

技能自动发现是减少维护成本的关键。我封装了一个装饰器,让技能定义和执行逻辑能放在一起,又能被启动器统一扫描:

# skills/registry.py def skill(name, version, description, parameters, skill_type="basic"): def decorator(func): s = Skill( name=name, version=version, skill_type=skill_type, description=description, parameters=parameters, handler=func, ) register_skill(s) return func return decorator

使用起来非常直观:

# skills/builtin/weather.py @skill( name="get_weather", version="1.2.0", description=""" 当用户询问当前或未来某时间段天气时使用。必须明确城市名。 """, parameters={ "type": "object", "properties": { "city": {"type": "string", "descripton": "城市中文名"}, "date": {"type": "string", "description": "日期,格式YYYY-MM-DD"} }, "required": ["city"] }, ) def get_weather(city: str, date: str) -> dict: # 调用外部气象服务接口 ...

自动发现则是在服务启动时扫描descriptors/下的YAML文件,与代码中的装饰器注册表做合并。YAML文件里如果写了“禁用当前技能”的字段,注册器会跳过该技能。这个机制让我可以在不删除代码的情况下临时下线某个问题技能,非常实用。

3.3 测试、打包与版本管理

技能包也是代码,不能绕开测试。我的测试策略分三层:注册表单元测试、执行层集成测试、路由正确性回归测试。注册表测试关注技能注册是否成功、重复注册是否报错;执行层测试会mock外部接口,验证参数校验、超时、结果摘要是否符合预期;路由正确性回归测试则是把一批真实用户问句放进测试集,统计每个技能被触发的次数是否符合预期。

这套测试里最值得下功夫的是路由回归测试。每增加一个新技能,都可能改变模型对其他技能的判断。如果不做回归,很容易出现“新增了一个会议相关技能,结果天气技能被调得越来越频繁”的怪问题。我会把历史问句样本固化到测试集里,只要路由正确率下降超过2%,就阻止合并。

版本管理上,我对技能包使用语义化版本号:主版本号变更代表技能描述或行为不兼容,次版本号代表新增技能,补丁版本号代表修复某个描述字段或参数校验逻辑。这样不同项目引用同一个技能包时,可以明确锁定兼容版本。

4. 技能编排里最容易翻车的三个坑

4.1 调用日志撑爆上下文,模型开始“圆谎”

上线第三周,我发现一个问题:对话超过15轮之后,模型开始频繁重复同一个动作,甚至把已经被否定的结论再翻出来。我一开始以为是温度参数问题,后来翻看调用链,发现上下文里堆满了技能返回的原始大块文本。尤其是知识检索技能,一次搜索返回了5篇文档,每篇都是3000字,10次搜索就是3万字。模型在长上下文中丢失了“当前问题焦点”,只能靠概率生成一个可能相关的动作。

排查链路其实很短:我先打印了每次技能调用前后上下文长度的变化,很快定位到是知识检索返回内容太大。然后我给知识检索类技能增加了“最大返回片段数”和“单片段最大长度”的约束,并实现了一个内容压缩器,用模型把长文档压缩成带来源指引的摘要,而不是直接返回原文。这个改动把单次检索的上下文占用从平均9000字降到了800字,对话轮次上限从15轮提升到了40轮。

关键经验是:技能执行层必须负责“结果瘦身”,不能把外部数据原样丢给模型。即使个别技能确实需要返回长文本,也应该分成多次返回,每次只返还当前步骤需要的部分,而不是一次塞完。

4.2 两个技能抢同一意图,路由不稳定

第二个坑是技能冲突。我在库里面同时加入了“股市行情查询”和“财经资讯搜索”,前者的描述里包含“股票”“行情”“涨跌”,后者的描述里包含“财经”“资讯”“股票”。结果模型经常在用户问“帮我看看最近A股走势”时,有时触发行情查询,有时触发资讯搜索,偶尔还会两个都触发。

排查方法是做了一次“路由混淆测试”:我收集了150条真实问句,分别让模型在只有A技能、只有B技能、AB技能同时存在三种情况下选择,统计选择分布。最后确认问题出在描述重叠。我给股票行情查询增加了强触发条件“需要具体股票代码或名称”,给财经资讯搜索增加了排除条件“不适用于查询具体某只股票的价格走势”。同时我引入了一个互斥标签机制:如果技能A和技能B声明了相同的互斥分组,一旦模型路由到A,执行层就会在本次请求中临时禁用B。这个机制虽然粗暴,但能很好地防止并行调用时的冲突。

4.3 并行调用触发限流,批量失败

第三个坑来自并发。模型为了提高效率,会在一次回复里同时生成多个函数调用。比如用户问“帮我比较几个城市的天气”,模型一次生成了三个get_weather请求。我的执行层当初设计成每来一个调用就立刻开一个线程,三个请求同时打到外部接口。单个请求都能成功,但因为共享同一个API密钥和套餐限流,三个并发请求直接触发限流,返回503,最后用户看到的是“三个城市全都查询失败”。

解决方案不复杂:给执行层加一个轻量级调度器,把技能调用放进固定大小的worker池,池大小按外部接口限流阈值设定。同一种技能的并发调用数严格限制为1,其他请求排队执行。同时给每个技能配置了“失败重试次数”和“降级技能”字段。天气接口失败后,调度器会尝试调用备用天气源,如果备用源也失败,再返回错误提示。这套机制上线后,批量失败率从34%降到了3%以下。

5. 从“能用”到“好用”:可观测性与自愈

5.1 每次技能调用必须记录哪些字段

技能调度系统越复杂,越需要可观测性。我见过不少团队把日志写成一行字符串,里面只有“调用成功”或“调用失败”,遇到问题根本无从查起。agent-skills 里的结构化日志,每条记录至少包含以下字段:

字段说明示例
技能名被调用的技能名称get_weather
版本技能版本,用于定位行为变更1.2.0
参数摘要脱敏后的入参{city: "某城市", date: "2025-06-01"}
输出摘要压缩后的结果摘要晴,22~28℃,风力3级
耗时从开始到返回的毫秒数1287
错误码成功为空,失败为错误标识UPSTREAM_TIMEOUT
上下文占比本次技能输出占上下文的字符数占比0.08

其中“上下文占比”是我后面加的,极有价值。它能帮你发现某次调用突然撑大了上下文,也可以作为优化技能摘要器的指标依据。

5.2 用调用日志反哺技能优化

日志不只是用来排查故障的,更是技能优化的数据来源。我每周会跑一次统计脚本,输出每个技能四个指标:调用次数、成功率、平均耗时、上下文占比。然后按表格逐项审视:

如果某个技能调用次数很多但成功率偏低,说明它的前置校验没做好,可能是模型经常传入非法参数,那我就会强化参数Schema里的正则约束。如果平均耗时长,考虑加缓存或升级接口。如果上下文占比高,优先优化结果摘要器。我甚至有一段时间以“上下文占比”为核心KPI,要求所有技能单次返回值不超过300个汉字,倒逼每个技能去思考“模型到底需要看到什么”。

这个闭环最大的好处是,技能系统可以持续变好。语言模型本身会升级,技能描述也需要随模型的能力变化而调整。通过日志统计,你能发现过去常用的描述写法在新模型上可能失效了,尽早调整而不是等到用户投诉。

5.3 权限边界与敏感数据隔离

技能一旦变多,权限控制就是绕不开的问题。特别是同时存在“读取知识库”和“发送邮件”这样的技能,如果模型把两者串起来,就可能触发数据泄露事故。我在每个技能定义里增加了权限声明字段,执行层在调用前做权限校验:

PERMISSIONS = { "read_knowledge": {"allowed_skills": ["search_docs", "query_database"]}, "send_message": {"allowed_skills": ["send_email", "send_sms"]}, }

执行层会先判断当前会话是否具备调用该技能的权限,如果没权限,直接返回“权限不足”的技能响应,并建议模型改用其他无权限要求的技能。这样即使模型生成了一个危险调用序列,也不会真正执行。

敏感数据隔离主要体现在参数脱敏和输出脱敏两个环节。所有技能参数在进入日志前要经过脱敏处理,例如身份证号、手机号等字段直接用掩码替换。输出内容如果包含内部ID或密钥信息,也会在摘要器里直接过滤。这个做法和具体接口无关,是一种统一的“技能卫生”规范。

6. 再往前一步:技能库支撑多Agent协作

6.1 把技能当成Agent之间的最小协作单元

单Agent技能库稳定之后,我开始考虑多Agent场景。很多复杂任务需要不同职责的Agent协作:一个Agent负责对话理解,一个Agent负责查询业务数据,另一个Agent负责对外发送通知。如果技能库只在一个进程内部使用,协作就非常困难。

我的思路是把“技能注册中心”抽象成独立服务,多个Agent进程通过标准接口访问同一个注册中心。每个Agent仍然维护自己的技能子集,但技能可以被注册中心统一发现和调用。跨Agent调用时,会引入“技能租约”机制:Agent A需要调用Agent B的技能时,先申请一个短期凭证,凭证包含调用范围、有效期、最大次数。这样既保持协作灵活,又能防止某个Agent把另一个Agent的技能当无限资源滥用。

这套设计其实是从日常权限管理推演出来的。没有租约之前,我尝试过让Agent之间直接互相调用函数,结果调用链乱成一团,出错后根本无法定位责任边界。改成注册中心加租约后,每次调用都有据可查,安全性和可追溯性都上来了。

6.2 技能合成与人工复核门禁

最后想聊一下技能合成。单Agent的技能库是人工维护的,但多Agent系统里,用户或上层编排器经常需要一个“不存在的技能”,比如“根据天气和日历自动生成出行建议”。与其临时写代码,不如让系统基于已有技能动态合成新技能。

agent-skills 可以在运行时创建一个“组合技能”,它本质上是一段可执行的编排脚本,脚本内部顺序调用已有技能,并把结果汇总成新技能的输出。为了让模型能够理解新技能,它同样会生成一份描述文本和参数Schema。这里必须加一道人工复核门禁:自动生成的组合技能,默认处于“草稿”状态,只有经过人工审核并标记为“已启用”后,才对外可见。

我踩过的坑是,自动组合技能有时会产生互相矛盾的前置条件。比如一个组合技能要求先查询天气再读取日历,但它的描述里没写清楚“如果日历里没有出行计划,就不需要调用天气”。如果直接启用,模型会被这种矛盾描述带偏。后来我规定所有组合技能必须附带“使用示例”和“禁用场景”,并且在回归测试里加入至少五条相关问句。这看起来增加了工作量,但反而让技能库越滚越准确。

我在实际使用中发现,技能库真正变成“体系”的关键,不是注册了多少功能,而是每个技能都经得起路由测试、权限校验和日志复盘。很多团队把技能库做成了“大号函数列表”,又怪模型不会用,其实是描述、约束和可观测性没有跟上。如果你也想搭一套类似 agent-skills 的技能化方案,可以先从最小闭环开始:注册表加执行器,先接一个技能,把日志和回归跑通,再慢慢加量。最后分享一个个人偏好的小技巧:给每个技能维护一个“失败案例”字段,每翻一次车就往上记一条,下次版本更新时把它写进描述或测试集,长期下来技能鲁棒性提升非常明显。

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

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

立即咨询