拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Vue3+SpringBoot+百炼大模型:复刻Cursor行内补全实现指南

简介面向全栈开发者的一套前后端分离示例工程演示如何用Vue3构建交互界面、SpringBoot提供后端接口并调用阿里云百炼大模型实现类似Cursor的代码提示与生成效果适合对AI辅助编码、大模型API接入感兴趣的初中级Java/前端开发者。资源共42个文件涵盖8个Vue组件、8个JavaScript脚本、6个Java后端类以及HTML页面、JSON配置、CSS样式、字体图标、项目说明等压缩包仅572KB结构轻量便于快速阅读。目前已有831人学习。包内提供2.0版本前后台源码、README说明及多张运行截图包含提问效果展示、代码对比、修改API Key等可视化资料可帮助读者快速定位关键代码掌握从后端封装请求到前端实时展示补全结果的全链路实现方法。 用了几个月 Cursor最上头的往往不是那个 AI 聊天框而是写代码时跟在你后面的灰色提示——你刚敲了一半函数名它已经把后半段参数、整个方法体甚至下一个函数都摆在了那里你只需要按一下 Tab 让代码落下来。这种网页里用 Vue3 SpringBoot 接阿里云百炼大模型复刻一个类 Cursor 补全的想法其实并不是什么遥不可及的事。把它拆开看无非就是编辑器怎么捕获上下文、后端怎么流式转发大模型结果、前端怎么把结果渲染成幽灵文本这几件事。这篇博文我会从零开始把这条链路完整搭建一遍。适合已经会 Vue3 和 SpringBoot 基本用法、想给自家项目塞一个 AI 编程辅助能力但又不想被国外服务绑定、想走国内模型通道的开发者。你不需要了解大模型训练细节只要会写接口、会调组件就能跟完整个实现。1. 动手前先拆解Cursor 的代码提示生成到底在提示什么1.1 一个容易被忽略的事实补全不是聊天很多人一听说 AI 代码提示第一反应是弹个对话窗口像 ChatGPT 那样问一句、答一段。但 Cursor 真正让人上瘾的是另一套交互行内补全inline completion。你在编辑器里打字停顿一下光标后面就直接出现一段灰色文字Tab 确认Esc 取消。它不说话不解释就是补代码。这两者的技术路线差别很大。聊天式交互只需要把问题丢给模型然后把答案渲染成 Markdown 就行而行内补全要求系统做到三件事触发时机怎么判断用户停下来了、想不想被补全、当前代码上下文是否适合补全。呈现方式补全内容是预览而不是真实插入必须以特殊样式叠加在编辑器上不能污染 buffer。确认交互Tab 接受、Esc 拒绝接受后要正确处理光标位置、缩进和撤销栈。我们后面实现的模仿 Cursor 效果重点就是在这三件事上还原体验而不是简单做一个 AI 聊天框 复制粘贴。1.2 整体架构与组件选型整个系统分三层浏览器 (Vue3 Monaco Editor) | | HTTP SSE v SpringBoot 服务端鉴权、限流、上下文拼装、转发 | | OpenAI 兼容协议streamtrue v 阿里云百炼大模型qwen-coder / qwen-max 系列为什么前端要用 Monaco因为它是 VS Code 同款编辑器内核浏览器里能做的编辑器体验它基本都能做装饰器decoration、光标控制、缩进处理、快捷键拦截这些能力是实现幽灵文本的基础。CodeMirror 当然也能做但对熟悉 VS Code 生态的人来说Monaco 的 API 更容易上手后续想扩展跳转定义语法高亮也顺手。后端选 SpringBoot不是为了重而是因为真实场景里你不可能把大模型 API Key 直接塞到前端页面里。任何稍微正规一点的项目都需要一个中间层来保管密钥、做调用频率限制、记录审计日志SpringBoot 在这种事务性工作上比 Node.js 脚本更稳团队里也更好交接。百炼大模型在整个链路里承担的职责很纯粹拿到一段代码上下文返回一段最可能接着往下写的代码。2. SpringBoot 服务端把百炼大模型包成安全的 SSE 流2.1 为什么前端不能直接调用百炼接口有人会问既然百炼提供了 OpenAI 兼容的 HTTP 接口前端 fetch 一下不就行了答案是不行原因有三密钥暴露百炼的 API Key 放在前端等于明文公开别人拿着你的 Key 可以随便刷额度月底账单会教你做人。无法控制上下文前端直接请求上下文拼装逻辑散落在浏览器里出了质量问题没法集中排查和调优。无法限流和审计没有服务端拦截谁的账号在什么时间调用了多少次、传了什么内容你一概不知。所以服务端这一层不是可选项是生产环境的刚需。它做的事情是接收前端发来的代码上下文在后端重新拼接 prompt夹带密钥调用百炼再把流式的 token 转发给前端。2.2 核心依赖与配置项目用 Spring Boot 3.x JDK 17如果你们团队还在 JDK 8建议先升级因为百炼官方 SDK 和 Spring Boot 3.x 都是基于 Jakarta EE 的老版本踩坑成本反而更高。pom.xml 里核心依赖只需要两个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency等一下你可能会问有了 starter-web 为什么还要 webflux因为我们要做 SSE 流式转发阻塞式 Tomcat 线程在大模型流式返回的场景下容易占满线程池。引入 webflux 不是为了全面拥抱响应式而是为了用它的WebClient以流式方式读取上游 HTTP 响应再把数据块转手写到下游 SseEmitter。这是实践中很省事的组合。application.yml 里这样配置ai: dashscope: api-key: ${DASHSCOPE_API_KEY} base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 model: qwen-coder-turboAPI Key 不要写死在配置文件里用环境变量注入。百炼控制台创建的 Key 是有权限范围的建议单独建一个只用于代码补全的 Key别拿主账号的 Key 到处用。2.3 流式补全接口的实现后端只需要暴露一个 POST 接口接收前端的补全请求返回text/event-stream。请求体长这样{ filePath: src/main/java/com/example/UserService.java, language: java, cursorOffset: 1024, contentBefore: public User findUser(String id) {\n return userRepository., contentAfter: \n}\n }接口签名用 SseEmitterRestController RequestMapping(/api/code) CrossOrigin(origins http://localhost:5173) public class CodeCompletionController { private final CompletionService completionService; public CodeCompletionController(CompletionService completionService) { this.completionService completionService; } PostMapping(value /complete, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter complete(RequestBody CompletionRequest request) { SseEmitter emitter new SseEmitter(60_000L); completionService.streamComplete(request, emitter); return emitter; } }用户按 Esc 关闭前端页面时SseEmitter 会抛异常需要在onCompletion和onError里处理结束逻辑避免线程泄漏。核心的 Service 层用 WebClient 调百炼的流式接口。这里的关键点是百炼的 OpenAI 兼容模式直接把messages传过去就行模型选择可以用qwen-coder-turbo代码场景专用或者qwen-max综合能力更强具体看百炼控制台上你对哪些模型有开通权限。简化后的代码大致是这样public void streamComplete(CompletionRequest req, SseEmitter emitter) { MapString, Object body Map.of( model, modelName, stream, true, temperature, 0.2, max_tokens, 512, messages, List.of( Map.of(role, system, content, SYSTEM_PROMPT), Map.of(role, user, content, buildPrompt(req)) ) ); webClient.post() .uri(baseUrl /chat/completions) .header(Authorization, Bearer apiKey) .bodyValue(body) .retrieve() .bodyToFlux(String.class) .doOnNext(chunk - { // 解析 SSE 数据块提取 delta.content String delta parseDelta(chunk); if (StringUtils.hasText(delta)) { emitter.send(delta); } }) .doOnComplete(emitter::complete) .doOnError(emitter::completeWithError) .subscribe(); }注意这里的bodyToFlux(String.class)拿到的每个 chunk 是纯字符串需要自己解析 SSE 协议里data:后面的 JSON。你可以自己用正则或者 Jackson 处理也可以用现成的 SSE 客户端库。这是本项目中第一个看起来简单、实际容易翻车的地方后面第 5 章会细说。3. Vue3 前端在 Monaco Editor 里实现行内幽灵提示3.1 编辑器选型为什么是 Monaco前端编辑器我几乎没有犹豫就选了 Monaco Editor。它不是最简单的编辑器想想 textarea 那确实是最简单的但它是唯一一个在浏览器里能接近原生 IDE 体验的开源编辑器和 VS Code 同源语法高亮、智能提示、缩进规则直接继承deltaDecorations可以做非破坏性的行内装饰适合渲染预览文本executeEdits能控制代码插入和撤销栈官方提供了 Vue 3 的集成方式社区例子很多遇到问题容易搜到。安装方式npm install monaco-editor npm install monaco-editor/vue3 # 如果你想要现成的 Vue 组件封装我建议直接用monaco-editor/vue3它把编辑器的创建、销毁、主题和语言加载都封装好了省去很多样板代码。自定义需求再通过editor实例对象去操作。3.2 触发逻辑与请求竞态处理触发补全的逻辑要克制不能用户每敲一个字符就请求一次。我实测下来比较合理的策略是监听onDidChangeModelContent停笔 800ms 后触发补全如果补全请求已经发出去了用户又继续输入必须取消上一次请求如果当前光标不在代码末尾而是插在中间也要触发补全但上下文组织方式不同第 4 章会讲。竞态处理是这里最容易写崩的点。我的做法是给每个请求发一个递增的requestId后端在 SSE 响应头里原样带回这个 id前端收到结果后先判断是不是最新一次请求不是就直接丢弃渲染。let latestRequestId 0 editor.onDidChangeModelContent(debounce(async () { const requestId latestRequestId const offset editor.getModel().getOffsetAt(editor.getPosition()) const resp await fetch(/api/code/complete, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ language: editor.getModel().getLanguageId(), cursorOffset: offset, contentBefore: contentBefore, contentAfter: contentAfter }) }) for await (const chunk of readSSE(resp)) { if (requestId ! latestRequestId) return appendSuggestion(chunk) } }, 800))这里没有用 axios因为 SSE 流式响应用 fetch 的ReadableStream更好处理。EventSource虽然原生支持 SSE但它只支持 GET 请求传参受限所以放弃。3.3 幽灵文本渲染与 Tab/Esc 交互拿到流式返回的文本后最核心的问题来了怎么把它画在光标后面又不真正插入到代码里Monaco 从某个版本开始提供了injectedText装饰能力我们可以用它把一个不可编辑的文本片段渲染在指定位置。这是实现行内补全的关键 API不同版本写法略有差异但思路一致const decorations editor.createDecorationsCollection() function renderSuggestion(suggestionText) { const position editor.getPosition() const range new monaco.Range( position.lineNumber, position.column, position.lineNumber, position.column ) decorations.set([{ range, options: { // 关键在指定位置注入灰色预览文本 injectedText: suggestionText, inlineClassName: ghost-text, afterContentClassName: ghost-text--after, stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }]) }CSS 里给ghost-text设置灰色和斜体视觉上就和 Cursor 的预览效果基本一致了。然后是 Tab 确认和 Esc 取消。这个交互容易踩坑Monaco 默认把 Tab 绑定为插入制表符和缩进你必须拦截它。在onKeyDown里判断当前有没有活跃的补全editor.addCommand(monaco.KeyCode.Tab, () { if (!currentSuggestion) { // 没有补全时保持默认 Tab 行为 return } // 阻止默认插入 tab editor.trigger(suggestion, tab, {}) // 把补全文本真正插入代码 const position editor.getPosition() editor.executeEdits(ai-completion, [{ range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column), text: currentSuggestion, forceMoveMarkers: true }]) decorations.clear() currentSuggestion }, editor)Esc 就简单了onKeyDown里监听KeyCode.Escape清除装饰器、清空 currentSuggestion 就行。还有两个细节一是补全文本如果包含换行插入后要把光标移到补全内容的末尾而不是停留在行尾二是executeEdits之前最好调用pushUndoStop()这样用户 CtrlZ 可以按一次 AI 补全为单位回退而不是一卡一卡地回退几十个 token。4. 提示词与上下文组织补全质量的决定因素4.1 让模型闭嘴只写代码的提示词模板打开百炼的调试台直接问帮我写一个排序函数模型会回答一长串废话算法思路、时间复杂度的分析、代码块标记、使用注意事项。但代码补全场景里这些全是干扰。所以系统提示词必须立规矩。我用的模板经过多轮调整目前稳定版是你是一个代码补全引擎。你的任务是根据用户提供的代码上下文续写接下来最可能出现的代码。 严格要求 1. 只输出补全的代码本身不要输出任何解释、前后缀说明、Markdown 代码块标记。 2. 保持与上下文一致的缩进、换行和命名风格。 3. 补全要克制预测到当前逻辑自然结束为止不要展开无关的新功能。 4. 如果上下文不足以判断意图只输出最简单合理的备选。这段 prompt 我建议不要随意精简。很多人调不好 AI 补全80% 的原因不是模型不行是模型不知道自己要闭嘴写代码而不是讲一段代码。你可以拿同样一段上下文对比有这段 system prompt 和没有的输出差异效果立竿见影。4.2 光标前后上下文的取舍接下来是上下文怎么拼。最简单粗暴的做法是把整个文件内容发给模型问它接着写。但这样有几个问题大文件可能超过模型上下文窗口费用也高模型会接收到太多与当前光标位置无关的信息反而降低补全准确率如果光标在文件中间模型不知道哪些代码已经写了、哪些不该重复输出。实际操作中我推荐只发送两个片段光标前的代码从光标往前取最近 2000 个字符左右这是补全时最直接的前文光标后的代码从光标往后取最近 500 个字符左右用来告诉模型这些后面的代码已经存在不要重复输出也不要破坏结构。用户在某个函数内部停顿等待补全时这个策略的补全效果最好。光标后代码在 prompt 里的表述要设计一下当前文件光标后已经存在以下代码不要重复输出也不要修改它只补全光标位置缺失的部分 [contentAfter]这样做还有一个好处请求体变小网络传输快费用也更低。毕竟代码补全接口是高频接口用户在代码里每停一次就请求一次省一点是一点。4.3 参数调整temperature、max_tokens、stop同样的上下文参数不同补全风格完全不同。我的默认值是参数推荐值说明temperature0.2值越高越发散代码补全场景不需要创意稳定优先max_tokens512一次补全不要超过 512 token过长补全基本会跑偏top_p0.9配合 temperature 使用也可直接用默认值stop空如果模型老是多输出可以考虑设置停止标记temperature0.2是反复试出来的。用 0 会觉得模型死板甚至重复已有代码用 0.7 以上补全结果经常出现模型自己发挥了一整套方案的情况看着炫实际不敢按下 Tab。0.2 左右的折中在不出错和有点用之间最平衡。max_tokens512也是经过代价权衡的超过这个量补全内容很大概率开始胡编。你要的是补完当前这个函数而不是让模型替你把整个项目写完。如果真的需要更长的生成应该走对话式接口而不是行内补全。5. 从能跑到好用参数调优与踩坑记录5.1 版本选型带来的隐性成本工程上一直有个魔咒新版本一时爽联调火葬场。Spring Boot 版本太高带来的第一个坑就是 JDK 版本要求——3.x 强制 JDK 17而很多团队的老项目还停留在 JDK 8。如果你只是想在现有老项目里加个 AI 补全模块先确认基建是否支持别上来就升 Boot 版本。另一个坑是 SSE 相关依赖在不同版本间的表现差异。Spring Boot 3.1 之前用SseEmitter需要处理一些兼容问题3.2 之后原生的ResponseBodyEmitter对 SSE 的MediaType.TEXT_EVENT_STREAM处理更顺滑。如果你用的是 3.4 或 3.5记得确认你引用的第三方库对 Jakarta EE 10 的支持情况别出现编译过了一启动就报 ClassNotFound这种问题。前端也有类似的坑。Monaco Editor 的版本迭代快injectedText这个 API 在早期版本里是没有的如果你看到options.injectedText不生效先检查版本再查 API 变动记录别怀疑是自己的代码写错了。5.2 实测中常见的五个问题把整个链路跑起来之后我遇到了一堆文档里不会写的问题。挑几个有代表性的问题一模型输出带解释文字现象是补全结果前面突然出现这是一个计算平均值的函数之类的废话。根因就是提示词里只输出代码的约束不够强。除了加强 system prompt还可以在解析端加一道防线对返回文本做一次清理如果开头是或注释符号剥离掉。问题二SSE 流被截断百炼的流式响应在大响应场景下偶尔会在一个data:块里同时包含多条数据或者把一条 JSON 拆成两半。如果前端用JSON.parse直接解析会报错。解决方式是解析时用缓冲区分行处理只解析以data:开头的完整行不完整数据等待下一个 chunk 拼接。问题三补全缩进错乱模型返回的补全文本常常带着自己的缩进习惯而用户当前可能在一个深层嵌套的代码块里。如果不处理插入后代码缩进直接崩。我加了一层后处理检测补全文本第一行的缩进如果和光标所在行不一致按当前行的缩进重新对齐。这个逻辑不复杂但对体验提升极大。问题四补全被用户输入打断后响应又回来了这是个纯粹的前端竞态问题。用户在第 1 秒触发请求第 2 秒又输入了字符第 3 秒第一次请求的响应才缓缓回来——如果这时候把补全渲染到光标处位置是错的而且会吓人一跳。解决方式就是前面说的requestId递增丢弃机制。问题五请求超时大模型流式生成可能需要 10~30 秒fetch 默认没有超时限制反而没问题但如果你用 axios默认超时 0 表示不超时一旦有人手动设置了 5 秒超时稳定报错。建议在代码里明确设置 60 秒的读取超时并配上合理的 UI 提示。5.3 安全、限流与日志审计最后说下安全底线这个绝对不能省。API Key 只存在后端环境变量里前端任何情况下都接触不到接口做限流比如按用户维度每分钟最多调用 20 次。用 Spring Boot 的Bucket4j或者简单的计数器都行反正必须能挡住恶意刷量日志不要打印完整代码内容。代码片段是敏感数据生产环境日志里如果出现大段源码合规检查过不去对生成内容做基本合规过滤百炼本身有安全过滤机制但业务侧最好再叠加一层内容长度限制和关键词拦截避免特殊内容被展示到编辑器里。这些都是不会直接提升 demo 效果但决定项目能不能上线的细节。6. 后续扩展方向与我的实际体会6.1 从单文件补全到项目级上下文目前的实现是单文件内补全它能做到看到你写了一半的方法帮你把剩下的写完。但 Cursor 真正厉害的地方是跨文件感知你在 A 文件里定义的工具函数在 B 文件调用时它能自动补出参数名和返回类型。要往这个方向走就不能只把光标前后文本发给模型了。比较接地气的做法是做一个项目上下文收集器当触发补全时从项目里找出与当前文件相关的几个关键文件比如同目录下、被 import 的文件截取它们的文件头、导出符号、函数签名作为上下文一并发给模型。这一步不需要非常精确模型只要知道有这么个东西存在补全准确率就能再上一个台阶。6.2 划词解释、对话改代码与私有化部署行内补全只是第一步。做完它之后你会发现选中代码按一个快捷键让 AI 解释或重构这个需求会自然而然地出现。它的实现路径和补全不一样走的是对话式接口 右上角浮窗展示但在服务端复用同一套鉴权、限流、审计机制前端复用同一个编辑器实例骨架不变只是交互层多了几个入口。如果业务上有更严格的数据安全要求比如代码不能出内网可以考虑把模型从百炼云端接口切换到私有化部署方案。服务端的接口设计只要遵循 OpenAI 兼容协议切换成本很低——把base-url和鉴权方式换一下其他地方不用动。6.3 我的实际体会这套链路搭完之后我最深的感受是真正决定补全体验的不是模型参数调得多花哨而是那些看不见的工程细节。触发时机克制不克制、竞态处理到不到位、缩进对齐准不准、Tab 按下后光标落在哪——每一个小决定都在影响用户是否愿意按下那个 Tab 键。如果你也想做类似的功能我的建议是先别急着上大而全的方案。把单文件补全做到我本人愿意在日常开发里用它的程度再考虑跨文件、对话、重构这些进阶能力。行内补全这个交互本身已经足够考验工程能力了。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门