基于LangChain4j官方文档-AI零代码生成平台的RAG扩展与Skill集成
AI零代码生成平台的RAG扩展与Skill集成
一、前言
AI零代码生成平台初始版本只实现了"用户输入 → AI 输出代码"的简单流程。随着项目复杂度的提升,遇到了两个核心问题:
-
知识复用问题:每次代码生成都是"从零开始",AI 无法参考历史生成的优质代码。同样的设计模式、组件结构在不同项目中反复生成,质量参差不齐。
-
规范约束问题:代码生成的风格、规范完全依赖系统提示词,但提示词越长,AI 越容易忽略关键约束。需要一种更灵活的机制来按需注入规范。
针对这两个问题,我查阅了 LangChain4j 官方文档,在项目中实现了 RAG(检索增强生成) 和 Skill(技能) 两套扩展机制。
因为 Langchain4j 更新的很快,鱼皮做这个项目的时候好像使用的是1.1.0版本,现在最新版本是1.16.1。我是项目完成后更新到1.15.0后才去做的改造,更新后立马就是大片的红,所以自己扩展的时候请慎重,如果自己改造过程中出现什么问题,很可能就是版本问题,建议自己多去看看官方文档。接下来我在介绍时,也会贴出官方文档对应的网站地址。
下面我主要介绍我项目中 RAG 和 Skill 的实现,其他的改造欢迎查看我的 GitHub 仓库或者去线上地址体验~~~
线上地址:http://www.icodeplay.site/
GitHub:https://github.com/20223309-zhou/ai-code-platform-backend
二、RAG 扩展
2.1 实现目标
在本项目中,RAG 的核心目标不是让 AI"搜索资料",而是让 AI 在生成代码时参考已部署的优质代码块,因为只有构建和部署成功的项目才能保证代码的正确性和可用性。当用户生成"电商网站"时,AI 能从已部署的模板库中检索到风格相似的导航栏、商品卡片等代码片段,作为生成参考,从而保持平台输出的一致性。
2.2 向量嵌入模型选型
官方文档基于本地运行的嵌入模型介绍 在这里 本地嵌入模型完整列表 可在此处找到
LangChain4j提供的本地嵌入模型简单对比:
| 维度 | BgeSmallZhV15 | e5-small-v2 | AllMiniLmL6V2 |
|---|---|---|---|
| 开发者 | 智源(BAAI) | 微软(intfloat) | sentence-transformers |
| 向量维度 | 512 | 384 | 384 |
| 语言侧重 | 中文 | 英文 | 英文(中文较弱) |
| 参数量 | ~37M | ~38M | ~22M |
| MTEB/基准表现 | C-MTEB 优异 | BEIR 49.0 | MTEB 56.3 |
| 最佳场景 | 中文 RAG/语义搜索 | 英文高质量检索 | 原型/边缘部署 |
所以本项目选择了 BAAI/bge-small-zh-v1.5(BgeSmallZhV15) 作为嵌入模型,原因是:
- 中文优化:项目的中文 Prompt、代码注释、模板描述多,bge-small-zh 中英双语效果好
- 维度均衡:512 维,精度高于 MiniLM 的 384 维,又远低于 text-embedding-3-large 的 3072 维,存储和检索效率高
- 本地部署(主要原因):LangChain4j 官方支持 ONNX 格式,模型约 30MB,零成本零延迟,离线可用
2.3 向量数据库选型
一开始我选择的是Milvus,但是Milvus需要使用Docker进行集群部署,相对复杂,所以我去让AI为我进行选型;
AI对比了四种方案:
| 方案 | 结论 |
|---|---|
| Qdrant | ✅ 选用。Docker 单节点部署,LangChain4j 官方提供 QdrantEmbeddingStore,集成代码仅需几行 |
| Milvus | ❌ 太重。适合大规模集群,小项目杀鸡用牛刀 |
| PGvector | ❌ 需要绑定 PostgreSQL,项目未使用 PG |
| Chroma | ❌ Python 生态为主,Java 集成不成熟 |
最终我选择了 Qdrant,余弦距离 + HNSW 索引,当前几千条代码向量检索毫秒级响应。
2.4 代码切分策略
代码切分不能像切散文一样按 token 数机械截断——那会从中间切开一个函数体,检索到的片段毫无意义。我实现了 SplitExecutor 策略模式,通过标签的正则匹配按代码的语义结构切分:
▼text复制代码QdrantDocumentLoader 扫描 _deployed/ 目录 → LanguageTool 识别代码类型(Vue / HTML / 多文件) → SplitExecutor 路由到对应的 Splitter → VueSplitter:按 <template> / <script> / <style> 三大块拆分 → HtmlSplitter:按 <section> 顶层语义容器拆分 → MultiSplitter:保持文件完整性,每个文件为一个单元 → BgeSmallZhV15 嵌入 → Qdrant 存储
切分流程:
▼text复制代码loadDocuments(filePath) │ ▼ splitExecutor.chunk(项目路径, 文件内容) │ ├─ 从路径提取 codeGenType:"multi_file_1" → "multi" │ ├─ switch(codeGenType) │ │ │ ├─ "vue" → VueSplitter.chunk() │ ├─ "html" → HtmlSplitter.chunk() │ └─ "multi" → MultiSplitter.chunk() │ ▼ ┌─────────────────────────────────────────────────────┐ │ splitter.chunk() │ │ │ │ 文件内容 ≤ 200 字符 → 整个文件作为一个 TextSegment │ │ │ │ 文件内容 > 200 字符 → 按正则匹配语义标签拆分 │ │ ┌──────────┬────────────────────┐ │ │ │ Vue │ <template> │ │ │ │ │ <script> │ │ │ │ │ <style> │ │ │ ├──────────┼────────────────────┤ │ │ │ HTML │ <header> │ │ │ │ │ <section.hero> │ │ │ │ │ <footer> │ │ │ ├──────────┼────────────────────┤ │ │ │ Multi │ 整个文件作为一个块 │ │ │ └──────────┴────────────────────┘ │ │ │ │ 无匹配 → 整个文件作为一个 TextSegment │ │ │ └──────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────┐ │ supplementChunk(chunks) │ │ │ │ 对每个 TextSegment的text,调用 AI 模型: │ │ "用一句话描述以下代码的功能(不超过30字):" │ │ │ │ 将AI描述拼接为新的 TextSegment, | | text格式为"描述:代码内容",例如: │ │ "这是一个深蓝风格的导航栏组件:<nav>...</nav>" │ │ │ └──────────────────────────────────────────────────────┘ │ ▼ List<TextSegment> → embeddingModel.embedAll() → Qdrant存储
TextSegment 的组成:
▼text复制代码TextSegment { String text; // "导航栏组件:<nav>...</nav>"(描述:原始代码) Metadata metadata; // 元数据 } Metadata { "file_name" : "D:/tmp/code_output/vue_project_1/src/App.vue" // 文件完整路径 "tag_type" : "template" // template / script / style / header / full "project_type" : "vue" // vue / html / multi }
存储流程:
1、用户点击部署 → 后端调用deployApp() :
▼java复制代码// ① 复制到部署目录(供访问) FileUtil.copyContent(sourceDir, new File(deployDirPath), true); // 在tmp目录下创建一个目录用来复制所有部署后的项目,作为写入Qdrant的语料库 // ② 复制到知识库目录(供检索) String filePath = RAG_LOAD_DIRECTORY_PATH + "/" + codeGenType + "_" + appId; if (!new File(filePath).exists()) { // 只处理首次部署 Thread.startVirtualThread(() -> { // 异步执行,不阻塞部署返回 FileUtil.copyContent(new File(sourceDirPath), new File(filePath), true); qdrantDocumentLoader.loadDocuments(filePath); // 核心:加载到 Qdrant }); }
2、loadDocuments(dirPath) 内部流程
▼text复制代码loadDocuments(RAG_LOAD_DIRECTORY_PATH/multi_file_1) │ ├─ ① getExistingFilePaths() │ └─ 零向量搜索 Qdrant(maxResults=10000, minScore=0.0) │ └─ 提取所有已有文件的 metadata.file_name → Set<String> │ ├─ ② Files.walk(dirPath) 扫描目录 │ ├─ 排除: node_modules / dist / .git │ ├─ 只保留: .vue / .html / .js / .css │ └─ 得到待处理文件列表 │ └─ ③ 逐文件处理 ├─ 检查 metadata.file_name 是否在 Qdrant 中已存在 │ ├─ 已存在 → skip │ └─ 不存在 → │ ├─ splitExecutor.chunk(filePath, content) // 按语义结构切分 │ ├─ embeddingModel.embedAll(segments) // BGE 嵌入(512维) │ └─ embeddingStore.addAll(embeddings, segments) // 存入 Qdrant │ └─ 日志: 新增/跳过 统计
3、关键设计点
| 环节 | 说明 |
|---|---|
| 去重机制 | getExistingFilePaths() 用零向量搜索查出 Qdrant 中所有已有文件的完整路径,新文件才处理。已部署的项目再次部署不会重复索引 |
| 异步执行 | Thread.startVirtualThread(),不阻塞部署接口返回 |
| 目录隔离 | 部署目录 deployKey/ 和知识库目录 codeGenType_appId/ 分离。用户删除部署目录并不会删除知识库目录对应的项目,即使项目重部署,也不会重复导入到Qdrant中 |
| 首次部署 | if (!new File(filePath).exists()) 只检查目录是否存在,不检查内容是否更新。如需强制重新索引需要手动删目录 |
| 增量而非全量 | 永远只添加新文件,不清理已删除的旧文件(旧文件的向量仍留在 Qdrant 中) |
2.5 RAG 执行链路
LangChain4j官方文档的的RAG实现 在这里
上面已经通过标签的语义切分将部署的项目全部存入了Qdrant,那拥有语料后LangChain4j是如何去实现RAG检索的呢?
完整链路:
▼text复制代码┌─────────────────────────────────────────────────────────────┐ │ 用户启用 RAG 的完整检索链路 │ └─────────────────────────────────────────────────────────────┘ 前端 POST /chat/gen/code ?useRag=true │ ▼ ┌─────────────────────────────────┐ │ AppController.chatToGenCode() │ │ RagSwitchHolder.set(true) │ ← 使用ThreadLocal记录用户是否开启了RAG按钮 └──────────────────────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ AiCodeGeneratorFacade │ │ 获取 AiService 实例 │ │ (已注入 retrievalAugmentor) │ └──────────────────────────────────┘ │ ▼ AiServices 处理用户消息 │ ▼ ┌──────────────────────────────────┐ │ DefaultRetrievalAugmentor │ │ 拦截用户消息,准备检索 │ └──────────────────────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ ConditionalContentRetriever │ ← 装饰器 │ RagSwitchHolder.isEnabled() │ ← 从ThreadLocal获取boolean,判断是否需要开启RAG │ ├─ true → 继续检索 │ │ └─ false → 返回空列表,跳过 │ └──────────────────────────────────┘ │ true ▼ ┌──────────────────────────────────┐ │ EmbeddingStoreContentRetriever │ │ │ │ ① query.text() → embeddingModel │ │ BgeSmallZhV15.embed(userMsg) │ │ → 将用户消息转为 512 维向量 │ │ │ │ ② dynamicFilter(query) │ │ 如果 query 含 "vue" → 过滤 │ │ metadata.project_type = vue │ │ │ │ ③ embeddingStore.search() │ │ Qdrant 余弦相似度检索 │ │ maxResults=5, minScore=0.75 │ │ → 返回 Top-5 相似代码块 │ └──────────────────────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ DefaultContentInjector │ │ 将检索结果注入用户消息: │ │ │ │ === 知识库参考 === │ │ 导航栏组件:<nav>...</nav> │ │ 商品卡片:<div>...</div> │ │ ... │ │ === 用户问题 === │ │ 帮我生成一个电商网站 │ │ │ │ → 返回增强后的 UserMessage │ └──────────────────────────────────┘ │ ▼ 发送给 LLM (DeepSeek / Qwen 等) │ ▼ 生成带参考的代码 │ ▼ ┌──────────────────────────────────┐ │ RagSwitchInterceptor │ │ afterCompletion() │ │ RagSwitchHolder.clear() │ ← 清理 ThreadLocal └──────────────────────────────────┘
在这个链路中,LangChain4j 提供了四个关键扩展点:
| 组件 | 类名 | 角色 |
|---|---|---|
| ContentRetriever | EmbeddingStoreContentRetriever | 将用户 query 向量化 → 搜索 Qdrant → 返回匹配的 TextSegment |
| ContentInjector | DefaultContentInjector | 把检索到的代码块按模板拼接到用户消息中 |
| RetrievalAugmentor | DefaultRetrievalAugmentor | 编排整个流程:调用 Retriever → 调用 Injector → 返回增强消息 |
| ContentRetriever(自定义) | ConditionalContentRetriever | 装饰器模式,通过 ThreadLocal 动态开关检索 |
EmbeddingStoreContentRetriever 做了什么?
▼text复制代码// 配置 EmbeddingStoreContentRetriever.builder() .embeddingModel(embeddingModel) // BgeSmallZhV15,将 query 转向量 .embeddingStore(embeddingStore) // Qdrant,执行向量搜索 .dynamicFilter(query -> ...) // 可以根据TextSegment的元数据标签实现动态过滤 .maxResults(5) // 返回 Top-5 .minScore(0.75) // 相似度阈值 .build();
核心逻辑:
▼text复制代码用户消息"帮我生成一个电商网站" → BgeSmallZhV15.embed("帮我生成一个电商网站") → 得到 [0.123, -0.456, ..., 0.789] (512维向量) → Qdrant.search(向量, limit=5, score_threshold=0.75) → 返回 5 个最相似的代码 TextSegment → ContentInjector 按模板格式化后拼入用户消息 → LLM 收到:"参考这段代码的风格,帮我生成..."
三、Skill 集成
3.1 背景
随着视觉规范、组件设计规范、响应式断点规则不断增加,系统提示词越来越长。长提示词的问题:AI 在长文本中容易"迷失",尤其在需要专注特定任务时,无关的约束反而会干扰输出。
3.2 Skill 设计
LangChain4j 提供了 Skills.toolProvider() 机制——每个 Skill 是一个包含 YAML 元信息的 Markdown 文件,注册为 AI 可调用的"技能工具"。AI 在生成过程中根据当前任务自主决定是否激活某个技能。
我的项目共实现了 9 个 Skill,放置在项目根目录的 skills/ 下:
| 技能 | 触发条件 |
|---|---|
| form-validation-patterns | 页面包含表单 |
| table-list-patterns | 需要展示列表/表格 |
| api-call-pattern | 涉及 API 请求 |
| responsive-breakpoints | 响应式布局 |
| design-tokens | 定义颜色/间距/阴影 |
| micro-interactions | 动画/过渡反馈 |
| ui-reference | 用户上传了参考图片或 URL |
| code-audit | 代码生成完成后质量检查 |
| responsive-check | 验证多断点布局表现 |
3.3 集成方式
官方文档的 Skills 集成 看这里
根据官方的推荐,使用工具模式去集成 Skills,需要提前将 Skill 资源加载到内存中去:

每个 SKILL.md 文件结构:
▼yaml复制代码--- name: form-validation-patterns description: 智能表单校验:联动规则、动态表单项、实时反馈 trigger: 当前页面包含表单时 --- ## 表单校验实现规范 ...
提前准备好需要的 Skills ,采用ClassPathSkillLoader去将Skills 文档加载到内存中去:


▼java复制代码@Configuration @Slf4j public class SkillsInitConfig { @Bean public Skills skills() { log.info("初始化 Skills ..."); return Skills.from(ClassPathSkillLoader.loadSkills("skills")); } }
▼java复制代码@Slf4j @Configuration public class AiCodeGeneratorServiceFactory { @Resource private Skills skills; ~~~ AiServices.builder(AiCodeGeneratorService.class) .toolProvider(skills.toolProvider()) // 一行代码注册所有 Skill .chatMemoryProvider(memoryId -> chatMemory) .tools(toolManager.getAllTools()) .build();
AI 在生成代码时,如果判断当前任务触发了某个技能,会主动激活对应的 SKILL.md 获取完整规范,而不是让所有规范完整写在系统提示词里。这种按需加载的方式大幅降低了无效 token 消耗,同时规范的执行率更高。
四、多模态支持
4.1 图片与文本文件上传
对话生成支持上传图片和文本文件作为生成参考:
▼java复制代码List<Content> contents = new ArrayList<>(); // 用户提示词 TextContent textContent = new TextContent(message); contents.add(textContent); // 图片 → ImageContent ImageContent imageContent = new ImageContent(imageUrl); contents.add(imageContent); // 文本文件 → TextContent TextContent textContent = new TextContent("=== 上传的文件内容 ===\n" + fileText); contents.add(textContent); // 构造多模态 UserMessage UserMessage userMessage = UserMessage.from(contents);
- 图片:上传后被存储到腾讯云 COS,返回imageUrl转换成 ImageContent 送入 AI
- 文本文件:支持
.md、.txt、.markdown,读取内容后作为 TextContent 送入 AI - 文件大小限制 5MB,前端通过 MIME 类型和后缀双重校验
4.2 网页检索工具(WebFetchTool)
除了上传本地文件,还提供了 WebFetchTool,AI 可以根据用户提供的 URL 访问目标网站,提取其配色方案、字体、布局结构作为设计参考。
结合 ui-reference Skill,流程如下:
▼text复制代码用户上传参考图片或提供参考网站 URL → 激活 ui-reference Skill → 分析参考素材的配色/字体/布局 → 调用 WebFetchTool 抓取目标网站样式 → 将分析结果融入代码生成
4.3 图片搜索与 Logo 生成
- SearchImageTool:通过 Bing 搜索可商用图片,用于填充网页中的配图
- GenerateLogoSvg:从 Iconify 图标库搜索 SVG 图标拼入 Logo 布局,搜不到时自动降级为首字母圆角方块 Logo
五、其他
5.1 用户体验改进
5.1.1 流式中断 — 从源头停止 AI 输出
官方文档的流式输出取消 看这里
前端的停止按钮调用后端的取消接口:
▼text复制代码前端点击停止 → POST /app/cancel/{appId} → CancelGenerationManager.cancel(appId) → processTokenStream 检测到 isCancelled(appId) == true → context.streamingHandle().cancel() // LangChain4j 从源头截断 TokenStream → sink.complete() // 结束 SSE 流
关键点在于使用 context.streamingHandle().cancel() 而非 sink.complete()——前者真正通知 AI 停止生成,后者只是关闭了前端的连接,后端仍在空转。
5.1.2 页面导航安全
当用户在生成过程中跳转到其他页面时:
- 前端:
onUnmounted中调用cancelAppGeneration接口,同时浏览器自动关闭 EventSource 连接 - 后端:
CancelGenerationManager.remove(appId)清理状态
返回对话页时,组件重新挂载,检查是否有已有对话历史,有则加载、没有则重新发送初始 Prompt——不会自动重复生成。
5.1.3 深度思考流式展示
官方文档支持推理思考过程的流式输出 看这里
使用 DeepSeek 模型的流式推理能力,前端同时展示推理过程(thinking) 和 生成结果(content) 两个区域:
▼javascript复制代码// thinking:展示推理过程 onPartialThinking → SSE 事件 → inner.type === 'thinking' → messages[index].thinking += inner.data // ai_response:展示生成结果 onPartialResponse → SSE 事件 → inner.type === 'ai_response' → messages[index].content += inner.data
两个流独立渲染,互不干扰。用户可以看到 AI 的推理链的同时,逐步看到生成的代码内容。
5.1.4 自动滚动与浮层按钮
▼text复制代码用户位于消息底部 → 新内容自动滚动到底部(autoScrollIfNearBottom) 用户向上翻阅 → 停止自动滚动,右下角出现浮层▼按钮 点击按钮 → 滚动到底部 → 按钮消失
使用 nextTick 确保 DOM 更新完毕后再判断是否在底部,防止内容追加时滚动位置判断错误。
5.2 业务优化
5.2.1 流式输出的演进
三种模式最初都采用流式输出,但能力不同:
- HTML / 多文件模式:最初使用
Flux<String>流式输出代码文本,前端逐字渲染,但不支持工具调用。每次修改只能让 AI 全量重新生成代码,再通过CodeParserExecutor解析代码块 →CodeFileSaverExecutor保存到文件。效率低,无法精准修改。 - Vue 工程模式:从一开始就使用
TokenStream+ 工具调用。AI 通过FileWriteTool逐个写入文件,通过FileReadTool/FileModifyTool完成修改,天然支持精准操作。
我将 HTML 和多文件模式统一升级为 TokenStream,注册了 FileReadTool、FileModifyTool、SearchImageTool、GenerateLogoSvg 等工具,实现了与 Vue 模式一致的工具调用能力。
总结
以上就是我基于官方文档,对AI零代码生成平台的扩展和改造。错误和不足的地方感谢指正。
