☰
Agent-Skills工程化实践:从单体Prompt到可复用技能模块的架构演进
2026/10/21 5:24:12 网站建设 项目流程

1. 从“agent-skills”这个标题说起:它到底在解决什么问题

第一次看到“agent-skills”这个标题,我脑子里蹦出来的不是某个具体框架,而是一类很实际的需求:怎么让一个智能体(agent)真正具备可复用、可组合、可评估的能力模块。过去两年里,我参与过几个自动化流程项目,从最简单的“定时抓数据+发通知”,到稍微复杂一点的“多轮对话+工具调用+结果校验”,踩过的坑几乎都指向同一个根因——agent 的能力边界模糊,写的时候靠堆 prompt,跑起来靠运气,出了问题靠猜。

“agent-skills”这个标题,本质上是在给 agent 的能力做工程化拆解。它不是一个新概念,但它是把“agent 能干什么”从玄学变成可管理资产的关键一步。你可以把它理解成:以前我们写一个 agent,是把所有逻辑塞进一个巨大的系统提示词里,像把整个工具箱倒进一个麻袋;而 skills 的思路,是把每项能力单独封装成一个小模块,有明确的输入输出、触发条件、失败处理和评估标准,用的时候按需挂载。

这套东西适合谁?如果你正在做以下任何一件事,它都值得你花时间:

  • 你在维护一个多轮对话机器人,发现 prompt 越写越长,改一处崩三处;
  • 你在做自动化任务编排,需要 agent 调用外部工具,但工具一多就乱;
  • 你在评估 agent 效果,发现“它有时候行有时候不行”,却定位不到问题;
  • 你想把某个 agent 的能力迁移到另一个场景,结果发现耦合太深,搬不动。

我见过太多项目死在“能跑 demo 但上不了线”这个阶段,核心原因就是能力没有模块化。agent-skills 这个方向,解决的就是从“能跑”到“可维护、可扩展、可度量”之间的那道鸿沟。接下来我会从设计思路、核心细节、实操落地、问题排查四个层面,把这件事拆开讲透。

2. 整体设计思路:为什么要把 agent 能力拆成 skills

2.1 从“单体 prompt”到“技能模块”的演进逻辑

早期做 agent,最直接的做法是写一个超长系统提示词,把角色设定、任务描述、工具说明、输出格式、边界条件全塞进去。我试过最夸张的一次,系统提示词写到四千多字,结果模型开始“选择性遗忘”——前面说的规则后面不遵守,工具调用格式偶尔跑偏,多轮对话到第五轮之后基本失控。

这不是模型不行,而是信息密度和注意力分配的问题。人类团队做事也一样:你不可能让一个人同时记住所有流程、所有工具、所有异常处理,然后指望他每次都做对。合理的做法是分工——每个人有自己的职责说明书,需要谁就找谁。

Skills 的设计思路完全一致。每个 skill 是一个独立的能力单元,包含:

  • 触发条件:什么情况下该用这个 skill;
  • 输入契约:需要哪些参数,格式是什么;
  • 执行逻辑:具体怎么做,调什么工具,走什么流程;
  • 输出契约:返回什么结构,成功和失败分别长什么样;
  • 评估标准:怎么判断这次执行是合格的。

这样做的好处是,agent 的“大脑”只需要做一件事:判断当前该调用哪个 skill。至于 skill 内部怎么实现,大脑不需要关心。这就把复杂度从“全局”降到了“局部”,改一个 skill 不会影响其他 skill,加一个新 skill 也不需要动主流程。

2.2 技能拆分的粒度怎么定:三个实用原则

拆分粒度是这件事里最容易翻车的地方。拆得太粗,等于没拆;拆得太细,调用链长得像迷宫。我总结下来,有三个原则比较实用:

原则一:一个 skill 只做一件“可命名”的事。如果你没法用一句动宾短语说清楚它干什么,比如“查询订单状态”“生成周报摘要”“校验地址格式”,那说明它可能该继续拆,或者它根本不是一个 skill 而是一个流程。

原则二:skill 之间尽量不共享状态。每个 skill 拿到输入、产出输出,中间状态自己管理。如果两个 skill 需要频繁读写同一份上下文,那它们大概率应该合并,或者抽出一个共享的数据层。

原则三:按“失败模式”拆分。如果两个能力失败的原因完全不同,处理方式也完全不同,那就该拆开。比如“调用外部接口”和“解析返回结果”,前者失败要重试,后者失败要兜底,混在一起就没法分别处理。

我一般会先用一个表格把候选 skill 列出来,标注触发条件、输入、输出、失败模式,然后看哪些可以合并、哪些必须拆开。这个表格本身就是很好的设计文档。

2.3 技能注册与发现机制:让 agent 知道“自己会什么”

拆完 skill 之后,下一个问题是:agent 怎么知道有哪些 skill 可用?最笨的办法是把所有 skill 描述硬编码进系统提示词,但这样又回到了“信息过载”的老路。

比较稳妥的做法是技能注册表 + 动态注入。注册表里存每个 skill 的元信息:名称、描述、触发关键词、输入 schema、输出 schema。agent 在每一轮推理时,先根据当前上下文从注册表里检索出最相关的几个 skill,只把这几个的描述注入提示词。这样既控制了提示词长度,又保证了 agent 能发现合适的能力。

检索方式可以很简单,比如关键词匹配加向量相似度;也可以更复杂,比如用一个轻量分类模型判断意图。我实测下来,对于 skill 数量在 20 个以内的场景,关键词加描述匹配就够用了;超过 50 个,才需要考虑更精细的检索策略。

注意:技能注册表本身也需要版本管理。每次 skill 的输入输出契约变更,都要同步更新注册表,否则 agent 会按旧契约调用,导致莫名其妙的失败。

3. 核心细节解析:一个 skill 到底该包含什么

3.1 输入输出契约:比你想的更重要

很多人写 skill 的时候,输入输出定义得很随意,觉得“反正都是我自己用”。但一旦 skill 数量超过五个,或者需要多人协作,契约不清晰的问题就会集中爆发。

输入契约要明确几件事:参数名、类型、是否必填、默认值、取值范围。比如一个“查询天气”的 skill,输入应该是{city: string, date: string(YYYY-MM-DD), unit: "celsius"|"fahrenheit"},而不是“传个城市和日期就行”。后者看起来灵活,实际上调用方每次都要猜格式,出错率极高。

输出契约同样重要。我习惯把输出分成三个部分:

  • status:成功、失败、部分成功;
  • data:成功时的结构化数据;
  • error:失败时的错误码和可读信息。

这样调用方可以统一处理,不需要为每个 skill 写一套解析逻辑。下面是一个我常用的输出模板:

{ "status": "success", "data": { "result": "...", "metadata": {} }, "error": null }

失败时:

{ "status": "failed", "data": null, "error": { "code": "TIMEOUT", "message": "外部接口响应超时", "retryable": true } }

retryable这个字段很关键,它告诉 agent 这个失败能不能重试。没有它,agent 要么盲目重试导致雪崩,要么该重试的时候直接放弃。

3.2 触发条件设计:什么时候该用这个 skill

触发条件是 skill 的“入口开关”。设计得不好,会出现两种典型问题:该用的时候没触发,或者不该用的时候乱触发。

我的做法是给每个 skill 定义三类触发信号:

  • 显式关键词:用户输入里出现特定词汇,比如“查一下”“帮我算”“生成”;
  • 上下文状态:当前对话处于某个阶段,比如“已经收集完信息,等待执行”;
  • 前置条件:某些数据已经就绪,比如“用户已提供地址”。

这三类信号可以组合使用,但不要过度依赖单一信号。只靠关键词,容易误触发;只靠上下文,容易漏触发。我一般会设置一个简单的打分机制:每命中一个信号加一分,超过阈值才触发。阈值可以根据实际效果调整,初期宁可保守一点,避免乱触发。

实操心得:触发条件一定要写进 skill 的元信息里,并且和描述放在一起。这样检索的时候,描述和触发条件会一起被匹配到,提高召回准确率。

3.3 执行逻辑与工具调用:skill 内部的“小流程”

一个 skill 内部可能包含多个步骤,比如“查询订单”可能要先校验用户身份,再调订单接口,再格式化结果。这些步骤不需要暴露给 agent 主流程,但需要在 skill 内部管理好。

我通常会把 skill 内部逻辑写成一个小型状态机或者流水线,每个步骤有明确的输入输出和失败处理。这样做的好处是,skill 内部可以独立测试,不需要每次都跑整个 agent。

工具调用是 skill 执行中最容易出问题的环节。我的经验是:

  • 所有外部调用都要设超时,默认 5 到 10 秒,根据接口特性调整;
  • 所有外部调用都要有重试策略,但重试次数不要超过 3 次,且要加退避;
  • 所有外部调用的返回都要校验,不能假设对方一定返回预期格式;
  • 所有外部调用的失败都要有兜底,要么返回降级结果,要么明确告诉 agent 失败原因。

这些规则看起来琐碎,但每一条都是踩坑换来的。我见过因为没设超时导致整个 agent 卡死的,也见过因为没校验返回格式导致后续步骤全部报错的。

3.4 评估标准:怎么判断一个 skill 干得好不好

没有评估标准的 skill,等于没有质量保障。评估标准不需要很复杂,但必须可量化、可复现。

我一般从四个维度评估:

维度说明示例指标
准确性输出是否符合预期正确率、F1 值
稳定性多次执行结果是否一致方差、失败率
效率执行耗时和资源消耗平均耗时、token 消耗
健壮性异常输入下的表现异常捕获率、降级成功率

评估数据来源可以是人工标注的小样本,也可以是线上真实调用的采样。关键是每次 skill 变更后都要重新跑一遍评估,否则你永远不知道这次改动是优化还是劣化。

4. 实操过程:从零搭建一个可用的 agent-skills 体系

4.1 环境准备与基础结构

假设你现在要从零开始,我建议先搭一个最小可用的结构,不要一上来就追求大而全。基础目录可以这样组织:

agent-skills/ ├── skills/ │ ├── skill_a/ │ │ ├── manifest.json # 元信息:名称、描述、触发条件、输入输出 schema │ │ ├── handler.py # 执行逻辑 │ │ └── tests/ # 单元测试和评估用例 │ └── skill_b/ ├── registry/ │ └── index.json # 技能注册表 ├── core/ │ ├── loader.py # 加载 skill │ ├── router.py # 检索和路由 │ └── executor.py # 执行和错误处理 └── config/ └── settings.yaml # 超时、重试等全局配置

manifest.json是每个 skill 的核心,我一般会包含这些字段:

{ "name": "query_order_status", "description": "根据订单号查询当前订单状态", "triggers": { "keywords": ["订单", "查单", "物流"], "context": ["user_authenticated"], "preconditions": ["order_id_provided"] }, "input_schema": { "order_id": {"type": "string", "required": true} }, "output_schema": { "status": {"type": "string"}, "order_status": {"type": "string"}, "updated_at": {"type": "string"} }, "timeout_seconds": 8, "retry": {"max_attempts": 2, "backoff": 1.5} }

这个文件既是文档,也是运行时配置。加载器读它来注册 skill,路由器读它来匹配触发条件,执行器读它来设置超时和重试。

4.2 技能加载与注册的代码实现

加载器的逻辑很简单:遍历skills/目录,读取每个manifest.json,校验 schema,然后写入注册表。下面是一个简化版的 Python 实现:

import json import os from pathlib import Path def load_skills(skills_dir: str) -> dict: registry = {} for skill_path in Path(skills_dir).iterdir(): if not skill_path.is_dir(): continue manifest_file = skill_path / "manifest.json" if not manifest_file.exists(): continue with open(manifest_file, "r", encoding="utf-8") as f: manifest = json.load(f) validate_manifest(manifest) registry[manifest["name"]] = { "manifest": manifest, "path": str(skill_path), "handler": load_handler(skill_path) } return registry def validate_manifest(manifest: dict): required = ["name", "description", "input_schema", "output_schema"] for key in required: if key not in manifest: raise ValueError(f"manifest 缺少必填字段: {key}") if not manifest["name"].replace("_", "").isalnum(): raise ValueError(f"skill 名称只能包含字母、数字和下划线: {manifest['name']}")

load_handler负责动态导入执行逻辑,可以用importlib实现。这里的关键是加载时校验,不要等到运行时才发现 manifest 有问题。

注册表可以持久化成 JSON,也可以放在内存里。我一般会同时做:内存里存一份用于快速检索,磁盘上存一份用于审计和版本对比。

4.3 路由与执行:agent 怎么找到并调用 skill

路由的核心是匹配。给定当前上下文(用户输入、对话历史、已有数据),从注册表里找出最合适的 skill。我常用的匹配逻辑分三步:

  1. 关键词过滤:扫描用户输入,命中触发关键词的 skill 进入候选集;
  2. 上下文校验:检查候选 skill 的上下文条件和前置条件是否满足;
  3. 打分排序:根据命中信号数量、描述相似度、历史成功率综合打分,取最高分。
def route_skill(user_input: str, context: dict, registry: dict) -> list: candidates = [] for name, skill in registry.items(): manifest = skill["manifest"] score = 0 triggers = manifest.get("triggers", {}) for kw in triggers.get("keywords", []): if kw in user_input: score += 2 for ctx in triggers.get("context", []): if context.get(ctx): score += 1 for pre in triggers.get("preconditions", []): if context.get(pre): score += 1 if score > 0: candidates.append((name, score)) candidates.sort(key=lambda x: x[1], reverse=True) return [name for name, _ in candidates[:3]]

执行器负责调用 skill 的 handler,处理超时、重试和异常。这里有一个细节:超时控制要用独立的机制,不能依赖 handler 自己实现。我一般用线程池或者异步任务加超时,确保即使 handler 内部卡住,也能被强制中断。

import concurrent.futures def execute_skill(skill: dict, params: dict, timeout: int) -> dict: handler = skill["handler"] with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(handler, params) try: result = future.result(timeout=timeout) return {"status": "success", "data": result, "error": None} except concurrent.futures.TimeoutError: return { "status": "failed", "data": None, "error": {"code": "TIMEOUT", "message": "执行超时", "retryable": True} } except Exception as e: return { "status": "failed", "data": None, "error": {"code": "EXECUTION_ERROR", "message": str(e), "retryable": False} }

4.4 一个完整 skill 的示例:从定义到测试

假设我们要做一个“生成周报摘要”的 skill。它的职责是:输入一周的工作记录,输出一段结构化摘要。

manifest.json:

{ "name": "generate_weekly_summary", "description": "根据一周的工作记录生成结构化周报摘要", "triggers": { "keywords": ["周报", "总结", "摘要"], "context": ["weekly_records_available"], "preconditions": [] }, "input_schema": { "records": {"type": "array", "required": true}, "style": {"type": "string", "required": false, "default": "concise"} }, "output_schema": { "summary": {"type": "string"}, "highlights": {"type": "array"}, "blockers": {"type": "array"} }, "timeout_seconds": 15, "retry": {"max_attempts": 1, "backoff": 1.0} }

handler.py:

def handle(params: dict) -> dict: records = params["records"] style = params.get("style", "concise") if not records: raise ValueError("records 不能为空") highlights = [r for r in records if r.get("priority") == "high"] blockers = [r for r in records if r.get("status") == "blocked"] summary = build_summary(records, style) return { "summary": summary, "highlights": highlights, "blockers": blockers } def build_summary(records, style): total = len(records) done = len([r for r in records if r.get("status") == "done"]) if style == "concise": return f"本周共 {total} 项任务,完成 {done} 项。" return f"本周共处理 {total} 项任务,其中已完成 {done} 项,进行中 {total - done} 项。"

测试用例要覆盖正常输入、空输入、异常输入三种情况。我一般会写一个简单的测试脚本,每次 skill 变更后跑一遍:

def test_generate_weekly_summary(): skill = load_skill("generate_weekly_summary") # 正常输入 result = execute_skill(skill, {"records": [ {"title": "任务A", "status": "done", "priority": "high"}, {"title": "任务B", "status": "blocked", "priority": "low"} ]}, timeout=15) assert result["status"] == "success" assert "summary" in result["data"] # 空输入 result = execute_skill(skill, {"records": []}, timeout=15) assert result["status"] == "failed" assert result["error"]["code"] == "EXECUTION_ERROR"

这套结构跑通之后,加新 skill 就是复制目录、改 manifest、写 handler、加测试,流程非常固定。

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

5.1 技能不触发或误触发怎么办

这是最常见的问题,表现是 agent 该调用某个 skill 的时候不调用,或者不该调用的时候乱调用。排查思路分三步:

第一步,检查触发条件是否写得太窄或太宽。如果关键词只有一两个,很容易漏;如果关键词太泛,比如“帮我”,几乎每句话都能命中。我一般会先用一批真实输入跑一遍,统计每个 skill 的触发次数,明显偏高或偏低的都要调整。

第二步,检查上下文条件是否满足。很多时候不是关键词没命中,而是上下文条件没满足。比如 skill 要求user_authenticated,但实际流程里这个标记没被设置。这种问题要靠日志定位,我一般会在路由阶段打印每个候选 skill 的打分明细。

第三步,检查检索范围。如果 skill 数量多,但每次只检索 top 3,可能把正确的 skill 挤掉了。这时候要么扩大检索范围,要么优化打分逻辑,让正确 skill 的分数更高。

下面是一个排查速查表:

现象可能原因排查方法解决方向
完全不触发关键词未命中打印输入和关键词匹配结果补充关键词或改用模糊匹配
偶尔触发上下文条件不稳定检查上下文标记设置时机调整条件或增加兜底
频繁误触发关键词太泛统计触发频率收窄关键词或提高阈值
触发但执行失败输入契约不匹配校验实际参数和 schema修正调用方或放宽 schema

5.2 执行超时和外部依赖不稳定

外部依赖不稳定是 agent 体系里最头疼的问题之一。我的处理原则是:永远假设外部依赖会失败。

具体做法包括:

  • 每个外部调用设独立超时,不要用全局超时;
  • 重试要加退避,避免瞬间打爆对方;
  • 重试次数不要超过 3 次,超过就降级;
  • 降级方案要提前设计好,不能临时想。

我遇到过一个案例:某个 skill 调用外部接口,接口平均响应 2 秒,但偶尔会卡到 30 秒。最初没设超时,导致 agent 整体卡死。后来加了 8 秒超时和 2 次重试,失败率从 15% 降到了 2% 以下。

注意:超时时间不是越短越好。设得太短,正常请求也会被误杀;设得太长,失败请求会拖慢整体。我一般会先统计接口的 P95 响应时间,然后在此基础上加 50% 作为超时阈值。

5.3 技能之间互相干扰怎么排查

技能之间互相干扰,通常是因为共享了状态或者执行顺序有问题。比如 skill A 修改了某个全局变量,skill B 依赖这个变量,但 B 执行时 A 还没跑完。

排查这类问题,我一般会做两件事:

第一,给每个 skill 的执行加上唯一 trace id,所有日志都带上这个 id,方便追踪调用链。这样能清楚看到哪个 skill 在什么时候改了什么东西。

第二,尽量消除共享状态。如果两个 skill 必须共享数据,就把数据放在显式的上下文对象里,由主流程统一管理,skill 只读不写。写入操作要么由主流程做,要么通过明确的事件机制通知。

我见过最隐蔽的干扰是“隐式缓存”:skill A 调用外部接口后把结果缓存在内存里,skill B 也调同一个接口,拿到了 A 的缓存,但缓存已经过期。这种问题只能靠严格的缓存 key 设计和过期策略来避免。

5.4 评估结果波动大怎么定位

评估结果波动大,说明 skill 的行为不稳定。可能的原因有:

  • 输入数据分布变化,比如线上输入和测试集差异大;
  • 外部依赖返回结果不稳定;
  • 模型本身有随机性,比如温度参数设得太高;
  • 评估指标本身不够鲁棒,比如用单次结果而不是多次平均。

我的做法是:先固定随机种子,再固定输入集,然后多次运行取平均。如果这样波动还是大,就逐个排查外部依赖和模型参数。很多时候,把温度从 0.7 降到 0.2,波动就会明显减小。

另外,评估集要定期更新。我一般每个月会从线上采样一批新数据,补充到评估集里,确保评估结果能反映真实情况。

6. 一些实操心得和后续扩展方向

这套 agent-skills 体系我前后迭代了三个版本,最大的体会是:不要一开始就追求完美设计。第一版能跑通就行,哪怕 skill 拆得不够合理、触发条件写得粗糙,都没关系。关键是先把“注册-路由-执行-评估”这个闭环跑起来,然后在实际使用中逐步调整。

另一个心得是:skill 的文档和测试比代码本身更重要。代码可以重构,但如果没有文档和测试,重构就是灾难。我现在的习惯是,每加一个 skill,先写 manifest 和测试用例,再写 handler。这样能强迫自己想清楚输入输出和边界条件。

后续如果要扩展,我觉得有几个方向值得尝试:

  • 技能组合:把多个 skill 编排成一个复合 skill,对外暴露单一入口;
  • 自动评估:用另一个 agent 来评估 skill 的输出质量,减少人工标注;
  • 版本灰度:skill 更新时先在小流量上验证,确认没问题再全量;
  • 跨 agent 复用:把 skill 注册表做成独立服务,多个 agent 共享同一套能力。

最后分享一个小技巧:如果你不确定某个能力该不该拆成独立 skill,就先把它写成独立函数,观察它在多少个地方被调用、被谁调用、失败模式是否一致。如果调用点多、失败模式统一,就值得拆;如果只有一处用、失败模式还很杂,就先放着,等需求明确了再拆。这个判断方法我用了很多次,基本没出过错。

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

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

立即咨询