找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4767

积分

0

好友

665

主题
发表于 4 天前 | 查看: 28| 回复: 0

摘要:许多团队在接入大模型时,第一步实现“能对话”后,第二步遇到的瓶颈往往是“不能可靠执行”。Skill的本质并非简单地给模型增加几个函数,而是将企业系统中的可控能力,以可治理、可审计、可扩展的方式交付给Agent。本文围绕Spring AI Alibaba Skill的定义、注册与渐进式披露,结合电商客服与售后场景,系统性地解析其底层原理、架构设计、生产级实现、高并发治理与工程实践。

一、为什么Skill是企业级Agent的分水岭

1.1 从“回答问题”到“完成任务”

大模型天然擅长语言理解与生成,但企业系统更关心三类结果:

  • 用户问题是否被准确识别
  • 业务动作是否被可靠执行
  • 整个过程是否可观测、可审计、可回滚

以电商客服为例,用户一句“帮我查一下订单ORD20260402001为什么还没发货,能不能顺便申请退款”,在企业系统中通常意味着一条完整链路:

  1. 意图识别:查询订单 + 判断退款资格
  2. 读取上下文:订单状态、支付状态、仓配状态、售后规则
  3. 决策执行:满足条件则发起退款或售后申请
  4. 风险控制:校验幂等、权限、库存、金额、时效
  5. 结果回传:生成自然语言答复,并附带业务结果

如果只有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);
    }
}

但框架真正做的事情至少包括四步:

  1. 扫描包含@Tool注解的方法
  2. 解析方法名、描述、参数类型、返回类型
  3. 推导模型可识别的参数结构
  4. 在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、事务、校验、指标和策略

3.3 执行阶段:一次Tool Call在运行时发生了什么

当模型决定调用某个Skill时,运行时链路通常如下:
运行时真正需要控制的关键点包括:

  • 模型是否有权看到该Skill
  • 模型生成的参数是否可信
  • Skill是否允许在当前上下文执行
  • 执行超时、失败、并发冲突时如何兜底
  • 返回结果如何做脱敏和压缩

这也是为什么生产环境里,Skill的重点不是“能不能调通”,而是“能不能长期稳定运行”。

四、渐进式披露:为什么企业级Agent不能把全部Skill一次性暴露给模型

4.1 渐进式披露的本质

所谓渐进式披露,可以理解为:

根据当前用户、场景、会话状态、系统负载、风险等级,动态决定哪些Skill对模型可见、哪些Skill可执行、哪些Skill只能进入受控流程。

它不是单纯的前端交互概念,而是Agent系统里非常关键的治理机制。

4.2 为什么必须做渐进式披露

如果把所有Skill一次性暴露给模型,通常会引出五类问题:

  1. 误用风险:模型看到太多工具,错误选择概率上升
  2. 提示词污染:工具定义过多,会挤占上下文窗口
  3. 权限泄漏:本不该让普通用户触达的能力被暴露
  4. 成本上升:每次都把全量工具描述发给模型,Token成本增加
  5. 稳定性下降:高峰期非核心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();
    }
}

检索服务建议做三段式:

  1. Query Rewrite:对用户问题做规范化
  2. Hybrid Retrieve:向量检索 + 关键词召回
  3. 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 写操作确认机制

在企业系统里,“模型决定写入”是高风险动作。建议采用“双阶段提交式会话确认”:

  1. 模型先调用草稿Skill
  2. 系统向用户展示拟执行内容
  3. 用户明确确认
  4. 才允许调用提交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
  • 不利于版本演进

建议:

  • 输入用命令对象
  • 输出用结构化DTO

13.4 全量暴露Skill

问题:

  • Token成本上升
  • 模型选错工具概率提高
  • 安全边界模糊

建议:

  • 采用渐进式披露
  • 每类会话只暴露必要Skill

13.5 没有幂等设计

问题:

  • 模型重试、网络重放、用户重复确认都可能造成重复写入

建议:

  • 所有写Skill强制要求requestId
  • 幂等逻辑下沉到服务层

十四、最佳实践总结

如果要把Spring AI Alibaba Skill真正用到生产,建议记住下面这几条原则:

  1. Skill是能力边界,不是业务逻辑堆放点。
  2. @Tool只是入口,真正的生产力在注册、治理、审计和策略层。
  3. 渐进式披露不是锦上添花,而是企业级Agent的基本盘。
  4. 查询与写操作必须分开治理,线程池、超时、限流策略也应不同。
  5. 高风险操作一定要有草稿、确认、审计与幂等。
  6. 结构化输入输出比String in / String out更适合长期演进。
  7. 当Skill数量和团队规模变大后,Skill Catalog与Executor会成为关键基础设施。

十五、结语

许多团队在做人工智能 Agent项目时,容易把注意力过度放在Prompt和模型效果上,但真正决定系统能否长期跑在生产上的,往往是Skill这一层。

因为企业系统最终比拼的不是“模型会不会说”,而是:

  • 是否能在复杂系统中稳定调用正确能力
  • 是否能在失败与高峰场景下继续可用
  • 是否能让每一次执行都留下清晰、可信、可审计的轨迹

Spring AI Alibaba Skill的意义,正是在这里。它让企业把大模型的推理能力,与Spring生态下成熟的工程能力真正连接起来。

如果把Prompt看成Agent的“思维接口”,那么Skill就是Agent的“执行接口”;而渐进式披露、治理与观测,则是这套执行接口能否进入企业生产的关键门槛。

只有跨过这道门槛,Agent才不再是一个会聊天的助手,而会成为真正能参与业务流程的数字员工。

希望这篇在云栈社区分享的深入指南,能帮助你更好地理解和落地Spring AI Alibaba Skill,构建可靠、高效且可治理的企业级智能体系统。




上一篇:Eino框架生产级实践指南:构建可扩展的Go大模型应用架构
下一篇:Claude Code登录报错ECONNREFUSED?一文理清代理配置与终极解决方案
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 20:34 , Processed in 1.013867 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表