Agent Skills 在 2026 年的 Agent 应用工程化中,已经从一个概念名词变成了实际落地时绕不开的基础设施。很多人第一次接触 Agent Skills,是从“让大模型调用外部能力”开始的,但真正上手写技能时会发现:技能定义还算简单,注册、路由、参数校验、上下文传递、可观测性和生产发布才是麻烦来源。这篇博客从零开始,先讲清楚 Agent Skills 解决什么问题,再逐步搭建一个最小可运行的技能框架,覆盖概念、代码、验证、排错和生产规范。读完以后,你不仅能写出自己的 Agent Skill,还能知道什么时候该做路由策略、什么时候该补幂等、什么时候该把技能接入配置中心。
1. 先搞清楚 Agent Skills 到底是什么,别和工具、插件混在一起
1.1 用一个业务场景理解 Agent Skills 出现的原因
假设你要做一个客服 Agent。用户经常会问“我的订单到哪了”。如果不在工程层面做设计,比较容易想到的方案是:把订单查询接口告诉大模型,让它自己去拼参数调用。最初只有一两个接口时还好,当接口增加到十几个、几十个时,后果会很直接:
- System Prompt 越来越长,模型开始漏参数、记错接口。
- 某个接口改了字段名,要重新调试 Prompt。
- 每次会话把全部 API 列表塞给模型,成本和延迟都在上升。
- 无法控制哪些用户能调用哪些接口,权限不好做。
Agent Skills 的处理方式不一样。它把“订单查询”封装成一个独立技能,技能自身包含了名称、职责描述、参数说明、校验规则、执行逻辑和返回结构。Agent 不再需要理解订单查询 API 的细节,它只需要看技能目录,判断“当前用户意图匹配哪一个技能”,然后按技能声明的参数格式生成参数,剩下的由技能内部完成。API 出现变化时,只改技能内部实现;模型侧看到的技能元数据保持稳定,调用侧的扰动被限制住了。
这个转变的本质是:Agent 从“学会调用所有工具”变成“学会选择合适技能”,选择比执行更容易控制、更容易测试、更容易收敛。
1.2 Skills、Tool、Plugin、Workflow 的边界
这几个概念在社区讨论中经常混用,放在同一张表里对比会更清楚。
| 维度 | Tool(工具) | Plugin(插件) | Workflow(工作流) | Agent Skill(技能) |
|---|---|---|---|---|
| 抽象层次 | 单个函数或 API 的封装 | 可插入宿主应用的能力单元 | 多步骤业务流的编排 | 可路由、可复用的原子能力 |
| 调用方 | 代码、Agent、用户 | 平台或框架加载 | 编排器控制步骤流转 | Agent 按语义路由后调用 |
| 有无生命周期 | 通常没有 | 有安装、启停、卸载 | 有状态流转 | 有注册、发现、执行、审计 |
| 关键关注点 | 参数与返回值 | 装载与隔离 | 流转状态与边界 | 元数据、路由、校验、可观测 |
| 典型粒度 | 小 | 中 | 大 | 小到中 |
虽然它们之间可以互相借签实现,但工程上应该按职责区分:Tool 倾向于解决“怎么调用”,Plugin 倾向于解决“怎么集成”,Workflow 倾向于解决“多步怎么办”,Agent Skill 倾向于解决“Agent 知道在什么时候用谁,并且能安全执行”。
1.3 Agent Skills 的完整生命周期
一个 Agent Skill 在运行时大致经历以下阶段:
- 定义:声明技能的元数据、参数、版本和执行逻辑。
- 注册:把技能加入运行时能访问的注册表或配置中心。
- 发现:Agent 或调度器从目录中获取可用技能列表。
- 路由:根据用户意图选择命中技能,或判断无技能可用。
- 校验:检查模型生成的参数是否符合技能声明的契约。
- 执行:调用技能内部逻辑,完成业务动作。
- 返回:返回结构化结果,包含成功状态、数据或错误码。
- 审计:记录调用链路、耗时、版本、结果,便于排查和回滚。
理解这个生命周期之后,后面搭建代码时会刻意把每个阶段拆成独立模块,而不是把所有逻辑塞进一个 execute() 方法里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:先搭一个面试干净的技能项目骨架
2.1 为什么技能框架本身不需要绑定具体大模型厂商
写 Agent Skills 时,有人一开始就选择某个 Agent 框架或大模型 SDK。这里建议把技能层独立出来:技能不直接依赖大模型 SDK,而是只依赖自己的元数据、上下文和结果对象。这样做的原因有三个:
- 替换模型时不会影响技能实现。
- 测试时可以绕过模型,直接调用技能验证逻辑。
- 路由系统既可以让真实 LLM 决策,也可以临时用规则或人工决策。
下面示例以 Python 3.10+ 为主,因为它写类型标注和枚举类更直观。最后会给一个等价的 Java 接口骨架,方便 Spring 技术栈参考。
2.2 Python 版项目目录
创建一个名为 agent-skills-demo/ 的目录,内部结构如下:
code复制agent-skills-demo/
├── requirements.txt
├── run_demo.py
├── orchestrator.py
├── skills/
│ ├── __init__.py
│ ├── base.py
│ ├── registry.py
│ ├── order_status.py
│ └── datetime_util.py
└── tests/
└── test_order_status.py
这个目录刻意保持最小:base.py 放抽象类和数据结构,registry.py 放注册表,order_status.py 和 datetime_util.py 是两个示例技能,orchestrator.py 负责把 LLM 决策和技能调度接起来,run_demo.py 是可运行入口。
2.3 requirements 与第三方依赖
为了让技能层本身不背框架包袱,核心部分不引入第三方依赖。完整内容可以只有一行。
code复制# 可选的校验增强依赖,暂时不用也可以
# jsonschema>=4.20
实际项目如果使用 jsonschema 做严格参数校验,可以放开这一行;但初学阶段建议先把手动校验写出来,明白原理后再引入库。
2.4 Java 技术栈的等价骨架
如果你在 Java/Spring 生态,核心也是一样的。先定义接口,再交给 Spring 容器管理实现类。
java复制public interface AgentSkill {
SkillMeta meta();
SkillResult execute(SkillContext context);
}
java复制@Component
public class OrderStatusSkill implements AgentSkill {
@Override
public SkillMeta meta() {
return SkillMeta.builder()
.name("query_order_status")
.description("查询订单当前状态。当用户询问订单到哪里了、是否发货、物流进度时使用。")
.version("1.0.0")
.parameter(SkillParameter.builder().name("order_id").type("string").required(true).build())
.build();
}
@Override
public SkillResult execute(SkillContext context) {
String orderId = context.getParam("order_id");
// 业务实现
return SkillResult.success(Map.of("order_id", orderId, "status", "已发货"));
}
}
java复制@Component
public class SkillRegistry {
private final Map<String, AgentSkill> skills = new ConcurrentHashMap<>();
public SkillRegistry(List<AgentSkill> skillBeans) {
skillBeans.forEach(s -> skills.put(s.meta().name(), s));
}
public AgentSkill get(String name) {
return skills.get(name);
}
}
Spring 环境下,List<AgentSkill> 会被自动注入,注册逻辑就从手工 register() 变成了容器扫描。相比 Python 版本更简洁,但核心思想一致:技能通过元数据进入注册表,供上层路由使用。
2.5 环境检查清单
动手写代码前,按这个清单确认环境:
- Python 版本 3.10 或更高,能使用
list[SkillParameter]、dict[str, Any]这类写法。 - Java 示例需要 JDK 17 或更高,如果使用 Spring Boot,版本以你的项目实际依赖为准。
- 是否打算接入真实 LLM:如果接入,请提前确认 API 地址、模型名称和计费策略;建议先用本地模型或模拟函数把链路跑通。
- 是否打算跑测试:Python 可以用
unittest或pytest,不额外安装依赖。
3. 从零搭建最小 Agent Skills 框架:注册表、上下文和执行器
3.1 核心抽象:BaseSkill、SkillContext、SkillResult
先写 skills/base.py。这里面定义三个基础对象:技能元数据、执行上下文、执行结果。
python复制from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class SkillParameter:
name: str
type: str # string, number, integer, boolean, array, object
required: bool = False
description: str = ""
default: Any = None
example: Any = None
@dataclass
class SkillMeta:
name: str
description: str
version: str = "1.0.0"
parameters: list[SkillParameter] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
timeout_ms: int = 5000
@dataclass
class SkillContext:
session_id: str
user_id: str
params: dict[str, Any]
trace_id: str = ""
history: list[dict] = field(default_factory=list)
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class SkillResult:
status: str # success, failed, retry
data: Any = None
error_code: str = ""
error_message: str = ""
@classmethod
def ok(cls, data: Any):
return cls(status="success", data=data)
@classmethod
def fail(cls, code: str, message: str):
return cls(status="failed", error_code=code, error_message=message)
class BaseSkill(ABC):
meta: SkillMeta
@abstractmethod
def execute(self, ctx: SkillContext) -> SkillResult:
"""执行技能,返回结构化结果。"""
def validate(self, params: dict) -> list[str]:
"""根据 meta.parameters 做基础校验。"""
errors = []
for p in self.meta.parameters:
if p.required and p.name not in params:
errors.append(f"缺少必填参数: {p.name}")
if p.name in params and p.type == "integer":
value = params[p.name]
if not isinstance(value, int):
try:
params[p.name] = int(value)
except (TypeError, ValueError):
errors.append(f"参数 {p.name} 需要是整数")
return errors
这里有几个设计决策需要解释:
SkillMeta.timeout_ms放在元数据里,是因为不同技能对耗时的容忍度不同。读缓存类技能一般 1 到 2 秒,调用第三方写接口可能需要更长时间。执行器读到这个字段后可以单独设置超时。SkillResult.status使用success/failed/retry三种状态,而不是直接抛异常。retry表示“暂时失败但可以重试”,便于上层调度策略决定是否需要二次执行。SkillContext包含session_id、user_id和trace_id,这三个字段是生产排查的基础。技能里所有业务日志都应该带上它们。
3.2 注册表:让技能可以被查找和列出
skills/registry.py 提供最基础的内存注册表。
python复制from typing import Optional
from .base import BaseSkill, SkillMeta
class SkillRegistry:
def __init__(self):
self._skills: dict[str, BaseSkill] = {}
def register(self, skill: BaseSkill):
if skill.meta.name in self._skills:
raise ValueError(f"技能重复注册: {skill.meta.name}")
self._skills[skill.meta.name] = skill
def get(self, name: str) -> Optional[BaseSkill]:
return self._skills.get(name)
def list_meta(self) -> list[SkillMeta]:
return [s.meta for s in self._skills.values()]
内存注册表适合学习和单机部署。生产环境可以换成“配置中心 + 本地缓存”的模式:配置中心保存技能版本和开关状态,服务启动时拉取并在本地构建索引。好处是新增技能可以灰度发布,某个技能出现问题时可快速下线。
3.3 条件分发:让 Agent 决定“什么时候调用哪个技能”
技能本身不应该决定自己是否被调用,这是调度器或 Agent 的工作。最简单的规则分发是关键词匹配,适合用来做单元测试:
python复制def route_by_tags(registry: SkillRegistry, intent: str) -> Optional[BaseSkill]:
for meta in registry.list_meta():
for tag in meta.tags:
if tag in intent:
return registry.get(meta.name)
return None
这种规则在真实场景不够稳健,所以主链路要交给 LLM 做路由。做法是:把技能目录以结构化形式传给模型,要求模型返回一个 JSON,字段只包含 skill 和 params。模型不负责执行 API,只负责选择技能和抽取参数。
下面把这个逻辑放进 orchestrator.py。
python复制import json
from skills.base import BaseSkill, SkillContext, SkillResult
from skills.registry import SkillRegistry
def build_skill_catalog(registry: SkillRegistry) -> list[dict]:
catalog = []
for meta in registry.list_meta():
params = [
{
"name": p.name,
"type": p.type,
"required": p.required,
"description": p.description,
"example": p.example,
}
for p in meta.parameters
]
catalog.append({
"name": meta.name,
"description": meta.description,
"parameters": params,
})
return catalog
class SkillOrchestrator:
def __init__(self, registry: SkillRegistry, llm_decision_func):
self.registry = registry
self.llm_decision_func = llm_decision_func
def run(self, user_message: str, context: dict) -> SkillResult:
catalog = build_skill_catalog(self.registry)
decision_text = self.llm_decision_func(
system_prompt=(
"你是技能调度器。根据用户消息,从技能目录中选择最合适的一个技能,"
"并抽取参数。只输出 JSON,不要解释。JSON 格式:"
'{"skill": "技能名", "params": {...}}'
),
user_message=json.dumps(
{"user_message": user_message, "skills": catalog},
ensure_ascii=False,
),
)
try:
decision = json.loads(decision_text)
except json.JSONDecodeError:
return SkillResult.fail("ROUTING_FAILED", "LLM 路由输出不是合法 JSON")
skill_name = decision.get("skill", "")
params = decision.get("params", {})
skill = self.registry.get(skill_name)
if skill is None:
return SkillResult.fail("SKILL_NOT_FOUND", f"技能不存在: {skill_name}")
return self._execute_skill(skill, params, context)
def _execute_skill(self, skill: BaseSkill, params: dict, context: dict) -> SkillResult:
errors = skill.validate(params)
if errors:
return SkillResult.fail("PARAM_ERROR", "; ".join(errors))
skill_context = SkillContext(
session_id=context.get("session_id", ""),
user_id=context.get("user_id", ""),
trace_id=context.get("trace_id", ""),
params=params,
history=context.get("history", []),
)
return skill.execute(skill_context)
这种设计把“LLM 路由”和“技能执行”解耦了。llm_decision_func 可以是任何函数:在测试时传 mock,在生产时传 OpenAI 兼容接口的调用封装。
3.4 两个示例技能和一个可运行 Demo
写一个订单查询技能和当前时间技能,便于后面验证不同参数类型。
python复制# skills/order_status.py
from .base import BaseSkill, SkillMeta, SkillParameter, SkillContext, SkillResult
class OrderStatusSkill(BaseSkill):
meta = SkillMeta(
name="query_order_status",
description="查询订单当前状态。当用户询问订单到哪里了、是否发货、物流进度时使用。",
version="1.0.0",
tags=["order", "shipment", "物流"],
parameters=[
SkillParameter(
name="order_id",
type="string",
required=True,
description="订单编号,例如 PO-2026-0001",
example="PO-2026-0001",
),
],
timeout_ms=3000,
)
def execute(self, ctx: SkillContext) -> SkillResult:
order_id = ctx.params.get("order_id", "")
# 这里应为真实的接口调用或缓存读取,示例直接返回固定数据
return SkillResult.ok({
"order_id": order_id,
"status": "已发货",
"eta": "2026-03-02",
})
python复制# skills/datetime_util.py
from datetime import datetime
from .base import BaseSkill, SkillMeta, SkillParameter, SkillContext, SkillResult
class CurrentTimeSkill(BaseSkill):
meta = SkillMeta(
name="get_current_time",
description="获取当前系统时间。当用户询问现在几点、今天日期时使用。",
version="1.0.0",
parameters=[],
timeout_ms=1000,
)
def execute(self, ctx: SkillContext) -> SkillResult:
now = datetime.now()
return SkillResult.ok({
"datetime": now.isoformat(timespec="seconds"),
})
run_demo.py 使用一个模拟的 LLM 决策函数,不请求任何外部模型,方便第一次跑通。
python复制import json
from skills.registry import SkillRegistry
from skills.order_status import OrderStatusSkill
from skills.datetime_util import CurrentTimeSkill
from orchestrator import SkillOrchestrator
def mock_llm_decision(system_prompt: str, user_message: str) -> str:
# 模拟 LLM 决策:总是选中订单查询技能
return json.dumps({
"skill": "query_order_status",
"params": {"order_id": "PO-2026-0001"},
}, ensure_ascii=False)
if __name__ == "__main__":
registry = SkillRegistry()
registry.register(OrderStatusSkill())
registry.register(CurrentTimeSkill())
orchestrator = SkillOrchestrator(registry, mock_llm_decision)
result = orchestrator.run(
user_message="我的订单 PO-2026-0001 到哪了?",
context={
"session_id": "session-001",
"user_id": "user-001",
"trace_id": "trace-001",
"history": [],
},
)
print(result)
运行 python run_demo.py 后,预期输出如下:
code复制SkillResult(status='success', data={'order_id': 'PO-2026-0001', 'status': '已发货', 'eta': '2026-03-02'}, error_code='', error_message='')
这里要注意:实际生产中的 LLM 决策函数不应该由技能调用方直接传参生成,而应该由运行时统一封装,并记录 LLM 原始返回,方便出现问题时还原现场。
4. 做好参数契约,比写业务逻辑更值得花时间
4.1 参数声明会直接影响 LLM 的生成质量
在 Agent Skills 里,参数契约不是只做后端校验,更是“指导大模型生成正确参数”的提示词模板。同一个技能,参数声明写得含糊和写得具体,LLM 抽取出来的结果会有明显差别。
下面是一个不推荐的写法:
python复制SkillParameter(
name="order_id",
type="string",
required=True,
description="订单ID",
)
推荐的写法至少包含类型、必填、说明和一个示例值:
python复制SkillParameter(
name="order_id",
type="string",
required=True,
description="订单编号,例如 PO-2026-0001",
example="PO-2026-0001",
)
原因很直接:LLM 不是按代码逻辑理解参数,而是按语义和示例理解参数。一条准确的描述和示例,比后期在代码里做各种参数补全要省事得多。
4.2 参数解析与校验
参数校验的目标是在技能执行前拦截错误输入,避免把脏数据送进业务接口。最简单的手写校验在前面的 BaseSkill.validate() 里已经实现了。更严格的方案是使用 JSON Schema。
以订单查询技能为例,它的 JSON Schema 如下:
json复制{
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单编号",
"example": "PO-2026-0001"
},
"consignee_phone_last4": {
"type": "string",
"description": "收件人手机尾号,用于身份核验",
"minLength": 4,
"maxLength": 4
}
},
"required": ["order_id"]
}
引入 jsonschema 库后,校验代码可以抽成一个公共工具:
python复制from jsonschema import validate, ValidationError
def validate_with_schema(schema: dict, params: dict) -> list[str]:
try:
validate(instance=params, schema=schema)
return []
except ValidationError as e:
return [e.message]
这里建议把 JSON Schema 存在技能元数据旁边,而不是散落在业务代码里。校验错误发生时,上层直接返回 PARAM_ERROR,并携带可读的错误信息。
4.3 复杂参数:数组、嵌套对象和多种输入方式
真实业务里,参数不会总是字符串、整数。批量查询、购物车结算这类场景需要数组和嵌套对象。
json复制{
"type": "object",
"properties": {
"order_ids": {
"type": "array",
"items": {
"type": "string"
},
"description": "订单编号列表",
"minItems": 1,
"maxItems": 20
},
"query_type": {
"type": "string",
"enum": ["latest", "all"],
"description": "查询类型,默认 latest"
}
},
"required": ["order_ids"]
}
复杂参数的难点不在校验库本身,而在示例。模型看到 example 字段时,更容易生成符合结构的 JSON。SkillParameter 里的 example 字段在序列化成技能目录时,应该始终保留下来。
4.4 错误信息要结构化,不要只抛异常
很多初学者在技能内部遇到业务错误时直接抛异常,这会让 Agent 陷入“看不到为什么失败”的状态。技能应该统一返回 SkillResult,并用错误码表达失败类型。
| 错误码 | 含义 | 是否可重试 |
|---|---|---|
MISSING_PARAM |
缺少必填参数 | 否 |
PARAM_ERROR |
参数格式或取值错误 | 否 |
SKILL_NOT_FOUND |
技能不存在 | 否 |
EXEC_FAILED |
技能内部业务失败 | 视情况 |
TIMEOUT |
技能执行超时 | 是 |
FORBIDDEN |
用户无权限调用该技能 | 否 |
错误信息里的 error_message 既要给 Agent 看,也要给人看。给 Agent 看的目的是让它调整下一次调用;给人看的目的是排查日志时的可读性。因此不要把完整参数和内部堆栈塞进返回结果。
5. 运行验证:从一次调用到完整链路排查
5.1 验证目标不是“跑通”,而是要覆盖关键分支
只验证“技能被调用成功”还不够。一个合格的 Agent Skills 验证用例至少要覆盖:
- 正常调用并返回结构化结果。
- 缺少必填参数时返回
PARAM_ERROR。 - 路由目标不存在时返回
SKILL_NOT_FOUND。 - LLM 返回非法 JSON 时返回
ROUTING_FAILED。 - 技能执行超时时能被打断。
- 日志中是否出现
skill_name、version、trace_id等字段。
5.2 单技能验证
单技能验证不经过模型,直接构造 SkillContext 调用 execute()。这是速度最快、定位最准的验证方式。
python复制import unittest
from skills.order_status import OrderStatusSkill
from skills.base import SkillContext
class TestOrderStatusSkill(unittest.TestCase):
def test_正常返回(self):
skill = OrderStatusSkill()
ctx = SkillContext(
session_id="s1",
user_id="u1",
params={"order_id": "PO-2026-0001"},
)
result = skill.execute(ctx)
self.assertEqual(result.status, "success")
self.assertEqual(result.data["order_id"], "PO-2026-0001")
def test_缺少参数时校验失败(self):
skill = OrderStatusSkill()
errors = skill.validate({})
self.assertIn("缺少必填参数: order_id", errors)
if __name__ == "__main__":
unittest.main()
运行命令:
bash复制python -m unittest tests/test_order_status.py -v
预期看到两个用例全部通过。这里要特别指出:校验逻辑属于技能自身的契约,应该在 validate() 中覆盖,不要依赖上层误传。
5.3 E2E 验证:从用户消息到技能返回
E2E 验证还是一条完整的 Agent 调用链路:用户消息 -> LLM 路由 -> 参数抽取 -> 技能执行 -> 结果返回。用真实模型时,务必把 LLM 的原始返回 JSON 打印出来,因为大量跳错问题都出在这一步。
bash复制python run_demo.py
如果使用真实模型,把 mock_llm_decision 替换成对应厂商的调用函数。这里给一个 OpenAI 兼容接口的调用示例,格式在不同模型服务上基本通用:
python复制import json
from openai import OpenAI
def llm_decision(system_prompt: str, user_message: str) -> str:
client = OpenAI() # 通过环境变量配置 api_key 和 base_url
resp = client.chat.completions.create(
model="gpt-4o-mini", # 按实际使用的模型调整
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
temperature=0,
)
return resp.choices[0].message.content
注意:实际使用时模型名称、API 地址、计费方式都要按你的供应商确认。调试阶段建议先打印原始返回,确认 JSON 结构稳定后再接入技能框架。
5.4 日志、追踪和调用指标
技能调用一旦多了,最痛苦的就是定位“为什么某一次调用没有触发技能”。推荐在 SkillOrchestrator 的外层统一记录如下字段:
| 字段 | 示例值 | 用途 |
|---|---|---|
trace_id |
trace-20260301-0001 |
串联一次 Agent 会话的完整调用 |
session_id |
session-001 |
定位会话上下文 |
user_id |
user-001 |
定位权限和审计对象 |
skill_name |
query_order_status |
定位路由结果 |
skill_version |
1.0.0 |
定位技能版本差异 |
status |
success |
判断执行结果 |
duration_ms |
120 |
判断性能 |
error_code |
PARAM_ERROR |
快速分类错误 |
生产项目可以直接输出 JSON 日志,或者接 OpenTelemetry 这类追踪体系。初学阶段至少保证这些字段出现在日志里,排查时才有抓手。
5.5 预期输出与异常输出对照
- 正常返回:
json复制{
"status": "success",
"data": {
"order_id": "PO-2026-0001",
"status": "已发货",
"eta": "2026-03-02"
}
}
- 参数错误:
json复制{
"status": "failed",
"error_code": "PARAM_ERROR",
"error_message": "缺少必填参数: order_id"
}
- 路由失败:
json复制{
"status": "failed",
"error_code": "SKILL_NOT_FOUND",
"error_message": "技能不存在: query_battery_level"
}
Agent 拿到这些结构化结果后,可以直接生成给用户的回复。这也是为什么结构化状态比异常更有价值:模型能从错误码理解下一步该做什么,是补充参数、更换说法,还是转人工。
6. 常见问题排查:按现象倒推原因
6.1 技能根本没有被触发
这是最常见的问题。用户消息已经表达了意图,但 Agent 没有调用任何技能。
可能原因:
description写得太宽,技能看起来和用户问题不相关。- 技能目录没有正确传给 LLM。
- LLM 返回了技能名,但
SkillRegistry里注册名不一致。
排查方式:
- 打印
registry.list_meta(),确认技能目录完整。 - 打印 LLM 决策函数的原始输出,确认返回 JSON 中的
skill字段。 - 检查技能元数据里的
name是否和注册时一致。
处理建议:
- 按照“当用户询问某某场景时使用”的模板重写描述。
- 在 LLM Prompt 中增加“如果没有合适技能,返回 SKILL_NOT_FOUND”。
6.2 参数抽取总是缺字段或格式不正确
现象是技能被命中,但校验失败,返回 PARAM_ERROR。
排查优先看 LLM 的原始输出,不要直接改后端校验。大多数情况是参数声明不够具体。
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 必填字段缺失 | 参数没标 required |
查看技能目录 JSON | 在 Schema 中设置 required |
| 类型错误 | 描述里没给类型和示例 | 查看 LLM 输出 JSON | 增加 type 和 example |
| 字段名不一致 | 代码里叫 order_id,Prompt 里叫 orderId |
对比代码和 LLM 返回 | 统一为 snake_case |
| 数组结构错误 | 没给 items 结构示例 |
查看 LLM 输出 JSON | 在 SkillParameter 中给嵌套示例 |
6.3 上下文串线
出现“A 用户收到了 B 用户的订单数据”这类问题时,多数原因是 SkillContext 复用了同一份 params 或同一个上下文对象。
检查方式:
- 查看日志中的
session_id和user_id。 - 检查代码里是否创建了全局
SkillContext,并在多个请求中复用。 - 检查并发执行时
params是否被共享修改。
处理建议:
- 每个请求必须新建
SkillContext。 - 不要把原始用户消息之外的全量上下文传给技能,只传技能执行必要的字段。
- 生产环境通过 trace 体系检查上下文生命周期。
6.4 技能重复执行或递归循环
Agent 可能在重试、超时或用户追问时重复调用同一个技能。如果技能是写操作,这会直接造成业务副作用。
处理建议:
- 技能声明中增加幂等能力,写操作必须支持幂等键。
- 在技能元数据中标注
idempotent: true/false。 - 调度器对非幂等技能默认不自动重试。
幂等键可以直接作为参数传入:
json复制{
"skill": "create_refund_order",
"params": {
"order_id": "PO-2026-0001",
"idempotency_key": "refund-20260301-abc"
}
}
执行层先检查幂等键是否处理过,处理过则直接返回上一次结果。
6.5 超时与并发冲突
技能执行超过元数据里的 timeout_ms 时,调度层应该能打断执行并返回 TIMEOUT。不要在技能内部写无限 while 循环,也不要让调度线程长时间阻塞。
推荐做法:
- 读操作默认控制在 3 秒以内。
- 写操作需要考虑第三方接口响应时间,但也应设置上限。
- 对并发量高的技能,增加限流和排队策略。
6.6 排错顺序清单
排查 Agent Skills 问题时,按这个顺序来:
- 输入:用户消息和上下文字段是否完整。
- 目录:技能元数据是否成功注册,描述是否会让模型误判。
- Schema:参数声明是否覆盖了数据类型、必填、示例。
- LLM 输出:原始返回 JSON 是否符合预期。
- 技能执行:
validate()是否通过,业务实现是否正确。 - 日志:
trace_id、skill_name、duration_ms是否记录完整。
7. 学习环境与生产环境的差异:从上手到上线
7.1 一张表看清两者差异
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 技能注册 | 内存字典 | 配置中心 / 数据库 + 本地缓存 |
| 路由 | 规则或单次 LLM 决策 | LLM 路由 + 兜底规则 + 人工审核 |
| 参数校验 | 手动写几个校验 | JSON Schema + 契约测试 |
| 鉴权 | 忽略 | 用户级权限 + 技能级 ACL |
| 日志 | print |
结构化日志 + trace_id |
| 超时 | 不设置 | 按技能分类设置超时和熔断 |
| 幂等 | 不关注 | 写操作必须支持幂等键 |
| 发布 | 直接改代码运行 | 版本管理、灰度、回滚 |
| 监控 | 看控制台输出 | 指标、告警、看板 |
7.2 生产环境要补齐的五件事
第一,注册表外部化。技能开关、版本号、路由优先级应该来自配置中心,而不是只靠代码里注册。出现某个技能异常时,可以快速下线。
第二,技能级权限。每个技能声明允许哪些用户或角色调用,调度前做鉴权。不能把全部技能目录暴露给所有用户。
第三,配置外置。模型名称、API 地址、超时值、重试次数都放到配置系统里,不要硬编码在技能代码中。
第四,可观测。接入结构化日志、调用链追踪和指标采集。每次技能调用至少要记录耗时、状态、错误码、技能版本。
第五,版本与回滚。技能要带上语义化版本号,执行日志记录版本。发布新版本后如果出现回归,能快速切回旧版本或下线新版本。
注意:不要只验证技能能启动。生产发布的验收必须包含异常分支验证,比如第三方接口超时、参数错误、用户无权限、重复调用时幂等键命中,这些分支往往才是事故源头。
8. 面向生产的 Agent Skills 工程规范
8.1 命名和描述模板
技能命名建议采用 动词_对象_范围 的 snake_case 形式:
| 推荐命名 | 实际含义 |
|---|---|
query_order_status |
查询订单状态 |
create_refund_order |
创建退款单 |
send_email_remind |
发送邮件提醒 |
calc_shipping_fee |
计算运费 |
描述推荐按模板写:
code复制当用户【场景描述】且需要【目标】时使用。
输入:需要哪些参数。
输出:返回什么数据。
不要用于:哪些场景。
对比一组描述:
- 不推荐:
查询订单。 - 推荐:
查询订单当前状态。当用户询问订单到哪里了、是否发货、物流进度时使用。输入订单号 order_id。返回订单状态和预计到达时间。不要用于创建订单或修改订单。
第二段描述明显能降低 LLM 误路由概率。“不要用于”这一段很容易被忽略,但它能避免技能被用于反方向意图。
8.2 幂等、重试和超时
不要在每个技能里自己定义一套重试逻辑。建议在运行时做统一约束:
- 读技能:超时后不自动重试,返回
TIMEOUT,由 Agent 决定是否换一种问法。 - 写技能:必须先透传幂等键,超时后可重试但必须带上同一个幂等键。
- 重试次数:默认最多 1 次,重试间隔遵循退避策略,不要立刻打第二次。
8.3 权限与数据安全
技能参数来自 LLM 抽取,这意味着来源不可信。所有参数在进入业务系统之前都要走校验和脱敏。日志中不要打印完整参数,尤其是手机号、身份证、登录凭证这类敏感信息,只记录参数名或哈希值。
user_id 应该由 Agent 运行时从受信的会话上下文注入,技能内部不要信任前端传入或 LLM 抽取的用户身份。技能在查询业务数据前,要做对象级权限检查,否则用户可能通过构造参数越权访问他人数据。
8.4 发布前检查清单
给每个技能,发布前过一遍这份清单:
- [ ] 技能描述是否通过至少 5 条真实用户语料的测试。
- [ ] 参数是否声明了
type、required、description、example。 - [ ] 校验失败是否返回结构化
PARAM_ERROR,而不是抛异常。 - [ ] 日志是否会记录
skill_name、version、status、duration_ms。 - [ ] 是否设置了
timeout_ms,超时后是否有兜底返回。 - [ ] 写操作是否支持幂等键。
- [ ] 是否配置了技能级权限校验。
- [ ] 是否对敏感参数做脱敏。
- [ ] 版本号是否递增,旧版本是否可以回滚。
- [ ] 是否在测试环境跑过“正常、缺参、无权限、超时”四条分支。
Agent Skills 的价值核心不在 execute() 方法内部,而在技能定义、注册、路由、参数契约、可观测性和发布规范这些外围机制上。初学者最该做的不是马上去读某个 Agent 框架的源码,而是亲手写两个技能,跑通从用户消息到参数校验再到执行返回的完整链路,然后再把链路里的每个环节都加上日志和兜底。下一步可以往两个方向扩展:一是把多个技能组合成业务工作流,二是建立技能误触发评测集,用一批真实语料验证描述和路由效果。先理解最小系统,再谈复杂编排,这条路比上来造框架稳得多。
