☰
Agent技能系统设计与实践:从工具函数到标准化技能编排
2026/10/14 8:27:52 网站建设 项目流程

断断续续折腾了大半年,我手头这个 agent-skills 项目总算能稳定地跑真实任务了。起因特别普通:某次给内部知识库 agent 加新功能,模型拿着错误参数去调接口,日志翻出来一看,十几个工具函数的描述写得含糊不清,模型根本分不清该调哪个、参数怎么传。那一刻我才意识到,agent 的可用性不取决于它背后挂了多少函数,而取决于这些能力怎么被定义、被暴露、被调用。于是我把工具层整个重做,设计了一套标准化的技能系统,也就是现在的 agent-skills。

这篇文章不打算贴完整源码,重点说清楚三件事:一是为什么我认定"技能"是比"工具函数"更好的抽象;二是 agent-skills 的技能清单、执行体、注册中心到底怎么设计;三是单独跑通一个完整技能、再把多个技能编排成工作流时,有哪些坑和可以复用的做法。如果你是第一次接触 agent 开发,或者正在被"工具越来越多、模型反而越来越笨"的问题困扰,这篇文章应该能帮你省下不少试错时间。

1. 为什么要给 agent 单独做一套技能系统

1.1 一次失败任务把我逼到了重写边缘

当时内部知识库 agent 已经接了十几个工具:查文档、搜代码、发通知、读数据库。听起来能力很全,实际每轮对话成功率不到一半。我翻调用日志,最常见的两类错误:模型把"查询文档"和"查询代码"搞混,传参时把时间格式从字符串传成数字。一开始我以为是模型不够聪明,换了个更强的模型还是一样,只是错误类型略有不同。

后来我想明白了,问题不在模型,而在工具层没有一个统一的"语言"。每个工具函数有自己的入参约定、返回格式、错误语义,模型要在上下文里同时理解十几套不同的约定,不出错才怪。真正该做的是把这些能力包装成结构一致的"技能",让模型只需要理解一种约定,剩下的差异由技能层消化。这就像给一堆零件统一换上同一种接口的螺丝孔,装配的人不再需要逐个背规格,照着统一说明拧就行。这个想法就是 agent-skills 的起点,也是我后来所有设计的判断基准:任何让模型需要额外记忆和猜疑的设计,都要优先改掉。

1.2 工具、插件、技能,本质区别在哪

很多人把工具(tool)、插件(plugin)、技能(skill)混着叫,实际上三者的抽象层级完全不同。工具是最底层的能力单元,通常指一个函数或一次 API 调用;插件是工具加上它的配置、依赖和交互界面,强调"可安装、可插拔";而技能更接近一个"带使用说明的能力包"——它除了执行代码,还包含触发条件、参数 schema、期望的输入输出、依赖的资源,甚至一段适合该任务的提示词片段。

用生活类比:工具是螺丝刀,插件是带工具箱的螺丝刀套装,技能则是"换灯泡 SOP"——不仅告诉你怎么拧螺丝,还告诉你在什么场景下该换灯泡、换之前要断电。对 agent 来说,模型真正需要的不是更多螺丝刀,而是能判断"现在该用哪份 SOP"的清晰说明。这个抽象层级的差别,直接决定了模型在复杂任务里的表现。我见过不少团队拼命往系统里塞工具,塞到最后模型反而更不会用了,本质就是一直在加螺丝刀,却没人把使用说明写好。

1.3 agent-skills 要解决的三件事

我给自己定的目标很明确,就三类问题。第一,能力编排的标准化,让一个新技能从写好代码到被模型正确调用,只需要做一个注册动作,不需要改 agent 主逻辑;第二,上下文的瘦身,模型不可能每次把几百个技能说明读完,必须只暴露最相关的部分;第三,调用过程的可观测性,每次技能执行都要有结构化日志、耗时、错误码,方便复盘模型为什么选错、哪个环节拖慢了任务。

这三个问题其实就是大多数 agent 项目走到中期的公共痛点,尤其当你从 Demo 走向真实业务时。所以 agent-skills 的设计原则被我压成一句话:清单可序列化、执行体解耦、检索可裁剪。后面的章节我会逐个展开,先说最基础也最容易被忽略的清单设计。

2. 技能的"档案"与执行体:一份规范解决一半问题

2.1 技能清单(manifest)字段怎么定

技能清单是整个系统最核心的数据结构,我把它定义成 JSON 文件,字段分成三组。身份信息包括 skill_id、name、version、author,保证技能可追溯、可升级;调用信息包括 description、parameters、returns,这一组是给模型看的,直接决定它会不会选错技能;资源信息包括 run 命令、timeout、dependencies,这一组是给执行器看的,决定技能能不能被真正跑起来。

其中 description 是整份清单里最容易被低估的字段。我见过把 description 写成"这个技能用于执行网页归档功能"的,等于什么都没说;也见过写三四百字把自己当 API 文档的,模型读不完反而产生误判。正确写法是讲清楚四件事:输入是什么、输出是什么、什么时候用、什么时候千万别用。后面第六章我会专门总结一套验证描述质量的方法,这里先记住一个方向:description 是写给模型看的,不是写给同事看的,简洁、明确、带反例,永远比详细堆术语有用。

2.2 执行体放在哪、怎么跑

执行体就是一个独立的可执行程序,我选用的约定是"技能目录 + 运行命令":skills 文件夹下每个子目录是一个技能,目录里有 manifest.json 和 run.py(其他语言也支持,只要实现同一套输入输出协议)。执行器拿到模型填好的参数后,把参数序列化传给子进程,技能读取参数、执行逻辑、把结果写回标准输出,执行器再解析回结构化结果。

为什么不用"直接在 agent 进程里 import 函数"这种看似更轻量的方案?因为进程隔离能带来两个实际好处。一是技能崩溃不会拖垮主 agent 进程:超时、内存泄漏、第三方库段错误,都可以用 subprocess 的 timeout 和资源限制兜住。二是技能和主语言解耦:研究团队同学用 Python 写数据处理技能,前端同学用 Node 写页面交互技能,只要满足 stdin/stdout 协议都能注册进来。代价是每次调用有进程启动开销,实测在 10~50ms 级别,对大部分任务的体感影响几乎可以忽略。用几百毫秒的响应换一个不会互相搞崩的系统,这笔账很划算。

2.3 注册中心:模型先"看见"技能,才谈得上调用

注册中心是一个内存中的技能索引,首次启动时扫描 skills 目录,解析所有 manifest.json,按 skill_id 去重、按 version 取最新,然后构建两类索引:全量清单用于管理端展示和调试,轻量描述索引用于模型侧。模型侧永远拿不到完整 manifest,只拿到 description、参数名和参数类型这些最小必要信息。

这一步我踩过一个很深的坑。一开始图省事,把整个 manifest 直接塞进 system prompt,技能到 30 个之后 prompt 膨胀到 8000 多 token,模型开始漏掉中间位置的技能,每次调用成本也肉眼可见地涨。后面改成注册中心只维护轻量索引,真正需要时才按相关度裁剪给模型,问题才算解决。模型不是浏览器,塞给它的每一个 token 都会稀释注意力,所以"让模型先看见该看的"才是注册中心真正的职责,而不是简单做个技能列表。

3. 把调用链路打通:从模型决策到结果回填

3.1 技能检索:不是把所有技能一次塞给模型

调用链路的第一步是决定模型"看到"哪些技能。全量塞给模型是最省事的做法,但技能一多就不成立。我采用的方案是两层漏斗:第一层基于 embedding 做语义粗筛,把用户 query 和每个技能 description 算相似度,取 top-k;第二层把 top-k 技能的轻量描述拼进 system prompt,让模型做最终决策。

k 值我建议按业务场景调,8~12 比较稳。k 太小容易漏掉相关技能,k 太大又回到上下文膨胀的老问题。相似度阈值也要设,低于我调的 0.3 的直接丢弃,这样用户聊日常话题时模型不会被一堆无关技能干扰。实测中这个方案让首轮技能选择准确率从 60% 提到了 85% 左右,代价只是每次多一次 embedding 调用,换算成耗时大概几十毫秒,完全可接受。这里有个容易被忽略的细节:粗筛用的是用户当前这一轮 query,不是整段历史对话,历史相关性交给模型在系统提示里自行参考就行,否则 embedding 的输入长度会失控。

3.2 参数填充与校验的细节

模型选定技能后,会按 manifest 里的 parameters 生成参数 JSON。这里最容易被忽视的是:模型填出来的参数值不一定符合 schema。日期可能是"明天"而不是标准 ISO 日期、数字可能是字符串、数组可能漏了元素。所以执行器必须在真正运行技能前做一次严格校验,校验不通过就把错误信息回灌给模型,让它重新填,而不是硬着头皮去跑。

我总结出三条校验规则:必填项缺失时明确报错,并指出具体缺哪个字段,方便模型修正;类型不匹配时做一次宽松转换而不是直接拒绝,比如把字符串形式的数字转成 int、把"明天"转成 ISO 日期;有枚举约束的字段要校验是否在允许值内,不在就回传候选值列表。每一次转换动作都要落日志,因为这是观察模型习惯性错误的最好窗口。时间久了你会发现自己整理出一套"模型常见坏习惯清单",比如总爱把标签参数漏传、总爱把日期格式写错,针对性调一调对应字段的 description,比换模型管用得多。

3.3 执行结果回填与错误恢复

技能跑完,结果要回填给模型,但回填的内容不是越多越好。我在执行器里加了一层"结果裁剪":技能只返回必要字段和一段摘要,完整数据写入临时存储或日志,模型后面需要细节时再按 skill_id + result_ref 去取。这样既保证模型有足够信息继续对话,又不至于让上下文被一大段抓取内容撑爆。

错误恢复是另一件值得做扎实的事。技能异常分两类:参数问题(模型填错)和运行问题(外部服务挂了、超时、权限不足)。参数问题我会把校验错误作为新的一轮提示回灌,让模型修正后重试;运行问题则返回统一错误码,模型可以选择换一个技能或如实告诉用户失败原因。这里最关键的认知是:一定要给模型"改错的机会"。我见过不少 agent 项目一次失败就结束对话,其实多给一轮重试,成功率能提升不少。重试次数也要设上限,一般两轮就够,再多就容易陷入同一个错误来回打转的死循环。

4. 实战:写一个"网页归档"技能并跑通全流程

4.1 需求边界先定清楚

拿我最近加的一个技能当例子:网页归档。需求是用户把某个 URL 甩给 agent,说"存一下",agent 要抓取正文、去掉广告和导航、提取标题与摘要、按标签存进知识库,最后返回一个文档 ID。边界我先划了三条规定:只接受 http/https 开头的完整 URL;只处理静态正文内容,遇到登录墙直接告诉用户"需要登录,无法归档";重复归档同一 URL 不生成新记录,而是返回原有文档 ID。

边界定清楚之后,manifest 写起来就顺了。description 里我特意加了"适用于用户希望保存或回顾某个网页的场景"和"当网页需要登录时不要尝试,直接报告失败"两句——前者帮助模型识别触发时机,后者帮助模型避开不该用的场景。这两句自然语言说明对选择准确率的贡献,实测比多写 200 字参数描述都大。给模型写说明的时候一定要代入它的视角:它不在你的业务上下文里,它只看得见这几行字。

4.2 manifest + 执行体的完整代码

网页归档技能的 manifest.json 长这样:

{ "skill_id": "webpage_archive", "name": "网页归档", "version": "1.2.0", "description": "抓取指定 URL 的网页正文,去除导航与广告后提取标题和摘要,生成去重文档 ID 并存入知识库。适用于用户希望保存或回顾某个网页、把网页内容整理进资料库的场景。网页需要登录时不要尝试,直接报告失败。", "parameters": [ { "name": "url", "type": "string", "required": true, "description": "目标网页的完整 URL,必须以 http:// 或 https:// 开头" }, { "name": "tags", "type": "array", "required": false, "description": "归档时附加的标签列表,例如 [\"论文\", \"待读\"]" } ], "timeout": 30, "run": "python3 skills/webpage_archive/run.py" }

执行体 run.py 的核心逻辑分三段:抓取、清洗、归档。抓取用标准库加超时控制,清洗用正则去掉 script、style、nav 这些非正文标签,归档前先按 URL 哈希查重,防止重复入库。核心代码示例:

import sys, json, re, hashlib from urllib.request import urlopen, Request def fetch(url: str) -> str: req = Request(url, headers={"User-Agent": "agent-skills/1.0"}) with urlopen(req, timeout=20) as resp: return resp.read().decode("utf-8", errors="ignore") def clean(html: str) -> dict: html = re.sub(r"<(script|style|nav)[^>]*>.*?</\1>", "", html, flags=re.S) body = re.search(r"<body[^>]*>(.*?)</body>", html, flags=re.S) text = re.sub(r"<[^>]+>", " ", body.group(1)) if body else html title = re.search(r"<title[^>]*>(.*?)</title>", html, flags=re.S) lines = [l.strip() for l in text.splitlines() if l.strip()] return {"title": title.group(1) if title else "无标题", "content": "\n".join(lines)} def run(raw: dict) -> dict: url = raw.get("url", "") if not url.startswith(("http://", "https://")): return {"status": "error", "code": "INVALID_URL"} doc_id = hashlib.sha256(url.encode()).hexdigest()[:16] if knowledge_base.exists(doc_id): return {"status": "ok", "doc_id": doc_id, "duplicate": True} try: page = fetch(url) except Exception as e: return {"status": "error", "code": "FETCH_FAILED", "message": str(e)} result = clean(page) knowledge_base.save(doc_id, {"url": url, **result, "tags": raw.get("tags", [])}) return {"status": "ok", "doc_id": doc_id, "title": result["title"], "duplicate": False}

注意:manifest 里的 timeout 只是软限制。真正执行时,我会在 subprocess 层再套一层更短的超时,并设置内存上限,防止某个技能出 bug 把整个 agent 卡死。这两个硬限制不在清单里体现,属于执行器层的公共安全网。

4.3 让模型自己决定调用:一次真实会话的观察

技能部署后,我做了几组真实会话观察。用户说"把这篇讲 agent memory 的文章存一下,标签加个待读",模型没有傻乎乎地反问"你要归档的链接是什么",而是先调用了网页归档技能,但参数缺了 url;执行器校验失败后把错误回灌给模型,模型紧接着向用户要链接,并要求提供完整 URL。整个过程只多花了一轮对话,最后结果完全正确。

这个观察很有价值:它说明能力边界内的触发、边界外的补齐,靠的是"清单 + 校验 + 回灌"这套机制在起作用,而不是写死在代码里的 if-else 分支。模型通过清单知道网页归档技能能做什么,通过校验知道哪个参数没给全,然后自己组织语言向用户追问。这种弹性,恰恰是结构化技能相比裸函数最大的优势——你在代码里给的是规则,而不是穷举每一种对话路径。后来我把同样的技能接到另一个场景,模型居然能主动区分"直接给链接"和"只给文章标题"两种输入,前者直接归档,后者先问链接。这种泛化行为,靠规则硬编码是写不出来的。

5. 多技能组合与编排:从单点能力到工作流

5.1 技能链:把复杂任务拆成多个技能接力

单技能解决单点问题,真实任务往往需要多个技能接力完成。我在 agent-skills 里支持了技能链配置,本质上是一个有向无环图:每个节点是一个技能,节点间的连接定义了前一个技能的输出如何映射为后一个技能的输入。这种编排方式把"一个大而全的复杂任务"拆解成"一组小而精的技能接力"。

举例,用户说"把这篇英文博客翻译成中文,总结要点并归档"。这条请求可以拆成四条技能链:webpage_archive 抓正文 → translate 翻译 → summarize 提炼要点 → knowledge_base_save 归档。配置技能链时我不写一行代码,只在编排配置里声明节点顺序和字段映射关系。模型在每一步只需要关注当前技能要什么参数、返回什么结果,复杂度由编排层消化,而不是把全部步骤塞进一次模型调用去硬拼。当初这么设计的动机很简单:单次长链条的模型调用,任何一个环节理解偏了,后面全部跟着错,而且很难定位是哪一步出的问题;拆成技能接力之后,每步都有结构化日志,出错位置一目了然。

5.2 技能冲突与优先级处理

技能多了,免不了出现"看起来都能做同一件事"的重叠。比如网页归档和稍后阅读列表,都能"存一个链接",但一个进知识库、一个进待读清单。这时模型怎么选?我的做法是在注册时显式声明互斥关系,并在 description 里互相加"排他提示":归档技能描述最后加一句"如果只是想快速收藏到待读清单,请使用稍后阅读技能",反过来也一样。

不要指望模型自己会去细心分辨这种细节,模型只知道两个技能都能处理"存链接",至于业务上的细微差别,它从字面上根本看不出来。互斥提示必须在注册阶段就配置好,注册中心构建轻量索引时会一并带上 see_also 和 conflicts_with 字段。实测中,这类显式声明能让重叠技能的选择准确率从 70% 提到 90% 以上,而改动成本只是拼几行 JSON。如果两个技能真的改了业务归属,记得同步更新互斥声明,这属于"不改就出事、改了没感觉"的维护项,建议把它写进技能上线的检查清单里。

5.3 热加载与版本管理

技能不是写完就不改了,description 里的措辞、参数的定义、执行逻辑都在持续迭代。agent-skills 支持热加载:skills 目录下文件变化时,注册中心会重新扫描增量更新,不需要重启主进程。这个能力让我迭代速度提升很大——改完描述或参数定义,几秒后模型就能用上新版本,对快速验证新技能很有帮助。

版本管理我建议用语义化版本号,并保留旧版本的变更日志。这里有一个容易踩的坑:模型可能在一次多轮对话里先按旧版本参数调用了技能,然后你热更新了版本,同一轮对话内第二次调用却用了新版本,参数校验就可能对不上。我的处理是:同一会话内锁定技能版本,只有新会话才使用最新版本。实现上只是多维护一张 session_skill_version 映射表,但对稳定性的提升非常明显。另外,线上环境我默认关闭热加载,只在测试环境开,避免生产环境技能配置变来变去导致排查问题时没法复现。

6. 踩坑记录与调优心得

6.1 描述写不好,模型就"瞎"

这个坑值得单独拎出来说。技能 description 的质量直接决定选择准确率,而大多数项目都没有认真对待它,包括我早期。走了一段时间弯路后,我把 description 的写法总结成四条:第一,说清楚输入输出,让人一看就知道这技能接受什么、返回什么;第二,说清楚触发时机,明确"在什么情况下该用它";第三,说清楚反例,明确"什么情况下绝对不要用它";第四,控制在 80~150 字,别把描述写成长文。这四条写下来,基本能保证模型在技能选择和参数生成上都有足够的决策依据。

我有一个快速验证描述质量的土办法:把技能描述单独喂给模型,不给任何其他技能,然后问"现在有一个用户请求,你判断这个请求是否应该调用该技能",来回做十几条正反样本,准确率低于 85% 就说明描述有歧义,必须改。这个方法不用写代码,调一个 prompt 界面就能做,比上线后翻日志高效得多。而且正反样本可以复用,每次改完描述跑一遍,能明显看出措辞调整带来的变化。我强烈建议每个技能上线前都过一遍这个测试,成本极低,收益却非常直接。

6.2 上下文膨胀与检索质量

技能数量超过 20 个之后,上下文膨胀是必然问题,必须靠检索而不是全量暴露来解决。除了前面说的 embedding 粗筛,我还做了两个优化。一是参数定义精简:参数个数超过 5 个的技能,模型填错的概率会急剧上升,优先拆技能而不是加参数;二是描述索引与完整清单分离:模型侧永远只看到轻量索引,管理端和调试页面才看完整 manifest,从源头上压缩 token。

另一个容易被忽略的点:embedding 模型的选择会影响粗筛效果。不同语义模型的相似度分布差异很大,同一个 query 在某个模型下和技能相似度是 0.4,在另一个模型下可能是 0.6。所以相似度阈值必须跟着 embedding 模型重新校准。我通常会先拿一批真实用户请求统计相似度分布,再看准确率拐点来定阈值,而不是直接抄网上现成的 0.3。如果你发现粗筛之后模型选技能还是老出错,先别急着改 prompt,回去看看到底是粗筛把正确的技能漏掉了,还是模型在正确的候选项里选错了,这两类问题的解法完全不同。

6.3 技能崩了,agent 要能优雅降级

最后一个心得是错误处理。一开始我对技能异常的态度是"技能自己负责处理",后来发现外部依赖一多,任何技能都可能挂。于是我做了一套统一降级策略:技能失败时先返回结构化错误码;模型判断错误属于参数问题还是运行问题;参数问题就修正后重试,运行问题则看有没有替代技能可以切换,没有就如实告知用户,不硬编。这套策略看起来简单,但真正执行时要注意:判断"错误属于哪类"这件事也可以交给模型,但必须把错误信息结构化地喂给它,而不是给它一段原始堆栈,否则模型很容易被一堆无关的报错细节带偏。

还有一个小技巧值得分享:给技能加"幂等键"。网页归档、发通知这类技能如果因为超时被重试,很可能导致重复执行,比如一条通知发两遍。我在执行器里要求每个技能可选声明 idempotency_key,由编排层在超时重试时做去重。实测中这条机制避免掉了不少重复发消息、重复建记录的事故,属于典型"平时看不见、出事救一命"的细节。别觉得这是过度设计,等你真正遇到过一遍"用户收到三条重复通知"的投诉,你就知道幂等键有多值钱了。

7. 最后:一个值得先做的小实验

如果这篇看下来只带走一样东西,我希望是"先把技能描述和参数校验做扎实,再谈花哨的编排"。技能系统的骨架再完整,描述含糊、校验缺失,模型一样会瞎选、会填错;反过来,描述准确、校验严格,就算技能数量还不多,整体可用性也会非常稳。我自己的经验是,与其一次性设计三五十个技能,不如先把三五个核心技能调到"描述无歧义、校验全兜底"的程度。

有兴趣的话,从你手头最高频的一个简单动作开始,比如"存一个链接"或者"查一下日历",按 manifest + 执行体的方式写一个技能,配上注册中心和一层校验回灌,跑几轮真实对话看看模型的选择行为。把描述和校验这两处做扎实,你会立刻看到 agent 的决策质量上一个台阶;等这一步稳了,再往技能链、互斥声明这些进阶方向上走,每一步都有数据支撑,不会觉得自己在凭感觉调参。技能系统的价值从来不在代码量,而在它让模型每一次调用都有据可依、有错可改、有迹可循。

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

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

立即咨询