Administrator
发布于 2025-11-26 / 2927 阅读
14

LangChain4j Agent 框架实战

新项目选了 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 提供了 DocumentTransformerDocumentSplitter 两个扩展点,而且内置了多种实现:

// 自定义按条款切分
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-max89.2%6.1%¥0.344.2s
deepseek-chat87.6%7.8%¥0.093.1s
本地 qwen2.5:32b81.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 AILangChain4j
上手速度更快(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 框架实战》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考