DeepSeek Harness与IDEA插件集成:打造AI编程助手实战

发布时间:2026/9/7 10:53:04
DeepSeek Harness与IDEA插件集成:打造AI编程助手实战 前阵子一直在折腾一个新的方向把 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 17Gradle 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.kts和src/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() } }type是IC也就是 Community Edition 社区版。虽然插件里会调用一些 Java 语言相关的 API但社区版已经包含了基础 Java 支持完全够用。sinceBuild设成 211表示 IDEA 2021.1 及以上都能装覆盖大多数人的版本。然后是plugin.xml这是插件的入口描述文件。IDEA 靠它知道你的插件有哪些扩展点、依赖哪些模块、提供什么配置项。idea-plugin idcom.example.deepseek.assistant/id nameDeepSeek Assistant/name descriptionA lightweight AI coding assistant for IDEA./description dependscom.intellij.modules.platform/depends dependscom.intellij.java/depends extensions defaultExtensionNscom.intellij toolWindow idDeepSeekHarness anchorright factoryClasscom.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} } }这里有个取舍值得说要不要用 PSIPSIProgram Structure Interface能把源码解析成结构树可以拿到类名、方法名、注解等结构化信息理论上比纯文本更利于模型理解。但 PSI 的读取需要runReadAction遍历结构树也比较耗时在实时对话场景里容易卡顿。我的建议是第一阶段用纯文本把链路跑通后再考虑用 PSI 补充结构化信息比如把当前光标所在的类名和方法名单独摘出来拼进 prompt。循序渐进比一口吃成胖子稳。文件路径这个信息比很多人想象得更重要。模型可以根据路径里的项目名、模块名、包名推断出这个文件在工程里的角色回答能精准不少。我在系统提示词里明确建议模型优先参考用户提供的文件路径和选中代码再结合问题回答避免模型天马行空乱猜。3.3 在编辑器里安全地改代码让模型输出的代码直接替换到编辑器里是整个插件里最香、也最容易出错的功能。直接改文档会在 IDEA 里抛ReadOnlyAction之类的异常因为 IDE 的文档模型是线程约束的任何修改都必须包在WriteCommandAction里。为了避免模型生成的内容解析出错我在工具协议里让模型输出两个字段oldText和newText。插件拿到之后先在当前文档里查找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工具插件用GeneralCommandLineOSProcessHandler在后台跑命令捕获 stdout 和 stderr 后返回给模型模型再根据输出决定下一步操作。这个能力实现起来也很直接核心是用GeneralCommandLine(sh, -c, command)设置好工作目录然后异步读取输出流。4. 把智能体接进来与 DeepSeek Harness 的通信与工具循环前面的工作都是在准备“工具”真正的智能体大脑在 Harness 这边。插件和 Harness 之间是标准的 HTTP 通信走的是 OpenAI 兼容协议。这一节其实才是整个项目的中枢请求消息怎么组织、工具怎么定义、工具调用的循环怎么收尾。4.1 通过本地网关调用 DeepSeekDeepSeek 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 mutableListOfChatMessage() 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更新 UIHarness 返回 404Base URL 配错或 Harness 没启动检查 Harness 监听端口curl 一下/v1/models确认可用模型一直调用工具不停参数描述不清晰或执行结果被模型误解检查工具定义加上更明确的 description限制最大轮数替换文本后 IDE 报只读错误没走WriteCommandAction所有文档修改统一包在WriteCommandAction.runWriteCommandAction里中文乱码编码配置不对JVM 参数加-Dfile.encodingUTF-8日志和控制台统一 UTF-8插件加载报版本冲突sinceBuild / untilBuild 范围不对调整patchPluginXml配置或者移除 untilBuild 限制侧边栏拿不到当前编辑器从 ToolWindow 的 DataContext 取编辑器为 null改用FileEditorManager.getSelectedTextEditor这里面的问题后面几个比较隐蔽比如连续操作时读到旧 PSI不连续操作完全看不出来。大家如果遇到类似情况可以优先检查文档同步和线程这两个方向覆盖面能到八成以上。我个人在实际开发中的一个体会是这类 IDE 集成项目最耗时间的从来不是模型调用而是平台 API 的磨合。IDE 的线程模型、文档模型、事件机制每一项都有自己的一套规则不熟悉的时候会觉得处处受限。我的建议是先做一个最小闭环侧边栏 发送一条消息 显示回答跑通之后再逐步加工具。有了这个闭环你每加一个功能都能立刻验证它和现有链路的兼容性不至于等到最后一次性调试那时候问题会复杂到让你怀疑人生。后面我打算在这个基础上继续加两个方向一是多模型切换通过配置项让用户自由切换不同模型二是基于测试结果自动修复把测试命令的输出反馈给模型让它根据失败原因直接改代码。这类功能一旦跑起来智能体的能力就比单纯聊天强了一个量级也更有意思。