新项目选了 LangChain4j,而不是继续用 Spring AI
我们团队的运维 Agent 一直是 Spring AI 写的,跑了半年挺稳。上个月开新项目——一个面向业务部门的合同审查 Agent,我评估了一圈,最后选了 LangChain4j。
这个决定在组内有争议,有人认为「统一技术栈」更重要。这篇记录我的判断依据,以及两个框架在实际编码中的差异。不是踩一捧一,两个都是好框架,只是适用场景不同。
需求差异在哪
先说清楚为什么新项目不能直接套用原来的方案。合同审查 Agent 有三个和运维 Agent 不同的要求:
| 需求 | 运维 Agent | 合同审查 Agent |
|---|---|---|
| 模型来源 | 单一厂商(通义) | 需要同时接多家(对比不同模型的审查质量) |
| RAG 控制粒度 | 标准流程够用 | 需要自定义切分、多路召回、自定义 rerank |
| 输出结构 | 自由文本 | 严格 JSON Schema,字段缺失要重试 |
| 运行时 | 长驻服务 | 批处理为主,需要 GraalVM 原生镜像 |
第三项和第四项是关键。输出必须能反序列化成固定的审查报告结构,而原生镜像要求框架不能有太重的运行时反射。
AiServices:LangChain4j 最舒服的地方
LangChain4j 的核心抽象是 AiServices,用接口 + 注解声明 AI 能力,框架生成代理实现。这个思路和 Spring AI 的 ChatClient 流式 API 差别挺大。
public interface ContractReviewer {
@SystemMessage("""
你是资深法务,负责审查合同风险。
只依据提供的【合同条款原文】输出结论,不要依据常识推测。
对于原文中没有覆盖的审查项,输出 status=NOT_FOUND,不要编造。
""")
@UserMessage("""
【合同条款原文】
{{contractText}}
【审查项】
{{checklist}}
逐项输出审查结果。
""")
ReviewReport review(@V("contractText") String contractText,
@V("checklist") String checklist);
}
调用就一行:
ContractReviewer reviewer = AiServices.builder(ContractReviewer.class)
.chatModel(model)
.chatMemory(MessageWindowChatMemory.withMaxMessages(10))
.tools(new ContractTools())
.build();
ReviewReport report = reviewer.review(text, checklist);
返回的是强类型的 ReviewReport,框架负责把模型输出反序列化成对象,失败时按配置重试。这一点比 Spring AI 的 BeanOutputConverter 做得更顺手——Spring AI 里输出转换是 advisor 链上的一环,出问题时错误信息很难定位;LangChain4j 把结构化输出做成了第一等公民,schema 校验失败会带原始输出一起抛异常。
// 抛出的异常信息很完整,直接能看到模型输出了什么
throw new OutputParsingException("""
Failed to parse output into ReviewReport
Expected fields: [riskLevel, findings, summary]
Missing: [findings]
Raw output: {"riskLevel": "HIGH", "summary": "存在付款条款风险"}
""");
我们在测试阶段靠这个异常信息快速迭代了三轮 prompt,把字段缺失率从 8.4% 降到 0.3%。
工具链:注解大同小异,动态工具差别大
静态工具定义两边写法几乎一样:
// LangChain4j
class ContractTools {
@Tool("查询该合作方的历史合同,返回最近 5 份的编号、金额、履约情况")
List<ContractSummary> queryHistory(
@P("合作方名称,必须是工商注册全称") String partyName) {
return contractRepo.findByParty(partyName, 5);
}
}
// Spring AI
@Component
class ContractTools {
@Tool(description = "查询该合作方的历史合同,返回最近 5 份的编号、金额、履约情况")
List<ContractSummary> queryHistory(
@ToolParam(description = "合作方名称,必须是工商注册全称") String partyName) {
return contractRepo.findByParty(partyName, 5);
}
}
差异在动态工具。我们的合同审查要支持「按合同类型加载不同的审查规则集」,也就是工具集要运行时可变。
LangChain4j 有 ToolProvider 接口,可以在每次调用时动态返回工具列表:
public class RuleToolProvider implements ToolProvider {
@Override
public ToolProviderResult provideTools(ToolProviderRequest request) {
// 从请求上下文里拿合同类型,只暴露相关的规则工具
String contractType = (String) request.userMessage().attributes()
.get("contractType");
List<ToolSpecification> specs = new ArrayList<>();
List<ToolExecutor> executors = new ArrayList<>();
for (RuleSet rule : ruleRegistry.forType(contractType)) {
specs.add(ToolSpecifications.toolSpecificationFrom(rule));
executors.add((toolReq, memId) -> rule.evaluate(toolReq.arguments()));
}
return ToolProviderResult.builder()
.addAll(specs, executors).build();
}
}
这个能力在 Spring AI 里要自己拼 ToolCallbackResolver,代码量大概是三倍。我们的规则集有 8 类,全部暴露是 62 个工具,按类型过滤后每类只有 6~11 个——前面在 MCP Server 那篇里提过,工具数量超过 40 个会显著拉低选择准确率,所以这个动态能力对我们是刚需。
RAG:控制粒度是选型的决定性因素
这是我最看重的一点。合同文档的特征是长(平均 28 页)、结构固定(条款编号清晰)、且检索的粒度要求很特殊——必须按条款检索,不能按固定长度切分。
LangChain4j 提供了 DocumentTransformer 和 DocumentSplitter 两个扩展点,而且内置了多种实现:
// 自定义按条款切分
public class ClauseSplitter implements DocumentSplitter {
private static final Pattern CLAUSE =
Pattern.compile("^\\s*第\\s*[〇零一二三四五六七八九十百]+条.*$", Pattern.MULTILINE);
@Override
public List<TextSegment> split(Document document) {
List<TextSegment> segments = new ArrayList<>();
Matcher m = CLAUSE.matcher(document.text());
int last = 0;
while (m.find()) {
if (m.start() > last) {
String body = document.text().substring(last, m.start()).trim();
if (!body.isEmpty()) {
segments.add(TextSegment.from(body,
Metadata.from(document.metadata())
.put("clause_no", extractClauseNo(body))));
}
}
last = m.start();
}
// ... 处理最后一段
return segments;
}
}
然后是检索链。LangChain4j 的 RetrievalAugmentor 支持组合多个检索源,这个我们要用——合同审查需要同时检索「当前合同正文」和「历史相似合同」:
RetrievalAugmentor augmentor = DefaultRetrievalAugmentor.builder()
.queryTransformer(new CompressingQueryTransformer(model)) // 查询改写
.queryRouter(new DefaultQueryRouter(
currentContractRetriever, // 源 1:当前合同
historyRetriever)) // 源 2:历史合同库
.contentAggregator(new ReRankingContentAggregator(
rerankingModel,
ReRankingContentAggregator.builder().minScore(0.4).build()))
.contentInjector(new DefaultContentInjector())
.build();
ContractReviewer reviewer = AiServices.builder(ContractReviewer.class)
.chatModel(model)
.retrievalAugmentor(augmentor)
.build();
DefaultQueryRouter 会把同一个查询发给所有检索源再合并,ReRankingContentAggregator 做统一重排。这套组合在 Spring AI 里要自己写 Advisor 实现,我们评估过,大约要多写 400 行。
实际效果:按条款切分 + 双路召回,审查项覆盖率从 71% 提到 89%。
补充一点公平的话:Spring AI 的
QuestionAnswerAdvisor+VectorStoreDocumentRetriever在标准 RAG 场景下更简单直观,几行配置就能跑。它的定位是「80% 场景开箱即用」,而不是「覆盖所有定制需求」。
多模型支持:这是 LangChain4j 的传统强项
合同审查要对比不同模型的表现,我们需要同时接通义、DeepSeek、以及一个本地部署的 Qwen。
Map<String, ChatModel> models = Map.of(
"qwen", QwenChatModel.builder().apiKey(k1).modelName("qwen-max").build(),
"ds", OpenAiChatModel.builder()
.baseUrl("https://api.deepseek.com")
.apiKey(k2).modelName("deepseek-chat").build(),
"local", OllamaChatModel.builder()
.baseUrl("http://gpu-01:11434")
.modelName("qwen2.5:32b").build()
);
LangChain4j 支持的模型/向量库/嵌入模型数量比 Spring AI 多不少,尤其是一些国内不太主流但在特定场景有用的(Ollama、LocalAI、各种 GGUF 格式的本地模型)。我们对本地模型的支持是硬需求——合同原文不能出内网。
测试对比结果(200 份标注合同):
| 模型 | 审查项召回率 | 误报率 | 单次成本 | P99 延迟 |
|---|---|---|---|---|
| qwen-max | 89.2% | 6.1% | ¥0.34 | 4.2s |
| deepseek-chat | 87.6% | 7.8% | ¥0.09 | 3.1s |
| 本地 qwen2.5:32b | 81.3% | 9.4% | ¥0.02(电费) | 11.7s |
最后选了 DeepSeek 做主力、本地模型做涉密合同的方案。这个对比如果只有一个框架支持,就做不出来了。
原生镜像:意外的坑
我们计划把批处理部分打成 GraalVM 原生镜像(启动从 3.4 秒降到 0.18 秒,对批处理任务有意义)。这里踩了两个坑。
第一个是反射配置。AiServices 的代理是运行时生成的,原生镜像下要提前声明:
@RegisterReflectionForBinding({ReviewReport.class, Finding.class})
public class NativeConfig {
// AiServices 接口本身也要注册
@RegisterReflectionForBinding(ContractReviewer.class)
static class AiServiceBinding {}
}
漏了 ContractReviewer.class 那一行,构建能过但运行时报 ClassNotFoundException。排查了一下午,因为错误信息指向的是生成的代理类,很难联想到是接口没注册。
第二个是 JSON 序列化的配置。LangChain4j 默认用 Jackson,原生镜像下需要给所有 DTO 加绑定注册。我们最后换成了一个更省事的做法——用 record + 显式注册,一共 14 个类。
Spring AI 那边我也做了同样的测试,因为有 Spring Boot 的 AOT 引擎,RuntimeHintsRegistrar 能自动处理大部分反射注册,体验好一些。但那需要引入整个 Spring Boot 栈,对纯批处理程序太重了。
两个框架的对比总结
| 维度 | Spring AI | LangChain4j |
|---|---|---|
| 上手速度 | 更快(Spring Boot 自动配置) | 中等(手动 builder) |
| Spring 生态集成 | 自然,无摩擦 | 有 starter,但不如原生 |
| RAG 定制能力 | 基础够用,深度定制要写代码 | 扩展点丰富,组合性强 |
| 模型/向量库覆盖 | 主流为主 | 更全,含本地模型 |
| 结构化输出 | BeanOutputConverter(advisor) | 一等公民,错误信息友好 |
| 动态工具 | 需自行扩展 | ToolProvider 开箱可用 |
| 可观测性 | 与 Micrometer/OTel 天然集成 | 有模块,集成稍麻烦 |
| 原生镜像 | 借 Spring AOT,省事 | 需手动注册,可控 |
我的选型建议
不谈优劣,只谈什么时候选哪个:
- Spring AI 适合:已有 Spring Boot 技术栈、需求是标准 RAG 或工具调用、团队对 Spring 生态熟悉、需要快速上线。我们的运维 Agent 就是这种情况,现在跑得很稳,不打算迁。
- LangChain4j 适合:需要深度定制 RAG 链路、要接非主流模型或本地模型、有动态工具需求、非 Spring 环境(批处理、CLI 工具、原生镜像)。
还有一条现实考虑:团队已有资产。如果你们已经有一套 Spring AI 的监控、工具注册、权限体系,贸然换框架的迁移成本可能大于收益。我们是因为新项目从零开始,才有得选。
关于「统一技术栈」的争议,我最后是这么说的:统一的价值在于降低维护成本,但前提是两者能互相替代。这两个框架的能力边界不完全重合,强行统一会让新项目削足适履。我们现在的做法是明确边界,不混用——运维 Agent 用 Spring AI,合同审查用 LangChain4j,两个项目的公共部分(审计日志、token 统计、权限校验)抽成独立模块,不依赖任何 AI 框架。
下篇预告
这篇先把《LangChain4j Agent 框架实战》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。