DeepSeek Harness与IDEA插件集成:打造AI编程助手实战
2026/9/7 10:52:56 网站建设 项目流程

前阵子一直在折腾一个新的方向:把 DeepSeek Harness 这个智能体框架嵌进 IntelliJ IDEA,做成一个类 Qoder 的 AI 编程插件。目标很简单,就是在 IDE 右侧开一个会话面板,让模型能感知你当前打开的文件、你选中的代码,然后基于这些上下文跟你对话,并且按你的指令直接修改代码、执行命令。整个过程复用 DeepSeek Harness 的智能体编排与工具调用能力,插件侧只负责承接 IDE 事件、收集上下文和操作编辑器。

这篇文章我会从思路到落地完整拆一遍:为什么选用 Harness 而不是直接在插件里裸调 API、插件工程怎么搭、侧边栏和编辑器联动怎么做、工具调用循环如何实现,最后附上我实际踩过的坑和排查记录。内容偏实战,面向准备给 IDEA 做 AI 辅助功能的开发者,或者想研究 Harness 和 IDE 怎么集成的朋友。不管你是从零开始,还是半路出家看插件源码,只要能跑通一个 Hello World 的 Gradle 工程,这篇文章里的东西就能直接拿来用。

1. 先聊清楚:DeepSeek Harness 是什么,插件要做什么

做任何工具之前,先把“它到底解决什么问题”想清楚比写代码重要。这个插件从表面看是“在 IDEA 里加一个 AI 聊天窗口”,但真正要解决的问题,其实是两件事:第一,让模型拿到 IDE 里的实时上下文,而不是靠你复制粘贴代码;第二,让模型的回答能直接作用到工程文件上,减少“它说一段我抄一段”的体力劳动。

1.1 对标 Qoder,我们到底在复刻什么体验

用过 Qoder 这类的 AI 编程插件,会发现它们的核心体验其实很一致:侧边栏对话、代码上下文感知、以 diff 形式回写代码、能跑命令和测试。你不用把代码粘进网页,也不用自己找文件去改,模型自己拆解任务、调用工具、逐步执行。

我们的插件不需要把 Qoder 所有功能抄一遍,那是产品团队干的事情。作为个人开发者或者研究性质的工程,我更关注的是打通一条链路:用户在 IDEA 里提问,Harness 负责和模型交互、决定下一步调用什么工具,插件负责执行这些工具,再把执行结果反馈给模型。这套链路通了,剩下的功能都是往这条管道里加东西。

我最终定的功能范围就四件事:能正常对话、能读取当前文件和选中代码、能替换编辑区文本、能执行终端命令。这四个能力覆盖了日常开发中最常用到的 80% 场景,而且每一样都不算太复杂,适合作为第一个版本的目标。

1.2 方案选型:为什么用 Harness 而不是在插件里裸接 API

最直觉的做法,是在 IDEA 插件里直接调用 DeepSeek 的 API,自己拼 prompt、自己管上下文,再自己处理工具调用的解析。这条路不是走不通,但有个问题:所有智能体逻辑都得在插件里重新实现一遍。对话历史怎么裁剪、工具返回结果怎么回填给模型、多轮工具调用怎么编排,这些不是几行代码能搞定的。

DeepSeek Harness 的价值在于,它把模型调用、密钥管理、上下文组织、工具请求的决策过程都收容在一个本地服务里,对外提供 OpenAI 兼容的 HTTP 接口。插件只需要按标准格式发送消息,收到工具调用请求后执行工具,再把工具结果返回给它,剩下的事情不用操心。

这个取舍很像平时开发里“用框架还是自己造轮子”的选择。Harness 就是那堵墙上的插座,你不用关心电是怎么从发电厂送过来的,只需要把插头插上去。插件侧最复杂的逻辑,从“如何让模型理解我的工具”变成了“如何把 IDE 里的文件操作和选择操作封装成工具”。后者恰恰是插件真正该做的事,也恰恰是通用 AI 框架做不了、只有 IDE 插件能做得好的事。

2. 动手前准备:环境、依赖和工程骨架

这个项目本质上是一个 IntelliJ Platform Plugin,开发方式和普通 Gradle 工程不太一样。IDE 插件最大的特点是它运行在 IDE 进程里,可以调用大量 IDEA 内部 API,所以环境搭配和工程初始化都有一些固定的路数。我第一次搭的时候也踩了几个坑,这里直接把可用的组合列出来。

2.1 版本组合怎么选(IDEA/JDK/Gradle/插件 SDK)

版本这个东西,别追新,也别太老。我用的组合是:IntelliJ IDEA 2024.1 作为开发目标版本,JDK 17,Gradle 8.5 左右,IntelliJ Platform Gradle Plugin 用的是 2.0.x 系列。这个组合的好处是稳定,插件 SDK 2.0 对 Gradle 配置的抽象已经比较成熟,2024.1 的 API 也已经把过去几年废弃的旧接口清理得差不多,写起来不用整天处理 deprecated 问题。

Java 版本上用 17 就够了。IDEA 2024.2 之后对插件的最低运行版本有要求,但我们目标是兼容 2021.1 以上的版本,所以把 JDK 版本锁在 17,配合java插件把 targetCompatibility 设成 17,基本不会出问题。如果你想兼容更老的 IDE,需要额外配setLowerVersion,但我觉得没必要,2021.1 以上的用户群体足够大了,维护新 API 省下的精力比兼容老版本更有价值。

开发语言我用的是 Kotlin。倒不是说 Java 不行,而是 IDEA 插件开发里大量 API 都是 Kotlin 友好的,加上数据类、空安全、协程这些特性,处理 JSON 和回调会舒服很多。如果你对 Kotlin 不熟,用 Java 也可以,核心逻辑不受影响,只是代码会长一些。

运行调试方面,插件 SDK 提供的runIde任务会启动一个独立的 IDEA 沙箱实例,里面自动装好你的插件。这个沙箱和日常开发用的 IDE 互不干扰,断点调试、日志输出都很方便。我在开发过程中几乎没用过“安装插件到正式 IDE 再重启”这种笨办法,全是在沙箱里迭代的。

2.2 初始化插件工程和 plugin.xml 声明

工程初始化我用的是 IntelliJ Platform Plugin Template,直接 clone 后改配置。核心文件就两个:build.gradle.ktssrc/main/resources/META-INF/plugin.xml

plugins { id("java") id("org.jetbrains.kotlin.jvm") version "1.9.24" id("org.jetbrains.intellij") version "2.0.0" } group = "com.example" version = "0.1.0" intellij { version.set("2024.1.0") type.set("IC") plugins.set(listOf("com.intellij.java")) } tasks { patchPluginXml { sinceBuild.set("211") untilBuild.set("") } }

typeIC,也就是 Community Edition 社区版。虽然插件里会调用一些 Java 语言相关的 API,但社区版已经包含了基础 Java 支持,完全够用。sinceBuild设成 211,表示 IDEA 2021.1 及以上都能装,覆盖大多数人的版本。

然后是plugin.xml,这是插件的入口描述文件。IDEA 靠它知道你的插件有哪些扩展点、依赖哪些模块、提供什么配置项。

<idea-plugin> <id>com.example.deepseek.assistant</id> <name>DeepSeek Assistant</name> <description>A lightweight AI coding assistant for IDEA.</description> <depends>com.intellij.modules.platform</depends> <depends>com.intellij.java</depends> <extensions defaultExtensionNs="com.intellij"> <toolWindow id="DeepSeekHarness" anchor="right" factoryClass="com.example.toolwindow.DsToolWindowFactory"/> </extensions> </idea-plugin>

关键就是toolWindow扩展点,声明了侧边栏窗口的位置和工厂类。这也是后面所有 UI 逻辑的容器。整个工程结构分成四块:toolwindow放侧边栏界面,context放代码上下文收集,tools放工具定义和执行,client放和 Harness 通信的 HTTP 客户端。

3. 核心实现:侧边栏、上下文采集与编辑器联动

IDE 插件和普通 Web 应用最大的区别,是它有大量只能在工作线程里访问的 API,比如文档修改必须走写操作,文件系统读取必须在应用线程内。这些规则不清楚,写出来的代码跑起来就会各种崩溃。这一节我把三个关键模块逐一拆开:侧边栏 UI、上下文收集、编辑器写操作。

3.1 搞一个能用的对话窗口

ToolWindow 本质上就是一个 Swing 容器,你可以往里面塞任何 JComponent。我用的方案是JBPanel做根布局,上边一个可滚动的JEditorPane显示对话历史,下边一个JBTextField做输入框,再加一个发送按钮。JBTextField是 IDEA 自带的 Swing 组件,比原生 JTextField 在 IDEA 的 Darcula 主题下更协调,不至于看起来像贴了一块补丁。

布局本身不复杂,但有个很重要的线程问题:ToolWindowFactory.createToolWindowContent是在 EDT(事件分发线程)上执行的,而后面发起网络请求、接收流式返回都是后台线程。更新 UI 必须切回 EDT。我在代码里统一用ApplicationManager.getApplication().invokeLater做界面刷新,避免在 Kotlin 协程里直接改 Swing 组件。

对话框的渲染我用的是 HTML 格式。JEditorPane设成text/html类型,模型返回的 Markdown 先简单转成 HTML,代码块用<pre>包起来。这个方案足够轻量,不用引额外的 Markdown 渲染库。如果你追求更好的展示效果,可以接 JCEF 或者 Markdown 渲染器,但作为 MVP,纯 HTML 转换已经能撑起日常使用。

class DsToolWindowFactory : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { val panel = DsChatPanel(project) val content = ContentFactory.getInstance().createContent(panel, "", false) toolWindow.contentManager.addContent(content) } }

DsChatPanel里维护一个消息列表,每条消息有角色和内容两个字段。发送时把用户输入追加到列表,同时把当前上下文(文件路径、选中代码等)打包进消息,然后启动后台线程去请求 Harness。这个设计不是最优的,但胜在直观,后续要改成消息流式管理,只需要替换数据结构,不影响 UI 层。

3.2 把“当前代码”变成上下文的三个关键动作

模型能不能回答得准,很大程度取决于上下文给得够不够。我实现了三个采集动作:当前打开文件、当前选中文本、项目根目录。这三个信息加起来,已经足够模型理解“用户在干什么活”。

获取当前编辑器用的是FileEditorManager.getInstance(project).selectedTextEditor,注意不要尝试从 ToolWindow 的 DataContext 里拿PlatformDataKeys.EDITOR,在侧边栏场景下这个值大概率是 null,会把你坑到怀疑人生。拿编辑器之后,通过editor.document可以拿到文档对象,再用FileDocumentManager.getInstance().getFile(document)拿到对应的 VFS 文件。

选中文本的获取更直接:editor.selectionModel.selectedText。如果没有选中内容,就返回整个文档的前面部分,比如前 2000 个字符,避免把大文件整个塞给模型导致 token 超限。

fun collectCurrentContext(project: Project): String { val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return "No active editor." val file = FileDocumentManager.getInstance().getFile(editor.document) ?: return "No file." val selection = editor.selectionModel.selectedText ?: "" return if (selection.isNotEmpty()) { "File: ${file.path}\nSelection:\n```\n$selection\n```" } else { "File: ${file.path}\nNo selection, full file length: ${editor.document.textLength}" } }

这里有个取舍值得说:要不要用 PSI?PSI(Program Structure Interface)能把源码解析成结构树,可以拿到类名、方法名、注解等结构化信息,理论上比纯文本更利于模型理解。但 PSI 的读取需要runReadAction,遍历结构树也比较耗时,在实时对话场景里容易卡顿。我的建议是第一阶段用纯文本,把链路跑通后再考虑用 PSI 补充结构化信息,比如把当前光标所在的类名和方法名单独摘出来拼进 prompt。循序渐进,比一口吃成胖子稳。

文件路径这个信息比很多人想象得更重要。模型可以根据路径里的项目名、模块名、包名推断出这个文件在工程里的角色,回答能精准不少。我在系统提示词里明确建议模型优先参考用户提供的文件路径和选中代码,再结合问题回答,避免模型天马行空乱猜。

3.3 在编辑器里安全地改代码

让模型输出的代码直接替换到编辑器里,是整个插件里最香、也最容易出错的功能。直接改文档会在 IDEA 里抛ReadOnlyAction之类的异常,因为 IDE 的文档模型是线程约束的,任何修改都必须包在WriteCommandAction里。

为了避免模型生成的内容解析出错,我在工具协议里让模型输出两个字段:oldTextnewText。插件拿到之后,先在当前文档里查找oldText出现的位置,然后替换成newText。查找用document.text.indexOf(oldText),如果找不到就报错返回给模型,提示它重新输出。

fun replaceInDocument(project: Project, editor: Editor, oldText: String, newText: String): String { val document = editor.document var result = "" WriteCommandAction.runWriteCommandAction(project) { val offset = document.text.indexOf(oldText) if (offset >= 0) { document.replaceString(offset, offset + oldText.length, newText) result = "Replaced at offset $offset" } else { result = "Error: oldText not found" } } return result }

这里要注意几个细节。第一,replaceString之后最好调用Editor的滚动和光标移动,让用户清楚看到哪里被改了。第二,写操作会触发 IDE 的文件变更事件,如果目标文件有 VCS 管理,会有 diff 变化,用户可以在 Git 面板里看到改动,相当于天然的 diff 审查。第三,模型返回的代码块经常自带 Markdown 的包裹,执行替换前必须剥掉。我写了一个简单的工具方法,按行首开头识别并剔除,否则替换进去就是带反引号的脏代码。

把当前打开的终端命令执行也封装成工具,能极大扩展智能体的能力边界。比如用户说“跑一下测试”,Harness 就能调用run_terminal_command工具,插件用GeneralCommandLine+OSProcessHandler在后台跑命令,捕获 stdout 和 stderr 后返回给模型,模型再根据输出决定下一步操作。这个能力实现起来也很直接,核心是用GeneralCommandLine("sh", "-c", command)设置好工作目录,然后异步读取输出流。

4. 把智能体接进来:与 DeepSeek Harness 的通信与工具循环

前面的工作都是在准备“工具”,真正的智能体大脑在 Harness 这边。插件和 Harness 之间是标准的 HTTP 通信,走的是 OpenAI 兼容协议。这一节其实才是整个项目的中枢:请求消息怎么组织、工具怎么定义、工具调用的循环怎么收尾。

4.1 通过本地网关调用 DeepSeek

DeepSeek Harness 启动后会在本地监听一个端口,对外提供/v1/chat/completions接口。插件侧只配置一个 Base URL 和可选的模型名称,密钥完全不需要知道,这算是一个安全性上的好处:大模型 API 的密钥不会以任何形式下发到用户侧,插件只跟本地网关说话。

网络上有个误区,很多人以为插件要内置各种 key,其实不用。Harness 作为本地代理层已经接好了 DeepSeek 的认证,插件这个角色就是一个“客户端”,带着对话历史和工具定义去打 HTTP 请求,拿到响应后渲染给用户。

我用 OkHttp 做 HTTP 客户端。别用HttpURLConnection,流式读取和超时控制在 OkHttp 里都简单得多。连接超时设置成 10 秒,读取超时设置成 60 秒,因为 DeepSeek 模型思考时间可能比较长,尤其带工具调用时,读完一个完整响应可能要十几秒,超时给太短必挂。

val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .build()

请求体的核心是messages数组和tools数组。messages是对话历史,tools是把插件能力暴露给模型的关键。模型不是真的去执行函数,而是根据你的工具定义,输出一个结构化的调用请求,真正执行还得靠插件。这种“模型决策,代码执行”的分工,就是 function calling 的精髓。

4.2 function calling 工具循环的完整实现

工具循环是智能体的心脏。我定义了三个核心工具:read_current_context读取当前文件和选中代码,replace_in_document替换文档文本,run_terminal_command执行终端命令。定义成 JSON 格式,按 OpenAI 的工具规范写清楚每个参数的用途。

{ "type": "function", "function": { "name": "replace_in_document", "description": "Replace text in the current editor document.", "parameters": { "type": "object", "properties": { "oldText": {"type": "string", "description": "The exact text to find."}, "newText": {"type": "string", "description": "The replacement text."} }, "required": ["oldText", "newText"] } } }

完整的一次交互是这样流转的。用户输入“把 main 函数里的 hello 改成 world”,插件带着系统提示词和用户输入请求 Harness。Harness 让思维模型进行规划,返回一个tool_calls数组,里面包含replace_in_document以及参数。插件收到后,执行本地替换,把执行结果当作一条role: "tool"的消息回传,再次请求 Harness。Harness 看到工具执行结果后,继续让模型推理,直到模型认为任务完成,返回最终的自然语言回答。

suspend fun runAgent(project: Project, userInput: String): String { val messages = mutableListOf<ChatMessage>() messages += ChatMessage("system", SYSTEM_PROMPT) messages += ChatMessage("user", userInput) repeat(MAX_TOOL_ROUNDS) { round -> val response = harnessClient.chat(messages, tools) val choice = response.choices.first() val msg = choice.message messages += msg if (msg.toolCalls.isEmpty()) { return msg.content ?: "" } for (call in msg.toolCalls) { val result = executeTool(project, call.function.name, call.function.arguments) messages += ChatMessage("tool", result, toolCallId = call.id) } } return "Max tool rounds exceeded." }

注意几个关键点。第一,tool_call_id必须原样回传,这是 OpenAI 协议的硬性要求,Harness 也是兼容的,回传错了模型会直接报错。第二,工具执行的结果要尽量结构化,比如返回{"success": true, "message": "..."},模型更容易解析。第三,必须设置最大轮数,我用的 8 轮。如果模型一直调用工具不结束,说明它陷入了死循环,得手动截断,不然请求会无限发下去。

4.3 流式输出与用户体验优化

刚开始我用的是非流式请求,一次请求等十几秒才看到完整答案,体验很差。后来改成stream: true,用 OkHttp 逐行读取 SSE 事件,把data:开头的行解析成增量内容,一边读一边往 UI 上追加。用户能看到回答像打字机一样蹦出来,体感明显好很多。

SSE 解析有个坑,数据和[DONE]结束标记混在一次响应里,协议和工具调用的消息也同时存在。第一版建议先用非流式把所有逻辑跑通,确认工具循环没问题之后,再升级到流式。原因很简单:流式 + 工具循环同时调通,报错时你根本分不清是解析问题还是循环逻辑问题。分步推进,每次只引入一个变量,是调试这类集成工程最省时间的策略。

还有个小细节,模型输出经常带着 Markdown 的加粗、代码块、列表,我把这些标记简单清洗后再显示,让侧边栏看起来干净一些。要完整渲染 Markdown 就得引第三方库了,这个看个人需求,我的经验是先用纯文本渲染,界面朴素但是稳定。

5. 踩坑实录:我在开发中遇到的问题与排查

这类东西做下来,真正有价值的东西很大一部分在坑里。IDE 插件的 API 约束比普通应用多得多,很多问题不是代码逻辑错,而是不符合平台规范。这一节我整理了实际开发中最消耗时间的几个问题,以及对应的排查思路。

5.1 工具调用结果回传的坑

第一次实现工具循环时,模型返回了工具调用请求,插件也执行成功了,但回传结果后 Harness 报错,提示消息格式不对。排查了半天,发现是tool_call_id没传对。OpenAI 协议里,工具执行结果必须以role: "tool"消息返回,并且必须带上tool_call_id,这个 id 来自模型的工具调用请求,不是自己生成的。漏了或者传了错误的 id,模型就无法把“工具执行结果”和“它之前的工具调用”关联起来。

还有一个类似的问题:工具执行结果太长。比如run_terminal_command执行一个编译命令,stdout 可能有几千行,全部塞进消息里会导致 token 超限。解决办法是截断,只保留最后 2000 个字符,或者用摘要替代。我在工具执行层统一做了截断处理,超过长度就提示模型“输出过长已截断,如需完整输出请指明”。

5.2 线程与界面卡顿问题

IDE 插件的 UI 操作有严格的线程要求。侧边栏发送按钮点击后,如果直接在 EDT 里发 HTTP 请求,界面会整个卡住,看起来像死机。第一次我没注意,点击发送后 IDEA 无响应了几秒钟,还以为是插件崩溃了。

后来统一改成:点击按钮后,先收集上下文和输入,然后丢到ApplicationManager.getApplication().executeOnPooledThread或者协程的Dispatchers.IO里跑网络请求,拿到结果后再通过invokeLater切回 EDT 更新 UI。这个模式几乎适用于插件里所有耗时操作,包括读取大文件、遍历目录、执行命令。

5.3 文档写入与 PSI 一致性

修改文档时如果用document.setText或者大范围replaceString,代码可能不会立刻反映到 PSI 结构上。如果模型紧接着要操作同一个文件的某个符号,可能会拿到旧数据。问题一般出现在连续两次工具调用时:第一次改了代码,第二次要去读代码,读到的还是旧内容。

解决办法是修改文档后调用一次PsiDocumentManager.getInstance(project).commitAllDocuments(),强制让 PSI 和文档状态同步。注意这个方法在写操作之外调用也会有副作用,最好是放在写操作完成之后、下一次读操作之前。我这个坑踩了挺久,因为 bug 不是必现的,只有连续操作时才出现,排查起来特别费劲。

5.4 常见问题速查表

症状可能原因解决办法
点击发送后无响应网络请求跑在 EDT 上改用后台线程发请求,invokeLater更新 UI
Harness 返回 404Base URL 配错,或 Harness 没启动检查 Harness 监听端口,curl 一下/v1/models确认可用
模型一直调用工具不停参数描述不清晰,或执行结果被模型误解检查工具定义,加上更明确的 description,限制最大轮数
替换文本后 IDE 报只读错误没走WriteCommandAction所有文档修改统一包在WriteCommandAction.runWriteCommandAction
中文乱码编码配置不对JVM 参数加-Dfile.encoding=UTF-8,日志和控制台统一 UTF-8
插件加载报版本冲突sinceBuild / untilBuild 范围不对调整patchPluginXml配置,或者移除 untilBuild 限制
侧边栏拿不到当前编辑器从 ToolWindow 的 DataContext 取编辑器为 null改用FileEditorManager.getSelectedTextEditor

这里面的问题,后面几个比较隐蔽,比如连续操作时读到旧 PSI,不连续操作完全看不出来。大家如果遇到类似情况,可以优先检查文档同步和线程这两个方向,覆盖面能到八成以上。

我个人在实际开发中的一个体会是,这类 IDE 集成项目最耗时间的从来不是模型调用,而是平台 API 的磨合。IDE 的线程模型、文档模型、事件机制每一项都有自己的一套规则,不熟悉的时候会觉得处处受限。我的建议是先做一个最小闭环:侧边栏 + 发送一条消息 + 显示回答,跑通之后再逐步加工具。有了这个闭环,你每加一个功能,都能立刻验证它和现有链路的兼容性,不至于等到最后一次性调试,那时候问题会复杂到让你怀疑人生。

后面我打算在这个基础上继续加两个方向:一是多模型切换,通过配置项让用户自由切换不同模型;二是基于测试结果自动修复,把测试命令的输出反馈给模型,让它根据失败原因直接改代码。这类功能一旦跑起来,智能体的能力就比单纯聊天强了一个量级,也更有意思。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询