DeepSeek Harness上下文管理插件开发实战:从原理到实现

📅 2026/8/27 10:55:50
DeepSeek Harness上下文管理插件开发实战:从原理到实现
1. 为什么需要上下文管理插件1.1 DeepSeek Harness 与上下文的关系先给不熟悉 DSH 的朋友做一个定位DeepSeek Harness后续简称 DSH可以理解为一套面向 DeepSeek 系列模型的工作台它把模型调用、Agent 工具链、会话管理、参数调试集中到一个界面里。开发者可以通过它在本地启动一个类似 IDE 的交互环境也可以把它嵌入到自己的自动化任务中。在使用 DSH 的时候模型并不只是“接收一条问题返回一条答案”。在真正的 Agent 应用中模型需要连续阅读多轮对话、工具调用结果、外部数据片段然后把它们汇总成新的回答。这一整段被喂给模型的内容就是我们常说的上下文Context。上下文的好坏直接决定了 Agent 回答的准确度。如果上下文中混入了过期的归档信息模型就可能被带偏如果上下文被截断得太厉害模型又可能丢失关键线索。所以DHS 的插件体系里上下文管理是一个非常核心的扩展方向。1.2 长对话中的上下文痛点在实际项目中长对话是所有 Agent 应用的噩梦。当用户和 Agent 来回交互了几十轮之后上下文会越来越大主要出现三类问题第一是 Token 超限。模型有上下文窗口上限一次请求塞不下所有历史内容。DSH 在底层会做自动截断但默认截断策略往往比较粗暴可能把中间的关键结论丢掉只保留开头和结尾。第二是无关信息污染。Agent 在运行期间会插入很多过程性信息比如某次临时工具调用的报错、某个中间变量的值。这些信息对当前对话已经没有价值但它们仍然留在上下文里每轮都会消耗 Token并干扰模型的注意力。第三是排查困难。当回答质量下降时你很难知道是模型的哪一段历史上下文出了问题。如果我能在发送请求之前像编辑文件一样把上下文打开、删掉某一段、压缩某一段调试效率会提升很多。1.3 agent-context-editor 能做什么我开发的 agent-context-editor 插件目标就是解决上面三个问题。它提供了一组能力上下文可视化在 DSH 界面中打开一个面板列出当前会话所有上下文片段。手动编辑删除指定片段、改写片段内容、调整片段顺序。上下文压缩对较长的历史轮次执行摘要压缩只保留关键词和结论。长对话自动管理设定阈值之后当上下文超过阈值时自动触发压缩或裁剪。配置持久化插件规则和操作记录会写入本地配置文件重启后仍然生效。下面这篇文章会从原理、开发、安装到排错完整演示这个插件怎么做。2. 环境准备与版本说明2.1 开发环境在开始写插件之前需要准备一套 DSH 插件开发环境。DSH 的插件本质上是一个 Node.js 模块前端交互部分可以走 Web 面板或 VSCode 风格的面板。因此开发环境建议如下项目建议配置操作系统Windows 10/11、macOS 13 或主流 Linux 发行版Node.js18 或 20 的 LTS 版本pnpm8 或 9DSH 官方插件市场常用 pnpm 管理依赖包管理器npm 与 pnpm 均可建议优先 pnpmIDEVSCode 可选因为 DSH 本身可能有桌面端交互界面TypeScript5.0 以上版本需要根据你的项目实际情况调整这里重点演示配置思路不绑定具体版本。如果你拿到的 DSH 最新版本已经改了插件 API请以官方 release notes 为准。2.2 项目目录设计一个完整的 DSH 上下文管理插件目录结构可以这样组织agent-context-editor/ ├── package.json ├── tsconfig.json ├── src/ │ ├── index.ts # 插件入口 │ ├── context-store.ts # 上下文存储与读取 │ ├── context-parser.ts # 上下文切分与解析 │ ├── context-compressor.ts # 上下文压缩逻辑 │ ├── context-editor-panel.ts# 编辑器面板 │ └── config-manager.ts # 配置管理 ├── dist/ # 编译产物 ├── README.md └── .gitignore把功能拆成独立模块后续维护会轻松很多。很多人写插件习惯把所有逻辑堆在 index.ts 里前期确实快但等到要加压缩策略、要处理多个会话时代码会迅速失控。2.3 需要的依赖package.json是插件的核心描述文件。一个最小可用的 package.json 如下{ name: agent-context-editor, version: 0.1.0, description: DeepSeek Harness 上下文管理插件, main: dist/index.js, type: module, scripts: { build: tsc -p tsconfig.json, dev: tsc -w }, dependencies: { dsh/sdk: ^0.1.0 }, devDependencies: { types/node: ^20.0.0, typescript: ^5.0.0 } }这里要注意几点插件必须声明main字段DSH 插件加载器会通过它找到插件入口依赖里只需要保留 DSH SDK具体的 AI 模型调用由 Harness 主进程完成开发依赖里的 TypeScript 版本不要太旧否则部分语法会解析失败。dsh/sdk的具体版本号需要根据你本地的 DSH 安装版本来匹配。如果 SDK 和主程序版本相差太大可能出现插件加载成功、但接口调用报错的情况。判断方法很简单先在 DSH 的插件管理页面查看主程序版本再打开插件市场的适配列表选择同主版本的 SDK。3. 插件核心原理DSH 与上下文的关系3.1 上下文在 Agent 会话中的流转在 DSH 中一次 Agent 会话由多个消息事件组成。用户输入、工具返回、模型输出都会变成消息这些消息会先落进一个内部缓冲区然后在每次发起模型请求之前被组装成最终的 Prompt。这个组装过程通常包括三步把消息列表按时间排序、按 Token 预算截断、把系统指令插入到最前面。DSH 的默认行为可以满足简单场景但遇到复杂任务时开发者往往需要更细粒度的干预。agent-context-editor 的介入点就在“消息列表组装成 Prompt 之前”。插件通过 DSH 暴露的 Hook 机制拿到完整的消息列表用户可以查看、编辑或压缩这些消息再放回流程中。3.2 插件如何介入上下文生命周期为了方便理解我把 DSH 插件的生命周期分成三个阶段注册阶段插件被加载时DSH 调用activate方法插件在这里完成命令注册、配置读取和面板初始化。拦截阶段每次 Agent 发起请求前DSH 发出before.build.prompt事件插件可以在这个事件中修改上下文。清理阶段会话结束后插件可以监听结束事件把压缩后的上下文摘要写回存储方便后续继续使用。这三个阶段对应插件里的三段代码入口函数、Hook 回调、清理回调。3.3 数据模型设计上下文管理插件的数据模型需要围绕“片段”来设计。这里说的片段是指一条或多条连续消息的组合。为什么要用片段而不是单条消息因为一次工具调用的输出可能包含几十行日志它们作为一个整体才有意义拆开了反而不利于压缩和删除。我设计了三个核心类型export interface ContextChunk { id: string; role: system | user | assistant | tool; content: string; tokenCount: number; timestamp: number; metadata?: Recordstring, any; } export interface SessionContext { sessionId: string; chunks: ContextChunk[]; totalTokens: number; updatedAt: number; } export interface CompressRule { maxTokens: number; reserveSystem: boolean; compressRole: string[]; action: delete | summarize | truncate; }ContextChunk描述一条最小的上下文单元SessionContext描述一个会话的整体上下文CompressRule则是用户配置的压缩规则。这样的数据结构既简单也足够灵活后续要增加片段级标签、片段来源追踪都不难。4. 实战agent-context-editor 的实现4.1 初始化插件项目首先创建一个目录并初始化项目mkdir agent-context-editor cd agent-context-editor npm init -y pnpm install如果你还没有安装 pnpm可以通过 npm 全局安装npm install -g pnpm安装完依赖后创建tsconfig.json{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, outDir: dist, rootDir: src, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src] }4.2 插件入口与注册逻辑接下来写插件入口文件src/index.ts。这个文件负责注册命令和 Hookimport { registerPlugin, PluginContext } from dsh/sdk; import { ContextEditorPanel } from ./context-editor-panel; import { ContextCompressor } from ./context-compressor; export function activate(ctx: PluginContext) { const panel new ContextEditorPanel(ctx); const compressor new ContextCompressor(ctx); // 注册“打开上下文编辑器”命令 ctx.registerCommand(context.editor.open, async () { await panel.open(); }); // 注册“立即压缩当前会话”命令 ctx.registerCommand(context.editor.compress, async () { const session await ctx.getCurrentSession(); await compressor.compress(session.id, session.chunks); ctx.notifyEditor(上下文已压缩); }); // 在构建 Prompt 之前介入 ctx.registerHook(before.build.prompt, async (payload) { const rule ctx.getConfig(compressRule); if (!rule) { return payload; } const compressor new ContextCompressor(ctx); const processed await compressor.autoProcess(payload.sessionId, payload.messages, rule); payload.messages processed; return payload; }); }这里的registerHook(before.build.prompt, ...)是核心。它返回的 payload 如果被修改DSH 主进程会使用修改后的消息列表去构建 Prompt。如果插件没有注册该 Hook流程就会走 DSH 默认上下文处理逻辑。4.3 上下文查看面板上下文编辑器面板是插件的主要交互入口用户需要在这里看到当前会话的所有片段。面板代码位于src/context-editor-panel.tsimport { PluginContext } from dsh/sdk; import { SessionContext, ContextChunk } from ./context-store; export class ContextEditorPanel { constructor(private ctx: PluginContext) {} async open(): Promisevoid { const session await this.ctx.getCurrentSession(); const webview await this.ctx.createWebviewPanel(contextEditor, 上下文编辑器, { enableScripts: true, }); webview.html this.renderHtml(session); webview.onMessage(async (message) { if (message.type deleteChunk) { await this.ctx.deleteChunk(message.chunkId); webview.html this.renderHtml(await this.ctx.getCurrentSession()); } if (message.type editChunk) { await this.ctx.updateChunk(message.chunkId, message.content); webview.html this.renderHtml(await this.ctx.getCurrentSession()); } }); } private renderHtml(session: SessionContext): string { const rows session.chunks .map( (chunk: ContextChunk) div classchunk>import { ContextChunk } from ./context-store; export class ContextParser { parse(messages: any[], maxChunkTokens 2000): ContextChunk[] { const chunks: ContextChunk[] []; for (const msg of messages) { const tokenCount this.estimateTokens(msg.content || ); if (tokenCount maxChunkTokens) { chunks.push({ id: msg.id || chunk-${Date.now()}-${Math.random().toString(16).slice(2)}, role: msg.role, content: msg.content, tokenCount, timestamp: msg.timestamp || Date.now(), }); } else { // 超长内容拆分成多个片段 const subContents this.splitByTokens(msg.content, maxChunkTokens); subContents.forEach((sub, index) { chunks.push({ id: ${msg.id || chunk}-${index}, role: msg.role, content: sub.text, tokenCount: sub.tokens, timestamp: msg.timestamp || Date.now(), }); }); } } return chunks; } estimateTokens(text: string): number { // 中文按 1.5 个字符估算 1 token英文按 3.5 个字符估算 1 token const chineseCount (text.match(/[\u4e00-\u9fa5]/g) || []).length; const englishCount text.length - chineseCount; return Math.ceil(chineseCount * 1.5 englishCount / 3.5); } splitByTokens(text: string, maxTokens: number) { const parts: { text: string; tokens: number }[] []; let current ; let currentTokens 0; for (const char of text) { const t char.match(/[\u4e00-\u9fa5]/) ? 1.5 : 1 / 3.5; if (currentTokens t maxTokens current) { parts.push({ text: current, tokens: Math.ceil(currentTokens) }); current ; currentTokens 0; } current char; currentTokens t; } if (current) { parts.push({ text: current, tokens: Math.ceil(currentTokens) }); } return parts; } }Token 统计在生产环境建议用 DSH SDK 自带的 Tokenizer 做准确计算千万不要用我这种字符估算方式去决定截断边界否则会出现模型 Token 计数不一致的情况。上面这段代码更多是演示“切分逻辑”和“核心思路”如果你要接入正式环境请替换成官方 Tokenizer。4.5 上下文压缩与长对话管理上下文压缩是这类插件最重要的功能。压缩策略可以有很多种我这里实现一个基础的“摘要替换”策略把超过阈值的旧消息压缩成一段摘要然后放到上下文最前面。import { PluginContext } from dsh/sdk; import { ContextChunk, CompressRule } from ./context-store; export class ContextCompressor { constructor(private ctx: PluginContext) {} async compress(sessionId: string, chunks: ContextChunk[], rule?: CompressRule) { const defaultRule: CompressRule { maxTokens: 4000, reserveSystem: true, compressRole: [user, assistant], action: summarize }; const finalRule rule || this.ctx.getConfig(compressRule) || defaultRule; const systemChunks finalRule.reserveSystem ? chunks.filter(c c.role system) : []; const compressible chunks.filter(c !systemChunks.includes(c)); const targetChunks compressible.filter(c finalRule.compressRole.includes(c.role)); const targetTotalTokens targetChunks.reduce((sum, c) sum c.tokenCount, 0); const otherTotalTokens compressible .filter(c !targetChunks.includes(c)) .reduce((sum, c) sum c.tokenCount, 0); if (otherTotalTokens targetTotalTokens finalRule.maxTokens) { return chunks; } if (finalRule.action summarize) { const summary await this.summarizeChunks(targetChunks); const keptChunks compressible.filter(c !targetChunks.includes(c)); const summaryChunk: ContextChunk { id: summary-${Date.now()}, role: system, content: summary, tokenCount: await this.ctx.countTokens(summary), timestamp: Date.now(), metadata: { type: summary } }; return [...systemChunks, summaryChunk, ...keptChunks]; } if (finalRule.action delete) { const keptChunks compressible.filter(c !targetChunks.includes(c)); return [...systemChunks, ...keptChunks]; } return chunks; } async autoProcess(sessionId: string, messages: any[], rule: CompressRule) { const parser await import(./context-parser); const chunks parser.ContextParser ? new parser.ContextParser().parse(messages) : []; const processed await this.compress(sessionId, chunks, rule); return processed.map(c ({ role: c.role, content: c.content, timestamp: c.timestamp })); } private async summarizeChunks(chunks: ContextChunk[]): Promisestring { const textToSummarize chunks.map(c [${c.role}]\n${c.content}).join(\n\n); const result await this.ctx.invokeModel({ model: deepseek-chat, messages: [ { role: system, content: 请将以下对话内容压缩为一段 300 字以内的摘要保留关键结论、用户需求和待办事项。 }, { role: user, content: textToSummarize } ], temperature: 0.2 }); return result.content; } }这段代码的核心逻辑很直观先计算当前上下文总 Token 是否超限如果超限就挑选可压缩的角色类型把目标片段交给模型生成摘要然后把摘要以system角色放回上下文最前面再拼接没有被压缩的片段。这里有一个细节要注意生成摘要本身也要消耗模型调用所以不要在每次请求前都执行。建议配置触发阈值比如总 Token 超过 8000 才自动压缩低于 8000 直接跳过。4.6 配置管理为了让插件支持不同场景我加了一个config-manager.ts用来读写插件配置import { PluginContext } from dsh/sdk; export class ConfigManager { private configKey agent-context-editor; constructor(private ctx: PluginContext) {} async getConfig(): Promiseany { return this.ctx.getConfig(this.configKey) || {}; } async setConfig(config: any): Promisevoid { const current await this.getConfig(); await this.ctx.setConfig(this.configKey, { ...current, ...config }); } async registerDefaultRules(): Promisevoid { const config await this.getConfig(); if (!config.compressRule) { await this.setConfig({ compressRule: { maxTokens: 6000, reserveSystem: true, compressRole: [user, assistant], action: summarize }, autoCompress: true, compressThreshold: 8000 }); } } }所有配置都持久化到 DSH 的配置中心用户可以在插件设置页面里直接修改。这是一个很好的习惯插件功能可以默认开箱即用但高级参数要交给用户控制。5. 安装到 DeepSeek Harness 的完整流程5.1 构建插件在插件开发目录中执行构建命令pnpm install pnpm build构建完成后dist/index.js会生成。此时需要检查dist目录下是否有编译产物如果用的是 TypeScript确认tsconfig.json中outDir配置正确。5.2 放入 DSH 插件目录DSH 支持本地插件目录加载方式。你可以把插件目录复制到 DSH 配置目录下的plugins/agent-context-editor/位置具体路径根据 DSH 的安装位置决定。常见有两种方式通过 DSH 设置页面的“添加本地插件”按钮选择package.json所在目录。手动复制插件目录到 DSH 扫描的 plugins 目录。复制完成后确认目录结构如下plugins/ └── agent-context-editor/ ├── package.json ├── dist/ │ └── index.js └── README.md5.3 重启与验证重启 DSH 后打开插件管理页面确认 agent-context-editor 出现在“已启用插件”列表中。接下来验证插件是否正常工作打开一个长对话会话。在命令面板或插件菜单中运行context.editor.open。观察上下文编辑器面板是否显示出当前会话所有片段。修改某个片段并保存然后发起一轮新请求确认 DSH 使用的新上下文已生效。如果面板没有正常显示可以先查看 DSH 运行日志确认插件加载阶段是否有报错。最常见的错误是Cannot find module dsh/sdk这是因为插件目录没有安装依赖需要把插件目录单独执行一次pnpm install。6. 常见问题与排查思路在实际使用和测试过程中我遇到过不少问题这里整理成一张表格方便大家直接对号排查问题现象常见原因解决思路插件加载失败依赖未安装或 SDK 版本不匹配在插件目录执行pnpm install并检查 SDK 版本面板无法打开插件入口文件路径错误检查 package.json 的main字段是否正确指向 dist/index.js修改上下文后没有生效Hook 未注册或注册顺序不对检查before.build.prompt的注册位置确认返回值为修改后的 payload上下文总 Token 计算偏差大用估算方法而不是官方 Tokenizer替换为 DSH SDK 提供的 Token 计数接口自动压缩没有触发压缩阈值配置过高检查 config 中maxTokens和compressThreshold的数值关系构建报错 TypeScript 版本冲突全局 TypeScript 与本地版本不一致项目内统一使用本地依赖的 TypeScript 命令pnpm dsh web卡住网络下载依赖慢或依赖源不稳定检查 pnpm 镜像配置使用官方镜像或合适镜像源避免反复强制中断插件引起 DSH 主进程崩溃同步阻塞操作导致事件循环卡死所有 IO 和模型调用必须使用异步方式关于pnpm dsh web卡住的问题我再多说一句。DSH 在初始化 Web 界面时依赖下载量比较大如果你所在的网络环境对某些 registry 不稳定会出现长时间停在pnpm dsh web的情况。建议先检查 pnpm 的 registry 配置再考虑缓存策略不要频繁强制结束进程因为反复中断还可能导致本地缓存损坏。如果你在本地环境无法顺利下载 DSH 的 Web 依赖也可以试试 DSH 的桌面端或预构建包不同发布渠道的安装方式差异较大选择最稳定的安装渠道即可。7. 最佳实践与工程建议7.1 数据安全与权限边界上下文管理插件本质上会读写 Agent 的完整会话内容。这些内容往往包含用户隐私、业务数据、密钥片段。在开发插件时需要明确几条安全边界最小权限原则插件只读取自己需要的会话字段不主动采样无关数据。本地处理优先尽量在本地完成切片和修改不把完整上下文上传到第三方服务。摘要调用需谨慎压缩摘要会调用模型接口生产环境要确认模型服务的数据使用协议。操作审计删除上下文前最好做一个备份至少要把被删除片段写入历史文件夹。7.2 性能优化上下文管理是高频操作性能影响会直接投射到 Agent 响应速度上。建议遵循以下原则Token 统计要做缓存不要每次请求都重新遍历全部消息。自动压缩只在超过阈值时触发避免每轮都调用压缩模型。面板渲染使用虚拟滚动当片段数量达到几百条时一次性渲染全部 DOM 会很卡。所有模型调用限制并发避免压缩多个会话时打满模型接口。7.3 面向实际项目的使用建议agent-context-editor 虽然是我为 DeepSeek Harness 写的插件但它的设计思路可以复用到任何 Agent 项目中。核心是不要把上下文当作黑盒给开发者一个可以查看、编辑、压缩上下文的入口。在实际业务中我更推荐将压缩策略与业务场景绑定。比如客服场景建议保留最新的 10 轮用户消息把更早的对话压缩成“用户意图摘要”在代码生成场景建议保留工具调用结果压缩对话寒暄内容。没有一种压缩策略能适配所有场景插件化最大的价值就是让这些策略可以快速迭代而不是写死在框架里。另外DSH 插件市场正在快速丰富越来越多开发者开始做工具类、代码类、上下文类插件。如果后续你想发布自己的插件建议提前规划好命令命名空间和配置字段避免和第三方插件冲突。同时也要注意随着 DSH 版本升级部分 Hook 名称可能发生迁移升级前先读官方迁移说明再更新插件代码。8. 总结与后续规划这篇文章从核心概念、环境准备、源码实现、安装配置到排查思路完整介绍了 DeepSeek Harness 上下文管理插件 agent-context-editor 的诞生过程。它的核心价值在于把 Agent 的上下文从黑盒变成透明状态让开发者能主动干预长对话而不是被动接受默认截断。后续我计划继续完善几个方向支持基于规则的上下文标签自动归类、提供更细粒度的压缩预览、增加会话内关键字检索以及把上下文修改日志做成一键复盘视图。如果你也在使用 DSH 并且遇到了类似的长对话问题可以先从上下文面板看起搞清楚模型到底读了什么很多玄学问题都会变得清晰。如果你按照本文的思路成功跑通了自己的上下文管理插件欢迎在评论区分享你的实际效果和改进方案。