共计 4245 个字符,预计需要花费 11 分钟才能阅读完成。
背景痛点:当 LangChain4j 遇上复杂业务
最近在电商客服系统中遇到一个典型场景:用户咨询商品时,需要动态组合多种能力——先通过 NLP 理解意图,再调用推荐算法,接着计算个性化折扣,最后还要过风控检查。传统实现方式是这样的:

// 典型硬编码流程(问题明显)public Response handleRequest(Request request) {
// 1. NLP 解析
Intent intent = nlpService.parse(request);
// 2. 商品推荐
List<Product> products = recommendService.getList(intent);
// 3. 折扣计算
Discount discount = discountService.calculate(products, request.user());
// 4. 风控检查
if(riskService.check(discount).isBlocked()) {throw new RuntimeException("风控拦截");
}
// ... 后续处理
}
这种写法存在三个致命问题:
- 流程固化 :想要调整顺序(比如先风控再推荐)必须修改代码
- 复用困难 :折扣计算逻辑无法直接复用到其他业务流程
- 维护成本高 :每新增一个环节就需要重新测试整个链路
技术方案:Skill 模块设计哲学
LangChain4j 的 Skill 模块通过三个核心设计解决上述问题:
1. 与传统方案对比
- 硬编码流程 :像钢筋混凝土结构,修改需要砸墙重建
- Skill 集成 :像乐高积木,通过标准接口实现自由组合
2. 接口设计原则
/**
* Skill 通用接口(注意泛型设计)* @param <I> 输入类型
* @param <O> 输出类型
*/
public interface Skill<I, O> {O execute(I input, Context context);
// 默认支持的方法级超时
default Duration timeout() {return Duration.ofSeconds(3);
}
}
关键设计要点:
- 单一职责 :每个 Skill 只做一件事(如折扣计算不关心风控)
- 输入输出标准化 :通过泛型约束数据类型
- 上下文透传 :Context 对象携带链路全局参数
3. 自动注册机制
通过自定义注解实现零配置注册:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Skill {String value(); // Skill 唯一标识
}
// 使用示例
@Skill("discountCalculator")
public class DiscountSkill implements Skill<DiscountRequest, DiscountResult> {//... 实现逻辑}
代码实战:从单 Skill 到流程编排
1. Skill 基类实现(生产级建议)
@RequiredArgsConstructor
public abstract class BaseSkill<I, O> implements Skill<I, O> {
private final Validator validator; // 参数校验器
@Override
public final O execute(I input, Context context) {
// 1. 参数校验
validateInput(input);
// 2. 幂等 ID 处理(防重放)String idempotentId = context.get("idempotentId");
if(StringUtils.isNotBlank(idempotentId)) {return getIdempotentResult(idempotentId);
}
// 3. 实际业务处理
return doExecute(input, context);
}
protected abstract O doExecute(I input, Context context);
private void validateInput(I input) {Set<ConstraintViolation<I>> violations = validator.validate(input);
if(!violations.isEmpty()) {throw new SkillValidationException(violations);
}
}
}
2. 折扣计算 Skill 完整实现
@Skill("discountCalculator")
@Slf4j
public class DiscountSkill extends BaseSkill<DiscountRequest, DiscountResult> {
// 通过构造器注入依赖
private final UserLevelService levelService;
private final ProductPriceService priceService;
@Override
protected DiscountResult doExecute(DiscountRequest request, Context context) {
// 1. 获取用户等级(演示上下文使用)UserLevel level = context.get("userLevel");
if(level == null) {level = levelService.getLevel(request.userId());
context.put("userLevel", level);
}
// 2. 并行查询商品基准价(性能优化)List<CompletableFuture<Double>> priceFutures = request.products().stream()
.map(p -> CompletableFuture.supplyAsync(() -> priceService.getBasePrice(p.id())))
.toList();
// 3. 计算折扣逻辑
double total = priceFutures.stream()
.map(CompletableFuture::join)
.reduce(0.0, Double::sum);
double discountRate = calculateRate(level, total);
// 4. 记录审计日志
log.info("[DiscountSkill] userId={}, original={}, discount={}",
request.userId(), total, discountRate);
return new DiscountResult(total, discountRate);
}
// 私有方法不暴露给外部
private double calculateRate(UserLevel level, double amount) {// ... 具体算法实现}
}
3. 流程编排:ChainBuilder 魔法
// 构建处理链(类似 Java Stream API)SkillChain chain = ChainBuilder.create()
.add("intentParser") // NLP 意图识别
.add("productRecommender") // 商品推荐
.addParallel( // 并行执行组
"discountCalculator",
"riskChecker"
)
.add("responseGenerator") // 最终响应生成
.build();
// 执行整个流程(自动处理输入输出类型转换)CompletionStage<Response> future = chain.executeAsync(userQuery);
生产级考量:从能用变好用
1. 线程安全三原则
- 原则一 :Skill 内部状态必须用 ThreadLocal
private static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd")); - 原则二 :Context 对象线程隔离
- 原则三 :依赖的服务需确认线程安全
2. 超时熔断配置
# application.yml
langchain4j:
skills:
timeout:
default: 2s
overrides:
riskChecker: 5s # 风控单独配置
circuit-breaker:
failure-rate-threshold: 50%
sliding-window-size: 10
3. 性能压测数据
测试场景:组合 5 个 Skill 处理 1000 次请求
| 执行模式 | 平均耗时 | 错误率 |
|---|---|---|
| 纯串行 | 3200ms | 0% |
| 智能并行 (默认) | 1200ms | 0% |
| 全并行 | 800ms | 3% |
避坑指南:血泪经验总结
1. 循环依赖检测
在 Skill 注册阶段进行图检测:
// 使用拓扑排序检测循环依赖
List<String> sortedSkills = topologicalSort(skillDependencies);
if(sortedSkills.size() != totalSkills) {throw new CircularDependencyException("发现技能循环依赖!");
}
2. 版本兼容管理
通过命名规范区分类似技能:
[技能名]-v[主版本号]
示例:discountCalculator-v1 # 旧版算法
discountCalculator-v2 # 新版算法
3. 日志埋点规范
- 必打点 :执行开始 / 结束、关键参数摘要
- 禁止 :打印完整上下文(可能含敏感信息)
- 建议 :使用 MDC 添加 traceId
try(MDC.MDCCloseable ignored = MDC.putCloseable("skill", skillName)) {log.info("[SkillStart] input={}", abbreviate(input));
// ... 执行逻辑
log.info("[SkillEnd] output={}", abbreviate(output));
}
开放性问题
在 Skill 架构下,如何设计灰度发布方案?这里有几个思考方向:
- 路由策略 :根据用户 ID 哈希决定走新版还是旧版 Skill
- 流量染色 :通过 Context 中的标记控制流程分支
- A/ B 测试 :同时发布两个版本的 Skill,对比监控指标
- 渐进式发布 :从 1% 流量开始逐步放大
你会选择哪种方案?欢迎在评论区分享你的架构设计思路。
正文完
发表至: 技术分享
近一天内
