工程项目生成

本节重点

在前面的章节中‍,我们已经实现了对话历史功⁡能,让 AI 能够基于历史‎上下文进行网站的迭代优化。‎但目前平台只支持生成原生网站,在实际使用中有一定局限性。

本节我们将‍挑战一个有点儿难度⁡的功能 —— 生成‎更复杂的前端工程化‎项目,提高本平台的实用性。

主要内容包括:

  • 工程项目生成方案设计
  • 工程项目生成(工具调用流式输出)
  • 工程项目构建和浏览
  • 工程项目部署
  • 前端开发

一、需求分析

前端工程化项目是指使用‍现代化工具链、规范化流程和组件化架构来构建⁡的前端应用。相比传统的 HTML、CSS、‎JavaScript 三件套,它具备模块管‎理、自动化构建、代码分割、热更新等现代开发特性,能够更轻松地开发复杂网站。

现在很多前端工‍程化项目都是使用 Vue ⁡或 React 框架,结合‎ Vite 等打包构建工具‎,再加上 ESLint 之类的代码规范校验库来实现的。

image-c6db36b1

调研其他平‍台,美团 NoCo⁡de 平台支持 R‎eact 工程项目‎:

image-27eb92d8

我们不妨就以 Vue 作为示例,让平台能够生成完整的 Vue3 + Vite 工程项目,并且跟其他两种生成模式一样,实现流式输出、网站浏览和部署。这样一来,我们的平台也能够生成更符合企业标准的项目。

友情提示,‍受限于大模型的能力⁡,有时效果可能没有‎那么理想,但大家重‎点学习思路和方法。

二、方案设计

方案选型分析

通过对一些‍大厂生成平台的调研⁡,我们可以初步想到‎ 3 种复杂工程项‎目的生成方案。

方案 1 - 直接输出 Markdown

这种方案延‍续之前的思路,直接⁡让 AI 在输出的‎ Markdown‎ 中包含代码块,然后通过解析的方式保存文件。

这是我生成的项目:
```vue
App.vue ```

``` json
package.json  ```

优点是实现简‍单、好理解、实时展示效果⁡好。但缺点也很明显:如果‎代码量较大,一次对话可能‎无法完整输出,容易出现代码不全或者解析错误的情况。

方案 2 - 工具调用

给 AI ‍提供保存文件等工具⁡,让 AI 来决定‎什么时候保存文件、‎保存哪些文件、要保存什么代码到文件中。

这种方式的‍基本实现很简单,不⁡需要自己解析 AI‎ 的输出并保存为文‎件,全都交给 AI 和框架来处理。

image-d033c77c

但如果想实时展示工具调用‍信息(比如要保存为文件的代码内容),就很复杂了!⁡你需要解析 AI 响应的工具调用信息。由于流式输‎出的特性,AI 是一点点将 JSON 格式的工具‎调用信息 “吐” 出来的,你很难实时从中提取出有意义的、需要返回给用户的工具调用参数。

举个例子,完整的工具调用信息:

{
  "id": "123",
  "name": "writeFile",
  "arguments": {
    "relativePath": "src/App.vue",
    "content": "网页代码"
  }
}

但实际上,AI 会流式输出上述信息,比如可能是这样输出的:

"
relative
Path
:
"
src
/
App
.vue
"
content
":

也就是说,AI 的‍输出会把保存文件的路径和写入文件的⁡内容拆开。如果想在前端实时展示出这‎些信息,就需要比较复杂的拼接逻辑,‎比如拼接好 relativePath 后,立刻展示出保存文件的路径。

假设这是后‍端实时返回给前端的⁡工具调用信息,你应‎该如何解析和拼接参‎数呢?

data:{"d":"{\\"id\\":\\"call_1_06579b58-4206-4b3e-bd7f-2de949e39783\\",\\"name\\":\\"writeFile\\",\\"arguments\\":\\"{\\\\\\"\\",\\"type\\":\\"tool_request\\"}"}
data:{"d":"relative"}
data:{"d":"FilePath"}
data:{"d":"\\":\\""}
data:{"d":"v"}
data:{"d":"ite"}
data:{"d":".config"}
data:{"d":".js"}
data:{"d":"\\",\\""}
data:{"d":"content"}
data:{"d":"\\":\\""}
data:{"d":"import"}
data:{"d":" {"}
data:{"d":" define"}
data:{"d":"Config"}
data:{"d":" }"}
data:{"d":" from"}

想想就很复杂。。。而且不同工具的调用参数是不一样的!

此外,由于我‍们有对话历史模块,需要⁡尽可能确保实时流式输出‎的内容和保存到数据库中‎的对话历史一致,这就进一步增加了开发成本。

方案 3 - Agent 模式

智能体(Agent)是指能够感知环境、进行推理、制定计划、做出决策并自主采取行动以实现特定目标的 AI 系统。它以大语言模型为核心,集成 记忆、知识库和工具 等能力为一体,构造了完整的决策能力、执行能力和记忆能力,就像一个有主观能动性的人类一样。

简单来说,Agent 的特点是 先规划再执行。比如先制定网站生成计划、然后分别在每个步骤中生成一个文件并解析。

步骤 1:我要生成网站文件 page1.vue、page2.vue、page3.vue
步骤 2:生成 page1.vue,然后保存
步骤 3:生成 page2.vue,然后保存
步骤 4:生成 page3.vue,然后保存
步骤 5:生成网站成功,退出执行

这样做的优点是,每‍一个步骤都非常清晰,在步骤内我可以⁡通过工具调用来实现文件保存,也可以‎单独调用 AI 获取到生成的代码,‎然后再通过程序保存(就像我们之前一样),这样实现流式输出就很简单了。

此外,由于划分了多个步骤,即使某一个步骤出错,也能中断恢复,从而能够处理长逻辑的复杂任务。像鱼皮之前分享过的 Flowith 智能体,我一句话,让它执行了整整一天!

但 Agen‍t 模式的缺点是整体架构⁡更复杂,需要自己设计 A‎gent 流程;而且由于‎需要多次调用 AI,输出结果更加不可控,成本也更高。

💡 在 编程导航的 AI 超级智能体项目 中,鱼皮带大家分析了 OpenManus 智能体的源码,并且复刻了一个 Java 智能体,感兴趣的同学可以学习。


从美团 NoC‍ode 的输出效果来看,很有⁡可能采用了 Agent 模式‎,每一个步骤都可以做到流式输‎出,用户能即时看到生成的代码内容和 AI 的操作情况。

image-aba66b23

刷新页面后‍,你依然能看到上面⁡的内容,跟实时流式‎输出时用户看到的效‎果一样。

这是怎么做到的呢?美团是怎么维护对话历史的呢?

通过分析网‍络请求,我们能获取⁡到后端返回给前端的‎完整对话信息:

image-c849d88d

原来如此,美团自己约定了一些 工具标签,比如 mc-add-dependency 安装依赖、mc-write 保存文件,这样前端就能识别到展示什么内容了。

"我将为您创建一个复古波普艺术风格的电商网页,包含鲜艳的橙色和粉色撞色背景、漫画风格圆点图案和手绘插图,以及波普画框形式的商品展示区。\\n\\nSTEP 1: 安装必要的依赖\\n<mc-code>\\n<mc-add-dependency>framer-motion</mc-add-dependency>\\n</mc-code>\\n\\nSTEP 2: 更新首页为复古波普风格电商页面\\n<mc-code>\\n<mc-write file=\\"src/pages/Index.jsx\\">import { motion } from \\"framer-motion\\";\\nimport { ShoppingBag, Star, Heart } from \\"lucide-react\\";\\n\\nconst Index = () => {\\n  const products = [\\n    {\\n      id: 1,\\n;</mc-write>\\n</mc-code>\\n\\n创建了一个充满活力的复古波普艺术风格电商首页,包含鲜艳的橙色粉色渐变背景、漫画风格圆点图案、波普画框形式的商品展示区以及带有跳跃动画的交互元素。"

最终方案确定

由于 LangC‍hain4j 本身就支持 AI ⁡多次调用工具,相当于已经实现了基‎础的 Agent 多步骤执行能力‎,因此考虑到开发复杂度,我们选择第 2 种方案 - 工具调用。

image-bb3226bb

为了提升用户体验‍,需要为工具调用支持流式输出能⁡力,但是只流式输出工具调用的基‎本信息,让用户能看到 AI 调‎用了哪些工具,避免了复杂的拼接解析逻辑(不利于项目扩展)。

由于不知道要生成多‍少个文件、以及文件的层级组织关系,需⁡要使用工具调用来写入文件。工具调用中‎需要特别注意文件路径的处理。如果不使‎用工具调用,就要输出一大堆的代码块,自己解析,比如下列提示词格式:

## 必须遵守下列输出格式

- 你的整个回复必须是一系列 Markdown 代码块
- 每个代码块代表一个完整的文件
- 每个代码块的第一行必须是注释,用 `[FILE_PATH: ...]` 的格式清晰地标明该文件的路径

文件代码块示例:
格式 vue
// [FILE_PATH: src/components/NewComponent.vue]
然后是完整的代码内容

系统提示词

我们需要定义新‍的生成模式 Vue 工程模⁡式(vue_project‎),这种模式使用 Deep‎Seek 的推理模型,提供的系统提示词也会更复杂。如下:

你是一位资深的 Vue3 前端架构师,精通现代前端工程化开发、组合式 API、组件化设计和企业级应用架构。

你的任务是根据用户提供的项目描述,创建一个完整的、可运行的 Vue3 工程项目

## 核心技术栈

- Vue 3.x(组合式 API)
- Vite
- Vue Router 4.x
- Node.js 18+ 兼容

## 项目结构

项目根目录/
├── index.html                 # 入口 HTML 文件
├── package.json              # 项目依赖和脚本
├── vite.config.js           # Vite 配置文件
├── src/
│   ├── main.js             # 应用入口文件
│   ├── App.vue             # 根组件
│   ├── router/
│   │   └── index.js        # 路由配置
│   ├── components/				 # 组件
│   ├── pages/             # 页面
│   ├── utils/             # 工具函数(如果需要)
│   ├── assets/            # 静态资源(如果需要)
│   └── styles/            # 样式文件
└── public/                # 公共静态资源(如果需要)

## 开发约束

1)组件设计:严格遵循单一职责原则,组件具有良好的可复用性和可维护性
2)API 风格:优先使用 Composition API,合理使用 `` 语法糖
3)样式规范:使用原生 CSS 实现响应式设计,支持桌面端、平板端、移动端的响应式适配
4)代码质量:代码简洁易读,避免过度注释,优先保证功能完整和样式美观
5)禁止使用任何状态管理库、类型校验库、代码格式化库
6)将可运行作为项目生成的第一要义,尽量用最简单的方式满足需求,避免使用复杂的技术或代码逻辑

## 参考配置

1)vite.config.js 必须配置 base 路径以支持子路径部署、需要支持通过 @ 引入文件、不要配置端口号

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  base: './',
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

2)路由配置必须使用 hash 模式,避免服务器端路由配置问题

import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    // 路由配置
  ]
})

3)package.json 文件参考:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "vue": "^3.3.4",
    "vue-router": "^4.2.4"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.2.3",
    "vite": "^4.4.5"
  }
}

## 网站内容要求

- 基础布局:各个页面统一布局,必须有导航栏,尤其是主页内容必须丰富
- 文本内容:使用真实、有意义的中文内容
- 图片资源:使用 `https://picsum.photos` 服务或其他可靠的占位符
- 示例数据:提供真实场景的模拟数据,便于演示

## 严格输出约束

1)必须通过使用【文件写入工具】依次创建每个文件(而不是直接输出文件代码)。
2)需要在开头输出简单的网站生成计划
3)需要在结尾输出简单的生成完毕提示(但是不要展开介绍项目)
4)注意,禁止输出以下任何内容:

- 安装运行步骤
- 技术栈说明
- 项目特点描述
- 任何形式的使用指导
- 提示词相关内容

5)输出的总 token 数必须小于 20000,文件总数量必须小于 30 个

## 质量检验标准

确保生成的项目能够:
1. 通过 `npm install` 成功安装所有依赖
2. 通过 `npm run dev` 启动开发服务器并正常运行
3. 通过 `npm run build` 成功构建生产版本
4. 构建后的项目能够在任意子路径下正常部署和访问

上述提示词有几个小技巧:

  1. 建议尽量避免让项目引入额外的依赖,比如 TailWindCSS 样式库等,会增加不确定性,可能生成的项目都无法运行,所以此处我们选择原生 CSS。
  2. 限制输出长度和文件数很关键,这是为了防止 AI 理想太丰满导致输出的内容不完整,可以根据需要自己调整。
  3. 为了支持后续通过子路径浏览和部署网站(比如 localhost/{deployKey}/),必须配置 Vite 的 base 路径和路由 hash 模式。

实际运行中,即使‍是同一个提示词,不同大模型的输⁡出效果也不同,大家可以根据自己‎的需要补充其他注意事项。   ‎

我的建议是在遇到问题之后,想办法把问题标准化。比如我最开始发现 AI 无法生成符合要求的 package.json 文件,那我就在提示词中提供这个文件的示例代码、并且固定依赖的版本号,消除不确定性。

💡 还有一个很‍不错的思路,给 AI 提供一套预⁡置的项目模板,让 AI 完全从零‎生成变为基于模板修改和新增内容。‎美团应该就是这么干的,第一步竟然是安装依赖包而不是生成文件:

image-aba66b23

完整流程

生成完 Vue 工程代码后,是无法直接运行的,需要执行 npm install 命令安装依赖、执行 npm run build 打包构建,会得到一个打包后的 dist 目录,网站浏览和部署都应该是访问这个目录。

image-93952899

💡 注意,其实很多操‍作我们都可以定义成工具(比如安装依赖和打包构建⁡)。但是这样整个生成过程就充满了不确定因素‎,比如 AI 可能不会调用工具、或者给工具传‎入错误的参数。建议尽量将确定的操作通过编写代码流程化、标准化,减少不可控因素。

三、工程项目生成

配置推理流式模型

生产环境建议选择‍深度思考模型,但由于目前 La⁡ngChain4j 还不支持获‎取 AI 的思考过程,刚开始输‎出会比较慢,所以建议开发调试时还是用普通对话模型,效率更高。

config 包下新建推理流式模型配置类:

@Configuration
@ConfigurationProperties(prefix = "langchain4j.open-ai.chat-model")
@Data
public class ReasoningStreamingChatModelConfig {

    private String baseUrl;

    private String apiKey;

    /**
     * 推理流式模型(用于 Vue 项目生成,带工具调用)
     */
    @Bean
    public StreamingChatModel reasoningStreamingChatModel() {
        // 为了测试方便临时修改
        final String modelName = "deepseek-chat";
        final int maxTokens = 8192;
        // 生产环境使用:
        // final String modelName = "deepseek-reasoner";
        // final int maxTokens = 32768;
        return OpenAiStreamingChatModel.builder()
                .apiKey(apiKey)
                .baseUrl(baseUrl)
                .modelName(modelName)
                .maxTokens(maxTokens)
                .logRequests(true)
                .logResponses(true)
                .build();
    }
}

注意引入大模型的名称不要冲突,将之前引入的流式对话模型改为 openAiStreamingChatModel

public class AiCodeGeneratorServiceFactory {

    @Resource
    private ChatModel chatModel;

    @Resource
    private StreamingChatModel openAiStreamingChatModel;

    @Resource
    private StreamingChatModel reasoningStreamingChatModel;
}

开发写文件工具

按照 LangChain4j 的工具开发方法,新建一个文件写入工具类,编写 writeFile 方法,并给方法打上 @Tool 工具注解。为了减轻工具幻觉(错误调用工具或者传参错误),尽量给工具和每个参数添加描述:

@Slf4j
public class FileWriteTool {

    @Tool("写入文件到指定路径")
    public String writeFile(
            @P("文件的相对路径")
            String relativeFilePath,
            @P("要写入文件的内容")
            String content
    ) {
        // 具体实现
    }
}

由于每个 appId 对应一个生成的网站,因此需要根据 appId 构造文件保存路径,可以利用 LangChain4j 工具调用提供的 上下文传参 能力。在 AI Service 对话方法中加上 memoryId 参数,然后就能在工具中使用 memoryId 了。

ai.tools 包下新建工具,代码如下:

/**
 * 文件写入工具
 * 支持 AI 通过工具调用的方式写入文件
 */
@Slf4j
public class FileWriteTool {

    @Tool("写入文件到指定路径")
    public String writeFile(
            @P("文件的相对路径")
            String relativeFilePath,
            @P("要写入文件的内容")
            String content,
            @ToolMemoryId Long appId
    ) {
        try {
            Path path = Paths.get(relativeFilePath);
            if (!path.isAbsolute()) {
                // 相对路径处理,创建基于 appId 的项目目录
                String projectDirName = "vue_project_" + appId;
                Path projectRoot = Paths.get(AppConstant.CODE_OUTPUT_ROOT_DIR, projectDirName);
                path = projectRoot.resolve(relativeFilePath);
            }
            // 创建父目录(如果不存在)
            Path parentDir = path.getParent();
            if (parentDir != null) {
                Files.createDirectories(parentDir);
            }
            // 写入文件内容
            Files.write(path, content.getBytes(),
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING);
            log.info("成功写入文件: {}", path.toAbsolutePath());
            // 注意要返回相对路径,不能让 AI 把文件绝对路径返回给用户
            return "文件写入成功: " + relativeFilePath;
        } catch (IOException e) {
            String errorMessage = "文件写入失败: " + relativeFilePath + ", 错误: " + e.getMessage();
            log.error(errorMessage, e);
            return errorMessage;
        }
    }
}

注意,工具必须要返回 相对路径,不能让 AI 把文件绝对路径返回给用户,会直接暴露服务器的存储路径!

支持 Vue 项目生成

1)保存提示词到资源目录下,给 AI Service 补充新的流式生成方法,注意参数中必须包含 @MemoryId,支持工具调用时获取到 appId。

/**
 * 生成 Vue 项目代码(流式)
 *
 * @param userMessage 用户消息
 * @return 生成过程的流式响应
 */
@SystemMessage(fromResource = "prompt/codegen-vue-project-system-prompt.txt")
Flux<String> generateVueProjectCodeStream(@MemoryId long appId, @UserMessage String userMessage);

注意工具调用 ‍memoryId 必须也给⁡方法参数补上 memory‎Id,而且有了 memor‎yId 就必须指定 memoryProvider。

2)修改 A‍iCodeGenera⁡torServiceF‎actory 服务构造‎工厂,根据代码生成类型选择不同的模型配置。

记得先给 ‍CodeGenTy⁡peEnum 补充‎新的枚举类型:

VUE_PROJECT("Vue 工程模式", "vue_project");

然后编写工厂代码:

/**
 * 创建新的 AI 服务实例
 */
private AiCodeGeneratorService createAiCodeGeneratorService(long appId, CodeGenTypeEnum codeGenType) {
    // 根据 appId 构建独立的对话记忆
    MessageWindowChatMemory chatMemory = MessageWindowChatMemory
            .builder()
            .id(appId)
            .chatMemoryStore(redisChatMemoryStore)
            .maxMessages(20)
            .build();
    // 从数据库加载历史对话到记忆中
    chatHistoryService.loadChatHistoryToMemory(appId, chatMemory, 20);
    // 根据代码生成类型选择不同的模型配置
    return switch (codeGenType) {
        // Vue 项目生成使用推理模型
        case VUE_PROJECT -> AiServices.builder(AiCodeGeneratorService.class)
                .streamingChatModel(reasoningStreamingChatModel)
                .chatMemoryProvider(memoryId -> chatMemory)
                .tools(new FileWriteTool())
                .hallucinatedToolNameStrategy(toolExecutionRequest -> ToolExecutionResultMessage.from(
                    toolExecutionRequest, "Error: there is no tool called " + toolExecutionRequest.name()
                ))
                .build();
        // HTML 和多文件生成使用默认模型
        case HTML, MULTI_FILE -> AiServices.builder(AiCodeGeneratorService.class)
                .chatModel(chatModel)
                .streamingChatModel(openAiStreamingChatModel)
                .chatMemory(chatMemory)
                .build();
        default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR,
                "不支持的代码生成类型: " + codeGenType.getValue());
    };
}

注意,上述代码中,‍构造 Vue 模式的 AI Serv⁡ice 时,我们必须指定 chatM‎emoryProvider 的配置,‎为每个 memoryId 绑定会话记忆,否则调用对话方法时可能会报错。

此外,我们通过 h‍allucinatedToolNam⁡eStrategy(幻觉工具名称策略‎)配置了找不到工具时的处理策略,可以‎让框架帮我们处理 AI 出现幻觉的情况,比如告诉 AI “找不到工具”

💡 注意‍!这里最好做一些调⁡整,防止 AI 一‎直无限循环调用工具‎,包括:

  1. 调大对话记忆的容量,否则 AI 会中途断片儿,忘记已经生成了哪些文件
  2. 尝试换其他的 AI 大模型
  3. 优化提示词

此外,还有‍其他的方法解决这个⁡问题,在本项目第 ‎11 期《系统优化》‎中有讲。

3)调整获取 A‍I Service 缓存的逻⁡辑。因为现在不同生成模式获取到的 A‎I Service 不同,所以需‎要额外将 codeGenType 作为缓存 key 的构造条件:

/**
 * AI 服务实例缓存
 */
private final Cache<String, AiCodeGeneratorService> serviceCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(Duration.ofMinutes(30))
        .expireAfterAccess(Duration.ofMinutes(10))
        .removalListener((key, value, cause) -> {
            log.debug("AI 服务实例被移除,缓存键: {}, 原因: {}", key, cause);
        })
        .build();

/**
 * 根据 appId 获取服务(带缓存)这个方法是为了兼容历史逻辑
 */
public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
    return getAiCodeGeneratorService(appId, CodeGenTypeEnum.HTML);
}

/**
 * 根据 appId 和代码生成类型获取服务(带缓存)
 */
public AiCodeGeneratorService getAiCodeGeneratorService(long appId, CodeGenTypeEnum codeGenType) {
    String cacheKey = buildCacheKey(appId, codeGenType);
    return serviceCache.get(cacheKey, key -> createAiCodeGeneratorService(appId, codeGenType));
}

/**
 * 构建缓存键
 */
private String buildCacheKey(long appId, CodeGenTypeEnum codeGenType) {
    return appId + "_" + codeGenType.getValue();
}

4)AiCod‍eGeneratorFac⁡ade#generateA‎ndSaveCodeStr‎eam 方法新增 Vue 工程生成的 AI 调用:

// 根据 appId 获取对应的 AI 服务实例
AiCodeGeneratorService aiCodeGeneratorService = aiCodeGeneratorServiceFactory.getAiCodeGeneratorService(appId, codeGenTypeEnum);
return switch (codeGenTypeEnum) {
    case HTML -> {
        Flux<String> codeStream = aiCodeGeneratorService.generateHtmlCodeStream(userMessage);
        yield processCodeStream(codeStream, CodeGenTypeEnum.HTML, appId);
    }
    case MULTI_FILE -> {
        Flux<String> codeStream = aiCodeGeneratorService.generateMultiFileCodeStream(userMessage);
        yield processCodeStream(codeStream, CodeGenTypeEnum.MULTI_FILE, appId);
    }
    case VUE_PROJECT -> {
        Flux<String> codeStream = aiCodeGeneratorService.generateVueProjectCodeStream(appId, userMessage);
        yield processCodeStream(codeStream, CodeGenTypeEnum.MULTI_FILE, appId);
    }
    default -> {
        String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
    }
};

5)编写单元测试:

@Test
void generateVueProjectCodeStream() {
    Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream(
            "简单的任务记录网站,总代码量不超过 200 行",
            CodeGenTypeEnum.VUE_PROJECT, 1L);
    // 阻塞等待所有数据收集完成
    List<String> result = codeStream.collectList().block();
    // 验证结果
    Assertions.assertNotNull(result);
    String completeContent = String.join("", result);
    Assertions.assertNotNull(completeContent);
}

执行单元测试,会发‍现调用工具成功,文件成功保存。可以⁡通过查看 Redis 中的对话记忆‎来分析 AI 的调用过程,发现 A‎I 可以同时调用多个工具(如果用的是推理模型,这种现象会更明显)。

[
  {
    "text": "系统预设",
    "type": "SYSTEM"
  },
  {
    "contents": [
      {
        "text": "简单的任务记录网站,总代码量不超过 200 行",
        "type": "TEXT"
      }
    ],
    "type": "USER"
  },
  {
    "text": "下面我将为您创建一个简单但功能完整的任务记录网站,代码总量控制在200行以内。这个应用包含任务添加、完成状态切换和删除功能,并使用Tailwind CSS实现响应式设计。\\n\\n### 项目文件结构\\n\\n1. **入口HTML文件**\\n2. **Vite配置文件**\\n3. **Tailwind配置**\\n4. **项目依赖**\\n5. **应用入口**\\n6. **路由配置**\\n7. **根组件**\\n8. **主页面**\\n9. **任务组件**\\n10. **状态存储**\\n\\n现在开始逐个创建文件:",
    "toolExecutionRequests": [
      {
        "id": "call_0_ef3e77f7-6392-42e5-aaa2-7e9b3e2fc78e",
        "name": "writeFile",
        "arguments": "{\\"relativeFilePath\\":\\"index.html\\",\\"content\\":\\"<!DOCTYPE html>\\\\n<html lang=\\\\\\"zh-CN\\\\\\">\\\\n<head>\\\\n  \\\\n  \\\\n  <title>任务记录管理器</title>\\\\n</head>\\\\n<body>\\\\n  <div id=\\\\\\"app\\\\\\"></div>\\\\n  \\\\n</body>\\\\n</html>\\"}"
      },
      {
        "id": "call_1_69ac4a83-01b4-4c33-8207-b2fd05c6e337",
        "name": "writeFile",
        "arguments": "{\\"relativeFilePath\\":\\"vite.config.js\\",\\"content\\":\\"import { defineConfig } from 'vite'\\\\nimport vue from '@vitejs/plugin-vue'\\\\n\\\\nexport default defineConfig({\\\\n  plugins: [vue()],\\\\n  server: {\\\\n    port: 3000,\\\\n    open: true\\\\n  }\\\\n})\\"}"
      },
    ]
    "type": "TOOL_EXECUTION_RESULT"
  },
  {
    "text": "我已经为您创建了一个简单但功能完整的任务记录网站,代码总量控制在200行以内。这个应用包含任务添加、完成状态切换和删除功能,并使用Tailwind CSS实现了响应式设计。\\n\\n### 项目结构说明\\n\\n1. **核心文件**:\\n   - `index.html`:应用入口\\n   - `vite.config.js`:Vite构建配置\\n   - `tailwind.config.js`:Tailwind CSS配置\\n   - `package.json`:项目依赖和脚本\\n\\n2. **源代码目录**:\\n 项目启动后,您可以在浏览器中访问 `http://localhost:3000` 查看效果。整个应用简洁高效,完全满足任务记录的基本需求。",
    "toolExecutionRequests": [],
    "type": "AI"
  }
]

工具调用流式输出

虽然文件成‍功写入,但是目前要⁡等好久才会返回‎结果。如何获取到工具调‎用的流式输出呢?

还记得之前我们‍提到 LangChain4⁡j 的另一种流式返回方法 ‎TokenStream 么‎?它提供了更多监听处理流的事件,其中就包括工具执行完成。

TokenStream tokenStream = assistant.chat("Tell me a joke");

tokenStream.onPartialResponse((String partialResponse) -> System.out.println(partialResponse))
    .onRetrieved((List<Content> contents) -> System.out.println(contents))
    .onToolExecuted((ToolExecution toolExecution) -> System.out.println(toolExecution))
    .onCompleteResponse((ChatResponse response) -> System.out.println(response))
    .onError((Throwable error) -> error.printStackTrace())
    .start();

早期解决方案

截止到鱼皮写教程时,LangChain4j 对工具调用流式输出的支持度并不好,没有相应的回调,GitHub 上也有人提出了类似的问题:https://github.com/langchain4j/langchain4j/issues/2790

很快 GitHub 上也提供了解决方案:https://github.com/langchain4j/langchain4j/pull/3303

image-6e302ad9

我的做法是,复制 Issues 的相关源码到项目的 dev.langchain4j 包(跟 LangChain4j 里的包相同路径),如果 src/main/java 中有与依赖 jar 包中相同包名和类名的类,本地类会优先被加载,依赖 jar 中的类会被完全忽略。

image-fd55586f

这样就实现‍了对源码的覆盖和增⁡强,给 Token‎Stream 增加‎了两个回调函数。

image-7a6fe258

之后我就可以通过 onPartialToolExecutionRequest 事件实时获取工具调用的流式输出了:

tokenStream.onPartialResponse((String partialResponse) -> {
    System.out.println("{" + partialResponse + "}");
})
.onPartialToolExecutionRequest((index, toolExecutionRequest) -> {
    System.out.println("{" + toolExecutionRequest + "}");
})
.onToolExecuted((ToolExecution toolExecution) -> {
    System.out.println("{" + toolExecution + "}");
})
.onCompleteResponse((ChatResponse response) -> {
    System.out.println("{" + response + "}");
})
.onError((Throwable error) -> {
    error.printStackTrace();
})
.start();

可以通过 Debug 查看工具调用的实时流式参数:

image-11a75300

工具调用完‍成时,触发 onT⁡oolExecut‎ed 事件,可以获‎取到完整的工具调用参数:

image-6a1d7876

分享这个方‍案也是希望给大家开⁡拓一下思路,学会利‎用开源社区,要相信‎没有什么问题是解决不了的!

新版本解决方案

好消息是,就在鱼‍皮直播讲解这期之前,LangCh⁡ain4j 更新了!官方在 1.‎2 版本支持了工具调用流式输出、‎深度思考事件、准备调用工具事件、调用完工具后的 AI 响应事件。

image-65923533

但是比较难‍受的一点是,Tok⁡enStream ‎只加了 3 个事件‎,并没有工具调用流式输出!

tokenStream
    .onPartialResponse((String partialResponse) -> System.out.println(partialResponse))
    .onPartialThinking((PartialThinking partialThinking) -> System.out.println(partialThinking))
    .beforeToolExecution((BeforeToolExecution beforeToolExecution) -> System.out.println(beforeToolExecution))
    .onRetrieved((List<Content> contents) -> System.out.println(contents))
    .onIntermediateResponse((ChatResponse intermediateResponse) -> System.out.println(intermediateResponse))
    .onToolExecuted((ToolExecution toolExecution) -> System.out.println(toolExecution))
    .onCompleteResponse((ChatResponse response) -> System.out.println(response))
    .onError((Throwable error) -> error.printStackTrace())
    .start();

不过 1.2 版本为 ChatModel 提供了工具流式输出的能力,参考 官方文档

image-5d4c7883

但是,经过我的测‍试,这个版本目前并不稳定。比如⁡工具调用过程中 Partial‎ToolCall 会不断地封装‎流式输出的文本块,但是如果本文块中只有空格字符,就会触发报错:

image-59274c87

我估计这是官方的一个小 Bug 吧,之后的版本应该会修复。

image-ae29b8b4

所以如果升级到‍ 1.2 版本,不仅没办法⁡使用 TokenStrea‎m 的工具流式输出方法,项‎目还可能会出现很多报错,大可不必,可以等稳定一些再说。

因此,我们还是选择早期解决方案,通过覆盖依赖包源码的方式,给 TokenStream 加上流式输出。

大家直接从 我的 GitHub 上获取这部分源码 即可,相信官方会尽快给 TokenStream 加上对应的事件的。

  • 📎dev.zip

👆 资源获取文档:密码 tpkb

语雀显示 404 无权限,请重新再输入一次链接和密码

💡 如果你一‍定要使用 1.2 版本,可⁡以通过覆盖 Partial‎ToolCall 源码的方‎式修复,把校验逻辑去掉就好了,这个思路大家一定要掌握。

public PartialToolCall(Builder builder) {
    this.index = ensureNotNegative(builder.index, "index");
    this.id = builder.id;
    this.name = ensureNotBlank(builder.name, "name");
    this.partialArguments = builder.partialArguments;
}

统一消息格式

之前我们只需要‍给前端返回 AI 的响应信息⁡,但现在还需要返回工具调用信‎息(后续还有可能需要返回深度‎思考信息),因此需要约定一种消息格式,来区分不同的信息类型。

包括:

  • AI 响应消息
  • 工具调用消息
  • 工具调用完成消息

ai.model.message 包下新建 StreamMessage 流式消息基类,并提供几种类型的消息实现子类、以及消息类型枚举类。

image-53734e35

注意封装对象必须有无参构造函数,否则解析 JSON 会失败。

消息基类代码如下:

/**
 * 流式消息响应基类
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StreamMessage {
    private String type;
}

每种具体消‍息类的构造函数中,⁡要给基类传递消息类‎别 type 属性‎:

/**
 * AI 响应消息
 */
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
public class AiResponseMessage extends StreamMessage {

    private String data;

    public AiResponseMessage(String data) {
        super(StreamMessageTypeEnum.AI_RESPONSE.getValue());
        this.data = data;
    }
}
/**
 * 工具调用消息
 */
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
public class ToolRequestMessage extends StreamMessage {

    private String id;

    private String name;

    private String arguments;

    public ToolRequestMessage(ToolExecutionRequest toolExecutionRequest) {
        super(StreamMessageTypeEnum.TOOL_REQUEST.getValue());
        this.id = toolExecutionRequest.id();
        this.name = toolExecutionRequest.name();
        this.arguments = toolExecutionRequest.arguments();
    }
}
/**
 * 工具执行结果消息
 */
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
public class ToolExecutedMessage extends StreamMessage {

    private String id;

    private String name;

    private String arguments;

    private String result;

    public ToolExecutedMessage(ToolExecution toolExecution) {
        super(StreamMessageTypeEnum.TOOL_EXECUTED.getValue());
        this.id = toolExecution.request().id();
        this.name = toolExecution.request().name();
        this.arguments = toolExecution.request().arguments();
        this.result = toolExecution.result();
    }
}

消息枚举类:

/**
 * 流式消息类型枚举
 */
@Getter
public enum StreamMessageTypeEnum {

    AI_RESPONSE("ai_response", "AI响应"),
    TOOL_REQUEST("tool_request", "工具请求"),
    TOOL_EXECUTED("tool_executed", "工具执行结果");

    private final String value;
    private final String text;

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

    /**
     * 根据值获取枚举
     */
    public static StreamMessageTypeEnum getEnumByValue(String value) {
        for (StreamMessageTypeEnum typeEnum : values()) {
            if (typeEnum.getValue().equals(value)) {
                return typeEnum;
            }
        }
        return null;
    }
}

TokenStream 流处理过程

调用 AI ‍对话方法时,我们可以获⁡得 TokenStre‎am 流,接下来应该怎‎么对 TokenStream 进行处理呢?

这就要从需‍求出发了,考虑后端⁡对话记忆要保存什么‎内容?前端需要看到‎什么内容?

1)假设 AI 原始返回的内容是:

AI 响应 {"为你生成代码"}

工具调用请求 {index=0, id="call_0", name="writeFile", arguments="流式参数"}
工具调用请求 {index=0, id="call_0", name="writeFile", arguments="流式参数"}
工具调用请求 {index=0, id="call_0", name="writeFile", arguments="流式参数"}
工具调用完成 {index=0, id="call_0", name="writeFile", arguments="完整参数"}

工具调用请求 {index=1, id="call_1", name="writeFile", arguments="流式参数"}
工具调用请求 {index=1, id="call_1", name="writeFile", arguments="流式参数"}
工具调用请求 {index=1, id="call_1", name="writeFile", arguments="流式参数"}
工具调用完成 {index=1, id="call_1", name="writeFile", arguments="完整参数"}

AI 响应 {"生成代码结束"}

2)接下来,我们要统一封装消息,便于下游处理:

{type="ai_response", data="为你生成代码"}

{type="tool_request", index=0, id="call_0", name="writeFile", arguments="流式参数"}
{type="tool_request", index=0, id="call_0", name="writeFile", arguments="流式参数"}
{type="tool_request", index=0, id="call_0", name="writeFile", arguments="流式参数"}
{type="tool_executed", index=0, id="call_0", name="writeFile", arguments="完整参数"}

{type="tool_request", index=1, id="call_1", name="writeFile", arguments="流式参数"}
{type="tool_request", index=1, id="call_1", name="writeFile", arguments="流式参数"}
{type="tool_request", index=1, id="call_1", name="writeFile", arguments="流式参数"}
{type="tool_executed", index=1, id="call_1", name="writeFile", arguments="完整参数"}

{type="ai_response", data="生成代码结束"}

3)拿到这些信息后‍,后端需要对流进行处理,一方面是按需⁡返回给前端,另外一方面是保存对话记忆‎到数据库中。保存到数据库中的对话记忆‎格式为:

为你生成代码:

[工具调用] 写入文件 src/index.html
```html
写入的代码

[工具调用] 写入文件 src/about.html
```html
写入的代码

生成代码结束!

上述内容可‍以直接通过 Too⁡lExecuted‎Message 工‎具调用完成消息获取到。

4)返回给前端的内容也‍是类似的,只不过为了减少用户等待,首次调用某⁡一个工具时就应该告诉用户 “选择工具” ‎信息。其他的内容跟要保存的对话记忆是一致的,这样‎设计不仅减少了定制开发的成本,用户刷新后看到的内容和实时生成时看到的内容也是一致的。

为你生成代码:

[选择工具] 写入文件
[工具调用] 写入文件 src/index.html
```html
写入的代码 ```

[选择工具] 写入文件
[工具调用] 写入文件 src/about.html
```html
写入的代码 ```

生成代码结束!

整个 AI 流式处理过程图:

image-860de7b6

TokenStream 流适配

了解流处理过程后,我们就可以开发了,这里有一些小技巧。

还记得么,我们目前是通过门面模式统一对外提供 AI 生成服务的,方法的返回值是 Flux 响应流:

public Flux<String> generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum, Long appId) {}

但是 Vu‍e 模式生成的流是 ⁡TokenStrea‎m,跟 Flux 不‎一致呀,那怎么用同一个方法实现 AI 生成呢?

其实很简单‍,如果我们的手机不⁡支持某个类型的充电‎线头,找一个适配器‎不就好了?

没错,我们可以在 AiCodeGeneratorFacade 门面类中编写一个 适配方法,将 TokenStream 转换为 Flux 对象,之后下游可以愉快地对 Flux 进行处理了,也便于返回给前端。

适配方法中,‍需要监听 tokenSt⁡ream 的 AI 响应‎、工具调用、工具调用完成‎等事件,将不同事件封装为不同的消息。代码如下:

/**
 * 将 TokenStream 转换为 Flux<String>,并传递工具调用信息
 *
 * @param tokenStream TokenStream 对象
 * @return Flux<String> 流式响应
 */
private Flux<String> processTokenStream(TokenStream tokenStream) {
    return Flux.create(sink -> {
        tokenStream.onPartialResponse((String partialResponse) -> {
                    AiResponseMessage aiResponseMessage = new AiResponseMessage(partialResponse);
                    sink.next(JSONUtil.toJsonStr(aiResponseMessage));
                })
                .onPartialToolExecutionRequest((index, toolExecutionRequest) -> {
                    ToolRequestMessage toolRequestMessage = new ToolRequestMessage(toolExecutionRequest);
                    sink.next(JSONUtil.toJsonStr(toolRequestMessage));
                })
                .onToolExecuted((ToolExecution toolExecution) -> {
                    ToolExecutedMessage toolExecutedMessage = new ToolExecutedMessage(toolExecution);
                    sink.next(JSONUtil.toJsonStr(toolExecutedMessage));
                })
                .onCompleteResponse((ChatResponse response) -> {
                    sink.complete();
                })
                .onError((Throwable error) -> {
                    error.printStackTrace();
                    sink.error(error);
                })
                .start();
    });
}

💡 由于之前 pr‍ocessCodeStream 方法也是⁡写在门面类里的,所以我才选择将 proc‎essTokenStream 也写在同一‎个地方。其实也可以单独新建一个适配器类,会更符合适配器模式的基本写法。

Flux 流处理器

接下来,我们还要编写下游的 ‍Flux 流处理器,之前我们是在 AppService⁡ 的 chatToGenCode 生成方法内处理了原生‎模式生成的流。现在由于 Vue 生成模式的消息被封装为‎了 JSON 格式消息,所以我们最好针对每类生成模式单独定义一个流处理器,防止逻辑互相影响。

  • 原生文本流处理器(原生模式使用)
  • JSON 消息流处理器(Vue 工程使用)

然后再定义‍一个执行器,根据生⁡成类型调用不同的流处‎理器。(这个操作‎在之前的策略模式、模板方法模式中都用过)

image-09ac4d14

代码结构如下:

image-f909a7b5

1)开发原‍生文本流处理器,其⁡实就是把之前的流处‎理逻辑移到了单独的‎类中:

/**
 * 简单文本流处理器
 * 处理 HTML 和 MULTI_FILE 类型的流式响应
 */
@Slf4j
public class SimpleTextStreamHandler {

    /**
     * 处理传统流(HTML, MULTI_FILE)
     * 直接收集完整的文本响应
     *
     * @param originFlux         原始流
     * @param chatHistoryService 聊天历史服务
     * @param appId              应用ID
     * @param loginUser          登录用户
     * @return 处理后的流
     */
    public Flux<String> handle(Flux<String> originFlux,
                                      ChatHistoryService chatHistoryService,
                                      long appId, User loginUser) {
        StringBuilder aiResponseBuilder = new StringBuilder();
        return originFlux
                .map(chunk -> {
                    // 收集AI响应内容
                    aiResponseBuilder.append(chunk);
                    return chunk;
                })
                .doOnComplete(() -> {
                    // 流式响应完成后,添加AI消息到对话历史
                    String aiResponse = aiResponseBuilder.toString();
                    chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
                })
                .doOnError(error -> {
                    // 如果AI回复失败,也要记录错误消息
                    String errorMessage = "AI回复失败: " + error.getMessage();
                    chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
                });
    }
}

2)开发 ‍JSON 消息流处理⁡器。在原生流处理‎器的基础上增加了 ‎2 个逻辑:

  1. 消息解析:需要根据消息类型,将 JSON 字符串转换为对应的消息对象,然后提取属性进行其他操作(比如返回给前端、或者拼接起来保存到数据库中)
  2. 输出选择工具消息:虽然我们后端实现了工具调用的流式输出,但是考虑到前端不好对这些消息进行解析和处理,因此我们只在 同一个工具第一次输出时,输出给前端 “选择工具” 的消息。可以利用一个集合来判断某个 id 的工具是否为首次输出。

完整代码如‍下:       ⁡         ‎         ‎

/**
 * JSON 消息流处理器
 * 处理 VUE_PROJECT 类型的复杂流式响应,包含工具调用信息
 */
@Slf4j
@Component
public class JsonMessageStreamHandler {

    /**
     * 处理 TokenStream(VUE_PROJECT)
     * 解析 JSON 消息并重组为完整的响应格式
     *
     * @param originFlux         原始流
     * @param chatHistoryService 聊天历史服务
     * @param appId              应用ID
     * @param loginUser          登录用户
     * @return 处理后的流
     */
    public Flux<String> handle(Flux<String> originFlux,
                               ChatHistoryService chatHistoryService,
                               long appId, User loginUser) {
        // 收集数据用于生成后端记忆格式
        StringBuilder chatHistoryStringBuilder = new StringBuilder();
        // 用于跟踪已经见过的工具ID,判断是否是第一次调用
        Set<String> seenToolIds = new HashSet<>();
        return originFlux
                .map(chunk -> {
                    // 解析每个 JSON 消息块
                    return handleJsonMessageChunk(chunk, chatHistoryStringBuilder, seenToolIds);
                })
                .filter(StrUtil::isNotEmpty) // 过滤空字串
                .doOnComplete(() -> {
                    // 流式响应完成后,添加 AI 消息到对话历史
                    String aiResponse = chatHistoryStringBuilder.toString();
                    chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
                })
                .doOnError(error -> {
                    // 如果AI回复失败,也要记录错误消息
                    String errorMessage = "AI回复失败: " + error.getMessage();
                    chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
                });
    }

    /**
     * 解析并收集 TokenStream 数据
     */
    private String handleJsonMessageChunk(String chunk, StringBuilder chatHistoryStringBuilder, Set<String> seenToolIds) {
        // 解析 JSON
        StreamMessage streamMessage = JSONUtil.toBean(chunk, StreamMessage.class);
        StreamMessageTypeEnum typeEnum = StreamMessageTypeEnum.getEnumByValue(streamMessage.getType());
        switch (typeEnum) {
            case AI_RESPONSE -> {
                AiResponseMessage aiMessage = JSONUtil.toBean(chunk, AiResponseMessage.class);
                String data = aiMessage.getData();
                // 直接拼接响应
                chatHistoryStringBuilder.append(data);
                return data;
            }
            case TOOL_REQUEST -> {
                ToolRequestMessage toolRequestMessage = JSONUtil.toBean(chunk, ToolRequestMessage.class);
                String toolId = toolRequestMessage.getId();
                // 检查是否是第一次看到这个工具 ID
                if (toolId != null && !seenToolIds.contains(toolId)) {
                    // 第一次调用这个工具,记录 ID 并完整返回工具信息
                    seenToolIds.add(toolId);
                    return "\\n\\n[选择工具] 写入文件\\n\\n";
                } else {
                    // 不是第一次调用这个工具,直接返回空
                    return "";
                }
            }
            case TOOL_EXECUTED -> {
                ToolExecutedMessage toolExecutedMessage = JSONUtil.toBean(chunk, ToolExecutedMessage.class);
                JSONObject jsonObject = JSONUtil.parseObj(toolExecutedMessage.getArguments());
                String relativeFilePath = jsonObject.getStr("relativeFilePath");
                String suffix = FileUtil.getSuffix(relativeFilePath);
                String content = jsonObject.getStr("content");
                String result = String.format("""
                        [工具调用] 写入文件 %s
                        ```%s
                        %s
                        ```
                        """, relativeFilePath, suffix, content);
                // 输出前端和要持久化的内容
                String output = String.format("\\n\\n%s\\n\\n", result);
                chatHistoryStringBuilder.append(output);
                return output;
            }
            default -> {
                log.error("不支持的消息类型: {}", typeEnum);
                return "";
            }
        }
    }
}

3)开发流处理器执行器,根据生成类别调用不同的流。

/**
 * 流处理器执行器
 * 根据代码生成类型创建合适的流处理器:
 * 1. 传统的 Flux<String> 流(HTML、MULTI_FILE) -> SimpleTextStreamHandler
 * 2. TokenStream 格式的复杂流(VUE_PROJECT) -> JsonMessageStreamHandler
 */
@Slf4j
@Component
public class StreamHandlerExecutor {

    @Resource
    private JsonMessageStreamHandler jsonMessageStreamHandler;

    /**
     * 创建流处理器并处理聊天历史记录
     *
     * @param originFlux         原始流
     * @param chatHistoryService 聊天历史服务
     * @param appId              应用ID
     * @param loginUser          登录用户
     * @param codeGenType        代码生成类型
     * @return 处理后的流
     */
    public Flux<String> doExecute(Flux<String> originFlux,
                                  ChatHistoryService chatHistoryService,
                                  long appId, User loginUser, CodeGenTypeEnum codeGenType) {
        return switch (codeGenType) {
            case VUE_PROJECT -> // 使用注入的组件实例
                    jsonMessageStreamHandler.handle(originFlux, chatHistoryService, appId, loginUser);
            case HTML, MULTI_FILE -> // 简单文本处理器不需要依赖注入
                    new SimpleTextStreamHandler().handle(originFlux, chatHistoryService, appId, loginUser);
        };
    }
}

4)最后,A‍ppService 的 ⁡chatToGenCod‎e 方法可以直接调用执行‎器,自动根据生成类型调用不同的流处理器,简化了代码:

// 5. 通过校验后,添加用户消息到对话历史
chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId());
// 6. 调用 AI 生成代码(流式)
Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId);
// 7. 收集 AI 响应内容并在完成后记录到对话历史
return streamHandlerExecutor.doExecute(codeStream, chatHistoryService, appId, loginUser, codeGenTypeEnum);

测试

临时修改创建 APP 时的生成模式:

// 暂时设置为 VUE 工程生成
app.setCodeGenType(CodeGenTypeEnum.VUE_PROJECT.getValue());

然后在前端和 AI 对话,查看运行效果,流式输出时:

image-21b5aa8c

流式输出完成,刷新页面,查看加载的对话历史:

image-19bb0c04

效果符合预期,恭喜你,这块硬骨头终于啃下来了!

四、工程项目构建和浏览

代码生成后‍,需要安装依赖和打⁡包构建,才能浏览 ‎Vue 项目。

我们在 core.builder 目录下新建一个 VueProjectBuilder,专门用来编写 Vue 项目的构建过程。

1)首先编写一‍个执行任意命令的通用方法,⁡通过 Hutool 的 R‎untimeUtil 结合‎ Java 的 Process 实现命令执行。代码如下:

/**
 * 执行命令
 *
 * @param workingDir     工作目录
 * @param command        命令字符串
 * @param timeoutSeconds 超时时间(秒)
 * @return 是否执行成功
 */
private boolean executeCommand(File workingDir, String command, int timeoutSeconds) {
    try {
        log.info("在目录 {} 中执行命令: {}", workingDir.getAbsolutePath(), command);
        Process process = RuntimeUtil.exec(
                null,
                workingDir,
                command.split("\\\\s+") // 命令分割为数组
        );
        // 等待进程完成,设置超时
        boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
        if (!finished) {
            log.error("命令执行超时({}秒),强制终止进程", timeoutSeconds);
            process.destroyForcibly();
            return false;
        }
        int exitCode = process.exitValue();
        if (exitCode == 0) {
            log.info("命令执行成功: {}", command);
            return true;
        } else {
            log.error("命令执行失败,退出码: {}", exitCode);
            return false;
        }
    } catch (Exception e) {
        log.error("执行命令失败: {}, 错误信息: {}", command, e.getMessage());
        return false;
    }
}

💡 还有一种更简单的写法:

Process installExec = RuntimeUtil.exec(null, directory, "npm", "i");
log.info("dir: {}", directory);
String installResult = RuntimeUtil.getResult(installExec);
log.info("依赖安装结果: {}", installResult);

2)然后分别编写执行安装依赖和执行打包构建命令的方法:

/**
 * 执行 npm install 命令
 */
private boolean executeNpmInstall(File projectDir) {
    log.info("执行 npm install...");
    return executeCommand(projectDir, "npm install", 300); // 5分钟超时
}

/**
 * 执行 npm run build 命令
 */
private boolean executeNpmBuild(File projectDir) {
    log.info("执行 npm run build...");
    return executeCommand(projectDir, "npm run build", 180); // 3分钟超时
}

注意,如果是 Windows 系统,命令需要添加 .cmd

编写操作系统检测方法:

private boolean isWindows() {
   return System.getProperty("os.name").toLowerCase().contains("windows");
}

编写根据操作系统构造命令的方法:

private String buildCommand(String baseCommand) {
   if (isWindows()) {
       return baseCommand + ".cmd";
   }
   return baseCommand;
}

上述方法改为:

/**
 * 执行 npm install 命令
 */
private boolean executeNpmInstall(File projectDir) {
    log.info("执行 npm install...");
    String command = String.format("%s install", buildCommand("npm"));
    return executeCommand(projectDir, command, 300); // 5分钟超时
}

/**
 * 执行 npm run build 命令
 */
private boolean executeNpmBuild(File projectDir) {
    log.info("执行 npm run build...");
    String command = String.format("%s run build", buildCommand("npm"));
    return executeCommand(projectDir, command, 180); // 3分钟超时
}

3)编写构‍建项目的方法,组合⁡执行上述命令,并且‎校验是否构建成功。

注意,尽量不在这个方法中抛出异常,防止影响主业务流程。

/**
 * 构建 Vue 项目
 *
 * @param projectPath 项目根目录路径
 * @return 是否构建成功
 */
public boolean buildProject(String projectPath) {
    File projectDir = new File(projectPath);
    if (!projectDir.exists() || !projectDir.isDirectory()) {
        log.error("项目目录不存在: {}", projectPath);
        return false;
    }
    // 检查 package.json 是否存在
    File packageJson = new File(projectDir, "package.json");
    if (!packageJson.exists()) {
        log.error("package.json 文件不存在: {}", packageJson.getAbsolutePath());
        return false;
    }
    log.info("开始构建 Vue 项目: {}", projectPath);
    // 执行 npm install
    if (!executeNpmInstall(projectDir)) {
        log.error("npm install 执行失败");
        return false;
    }
    // 执行 npm run build
    if (!executeNpmBuild(projectDir)) {
        log.error("npm run build 执行失败");
        return false;
    }
    // 验证 dist 目录是否生成
    File distDir = new File(projectDir, "dist");
    if (!distDir.exists()) {
        log.error("构建完成但 dist 目录未生成: {}", distDir.getAbsolutePath());
        return false;
    }
    log.info("Vue 项目构建成功,dist 目录: {}", distDir.getAbsolutePath());
    return true;
}

4)由于打‍包构建是个耗时操作,⁡为了不阻塞主流程,可‎以使用 Java 2‎1 的虚拟线程特性,在单独的线程中执行构建。

代码如下:

@Slf4j
@Component
public class VueProjectBuilder {

    /**
     * 异步构建项目(不阻塞主流程)
     *
     * @param projectPath 项目路径
     */
    public void buildProjectAsync(String projectPath) {
        // 在单独的线程中执行构建,避免阻塞主流程
        Thread.ofVirtual().name("vue-builder-" + System.currentTimeMillis()).start(() -> {
            try {
                buildProject(projectPath);
            } catch (Exception e) {
                log.error("异步构建 Vue 项目时发生异常: {}", e.getMessage(), e);
            }
        });
    }
}

5)JSON‍ 消息流式处理器 Js⁡onMessageSt‎reamHandler‎ 中,当流式输出完成后,执行 Vue 项目的构建:

@Resource
private VueProjectBuilder vueProjectBuilder;

originFlux
.doOnComplete(() -> {
    // 流式响应完成后,添加 AI 消息到对话历史
    String aiResponse = chatHistoryStringBuilder.toString();
    chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
    // 异步构造 Vue 项目
    String projectPath = AppConstant.CODE_OUTPUT_ROOT_DIR + "/vue_project_" + appId;
    vueProjectBuilder.buildProjectAsync(projectPath);
})

异步构建的优点‍是提高系统的并发量,但缺点是⁡可能会导致前端无法实时浏览到‎ AI 生成的网站,因为不知‎道后端什么时候构建完成,后续章节中会调整这里的逻辑。

五、工程项目部署

如果部署的是 V‍ue 项目,则调用 VuePr⁡ojectBuilder 执行‎安装依赖和打包命令,并且将得到‎的 dist 目录移动到部署目录。其他都能复用已有的部署流程。

修改 AppService 的 deployApp 方法:

// 6. 检查源目录是否存在
File sourceDir = new File(sourceDirPath);
if (!sourceDir.exists() || !sourceDir.isDirectory()) {
    throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用代码不存在,请先生成代码");
}
// 7. Vue 项目特殊处理:执行构建
CodeGenTypeEnum codeGenTypeEnum = CodeGenTypeEnum.getEnumByValue(codeGenType);
if (codeGenTypeEnum == CodeGenTypeEnum.VUE_PROJECT) {
    // Vue 项目需要构建
    boolean buildSuccess = vueProjectBuilder.buildProject(sourceDirPath);
    ThrowUtils.throwIf(!buildSuccess, ErrorCode.SYSTEM_ERROR, "Vue 项目构建失败,请检查代码和依赖");
    // 检查 dist 目录是否存在
    File distDir = new File(sourceDirPath, "dist");
    ThrowUtils.throwIf(!distDir.exists(), ErrorCode.SYSTEM_ERROR, "Vue 项目构建完成但未生成 dist 目录");
    // 将 dist 目录作为部署源
    sourceDir = distDir;
    log.info("Vue 项目构建成功,将部署 dist 目录: {}", distDir.getAbsolutePath());
}
// 8. 复制文件到部署目录
String deployDirPath = AppConstant.CODE_DEPLOY_ROOT_DIR + File.separator + deployKey;

六、前端开发

这次前端项‍目的改动不大。前端⁡如果识别到是 Vu‎e 项目,项目的浏‎览地址需要增加 dist 后缀。

1)补充枚举类型:

/**
 * 代码生成类型枚举
 */
export enum CodeGenTypeEnum {
  HTML = 'html',
  MULTI_FILE = 'multi_file',
  VUE_PROJECT = 'vue_project',
}

/**
 * 代码生成类型配置
 */
export const CODE_GEN_TYPE_CONFIG = {
  [CodeGenTypeEnum.HTML]: {
    label: '原生 HTML 模式',
    value: CodeGenTypeEnum.HTML,
  },
  [CodeGenTypeEnum.MULTI_FILE]: {
    label: '原生多文件模式',
    value: CodeGenTypeEnum.MULTI_FILE,
  },
  [CodeGenTypeEnum.VUE_PROJECT]: {
    label: 'Vue 项目模式',
    value: CodeGenTypeEnum.VUE_PROJECT,
  },
}

2)修改获取网站浏览地址的函数:

// 获取静态资源预览URL
export const getStaticPreviewUrl = (codeGenType: string, appId: string) => {
  const baseUrl = `${STATIC_BASE_URL}/${codeGenType}_${appId}/`
  // 如果是 Vue 项目,浏览地址需要添加 dist 后缀
  if (codeGenType === CodeGenTypeEnum.VUE_PROJECT) {
    return `${baseUrl}dist/index.html`
  }
  return baseUrl
}

最后,完整测试一下生成、浏览和部署的流程。

由于打包构‍建是异步的,所以目前⁡生成后可能无法立即浏‎览,需要刷新页面。不‎过别担心,后面的章节中,我们会解决这个问题。

image-55968353

效果还不错‍,有时可能会出现错⁡误,大家可以自行调‎整提示词或者更换大‎模型来优化:

image-55968353image-b7b0a161image-a51c262f

💡 注意‍!如果你发现 AI⁡ 一直无限循环调用‎工具,可以尝试下面‎的方法:

  1. 调大对话记忆的容量,否则 AI 会中途断片儿,忘记已经生成了哪些文件
  2. 尝试换其他的 AI 大模型
  3. 优化提示词

此外,还有‍其他的方法解决这个⁡问题,在本项目第 ‎11 期《系统优化》‎中有讲。

七、扩展思路

1、为 AI 提供代码模板

前面有提到,‍美团 NoCode 平台⁡应该是使用了预置代码模板‎来提高 AI 生成的效率和‎稳定性。确保项目能正常运行、以及项目结构的完整。

image-8597e7eeimage-db316471

这个实现思路并不难,每次‍调用 AI 之前,先根据用户的提示词找到一套合适⁡的预置代码模板(比如简单的 Vue 工程),然后‎复制到应用生成目录中,把模板信息作为提示词提供给‎ AI,并且提供给 AI 文件读取和修改工具,接下来就让 AI 基于模板二次处理就好。

比如追加这么一段提示词:

## 现有文件内容
{
  "filePath": "package.json",
  "desc": "依赖管理文件",
  "content": "文件主要内容"
},
...

这个思路其‍实有点儿缓存的感觉⁡,如果正好有和预置‎代码模板一样的需求‎,搞不好就直接把预置模板返回了。

2、深度思考

前面提到,LangChain4j 的 v1.2 版本已经支持了 深度思考的流式输出特性,直接监听 TokenStream 的 onPartialThinking 事件就能实现了。

tokenStream
    .onPartialThinking((PartialThinking partialThinking) -> System.out.println(partialThinking))
    .start();

但是要注意:

  1. 有些大模型不支持输出深度思考信息,自然也获取不到
  2. 确保引入了 LangChain4j 以及 LangChain4j 整合大模型的最新依赖,否则可能不生效

这个功能我觉得‍还是挺值得尝试的,大家可以⁡参考本节教程中输出工具调用‎信息的方式,将深度思考作为‎一种消息类型,流式输出给前端,在单独的位置进行展示。


项目分区导航:⬅️ 08-对话历史模块 | 09-工程项目生成 | ➡️ 10-应用模块