摘要:许多团队在接入大模型时,第一步实现“能对话”后,第二步遇到的瓶颈往往是“不能可靠执行”。Skill的本质并非简单地给模型增加几个函数,而是将企业系统中的可控能力,以可治理、可审计、可扩展的方式交付给Agent。本文围绕Spring AI Alibaba Skill的定义、注册与渐进式披露,结合电商客服与售后场景,系统性地解析其底层原理、架构设计、生产级实现、高并发治理与工程实践。
一、为什么Skill是企业级Agent的分水岭
1.1 从“回答问题”到“完成任务”
大模型天然擅长语言理解与生成,但企业系统更关心三类结果:
- 用户问题是否被准确识别
- 业务动作是否被可靠执行
- 整个过程是否可观测、可审计、可回滚
以电商客服为例,用户一句“帮我查一下订单ORD20260402001为什么还没发货,能不能顺便申请退款”,在企业系统中通常意味着一条完整链路:
- 意图识别:查询订单 + 判断退款资格
- 读取上下文:订单状态、支付状态、仓配状态、售后规则
- 决策执行:满足条件则发起退款或售后申请
- 风险控制:校验幂等、权限、库存、金额、时效
- 结果回传:生成自然语言答复,并附带业务结果
如果只有RAG,系统最多回答“退款规则是什么”;如果有Skill,Agent才真正具备“查、算、调、写”的执行能力。
1.2 Skill不是简单函数调用,而是业务能力契约
很多文章会把Skill等同于Function Calling,但在企业系统里,二者差别很大:
| 维度 |
简单 Function Calling |
企业级 Skill |
| 暴露对象 |
单个函数 |
稳定的业务能力契约 |
| 参数描述 |
手写 JSON Schema |
注解 + 类型系统 + 校验模型 |
| 生命周期 |
临时拼装 |
由 Spring 容器托管 |
| 调用目标 |
单机函数 |
可映射到本地服务、远程服务、编排服务 |
| 失败处理 |
调用失败即结束 |
超时、重试、熔断、降级、补偿 |
| 安全性 |
依赖 Prompt 约束 |
权限、脱敏、审计、策略控制 |
| 可观测性 |
基本无 |
Trace、Metrics、Audit 全链路 |
所以,Skill的核心价值不是“让模型能调用代码”,而是“让模型以受控方式使用企业能力”。
1.3 适合Skill化的业务能力
不是所有能力都适合直接暴露给Agent。一般建议优先Skill化以下类型:
- 查询型:订单查询、库存查询、价格查询、政策查询
- 决策辅助型:运费估算、退款资格判断、优惠方案推荐
- 受控写入型:创建工单、预约服务、生成草稿、提交审批申请
- 编排型:聚合多个内部服务,输出统一结果
不建议一开始就直接暴露:
- 高风险强写操作:直接退款、直接扣款、直接删除数据
- 非幂等且无补偿能力的操作
- 需要复杂人工复核的操作
正确路径通常是:先暴露查询和试算,再暴露草稿生成,最后才是受控提交。
二、Spring AI Alibaba Skill的核心定位
Spring AI Alibaba对Skill的价值,不在于把@Tool换个名字,而在于它让Skill进入Spring生态,拥有:
- 容器管理:Bean生命周期、依赖注入、配置装配
- 类型约束:参数对象、校验注解、枚举、统一返回模型
- 可治理能力:拦截器、AOP、限流、熔断、监控、审计
- 组合能力:可接入Spring AI、微服务、消息队列、缓存与配置中心
这使得Skill从“Demo代码”升级为“可生产运维的执行单元”。
2.1 一个推荐的分层视角
在工程实践中,我们建议把Skill放在如下分层中:
各层职责建议如下:
Agent Orchestrator:负责对话驱动、工具选择、结果编排
Skill Facade:负责把业务能力包装成可调用Skill
Domain Service:承载真实业务规则,不直接暴露给模型
Observability / Audit / Policy:对Skill做统一治理
一个很重要的原则是:不要把核心业务逻辑直接写在@Tool方法里,Skill只负责协议转换与边界控制。
三、Skill的底层原理:定义、注册、执行是如何串起来的
3.1 定义阶段:从Java方法到模型可理解的能力描述
一个最小的Skill看起来很简单:
@Component
public class OrderSkill {
@Tool(description = "根据订单号查询订单状态、支付状态和物流状态")
public OrderQueryResult queryOrder(OrderQueryCommand command) {
return orderApplicationService.queryOrder(command);
}
}
但框架真正做的事情至少包括四步:
- 扫描包含
@Tool注解的方法
- 解析方法名、描述、参数类型、返回类型
- 推导模型可识别的参数结构
- 在ChatClient或ToolCalling组件中注册为可调用能力
本质上,框架在做两类转换:
- 开发者视角:Java方法签名
- 模型视角:结构化工具定义
例如下面的参数对象:
public record OrderQueryCommand(
@NotBlank String userId,
@NotBlank String orderNo,
@Size(max = 32) String requestId
) {}
会被转换为模型更容易理解的结构化输入模型。比起直接暴露多个松散参数,这种方式有三个明显优势:
- 参数语义更稳定
- 更适合校验与扩展
- 更适合后续做版本演进
3.2 注册阶段:Spring容器如何接管Skill生命周期
Skill的注册本质上不是“加到一个List里”,而是由Spring容器在应用启动期完成统一发现与装配。
一个简化的注册流程可以理解为:
@Configuration
public class SkillConfiguration {
@Bean
public List<ToolCallback> toolCallbacks(ApplicationContext applicationContext) {
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class);
return beans.values().stream()
.flatMap(bean -> Arrays.stream(bean.getClass().getMethods())
.filter(method -> method.isAnnotationPresent(Tool.class))
.map(method -> ToolCallbacks.from(bean, method)))
.toList();
}
}
这里最关键的不是反射本身,而是它带来的两个工程收益:
- Skill与业务Bean解耦,注册不再手工维护
- 能统一接入AOP、事务、校验、指标和策略
当模型决定调用某个Skill时,运行时链路通常如下:
运行时真正需要控制的关键点包括:
- 模型是否有权看到该Skill
- 模型生成的参数是否可信
- Skill是否允许在当前上下文执行
- 执行超时、失败、并发冲突时如何兜底
- 返回结果如何做脱敏和压缩
这也是为什么生产环境里,Skill的重点不是“能不能调通”,而是“能不能长期稳定运行”。
四、渐进式披露:为什么企业级Agent不能把全部Skill一次性暴露给模型
4.1 渐进式披露的本质
所谓渐进式披露,可以理解为:
根据当前用户、场景、会话状态、系统负载、风险等级,动态决定哪些Skill对模型可见、哪些Skill可执行、哪些Skill只能进入受控流程。
它不是单纯的前端交互概念,而是Agent系统里非常关键的治理机制。
4.2 为什么必须做渐进式披露
如果把所有Skill一次性暴露给模型,通常会引出五类问题:
- 误用风险:模型看到太多工具,错误选择概率上升
- 提示词污染:工具定义过多,会挤占上下文窗口
- 权限泄漏:本不该让普通用户触达的能力被暴露
- 成本上升:每次都把全量工具描述发给模型,Token成本增加
- 稳定性下降:高峰期非核心Skill也参与决策和执行
4.3 一种推荐的三层披露模型
在真实项目中,建议将Skill分成三层:
| 层级 |
类型 |
示例 |
触发方式 |
| L1 |
通用查询 Skill |
查询订单、查询知识、查询商品 |
默认暴露 |
| L2 |
场景增强 Skill |
退款资格试算、物流改派试算 |
命中特定意图时暴露 |
| L3 |
高风险操作 Skill |
提交退款、修改地址、发起审批 |
二次确认或审批后执行 |
这样做的收益很直接:
- 模型在早期决策时认知负担更低
- 高风险能力延迟到必要时再进入上下文
- 能把系统治理策略和业务流程绑定在一起
4.4 一个生产可用的披露策略接口
public interface SkillExposurePolicy {
List<ExposedSkill> selectSkills(ChatSessionContext context, List<RegisteredSkill> allSkills);
boolean canExecute(String skillName, ChatSessionContext context, Object input);
}
配套上下文对象:
public record ChatSessionContext(
String sessionId,
String userId,
String tenantId,
String channel,
UserRole role,
RiskLevel riskLevel,
Set<String> featureFlags,
Map<String, Object> attributes
) {}
一个简单实现如下:
@Component
public class DefaultSkillExposurePolicy implements SkillExposurePolicy {
@Override
public List<ExposedSkill> selectSkills(ChatSessionContext context, List<RegisteredSkill> allSkills) {
return allSkills.stream()
.filter(skill -> matchTenant(skill, context))
.filter(skill -> matchFeatureFlag(skill, context))
.filter(skill -> matchRole(skill, context))
.filter(skill -> matchRisk(skill, context))
.sorted(Comparator.comparingInt(RegisteredSkill::priority))
.limit(12)
.map(ExposedSkill::from)
.toList();
}
@Override
public boolean canExecute(String skillName, ChatSessionContext context, Object input) {
if (context.riskLevel() == RiskLevel.HIGH && skillName.startsWith("submit")) {
return false;
}
return true;
}
private boolean matchTenant(RegisteredSkill skill, ChatSessionContext context) {
return skill.tenants().isEmpty() || skill.tenants().contains(context.tenantId());
}
private boolean matchFeatureFlag(RegisteredSkill skill, ChatSessionContext context) {
return skill.featureFlag() == null || context.featureFlags().contains(skill.featureFlag());
}
private boolean matchRole(RegisteredSkill skill, ChatSessionContext context) {
return skill.allowedRoles().isEmpty() || skill.allowedRoles().contains(context.role());
}
private boolean matchRisk(RegisteredSkill skill, ChatSessionContext context) {
return context.riskLevel().level() <= skill.maxRiskLevel().level();
}
}
4.5 渐进式披露常见策略
- 基于角色:普通用户、VIP客服、运营、管理员看到的Skill不同
- 基于租户:不同业务线、不同品牌、不同区域启用不同能力
- 基于Feature Flag:灰度上线新Skill
- 基于会话阶段:先查后写,先试算后提交
- 基于系统负载:高峰期关闭低优先级Skill
- 基于风险等级:高风险会话自动降权
五、生产级架构设计:Skill不只是方法,而是一个能力网关
5.1 推荐总体架构
这个架构中,建议特别关注两个模块:
Skill Exposure Engine:决定哪些Skill对模型可见
Skill Executor:负责校验、超时、并发、重试、熔断、审计
5.2 为什么建议引入Skill Executor
很多项目一开始直接让Agent调@Tool方法,但当流量上来后,很快会遇到这些问题:
- 每个Skill自己处理异常,风格不一致
- 每个Skill自己埋点,指标维度混乱
- 每个Skill自己做超时与重试,重复代码多
- 高风险Skill缺乏统一权限校验
一个统一的执行器可以收敛这些横切逻辑。
public interface SkillExecutor {
SkillExecutionResult execute(
String skillName,
Object request,
ChatSessionContext sessionContext,
Supplier<Object> invocation
);
}
生产实现:
@Component
@RequiredArgsConstructor
public class DefaultSkillExecutor implements SkillExecutor {
private final Validator validator;
private final SkillExposurePolicy exposurePolicy;
private final SkillAuditService skillAuditService;
private final ObservationRegistry observationRegistry;
private final ExecutorService skillExecutorService;
@Override
public SkillExecutionResult execute(
String skillName,
Object request,
ChatSessionContext sessionContext,
Supplier<Object> invocation
) {
long start = System.nanoTime();
String traceId = MDC.get("traceId");
if (!exposurePolicy.canExecute(skillName, sessionContext, request)) {
throw new AccessDeniedException("skill execution denied: " + skillName);
}
Set<ConstraintViolation<Object>> violations = validator.validate(request);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
return Observation.createNotStarted("skill.execute", observationRegistry)
.lowCardinalityKeyValue("skill.name", skillName)
.lowCardinalityKeyValue("tenant.id", sessionContext.tenantId())
.observe(() -> doExecute(skillName, request, sessionContext, invocation, start, traceId));
}
private SkillExecutionResult doExecute(
String skillName,
Object request,
ChatSessionContext sessionContext,
Supplier<Object> invocation,
long start,
String traceId
) {
try {
Future<Object> future = skillExecutorService.submit(invocation::get);
Object payload = future.get(1200, TimeUnit.MILLISECONDS);
long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
SkillExecutionResult result = SkillExecutionResult.success(payload, costMs, traceId);
skillAuditService.recordSuccess(skillName, request, result, sessionContext);
return result;
} catch (TimeoutException ex) {
skillAuditService.recordTimeout(skillName, request, sessionContext);
throw new SkillTimeoutException(skillName, ex);
} catch (Exception ex) {
skillAuditService.recordFailure(skillName, request, ex, sessionContext);
throw new SkillExecutionException(skillName, ex);
}
}
}
这个实现的价值在于:
- Skill本身可以保持薄
- 横切关注点被统一治理
- 指标、审计、超时、校验不再分散
六、实战案例:电商客服Agent的Skill体系设计
6.1 业务目标
我们以“售前咨询 + 订单查询 + 售后申请”的电商客服场景为例,目标如下:
- 80%常见问题由Agent自助完成
- 查询类请求P95小于800ms
- 写操作请求必须具备幂等、审计与人工兜底
- 高峰期支持3000+ QPS的查询流量与300+ QPS的写请求流量
6.2 Skill目录设计
建议不要把所有能力都塞进一个CustomerServiceToolFunctions类,而是按领域拆分:
skill
├── order
│ ├── OrderQuerySkill
│ └── RefundEligibilitySkill
├── aftersales
│ ├── AfterSalesDraftSkill
│ └── AfterSalesSubmitSkill
├── product
│ └── ProductSearchSkill
├── knowledge
│ └── KnowledgeSearchSkill
└── support
└── HandoverSkill
这样拆分有两个好处:
- 便于按领域治理、灰度和授权
- 有利于后续从单体演进到微服务
6.3 领域命令对象与返回对象
不要把Skill做成String -> String。生产环境下更推荐:
- 输入使用明确的命令对象
- 输出使用稳定的DTO
- 最终由Agent再把结果组织成自然语言
例如查询订单:
public record QueryOrderRequest(
@NotBlank String userId,
@NotBlank String orderNo,
@Pattern(regexp = "^[A-Za-z0-9\\-]{8,64}$") String requestId
) {}
@Builder
public record QueryOrderResponse(
String orderNo,
String orderStatus,
String paymentStatus,
String logisticsStatus,
String logisticsCompany,
String trackingNo,
boolean canRefund,
String message
) {}
6.4 生产级Skill示例一:订单查询Skill
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderQuerySkill {
private final OrderApplicationService orderApplicationService;
private final SkillExecutor skillExecutor;
@Tool(name = "queryOrder", description = "查询订单状态、支付状态、物流状态与退款资格")
public QueryOrderResponse queryOrder(QueryOrderRequest request) {
ChatSessionContext sessionContext = ChatSessionContextHolder.required();
SkillExecutionResult result = skillExecutor.execute(
"queryOrder",
request,
sessionContext,
() -> orderApplicationService.queryOrder(request)
);
return (QueryOrderResponse) result.payload();
}
}
应用服务层:
@Service
@RequiredArgsConstructor
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final RefundPolicyService refundPolicyService;
@Transactional(readOnly = true)
public QueryOrderResponse queryOrder(QueryOrderRequest request) {
OrderAggregate order = orderRepository.findByUserIdAndOrderNo(request.userId(), request.orderNo())
.orElseThrow(() -> new BizException("ORDER_NOT_FOUND", "订单不存在"));
boolean canRefund = refundPolicyService.canApplyRefund(order);
return QueryOrderResponse.builder()
.orderNo(order.getOrderNo())
.orderStatus(order.getOrderStatus().name())
.paymentStatus(order.getPaymentStatus().name())
.logisticsStatus(order.getLogisticsStatus().name())
.logisticsCompany(order.getLogisticsCompany())
.trackingNo(mask(order.getTrackingNo()))
.canRefund(canRefund)
.message(buildMessage(order, canRefund))
.build();
}
private String buildMessage(OrderAggregate order, boolean canRefund) {
if (order.getLogisticsStatus() == LogisticsStatus.PENDING) {
return canRefund ? "订单尚未发货,可申请退款" : "订单尚未发货,但当前不满足退款条件";
}
if (order.getLogisticsStatus() == LogisticsStatus.SHIPPED) {
return "订单已发货,可根据售后政策申请退货退款";
}
return "订单状态已更新";
}
private String mask(String trackingNo) {
if (trackingNo == null || trackingNo.length() < 6) {
return trackingNo;
}
return trackingNo.substring(0, 3) + "****" + trackingNo.substring(trackingNo.length() - 3);
}
}
6.5 生产级Skill示例二:退款资格试算Skill
这是非常适合渐进式披露的能力。因为它不直接写数据,但可以辅助决策。
public record RefundEligibilityRequest(
@NotBlank String userId,
@NotBlank String orderNo,
@NotNull RefundReason reason
) {}
@Builder
public record RefundEligibilityResponse(
boolean eligible,
BigDecimal refundableAmount,
String decision,
List<String> reasons
) {}
@Component
@RequiredArgsConstructor
public class RefundEligibilitySkill {
private final RefundDomainService refundDomainService;
private final SkillExecutor skillExecutor;
@Tool(name = "checkRefundEligibility", description = "判断订单是否满足退款申请条件,并返回可退金额")
public RefundEligibilityResponse checkRefundEligibility(RefundEligibilityRequest request) {
ChatSessionContext sessionContext = ChatSessionContextHolder.required();
SkillExecutionResult result = skillExecutor.execute(
"checkRefundEligibility",
request,
sessionContext,
() -> refundDomainService.evaluate(request)
);
return (RefundEligibilityResponse) result.payload();
}
}
领域服务实现:
@Service
@RequiredArgsConstructor
public class RefundDomainService {
private final OrderRepository orderRepository;
private final PaymentRepository paymentRepository;
@Transactional(readOnly = true)
public RefundEligibilityResponse evaluate(RefundEligibilityRequest request) {
OrderAggregate order = orderRepository.findByUserIdAndOrderNo(request.userId(), request.orderNo())
.orElseThrow(() -> new BizException("ORDER_NOT_FOUND", "订单不存在"));
PaymentRecord paymentRecord = paymentRepository.findByOrderNo(order.getOrderNo())
.orElseThrow(() -> new BizException("PAYMENT_NOT_FOUND", "支付记录不存在"));
List<String> reasons = new ArrayList<>();
if (order.getOrderStatus() == OrderStatus.CANCELLED) {
reasons.add("订单已取消,无需重复退款");
}
if (paymentRecord.getPaymentStatus() != PaymentStatus.PAID) {
reasons.add("订单未支付,不支持退款");
}
if (order.getRefundStatus() == RefundStatus.REFUNDED) {
reasons.add("订单已完成退款");
}
boolean eligible = reasons.isEmpty();
BigDecimal amount = eligible ? order.getPayAmount() : BigDecimal.ZERO;
String decision = eligible ? "ALLOW" : "REJECT";
return RefundEligibilityResponse.builder()
.eligible(eligible)
.refundableAmount(amount)
.decision(decision)
.reasons(reasons)
.build();
}
}
6.6 生产级Skill示例三:售后草稿创建Skill
强写操作不建议第一步就直接提交,而是先生成草稿,再做二次确认:
public record CreateAfterSalesDraftRequest(
@NotBlank String userId,
@NotBlank String orderNo,
@NotNull AfterSalesType type,
@NotBlank @Size(max = 200) String reason,
@NotBlank String requestId
) {}
@Builder
public record CreateAfterSalesDraftResponse(
String draftNo,
String orderNo,
String type,
String suggestedNextAction,
Instant expiredAt
) {}
@Component
@RequiredArgsConstructor
public class AfterSalesDraftSkill {
private final AfterSalesDraftService afterSalesDraftService;
private final SkillExecutor skillExecutor;
@Tool(name = "createAfterSalesDraft", description = "为退款、退货或换货生成售后申请草稿,不直接提交")
public CreateAfterSalesDraftResponse createDraft(CreateAfterSalesDraftRequest request) {
ChatSessionContext sessionContext = ChatSessionContextHolder.required();
SkillExecutionResult result = skillExecutor.execute(
"createAfterSalesDraft",
request,
sessionContext,
() -> afterSalesDraftService.createDraft(request)
);
return (CreateAfterSalesDraftResponse) result.payload();
}
}
幂等实现:
@Service
@RequiredArgsConstructor
public class AfterSalesDraftService {
private final AfterSalesDraftRepository draftRepository;
private final IdempotencyService idempotencyService;
@Transactional
public CreateAfterSalesDraftResponse createDraft(CreateAfterSalesDraftRequest request) {
return idempotencyService.execute(
"aftersales:draft:" + request.requestId(),
Duration.ofHours(24),
() -> doCreateDraft(request)
);
}
private CreateAfterSalesDraftResponse doCreateDraft(CreateAfterSalesDraftRequest request) {
AfterSalesDraftEntity entity = new AfterSalesDraftEntity();
entity.setDraftNo("ASD" + System.currentTimeMillis());
entity.setUserId(request.userId());
entity.setOrderNo(request.orderNo());
entity.setType(request.type().name());
entity.setReason(request.reason());
entity.setExpiredAt(Instant.now().plus(Duration.ofMinutes(30)));
draftRepository.save(entity);
return CreateAfterSalesDraftResponse.builder()
.draftNo(entity.getDraftNo())
.orderNo(entity.getOrderNo())
.type(entity.getType())
.suggestedNextAction("如用户确认提交,再调用 submitAfterSalesApplication")
.expiredAt(entity.getExpiredAt())
.build();
}
}
6.7 生产级Skill示例四:知识库查询Skill
知识库Skill最大的问题不在于“能不能查到”,而在于“如何控制召回质量、延迟和回答可引用性”。
public record SearchKnowledgeRequest(
@NotBlank String question,
@Min(1) @Max(10) int topK,
String category
) {}
@Builder
public record SearchKnowledgeResponse(
String summary,
List<KnowledgeHit> hits
) {
public record KnowledgeHit(String title, String snippet, String source, double score) {}
}
@Component
@RequiredArgsConstructor
public class KnowledgeSearchSkill {
private final KnowledgeRetrievalService knowledgeRetrievalService;
private final SkillExecutor skillExecutor;
@Tool(name = "searchKnowledge", description = "检索商品规则、售后政策和客服知识库,并返回可引用结果")
public SearchKnowledgeResponse searchKnowledge(SearchKnowledgeRequest request) {
ChatSessionContext sessionContext = ChatSessionContextHolder.required();
SkillExecutionResult result = skillExecutor.execute(
"searchKnowledge",
request,
sessionContext,
() -> knowledgeRetrievalService.search(request)
);
return (SearchKnowledgeResponse) result.payload();
}
}
检索服务建议做三段式:
- Query Rewrite:对用户问题做规范化
- Hybrid Retrieve:向量检索 + 关键词召回
- Re-rank:按相关性与可信度重新排序
@Service
@RequiredArgsConstructor
public class KnowledgeRetrievalService {
private final VectorSearcher vectorSearcher;
private final KeywordSearcher keywordSearcher;
private final KnowledgeReranker knowledgeReranker;
public SearchKnowledgeResponse search(SearchKnowledgeRequest request) {
String normalizedQuery = normalize(request.question());
List<KnowledgeDocument> vectorHits = vectorSearcher.search(normalizedQuery, request.topK());
List<KnowledgeDocument> keywordHits = keywordSearcher.search(normalizedQuery, request.topK());
List<KnowledgeDocument> merged = Stream.concat(vectorHits.stream(), keywordHits.stream())
.collect(Collectors.toMap(KnowledgeDocument::id, doc -> doc, this::preferHigherScore))
.values()
.stream()
.toList();
List<KnowledgeDocument> reranked = knowledgeReranker.rerank(normalizedQuery, merged).stream()
.limit(request.topK())
.toList();
return SearchKnowledgeResponse.builder()
.summary(buildSummary(reranked))
.hits(reranked.stream()
.map(doc -> new SearchKnowledgeResponse.KnowledgeHit(
doc.title(),
trim(doc.content(), 160),
doc.source(),
doc.score()))
.toList())
.build();
}
private KnowledgeDocument preferHigherScore(KnowledgeDocument left, KnowledgeDocument right) {
return left.score() >= right.score() ? left : right;
}
private String normalize(String question) {
return question == null ? "" : question.strip();
}
private String buildSummary(List<KnowledgeDocument> docs) {
if (docs.isEmpty()) {
return "未检索到可信知识片段";
}
return "已检索到 " + docs.size() + " 条候选知识,可用于生成带引用回答";
}
private String trim(String content, int max) {
if (content == null || content.length() <= max) {
return content;
}
return content.substring(0, max) + "...";
}
}
七、Agent编排:如何把Skill体系真正跑起来
7.1 不建议让模型无限制自由决策
很多Demo会直接把用户消息和所有工具丢给模型,然后让模型自行决定调用链。这个模式在简单场景可行,但在企业系统里容易出现:
- 多次重复调用同一个Skill
- 高风险Skill被误触发
- 明明只需要一个查询,却被拆成多次低效调用
更可控的方式,是引入“轻编排”:
- 由分类器或Planner判断当前请求属于哪类任务
- 根据任务类型决定暴露哪些Skill
- 对写操作要求明确确认状态
7.2 一个推荐的编排流程
7.3 生产级编排代码示例
@Service
@RequiredArgsConstructor
public class CustomerServiceAgentFacade {
private final IntentClassifier intentClassifier;
private final SkillExposurePolicy skillExposurePolicy;
private final ChatClient chatClient;
private final SkillCatalog skillCatalog;
public AgentReply chat(ChatRequest request) {
ChatSessionContext sessionContext = buildSessionContext(request);
IntentType intentType = intentClassifier.classify(request.message(), request.history());
List<RegisteredSkill> candidateSkills = skillCatalog.skillsFor(intentType);
List<ExposedSkill> exposedSkills = skillExposurePolicy.selectSkills(sessionContext, candidateSkills);
String systemPrompt = buildSystemPrompt(intentType, sessionContext);
ChatClient.CallResponseSpec response = chatClient.prompt()
.system(systemPrompt)
.user(request.message())
.tools(exposedSkills.stream().map(ExposedSkill::toolCallback).toArray(ToolCallback[]::new))
.call();
return AgentReply.from(response.content());
}
private ChatSessionContext buildSessionContext(ChatRequest request) {
return new ChatSessionContext(
request.sessionId(),
request.userId(),
request.tenantId(),
request.channel(),
request.role(),
request.riskLevel(),
request.featureFlags(),
Map.of("confirmed", request.confirmed())
);
}
private String buildSystemPrompt(IntentType intentType, ChatSessionContext sessionContext) {
return """
你是企业客服 Agent。
只可使用当前暴露的 Skill。
查询类请求优先使用 query 或 search 类 Skill。
涉及写操作时,若会话未确认,不得直接提交,只能创建草稿或返回确认提示。
当前意图类型:%s
当前用户角色:%s
当前风险等级:%s
""".formatted(intentType, sessionContext.role(), sessionContext.riskLevel());
}
}
这个实现体现的是“模型负责语言与局部决策,系统负责边界与治理”。
八、工程化升级:高并发、可扩展、可治理
8.1 高并发设计要点
在高并发场景中,Skill系统的瓶颈通常不在@Tool本身,而在其背后的数据库/中间件/技术栈、远程服务和模型调用链。建议重点治理四个方面。
1. 读写分离
- 查询型Skill尽量走只读链路
- 写型Skill单独隔离线程池与限流策略
- 不同优先级Skill使用不同资源池
2. 缓存前置
- 热点订单查询、商品信息、政策文档可做短TTL缓存
- 知识库查询可缓存Query Rewrite和Rerank结果
- Skill描述元数据本身也应缓存,避免每次动态重建
3. 并发隔离
- 模型调用线程池与业务执行线程池分离
- 查询Skill与写操作Skill分离
- 外部依赖按服务维度做Bulkhead隔离
4. 限流与背压
- 针对租户、用户、Skill名称做多维限流
- 高峰时关闭非核心Skill曝露
- 超出系统承载时,优先保住查询能力
8.2 一个推荐的线程池配置
@Configuration
public class ExecutorConfiguration {
@Bean(destroyMethod = "shutdown")
public ExecutorService skillQueryExecutor() {
return new ThreadPoolExecutor(
32,
64,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2000),
new ThreadFactoryBuilder().setNameFormat("skill-query-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
@Bean(destroyMethod = "shutdown")
public ExecutorService skillWriteExecutor() {
return new ThreadPoolExecutor(
16,
32,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new ThreadFactoryBuilder().setNameFormat("skill-write-%d").build(),
new ThreadPoolExecutor.AbortPolicy()
);
}
}
这里的思路是:
- 查询池更大,保证吞吐
- 写池更小、更严格,优先保护一致性
- 背压策略不同,避免整个系统被写流量拖垮
8.3 一个推荐的限流实现
@Component
@RequiredArgsConstructor
public class SkillRateLimitGuard {
private final LoadingCache<String, RateLimiter> limiterCache = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofHours(1))
.build(this::createRateLimiter);
public void acquire(String tenantId, String skillName) {
String key = tenantId + ":" + skillName;
RateLimiter limiter = limiterCache.get(key);
if (!limiter.tryAcquire()) {
throw new TooManyRequestsException("skill rate limit exceeded: " + skillName);
}
}
private RateLimiter createRateLimiter(String key) {
if (key.endsWith(":submitAfterSalesApplication")) {
return RateLimiter.create(20.0);
}
return RateLimiter.create(200.0);
}
}
8.4 熔断、超时与降级
建议为每类Skill设计不同的降级策略:
| Skill 类型 |
超时目标 |
降级策略 |
| 订单查询 |
300ms - 800ms |
返回基础状态或缓存结果 |
| 商品查询 |
300ms - 1000ms |
返回缓存商品摘要 |
| 知识检索 |
500ms - 1200ms |
返回 FAQ 缩略结果 |
| 售后试算 |
800ms - 1500ms |
返回“建议人工确认” |
| 提交型写操作 |
1000ms - 2000ms |
直接失败并保留草稿 |
以Resilience4j为例:
@Service
@RequiredArgsConstructor
public class ResilientOrderService {
private final OrderApplicationService orderApplicationService;
private final CircuitBreaker circuitBreaker;
private final TimeLimiter timeLimiter;
public QueryOrderResponse query(QueryOrderRequest request) {
Supplier<CompletionStage<QueryOrderResponse>> supplier = () ->
CompletableFuture.supplyAsync(() -> orderApplicationService.queryOrder(request));
try {
return Decorators.ofCompletionStage(supplier)
.withCircuitBreaker(circuitBreaker)
.withTimeLimiter(timeLimiter)
.get()
.toCompletableFuture()
.join();
} catch (Exception ex) {
return QueryOrderResponse.builder()
.orderNo(request.orderNo())
.message("订单服务暂时繁忙,已返回降级结果")
.build();
}
}
}
8.5 扩展设计:从单体Skill到分布式Skill
当业务增长到一定规模,Skill可以从本地Bean演进为独立服务。推荐路径如下:
阶段一:单体内本地Skill
- 所有Skill与Agent在同一进程
- 适合快速验证和迭代
阶段二:按领域拆Skill模块
- 订单、商品、售后、知识检索拆成独立模块
- 仍然在单体部署,但边界清晰
阶段三:部分Skill服务化
- 高频或重依赖的Skill独立为服务
- Agent仍作为统一入口
阶段四:Skill网关化
- 统一Skill Catalog、权限、指标、版本路由
- 支持多团队并行交付Skill
一个简单的远程Skill代理可以这样设计:
public interface RemoteSkillInvoker {
<T> T invoke(String skillName, Object request, Class<T> responseType);
}
@Component
@RequiredArgsConstructor
public class HttpRemoteSkillInvoker implements RemoteSkillInvoker {
private final WebClient webClient;
@Override
public <T> T invoke(String skillName, Object request, Class<T> responseType) {
return webClient.post()
.uri("/api/skills/{skillName}/invoke", skillName)
.bodyValue(request)
.retrieve()
.bodyToMono(responseType)
.timeout(Duration.ofMillis(1500))
.block();
}
}
这样,Skill对模型仍表现为一个能力,但底层已经可以按服务路由。
九、生产级代码补全:注册、审计、权限、确认机制
9.1 Skill Catalog:统一注册中心
@Component
public class SkillCatalog implements InitializingBean {
private final ApplicationContext applicationContext;
private final Map<String, RegisteredSkill> registeredSkills = new ConcurrentHashMap<>();
public SkillCatalog(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void afterPropertiesSet() {
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class);
beans.values().forEach(bean -> Arrays.stream(bean.getClass().getMethods())
.filter(method -> method.isAnnotationPresent(Tool.class))
.forEach(method -> {
Tool tool = method.getAnnotation(Tool.class);
String skillName = tool.name().isBlank() ? method.getName() : tool.name();
registeredSkills.put(skillName, RegisteredSkill.from(bean, method, tool));
}));
}
public List<RegisteredSkill> allSkills() {
return registeredSkills.values().stream().sorted(Comparator.comparing(RegisteredSkill::name)).toList();
}
public List<RegisteredSkill> skillsFor(IntentType intentType) {
return registeredSkills.values().stream()
.filter(skill -> skill.intentTypes().contains(intentType))
.toList();
}
public Optional<RegisteredSkill> findByName(String skillName) {
return Optional.ofNullable(registeredSkills.get(skillName));
}
}
9.2 审计日志:一定要记录什么
Skill的审计不是为了“出问题后再看”,而是为了让业务、风控、研发都能回放一次Agent的决策过程。
建议每次Skill调用至少记录:
- traceId / sessionId / userId / tenantId
- skillName / version
- 输入参数摘要
- 执行结果摘要
- 成功 / 失败 / 超时状态
- 耗时
- 是否命中降级
示例:
@Service
public class SkillAuditService {
public void recordSuccess(String skillName, Object request, SkillExecutionResult result, ChatSessionContext context) {
SkillAuditLog log = SkillAuditLog.success(
context.sessionId(),
context.userId(),
context.tenantId(),
skillName,
summarize(request),
summarize(result.payload()),
result.costMs(),
result.traceId()
);
persist(log);
}
public void recordFailure(String skillName, Object request, Exception ex, ChatSessionContext context) {
SkillAuditLog log = SkillAuditLog.failure(
context.sessionId(),
context.userId(),
context.tenantId(),
skillName,
summarize(request),
ex.getClass().getSimpleName() + ":" + ex.getMessage(),
MDC.get("traceId")
);
persist(log);
}
public void recordTimeout(String skillName, Object request, ChatSessionContext context) {
SkillAuditLog log = SkillAuditLog.timeout(
context.sessionId(),
context.userId(),
context.tenantId(),
skillName,
summarize(request),
MDC.get("traceId")
);
persist(log);
}
private String summarize(Object obj) {
return obj == null ? "" : Jsons.toCompactJson(obj);
}
private void persist(SkillAuditLog log) {
// 可以落 ES、Kafka、ClickHouse、OLTP DB
}
}
9.3 权限控制:模型能看到,不代表一定能执行
建议把权限控制分成两段:
- 暴露权限:该会话是否允许看到Skill
- 执行权限:即使模型选择了Skill,是否允许真正执行
示例:
@Component
public class SkillPermissionChecker {
public void checkExecutionPermission(String skillName, ChatSessionContext context) {
if ("submitAfterSalesApplication".equals(skillName) && context.role() == UserRole.GUEST) {
throw new AccessDeniedException("guest cannot submit after-sales application");
}
if ("modifyDeliveryAddress".equals(skillName) && context.riskLevel() == RiskLevel.HIGH) {
throw new AccessDeniedException("high risk session cannot modify address");
}
}
}
9.4 写操作确认机制
在企业系统里,“模型决定写入”是高风险动作。建议采用“双阶段提交式会话确认”:
- 模型先调用草稿Skill
- 系统向用户展示拟执行内容
- 用户明确确认
- 才允许调用提交Skill
可以通过会话状态控制:
public enum ConversationStage {
QUERY,
DRAFT_CREATED,
USER_CONFIRMED,
SUBMITTED
}
public boolean canExecuteSubmit(ChatSessionContext context) {
Object confirmed = context.attributes().get("confirmed");
return Boolean.TRUE.equals(confirmed);
}
这类机制是“安全前置”,而不是“Prompt里提醒一下”。
十、数据库与缓存设计:别让Skill成为慢SQL的入口
10.1 订单查询表设计建议
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
order_status VARCHAR(32) NOT NULL,
payment_status VARCHAR(32) NOT NULL,
logistics_status VARCHAR(32) NOT NULL,
pay_amount DECIMAL(18,2) NOT NULL,
refund_status VARCHAR(32) NOT NULL,
logistics_company VARCHAR(64),
tracking_no VARCHAR(64),
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_created (user_id, created_at DESC),
KEY idx_user_order (user_id, order_no)
);
查询型Skill常见的性能问题,不是SQL写错,而是:
- 没有覆盖核心查询路径的组合索引
- 把大量不必要字段带回应用层
- 不区分热路径与冷路径
10.2 Redis缓存建议
订单查询可采用Cache-Aside模式:
@Service
@RequiredArgsConstructor
public class CachedOrderQueryService {
private final StringRedisTemplate redisTemplate;
private final OrderApplicationService orderApplicationService;
public QueryOrderResponse query(QueryOrderRequest request) {
String key = "order:query:" + request.userId() + ":" + request.orderNo();
String cacheValue = redisTemplate.opsForValue().get(key);
if (cacheValue != null) {
return Jsons.fromJson(cacheValue, QueryOrderResponse.class);
}
QueryOrderResponse response = orderApplicationService.queryOrder(request);
redisTemplate.opsForValue().set(key, Jsons.toJson(response), Duration.ofSeconds(30));
return response;
}
}
缓存策略建议:
- 查询结果TTL短一些,保障实时性
- 政策、FAQ、商品元数据TTL长一些
- 写操作成功后精确删缓存,不做全局清理
十一、可观测性体系:没有观测,就没有生产级Skill
11.1 核心指标
建议按Skill维度暴露以下指标:
skill_call_total
skill_call_success_total
skill_call_failure_total
skill_call_timeout_total
skill_call_duration_ms
skill_exposure_total
skill_denied_total
skill_fallback_total
Micrometer示例:
@Component
@RequiredArgsConstructor
public class SkillMetricsRecorder {
private final MeterRegistry meterRegistry;
public void recordSuccess(String skillName, long costMs) {
meterRegistry.counter("skill_call_success_total", "skill", skillName).increment();
meterRegistry.timer("skill_call_duration_ms", "skill", skillName)
.record(costMs, TimeUnit.MILLISECONDS);
}
public void recordFailure(String skillName, String errorType) {
meterRegistry.counter("skill_call_failure_total", "skill", skillName, "error", errorType).increment();
}
public void recordExposure(String skillName) {
meterRegistry.counter("skill_exposure_total", "skill", skillName).increment();
}
}
11.2 Trace设计
建议一次用户请求下,所有Skill共用同一个traceId,同时为每次Skill调用生成spanId。这样可以非常清晰地回答三个问题:
- 这次用户请求到底触发了哪些Skill
- 哪一个Skill最耗时
- 降级、重试、超时发生在哪一环
11.3 告警建议
生产上至少要配以下告警:
- 核心查询Skill错误率超过阈值
- 写操作Skill超时率升高
- 某个租户的Skill调用异常激增
- 模型工具调用次数异常升高
- 暴露Skill数量突增,可能代表策略异常
十二、从单体到分布式的演进路线
12.1 Phase 1:单体验证期
适合阶段:
- 刚做MVP
- Skill数量少于10
- 团队人数少,强调快速迭代
架构特点:
- Agent、Skill、业务服务在同一应用
- 成本低,排查快
- 发布简单
限制:
- 查询与写操作无法完全隔离
- 团队协作边界不清晰
- 某个重Skill可能拖垮整个应用
12.2 Phase 2:模块化单体
这是非常推荐的过渡形态:
- 代码按领域拆模块
- Skill Catalog与Executor独立出来
- 统一审计、限流、监控
收益:
- 不急着上微服务,也能享受良好的边界治理
- 为后续远程Skill化做好准备
12.3 Phase 3:部分Skill服务化
适合如下情况:
- 某类Skill调用量显著更高
- 某类Skill依赖特殊资源,例如向量库、GPU、OCR、搜索引擎
- 某个领域由独立团队维护
典型拆法:
- Knowledge Skill服务化
- Product Search Skill服务化
- AfterSales Submit Skill单独受控部署
12.4 Phase 4:Skill平台化
当组织进入多团队并行交付阶段,Skill的治理会从“代码问题”演变成“平台问题”:
- Skill注册与版本管理
- 技能标签、租户绑定、灰度开关
- 执行策略、配额、SLA
- 审计回放与故障诊断
此时建议建设:
- Skill Catalog平台
- Skill Gateway
- Skill配置中心
- Skill可观测与回放平台
十三、常见问题与避坑清单
13.1 Skill方法里直接写业务逻辑
问题:
- 逻辑难复用
- 测试困难
- 演进成远程Skill时成本很高
建议:
@Tool方法只做协议层适配
- 核心逻辑进入应用服务或领域服务
13.2 让模型直接触达高风险写操作
问题:
建议:
- 草稿 + 确认 + 提交 三段式
- 高风险操作强制二次确认
13.3 输入输出全部用String
问题:
- 参数缺少明确语义
- 无法利用Bean Validation
- 不利于版本演进
建议:
13.4 全量暴露Skill
问题:
- Token成本上升
- 模型选错工具概率提高
- 安全边界模糊
建议:
13.5 没有幂等设计
问题:
- 模型重试、网络重放、用户重复确认都可能造成重复写入
建议:
- 所有写Skill强制要求
requestId
- 幂等逻辑下沉到服务层
十四、最佳实践总结
如果要把Spring AI Alibaba Skill真正用到生产,建议记住下面这几条原则:
- Skill是能力边界,不是业务逻辑堆放点。
@Tool只是入口,真正的生产力在注册、治理、审计和策略层。
- 渐进式披露不是锦上添花,而是企业级Agent的基本盘。
- 查询与写操作必须分开治理,线程池、超时、限流策略也应不同。
- 高风险操作一定要有草稿、确认、审计与幂等。
- 结构化输入输出比
String in / String out更适合长期演进。
- 当Skill数量和团队规模变大后,Skill Catalog与Executor会成为关键基础设施。
十五、结语
许多团队在做人工智能 Agent项目时,容易把注意力过度放在Prompt和模型效果上,但真正决定系统能否长期跑在生产上的,往往是Skill这一层。
因为企业系统最终比拼的不是“模型会不会说”,而是:
- 是否能在复杂系统中稳定调用正确能力
- 是否能在失败与高峰场景下继续可用
- 是否能让每一次执行都留下清晰、可信、可审计的轨迹
Spring AI Alibaba Skill的意义,正是在这里。它让企业把大模型的推理能力,与Spring生态下成熟的工程能力真正连接起来。
如果把Prompt看成Agent的“思维接口”,那么Skill就是Agent的“执行接口”;而渐进式披露、治理与观测,则是这套执行接口能否进入企业生产的关键门槛。
只有跨过这道门槛,Agent才不再是一个会聊天的助手,而会成为真正能参与业务流程的数字员工。
希望这篇在云栈社区分享的深入指南,能帮助你更好地理解和落地Spring AI Alibaba Skill,构建可靠、高效且可治理的企业级智能体系统。