LangChain4j 生产级工程骨架:12 个模块 + Docker 一键启动

LangChain4j 生产级工程骨架:12 个模块 + Docker 一键启动

一句话结论 :单文件 Demo 的问题不是"代码少",而是所有决策都被隐式写死。把模型、记忆、检索、工具、配置、观测拆成 12 个边界清晰的模块,并用 内存模式 保证零外部依赖启动;Docker 只是生产可选,不是开发前提。

适用版本 :LangChain4j 1.23.0 / JDK 21 / Spring Boot 4.1.x

运行方式 :默认内存模式,无需 Docker;真实模型与 PgVector/Redis 均为可选 profile

适合读者:准备从示例代码搭起第一个 LangChain4j 生产项目的工程师


0. 先说结论

我见过最多的"生产项目",结构是这样的:

text 复制代码
src/main/java/
└── DemoApplication.java   // 400 行,模型、Prompt、RAG、工具全在里面

第一个需求跑得很快;第二个需求进来时,只能复制粘贴。第三个需求进来时,没人敢改。

生产骨架要解决的不是"怎么调模型",而是六个问题:

  1. 模型、Key、超时、重试放在哪里?
  2. 记忆、检索、工具如何按环境替换?
  3. 没有 Docker、没有 API Key 时,项目还能不能启动和测试?
  4. 出问题时,能不能定位到"检索慢"还是"模型慢"?
  5. 换模型、换向量库时,业务代码要不要改?
  6. 新同学进来,能不能在 10 分钟内跑起来?

本文给出一个可复用的骨架答案。


1. 单文件 Demo 到生产项目的 12 个差距

# Demo 写法 生产要求 不做的后果
1 new OpenAiChatModel() 写在方法里 统一 ModelFactory 换模型要改业务代码
2 API Key 写在常量里 环境变量 / 密钥管理 泄露、无法多环境
3 没有超时与重试 统一超时、重试、熔断 线程池被打满
4 单例记忆 ChatMemoryProvider + 租户 key 记忆串号
5 RAG 逻辑混在 Service 摄取与检索分离 索引不可维护
6 所有方法都加 @Tool 权限矩阵 + 审批 模型触发危险副作用
7 异常直接抛出 降级与兜底回答 一个工具失败拖垮整轮
8 打印完整 Prompt 结构化日志 + 脱敏 泄露用户数据
9 没有观测 Listener + 指标 + Trace 无法归因
10 只手工测试 Mock + 契约 + 回归集 改 Prompt 全靠运气
11 配置散落 集中配置 + 校验 环境不一致
12 版本随手写 根 POM 统一锁定 依赖冲突、不可复现

这 12 条里,只有第 1 条和模型有关。生产化的主战场在工程约束,不在 API。


2. 仓库结构:12 个模块

"12 个模块"指职责边界,不是 12 个 Maven module。Maven 模块过多会让构建变慢,职责包更实用。
#mermaid-svg-Ip8yVpAYIz9DJMJs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ip8yVpAYIz9DJMJs .error-icon{fill:#552222;}#mermaid-svg-Ip8yVpAYIz9DJMJs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ip8yVpAYIz9DJMJs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .marker.cross{stroke:#333333;}#mermaid-svg-Ip8yVpAYIz9DJMJs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ip8yVpAYIz9DJMJs p{margin:0;}#mermaid-svg-Ip8yVpAYIz9DJMJs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .cluster-label text{fill:#333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .cluster-label span{color:#333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .cluster-label span p{background-color:transparent;}#mermaid-svg-Ip8yVpAYIz9DJMJs .label text,#mermaid-svg-Ip8yVpAYIz9DJMJs span{fill:#333;color:#333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .node rect,#mermaid-svg-Ip8yVpAYIz9DJMJs .node circle,#mermaid-svg-Ip8yVpAYIz9DJMJs .node ellipse,#mermaid-svg-Ip8yVpAYIz9DJMJs .node polygon,#mermaid-svg-Ip8yVpAYIz9DJMJs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ip8yVpAYIz9DJMJs .rough-node .label text,#mermaid-svg-Ip8yVpAYIz9DJMJs .node .label text,#mermaid-svg-Ip8yVpAYIz9DJMJs .image-shape .label,#mermaid-svg-Ip8yVpAYIz9DJMJs .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ip8yVpAYIz9DJMJs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ip8yVpAYIz9DJMJs .rough-node .label,#mermaid-svg-Ip8yVpAYIz9DJMJs .node .label,#mermaid-svg-Ip8yVpAYIz9DJMJs .image-shape .label,#mermaid-svg-Ip8yVpAYIz9DJMJs .icon-shape .label{text-align:center;}#mermaid-svg-Ip8yVpAYIz9DJMJs .node.clickable{cursor:pointer;}#mermaid-svg-Ip8yVpAYIz9DJMJs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .arrowheadPath{fill:#333333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ip8yVpAYIz9DJMJs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ip8yVpAYIz9DJMJs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ip8yVpAYIz9DJMJs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ip8yVpAYIz9DJMJs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ip8yVpAYIz9DJMJs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ip8yVpAYIz9DJMJs .cluster text{fill:#333;}#mermaid-svg-Ip8yVpAYIz9DJMJs .cluster span{color:#333;}#mermaid-svg-Ip8yVpAYIz9DJMJs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ip8yVpAYIz9DJMJs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ip8yVpAYIz9DJMJs rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ip8yVpAYIz9DJMJs .icon-shape,#mermaid-svg-Ip8yVpAYIz9DJMJs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ip8yVpAYIz9DJMJs .icon-shape p,#mermaid-svg-Ip8yVpAYIz9DJMJs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ip8yVpAYIz9DJMJs .icon-shape rect,#mermaid-svg-Ip8yVpAYIz9DJMJs .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ip8yVpAYIz9DJMJs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ip8yVpAYIz9DJMJs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ip8yVpAYIz9DJMJs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-Ip8yVpAYIz9DJMJs .shared>*{fill:#2A1F3D!important;stroke:#A78BFA!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .shared span{fill:#2A1F3D!important;stroke:#A78BFA!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .shared tspan{fill:#E8EEF9!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .lab>*{fill:#111A2E!important;stroke:#4F8CFF!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .lab span{fill:#111A2E!important;stroke:#4F8CFF!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .lab tspan{fill:#E8EEF9!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .project>*{fill:#0F2A1D!important;stroke:#22C55E!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .project span{fill:#0F2A1D!important;stroke:#22C55E!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .project tspan{fill:#E8EEF9!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .infra>*{fill:#1E293B!important;stroke:#94A3B8!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .infra span{fill:#1E293B!important;stroke:#94A3B8!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-Ip8yVpAYIz9DJMJs .infra tspan{fill:#E8EEF9!important;} lab-common

模型 / 配置 / 记忆 / 观测 / 测试
labs-core

A01--A06
labs-rag

A07--A11
labs-prod

A12--A15
labs-agent

A16--A18
projects/kb-assistant

A19
projects/ops-copilot

A20
benchmarks + datasets

实验与黄金集
docker + ci + docs

环境与流水线

图 1:模块依赖方向。业务模块只依赖 lab-common,项目模块组合业务模块。

# 模块 职责 不做什么
1 lab-common/model 模型工厂、Profile、超时 不写业务 Prompt
2 lab-common/config 配置绑定、校验 不读业务数据
3 lab-common/memory 记忆 Store、租户 key 不决定对话策略
4 lab-common/observe Listener、指标、Trace 不采集敏感内容
5 lab-common/test Mock、Fixture、断言工具 不依赖外部服务
6 labs-core AiServices、记忆、流式、工具 不包含 RAG 管线
7 labs-rag 摄取、检索、重排、评估 不包含业务权限
8 labs-prod Spring 集成、安全、测试 不包含 Agent 编排
9 labs-agent MCP、Agentic、决策模型 不包含具体业务工具
10 projects/kb-assistant 知识库完整实现 不重复造 common
11 projects/ops-copilot 运维 Copilot 不直接操作生产
12 benchmarks/datasets/docker/ci/docs 实验、数据、环境、流水线 不进入运行时依赖

2.1 包结构示例

text 复制代码
com.acme.lc4j
├── common
│   ├── model        # ModelFactory / Profile / 超时
│   ├── config       # Lc4jProperties / 校验
│   ├── memory       # ChatMemoryStore 实现
│   ├── observe      # Listener / Metrics / Trace
│   └── test         # Mock / Fixture / 断言
├── core             # A01--A06
│   ├── a01
│   ├── a02
│   └── ...
├── rag              # A07--A11
├── prod             # A12--A15
├── agent            # A16--A18
└── projects
    ├── kb           # A19
    └── ops          # A20

三条原则:

  1. 依赖只能向下 :projects → rag/prod/agent → common,禁止反向依赖;
  2. 文章编号只出现在叶子包,公共层不绑定文章;
  3. 包边界就是替换边界 :换向量库只动 rag/store,换模型只动 common/model。

2.2 模块依赖规则

模块 可以依赖 禁止依赖
lab-common JDK、LangChain4j core 任何业务模块
labs-core lab-common labs-rag / labs-prod
labs-rag lab-common labs-core 的业务类
labs-prod lab-common 项目模块
labs-agent lab-common 项目模块
projects/* 业务模块 + lab-common 其他项目模块

三个反模式:

  1. common 反向依赖业务 :common 里出现 OrderTools,导致所有项目被迫带上订单逻辑;
  2. 项目之间互相依赖:知识库项目 import 运维项目的工具,最后只能一起发布;
  3. 文章代码跨模块引用:A09 的示例依赖 A12 的 Spring Bean,读者单独跑不起来。

约束建议:用 ArchUnit 写 3 条包依赖测试,或在 CI 里跑 mvn dependency:analyze + 自定义脚本。架构约束不写进 CI,三个月后一定破。


3. 零依赖启动:内存模式

这是本骨架最重要的设计:没有 Docker、没有 API Key,也能启动、能测试、能演示。

3.1 三个内存替身

生产组件 内存替身 用途
PgVector / Elasticsearch InMemoryEmbeddingStore 本地 RAG、单测
Redis ChatMemoryStore ConcurrentHashMap 实现 多轮对话、租户隔离验证
真实 ChatModel Mock / 录制回放 契约测试、CI

真实模型不能完全 Mock 的是"生成质量";但工程逻辑、装配、工具调用、记忆读写都可以在内存模式下验证。

3.2 统一模型工厂

业务代码不应该出现 OpenAiChatModel、OllamaChatModel 这些具体类。用一个工厂收口:

java 复制代码
// LangChain4j 1.23.0 | JDK 21
public final class ModelFactory {

    public static ChatModel chatModel(ModelProperties props) {
        return switch (props.profile()) {
            case "deepseek" -> OpenAiChatModel.builder()
                    .baseUrl(props.baseUrl())
                    .apiKey(props.apiKey())
                    .modelName(props.modelName())
                    .temperature(0.2)
                    .timeout(Duration.ofSeconds(60))
                    .build();
            case "ollama" -> OllamaChatModel.builder()
                    .baseUrl(props.baseUrl())
                    .modelName(props.modelName())
                    .timeout(Duration.ofSeconds(120))
                    .build();
            case "mock" -> new EchoChatModel(); // 仅用于本地/CI
            default -> throw new IllegalArgumentException("未知 profile: " + props.profile());
        };
    }
}

EchoChatModel 不需要模拟智能,只需要稳定返回固定文本,用于验证装配链路。真实质量回归交给 A11/A15 的评估体系。

3.3 配置矩阵

Profile ChatModel Embedding 外部依赖 用途
mock Echo/Mock 固定向量 无 本地启动、CI、契约测试
ollama 本地模型 本地 embedding Ollama(可选) 离线开发
deepseek DeepSeek 云端 embedding API Key 国内云端主力
qwen Qwen text-embedding-v3 API Key 国内云端备选
openai GPT 系列 text-embedding-3-small API Key 对照实验
yaml 复制代码
# application.yml(节选)
lc4j:
  profile: mock          # 默认零依赖;切换 deepseek/ollama/qwen 即可
  model:
    base-url: ${LC4J_BASE_URL:}
    api-key: ${LC4J_API_KEY:}
    model-name: ${LC4J_MODEL:}
  rag:
    vector-store: memory # memory | pgvector | elasticsearch
    top-k: 5

原则:默认 profile 必须是"没有外部服务也能启动"的。否则新同学第一小时就卡在环境上。


3.4 内存 Store:让多轮对话可测

除了向量库,记忆也需要一个"零依赖替身"。下面这个 Store 用 ConcurrentHashMap 保存每个会话的消息:

java 复制代码
public class InMemoryChatMemoryStore implements ChatMemoryStore {

    private final Map<Object, List<ChatMessage>> store = new ConcurrentHashMap<>();

    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        return store.getOrDefault(memoryId, List.of());
    }

    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        store.put(memoryId, List.copyOf(messages));
    }

    @Override
    public void deleteMessages(Object memoryId) {
        store.remove(memoryId);
    }
}

它不能替代 Redis 的持久化与 TTL,但能验证三件事:记忆是否按 memoryId 隔离、窗口是否按预期淘汰、AiServices 是否正确写入。

3.5 配置校验:启动时就失败

生产事故里有一类很隐蔽:配置写错,应用能启动,直到第一次调用模型才报错。正确做法是启动时校验。

java 复制代码
@ConfigurationProperties(prefix = "lc4j")
@Validated
public record Lc4jProperties(
        @NotBlank String profile,
        @Valid Model model,
        @Valid Rag rag) {

    public record Model(String baseUrl, String apiKey, String modelName) {}

    public record Rag(@NotBlank String vectorStore,
                      @Min(1) int topK) {}
}

配套规则:

  • profile 必填,且只允许白名单值;
  • vectorStore 必填,避免运行到检索时才发现;
  • 真实模型 profile 才校验 apiKey,mock profile 不要求;
  • 配置错误直接启动失败,不要"带病运行"。

3.6 依赖与版本锁定

xml 复制代码
<properties>
    <java.version>21</java.version>
    <langchain4j.version>1.23.0</langchain4j.version>
    <langchain4j.beta.version>1.23.0-beta33</langchain4j.beta.version>
</properties>

三条规则:

  1. core 与 beta 分开变量:MCP、Agentic、Starter 等 Beta 模块版本号独立;
  2. 禁止子模块写死版本:统一继承根 POM;
  3. 每次升级跑全量测试 ,并把变更记录到 VERSIONS.md。
bash 复制代码
mvn dependency:tree -Dincludes=dev.langchain4j
mvn versions:display-dependency-updates

4. 装配:把六层收口到一个配置类

下面是配置类的核心结构;EmbeddingFactory、StoreFactory 的完整实现见仓库。

java 复制代码
@Configuration
public class AssistantConfiguration {

    @Bean
    SupportAssistant supportAssistant(ModelProperties modelProps,
                                      RagProperties ragProps) {
        ChatModel chatModel = ModelFactory.chatModel(modelProps);
        EmbeddingModel embeddingModel = EmbeddingFactory.create(ragProps);
        EmbeddingStore<TextSegment> store = StoreFactory.create(ragProps, embeddingModel);

        ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
                .embeddingStore(store)
                .embeddingModel(embeddingModel)
                .maxResults(ragProps.topK())
                .minScore(ragProps.minScore())
                .build();

        return AiServices.builder(SupportAssistant.class)
                .chatModel(chatModel)
                .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20))
                .contentRetriever(retriever)
                .tools(new OrderTools())
                .build();
    }
}

这段配置对应六层:

  • ModelFactory → L1;
  • @SystemMessage / @UserMessage → L2(在接口上);
  • chatMemoryProvider → L3;
  • ContentRetriever → L4;
  • tools → L5;
  • AiServices.builder → L6。

4.1 Bean 生命周期与线程安全

装配类里最容易犯的错,是把"请求级状态"放进单例 Bean。

组件 默认生命周期 线程安全 注意
ChatModel 单例 通常安全 不要在模型上存请求状态
EmbeddingModel 单例 通常安全 批量调用要限流
ContentRetriever 单例 安全 依赖的 Store 要线程安全
ChatMemory 每会话 不安全 同一 memoryId 串行化
Tools 单例 取决于实现 工具必须无状态
AiServices 代理 单例 基本安全 记忆并发问题仍在
java 复制代码
// 反例:把请求参数存进单例工具
public class BadTool {
    private String currentUserId; // 并发下会串
    @Tool public String query() { return queryBy(currentUserId); }
}

// 正例:参数从方法入参来,工具无状态
public class GoodTool {
    @Tool public String query(@P("用户 ID") String userId) {
        return queryBy(userId);
    }
}

4.2 配置分环境:三份文件,一套结构

yaml 复制代码
# application-mock.yml
lc4j:
  profile: mock
  rag:
    vector-store: memory
    top-k: 5

# application-deepseek.yml
lc4j:
  profile: deepseek
  model:
    base-url: https://api.deepseek.com/v1
    model-name: deepseek-chat
    api-key: ${DEEPSEEK_API_KEY}
  rag:
    vector-store: pgvector
    top-k: 5

规则:

  • 公共配置放 application.yml,环境差异放 application-{profile}.yml;
  • 密钥只从环境变量/密钥管理读取,配置文件里不出现明文;
  • 启动时打印"生效 profile + 脱敏后的关键配置",方便排查;
  • 不允许业务代码用 @Value 零散读取,统一走 Lc4jProperties。

5. Docker 只是可选:生产环境再打开

开发默认 mock/memory;需要真实验证时再启动容器:

yaml 复制代码
# docker/compose.yml(节选)
services:
  postgres:
    image: pgvector/pgvector:pg17
    ports: ["5432:5432"]
  redis:
    image: redis:7.4-alpine
    ports: ["6379:6379"]
  ollama:
    image: ollama/ollama:latest
    ports: ["11434:11434"]
bash 复制代码
docker compose up -d
ollama pull qwen3:8b

不要把"能起 Docker"当成开发前置条件。容器是验证环境,不是代码结构的遮羞布。


5.1 启动流程:先校验,再装配

#mermaid-svg-DPYS4C6kRgmbtNZG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DPYS4C6kRgmbtNZG .error-icon{fill:#552222;}#mermaid-svg-DPYS4C6kRgmbtNZG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DPYS4C6kRgmbtNZG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DPYS4C6kRgmbtNZG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DPYS4C6kRgmbtNZG .marker.cross{stroke:#333333;}#mermaid-svg-DPYS4C6kRgmbtNZG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DPYS4C6kRgmbtNZG p{margin:0;}#mermaid-svg-DPYS4C6kRgmbtNZG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DPYS4C6kRgmbtNZG .cluster-label text{fill:#333;}#mermaid-svg-DPYS4C6kRgmbtNZG .cluster-label span{color:#333;}#mermaid-svg-DPYS4C6kRgmbtNZG .cluster-label span p{background-color:transparent;}#mermaid-svg-DPYS4C6kRgmbtNZG .label text,#mermaid-svg-DPYS4C6kRgmbtNZG span{fill:#333;color:#333;}#mermaid-svg-DPYS4C6kRgmbtNZG .node rect,#mermaid-svg-DPYS4C6kRgmbtNZG .node circle,#mermaid-svg-DPYS4C6kRgmbtNZG .node ellipse,#mermaid-svg-DPYS4C6kRgmbtNZG .node polygon,#mermaid-svg-DPYS4C6kRgmbtNZG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DPYS4C6kRgmbtNZG .rough-node .label text,#mermaid-svg-DPYS4C6kRgmbtNZG .node .label text,#mermaid-svg-DPYS4C6kRgmbtNZG .image-shape .label,#mermaid-svg-DPYS4C6kRgmbtNZG .icon-shape .label{text-anchor:middle;}#mermaid-svg-DPYS4C6kRgmbtNZG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DPYS4C6kRgmbtNZG .rough-node .label,#mermaid-svg-DPYS4C6kRgmbtNZG .node .label,#mermaid-svg-DPYS4C6kRgmbtNZG .image-shape .label,#mermaid-svg-DPYS4C6kRgmbtNZG .icon-shape .label{text-align:center;}#mermaid-svg-DPYS4C6kRgmbtNZG .node.clickable{cursor:pointer;}#mermaid-svg-DPYS4C6kRgmbtNZG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DPYS4C6kRgmbtNZG .arrowheadPath{fill:#333333;}#mermaid-svg-DPYS4C6kRgmbtNZG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DPYS4C6kRgmbtNZG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DPYS4C6kRgmbtNZG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DPYS4C6kRgmbtNZG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DPYS4C6kRgmbtNZG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DPYS4C6kRgmbtNZG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DPYS4C6kRgmbtNZG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DPYS4C6kRgmbtNZG .cluster text{fill:#333;}#mermaid-svg-DPYS4C6kRgmbtNZG .cluster span{color:#333;}#mermaid-svg-DPYS4C6kRgmbtNZG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DPYS4C6kRgmbtNZG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DPYS4C6kRgmbtNZG rect.text{fill:none;stroke-width:0;}#mermaid-svg-DPYS4C6kRgmbtNZG .icon-shape,#mermaid-svg-DPYS4C6kRgmbtNZG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DPYS4C6kRgmbtNZG .icon-shape p,#mermaid-svg-DPYS4C6kRgmbtNZG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DPYS4C6kRgmbtNZG .icon-shape rect,#mermaid-svg-DPYS4C6kRgmbtNZG .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DPYS4C6kRgmbtNZG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DPYS4C6kRgmbtNZG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DPYS4C6kRgmbtNZG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-DPYS4C6kRgmbtNZG .step>*{fill:#111A2E!important;stroke:#4F8CFF!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .step span{fill:#111A2E!important;stroke:#4F8CFF!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .step tspan{fill:#E8EEF9!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .factory>*{fill:#0F2A1D!important;stroke:#22C55E!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .factory span{fill:#0F2A1D!important;stroke:#22C55E!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .factory tspan{fill:#E8EEF9!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .agent>*{fill:#2A1F3D!important;stroke:#A78BFA!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .agent span{fill:#2A1F3D!important;stroke:#A78BFA!important;color:#E8EEF9!important;stroke-width:2px!important;}#mermaid-svg-DPYS4C6kRgmbtNZG .agent tspan{fill:#E8EEF9!important;} 读取 profile
校验配置
ModelFactory
EmbeddingFactory
StoreFactory
ContentRetriever
AiServices

图 2:启动装配顺序。任何一步失败都应快速失败,而不是等到第一次请求。

三个原则:

  1. 配置校验在创建 Bean 之前,避免半初始化状态;
  2. 工厂不发起网络请求,连接延迟到第一次调用;
  3. mock profile 短路真实依赖,保证本地与 CI 可启动。

6. 12 个高频坑

# 坑 现象 修复
1 模型 Bean 单例但配置可变 并发下模型参数串台 按 profile 建不可变 Bean
2 记忆 key 只用 userId 多会话互相污染 tenant:user:conversation
3 启动时强依赖外部服务 本地/CI 起不来 内存 profile + 懒加载
4 超时只设连接超时 请求一直挂 读超时 + 总超时
5 重试没有幂等 工具重复执行 幂等键 + 只重试只读操作
6 日志打印完整 Prompt 泄露 PII 结构化 + 脱敏
7 配置散落在 @Value 环境不一致 @ConfigurationProperties + 校验
8 向量维度写死 换 embedding 即崩 维度与表结构同源配置
9 工具异常直接抛出 整轮对话失败 错误回填给模型 + 兜底
10 没有请求 ID 无法串联链路 入口生成 traceId
11 测试依赖真实模型 CI 慢且不稳定 Mock + 契约断言
12 版本不锁定 构建不可复现 根 POM 统一版本

6.1 三个最贵的坑

坑 A:记忆串号。

现象:用户 A 问"我的订单",模型答出了用户 B 的订单。

根因:memoryId 只用了 userId,同一用户多会话共享记忆;或 Store key 少了租户前缀。

修复:tenant:user:conversation 三段式 key;所有读写强制带租户;加隔离测试。

坑 B:工具重复执行。

现象:一次退款请求扣了两次款。

根因:工具超时后框架/网关重试,工具本身没有幂等键;或模型重复发起同一调用。

修复:写工具必须带幂等键;执行前查状态;重复请求返回同一结果。

坑 C:配置漂移。

现象:本地正常,预发报错,生产行为又不同。

根因:application.yml、环境变量、配置中心三套来源,优先级不清晰。

修复:单一配置入口 + 启动打印脱敏后的生效配置 + 配置校验;禁止在代码里写默认 Key。


7. 测试策略:不依赖真实模型

生产骨架如果没有测试,第二次改 Prompt 就会退化成"手工回归"。

层 是否调真实模型 依赖 目标
单元测试 否 无 分块、解析、配置、工具方法
契约测试 否 Mock 断言 ChatRequest 的 Prompt/工具/记忆
集成测试 可选 Testcontainers 验证 PgVector/Redis/ES 适配
质量回归 是 真实模型 + 黄金集 验证回答质量, nightly
线上评估 是 采样 真实用户分布下的持续观测

7.1 契约测试断言什么

不调真实模型,也能验证装配是否正确。核心是捕获出站 ChatRequest,断言四件事:

  • System message 是否包含关键约束;
  • 工具是否被注册(tools 非空且名称正确);
  • 记忆是否被带上(历史消息数量符合预期);
  • RAG 内容是否被注入(检索结果出现在消息中)。
java 复制代码
// 伪代码:用 Listener 捕获请求,包名以 1.23.0 为准
ChatModelListener capturing = new ChatModelListener() {
    @Override
    public void onRequest(ChatModelRequestContext ctx) {
        ChatRequest request = ctx.chatRequest();
        assertThat(request.tools()).isNotEmpty();
        assertThat(request.messages()).anyMatch(m -> m instanceof SystemMessage);
    }
};

7.2 上线前检查清单

  • 默认 profile 无外部依赖即可启动;
  • 配置错误会在启动时失败;
  • 模型、Embedding、Store 都有超时;
  • 记忆 key 包含租户/用户/会话;
  • 工具权限在代码内校验;
  • 日志脱敏,不打印完整 Prompt;
  • 有请求 ID、Token 计量、错误分类;
  • 有降级路径与开关;
  • 版本号集中锁定;
  • CI 能在无网络、无 Docker 下跑单测。

7.3 CI 流水线:无网络也能跑

yaml 复制代码
name: ci
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven
      - run: mvn -q -DskipTests package
      - run: mvn -q test

四条约束:

  • 单测不访问网络、不依赖 Docker;
  • 集成测试用 @Tag("integration") 单独跑;
  • 缓存 ~/.m2,避免每次全量下载;
  • 加依赖漏洞扫描与版本检查,防止供应链问题。

8. 跑起来:三条命令

bash 复制代码
# 1. 零依赖启动(默认 mock + memory)
mvn -q -pl labs-core -am spring-boot:run

# 2. 跑单测(不调真实模型)
mvn -q test

# 3. 可选:启动真实依赖
cd docker && docker compose up -d
mvn -q -pl labs-core -am spring-boot:run -Dspring-boot.run.profiles=ollama

上面是示例命令,模块名与启动类按实际仓库调整;核心是"默认 mock 能跑,真实依赖作为可选 profile"。


9. 适用边界

适合:需要长期维护、多人协作、要接真实业务的 Java LLM 项目。

不适合:一次性脚本、纯 Demo、只调用一次模型的工具。对这类场景,单文件反而更高效。


9.1 演进路线:不要第一天就拆 12 个模块

阶段 团队规模 建议结构
原型 1--2 人 单模块 + 清晰包边界
首个生产 2--5 人 common + app 两个模块
多场景 5--10 人 按 core/rag/prod 拆
多产品 10+ 人 项目模块 + 共享 common

判断是否该拆的标准不是"代码行数",而是:

  • 是否出现两个以上独立发布节奏;
  • 是否有明确的依赖方向需要强制;
  • 是否有人因为构建太慢而开始复制代码。

如果答案都是"否",先保持单模块。模块化的目的是降低协作成本,不是制造架构表演。


9.2 容量估算:先算 Token,再谈机器

text 复制代码
日 Token = 日请求数 × 平均输入 Token × (1 + 重试率)
日成本 = 日 Token / 1M × 每百万 Token 单价
并发数 ≈ QPS × 平均响应时间(秒)

不要凭"感觉"买机器。先测三个数:平均输入 Token、平均输出 Token、P95 延迟;再用公式反推并发与预算。

变量 从哪里来 常见误区
平均输入 Token 模型 usage / 日志 只算用户问题,不算 RAG 上下文
平均输出 Token 模型 usage 忽略工具调用轮次
重试率 网关指标 把重试算成新请求
P95 延迟 端到端埋点 只测模型,不测检索/工具

9.3 安全基线

  • 密钥只进环境变量/密钥管理,不进 Git、不进日志;
  • 模型与向量库的出站访问要走白名单/代理;
  • Prompt 日志脱敏,禁止打印完整用户输入与工具参数;
  • 工具默认拒绝,按白名单开放,高风险操作加审批;
  • 依赖锁定版本并做漏洞扫描;
  • 多租户请求必须带租户上下文,禁止跨租户检索。

9.4 什么时候用纯 SDK 就够

如果只有一个模型、一个 Prompt、没有记忆/RAG/工具,直接用 HTTP/SDK 更简单。框架的价值在"组合与治理",不在"单次调用"。不要为了用框架而用框架。


9.5 本篇小结:骨架的验收标准

一个生产骨架是否合格,不看目录有多漂亮,只看四条:

  1. 无外部依赖可启动:新同学 clone 后,不装 Docker、不配 Key,也能跑起来;
  2. 配置错误启动即失败:不要让错误配置潜伏到第一次请求;
  3. 模型/记忆/检索/工具均可替换:换供应商只改工厂与配置,不改业务;
  4. 单测不访问网络:CI 稳定、快、可重复。

这四条同时成立,后面的 RAG、Agent、观测才有地方可挂。


10. FAQ

为什么默认用 mock,而不是直接连真实模型?

因为工程的第一个验收标准是"任何人 clone 后能启动、能跑测试"。真实模型是第二个验收标准。顺序反了, onboarding 成本会很高。

内存模式能验证什么,不能验证什么?

能验证装配、记忆读写、检索链路、工具注册、异常处理、配置切换;不能验证生成质量、真实延迟、真实召回率。后者需要 A11/A15 的评估体系。

一定要 Spring Boot 吗?

不一定。lab-common 可以用纯 Java;Spring 只是配置与生命周期管理。A12 会给出 Spring Boot 集成细节。

12 个模块会不会太重?

职责边界是 12 个,Maven 模块可以合并。小团队建议 3--5 个 Maven module + 清晰的包边界,不要为了"架构感"拆 12 个构建单元。

文章代码和仓库代码如何保持一致?

每条示例都在仓库里有对应模块与测试;文章发布前跑一次 CI,代码片段从仓库回填。不要把文章当成"另一个代码库"。

没有 API Key 的读者怎么验证?

用 mock profile + InMemoryEmbeddingStore + 内存 ChatMemoryStore,先验证装配、记忆、检索与工具注册;真实生成质量再切到自己的模型。


11. 系列导航

  • 上一篇:A01《LangChain4j 1.23 架构全景:Java 大模型应用的 6 层抽象》
  • 下一篇:A03《AiServices 源码级拆解:一个接口如何变成 RAG Agent》
  • 全系列:《LangChain4j 生产级实战》20 篇路线图(专栏内查看)

相关推荐
叼烟扛炮33 分钟前
[LangChain] 学习笔记(08):工具调用底层闭环与第三方搜索(Tavily)实战
人工智能·笔记·学习·langchain
RuoyiOffice1 小时前
固定资产维修管理:从报修、审批到办结,原资产卡怎么回查?
spring boot·固定资产·ruoyi office·资产维修·维修审批·维修办结·资产履历
噢,我明白了1 小时前
JAVA中的一些基础应用知识点
java·开发语言
招风的黑耳1 小时前
SpringBoot+Vue+MySQL 民宿在线预定平台 完整可运行版(源码+文档)
vue.js·spring boot·mysql
GISer_Jing2 小时前
AI Agent秋招面试万字通关指南|架构、ReAct、上下文工程、RAG、评测、生产运维全解
后端·ai·langchain
SL_staff2 小时前
私有化低代码不是妥协,而是企业级数字化的自主可控主航道
java
装不满的克莱因瓶2 小时前
【SpringAI Alibaba】ReactAgent结构化输出OutputType与OutputSchema实战
java·人工智能·ai·springai·springaialibaba
秋天的怀念_David2 小时前
算法一千零一夜 · 第二十八夜 注册
java·程序人生·算法
Java码农也是农3 小时前
Spring 事务详解:@Transactional 如何生效、何时回滚、传播行为怎么选
java·spring boot·spring·事务