LangChain4j 生产级工程骨架:12 个模块 + Docker 一键启动
一句话结论 :单文件 Demo 的问题不是"代码少",而是所有决策都被隐式写死。把模型、记忆、检索、工具、配置、观测拆成 12 个边界清晰的模块,并用 内存模式 保证零外部依赖启动;Docker 只是生产可选,不是开发前提。
适用版本 :LangChain4j
1.23.0/ JDK21/ Spring Boot4.1.x运行方式 :默认内存模式,无需 Docker;真实模型与 PgVector/Redis 均为可选 profile
适合读者:准备从示例代码搭起第一个 LangChain4j 生产项目的工程师
0. 先说结论
我见过最多的"生产项目",结构是这样的:
text
src/main/java/
└── DemoApplication.java // 400 行,模型、Prompt、RAG、工具全在里面
第一个需求跑得很快;第二个需求进来时,只能复制粘贴。第三个需求进来时,没人敢改。
生产骨架要解决的不是"怎么调模型",而是六个问题:
- 模型、Key、超时、重试放在哪里?
- 记忆、检索、工具如何按环境替换?
- 没有 Docker、没有 API Key 时,项目还能不能启动和测试?
- 出问题时,能不能定位到"检索慢"还是"模型慢"?
- 换模型、换向量库时,业务代码要不要改?
- 新同学进来,能不能在 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
三条原则:
- 依赖只能向下 :
projects → rag/prod/agent → common,禁止反向依赖; - 文章编号只出现在叶子包,公共层不绑定文章;
- 包边界就是替换边界 :换向量库只动
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 |
其他项目模块 |
三个反模式:
- common 反向依赖业务 :common 里出现
OrderTools,导致所有项目被迫带上订单逻辑; - 项目之间互相依赖:知识库项目 import 运维项目的工具,最后只能一起发布;
- 文章代码跨模块引用: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,mockprofile 不要求; - 配置错误直接启动失败,不要"带病运行"。
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>
三条规则:
- core 与 beta 分开变量:MCP、Agentic、Starter 等 Beta 模块版本号独立;
- 禁止子模块写死版本:统一继承根 POM;
- 每次升级跑全量测试 ,并把变更记录到
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:启动装配顺序。任何一步失败都应快速失败,而不是等到第一次请求。
三个原则:
- 配置校验在创建 Bean 之前,避免半初始化状态;
- 工厂不发起网络请求,连接延迟到第一次调用;
- 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 本篇小结:骨架的验收标准
一个生产骨架是否合格,不看目录有多漂亮,只看四条:
- 无外部依赖可启动:新同学 clone 后,不装 Docker、不配 Key,也能跑起来;
- 配置错误启动即失败:不要让错误配置潜伏到第一次请求;
- 模型/记忆/检索/工具均可替换:换供应商只改工厂与配置,不改业务;
- 单测不访问网络: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 篇路线图(专栏内查看)