大家好,我是程序员小策。
先做个自测------你在项目里遇到"多个步骤按顺序执行,每步都可能出错"的场景时,怎么写的?
A. 一把梭------大 try-catch 包住所有逻辑,出错统一处理
B. 逐步调用------step1() → step2() → step3(),每步自己 try-catch
C. 回调嵌套------step1(result1 -> step2(result2 -> step3(...)))
D. 函数式编排------把每一步抽象成函数式接口,用建造者模式组装
选 A 的同学,恭喜你,出了 bug 连错在哪一步都不知道。选 B 的,代码重复到你怀疑人生。选 C 的,恭喜进入回调地狱,缩进能排到屏幕右边。
选 D 的,你已经在用函数式接口 + 建造者模式了------只是你可能没意识到这个组合有多强大。
今天我们就从一个真实的 AI 对话流式处理模块出发,把 Lambda 和建造者模式怎么配合这件事彻底讲透。
问题定义:流式对话的编排难题
假设你在做一个 AI 对话系统,一次完整的对话流程是这样的:
- 加载历史消息
- 保存用户消息
- 调用 AI 模型进行流式对话
- 保存 AI 回复
- 更新会话信息
- 成功回调 / 错误回调
朴素写法?直接在 Service 里按顺序调用:
java
public void processChat(String sessionId, String userMessage) {
try {
List<Message> history = historyService.load(sessionId);
messageService.saveUserMessage(sessionId, userMessage);
String aiReply = aiService.chat(history, userMessage);
messageService.saveAssistantMessage(sessionId, aiReply);
conversationService.update(sessionId);
onComplete();
} catch (Exception e) {
onError(e);
}
}
看起来没问题是吧?
但现实是:你的项目里有对话模块 、有知识库问答模块 、有Agent 工具调用模块------每个模块都要走类似的流程,只是每一步的具体实现不同。
复制粘贴?三个模块三份几乎一样的代码,改一个漏一个。抽象基类?步骤顺序不同时继承体系就崩了。
核心矛盾:流程骨架是固定的,但每一步的具体行为是变化的------怎么把"变与不变"分离?
核心概念:函数式接口 + 建造者模式
函数式接口:只有一个抽象方法的接口,可以用 Lambda 表达式实现,本质上是把"行为"当作参数传递。
建造者模式:将复杂对象的构建过程与表示分离,使得同样的构建过程可以创建不同的表示。
把这两个概念放在一起,你得到的是一种行为编排能力------用建造者模式组装"每一步做什么",用函数式接口定义"每一步的行为形状"。
类比一下:你在公司负责项目交接。交接文档的模板是固定的(公司统一格式),但每个项目的具体内容不同(你填什么)。模板就是建造者模式,你填的内容就是 Lambda 表达式。
更具体地说:
| 交接文档模板 | 代码中的对应 |
|---|---|
| "项目背景"这一栏 | historySupplier 字段 |
| 你填的项目背景内容 | () -> historyService.load(sessionId) |
| "交接人签字"这一栏 | successHandler 字段 |
| 你签的字 | () -> sink.complete() |
模板规定了"有哪些栏",Lambda 决定了"每栏填什么"。
实现:ConversationStreamingSupport 的完整拆解
来看真实代码。这个类做了两件事:定义请求对象的结构,定义执行流程的骨架。
第一部分:请求对象------用建造者模式组装行为
java
@Getter
@Builder
public static class ConversationStreamRequest<H> {
private final String sessionId;
private final String defaultErrorContent;
private final AIContentAccumulator accumulator;
private final ConversationHistorySupplier<H> historySupplier;
private final CheckedRunnable userMessageSaver;
private final ConversationStreamExecutor<H> streamExecutor;
private final Function<AssistantMessagePayload, Integer> assistantMessageSaver;
private final Consumer<Integer> conversationUpdater;
private final Runnable successHandler;
private final Consumer<Exception> errorHandler;
}
注意看,这个类里有三种东西:
- 数据字段 :
sessionId、defaultErrorContent、accumulator------纯数据,直接传值 - 自定义函数式接口 :
ConversationHistorySupplier、CheckedRunnable、ConversationStreamExecutor------为什么不用 Java 标准接口?因为它们可以抛检查异常 - Java 标准函数式接口 :
Function、Consumer、Runnable------不需要抛异常的场景直接用标准接口
这里有个关键设计决策:为什么自定义了 CheckedRunnable 而不直接用 Runnable?
因为 Runnable.run() 不允许抛检查异常,而保存消息到数据库这个操作可能抛 SQLException。如果用标准 Runnable,你被迫在 Lambda 里 try-catch,异常就吞掉了。自定义接口让异常签名显式化,由框架统一处理。
第二部分:自定义函数式接口
java
@FunctionalInterface
public interface ConversationHistorySupplier<H> {
List<H> get() throws Exception;
}
@FunctionalInterface
public interface ConversationStreamExecutor<H> {
void execute(List<H> historyMessages, AIContentAccumulator accumulator) throws Exception;
}
@FunctionalInterface
public interface CheckedRunnable {
void run() throws Exception;
}
三行代码,三种"行为形状":
ConversationHistorySupplier:无参,返回历史消息列表,可抛异常------对应 Java 标准的Supplier,但加了throws ExceptionConversationStreamExecutor:两个参数,无返回值,可抛异常------对应BiConsumer,但加了throws ExceptionCheckedRunnable:无参无返回值,可抛异常------对应Runnable,但加了throws Exception
第三部分:调用方------用 Lambda 填充行为
java
conversationStreamingSupport.execute(
ConversationStreamingSupport.ConversationStreamRequest
.<AiMessageHistoryRespDTO>builder()
.sessionId(sessionId)
.defaultErrorContent(DEFAULT_ERROR_CONTENT)
.accumulator(accumulator)
.historySupplier(() -> conversationMessageHistoryService.listAiHistory(sessionId))
.userMessageSaver(() -> conversationMessagePersistenceService.saveAiUserMessage(sessionId, userMessage))
.streamExecutor((historyMessages, contentAccumulator) -> {
AiPropertiesDO aiProperties = resolveAiProperties(aiId);
AiChatHandler handler = aiChatHandlerFactory.getHandler(aiProperties.getAiType());
if (handler == null) {
sendUnsupportedSink(sink, contentAccumulator);
return;
}
handler.streamToSink(aiProperties, userMessage, historyMessages, sink, contentAccumulator);
})
.assistantMessageSaver(payload -> conversationMessagePersistenceService.saveAiAssistantMessage(
sessionId,
payload.content(),
payload.reasoningContent(),
payload.responseTime(),
payload.errorMessage()))
.conversationUpdater(messageSeq -> aiConversationService.updateConversation(sessionId, messageSeq, null))
.successHandler(() -> {
if (!sink.isCancelled()) {
sink.complete();
}
})
.errorHandler(ex -> {
if (!sink.isCancelled()) {
sink.next(DEFAULT_ERROR_CONTENT);
sink.error(ex);
}
})
.build());
这段代码的阅读方式:把每个 .xxx() 调用看作"填写交接文档的一栏"。
.historySupplier(() -> ...)------ 填"怎么加载历史".userMessageSaver(() -> ...)------ 填"怎么保存用户消息".streamExecutor((h, a) -> ...)------ 填"怎么执行对话".assistantMessageSaver(payload -> ...)------ 填"怎么保存AI回复".conversationUpdater(seq -> ...)------ 填"怎么更新会话".successHandler(() -> ...)------ 填"成功后做什么".errorHandler(ex -> ...)------ 填"出错后做什么"
每个 Lambda 的"形状"由对应的函数式接口决定------参数个数、返回类型、能否抛异常,全在接口里定义好了。
第四部分:执行骨架------模板方法
java
public <H> void execute(ConversationStreamRequest<H> request) {
long startTime = System.currentTimeMillis();
try {
List<H> historyMessages = request.historySupplier.get();
request.userMessageSaver.run();
request.streamExecutor.execute(historyMessages, request.accumulator);
int responseTime = (int) (System.currentTimeMillis() - startTime);
int assistantMessageSeq = request.assistantMessageSaver.apply(
new AssistantMessagePayload(
request.accumulator.getFullContent(),
request.accumulator.getFullReasoningContent(),
responseTime,
null
));
if (request.conversationUpdater != null) {
request.conversationUpdater.accept(assistantMessageSeq);
}
if (request.successHandler != null) {
request.successHandler.run();
}
} catch (Exception ex) {
log.error("Conversation streaming failed, sessionId={}", request.sessionId, ex);
int responseTime = (int) (System.currentTimeMillis() - startTime);
try {
request.assistantMessageSaver.apply(new AssistantMessagePayload(
request.defaultErrorContent,
null,
responseTime,
ex.getMessage()
));
} catch (Exception persistenceEx) {
log.error("Failed to persist conversation error message, sessionId={}",
request.sessionId, persistenceEx);
}
if (request.errorHandler != null) {
request.errorHandler.accept(ex);
}
}
}
这段代码的执行流程用 Mermaid 时序图表示:
Lambda表达式 ConversationStreamingSupport 调用方(AiMessageServiceImpl) Lambda表达式 ConversationStreamingSupport 调用方(AiMessageServiceImpl) execute(request) historySupplier.get() List<H> 历史消息 userMessageSaver.run() streamExecutor.execute(history, accumulator) 流式对话完成 计算 responseTime 创建 AssistantMessagePayload assistantMessageSaver.apply(payload) Integer 消息序号 conversationUpdater.accept(seq) successHandler.run()
文字版流程说明:execute() 方法按固定顺序调用各 Lambda------先加载历史,再保存用户消息,然后执行对话,接着保存AI回复并获取消息序号,用序号更新会话,最后触发成功回调。任何一步抛异常,进入 catch 块:先尝试保存错误消息,再触发错误回调。
注意一个细节:assistantMessageSaver 的返回值是怎么回来的?
java
int assistantMessageSeq = request.assistantMessageSaver.apply(payload);
Lambda 作为参数传入的是"函数对象",不是"执行结果"。框架在适当时机调用 .apply(payload),Lambda 执行后返回值回到框架手中。这就是控制反转------调用方定义"做什么",框架决定"什么时候做"。
边界与陷阱
陷阱一:Lambda 里捕获的变量可能过期
java
String userMessage = requestParam.getInputMessage();
// ... 中间经过异步操作 ...
.userMessageSaver(() -> saveMessage(sessionId, userMessage))
Lambda 捕获的是变量的值 ,不是变量的引用 。如果 userMessage 在 Lambda 执行前被修改,Lambda 里用的还是旧值。对于局部变量这不是问题(局部变量 effectively final),但如果你捕获的是对象的字段,就要小心了。
后果 :保存了错误的消息内容。解法:确保 Lambda 捕获的变量是不可变的,或者在 Lambda 内部重新获取最新值。
陷阱二:忽略 null 检查导致 NPE
看 execute() 方法里的这段代码:
java
if (request.conversationUpdater != null) {
request.conversationUpdater.accept(assistantMessageSeq);
}
if (request.successHandler != null) {
request.successHandler.run();
}
conversationUpdater 和 successHandler 是可空的------调用方不一定要设置它们。但 historySupplier、userMessageSaver、streamExecutor、assistantMessageSaver 是必填的,没有 null 检查。
后果 :如果必填字段为 null,直接 NPE。解法 :在 build() 时做校验,或者用 @NonNull 注解让 Lombok 在构造时抛异常。
陷阱三:异常处理中的异常
java
catch (Exception ex) {
try {
request.assistantMessageSaver.apply(new AssistantMessagePayload(
request.defaultErrorContent, null, responseTime, ex.getMessage()));
} catch (Exception persistenceEx) {
log.error("Failed to persist conversation error message", persistenceEx);
}
if (request.errorHandler != null) {
request.errorHandler.accept(ex);
}
}
catch 块里又调了 assistantMessageSaver,这个调用本身也可能抛异常。所以又套了一层 try-catch。如果连保存错误消息都失败了,至少把异常日志打出来------这是最后的兜底。
教训:异常处理代码本身也可能抛异常,永远要有兜底策略。
高级考量:多模块复用与扩展
这个设计的真正威力在于复用 。ConversationStreamingSupport 是一个通用的流式对话编排器,它不关心你是 AI 对话、知识库问答还是 Agent 工具调用------只要你的流程符合"加载历史→保存用户消息→执行对话→保存回复→更新会话→回调"这个骨架,就可以复用。
假设你要加一个知识库问答模块,只需要:
java
conversationStreamingSupport.execute(
ConversationStreamRequest.<KnowledgeHistoryRespDTO>builder()
.sessionId(sessionId)
.historySupplier(() -> knowledgeService.loadHistory(sessionId))
.userMessageSaver(() -> knowledgeService.saveUserMessage(sessionId, userMessage))
.streamExecutor((history, acc) -> {
List<Document> docs = ragService.retrieve(userMessage);
knowledgeChatHandler.streamToSink(docs, history, sink, acc);
})
.assistantMessageSaver(payload -> knowledgeService.saveAnswer(sessionId, payload))
.conversationUpdater(seq -> knowledgeService.updateSession(sessionId, seq))
.successHandler(() -> sink.complete())
.errorHandler(ex -> { sink.next("知识库服务异常"); sink.error(ex); })
.build());
零修改 ConversationStreamingSupport 的代码,就接入了全新的业务模块。这就是开闭原则------对扩展开放,对修改关闭。
但也要注意边界:如果某个模块的流程骨架跟这个不一致(比如需要跳过"保存用户消息"这一步,或者需要加一个"检索前预处理"步骤),强行复用这个骨架反而会增加复杂度。模式不是万能药,流程骨架真正匹配时才用。
对比表格
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 大 try-catch 一把梭 | 所有逻辑写在一个方法里 | 简单直接 | 无法复用,出错定位难 | 一次性脚本、POC |
| 模板方法模式(继承) | 抽象基类定义流程,子类实现步骤 | 流程固定,扩展点明确 | 继承耦合,步骤顺序难调整 | 流程固定且步骤少的场景 |
| 策略模式 | 每个步骤一个策略接口 | 灵活替换单个步骤 | 多步骤编排复杂,缺乏整体流程控制 | 单一步骤需要多实现的场景 |
| 函数式接口 + 建造者 | Lambda 定义行为,建造者组装流程 | 高度灵活,零继承耦合,流程可复用 | Lambda 过多时可读性下降 | 多模块共享流程骨架的场景 |
一句话选型:流程骨架固定、步骤实现多变------选函数式接口 + 建造者。
面试追问
追问 1:为什么不用标准的 Runnable 而自定义 CheckedRunnable?
→ 回答方向:标准 Runnable.run() 不声明 throws Exception,Lambda 内部调数据库方法时编译报错。自定义接口让异常显式化,由框架统一 catch 处理,避免 Lambda 内部吞异常。
追问 2:Function<AssistantMessagePayload, Integer> 这个泛型参数是两个参数吗?
→ 回答方向:不是。<T, R> 中 T 是输入类型,R 是输出类型。Function 只接收一个输入参数,返回一个结果。需要两个输入参数时用 BiFunction<T, U, R>。
追问 3:Lambda 里捕获的 sessionId 是怎么传进去的?它是函数式接口的参数吗?
→ 回答方向:不是。sessionId 是通过闭包捕获(closure capture)访问的外部变量,不是函数式接口的参数。接口的参数由框架在调用时传入(如 payload、messageSeq、ex),闭包捕获的变量是 Lambda 定义时就绑定的。
追问 4:如果 streamExecutor 执行时间很长,这个设计会不会阻塞线程?
→ 回答方向:会。execute() 是同步方法,streamExecutor.execute() 如果是阻塞调用,当前线程就会被占住。在这个项目中,调用方通过 threadPoolTaskExecutor.submit() 把整个 processChat 提交到线程池执行,所以不会阻塞 WebFlux 的 EventLoop 线程。但线程池大小需要合理配置,否则高并发下线程池满会导致任务排队。
追问 5:建造者模式在这里的 @Builder 是 Lombok 生成的,和手写建造者有什么区别?
→ 回答方向:Lombok @Builder 生成的是简化版建造者------不支持参数校验、不支持必填检查、不支持默认值逻辑。手写建造者可以在 build() 方法里做参数校验(如必填字段为 null 时抛异常),也可以实现不可变对象。这个项目中 conversationUpdater 和 successHandler 可空,其他字段必填,理想情况下应该在 build() 里校验。
总结
函数式接口定义了"行为形状",建造者模式组装了"行为集合",模板方法编排了"行为顺序"------三者配合,把流程骨架和步骤实现彻底分离。
读完这篇你应该能:
- 看懂
Function<T, R>、Consumer<T>、Runnable在 Lambda 中的对应关系 - 理解为什么需要自定义
CheckedRunnable而不是用标准Runnable - 在自己的项目中用"函数式接口 + 建造者"模式编排多步骤流程
- 在面试时说出"Lambda 是延迟执行的行为对象,不是即时执行的结果"