AI 生成应用

本节重点

经过前面几期的基‍础搭建,我们的项目代码已经基本成⁡型。本节我们将学习如何使用 La‎ngChain4j 框架接入 A‎I 大模型,让 AI 根据用户的简单描述,自动生成完整的网页应用。

本节主要内容:

  • 设计 AI 应用生成方案
  • LangChain4j 框架入门
  • 实现 AI 应用生成(2 种生成模式)
  • 实现 SSE 流式输出提升用户体验
  • 通过多种设计模式优化代码架构

一、需求分析

我们的需求‍很好理解 —— 让⁡ AI 根据用户的‎描述,自动生成完整的网‎页应用。

本节先从最基础的需求开始,让 AI 能够生成 原生网页代码,并将代码文件保存到本地。

原生是指代‍码中不需要引入第三方⁡框架(比如 Vue ‎和 React),简‎简单单、干干净净,更容易运行,实现起来更简单。

我们可以采用 2 种原生生成模式来满足不同的使用场景:

  • 原生 HTML 模式:将所有代码(HTML、CSS、JS)打包在一个 HTML 文件中,适合快速原型和简单应用
  • 原生多文件模式:按照标准的前端项目结构,分别生成 index.html、style.css 和 script.js 文件

二、方案设计

在正式开始‍编码之前,我们需要⁡先明确整个 AI ‎应用生成的核心流程‎:

用户输入描述 → AI 大模型生成 → 提取生成内容 → 写入本地文件

这个看似简单的流程,实际上涉及不少技术细节。比如:

  • 如何实现和 AI 的对话?
  • 如何设计有效的提示词?
  • 如何确保 AI 输出的格式符合我们的要求?
  • 如何处理生成的代码并保存到合适的位置?

编写系统提示词

提示词的质量直接决定了 AI 生成结果的好坏。在这个环节,我们可以参考网上的 Prompt 编写指南 以及鱼皮 AI 超级智能体项目 中的 Prompt 指南。

有个小技巧‍是直接让 AI 帮⁡我们根据需求来生成‎提示词,比如我向 ‎AI 提问:

我正在做一个 AI 零代码应用生成平台,根据用户的一段描述即可生成一个完整网站。生成的网站使用 HTML + CSS + JS 实现,帮我编写一个专业的 Prompt。

参考资料:<https://help.aliyun.com/zh/model-studio/use-cases/prompt-engineering-guide>

经过多轮调试和优化,我最终确定了两套系统提示词模板:

1)生成单个 HTML 文件模式的提示词:

你是一位资深的 Web 前端开发专家,精通 HTML、CSS 和原生 JavaScript。你擅长构建响应式、美观且代码整洁的单页面网站。

你的任务是根据用户提供的网站描述,生成一个完整、独立的单页面网站。你需要一步步思考,并最终将所有代码整合到一个 HTML 文件中。

约束:
1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。
2. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。
3. 独立文件: 必须将所有的 CSS 代码都内联在 `<head>` 标签的 `` 标签内,并将所有的 JavaScript 代码都放在 `</body>` 标签之前的 `` 标签内。最终只输出一个 `.html` 文件,不包含任何外部文件引用。
4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请优先使用 Flexbox 或 Grid 进行布局。
5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 <https://picsum.photos> 的服务 (例如 `<img src="<https://picsum.photos/800/600>" alt="Placeholder Image">`)。
6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。
7. 交互性: 如果用户描述了交互功能 (如 Tab 切换、图片轮播、表单提交提示等),请使用原生 JavaScript 来实现。
8. 安全性: 不要包含任何服务器端代码或逻辑。所有功能都是纯客户端的。
9. 输出格式: 你的最终输出必须包含 HTML 代码块,可以在代码块之外添加解释、标题或总结性文字。格式如下:

```html
... HTML 代码 ...

这里值得一提的是 Lorem ipsum 的使用。Lorem ipsum 是印刷排版行业使用的虚拟文本,主要用于测试文章或文字在不同字型、版型下的视觉效果。在我们的场景中,当用户描述比较简略时,AI 可以用这类占位符内容来完善页面结构。

2)生成多文件模式的提示词:

你是一位资深的‍ Web 前端开发专家,你精⁡通编写结构化的 HTML、清‎晰的 CSS 和高效的原生 ‎JavaScript,遵循代码分离和模块化的最佳实践。

你的任务是根据用户提供的网站描述,创建构成一个完整单页网站所需的三个核心文件:HTML, CSS, 和 JavaScript。你需要在最终输出时,将这三部分代码分别放入三个独立的 Markdown 代码块中,并明确标注文件名。

约束:
1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。
2. 文件分离:
- index.html: 只包含网页的结构和内容。它必须在 `<head>` 中通过 `` 标签引用 `style.css`,并且在 `</body>` 结束标签之前通过 `` 标签引用 `script.js`。
- style.css: 包含网站所有的样式规则。
- script.js: 包含网站所有的交互逻辑。
3. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。
4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请在 CSS 中使用 Flexbox 或 Grid 进行布局。
5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 <https://picsum.photos> 的服务 (例如 `<img src="<https://picsum.photos/800/600>" alt="Placeholder Image">`)。
6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。
7. 输出格式: 每个代码块前要注明文件名。可以在代码块之外添加解释、标题或总结性文字。格式如下:

```html
... HTML 代码 ...

```css
... CSS 代码 ...

```javascript
... JavaScript 代码 ...

为什么这个 Prompt 会很有效?大家可以关注下面几点:

  • 角色扮演:让 AI 的回答更具专业性。
  • 严格约束:排除了所有可能导致问题的变量(如外部库、多文件),确保了输出的稳定性和可用性。
  • 清晰的输出格式要求:使得程序可以轻松集成,无需复杂的解析逻辑。
  • Few-shot 示例:为 AI 提供了一个非常具体的模仿对象,大大提高了输出结果和预期结果的相似度,示例中包含了 HTML、CSS 和 JS,完整地展示了最终产物的形态。
  • 细节考虑:Prompt 中考虑到了响应式设计、占位符等实际开发中常见的问题。不过这点需要发挥一下自己的网站开发经验。

在编写 Prompt 的过程中,我们可以利用一些 大模型开发平台 进行测试,直到满足需求:

image-e5b343e0

如果反复调‍整提示词后,还是无⁡法生成符合预期的内‎容,可能就意味着大‎模型的能力不够了。

大模型技术选型

  • *大模型的能力决定了 AI 生成结果的上限。**一般我选择大模型时,会重点关注:效果、成本、生成速度、开发成本、性能、生态。

像我平时用‍的比较多的是阿里的⁡通义系列大模型,因‎为阿里的生态做的不‎错,能很轻松地在 Java 中接入。

所以在选择具体的大模型服务时,我会优先使用 阿里云百炼平台 进行效果测试,发现它的输出结构是能够满足需求的。

image-c885a809

但是经过对‍比测试,我发现 De⁡epSeek R1 ‎在代码生成任务上表现‎更出色,下面这个是我用编写好的提示词生成的网站:

image-c64b6022

除了生成效果,我们还需要考虑开发成本和功能完整性。查看 DeepSeek 的官方文档 后,我发现它与 OpenAI 的 API 完全兼容,这意味着我们可以直接使用现有的 OpenAI 客户端库来接入 DeepSeek。

image-a0fa9e2f

在具体的模型选择上,我建议开发阶段使用 deepseek-chat,因为响应速度更快,有利于提高开发效率;如果追求更高的生成质量或需要处理更长的文档,可以使用 deepseek-reasoner

image-9a5d07b0

AI 开发框架选型

AI 开发框架的作用是 简化项目中开发 AI 应用的过程。实际企业开发中,可不是调用一下 AI 的 API 接口这么简单,还有像 RAG 检索增强生成、Tools 工具调用、MCP 模型上下文协议等典型场景,这些功能如果都自己开发,那成本可就太高了。

实际开发中应该如何选择 AI 开发框架呢?

目前主流的 Java AI 开发框‍架当属 Spring AI 和 LangChain4j。我其实会更喜⁡欢 Spring AI 的开发模式,而且 Spring AI 目前支‎持的能力更多,还有国内 Spring AI Alibaba 的巨头加‎持,生态更好,遇到问题更容易解决;LangChain4j 的优势在于可以独立于 Spring 项目使用,更自由灵活一些。

不过这类框‍架大家重点学习一个⁡就好了,很多概念和‎用法是相通的:

image-bf5cfdf3

由于在 编程导航的上个项目 - AI 超级智能体 中,鱼皮已经带大家实战了 Spring AI 的核心特性,所以在本项目中,专门换一个新的框架 LangChain4j,带大家多学习一门新的技术,这样以后企业无论用哪个,咱都会!嘎嘎猛~

三、LangChain4j 入门

LangC‍hain4j 是目⁡前主流的 Java‎ AI 开发框架。‎我看重它的 3 大优势:

  • 声明式编程模式:通过简单的注解和接口定义,就能实现复杂的 AI 交互逻辑,这大大降低了开发门槛
  • 丰富的模型支持:不仅支持 OpenAI,还兼容国内外主流的大模型服务
  • 容易集成:它和 Spring Boot 的集成做得非常好,能快速整合到已有项目中

如果你对 LangChain4j 一无所知,强烈推荐先学习鱼皮之前写的 LangChain4j 保姆级项目实战教程,大概 2 小时左右就能实践一遍 LangChain4j 的关键特性,并且开发一个低配版的 AI 编程小助手。

image-7db49e6d

我们需要掌握的 LangChain4j 特性如下:

  • AI 对话 - ChatModel
  • 多模态 - Multimodality
  • 系统提示词 - SystemMessage
  • AI 服务 - AI Service
  • Spring Boot 项目整合
  • 会话记忆 - ChatMemory
  • 结构化输出
  • 检索增强生成 - RAG
  • 工具调用 - Tools
  • 模型上下文协议 - MCP
  • 护轨 - Guardrail
  • 日志和可观测性

不过哪怕不看前面提到的教程,也能跟得上这个项目,而且本项目中对 LangChain4j 的应用会更加深入。建议大家在学习本项目时,结合阅读 官方文档 进行巩固。

四、实现 AI 应用生成(原生模式)

确定了技术方案后,我们开始实现。

1、大模型接入

首先需要获取 DeepSeek 的 API Key,访问 官方文档

image-b95692a6

接下来引入必要的依赖。参考 LangChain4j 的官方文档,我们需要添加 OpenAI 大模型的依赖:

image-fa193aaf

pom.xml 中添加依赖:

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
    <version>1.1.0</version>
</dependency>
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
    <version>1.1.0-beta7</version>
</dependency>

为了保护敏感信息,我们需要在 .gitignore 中添加本地配置文件 application-local.yml,忽略该文件的提交,之后就可以放心地将敏感配置都写在这个文件里了。

### CUSTOM ###
application-local.yml

创建本地配置文件 application-local.yml,填写 Chat Model 配置。此外,为了调试方便,我们开启了详细的日志记录。这里参考了 LangChain4j 的日志配置文档

# AI
langchain4j:
  open-ai:
    chat-model:
      base-url: <https://api.deepseek.com>
      api-key: <Your API Key>
      model-name: deepseek-chat
      log-requests: true
      log-responses: true

在主配置文件中激活本地环境:

spring:
  profiles:
    active: local

2、开发 AI 服务

按照 LangChain4j 推荐的 AI Service 开发模式,在 ai 包下创建服务接口:

public interface AiCodeGeneratorService {

    String generateCode(String userMessage);
}

考虑到系统‍提示词通常比较长,⁡将它们单独维护在资源文‎件中。准备了两‎种生成模式对应的系统提示词文件:

  • codegen-html-system-prompt.txt:原生 HTML 模式
  • codegen-multi-file-system-prompt.txt:原生三件套模式
image-b1448c2f

在服务接口中‍添加 2 个生成代码的⁡方法,分别对应 2 种生成‎模式。使用 Lan‎gChain4j 的注解来指定系统提示词:

public interface AiCodeGeneratorService {

    /**
     * 生成 HTML 代码
     *
     * @param userMessage 用户消息
     * @return 生成的代码结果
     */
    @SystemMessage(fromResource = "prompt/codegen-html-system-prompt.txt")
    String generateHtmlCode(String userMessage);

    /**
     * 生成多文件代码
     *
     * @param userMessage 用户消息
     * @return 生成的代码结果
     */
    @SystemMessage(fromResource = "prompt/codegen-multi-file-system-prompt.txt")
    String generateMultiFileCode(String userMessage);
}

创建工厂类来初始化 AI 服务:

@Configuration
public class AiCodeGeneratorServiceFactory {

    @Resource
    private ChatModel chatModel;

    @Bean
    public AiCodeGeneratorService aiCodeGeneratorService() {
        return AiServices.create(AiCodeGeneratorService.class, chatModel);
    }
}

最后,编写单元测试来验证功能:

@SpringBootTest
class AiCodeGeneratorServiceTest {

    @Resource
    private AiCodeGeneratorService aiCodeGeneratorService;

    @Test
    void generateHtmlCode() {
        String result = aiCodeGeneratorService.generateHtmlCode("做个程序员鱼皮的工作记录小工具");
        Assertions.assertNotNull(result);
    }

    @Test
    void generateMultiFileCode() {
        String multiFileCode = aiCodeGeneratorService.generateMultiFileCode("做个程序员鱼皮的留言板");
        Assertions.assertNotNull(multiFileCode);
    }
}

以 Deb‍ug 方式运行测试⁡,可以在控制台看到‎详细的请求和响应日‎志

image-e5b9eb7d

测试结果符合预期,AI 成功生成了完整的网页代码:

image-460d3908image-460d3908

3、结构化输出

虽然已经能够调用 AI 生成代码,但直接返回字符串的方式不便于后续解析代码并保存为文件。因此我们需要将 AI 的输出转换为结构化的对象,利用 LangChain4j 的结构化输出 特性可以轻松实现。

基本实现

1)在 ai.model 包下创建生成结果类,用于封装 AI 返回的内容:

@Data
public class HtmlCodeResult {

    private String htmlCode;

    private String description;
}
@Data
public class MultiFileCodeResult {

    private String htmlCode;

    private String cssCode;

    private String jsCode;

    private String description;
}

2)修改 AI 服务接口,让方法返回结构化对象:

/**
 * 生成 HTML 代码
 *
 * @param userMessage 用户消息
 * @return 生成的代码结果
 */
@SystemMessage(fromResource = "prompt/codegen-html-system-prompt.txt")
HtmlCodeResult generateHtmlCode(String userMessage);

/**
 * 生成多文件代码
 *
 * @param userMessage 用户消息
 * @return 生成的代码结果
 */
@SystemMessage(fromResource = "prompt/codegen-multi-file-system-prompt.txt")
MultiFileCodeResult generateMultiFileCode(String userMessage);

3)相应地修改单元测试调用方法的返回值类型:

@Test
void generateHtmlCode() {
    HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode("做个程序员鱼皮的工作记录小工具");
    Assertions.assertNotNull(result);
}

@Test
void generateMultiFileCode() {
    MultiFileCodeResult multiFileCode = aiCodeGeneratorService.generateMultiFileCode("做个程序员鱼皮的留言板");
    Assertions.assertNotNull(multiFileCode);
}

4)执行单‍元测试,通过日志可⁡以看到,LangC‎hain4j 自动‎在我们的提示词后面拼接了结构化输出的要求:

image-9ab88d5b

但是在实际‍测试中出现了报错,⁡AI 好像并没有乖‎乖地返回我们预期的‎ JSON 格式!

image-8d9495a4

优化技巧

经过研究和‍实践,我总结了 4⁡ 个关键的优化技巧‎,来提高结构化输出‎的准确度和稳定性。

1)设置 max_tokens

参考 DeepSeek 官方文档 的建议,设置一下输出长度,防止 AI 生成的 JSON 被半路截断:

image-c7270c1e

注意不要超出模型的限制:

image-9a5d07b0

在项目配置文件中添加:

langchain4j:
  open-ai:
    chat-model:
      max-tokens: 8192

2)JSON Schema 配置

OpenAI 相关文档 提到了 response_format_json_schema 配置,可以严格确保结构化输出生效:

image-22abeaa6

但经过测试‍发现,DeepSe⁡ek 不支持这种配‎置,项目中使用‎会报错。

不过官方文档提到了另外一种配置,设置 response-format 参数为 json_object

image-00d74344

可以在项目中使用这个配置:

langchain4j:
  open-ai:
    chat-model:
      strict-json-schema: true
      response-format: json_object

3)添加字段描述

参考 LangChain4j 文档,为结果类和属性添加详细的描述信息,便于 AI 理解:

@Description("生成 HTML 代码文件的结果")
@Data
public class HtmlCodeResult {

    @Description("HTML代码")
    private String htmlCode;

    @Description("生成代码的描述")
    private String description;
}
@Description("生成多个代码文件的结果")
@Data
public class MultiFileCodeResult {

    @Description("HTML代码")
    private String htmlCode;

    @Description("CSS代码")
    private String cssCode;

    @Description("JS代码")
    private String jsCode;

    @Description("生成代码的描述")
    private String description;
}

再次运行测‍试,可以看到提示词⁡中自动补充了字段描‎述和配置信息:

image-a6c97382

现在 AI 结构化输出的稳定性高了不少:

image-db9f66e0

多文件生成也正常工作:

image-0265b3a7

经过这些优化,结构化输出的准确度有了显著提升。虽然偶尔还会有不稳定的情况,但这是 DeepSeek 大模型的正常现象(DeepSeek 官方也提到了)。LangChain4j 默认会自动重试,我们也可以通过调整 max-retries 参数来控制重试次数。

4)提示词优化

最后一个技‍巧是在系统提示词中⁡明确要求输出 JS‎ON 格式,这样可‎以进一步提高成功率。

4、程序处理写入 - 门面模式

有了结构化‍的输出对象,接下来⁡就是将生成的代码保‎存到本地文件系统。

1)首先定义生成类型枚举,放在 model.enums 包下,因为后续其他的 Service 应该会用到。

@Getter
public enum CodeGenTypeEnum {

    HTML("原生 HTML 模式", "html"),
    MULTI_FILE("原生多文件模式", "multi_file");

    private final String text;
    private final String value;

    CodeGenTypeEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value 枚举值的value
     * @return 枚举值
     */
    public static CodeGenTypeEnum getEnumByValue(String value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (CodeGenTypeEnum anEnum : CodeGenTypeEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }
}

2)创建文件写入工具类 CodeFileSaver,放在 core 包下,表示核心业务代码。

我选择在临时目录 tmp 下保存文件,每次生成都对应一个临时目录下的文件夹,使用 业务类型 + 雪花 ID 的命名方式来确保唯一性。

可以使用 Hutool 工具库实现雪花 ID,代码如下:

public class CodeFileSaver {

    // 文件保存根目录
    private static final String FILE_SAVE_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output";

    /**
     * 保存 HtmlCodeResult
     */
    public static File saveHtmlCodeResult(HtmlCodeResult result) {
        String baseDirPath = buildUniqueDir(CodeGenTypeEnum.HTML.getValue());
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
        return new File(baseDirPath);
    }

    /**
     * 保存 MultiFileCodeResult
     */
    public static File saveMultiFileCodeResult(MultiFileCodeResult result) {
        String baseDirPath = buildUniqueDir(CodeGenTypeEnum.MULTI_FILE.getValue());
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
        writeToFile(baseDirPath, "style.css", result.getCssCode());
        writeToFile(baseDirPath, "script.js", result.getJsCode());
        return new File(baseDirPath);
    }

    /**
     * 构建唯一目录路径:tmp/code_output/bizType_雪花ID
     */
    private static String buildUniqueDir(String bizType) {
        String uniqueDirName = StrUtil.format("{}_{}", bizType, IdUtil.getSnowflakeNextIdStr());
        String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName;
        FileUtil.mkdir(dirPath);
        return dirPath;
    }

    /**
     * 写入单个文件
     */
    private static void writeToFile(String dirPath, String filename, String content) {
        String filePath = dirPath + File.separator + filename;
        FileUtil.writeString(content, filePath, StandardCharsets.UTF_8);
    }
}

记得将 tmp 目录添加到 .gitignore 中,避免生成的文件被提交到代码仓库。

3)为了统一管理生成和保存的逻辑,我决定使用 门面模式 这一设计模式。

门面模式通过‍提供一个统一的高层接口⁡来隐藏子系统的复杂性,‎让客户端只需要与这个简‎化的接口交互,而不用了解内部的复杂实现细节。

image-4c99d88a

core 包下创建 AiCodeGeneratorFacade 类,代码如下:

/**
 * AI 代码生成外观类,组合生成和保存功能
 */
@Service
public class AiCodeGeneratorFacade {

    @Resource
    private AiCodeGeneratorService aiCodeGeneratorService;

    /**
     * 统一入口:根据类型生成并保存代码
     *
     * @param userMessage     用户提示词
     * @param codeGenTypeEnum 生成类型
     * @return 保存的目录
     */
    public File generateAndSaveCode(String userMessage, CodeGenTypeEnum codeGenTypeEnum) {
        if (codeGenTypeEnum == null) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
        }
        return switch (codeGenTypeEnum) {
            case HTML -> generateAndSaveHtmlCode(userMessage);
            case MULTI_FILE -> generateAndSaveMultiFileCode(userMessage);
            default -> {
                String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
                throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
            }
        };
    }

    /**
     * 生成 HTML 模式的代码并保存
     *
     * @param userMessage 用户提示词
     * @return 保存的目录
     */
    private File generateAndSaveHtmlCode(String userMessage) {
        HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode(userMessage);
        return CodeFileSaver.saveHtmlCodeResult(result);
    }

    /**
     * 生成多文件模式的代码并保存
     *
     * @param userMessage 用户提示词
     * @return 保存的目录
     */
    private File generateAndSaveMultiFileCode(String userMessage) {
        MultiFileCodeResult result = aiCodeGeneratorService.generateMultiFileCode(userMessage);
        return CodeFileSaver.saveMultiFileCodeResult(result);
    }
}

4)编写单元测试验证整个流程:

@SpringBootTest
class AiCodeGeneratorFacadeTest {

    @Resource
    private AiCodeGeneratorFacade aiCodeGeneratorFacade;

    @Test
    void generateAndSaveCode() {
        File file = aiCodeGeneratorFacade.generateAndSaveCode("任务记录网站", CodeGenTypeEnum.MULTI_FILE);
        Assertions.assertNotNull(file);
    }
}

测试成功,可以看到生成文件的保存路径:

image-2dbc98e5

双击生成文件的 index.html,即可看到生成的网站效果:

image-743374a3

五、SSE 流式输出

到这一步,基本‍的代码生成功能已经可以正常工作⁡了。但是我们发现了一个问题‎:结构化输出的速度比较慢,用‎户需要等待较长时间才能看到结果,这种体验显然不够好。

为了提升用户体验,‍需要引入 SSE(Server-Se⁡nt Events)流式输出,像打字‎机一样,AI 返回一个词,前端输出‎一个词。

方案选择

目前流式输出不支持结构化输出,但我们可以在流式返回的过程中 拼接 AI 的返回结果(可以实时返回给前端),等全部输出完成后,再对拼接结果进行解析和保存。这样既保证了实时性,又不影响最终的处理流程。

在实现 SS‍E 的技术方案上,Lan⁡gChain4j 提供了‎两种方式:       ‎

1、LangChain4j + Reactor(推荐)

Reactor 是指响‍应式编程,LangChain4j 提供了响应⁡式编程依赖包,可以直接把 AI 返回的内容封‎装为更通用的 Flux 响应式对象。可以把 ‎Flux 想象成一个数据流,有了这个对象后,上游发来一块数据,下游就能处理一块数据。

)

image-30470105

我们可以对 Flux 对象进行下列操作:

image-dae53fef

这种方案的‍优点是与前端集成更方⁡便,通过 Flux ‎对象可以很容易地将流‎式内容返回给前端。缺点是需要引入额外的依赖:

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-reactor</artifactId>
  <version>1.1.0-beta7</version>
</dependency>

2、TokenStream(了解即可)

这是 LangChai‍n4j 的原生实现方式,好处是提供了更多高⁡级回调,比如工具调用完成回调(onTool‎Executed)、工具调用内容实时响应。‎但缺点是使用起来相对复杂,而且要返回前端时还需要用 Flux 包装一层。

示例代码:

return Flux.create(sink -> {
    StringBuilder respContent = new StringBuilder();
    assistant.chat(finalUserPrompt) // 返回 tokenStream
    .onPartialResponse(partialResponse -> {
        log.info("partialResponse: {}", partialResponse);
        sink.next(partialResponse);
    })
    .onCompleteResponse(completeResponse -> {
        log.info("chatResponse: {}", completeResponse);
        sink.complete();
    })
    .onToolExecuted(toolExecution -> {
        log.info("tool executed successfully: {}", toolExecution);
    })
    .onError(sink::error)
    .start();
});

我最终选择‍了方案 1,因为它⁡更适合我们目前的使‎用场景,本项目后续‎会实战方案 2。

开发实现

1)首先配置流式模型:

langchain4j:
  open-ai:
    streaming-chat-model:
      base-url: <https://api.deepseek.com>
      api-key: <Your API Key>
      model-name: deepseek-chat
      max-tokens: 8192
      log-requests: true
      log-responses: true

2)在创建 AI Service 的工厂类中注入流式模型:

@Configuration
public class AiCodeGeneratorServiceFactory {

    @Resource
    private ChatModel chatModel;

    @Resource
    private StreamingChatModel streamingChatModel;

    @Bean
    public AiCodeGeneratorService aiCodeGeneratorService() {
        return AiServices.builder(AiCodeGeneratorService.class)
                .chatModel(chatModel)
                .streamingChatModel(streamingChatModel)
                .build();
    }
}

3)在 A‍I Service⁡ 中新增流式方法,‎跟之前方法的区别在‎于返回值改为了 Flux 对象:

/**
 * 生成 HTML 代码(流式)
 *
 * @param userMessage 用户消息
 * @return 生成的代码结果
 */
@SystemMessage(fromResource = "prompt/codegen-html-system-prompt.txt")
Flux<String> generateHtmlCodeStream(String userMessage);

/**
 * 生成多文件代码(流式)
 *
 * @param userMessage 用户消息
 * @return 生成的代码结果
 */
@SystemMessage(fromResource = "prompt/codegen-multi-file-system-prompt.txt")
Flux<String> generateMultiFileCodeStream(String userMessage);

4)编写解析逻辑。

由于流式输‍出返回的是字符串片⁡段,我们需要在 A‎I 全部返回完成后进‎行解析。

由于代码解析逻辑相对复杂,单独在 core 包下创建代码解析器 CodeParser。核心逻辑是通过正则表达式从完整字符串中提取到对应的代码块,并返回结构化输出对象,这样可以复用之前的文件保存器。

/**
 * 代码解析器
 * 提供静态方法解析不同类型的代码内容
 *
 * @author yupi
 */
public class CodeParser {

    private static final Pattern HTML_CODE_PATTERN = Pattern.compile("```html\\\\s*\\\\n([\\\\s\\\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern CSS_CODE_PATTERN = Pattern.compile("```css\\\\s*\\\\n([\\\\s\\\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern JS_CODE_PATTERN = Pattern.compile("```(?:js|javascript)\\\\s*\\\\n([\\\\s\\\\S]*?)```", Pattern.CASE_INSENSITIVE);

    /**
     * 解析 HTML 单文件代码
     */
    public static HtmlCodeResult parseHtmlCode(String codeContent) {
        HtmlCodeResult result = new HtmlCodeResult();
        // 提取 HTML 代码
        String htmlCode = extractHtmlCode(codeContent);
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        } else {
            // 如果没有找到代码块,将整个内容作为HTML
            result.setHtmlCode(codeContent.trim());
        }
        return result;
    }

    /**
     * 解析多文件代码(HTML + CSS + JS)
     */
    public static MultiFileCodeResult parseMultiFileCode(String codeContent) {
        MultiFileCodeResult result = new MultiFileCodeResult();
        // 提取各类代码
        String htmlCode = extractCodeByPattern(codeContent, HTML_CODE_PATTERN);
        String cssCode = extractCodeByPattern(codeContent, CSS_CODE_PATTERN);
        String jsCode = extractCodeByPattern(codeContent, JS_CODE_PATTERN);
        // 设置HTML代码
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        }
        // 设置CSS代码
        if (cssCode != null && !cssCode.trim().isEmpty()) {
            result.setCssCode(cssCode.trim());
        }
        // 设置JS代码
        if (jsCode != null && !jsCode.trim().isEmpty()) {
            result.setJsCode(jsCode.trim());
        }
        return result;
    }

    /**
     * 提取HTML代码内容
     *
     * @param content 原始内容
     * @return HTML代码
     */
    private static String extractHtmlCode(String content) {
        Matcher matcher = HTML_CODE_PATTERN.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }

    /**
     * 根据正则模式提取代码
     *
     * @param content 原始内容
     * @param pattern 正则模式
     * @return 提取的代码
     */
    private static String extractCodeByPattern(String content, Pattern pattern) {
        Matcher matcher = pattern.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }
}

建议编写一个单元测试类,来验证解析器的功能:

class CodeParserTest {

    @Test
    void parseHtmlCode() {
        String codeContent = """
                随便写一段描述:
                html 格式
                <!DOCTYPE html>
                <html>
                <head>
                    <title>测试页面</title>
                </head>
                <body>
                    <h1>Hello World!</h1>
                </body>
                </html>

                随便写一段描述
                """;
        HtmlCodeResult result = CodeParser.parseHtmlCode(codeContent);
        assertNotNull(result);
        assertNotNull(result.getHtmlCode());
    }

    @Test
    void parseMultiFileCode() {
        String codeContent = """
                创建一个完整的网页:
                html 格式
                <!DOCTYPE html>
                <html>
                <head>
                    <title>多文件示例</title>
                    
                </head>
                <body>
                    <h1>欢迎使用</h1>
                    
                </body>
                </html>

                css 格式
                h1 {
                    color: blue;
                    text-align: center;
                }
                ```
                ```js
                console.log('页面加载完成');

                文件创建完成!
                """;
        MultiFileCodeResult result = CodeParser.parseMultiFileCode(codeContent);
        assertNotNull(result);
        assertNotNull(result.getHtmlCode());
        assertNotNull(result.getCssCode());
        assertNotNull(result.getJsCode());
    }
}

执行单元测试,确保解析正确:

image-d9d31b70

目前我们没‍有支持解析描述字段⁡,感兴趣的同学可以‎尝试把描述也解析了‎(不过我觉得意义不大)。

5)在 AiCodeGeneratorFacade 中添加流式调用 AI 的方法。

针对每种生成模‍式,分别提供一个 “生成代码⁡并保存” 的方法,核心逻辑都‎是:拼接 AI 实时响应的字‎符串,并在 流式返回完成后解析字符串并保存代码文件。

代码如下:

/**
 * 生成 HTML 模式的代码并保存(流式)
 *
 * @param userMessage 用户提示词
 * @return 保存的目录
 */
private Flux<String> generateAndSaveHtmlCodeStream(String userMessage) {
    Flux<String> result = aiCodeGeneratorService.generateHtmlCodeStream(userMessage);
    // 当流式返回生成代码完成后,再保存代码
    StringBuilder codeBuilder = new StringBuilder();
    return result
            .doOnNext(chunk -> {
                // 实时收集代码片段
                codeBuilder.append(chunk);
            })
            .doOnComplete(() -> {
                // 流式返回完成后保存代码
                try {
                    String completeHtmlCode = codeBuilder.toString();
                    HtmlCodeResult htmlCodeResult = CodeParser.parseHtmlCode(completeHtmlCode);
                    // 保存代码到文件
                    File savedDir = CodeFileSaver.saveHtmlCodeResult(htmlCodeResult);
                    log.info("保存成功,路径为:" + savedDir.getAbsolutePath());
                } catch (Exception e) {
                    log.error("保存失败: {}", e.getMessage());
                }
            });
}

/**
 * 生成多文件模式的代码并保存(流式)
 *
 * @param userMessage 用户提示词
 * @return 保存的目录
 */
private Flux<String> generateAndSaveMultiFileCodeStream(String userMessage) {
    Flux<String> result = aiCodeGeneratorService.generateMultiFileCodeStream(userMessage);
    // 当流式返回生成代码完成后,再保存代码
    StringBuilder codeBuilder = new StringBuilder();
    return result
            .doOnNext(chunk -> {
                // 实时收集代码片段
                codeBuilder.append(chunk);
            })
            .doOnComplete(() -> {
                // 流式返回完成后保存代码
                try {
                    String completeMultiFileCode = codeBuilder.toString();
                    MultiFileCodeResult multiFileResult = CodeParser.parseMultiFileCode(completeMultiFileCode);
                    // 保存代码到文件
                    File savedDir = CodeFileSaver.saveMultiFileCodeResult(multiFileResult);
                    log.info("保存成功,路径为:" + savedDir.getAbsolutePath());
                } catch (Exception e) {
                    log.error("保存失败: {}", e.getMessage());
                }
            });
}

6)在 AiCodeGeneratorFacade 中编写统一入口,根据生成模式枚举选择对应的流式方法:

/**
 * 统一入口:根据类型生成并保存代码(流式)
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 */
public Flux<String> generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> generateAndSaveHtmlCodeStream(userMessage);
        case MULTI_FILE -> generateAndSaveMultiFileCodeStream(userMessage);
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

7)编写单元测试验证流式功能:

@Test
void generateAndSaveCodeStream() {
    Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream("任务记录网站", CodeGenTypeEnum.MULTI_FILE);
    // 阻塞等待所有数据收集完成
    List<String> result = codeStream.collectList().block();
    // 验证结果
    Assertions.assertNotNull(result);
    String completeContent = String.join("", result);
    Assertions.assertNotNull(completeContent);
}

测试过程中多多 Debug,可以看到流式响应的效果:

image-acdfed41

获取到完整返回的字符串:

image-a6b6c6bf

最终成功解析并生成文件:

image-6d165419

查看生成的网站效果:

image-0af09338

六、代码优‍化 - 极致的抽象⁡ ‎ ‎

在开发过程‍中,我们会发现大量⁡的重复代码。作为一‎个有追求的程序员,‎我们可以通过几种设计模式来优化代码结构。

优化方案

经过仔细分析,我决定使用以下优化策略:

  • 解析器部分:使用策略模式,不同类型的解析策略独立维护(难点是不同解析策略的返回值不同)
  • 文件保存部分:使用模板方法模式,统一保存流程(难点是不同保存方式的方法参数不同)
  • SSE 流式处理:抽象出通用的流式处理逻辑(目前每种生成模式都写了一套处理代码)

策略模式

策略模式定义‍了一系列算法,将每个算⁡法封装起来,并让它们可‎以相互替换,使得算法的‎变化不会影响使用算法的代码,让项目更好维护和扩展。

image-d6686b89

模板方法模式

模板方法模式‍在抽象父类中定义了操作⁡的标准流程,将一些具体‎实现步骤交给子类,使得‎子类可以在不改变流程的情况下重新定义某些特定步骤。

image-177f686f

执行器模式

正常情况下,可以通过‍工厂模式来创建不同的策略或模板方法,但由⁡于每种生成模式的参数和返回值不同(Htm‎lCodeResult 和 MultiF‎ileCodeResult),很难对通过工厂模式创建出来的对象进行统一的调用。

public HtmlCodeResult parseCode(String codeContent) {}

public MultiFileCodeResult parseCode(String codeContent) {}

void saveFiles(HtmlCodeResult result, String baseDirPath) {}

void saveFiles(MultiFileCodeResult result, String baseDirPath) {}

对于方法参‍数不同的策略模式和⁡模板方法模式,建议‎使用执行器模式(E‎xecutor)。

执行器模式提供‍统一的执行入口来协调不同策⁡略和模板的调用,特别适合处‎理参数类型不同但业务逻辑相‎似的场景,避免了工厂模式在处理不同参数类型时的局限性。

image-03eb0162

混合模式

最终,我们预期的代码架构是一种 混合模式

  • 执行器模式:提供统一的执行入口,根据生成类型执行不同的操作
  • 策略模式:每种模式对应的解析方法单独作为一个类来维护
  • 模板方法模式:抽象模板类定义了通用的文件保存流程,子类可以有自己的实现(比如多文件生成模式需要保存 3 个文件,而原生 HTML 模式只需要保存 1 个文件)
image-738c74a5

开发实现

1、优化解析器

新建 parser 包:

image-e1c14840

定义接口,通过泛型统一方法的返回值:

/**
 * 代码解析器策略接口
 *
 * @author yupi
 */
public interface CodeParser<T> {

    /**
     * 解析代码内容
     *
     * @param codeContent 原始代码内容
     * @return 解析后的结果对象
     */
    T parseCode(String codeContent);
}

编写 HTML 单文件代码解析器:

/**
 * HTML 单文件代码解析器
 *
 * @author yupi
 */
public class HtmlCodeParser implements CodeParser<HtmlCodeResult> {

    private static final Pattern HTML_CODE_PATTERN = Pattern.compile("```html\\\\s*\\\\n([\\\\s\\\\S]*?)```", Pattern.CASE_INSENSITIVE);

    @Override
    public HtmlCodeResult parseCode(String codeContent) {
        HtmlCodeResult result = new HtmlCodeResult();
        // 提取 HTML 代码
        String htmlCode = extractHtmlCode(codeContent);
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        } else {
            // 如果没有找到代码块,将整个内容作为HTML
            result.setHtmlCode(codeContent.trim());
        }
        return result;
    }

    /**
     * 提取HTML代码内容
     *
     * @param content 原始内容
     * @return HTML代码
     */
    private String extractHtmlCode(String content) {
        Matcher matcher = HTML_CODE_PATTERN.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }
}

编写多文件代码解析器:

/**
 * 多文件代码解析器(HTML + CSS + JS)
 *
 * @author yupi
 */
public class MultiFileCodeParser implements CodeParser<MultiFileCodeResult> {

    private static final Pattern HTML_CODE_PATTERN = Pattern.compile("```html\\\\s*\\\\n([\\\\s\\\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern CSS_CODE_PATTERN = Pattern.compile("```css\\\\s*\\\\n([\\\\s\\\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern JS_CODE_PATTERN = Pattern.compile("```(?:js|javascript)\\\\s*\\\\n([\\\\s\\\\S]*?)```", Pattern.CASE_INSENSITIVE);

    @Override
    public MultiFileCodeResult parseCode(String codeContent) {
        MultiFileCodeResult result = new MultiFileCodeResult();
        // 提取各类代码
        String htmlCode = extractCodeByPattern(codeContent, HTML_CODE_PATTERN);
        String cssCode = extractCodeByPattern(codeContent, CSS_CODE_PATTERN);
        String jsCode = extractCodeByPattern(codeContent, JS_CODE_PATTERN);
        // 设置HTML代码
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        }
        // 设置CSS代码
        if (cssCode != null && !cssCode.trim().isEmpty()) {
            result.setCssCode(cssCode.trim());
        }
        // 设置JS代码
        if (jsCode != null && !jsCode.trim().isEmpty()) {
            result.setJsCode(jsCode.trim());
        }
        return result;
    }

    /**
     * 根据正则模式提取代码
     *
     * @param content 原始内容
     * @param pattern 正则模式
     * @return 提取的代码
     */
    private String extractCodeByPattern(String content, Pattern pattern) {
        Matcher matcher = pattern.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }
}

编写代码解析执行器,根据代码生成类型执行相应的解析逻辑:

/**
 * 代码解析执行器
 * 根据代码生成类型执行相应的解析逻辑
 *
 * @author yupi
 */
public class CodeParserExecutor {

    private static final HtmlCodeParser htmlCodeParser = new HtmlCodeParser();

    private static final MultiFileCodeParser multiFileCodeParser = new MultiFileCodeParser();

    /**
     * 执行代码解析
     *
     * @param codeContent 代码内容
     * @param codeGenType 代码生成类型
     * @return 解析结果(HtmlCodeResult 或 MultiFileCodeResult)
     */
    public static Object executeParser(String codeContent, CodeGenTypeEnum codeGenType) {
        return switch (codeGenType) {
            case HTML -> htmlCodeParser.parseCode(codeContent);
            case MULTI_FILE -> multiFileCodeParser.parseCode(codeContent);
            default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型: " + codeGenType);
        };
    }
}

2、优化文件保存器

新建 saver 包

image-25f11b0a

创建文件保‍存模板抽象类,通过⁡泛型统一了方法的调用‎参数。其中 sa‎veCode 方法定义了保存代码的通用流程:

/**
 * 抽象代码文件保存器 - 模板方法模式
 *
 * @author yupi
 */
public abstract class CodeFileSaverTemplate<T> {

    // 文件保存根目录
    protected static final String FILE_SAVE_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output";

    /**
     * 模板方法:保存代码的标准流程
     *
     * @param result 代码结果对象
     * @return 保存的目录
     */
    public final File saveCode(T result) {
        // 1. 验证输入
        validateInput(result);
        // 2. 构建唯一目录
        String baseDirPath = buildUniqueDir();
        // 3. 保存文件(具体实现由子类提供)
        saveFiles(result, baseDirPath);
        // 4. 返回目录文件对象
        return new File(baseDirPath);
    }

    /**
     * 验证输入参数(可由子类覆盖)
     *
     * @param result 代码结果对象
     */
    protected void validateInput(T result) {
        if (result == null) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "代码结果对象不能为空");
        }
    }

    /**
     * 构建唯一目录路径
     *
     * @return 目录路径
     */
    protected final String buildUniqueDir() {
        String codeType = getCodeType().getValue();
        String uniqueDirName = StrUtil.format("{}_{}", codeType, IdUtil.getSnowflakeNextIdStr());
        String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName;
        FileUtil.mkdir(dirPath);
        return dirPath;
    }

    /**
     * 写入单个文件的工具方法
     *
     * @param dirPath  目录路径
     * @param filename 文件名
     * @param content  文件内容
     */
    protected final void writeToFile(String dirPath, String filename, String content) {
        if (StrUtil.isNotBlank(content)) {
            String filePath = dirPath + File.separator + filename;
            FileUtil.writeString(content, filePath, StandardCharsets.UTF_8);
        }
    }

    /**
     * 获取代码类型(由子类实现)
     *
     * @return 代码生成类型
     */
    protected abstract CodeGenTypeEnum getCodeType();

    /**
     * 保存文件的具体实现(由子类实现)
     *
     * @param result      代码结果对象
     * @param baseDirPath 基础目录路径
     */
    protected abstract void saveFiles(T result, String baseDirPath);
}

编写 HTML 代码文件保存器:

/**
 * HTML代码文件保存器
 *
 * @author yupi
 */
public class HtmlCodeFileSaverTemplate extends CodeFileSaverTemplate<HtmlCodeResult> {

    @Override
    protected CodeGenTypeEnum getCodeType() {
        return CodeGenTypeEnum.HTML;
    }

    @Override
    protected void saveFiles(HtmlCodeResult result, String baseDirPath) {
        // 保存 HTML 文件
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
    }

    @Override
    protected void validateInput(HtmlCodeResult result) {
        super.validateInput(result);
        // HTML 代码不能为空
        if (StrUtil.isBlank(result.getHtmlCode())) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "HTML代码内容不能为空");
        }
    }
}

编写多文件代码保存器:

/**
 * 多文件代码保存器
 *
 * @author yupi
 */
public class MultiFileCodeFileSaverTemplate extends CodeFileSaverTemplate<MultiFileCodeResult> {

    @Override
    public CodeGenTypeEnum getCodeType() {
        return CodeGenTypeEnum.MULTI_FILE;
    }

    @Override
    protected void saveFiles(MultiFileCodeResult result, String baseDirPath) {
        // 保存 HTML 文件
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
        // 保存 CSS 文件
        writeToFile(baseDirPath, "style.css", result.getCssCode());
        // 保存 JavaScript 文件
        writeToFile(baseDirPath, "script.js", result.getJsCode());
    }

    @Override
    protected void validateInput(MultiFileCodeResult result) {
        super.validateInput(result);
        // 至少要有 HTML 代码,CSS 和 JS 可以为空
        if (StrUtil.isBlank(result.getHtmlCode())) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "HTML代码内容不能为空");
        }
    }
}

编写代码文‍件保存执行器,根据⁡代码生成类型执行相‎应的保存逻辑:

/**
 * 代码文件保存执行器
 * 根据代码生成类型执行相应的保存逻辑
 *
 * @author yupi
 */
public class CodeFileSaverExecutor {

    private static final HtmlCodeFileSaverTemplate htmlCodeFileSaver = new HtmlCodeFileSaverTemplate();

    private static final MultiFileCodeFileSaverTemplate multiFileCodeFileSaver = new MultiFileCodeFileSaverTemplate();

    /**
     * 执行代码保存
     *
     * @param codeResult  代码结果对象
     * @param codeGenType 代码生成类型
     * @return 保存的目录
     */
    public static File executeSaver(Object codeResult, CodeGenTypeEnum codeGenType) {
        return switch (codeGenType) {
            case HTML -> htmlCodeFileSaver.saveCode((HtmlCodeResult) codeResult);
            case MULTI_FILE -> multiFileCodeFileSaver.saveCode((MultiFileCodeResult) codeResult);
            default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型: " + codeGenType);
        };
    }
}

3、优化门面类

修改 AiCodeGeneratorFacade 类,抽象出通用的流式代码处理方法:

/**
 * 通用流式代码处理方法
 *
 * @param codeStream  代码流
 * @param codeGenType 代码生成类型
 * @return 流式响应
 */
private Flux<String> processCodeStream(Flux<String> codeStream, CodeGenTypeEnum codeGenType) {
    StringBuilder codeBuilder = new StringBuilder();
    return codeStream.doOnNext(chunk -> {
        // 实时收集代码片段
        codeBuilder.append(chunk);
    }).doOnComplete(() -> {
        // 流式返回完成后保存代码
        try {
            String completeCode = codeBuilder.toString();
            // 使用执行器解析代码
            Object parsedResult = CodeParserExecutor.executeParser(completeCode, codeGenType);
            // 使用执行器保存代码
            File savedDir = CodeFileSaverExecutor.executeSaver(parsedResult, codeGenType);
            log.info("保存成功,路径为:" + savedDir.getAbsolutePath());
        } catch (Exception e) {
            log.error("保存失败: {}", e.getMessage());
        }
    });
}

通过这些优化,我们 AiCodeGeneratorFacade 类中的 统一入口方法 可以变得更加简洁和优雅:

/**
 * 统一入口:根据类型生成并保存代码
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 * @return 保存的目录
 */
public File generateAndSaveCode(String userMessage, CodeGenTypeEnum codeGenTypeEnum) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> {
            HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode(userMessage);
            yield CodeFileSaverExecutor.executeSaver(result, CodeGenTypeEnum.HTML);
        }
        case MULTI_FILE -> {
            MultiFileCodeResult result = aiCodeGeneratorService.generateMultiFileCode(userMessage);
            yield CodeFileSaverExecutor.executeSaver(result, CodeGenTypeEnum.MULTI_FILE);
        }
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

/**
 * 统一入口:根据类型生成并保存代码(流式)
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 */
public Flux<String> generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> {
            Flux<String> codeStream = aiCodeGeneratorService.generateHtmlCodeStream(userMessage);
            yield processCodeStream(codeStream, CodeGenTypeEnum.HTML);
        }
        case MULTI_FILE -> {
            Flux<String> codeStream = aiCodeGeneratorService.generateMultiFileCodeStream(userMessage);
            yield processCodeStream(codeStream, CodeGenTypeEnum.MULTI_FILE);
        }
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

通过上述种种优‍化,代码的可读性和可维护性都⁡得到了显著提升。当我们需要支‎持新的生成类型时,只需要添加‎相应的枚举值和处理逻辑,而不需要修改主要的业务流程。

经过再次测试,所有功能都正常工作:

image-281d7174

总结

通过这一期‍的学习,我们成功地⁡将 AI 大模型集‎成到了后端项目中,‎并且掌握了以下几个关键技能:

  • LangChain4j 框架的实战应用:包括如何配置模型、编写 AI Service、处理结构化输出等核心技能。
  • 提示词工程的实践:学会了如何设计高效的系统提示词,让 AI 生成符合我们要求的代码。
  • 流式处理的技术方案:通过 SSE 提升了用户体验,同时保证了后续处理的完整性。
  • 代码架构的优化思路:运用多种设计模式让代码更加优雅和可维护。

目前,项目已经‍具备了基础的网站应用生成能⁡力,不过只能在本地点击‎执行。下一期我们将实现应用模块‎,包括前后端开发,将 AI 能力真正提供给用户。


项目分区导航:⬅️ 05-Mynotes | 06-AI 生成应用 | ➡️ 07-AI_零代码应用生成平台源码