☰
Agent技能系统:把会聊天的模型变成会干活的数字员工
2026/11/3 23:55:12 网站建设 项目流程

这是“从聊天到干活”系列的第八篇。前几篇我们把 Agent 的记忆、工具调用、上下文管理都过了一遍,今天聊聊最容易让人忽略、但直接决定 Agent 好不好用的环节:Skill 技能系统。

Skill 这个东西,翻译成“技能”其实有点温柔了,我更愿意叫它“行为包”。它解决的是 Agent 开发里最尴尬的一类问题:模型明明什么都懂,就是不干活。问它会答,让它操作文件、跑脚本、调接口、生成固定格式的报告,它就原地打转。真正干过 Agent 项目的人应该都有同感:聊天能力和执行能力之间有一条巨大的沟,Skill 技能系统就是用来填这条沟的。

这一篇适合正在做 Agent 开发、想把自己的“会说话的模型”变成“会做事的数字员工”的人。我会把技能系统的设计思路、常见坑、一个可落地的示例,以及技能评估的方法一次讲透。不管你是用 Claude Code、Codex,还是自己的框架,这套思路都能直接搬过去用。

1. 为什么 Agent 需要一套“技能系统”

1.1 聊天模型和干活模型的分水岭

先说一个很反直觉的事实:大语言模型本身不具备“执行能力”,它只具备“生成能力”。你问它“帮我统计这个目录下一共有多少个 Markdown 文件”,它能给你一段完美的 Python 代码,但它不会真的打开文件系统看一眼。开头的那个画面你应该见过:模型信誓旦旦说“我已经帮你统计好了”,实际上一个文件都没碰。

这就是聊天模和干活模型的根本分歧。聊天只需要在一个封闭的对话上下文里完成输入输出,干活却要面向真实世界:读文件、写文件、发请求、执行命令、调用外部服务。这些动作必须发生在模型“之外”,由一段真实运行的程序完成,模型只负责决策:我应该调用哪个工具、传什么参数、怎么处理返回结果。

Skill 技能系统就是在这条分水岭上搭的桥。它把一组原子操作包成一个有名字、有描述、有输入输出契约的完整单元,让模型在看到某种意图时,能稳定选择这个单元,并能按约定把参数递进去。

模型像是一个聪明但没手的项目经理,Skill 是它手底下那些夹着标准作业指导书的熟练工。项目经理的任务是判断“这件事该谁干”,熟练工的任务是按标准流程把活干完。

1.2 Skill、Function Calling 与 Tool 到底有什么不一样

这个术语问题经常把人绕晕,连社区里都在吵 skill 和 agent 的区别。我先给一个比较干净的理解框架。

Function Calling(函数调用)是一种协议。它定义了大模型如何“请求调用一个函数”:模型输出一段结构化 JSON,里面包含函数名和参数,框架再去执行对应函数。OpenAI 的 tools API、Anthropic 的 tool use 都属于这一层。

Tool(工具)是一个可执行单元。它可以是一个函数、一个 shell 命令、一个 HTTP API。Tool 是“最小可执行体”,只解决“某一步怎么干”,不关心业务闭环。

Skill(技能)是比 Tool 更高一层的组织单元。Skill 可以包含多个 Tool、一段执行脚本、一套提示词模板、甚至自己的校验规则和中间步骤。Skill 回答的是“用户意图怎么被完整满足”。举例:一个“文件整理”Skill,内部可能需要“读取目录结构”“识别文件类型”“移动文件”“生成整理报告”四个 Tool,外加一段判断逻辑。模型不需要知道这四个 Tool 分别怎么调,它只需要知道“用户说文件乱,就用文件整理 Skill”。

这样一拆,答案就清楚了:Agent 是载体,Skill 是能力包,Tool 是组成 Skill 的零件,Function Calling 是它们之间的通信协议。很多 Agent 调不动工具,不是因为 Tool 写得不对,而是缺少 Skill 这一层“意图到执行”的封装。

2. 技能系统的整体架构:拆开看核心模块

2.1 核心模块拆解:技能清单、路由、执行、反馈

一个正经的技能系统,至少要有四个模块。缺了任何一个,都会出现“有技能但用不上”或“技能用上了但结果不可控”的问题。

第一个是技能清单。系统里有哪些技能、每个技能的名字、描述、参数定义,都汇总在这里。这些信息会以特定格式写入模型上下文,让模型“知道”存在哪些选择。技能一多,清单本身就会变成上下文负担,所以还需要管理模块负责排序、裁剪、按需加载。

第二个是技能路由。这是最核心的决策环节,解决的问题是“面对当前用户请求,到底该不该用某个技能”。路由可以走 LLM 判断,也可以走规则或向量召回。实际项目里往往混合:简单意图用关键词匹配直接命中,复杂意图交给模型从技能列表中选。

第三个是技能执行。路由做完决策,框架会按照技能的参数定义把模型输出解析成真实参数,拉起进程、执行脚本、调用接口,并把标准输出、错误信息、退出码汇总成一份“执行结果”。

第四个是反馈闭环。执行结果需要被送回模型上下文,让模型基于真实结果继续下一步。这一步最容易被忽略。技能执行失败了,模型必须能看见“失败原因”,否则它只能尴尬地重复调用,或者干脆编一个成功结果。

这四个模块串起来的流程是:用户输入 → Agent 检索技能清单 → 路由决策选择技能 → 解析参数 → 执行技能 → 结果回填上下文 → 模型生成最终回复。很多开发者在技能执行没问题的情况下还说“Agent 老是不调用”,十有八九是路由这个环节的信息喂得不对。

2.2 Skill 的元信息设计:名称、描述、参数、资源

如果说技能代码是肌肉,那么技能元信息就是神经系统。模型是根据元信息决定“调不调用、怎么调用”的,所以元信息的质量往往比执行脚本的质量更重要。

一个实用的元信息至少包含四块:名称、描述、参数定义、资源依赖。名称要短,最好一眼能看出能力边界,像file_organize、note_to_report这样。描述是重中之重,它就像是模型的“操作手册”,必须写明两个东西:什么情况下用、什么情况下不要用。我见过最好的技能描述写法是“When the user wants to organize downloaded files, use this skill; if the user only asks for advice, do not use it”,即“当用户想整理下载文件时使用;如果用户只是问建议,不要用”。

参数定义必须用严格的结构,建议直接采用 JSON Schema。明确参数类型、必填项、枚举值、默认值,并且为每个参数写清楚“这个参数是什么意思”。这一步做好了,模型才能在正确的位置填正确的值。

资源依赖用来声明技能运行环境:需要哪些环境变量、哪些外部命令、是否必须要联网、是否需要在特定目录下执行。一方面方便框架做沙箱隔离,另一方面可以在技能启动前做前置检查,而不是等执行到一半才报错。

2.3 技能的运行沙箱与权限边界

一个能直接读写文件系统、执行任意命令的 Agent 是很危险的。技能系统如果不管权限边界,等于给模型发了一把无限权限的钥匙。专业点说,是做“最小权限”的沙箱。

每次技能执行,都应该限定好它能触碰的范围。本地文件类技能,把工作目录锁在一个临时目录或指定项目目录里,不允许它用相对路径往上越权;命令类技能,启用白名单而不是黑名单,只允许白名单内的命令;网络类技能,配置好允许访问的域名和超时时间。另外,所有执行结果都要有超时和输出长度限制,防止技能进程卡死或者输出大量垃圾内容把上下文撑爆。

沙箱隔离不是只有安全意义,它还能让技能行为变得可预测。一个技能只在自己目录里操作,就不容易和历史遗留文件互相污染,反复执行结果也更稳定。我习惯用“dry-run”模式做默认首选项:真实改动文件之前先输出“将要执行哪些动作”,模型看过一遍计划之后再实跑。效果很好,至少能少删一半东西。

3. 实操:手写第一个 Skill 并接入 Agent

3.1 定义技能的结构和元信息

这一节我们动手写一个真实技能,目标场景:用户有一堆散乱的 Markdown 笔记,希望把它们整理成一份按日期分组的周报。我给它起名notes_to_weekly_report。

第一步,建一个技能目录,把元信息和执行脚本拆开。目录结构类似:

skills/ └── notes_to_weekly_report/ ├── SKILL.md └── scripts/ └── build_report.py

SKILL.md 是技能的说明书,框架启动时会读取它来生成技能描述。我的写法一般是这样:

--- name: notes_to_weekly_report description: > 当用户提供多条分散的 Markdown 笔记,或者希望把指定时间段内的笔记整理成周报时, 使用这个技能。它会扫描指定目录下的 .md 文件,按日期聚合,并生成一份结构化周报。 如果用户只是询问"笔记应该怎么写",不要使用该技能。 version: 1.0.0 inputs: notes_dir: type: string description: 存放 Markdown 笔记的目录路径 required: true period_days: type: integer description: 向前回溯的天数,默认 7 required: false default: 7 output_path: type: string description: 生成周报的保存路径,默认 ./weekly_report.md required: false scripts: - build_report.py ---

看见没有,描述里明确写了“什么时候不要用”。这一句话,能避免模型在用户单纯闲聊时也乱触发技能。很多技能开发新手喜欢把描述写成长篇大论,恨不得把内部实现都写进去,结果模型被一堆无关信息干扰,准确率反而下降。

3.2 写一个真正能跑的执行脚本

元信息准备好之后,写执行脚本。脚本本身不复杂,重点是要做好防御性处理:目录不存在要报错,文件读取失败要跳过而不是中断,日期解析失败要有备用策略。

#!/usr/bin/env python3 """Build a weekly report from markdown notes.""" import argparse import re from collections import defaultdict from datetime import datetime, timedelta from pathlib import Path DATE_PATTERN = re.compile(r"20\d{2}[-./]\d{1,2}[-./]\d{1,2}|\d{1,2}月\d{1,2}日") def extract_date(content: str, filename: str) -> str: m = DATE_PATTERN.search(filename + " " + content[:500]) if not m: return "未分类" raw = m.group().replace("/", "-").replace(".", "-") return raw[:10] def main(): parser = argparse.ArgumentParser() parser.add_argument("--notes_dir", required=True) parser.add_argument("--period_days", type=int, default=7) parser.add_argument("--output_path", default="./weekly_report.md") args = parser.parse_args() notes_dir = Path(args.notes_dir) if not notes_dir.is_dir(): raise SystemExit(f"目录不存在: {notes_dir}") cutoff = datetime.now() - timedelta(days=args.period_days) grouped = defaultdict(list) for path in notes_dir.rglob("*.md"): try: content = path.read_text(encoding="utf-8") except UnicodeDecodeError: content = path.read_text(encoding="gbk", errors="ignore") date_str = extract_date(content, path.name) if date_str == "未分类": continue try: parsed = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: parsed = datetime.strptime(date_str.replace("-", "/"), "%Y/%m/%d") if parsed.date() >= cutoff.date(): grouped[parsed.date().isoformat()].append((path.name, content.strip()[:600])) if not grouped: print("最近 {} 天内没有可整理的新笔记。".format(args.period_days)) return lines = ["# 周报\n"] for day in sorted(grouped, reverse=True): lines.append(f"## {day}\n") for fname, snippet in grouped[day]: lines.append(f"- {fname}: {snippet}") output = Path(args.output_path) output.parent.mkdir(parents=True, exist_ok=True) output.write_text("\n".join(lines), encoding="utf-8") print(f"周报已生成: {output},共 {len(grouped)} 天,{sum(len(v) for v in grouped.values())} 条笔记。") if __name__ == "__main__": main()

脚本里有一点我特别坚持:必须给执行一个明确的、结构化的成功输出。为什么?因为 Agent 要靠这段输出去组织最终回复。如果脚本静默执行完,只写文件不打印“我到底生成了几天的内容”,模型面对空白返回就只能瞎猜,回复质量就会崩。你的技能可以不给用户看细节,但一定给模型留点“线索”。

3.3 接入 Agent 的注册与路由

脚本写完,最后一步是让 Agent 知道它存在。这一步在不同框架里的做法不太一样,但底层思路是一致的:把技能列进模型可观察的上下文,并提供调用入口。

核心 API 式的接入方法是函数注册。我们把这个技能定义成函数调用 schema:

{ "type": "function", "function": { "name": "notes_to_weekly_report", "description": "将指定目录下最近的 Markdown 笔记按日期聚合,生成周报。", "parameters": { "type": "object", "properties": { "notes_dir": { "type": "string", "description": "笔记目录路径" }, "period_days": { "type": "integer", "description": "回溯天数" }, "output_path": { "type": "string", "description": "输出文件路径" } }, "required": ["notes_dir"] } } }

注册完之后,系统 Prompt 里最好再给一段路由提示,比如“当用户提到笔记、周报、工作总结时,优先调用 notes_to_weekly_report”,这样模型的决策更稳定。

如果你用的是本地 Agent 框架,做法通常是:把 SKILL.md 里的描述动态插入到系统消息的“可用技能”段落中,然后由 Agent 内部的 Function Calling 路由决定调用时机。实测下来,技能列表的描述顺序也有讲究,重要技能放前面,触发概率能高不少。

4. 常见问题与排查技巧

4.1 Agent 不调用技能怎么办

这是被问得最多的问题。技能写好了、注册成功了,模型却死活不触发。总结下来通常就是三种原因。

第一种是描述写成了“能力介绍”,而不是“触发条件”。模型无法从“本技能可以整理笔记”推导出“用户说笔记太乱时应该用它”。改成例句式描述,效果立竿见影:“当用户表示笔记混乱、需要按日期整理、或要求生成周报时,调用此技能”。

第二种是技能清单里相似技能太多,互相抢触发。模型一犹豫,就可能什么都不调用。解决方法是控制同一场景下的技能数量,相近技能合并成一个,或者给描述加上明确的“优先级”词,比如“优先于 xxx”。

第三种是参数定义卡得太死。如果必填参数太多,模型稍不确定就会放弃调用,改为“礼貌地请求用户补充信息”。程序员的直觉是“参数越全越好”,模型的想法是“调用一次要凑齐所有参数,太麻烦了,不如不调”。在关键参数必填、非关键参数给默认值的状态下,调用率会有明显提升。

4.2 报错:Agent execution terminated due to error

这个错误基本等于“技能运行时炸了”。排查顺序要跟剥洋葱一样,从外到内层层逼近。

第一步检查参数传递。到日志里看模型实际传入的参数,是不是路径写错、类型不是字符串、JSON 转义出了问题。这类错误常见于中文内容,路径里有中文空格或者引号时,模型生成的 JSON 很容易出错。

第二步手工跑脚本。在终端里用同样的参数执行技能脚本,看有没有退出码非 0、依赖缺失、路径权限问题。如果手工能跑通而 Agent 跑不通,基本可以断定是参数问题或者环境变量问题。

第三步关注输出长度和大对象。某些技能会返回超长 JSON,超出了模型或框架设定的上限,导致执行结果被截断,模型拿到半个字符串直接卡死。解决办法是在技能脚本内部就截断输出,只把摘要、状态码、关键数据返回给模型,大文件路径留在日志里。

第四步加日志。在框架层捕获脚本 stdout、stderr、退出码,统一写到执行记录里。我的经验是:报错不可怕,可怕的是报错信息没有上下文。日志里把“用户请求摘要 + 技能名 + 参数 + 退出码 + 报错尾部”五件事拼在一起,排查速度能快好几倍。

4.3 技能之间的冲突、可观测性与回滚

技能一多,还会遇到技能之间的“互踩”。最常见的是一个技能调用了另一个技能,中间出了错误,错误信息里只显示子技能的堆栈,根本看不出是哪条链路触发的。解决办法是给每个技能的执行记录加上唯一 ID,并在日志里记录调用链路,类似“技能A → 子技能B → 执行失败”。

回滚也容易被忽略。技能升级后,发现新版本反而不如旧版本稳定,这时候如果没有版本回退机制,就只能靠重新改代码救火。我建议在技能清单里为每个技能保留最近两个版本,切换时只需要改一个版本号字段,不需要改注册文件。这个成本很低,收益却很大。

还有一类问题是数据污染:技能在调试过程中改掉了用户真实数据。规避办法就是把技能执行环境和工作数据隔离,所有技能优先跑在一个临时副本目录里,确认输出没问题再写正式目录。生产环境的 Agent 需要这样的保护层。

5. 让技能从“能用”变成“好用”

5.1 先改描述,再改逻辑

技能调不动时,大部分新手会疯狂改执行脚本,加各种功能,希望“让模型更容易调用成功”。但实践里,触发率问题的根子九成在描述,不在脚本。脚本解决的是“调用后干得好不好”,描述解决的是“到底会不会被调用”。

我自己迭代技能的标准流程是这样的:先写一个最小可用版本,跑过一条真实用例确认“能用”,然后把全部精力放在描述上。对着几十条可能触发技能的测试用户话术,逐条看模型是调用技能还是忽略技能,如果某条话术命中不了,就把它变成描述里的一个触发例句。等命中率稳定下来,再去优化脚本内部逻辑。这个顺序基本不会错。

热词里有一批奇怪的东西,比如“skill 编码 247”“ponytail skill”,翻译过来其实都是一类现象:社区里已经有大量现成技能分享出来,比如 Claude Code 的技能插件、各类开源技能库。与其从零手搓,不如先看看别人怎么定义技能描述。很多技能仓库把一个复杂任务的高质量描述浓缩成几十行,那是反复测试后的“最优解”,直接借鉴比自己瞎调快得多。

5.2 多个技能如何编排成一个工作流

单技能解决单个意图,但真实用户请求往往是复合的。比如“把这几篇笔记整理一下,然后写一封邮件发给领导,顺便把封面图生成出来”,这至少涉及三个技能。

技能编排的思路很简单:Agent 作为调度器,按顺序调用技能,上一个技能的输出参数直接成为下一个技能的输入。这是经典的编排模式。如果流程很固定,也可以把编排规则写成一份独立配置,减少模型在每一步的自主决策空间:

steps: - skill: notes_to_weekly_report continue_on_error: false - skill: send_email needs: weekly_report_path - skill: cover_banner skip_if: weekly_content_is_empty - skill: post_to_channel notify: "@leader"

配合这种配置,模型就不需要自己思考“邮件的内容该引用哪个文件”,因为编排配置已经把依赖关系写死了。固定的流程固定化处理,不确定的分支交给模型,这是 Agent 做复杂任务稳定输出的关键。

设计具体执行调度时记住两个原则:默认串行,失败即停;只有确认“必须先拿到 A 才能做 B”时才建立依赖。能并行执行的步骤要并行执行,因为 Agent 调技能最贵的是墙钟时间,能省一秒是一秒。

5.3 评估与迭代:建立技能回归测试集

技能系统做久了,会有一个困惑:“这次改完到底有没有变好?”答案藏在一个所有人都不爱做的工作里:维护技能回归测试集。

做法很简单。每个技能准备 10 到 20 条具有代表性的测试输入,覆盖“正常触发”“相似意图不触发”“参数变换”“异常输入”。每次改技能之前,先跑一遍回归测试集,记录原触发率和正确率;改完之后再跑一遍,对比数据。如果正确率没有提升,那这次改动就应该打个问号。

用大模型评估到底调得对不对,相比人工检查会快得多。对每一条测试输入,记录三样东西:是否触发了目标技能、传入参数是否符合预期、最终执行结果是否正确。如果返回内容从“多了一句话没执行”变成“正确生成了文件路径”,那说明对的提升是对的。

最后分享一个我自己的习惯。给 Agent 接任何一个新技能之前,我一定会先跑一个大概十句的微型测试集,模拟真实用户口语。调不动就改描述,而不是改代码;执行错了就加日志,而不是加防御逻辑。技能系统的价值不在技能本身写得多么酷,而在于模型的每一次调用都稳定可预测,真正从“聊什么都行”变成“指哪打哪”。把这一层做扎实了,你的 Agent 才算是真正长了手。

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

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

立即咨询