【LangChain4J-03】Springboot 项目搭建框架
- [🚀 LangChain4J--Springboot 项目搭建框架](#🚀 LangChain4J--Springboot 项目搭建框架)
-
- [🧭 一、环境准备](#🧭 一、环境准备)
-
- [🔧 本地模型准备(必须)](#🔧 本地模型准备(必须))
- [📦 二、创建项目 & 引入依赖](#📦 二、创建项目 & 引入依赖)
- [⚙️ 三、配置文件 `application.yml`](#⚙️ 三、配置文件
application.yml) - [🗂️ 四、项目结构](#🗂️ 四、项目结构)
- [🧩 五、整体架构:单一对话入口 + 策略模式 + 模板方法 + 工厂](#🧩 五、整体架构:单一对话入口 + 策略模式 + 模板方法 + 工厂)
- [🔥 六、对话 API 调用:从请求到模型](#🔥 六、对话 API 调用:从请求到模型)
-
- [6.1 控制器 `ChatController`(只做三件事)](#6.1 控制器
ChatController(只做三件事)) - [6.2 业务层 `ChatService`(路由 + 编排)](#6.2 业务层
ChatService(路由 + 编排)) - [6.3 抽象基类 `AbstractChatHandler`(模板方法)](#6.3 抽象基类
AbstractChatHandler(模板方法)) - [6.4 工厂 `ChatHandlerFactory`](#6.4 工厂
ChatHandlerFactory)
- [6.1 控制器 `ChatController`(只做三件事)](#6.1 控制器
- [🎛️ 七、七种对话模式(`ChatMode` 枚举)](#🎛️ 七、七种对话模式(
ChatMode枚举)) - [🌊 八、流式输出与「真中断」机制(重点)](#🌊 八、流式输出与「真中断」机制(重点))
-
- [8.1 流式(`POST /api/chat/stream`,SSE)------ 可「接口中断」](#8.1 流式(
POST /api/chat/stream,SSE)—— 可「接口中断」) - [8.2 非流式(`POST /api/chat`,`DeferredResult`)------ 需「显式取消信号」](#8.2 非流式(
POST /api/chat,DeferredResult)—— 需「显式取消信号」) - [8.3 为什么两种中断方式不同?](#8.3 为什么两种中断方式不同?)
- [8.1 流式(`POST /api/chat/stream`,SSE)------ 可「接口中断」](#8.1 流式(
- [🧠 九、记忆 / 人设 / 上下文裁剪 / 清空 机制](#🧠 九、记忆 / 人设 / 上下文裁剪 / 清空 机制)
- [🎚️ 十、参数体系:采样参数 + L7 全参数自定义](#🎚️ 十、参数体系:采样参数 + L7 全参数自定义)
-
- [10.1 采样参数(`params` 模式)](#10.1 采样参数(
params模式)) - [10.2 L7 全参数自定义(`custom` 模式)](#10.2 L7 全参数自定义(
custom模式)) - [10.3 三套通用模板(精准 / 通用 / 创意)的区别与效果](#10.3 三套通用模板(精准 / 通用 / 创意)的区别与效果)
- [10.1 采样参数(`params` 模式)](#10.1 采样参数(
- [📦 十一、DTO 与校验 / 全局异常](#📦 十一、DTO 与校验 / 全局异常)
-
- [统一请求 / 响应](#统一请求 / 响应)
- [校验 & 异常](#校验 & 异常)
- [🖥️ 十二、前端聊天页面(前后端不分离)](#🖥️ 十二、前端聊天页面(前后端不分离))
- [▶️ 十三、运行与调试](#▶️ 十三、运行与调试)
-
- [不用前端的 curl 调试示例(单一对话入口)](#不用前端的 curl 调试示例(单一对话入口))
- [SSE 流式(逐 token)](#SSE 流式(逐 token))
- 接口速查表
- [🚨 十四、常见问题排查](#🚨 十四、常见问题排查)
- [📎 附录 A · LangChain4j 注解(声明式 API)与命令式对比](#📎 附录 A · LangChain4j 注解(声明式 API)与命令式对比)
-
- [A.1 两路 API 总览](#A.1 两路 API 总览)
- [A.2 声明式注解一览(含义与作用)](#A.2 声明式注解一览(含义与作用))
- [A.3 声明式完整案例](#A.3 声明式完整案例)
- [A.4 命令式(本项目)等价写法](#A.4 命令式(本项目)等价写法)
- [A.5 声明式 vs 命令式 对比](#A.5 声明式 vs 命令式 对比)
- [A.6 小结](#A.6 小结)
🚀 LangChain4J--Springboot 项目搭建框架
📘 本文档记录 Spring Boot 3 + JDK 17 + LangChain4j + Ollama(qwen3:4b-instruct) 的整合过程与当前代码架构。
目标:先把「项目搭建」和「接口调试」吃透,不展开 RAG / Tools / 向量库等进阶知识点。
💡 阅读提示:文中 紫色 为注解名,蓝色 为 API/类名,橙色 为配置参数,红色 为注意事项。
🧭 一、环境准备
| 组件 | 版本 / 要求 | 说明 |
|---|---|---|
| JDK | 17 | LangChain4j 1.x 与 Spring Boot 3.x 均要求 Java 17+ |
| Spring Boot | 3.4.5 | 3.x 线,适配 JDK 17 |
| LangChain4j | 1.10.0-beta18 | 1.x 全部为 beta,但核心 API 已稳定 |
| Ollama | 最新版 | 本地大模型运行载体 |
| 模型 | qwen3:4b-instruct |
本地运行的对话模型 |
| Maven | 3.8+ | 构建工具 |
🔧 本地模型准备(必须)
bash
# 1) 启动 Ollama 服务(默认监听 http://localhost:11434)
ollama serve
# 2) 拉取并确认模型可用
ollama pull qwen3:4b-instruct
ollama list # 能看到 qwen3:4b-instruct 即正常
⚠️ 务必先保证 Ollama 在跑且模型已拉取 ,否则后端调用会报
Connection refused或model not found。
📦 二、创建项目 & 引入依赖
本 Demo 采用 Maven 多模块 结构:父工程 allen-ai 统一管版本,子模块 langchain4j-springboot-demo 只声明自己需要的依赖(不带版本号)。
xml
<!-- 📁 父工程 allen-ai/pom.xml:集中管理依赖与版本 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>allen-ai</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging> <!-- 父/聚合工程必须是 pom 打包 -->
<modules>
<module>langchain4j-springboot-demo</module>
</modules>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<langchain4j.version>1.10.0-beta18</langchain4j.version>
<!-- 另含 mybatis-plus / knife4j / sa-token / poi / elasticsearch / docx4j 等其它模块用版本 -->
</properties>
<!-- ★ 只做"版本管理":dependencyManagement 声明版本,子 module 继承,自身不写版本 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama-spring-boot-starter</artifactId>
<version>${langchain4j.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
xml
<!-- 📁 子模块 langchain4j-springboot-demo/pom.xml:只声明依赖,不写版本 -->
<parent>
<groupId>com.example</groupId>
<artifactId>allen-ai</artifactId>
<version>1.0.0</version>
</parent>
<dependencies>
<!-- Web:REST 接口 + 静态页面(前后端不分离) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 🔧 核心 Starter:扫描 @AiService、自动装配 ChatModel / StreamingChatModel -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
</dependency>
<!-- 🔧 Ollama 接入 Starter:按 yml 自动创建 OllamaChatModel / OllamaStreamingChatModel -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama-spring-boot-starter</artifactId>
</dependency>
<!-- 参数校验:Jakarta Validation(@NotNull / @NotBlank / 自定义约束)+ 全局异常 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok:@Slf4j / @Data 等(编译期注解处理器,运行时不需要) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
💡
langchain4j-spring-boot-starter负责把带@AiService的接口变成 Spring Bean,并自动装配ChatModel/StreamingChatModel;langchain4j-ollama-spring-boot-starter依据application.yml里的langchain4j.ollama.*创建模型 Bean,无需手写new OllamaChatModel()。
📌 版本说明:LangChain4j 的1.x在 Maven 上以beta形式发布(如本 Demo 使用的 1.10.0-beta18 ),这是官方常态,并非不稳定。自 1.0 起模型接口已由旧名ChatLanguageModel.generate()更名为ChatModel.chat(),ChatRequest/ChatResponse也迁移到dev.langchain4j.model.chat.request|response包;本 Demo 已按此新 API 编写。
⚙️ 三、配置文件 application.yml
yaml
server:
port: 8088
langchain4j:
ollama:
chat-model: # 🔖 非流式 ChatModel 配置(由 starter 自动创建 Bean)
base-url: http://localhost:11434
model-name: qwen3:4b-instruct
temperature: 0.7
top-p: 0.9
max-tokens: 2048
num-ctx: 8192
log-requests: true
log-responses: true
streaming-chat-model: # 🔖 流式 StreamingChatModel 配置(Ollama starter 要求显式配置才会注册该 Bean)
base-url: http://localhost:11434
model-name: qwen3:4b-instruct
temperature: 0.7
top-p: 0.9
max-tokens: 2048
num-ctx: 8192
log-requests: true
log-responses: true
app:
chat:
memory-max-messages: 20 # 🟠 记忆滑动窗口:每个 session 最多保留 20 条消息
default-temperature: 0.7 # 🟠 采样参数默认值(/params 模式按请求覆盖,null 时回退到这里)
default-top-p: 0.9
default-max-tokens: 512
logging:
level:
root: INFO
charset:
console: UTF-8 # 🟠 保证控制台(启动横幅等中文)以 UTF-8 输出,避免 GBK 终端乱码
🔑 重点参数说明:
langchain4j.ollama.chat-model/streaming-chat-model:Ollama starter 会据此分别创建ChatModel与StreamingChatModel两个 Bean。流式需要显式配置streaming-chat-model命名空间,否则该 Bean 不会被注册。memory-max-messages:记忆窗口,自动裁剪更早消息,防止 Token 超限。default-temperature / default-top-p / default-max-tokens:业务默认值,由AppProperties配置类读取(不再用散落的@Value)。
🗂️ 四、项目结构
allen-ai/ ← 父工程(Maven 多模块,集中管版本)
├── pom.xml
└── langchain4j-springboot-demo/ ← 子模块(只声明依赖,不写版本)
├── pom.xml
├── src/main/java/com/example/langchain4j/
│ ├── Langchain4jDemoApplication.java # 启动类
│ ├── StartupNotifier.java # 启动成功横幅(@Order(MAX_VALUE))
│ ├── config/
│ │ ├── AppProperties.java # app.* 业务配置绑定
│ │ ├── OllamaModelProperties.java # langchain4j.ollama.chat-model.* 绑定
│ │ └── ChatMemoryConfig.java # ChatMemoryProvider(记忆工厂 + 缓存)
│ ├── controller/
│ │ └── ChatController.java # 单一对话入口 + 流式 + 元数据 + 清空
│ ├── service/
│ │ └── ChatService.java # 路由编排(按 mode 取 handler)
│ ├── chat/ # ← 对话处理核心(按职责分子包)
│ │ ├── handler/ # 策略族 + 工厂 + 流式回调契约
│ │ │ ├── AbstractChatHandler.java # 抽象基类 / 模板方法
│ │ │ ├── BasicChatHandler.java # 模式 BASIC
│ │ │ ├── AnnotationChatHandler.java # 模式 ANNOTATION
│ │ │ ├── TemplateChatHandler.java # 模式 TEMPLATE
│ │ │ ├── SystemChatHandler.java # 模式 SYSTEM
│ │ │ ├── ParamsChatHandler.java # 模式 PARAMS
│ │ │ ├── MemoryChatHandler.java # 模式 MEMORY
│ │ │ ├── CustomChatHandler.java # 模式 CUSTOM(L7 全参数自定义)
│ │ │ ├── ChatHandlerFactory.java # 工厂:枚举 → 处理器
│ │ │ └── StreamCallback.java # 流式输出回调接口
│ │ ├── client/
│ │ │ └── OllamaStreamClient.java # 直连 Ollama 的可中断流式客户端
│ │ └── support/
│ │ ├── ChatTokenResult.java # 非流式结果载体
│ │ ├── ChatCancellationRegistry.java # 非流式取消信号注册表
│ │ └── CustomParamCatalog.java # L7 全参数目录 + 模板预设
│ ├── dto/ # ← 请求/响应对象(按业务域分子包)
│ │ ├── chat/ ChatRequestDTO / ChatResponse / ChatModeInfo
│ │ ├── param/ ParamSpec / ParamPreset
│ │ ├── user/ UserInfoDTO
│ │ ├── session/ ClearRequest / ClearResponse
│ │ └── common/ ErrorResponse
│ ├── enums/
│ │ ├── ChatMode.java # 对话模式枚举(含前端元数据)
│ │ ├── OllamaField.java # Ollama /api/chat 协议字段名(去魔法值)
│ │ ├── ParamField.java # 自定义参数 wire key(去魔法值)
│ │ └── UserInfo.java # 预设用户(下拉选 sessionId)
│ ├── exception/
│ │ └── GlobalExceptionHandler.java # 统一异常 → 友好 ErrorResponse
│ ├── validation/
│ │ ├── ValidChatRequest.java # 类级交叉校验注解
│ │ └── ChatRequestValidator.java # 校验逻辑(记忆模式需 sessionId)
│ └── util/
│ └── NumberUtils.java # 采样参数回退默认值 / 范围裁剪
└── src/main/resources/
├── application.yml
├── logback-spring.xml
├── system-prompt.txt # 固定全局人设(小Lang)
└── static/
└── chat.html # 前端聊天页(前后端不分离)
🧩 五、整体架构:单一对话入口 + 策略模式 + 模板方法 + 工厂
当前代码不再 为每种模式各写一个
@AiService接口 / 各写一个*Request。而是:
一个统一入口 → 由 mode 枚举决定走哪条「策略处理器」,所有模式共用同一套请求/响应对象。
渲染错误: Mermaid 渲染失败: Parse error on line 4: ...erFactory.getHandler(mode)] D --> E{ -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'
- 策略模式(Strategy) :每个模式是一个
AbstractChatHandler子类,只实现buildMessages()(构造发给模型的消息)等钩子,个性都在这里。 - 模板方法(Template Method) :
AbstractChatHandler.chat() / stream()是不可变的「一次对话」骨架(建消息 → 建请求 → 流式拉取 → 收尾写记忆),易变部分留给子类。 - 工厂模式(Factory) :
ChatHandlerFactory启动时收集所有 handler,用EnumMap<ChatMode, AbstractChatHandler>建立「枚举 → 处理器」映射。调用方只持有一个工厂,新增模式只需新增一个 Handler(开闭原则)。 - 直连 Ollama 客户端(可中断) :用
OllamaStreamClient替代StreamingChatModel,以获得「前端中断后端生成」的能力(见第八章)。
🔥 六、对话 API 调用:从请求到模型
6.1 控制器 ChatController(只做三件事)
接收规范 DTO → 委派 ChatService → 封装响应。提供以下端点(路径前缀 /api/chat):
| 端点 | 方法 | 说明 |
|---|---|---|
/ |
POST | 单一对话入口(非流式,可中断) ,由 mode 决定类型 |
/stream |
POST (text/event-stream) |
单一对话入口(SSE 流式,可中断) |
/cancel |
POST | 非流式中断信号 (带 requestId,置位 cancelled) |
/modes |
GET | 模式元数据(供前端展示各模式区别) |
/custom-params |
GET | L7 全参数目录(供前端渲染自定义弹窗) |
/presets |
GET | 通用参数模板预设(精准/通用/创意) |
/users |
GET | 预设用户列表(供前端下拉选 sessionId) |
/clear |
POST | 清空指定 session 的记忆 |
非流式用
DeferredResult异步执行(阻塞式生成不能占 Tomcat 请求线程);流式用SseEmitter。两者都把cancelled(BooleanSupplier)一路传到OllamaStreamClient,用于中断。
6.2 业务层 ChatService(路由 + 编排)
java
@Service
public class ChatService {
private final ChatHandlerFactory handlerFactory;
private final ChatMemoryProvider memoryProvider;
public ChatResponse chat(ChatRequestDTO req, BooleanSupplier cancelled) {
AbstractChatHandler handler = handlerFactory.getHandler(req.getMode()); // 工厂按枚举取策略
ChatTokenResult result = handler.chat(req, cancelled); // 模板方法:非流式
// 记忆模式把 sessionId 回传前端......
return ChatResponse.of(result.getText(), sessionId);
}
public void stream(ChatRequestDTO req, StreamCallback callback, BooleanSupplier cancelled) {
AbstractChatHandler handler = handlerFactory.getHandler(req.getMode());
handler.stream(req, callback, cancelled); // 模板方法:流式
}
public void clearMemory(String sessionId) {
memoryProvider.get(sessionId).clear(); // 与处理器共用同一记忆缓存
}
}
6.3 抽象基类 AbstractChatHandler(模板方法)
java
public abstract class AbstractChatHandler {
protected final ChatModel chatModel;
protected final StreamingChatModel streamingChatModel;
protected final OllamaModelProperties modelProps;
protected final AppProperties appProps;
@Autowired protected OllamaStreamClient streamingClient; // 直连 Ollama 的可中断客户端
// 模板方法(final,固定骨架)
public final ChatTokenResult chat(ChatRequestDTO req, BooleanSupplier cancelled) { /* ... */ }
public final void stream(ChatRequestDTO req, StreamCallback cb, BooleanSupplier cancelled) { /* ... */ }
// 抽象方法(策略核心):构造发给模型的消息
protected abstract List<ChatMessage> buildMessages(ChatRequestDTO req);
// 钩子方法(默认实现,子类按需重写)
protected ChatRequest buildRequest(List<ChatMessage> messages, ChatRequestDTO req) { /* 默认无采样参数 */ }
protected void beforeChat(ChatRequestDTO req) { }
protected void afterChat(ChatRequestDTO req, String answer) { } // 记忆模式重写:把回答写回记忆
public abstract ChatMode supportedMode();
}
关键点:
buildRequest()默认不带采样参数;PARAMS模式重写它以加temperature/topP/maxOutputTokens,CUSTOM模式重写它来应用全部自定义参数。- 流式/非流式都复用
OllamaStreamClient.stream(request, cancelled, handler):读取 NDJSON、逐 token 回调;cancelled为真时立即关闭底层连接(Ollama 真正停止),且不调用afterChat(跳过记忆写入,避免保存被打断的答案)。
6.4 工厂 ChatHandlerFactory
java
@Component
public class ChatHandlerFactory {
private final Map<ChatMode, AbstractChatHandler> handlers;
@Autowired
public ChatHandlerFactory(List<AbstractChatHandler> allHandlers) {
this.handlers = new EnumMap<>(ChatMode.class);
for (AbstractChatHandler h : allHandlers) handlers.put(h.supportedMode(), h);
}
public AbstractChatHandler getHandler(ChatMode mode) {
AbstractChatHandler h = handlers.get(mode);
if (h == null) throw new IllegalArgumentException("不支持的对话模式:" + mode);
return h;
}
}
🎛️ 七、七种对话模式(ChatMode 枚举)
前端模式下拉、模式说明面板、默认选中项都来自 ChatMode 枚举(每个枚举自带面向前端的元数据:label / description / features / requiresSession / supportsParams / customizable)。
| code | 标签 | 演示重点 | 需要 sessionId | 可调采样参数(3项) | 全参数自定义(L7) |
|---|---|---|---|---|---|
basic |
L1 · 基础单轮对话 | 最朴素:直接 UserMessage,无系统提示、无记忆 |
❌ | ❌ | ❌ |
annotation |
L2 · @AiService 注解封装 | 叠加固定轻量人设(简洁分点),等价于 @AiService+@SystemMessage 的效果 |
❌ | ❌ | ❌ |
template |
L3 · 模板参数对话 | 把 message 当模板变量拼进固定话术,演示 Prompt Template | ❌ | ❌ | ❌ |
system |
L4 · 系统人设对话 | SystemMessage 设定专家人设(资深 Java 架构师) |
❌ | ❌ | ❌ |
params |
L5 · 采样参数调节 | 单次请求覆盖 temperature / topP / maxTokens |
❌ | ✅ | ❌ |
memory |
L6 · 带记忆多轮对话 | sessionId 隔离 + 滑动窗口记忆 + 全局人设 |
✅ | ❌ | ❌ |
custom |
L7 · 全参数自定义 | 在 L6 基础上开放全部对话参数可调(见第十章) | ✅ | ❌ | ✅(16项) |
实现位置:每个模式对应一个
com.example.langchain4j.chat.handler.*ChatHandler,只需实现buildMessages()(与必要钩子)。例如:
java
@Component
public class BasicChatHandler extends AbstractChatHandler {
@Override public ChatMode supportedMode() { return ChatMode.BASIC; }
@Override
protected List<ChatMessage> buildMessages(ChatRequestDTO req) {
return List.of(UserMessage.from(req.getMessage())); // 最底层 API 等价物
}
}
java
// L5 采样参数:重写 buildRequest 钩子,覆盖采样参数
@Override
protected ChatRequest buildRequest(List<ChatMessage> messages, ChatRequestDTO req) {
AppProperties.Chat chat = appProps.getChat();
double temperature = NumberUtils.clampTemperature(
NumberUtils.resolve(req.getTemperature(), chat.getDefaultTemperature()));
// ... topP / maxTokens 同理(null 回退默认值,再做 [0,2] / [0,1] / >=1 裁剪)
return ChatRequest.builder().messages(messages)
.parameters(ChatRequestParameters.builder()
.modelName(modelProps.getModelName())
.temperature(temperature).topP(topP).maxOutputTokens(maxTokens).build())
.build();
}
🌊 八、流式输出与「真中断」机制(重点)
早期版本用 LangChain4j 的
StreamingChatModel.chat()是 fire-and-forget ,拿不到可取消句柄:前端 abort 只能断开「前端↔后端」,后端↔Ollama 仍会算到自然结束(浪费算力,且被打断的完整答案还会被写进记忆)。为此本项目自建
OllamaStreamClient,用 JDKHttpClient直连 Ollama/api/chat(stream:true),逐行解析 NDJSON,从而获得「可中断」能力。
8.1 流式(POST /api/chat/stream,SSE)------ 可「接口中断」
- 前端用
AbortController.signal发起fetch;emitter.onError(客户端断开)立即置cancelled=true。 OllamaStreamClient读循环每次迭代检查cancelled,为真则关闭InputStream断开 Ollama 连接,Ollama 真正停止生成 ,且不调用afterChat(跳过记忆写入)。- 每收到一个 token 通过
StreamCallback.onToken推给前端;结束推done事件(含文本 + token 数)。
java
// ChatController.chatStream 片段
SseEmitter emitter = new SseEmitter(120_000L);
AtomicBoolean cancelled = new AtomicBoolean(false);
emitter.onError(e -> cancelled.set(true)); // 前端 abort → 连接断开 → 这里感知
emitter.onCompletion(() -> cancelled.set(true));
chatService.stream(req, new SseStreamCallback(emitter), cancelled::get);
8.2 非流式(POST /api/chat,DeferredResult)------ 需「显式取消信号」
⚠️ 非流式是「生成完才返回一段 JSON」,服务端在生成期间从不向客户端写字节 ,Tomcat 无法在生成中途感知客户端断开(只能到最后写响应时才发现
Connection reset by peer,此时 Ollama 已跑完)。因此单纯的前端 abort 不足以让后端停止。
解决方式:带外 RPC 取消信号
- 前端发起非流式请求时附带
requestId;点击「停止」时额外调用POST /api/chat/cancel?requestId=...。 - 后端
ChatCancellationRegistry.cancel(requestId)把对应AtomicBoolean置 true;OllamaStreamClient检测到后立刻断开 Ollama 连接(真正停止),并跳过记忆写入。 - 请求正常结束 / 出错 / 断开后都会
registry.remove(requestId)清理,避免内存泄漏。
java
// 非流式入口:注册 requestId → cancelled,异步执行
String requestId = (req.getRequestId() != null && !req.getRequestId().isBlank())
? req.getRequestId() : UUID.randomUUID().toString();
AtomicBoolean cancelled = cancellationRegistry.register(requestId);
DeferredResult<ChatResponse> dr = new DeferredResult<>(300_000L);
dr.onCompletion(() -> cancellationRegistry.remove(requestId));
dr.onError(e -> cancellationRegistry.remove(requestId));
CompletableFuture.supplyAsync(() -> chatService.chat(req, cancelled::get))
.whenComplete((resp, ex) -> { /* 设置 dr 结果(友好错误) */ });
8.3 为什么两种中断方式不同?
| 维度 | 流式 /stream |
非流式 /chat |
|---|---|---|
| 响应形态 | 持续 flush 每个 token |
生成完才返回一整段 JSON |
| 能否靠「连接断开」感知中断 | ✅ 可以(断连即时可感知) | ❌ 不可以(中途不写字节) |
| 触发中断的机制 | 前端 abort → emitter.onError(接口中断,可行) |
前端另发 POST /cancel(带外 RPC 信号) |
| 真正停 Ollama 的动作 | 关闭 OllamaStreamClient 的 InputStream |
同左(由 cancelled 标志驱动) |
💡 一句话:流式靠连接断开就能接口中断;非流式因为单段 JSON 无法中途感知断连,只能显式发取消信号。 二者真正停 Ollama 的动作相同------断连接 + 跳过
afterChat落库。
🧠 九、记忆 / 人设 / 上下文裁剪 / 清空 机制
固定全局人设(小Lang)
MemoryChatHandler / CustomChatHandler 在 buildMessages() 里注入 system-prompt.txt 的内容(统一从 classpath 读取,全局只维护这一份),模型始终以「小Lang」身份作答;CUSTOM 模式若前端传了 system 参数则覆盖默认人设。
多用户隔离
ChatMemoryConfig 定义 ChatMemoryProvider:收到 sessionId → 建一个 MessageWindowChatMemory,并用 ConcurrentHashMap 缓存。前端通过 GET /api/chat/users 拿到预设用户(张三/李四...),以下拉单选方式选定 sessionId。不同 sessionId 的对话互不可见。
自动上下文裁剪
MessageWindowChatMemory.builder().maxMessages(20) 只保留最近 20 条消息,更早的自动丢弃 → 上下文有上限 → 避免 Token 超限。窗口大小由 app.chat.memory-max-messages 控制。
清空记忆
ChatMemoryConfig 用 ConcurrentHashMap 缓存每个 sessionId 对应的 ChatMemory 实例;ChatService.clearMemory() 注入同一个 ChatMemoryProvider,调用 memoryProvider.get(sessionId).clear() 删除该会话全部历史。前端「🧹 清空记忆」按钮即调用 POST /api/chat/clear。
⚠️ LangChain4j 1.x 已移除旧版
ChatMemoryAccess.evictChatMemory(),请勿再使用。
🎚️ 十、参数体系:采样参数 + L7 全参数自定义
10.1 采样参数(params 模式)
PARAMS 模式在 buildRequest() 里覆盖 temperature / topP / maxOutputTokens,未传(null)时回退到 AppProperties 默认值;NumberUtils 负责 null 回退 + 范围裁剪([0,2] / [0,1] / >=1)。
10.2 L7 全参数自定义(custom 模式)
CustomChatHandler 在 buildRequest() 里用 OllamaChatRequestParameters.Builder 应用前端传来的 customParams(key→值),数值安全解析、未配置项沿用默认值。
CustomParamCatalog 维护参数目录 与模板预设,并通过两个 GET 接口下发给前端:
GET /api/chat/custom-params→List<ParamSpec>:16 个参数(label/类型/范围/默认值/作用说明),前端据此渲染「拖动横条 / 输入框」弹窗。GET /api/chat/presets→List<ParamPreset>:三套通用模板(精准 / 通用 / 创意),取值集中在后端维护,前端不再写死。
⚠️ Ollama 不支持 OpenAI 风格参数 :
presence_penalty/frequency_penalty进入OllamaChatRequestParameters后会在chat()阶段抛UnsupportedFeatureException(导致 500)。因此CustomChatHandler在buildRequest()中刻意跳过这两个参数(仅打日志说明「已跳过」),不进入请求。
10.3 三套通用模板(精准 / 通用 / 创意)的区别与效果
GET /api/chat/presets 返回三套 ParamPreset,取值集中维护在 CustomParamCatalog.presets()。前端点击模板按钮即把这套取值整体套用到全部 16 个参数 ,省去逐项调参。三套的本质差异是「系统提示词 + 采样参数 」组合取向不同------用同一句提问 讲讲春天,你会看到截然不同的回答风格。
参数取值对照表(取自代码,非臆造)
| 参数(wire key) | 🎯 精准 precision |
⚖️ 通用 general |
🎨 创意 creative |
|---|---|---|---|
系统提示词 system |
严谨精准、只基于事实、避免发散 | (空 → 沿用默认「小Lang」人设) | 富有想象力、鼓励发散、比喻/故事化 |
温度 temperature |
0.1 | 0.7 | 1.2 |
核采样 top_p |
0.1 | 0.9 | 1.0 |
取词数 top_k |
1 | 40 | 80 |
最小概率 min_p |
0.0 | 0.0 | 0.05 |
最大生成长度 max_tokens |
256 | 512 | 1024 |
重复惩罚 repeat_penalty |
1.0 | 1.1 | 1.0 |
话题/词频惩罚 presence/frequency |
0.0 / 0.0 | 0.0 / 0.0 | 0.5 / 0.5 |
随机种子 seed |
42(固定,可复现) | 0(随机) | 0(随机) |
上下文窗口 num_ctx |
2048 | 4096 | 8192 |
各自效果与适用场景
- 🎯 精准模式 :
temperature=0.1+top_p=0.1+top_k=1+ 固定seed=42→ 采样空间被压到极窄,输出高度确定、可复现、贴近训练分布、几乎不发散 ;max_tokens=256限定短回答;专属系统提示词强制「只基于事实、避免主观推测」。适合事实问答、代码生成、确定性任务、需要结果可复现的场景。 - ⚖️ 通用模式 :默认人设 +
temperature=0.7/top_p=0.9/top_k=40→ 标准平衡档,日常对话的「中庸」表现,既不太死板也不太飘。num_ctx=4096提供适中上下文。绝大多数场景的默认选择。 - 🎨 创意模式 :
temperature=1.2+top_p=1.0+top_k=80+min_p=0.05+max_tokens=1024+num_ctx=8192→ 采样空间最宽、输出新颖、独特、不拘一格 ,允许比喻与故事化,且能写更长、num_ctx也最大以容纳长上下文。适合头脑风暴、文案/诗歌、发散性写作。
⚠️ 关于「话题/词频惩罚」在 Ollama 下的真相 :三套模板都给
presence_penalty/frequency_penalty赋了值(创意模式还设到 0.5),但这两个是 OpenAI 风格参数,Ollama 不支持 ,CustomChatHandler在buildRequest()中会刻意跳过 (仅打日志),因此它们在 Ollama 上实际不生效 。创意模式的「发散感」主要靠temperature / top_k / top_p / min_p这些真正生效的参数体现,而不是靠这两个惩罚值。💡 一句话区分:精准 = 低温度 + 固定种子 + 短输出 + 事实人设;通用 = 默认平衡;创意 = 高温度 + 宽采样 + 长输出 + 发散人设。 真正在 Ollama 上起作用的差异集中在这几组采样参数与系统提示词上。
📦 十一、DTO 与校验 / 全局异常
统一请求 / 响应
ChatRequestDTO(dto.chat):所有模式共用,含mode(枚举) /message/sessionId/temperature,topP,maxTokens/stream/requestId(非流式中断关联) /customParams(Map)。@ValidChatRequest做类级交叉校验:记忆模式必须带sessionId。ChatResponse(dto.chat):content / sessionId / tokenCount / error,提供of()/error()工厂。- 其余:
ChatModeInfo、ParamSpec、ParamPreset、ClearRequest、ClearResponse、UserInfoDTO(dto 下各业务域子包)、ErrorResponse(dto.common)。
校验 & 异常
ValidChatRequest+ChatRequestValidator:类级约束(记忆模式需sessionId),与单字段@NotNull/@NotBlank互补。GlobalExceptionHandler(@RestControllerAdvice):所有异常收敛为ErrorResponse,message一定是友好中文说明,绝不回传原始堆栈 ;AsyncRequestNotUsableException/ClientAbortException(中断副产物)降级为 DEBUG,避免噪声。流式错误则直接在StreamCallback.onError里以友好事件推给前端。
🖥️ 十二、前端聊天页面(前后端不分离)
页面位于 src/main/resources/static/chat.html,启动后即自动映射为 http://localhost:8088/chat.html。
功能:
- 左侧「对话模式」面板按
GET /modes渲染 7 种模式(默认选中 L7 全参数自定义); - 右侧配置面板:记忆模式出现
sessionId下拉(来自GET /users)、params模式出现temperature/topP/maxTokens输入、custom模式出现「自定义参数」弹窗(来自GET /custom-params与GET /presets); - 聊天区以气泡展示多轮对话,回车发送、
Shift+Enter换行; - 发送按钮为圆形图标:回答未结束时点击即中断 (流式走 SSE 断连;非流式额外发
/cancel); - 思考与回答期间,在信息下方显示转圈加载效果,回答完成后移除;
- 错误统一展示友好文案(如「接口报错」),不暴露原始异常。
✅ 因为是同 origin 的静态资源 + REST 接口,无需任何跨域(CORS) 配置,启动即可用。
▶️ 十三、运行与调试
bash
# 终端 1:启动 Ollama
ollama serve
ollama pull qwen3:4b-instruct # 若已拉取可跳过
# 终端 2:启动项目(二选一)
mvn spring-boot:run
# 或在 IDEA 直接运行 Langchain4jDemoApplication
# 浏览器打开
# http://localhost:8088/chat.html
🚀 启动成功提示 :
StartupNotifier(@Component+ApplicationRunner+@Order(MAX_VALUE))在 Web 端口就绪后打印友好横幅,含聊天页地址、模型名、接口前缀、记忆窗口与 Ollama 前置条件;端口取自WebServerApplicationContext真实运行端口。横幅用 ANSI 转义(直接走System.out),logback-spring.xml负责控制台日志着色与 UTF-8。
不用前端的 curl 调试示例(单一对话入口)
bash
# 非流式:基础单轮
curl -X POST http://localhost:8088/api/chat \
-H 'Content-Type: application/json' \
-d '{"mode":"basic","message":"你好"}'
# 非流式:系统人设(L4)
curl -X POST http://localhost:8088/api/chat \
-H 'Content-Type: application/json' \
-d '{"mode":"system","message":"用 Java 写个单例模式"}'
# 非流式:采样参数(L5,temperature 拉到 1.2 看更发散的输出)
curl -X POST http://localhost:8088/api/chat \
-H 'Content-Type: application/json' \
-d '{"mode":"params","message":"给我一句诗","temperature":1.2,"topP":0.95,"maxTokens":64}'
# 非流式:带记忆(L6),先问名字再用同一 sessionId 追问
curl -X POST http://localhost:8088/api/chat \
-H 'Content-Type: application/json' \
-d '{"mode":"memory","sessionId":"user-1","message":"我叫小明"}'
curl -X POST http://localhost:8088/api/chat \
-H 'Content-Type: application/json' \
-d '{"mode":"memory","sessionId":"user-1","message":"我叫什么?"}'
# 非流式:L7 全参数自定义(自定义 system 与 temperature)
curl -X POST http://localhost:8088/api/chat \
-H 'Content-Type: application/json' \
-d '{"mode":"custom","sessionId":"user-1","message":"你好",
"customParams":{"system":"你是个严谨的助手","temperature":0.1}}'
# 清空记忆
curl -X POST http://localhost:8088/api/chat/clear \
-H 'Content-Type: application/json' -d '{"sessionId":"user-1"}'
SSE 流式(逐 token)
bash
curl -N -X POST http://localhost:8088/api/chat/stream \
-H 'Content-Type: application/json' \
-d '{"mode":"basic","message":"讲个冷笑话"}'
# 事件形如:event:token data:xxx ...... event:done data:{"text":"...","tokens":42}
接口速查表
| 路径 | 方法 | 关键字段 | 说明 |
|---|---|---|---|
/api/chat |
POST | mode,message,sessionId?,temperature?,topP?,maxTokens?,customParams?,requestId? |
非流式单一入口(可中断) |
/api/chat/stream |
POST (text/event-stream) |
同上(不含 requestId) |
SSE 流式单一入口(可中断) |
/api/chat/cancel |
POST | requestId |
非流式中断信号 |
/api/chat/modes |
GET | --- | 模式元数据 |
/api/chat/custom-params |
GET | --- | L7 全参数目录 |
/api/chat/presets |
GET | --- | 通用参数模板预设 |
/api/chat/users |
GET | --- | 预设用户(sessionId 下拉) |
/api/chat/clear |
POST | sessionId |
清空记忆 |
🚨 十四、常见问题排查
- ❌
Connection refused: localhost/11434
→ Ollama 没启动,执行ollama serve。 - ❌
model not found
→ 执行ollama pull qwen3:4b-instruct,并确认application.yml的model-name与本地一致。 - ⚠️ qwen3 回复里带
<think:6124c78e>...</think:6124c78e>思考块
→ qwen3 系列默认带思考过程;纯聊天无影响,若做结构化解析可关闭思考(模型侧参数)或后处理剔除。 - ⚠️ 长对话偶发 Token 超限
→ 调小app.chat.memory-max-messages,或确认num-ctx足够。 - ⚠️ 非流式点「停止」没立刻停
→ 非流式靠POST /api/chat/cancel?requestId=带外信号中断;前端已自动发送,后端收到后才真正断开 Ollama(与流式靠断连不同,见第八章)。 - ⚠️ L7 自定义里
presence_penalty/frequency_penalty不生效
→ 这两个是 OpenAI 风格参数,Ollama 不支持;后端在CustomChatHandler中刻意跳过(仅打日志),不会出现 500。 - ❌ 编译报找不到
@AiService/@V
→ 确认langchain4j-spring-boot-starter已引入;@AiService在dev.langchain4j.service.spring,@V在dev.langchain4j.service。
📎 附录 A · LangChain4j 注解(声明式 API)与命令式对比
前面章节全部采用 命令式(Imperative) 写法:自己组装
ChatMessage、自己调ChatModel.chat(...)/OllamaStreamClient.stream(...)。LangChain4j 还提供一条 声明式(Declarative / AiServices) 路线:用「接口 + 注解」描述意图,由框架在启动时生成代理实现并注册为 Spring Bean,省去大量样板代码。
本附录讲清这套注解「是什么、干什么、怎么写」,并与本项目命令式写法做对比,方便你按需取舍。
A.1 两路 API 总览
| 路线 | 核心思路 | 本项目的落点 |
|---|---|---|
| 声明式(AiServices) | 定义接口 + 注解 → 框架生成实现 Bean | 早期版本用过;现未采用 |
| 命令式(直接 API) | 手动建消息 → ChatModel.chat(...) / OllamaStreamClient |
当前全部采用(见第五、六章) |
💡 本项目刻意选命令式的三个硬理由:
- 可中断流式 (第八章):
@AiService的流式返回TokenStream是 fire-and-forget,拿不到可取消句柄,前端 abort 只能断前端↔后端,后端↔Ollama 仍算完;命令式用OllamaStreamClient能真正断开 Ollama 并跳过记忆写入。- 细粒度控制 :采样参数、
customParams、请求前后钩子(beforeChat/afterChat)需要完全自己掌控。- 单一入口 + mode 路由 :七种模式共用一个
ChatRequestDTO,由ChatHandlerFactory按mode分发到不同策略处理器;声明式通常是「一个接口服务一个用途」,不利于这种统一路由。
A.2 声明式注解一览(含义与作用)
紫色 为注解名,蓝色 为所在包。
| 注解 | 所在包 | 含义与作用 | 常用属性 |
|---|---|---|---|
@AiService |
dev.langchain4j.service.spring |
标记一个接口 为 AI 服务。Spring Boot starter 启动时扫描 classpath,对接口生成代理实现,自动装配上下文中的 ChatModel/StreamingChatModel/ChatMemoryProvider/ToolProvider 等,并注册为 Spring Bean |
--- |
@SystemMessage |
dev.langchain4j.service |
设定系统提示(角色 / 规则 / 语气)。可加在接口或方法上 | value() 直接写提示词;fromResource() 读 classpath 资源文件(如 system-prompt.txt);delimiter() 多段拼接分隔符 |
@UserMessage |
dev.langchain4j.service |
定义用户消息模板 。单参数可用 {``{it}},多参数配合 @V 用 {``{变量名}} |
value() / fromResource() 同 @SystemMessage |
@V (Variable) |
dev.langchain4j.service |
把方法参数 绑定为 prompt 模板变量,供 {``{变量名}} 引用 |
value() = 变量名(与 @UserMessage/@SystemMessage 中的 {``{...}} 对应) |
@MemoryId |
dev.langchain4j.service |
标记参数为会话 / 记忆隔离 key ,框架据此找到该用户/会话对应的 ChatMemory |
--- |
@Moderate |
dev.langchain4j.service |
开启内容审核:每次调用除 LLM 外并行调用审核模型,过滤违规输入/输出(需提供 moderation model) | --- |
@UserName |
dev.langchain4j.service |
把参数注入到 UserMessage 的 name 字段,标识发言人 |
--- |
@Tool |
dev.langchain4j.service.tool |
标记 @Component 类中的方法为 AI 可调用工具(函数调用),框架自动发现并接线 |
value() = 给模型看的工具描述(务必写清) |
@P (Parameter) |
dev.langchain4j.service.tool |
描述工具参数(给 AI 看),让模型理解参数含义 | value() = 参数说明 |
@Description / @Name |
dev.langchain4j.service.tool 等 |
给工具 / 参数 / 枚举值加自然语言说明,提升模型选择与使用准确率 | --- |
📌 记忆接线提醒:声明式下要让
@MemoryId生效,上下文里需有ChatMemoryProviderBean(本项目的ChatMemoryConfig就提供了它);否则声明式服务默认无记忆。
A.3 声明式完整案例
下面用 Spring Boot 风格演示「系统人设 + 用户模板 + 会话隔离 + 工具调用 + 流式」的完整声明式写法。
java
// 1) AI 服务接口:纯注解描述,无实现类
@AiService
public interface Assistant {
// 系统人设从 classpath 资源读取;sessionId 做记忆隔离;用户消息来自方法参数
@SystemMessage(fromResource = "system-prompt.txt")
String chat(@MemoryId String sessionId, @UserMessage String userMessage);
// 用户消息模板:@V("topic") 把参数绑定到 {{topic}}
@SystemMessage("你是一个严谨的百科助手")
@UserMessage("用一句话解释:{{topic}}")
String explain(@MemoryId String sessionId, @V("topic") String topic);
// 流式:方法返回 TokenStream,由框架做 SSE 推进
TokenStream streamChat(@MemoryId String sessionId, @UserMessage String userMessage);
}
// 2) 工具:被框架自动发现并接线给 Assistant
@Component
public class WeatherTools {
@Tool("查询指定城市的当前天气")
public String getWeather(@P("城市名称,例如 北京") String city) {
return "[" + city + "] 晴 26℃"; // 这里接真实天气 API
}
}
// 3) 控制器:像用普通 @Service 一样注入调用
@RestController
@RequestMapping("/assistant")
public class AssistantController {
@Autowired Assistant assistant;
@PostMapping("/chat")
public String chat(@RequestParam String sessionId, @RequestParam String msg) {
return assistant.chat(sessionId, msg); // 框架内部:拼 System+Memory+User → 调 ChatModel
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public void stream(@RequestParam String sessionId, @RequestParam String msg) {
assistant.streamChat(sessionId, msg)
.onNext(token -> { /* 推送 token */ })
.onComplete(response -> { /* 收尾 */ })
.onError(Throwable::printStackTrace)
.start();
}
}
要点:
- 你只写接口和注解 ,没有
new OllamaChatModel()、没有手工拼List<ChatMessage>------ 这些全部由@AiService代理完成。 - 流式靠返回
TokenStream并.start(),按onNext/onComplete/onError订阅。
A.4 命令式(本项目)等价写法
同一套「系统人设 + 记忆 + 用户消息 + 调用」在命令式下,正是 AbstractChatHandler.buildMessages() 做的事:
java
// 等价于「@AiService + @SystemMessage(fromResource) + @MemoryId + @UserMessage」的命令式写法
@Override
protected List<ChatMessage> buildMessages(ChatRequestDTO req) {
List<ChatMessage> messages = new ArrayList<>();
// ← @SystemMessage(fromResource="system-prompt.txt")
messages.add(SystemMessage.from(loadSystemPrompt()));
// ← @MemoryId:从记忆缓存取该 session 的历史(多轮隔离)
ChatMemory memory = memoryProvider.get(req.getSessionId());
if (memory != null) messages.addAll(memory.messages());
// ← @UserMessage:用户本轮输入
messages.add(UserMessage.from(req.getMessage()));
return messages;
}
// 调模型(非流式骨架,见 6.3 模板方法)
ChatResponse resp = chatModel.chat(ChatRequest.builder().messages(messages).build());
// 流式则交给 OllamaStreamClient.stream(request, cancelled, callback)(见第八章)
对照表(注解 → 命令式等价物):
| 声明式注解 | 命令式等价代码 |
|---|---|
@SystemMessage(fromResource=...) |
SystemMessage.from(读取 classpath 资源) |
@UserMessage("...{``{x}}...") + @V("x") |
UserMessage.from(手动 String.format / 模板拼接) |
@MemoryId |
memoryProvider.get(sessionId).messages() 注入历史 |
@Moderate |
自己接 moderation model 并前后调用 |
@Tool / @P |
自己实现 ToolSpecification + ToolExecutor(或 ToolProvider) |
TokenStream 流式 |
OllamaStreamClient.stream(...) + StreamCallback |
A.5 声明式 vs 命令式 对比
| 维度 | 声明式(@AiService) | 命令式(本项目) |
|---|---|---|
| 样板代码 | 极少(只写接口+注解) | 较多(自己拼消息/请求/回调) |
| 上手速度 | 快,适合「一个接口一个用途」 | 稍慢,但结构清晰、易扩展 |
| 系统/用户提示 | 注解直接写,模板变量优雅 | 自己读资源 + 字符串拼接 |
| 记忆/多轮 | @MemoryId 自动接线(需 Provider Bean) |
自己取 ChatMemory 注入历史 |
| 采样参数调节 | 需额外配置/默认固定 | buildRequest() 钩子自由覆盖(见第七章) |
| 流式 | TokenStream,但不可取消 |
OllamaStreamClient,可真中断(见第八章) |
| 多模式/策略复用 | 一个接口服务一个用途,难统一路由 | 一个入口 + mode + 工厂分发(见第五、六章) |
| 工具 / RAG | 注解自动接线,开箱即用 | 需自己接 ToolProvider / RetrievalAugmentor |
| 调试/可观测 | 代理层黑盒,断点难打 | 每一步都在自己代码里,断点自由 |
| 适用场景 | 快速原型、单用途 AI 接口、RAG/Tools 开箱集成 | 统一对话平台、需中断/细控/多模式共存 |
A.6 小结
@AiService是 LangChain4j 的高层声明式入口 :接口 +@SystemMessage/@UserMessage/@V/@MemoryId/@Tool等注解,框架自动生成实现并接线ChatModel/记忆/工具。- 它省样板、上手快、RAG/Tools 开箱即用 ,但流式不可取消、细粒度控制弱、不利于统一路由。
- 本项目因为要「单一入口 + 七模式 + 可中断流式 + 全参数自定义 」,选择了命令式,把每条注解对应的能力都亲手实现了一遍(见 A.4 对照表)。
- 两条路线底层都是
ChatModel/StreamingChatModel,可混用:例如工具调用用@Tool省事、核心对话用命令式拿中断能力。
✅ 到此,项目搭建 + 单一对话入口 + 七种模式(策略/模板/工厂)+ 可中断流式直连 Ollama + 记忆/人设/裁剪/清空 + L7 全参数自定义 + 前端页面均已跑通;本附录补充了 LangChain4j 声明式注解体系及其与本项目命令式写法的完整对比。后续再进阶 RAG / Tools 等。