☰
Agent技能系统实战:从工具调用到可维护的工程化框架
2026/10/13 17:15:42 网站建设 项目流程

上个月我把手头一个Agent项目重新整理了一遍,最大的变化就是多了一个叫agent-skills的模块。刚开始我觉得这名字太普通,但用了几周之后发现,它才是决定Agent能不能从演示品变成生产力工具的那道分水岭。后来不少朋友来问这个模块是怎么设计的,我干脆把整个思路、踩过的坑、以及一套可以直接复制的最小实现整理出来。

先说清楚agent-skills是什么。它不是某个框架的专属功能,而是一种组织方式:把Agent能够调用的所有外部能力,无论是查天气、查订单、发消息,还是跑一段计算脚本,都统一描述成带名字、带参数、带说明的“技能”,放进一个注册表里,再由一套调度器根据大模型的选择去执行。可以理解为:模型是大脑,技能系统是手脚。大脑负责决定要做哪件事,手脚负责把事真正办成。

这套内容适合谁看?如果你正在写一个会调用工具的大模型应用,或者你想把自己手头零散写的几百个工具函数整理成一个可维护的技能库,又或者只是好奇Agent内部是怎么做到“让它点击按钮它就点击按钮”的,都可以接着往下看。我不打算用太高的门槛,核心思路和实操细节都会展开。

1. 为什么需要一套“技能系统”:从裸模型到能干活的Agent

1.1 Agent能干的活,取决于技能边界

先聊一个很现实的问题:大模型本身只会生成文本。你问它“北京今天适合跑步吗”,它能凭训练数据给你编一段天气,但真让它去查当前实时温度、风力、空气质量,它做不到。这时你给它一个工具函数,比如get_weather(city),模型就可以先决策调用这个函数,拿到返回值之后再组织语言回答。

没有技能系统的时候,我自己是这么干的:写一堆if "天气" in user_input: result = get_weather()之类的关键词判断,然后人工把结果塞回提示词里让模型继续生成。项目规模小还好,一旦工具超过二十个,关键词匹配就彻底乱套。用户说“帮我看看明天去杭州穿什么”,你的关键词里可能既配到“天气”又配到“杭州”又配到“出行建议”,代码逻辑很快就变成一坨没人敢动的面条。

技能系统的核心价值,是把“让模型决定用哪个工具、参数是什么”这件事标准化。模型服务现在基本都开放了结构化调用接口:前端声明一批技能签名,模型在回答时直接输出一个结构化的调用请求,不再需要你自己写关键词匹配。也就是说,Agent能干的活,完全取决于你暴露给它的技能边界。技能越丰富、定义越清晰,Agent的上限越高;反过来,技能描述写得稀烂,模型再聪明也会像没看说明书的新员工一样乱操作。

1.2 技能系统要解决的四个核心问题

我整理技能系统时,强迫自己用四个维度去检验,每加一个技能都问一遍:它够不够“可见、可用、可控、可复盘”。

  • 可见:模型能不能在候选技能列表里准确地“看见”这个技能。技能数量越多,可见性问题越严重。
  • 可用:模型输出的参数,能不能被正确校验、转换,然后真正落到函数调用上来。
  • 可控:技能执行有没有权限边界、超时控制、限流和审批机制。这点在接真实业务系统时特别要命。
  • 可复盘:用户那次请求到底用了哪些技能、传了什么参数、执行了多久、成功还是失败,能不能全程追溯。

这四个维度里,大多数人一开始只盯着“可用”,代码跑通就以为成了。结果上线之后,模型频繁选错技能、参数校验失败、一个技能被并发调用几百次导致下游服务被打挂,又或者出了事故根本查不出是哪一步出的问题。所以我在一开始设计agent-skills时,就把这四件事全部纳入框架,而不是等出了问题再补。

1.3 为什么叫“技能”而不是“工具”或“插件”

这个名字不只是为了好听。我见过有团队把每个工具函数定义成一个“tool”,直接暴露给模型;也有团队把各种外部系统接入叫“plugin”,插件本身带了复杂的配置和UI能力。而“技能”这个词,强调的是一层更完整的行为包装:一个技能不光包含“执行函数”,还包含“这个技能在什么场景下用、参数怎么填、有哪些限制、失败之后怎么处理”。

换句话说,工具偏向描述“我能做什么动作”,技能偏向描述“我能在什么情况下、怎么把一件事做成”。以查天气为例:

  • 工具视角:get_weather(city: str) -> dict,只暴露函数签名。
  • 技能视角:一段完整的技能说明书,告诉模型“当用户询问某城市当前天气、穿衣建议、出行装备时使用,参数city必须传中文城市名,没有找到城市时返回错误码”。

这层包装看起来多写了不少字,但它解决的是Agent应用最常见的问题:模型想用工具,却不知道什么时候用最合适。后面我会专门讲技能描述怎么写,那时候你就知道多写的这些字有多值钱。

2. 技能的定义与注册:把能力变成模型看得懂的说明书

2.1 一份技能文件里该放什么

我建议每个技能用一个独立目录去存放,里面至少包含一份描述文件和一个执行脚本。描述文件的最简结构可以长这样:

name: weather_query description: 查询指定城市的实时天气。适合在用户询问当前天气、穿衣建议、出行准备时使用。如果用户没有给出城市名,先把城市列表提供给用户确认后再调用。 category: weather version: 1.0.0 timeout: 3000 parameters: - name: city type: string required: true description: "城市名,使用中文全称,例如:北京、杭州。不接受简称和拼音。" - name: unit type: string required: false enum: ["celsius", "fahrenheit"] default: "celsius" description: "温度单位,默认摄氏度"

你可能会问,这些字段是不是每项都必要?我的经验是:name、description、parameters是金三角,缺一个都会出大问题。timeout是给调度器兜底用的,后面你会看到模型傻等超时的痛苦。category在技能数量变多之后非常有用,可以作为分组过滤的维度。

关于name,我强烈建议全项目唯一,并且采用“领域_动作”的命名方式,比如weather_query、order_cancel、doc_search。因为模型在同时看到几十个技能时,名字本身就是一种记忆锚点,取一个一眼能看懂的名字,比让它读完整段描述再判断快得多。

2.2 描述文本写得越像“人话”,模型选得越准

这是我前后调了很多版最深的体会:技能描述是写给谁看的?不是写给人看的,是写给大模型看的。所以你写的描述必须包含决策信息:什么场景用、什么场景不要用、参数的坑在哪里。

举个反例。我早期给搜索技能写的描述是:

description: 提供搜索服务

然后模型在用户问“今天新闻有哪些”的时候,真的调搜索了;在用户问“2加2等于多少”的时候,也调搜索了。它根本分不清什么时候该搜,什么时候不该搜。后来我改成:

description: 搜索互联网资源,适合用户需要获取实时信息、外部资讯、专业知识或不确定事实时使用。 不适合:简单的数学计算、常识性问题、用户明确不要联网的内容。

加了“不适合”之后,误调用率明显下降。这个技巧非常推荐:每个技能描述都写清楚“什么时候别用我”,作用等于给模型画了一条减速带。

2.3 注册中心:命名空间、分类与版本

当技能数量超过五十个,你就不能只靠一个数组硬扛了。我的做法是引入注册中心,启动时扫描技能目录,建立三层索引:

  • 按命名空间索引,例如weather.*、order.*、doc.*,方便做权限控制;
  • 按标签索引,例如read_only、needs_approval、high_frequency,方便调度前做筛选;
  • 按版本索引,同一个技能可以存在v1、v2,路由时按配置灰度切换。

技能多了以后还会遇到命名冲突。比如两个团队各写了一个send_message,我要求所有技能必须带命名空间前缀,内部注册名类似im.send_message、sms.send_message,避免互相覆盖。否则后加载的那个会把先加载的顶掉,线上模型可能突然调用到一个错误实现。

2.4 参数定义决定调用成功率

模型选择技能之后,下一步是把自然语言里的关键信息映射成参数。这一步特别容易翻车。我有一次测试,技能order_query的参数order_id被模型填成用户说的“我上个月买的那个黑色耳机”,而不是订单编号,结果查询接口直接报错。

后来我把参数定义改得更细,每个字段都写清格式、枚举、例句,并在校验层做了强校验。理想的参数定义应该包括:

  • 类型和格式,比如“字符串,长度8到32位”;
  • 枚举值,写死给定范围;
  • 依赖关系,比如“query_type 为 phone 时,phone 必填,customer_id 可不填”;
  • 是否需要用户确认,比如涉及下单、发送信息等敏感操作,必须拿到用户明确同意后再执行。

参数校验失败时,不要直接把错误堆栈丢给模型,而是返回一段友好、可供模型二次修正的错误信息。比如:“order_id 格式不正确,需要是10位纯数字,请向用户确认订单号。”模型看到之后,往往会重新向用户追问正确信息,而不是直接放弃任务。

3. 技能的执行与调度:连接模型和真实世界的关键一跳

3.1 一次完整调用的五步流程

技能系统跑通的核心流程,我总结为五步:解析、查找、校验、执行、回填。每一步都不能省。

  • 解析:把模型输出的结构化调用请求解析成具体的技能名和参数字典。注意这里要处理单轮多个技能同时调用的情况,有些模型服务会一次返回多个工具调用。
  • 查找:拿着技能名去注册表里定位对应的技能实现。找不到时,立即返回“技能不存在”的错误,不要让模型继续猜。
  • 校验:按技能定义里的参数规则做类型、必填、枚举检查。检查失败就返回可读的错误信息,建议带着错误信息再调一次模型,让它修正。
  • 执行:确认技能存在、参数合规之后,进入真正的副作用操作,比如查询数据库、发HTTP请求、落文件。
  • 回填:把执行结果格式化成一段简短的结构化文本,塞回对话上下文,让模型基于真实结果继续生成回答。

下面这段伪代码展示了最小调度器的核心结构:

def dispatch(model_output, registry): skills = model_output.get("tool_calls", []) results = [] for call in skills: skill_id = call["name"] args = call["arguments"] skill = registry.lookup(skill_id) if skill is None: results.append({"name": skill_id, "ok": False, "error": "skill_not_found"}) continue validated, error = skill.validate(params=args) if error: results.append({"name": skill_id, "ok": False, "error": error}) continue output = skill.run(**validated) results.append({"name": skill_id, "ok": True, "output": summarize(output)}) return results

我特别想强调“回填”这一步的summarize:把执行结果压缩之后再送回模型。直接往上下文里塞原始执行结果,是上下文爆炸和费用失控的最大来源。

3.2 超时、重试和失败降级

真实世界里外部服务不可能永远稳定。我在做技能系统的时候,给每个技能都配了独立的超时时间,并且区分“连接超时”和“读取超时”。比如查询类技能,连接超时给2秒,读取超时给5秒;发送类技能,连接超时给3秒,整链超时给10秒。超时之后不要静默失败,要把超时原因写成简短提示回填给模型,让模型决定是重试、换方案还是如实告诉用户。

这里有个非常关键的坑:带副作用的技能不能盲目重试。查天气重试三次问题不大,但“发送订单确认短信”这种技能,如果第一次实际已经发出去了,只是网络延迟导致超时,你再重试一次,用户会收到两条一模一样的短信。我的处理方案是给这类技能增加幂等参数,比如让前端生成一个request_id,后端通过这个ID去重。没有幂等能力的技能,超时后宁可告诉用户“操作结果未知,请稍后查询”,也不要无条件重发。

3.3 技能编排:让技能自己调用技能

单个技能解决单点问题,但很多任务天然是复合的。比如用户说“帮我把昨天的销售数据整理成周报发给总监”,这里需要拆成“查数据”“生成周报文本”“发邮件”三个动作。如果让模型一次性调三个技能,参数很难一次给全,而且中间一步失败很难处理。

我的做法是支持技能编排:技能的执行函数内部可以继续调用其他技能。比如定义一个复合技能weekly_report_generate,它的代码逻辑是:先调用sales_data_query,拿到数据后调用content_maker生成文本,最后把文本传到email_send。这样模型从一开始只需要决定调用一个技能,内部流程全由代码控制,比让模型自由编排稳定得多。

编排需要注意上下文传递。子技能的执行结果不要直接丢给大模型的对话上下文,而是在内部代码里做处理和格式化,最后只在对话里回填一个精简结论。否则模型会被一大堆中间数据干扰,很容易答非所问。

3.4 安全边界:白名单、审批、沙箱与配额

技能是Agent接触真实世界的通道,安全设计不能省。我把技能按风险分成三个等级:

  • L1,只读且低成本,比如查天气、查文档、算算数,可以直接执行;
  • L2,可写但对影响面有限,比如创建草稿、发起工单,需要用户在界面上确认一次;
  • L3,高风险操作,比如批量发送、删除数据、发起支付,必须打到人工审批或者走强校验。

除此之外,调度器还需要做配额控制。我就遇到过几次这样的线上问题:模型在单轮对话里被激发出十几个技能调用连环执行,把下游系统打挂了。后来每个会话都加了配额限制,单轮最多执行5个技能、每分钟最多执行20个技能,超过之后明确告诉模型“当前操作过于频繁,请先汇总给用户确认”。配额限制看起来粗暴,但在生产环境里是保命符。

4. 从零搭一套最小可用的技能系统:实操记录

4.1 第一步:设计技能目录结构

我自己用的是下面这种目录结构,简单清晰,一个技能一个文件夹:

skills/ weather_query/ skill.yaml handler.py order_query/ skill.yaml handler.py weekly_report/ skill.yaml handler.py

skill.yaml放技能定义,也就是前面那张表;handler.py放执行逻辑,对外暴露一个run(params)函数。一个文件夹里多出的其他文件都算这个技能的私有资源,比如order_query里放的数据库连接配置、SQL模板,只属于它自己。这样每个技能的边界都清晰,团队多人协作时互不干扰。

4.2 第二步:写一个通用的技能加载器

加载器的任务是启动时扫描目录、读取定义、动态导入处理函数,然后注册到中心注册表。Python实现大概是这样:

import importlib.util import yaml from pathlib import Path class SkillLoader: def __init__(self, root: Path): self.root = root def load_all(self): registry = {} for skill_dir in self.root.iterdir(): yaml_path = skill_dir / "skill.yaml" handler_path = skill_dir / "handler.py" if not (yaml_path.exists() and handler_path.exists()): continue spec = yaml.safe_load(yaml_path.read_text()) handler = self._load_handler(handler_path, spec["name"]) registry[spec["name"]] = {"spec": spec, "handler": handler} return registry def _load_handler(self, path, skill_name): module_name = f"skills_{skill_name}" module_spec = importlib.util.spec_from_file_location(module_name, path) module = importlib.util.module_from_spec(module_spec) module_spec.loader.exec_module(module) return module.run

注意两个细节:第一,动态导入的模块缓存要小心,热更新技能时最好在独立进程里做重载,不要直接importlib.reload,容易把旧状态的全局变量带进去;第二,加载前一定要做一次技能名合法性检查,避免路径拼接问题。

4.3 第三步:让模型学会选技能的提示词设计

技能注册好了,还得让大模型知道怎么用。每次请求模型前,需要把当前会话可用的技能列表拼进系统提示词。我常用的一种写法是:

你是智能助手,负责根据用户请求选择合适的技能完成任务。 可用技能如下: {skills_json} 选择规则: 1. 仅在确实需要外部信息或操作时才调用技能,简单常识问题不要调用。 2. 一次可以调用多个技能,但必须保证每个技能都有明确用途。 3. 调用时严格遵循技能参数要求,缺少必要信息时先向用户提问。 4. 技能执行结果以观测内容为准,不要猜测。 请同时输出要执行的动作和回复文本,动作格式为JSON数组。

这里最大的坑是技能太多:当技能列表超过30条,模型的选择准确率会明显下降,还会消耗大量token。我的解决方案是“两级召回”:先用分类粗筛,比如根据对话意图匹配若干候选技能,只把候选技能列表拼进提示词;如果候选里没有匹配项,再退回全量技能列表。这样既省token又提升准确率。

4.4 第四步:执行结果回填与上下文管理

技能执行完,返回的数据怎么进入下一轮模型对话,是决定对话质量的关键。这里我特别不建议把原始返回一整个塞回去。举例来说,order_query可能返回一个几十行的大订单列表,直接塞回去,模型会迷失在数据里,还会产生巨额token消耗。

我通常先做一次结构化摘要,比如把“查询到3笔订单,合计金额1280元,最近一笔是昨天购买的运动鞋”这种轻量结论回填。如果用户后续明确要求查看明细,再触发一次带明确条件的查询,把需要的那部分明细展示出来。这背后是一个“先粗后细”的思路:技能系统不负责一次性输出所有信息,而是根据对话需要按需拉取。

4.5 第五步:日志、追踪与效果度量

没有观测的技能系统是没法迭代的。我要求系统里所有技能调用统一打印结构化日志,内容包括:会话ID、技能名称、入参、出参摘要、耗时、错误信息、模型生成的原始调用串。每个会话再分配一个trace_id,把模型的推理轮次和技能调用串成一个完整链路。

日志不只用来排查线上问题,更是优化技能描述的依据。我会定期统计几个指标:

  • 技能命中率:候选列表里被实际调用的比例;
  • 参数校验失败率:模型生成参数一次通过校验的比例;
  • 执行成功率:技能真正跑通并返回有效结果的比例;
  • 平均耗时:单个技能从被选中到返回结果的时间。

这几个数字一出来,哪些技能描述不清晰、哪些技能签名太复杂,立刻一目了然。比如参数校验失败率高的技能,优先去优化parameters.description;命中率低的技能,大概率是因为描述里缺少典型触发场景。

5. 常见问题与排查经验实录

5.1 模型为什么老选错技能

这几乎是每个人都会遇到的第一个瓶颈。症状是用户问A话题,模型却去调了B技能。常见原因有三个:技能描述太宽泛、技能命名近似、缺少反例约束。

有一次我上线两个技能:doc_search和doc_preview。前者搜文档列表,后者预览某个具体文档内容。模型经常在用户想看某篇文档具体内容的时候去调doc_search,把返回的文档列表原样念给用户。后来我把doc_preview的描述里加了“仅当用户已经确定了具体文档ID或文档标题时调用”,并把doc_search的描述改成“仅返回文档标题列表,不包含正文内容,需要预览详情请使用 doc_preview”。两条技能描述一互补,选错率立刻降了下来。

如果你发现怎么改描述都不见效,建议直接加一个反面示例到系统提示词里,把用户可能会说出的近义表达列出来,并指明不该匹配的技能。给模型看例子,比讲一百遍规则都管用。

5.2 模型把参数填成了幻觉值

参数幻觉有两种:一种是填了不存在的值,比如把“上个月”解释成一个具体日期但算错了;另一种是填了用户根本没提到的值,比如用户只说了“查最近的订单”,模型自己补了一个order_id=12345。

我的处理套路是三层防线。第一层,参数描述里写清楚来源,比如“order_id必须来自用户提供的订单号,如果用户未提供,该参数置空并询问用户”。第二层,校验层对枚举值和格式做强校验,格式不对直接拦截。第三层,如果模型连续两次校验失败,就不再让它猜测了,改成向用户发起一次结构化澄清,列出当前已获得的参数和缺失参数,请用户确认。

5.3 技能执行超时,模型开始编造结果

这是个很隐蔽的问题。有一次技能因为慢查询超时了,我在超时后没有回填错误信息给模型,结果模型为了完成回答,自己“脑补”了一个查询结果,用户还在那儿等。后来我特意验证了一下,发现确实有部分模型服务在拿不到工具结果时,会选择自行编造而不是承认失败。

这提醒我两件事:第一,技能执行结果不管是成功还是失败,一定要有明确状态返回;第二,超时后必须把这个事实原样告诉模型,禁止它猜测。正确的回填格式类似:

工具状态:timeout 错误信息:order_query 在5秒内未返回结果 处理建议:告知用户查询超时,建议稍后重试,不要提供任何推测数据

对于真正耗时的任务,比如生成一份几十页报表,就不要用同步等待的方案了。改为技能先返回“任务已受理,ID为xx”,系统后台跑完后把结果回填进上下文。这种异步模式体验会好很多。

5.4 多轮对话里上下文被技能结果撑爆

技能调用次数一多,上下文里会积累大量工具结果、中间思考片段。我见过一个会话才聊了七八轮,就触发了模型上下文上限,前端直接报错。原因基本都出在把完整的技能返回原文塞回上下文。

对策是“每层都要压缩”:技能返回的原始数据不进上下文,只进执行层处理逻辑;进入上下文的必须是摘要;摘要里只保留跟当前用户问题直接相关的内容,其余用一句“还有X条其他记录”带过。如果确实需要长文本,比如调了一个文档预览技能返回了一篇长文,就单独把长文挂到会话的临时存储里,上下文里只放一个文章ID和开头摘要,等模型决定要给用户展示时再读全文。

5.5 技能上线前的验收清单

整理了一套我每次上技能前都会过的检查清单,分享出来给你抄作业。

检查项通过标准没通过会怎样
描述清晰度让一个不了解业务的同事读一遍描述,能说清什么场景用这个技能模型也会同样困惑,选错技能
参数完整性所有参数都有类型、必填标记、格式描述和示例参数幻觉、校验失败
超时与重试策略明确超时时间,副作用操作有幂等方案超时后模型编造结果或重复操作
失败提示文案执行失败时能返回一句可读的处理建议模型无法自行修正,任务中断
日志埋点包含技能名、参数、耗时、状态、trace_id出问题无法定位
配额与权限技能级别已分级,敏感操作已加确认安全问题、系统被拖垮
容量评估高频技能已做缓存或限流下游服务被打挂

每次上线新技能之前过一遍这张表,能省掉至少一半线上事故。特别是“描述清晰度”这一项,我强烈建议你在真实模型上多试几个说法,别只盯着代码逻辑跑通就发版。


这套agent-skills我前后迭代了三版,最深的体会是:代码的复杂度没有想象中高,真正耗时间的是把每个技能说明书写到让模型一眼就能看懂。你去看市面上很多Agent能力不及预期的案例,八成不是模型不行,而是技能边界描述得模模糊糊,模型像个在公司没待两天、对流程一知半解的新人,上哪儿找工具都不知道,又怎么指望它把事办漂亮。

如果你正在搭自己的技能系统,我建议从最小的两三个技能开始,先把加载、校验、回填的闭环跑通,再慢慢往上加。技能描述不要靠拍脑袋写,上线之后盯着命中率和校验失败率去调,你会很明显地看到Agent的表现一点点变好。这套方法不需要什么高深理论,但它真的能让你的Agent从“会聊天”升级成“会干活”。

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

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

立即咨询