ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

给 DeepSeek 装双眼睛:纯文本模型在 opencode 里看懂截图的折腾实录

给 DeepSeek 装双眼睛:纯文本模型在 opencode 里看懂截图的折腾实录 给 DeepSeek 装双眼睛纯文本模型在 opencode 里看懂截图的折腾实录我用 opencode 当日常的主力 AI 编辑器已经有一阵子了。这玩意儿生态不错插件机制、各种 hook 一应俱全用起来很顺手。唯一的痛点是我默认挂在背后的模型是 deepseek-v4-flash一个纯文本模型。纯文本模型意味着什么意味着我没法直接把一张截图甩给它说帮我看看这个报错。但日常干活哪有不截图的时候——GCP 控制台报错、BigQuery 查询结果、某个同事发来的架构图全都得靠截图沟通。于是这个需求就变成了怎么让一个不吃图片的模型也能看懂图片思路找个翻译官而不是换模型换个大模型当然最省事但成本高而且没必要——我的文本模型已经够用缺的只是一个图片翻译环节。所以方案是绕道走用户贴图 → 视觉模型把图片翻译成文字描述 → 文字描述喂给主模型 → 主模型读描述来回答翻译官我选了阿里的 DashScope用qwen3-vl-flash这个视觉模型。便宜、响应快关键是 DashScope 有 OpenAI 兼容的接口代码写起来几乎零成本。opencode 提供了插件机制可以在消息进入 LLM 之前做手脚。于是计划很清晰写一个插件拦截用户消息里的图片调 DashScope 分析把结果转成文字替换掉原图。听起来很简单对吧我当时的想法也是一下午就能搞定。结果这一下下午变成了两天。踩了三个坑每一个都值得单独拿出来说。坑一插件目录名单数和复数不是一个世界第一个版本写得很快chat.messagehook、getApiKey读环境变量、调 DashScope、替换 part一气呵成。然后重启 opencode贴图等回复。……没反应。一点动静都没有好像插件根本不存在。查日志、查配置、查文档折腾半天最后在 opencode 官方文档里看到了这样一句话自动加载的插件目录只有两个项目级.opencode/plugins/和全局级~/.config/opencode/plugins/。注意是plugins复数。我把插件放到了~/.config/opencode/plugin/单数。opencode 压根没扫这个目录。就这么一个字母的差别我的插件连被加载的资格都没有。改目录重启插件终于活了。教训插件机制这种东西目录名一定以官方文档为准别凭感觉猜。坑二非 async 函数里用 awaitJS 直接当场去世目录改对了插件加载了但启动就报错。看日志SyntaxError。定位到getApiKey这个函数functiongetApiKey():string|undefined{if(process.env.QWEN_API_KEY||process.env.DASHSCOPE_API_KEY){returnprocess.env.QWEN_API_KEY||process.env.DASHSCOPE_API_KEY;}constfsawaitimport(node:fs);// ← 这里// ...}我原本想偷懒用动态import去读.env.local文件但写的时候函数忘了加async。JS 的规则是await只能出现在 async 函数里。非 async 函数里写await直接 SyntaxError整个文件加载失败。修复也简单把node:fs、node:os、node:path挪到文件顶部静态导入getApiKey恢复成普通同步函数。教训写完代码先node --experimental-strip-types --check过一遍语法别急着跑。这种低级错误浪费了我大半个晚上。坑三消息丢了——其实是 hook 把管道堵死了到这里插件总算能用了但主人反馈了一个更诡异的现象贴图发消息第一次发不出去重发才成功。而且不是偶发是每次都这样。我一开始以为是消息真的丢了翻日志、查数据库把消息时间线全捞出来对。结果发现了一个惊人的规律——每条图片消息在数据库里都有两三条几乎一模一样的记录时间只差十几秒时间内容有没有回复20:07:36[Image] 解析一下截图❌ 没回复20:07:52[Image] 解析一下截图重发✅ 有回复20:08:42[Image] 里面是什么别乱说❌ 没回复20:08:48[Image] 里面是什么别乱说重发✅ 有回复消息根本没丢是第一次发送时整个界面卡住了。原因在于chat.message这个 hook 的工作方式它是同步等待的。我贴的截图一般比较大DashScope 分析一张大图要 10~20 秒。在这十几秒里opencode 的消息处理管道就卡在那儿等 hook 返回界面表现为发送中却毫无反应。主人以为没发出去重发一次第二条消息才触发正常的处理流程。说白了是假丢失——hook 同步阻塞 视觉模型响应慢 看起来像消息没了。双保险超时熔断 换 hook解决思路有两个我最后两个都上了第一个给 DashScope 调用加超时熔断。用AbortController设置 60 秒超时。分析超时就快速降级返回一段占位文本绝不让网络请求无限期阻塞消息管道constcontrollernewAbortController();consttimersetTimeout(()controller.abort(),60_000);try{constrespawaitfetch(${BASE_URL}/chat/completions,{method:POST,headers:{Content-Type:application/json,Authorization:Bearer${apiKey}},body:JSON.stringify({model:qwen3-vl-flash,messages:[...]}),signal:controller.signal,});// ...}finally{clearTimeout(timer);}第二个把 hook 从chat.message换成experimental.chat.messages.transform。这个 hook 的语义完全不同它是在消息即将发给 LLM 之前才执行转换。也就是说消息的入库、显示、发送流程完全不受影响图片分析发生在回复生成前这一步。就算分析超时降级消息本身也已经在对话里躺得好好的了绝不丢experimental.chat.messages.transform:async(_input,output){for(constmsgofoutput.messages){if(msg?.partsArray.isArray(msg.parts)){awaittranslateParts(msg.parts);}}},改完之后体验彻底变了贴图消息立刻显示在界面里然后慢个几秒等视觉模型分析完再正常出回复。再也没有发不出去的错觉。一些收尾的细节API Key 不硬编码走环境变量QWEN_API_KEY/DASHSCOPE_API_KEY回退到~/.config/opencode/.env.local这个被 gitignore 保护的文件插件本身干干净净随便传 GitHub 都不怕。图片描述做了缓存同一张图重复发直接命中缓存不重复花钱调 API。降级策略图片分析失败网络问题、Key 失效、超时时把图片替换成[图片解析失败xxx]的占位文本对话还能继续不会因为一张图把整个 session 卡死。现在回头看这套方案的本质其实是用翻译代替升级。模型不够强的地方用一个便宜的外部能力去补齐成本低、见效快、可替换。视觉模型今天用 qwen明天换别的插件逻辑一行都不用改换模型名就行。而且它治好了我最大的一个坏习惯——以前截图发不出去我总怀疑是 opencode 的 bug来回重启、清缓存。现在才知道八成是自己写的插件把管道堵死了。在怪工具之前先想想是不是自己写的东西在里面捣乱。如果哪天你也遇到了消息发不出去的诡异现象记得先检查一下自己的插件 hook 是不是同步阻塞了消息管道——这个坑我已经替你踩过了。附完整插件源码插件整个就一个文件vision-translator.ts放到~/.config/opencode/plugins/注意是复数重启 opencode 就能用。API Key 优先读环境变量QWEN_API_KEY/DASHSCOPE_API_KEY没有的话会回退到~/.config/opencode/.env.local这个被 gitignore 保护的文件里读格式就是一行QWEN_API_KEYsk-xxx/** * vision-translator plugin * -------------------------- * 为纯文本模型如 deepseek-v4-flash提供图片预分析能力 * 用户发送图片 → 调用 DashScope qwen3-vl-flash 视觉模型 → 转成文字描述 → 主模型读描述。 * * API Key 读取优先级绝不硬编码 * 1. 环境变量 QWEN_API_KEY * 2. 环境变量 DASHSCOPE_API_KEY * 3. 回退到 gitignored 安全文件 ~/.config/opencode/.env.local * 若均缺失 → 保留原图片 part不阻塞用户。 * * ⚠️ 2026-08-06 修复记录 * - 目录~/.config/opencode/plugin/单数opencode 不识别→ plugins/复数官方要求 * - BuggetApiKey 曾用 await import() 但函数未声明 async导致 SyntaxError 加载失败 * 现改为顶部静态导入 node:fs/os/pathgetApiKey 恢复同步函数。 * - 方案AB消息丢失修复 * A. DashScope 调用加 AbortController 超时熔断60s超时快速降级绝不阻塞消息管道 * B. hook 从 chat.message 改为 experimental.chat.messages.transform * 在消息即将发给 LLM 前才转换图片避免同步 await 网络请求卡死消息处理。 */import{existsSync,readFileSync}fromnode:fs;import{homedir}fromnode:os;import{join}fromnode:path;importtype{Plugin}fromopencode-ai/plugin;constDASHSCOPE_BASE_URLhttps://dashscope.aliyuncs.com/compatible-mode/v1;constVISION_MODELqwen3-vl-flash;constDASHSCOPE_TIMEOUT_MS60_000;functiongetApiKey():string|undefined{// 1. 环境变量优先if(process.env.QWEN_API_KEY||process.env.DASHSCOPE_API_KEY){returnprocess.env.QWEN_API_KEY||process.env.DASHSCOPE_API_KEY;}// 2. 回退到本地 gitignored 安全文件~/.config/opencode/.env.localtry{constenvFilejoin(homedir(),.config/opencode/.env.local);if(existsSync(envFile)){constcontentreadFileSync(envFile,utf8);constmcontent.match(/^QWEN_API_KEY(.)$/m);if(m)returnm[1].trim();}}catch{// 文件读取失败则忽略交给上层处理}returnundefined;}/** 图片描述缓存避免同一图片重复调用视觉 API */constdescriptionCachenewMapstring,string();/** * 调用 DashScope 视觉模型分析图片。 * 带 60s 超时熔断DashScope 响应慢时立即 abort快速降级返回错误信息 * 避免网络请求无限阻塞 opencode 的消息处理管道。 */asyncfunctionanalyzeImage(dataUrl:string):Promisestring{constcacheddescriptionCache.get(dataUrl);if(cached)returncached;constapiKeygetApiKey();if(!apiKey){thrownewError(QWEN_API_KEY / DASHSCOPE_API_KEY 未配置);}// 超时熔断器constcontrollernewAbortController();consttimersetTimeout(()controller.abort(),DASHSCOPE_TIMEOUT_MS);try{constrespawaitfetch(${DASHSCOPE_BASE_URL}/chat/completions,{method:POST,headers:{Content-Type:application/json,Authorization:Bearer${apiKey},},body:JSON.stringify({model:VISION_MODEL,messages:[{role:user,content:[{type:image_url,image_url:{url:dataUrl}},{type:text,text:请详细描述这张图片的内容包括画面主体、文字、图表、界面元素、颜色布局等所有可辨识的细节用中文回答。,},],},],}),signal:controller.signal,});if(!resp.ok){consterrBodyawaitresp.text();thrownewError(DashScope API${resp.status}:${errBody.slice(0,300)});}constdata(awaitresp.json())as{choices?:Array{message?:{content?:string}};};constdescriptiondata?.choices?.[0]?.message?.content||(图片描述为空);descriptionCache.set(dataUrl,description);returndescription;}catch(e){if(controller.signal.aborted){thrownewError(DashScope 分析超时${DASHSCOPE_TIMEOUT_MS/1000}s);}throwe;}finally{clearTimeout(timer);}}/** 转换单条消息里的图片 part 为文字描述替换而非追加模型只读文字 */asyncfunctiontranslateParts(parts:ArrayRecordstring,any):Promisevoid{for(leti0;iparts.length;i){constpartparts[i];// 只处理图片类型的文件 partmime 以 image/ 开头且为 data URLif(part?.typefilepart.mime?.startsWith(image/)part.url?.startsWith(data:)){try{constdescriptionawaitanalyzeImage(part.url);parts[i]{id:part.id,sessionID:part.sessionID,messageID:part.messageID,type:text,synthetic:true,text:[图片预分析 -${part.filename||image}]${description},};}catch(e){console.error([vision-translator] 图片解析失败:,e);// 解析失败时降级为文本占位绝不让原始图片卡住纯文本模型parts[i]{id:part.id,sessionID:part.sessionID,messageID:part.messageID,type:text,synthetic:true,text:[图片解析失败${(easError).message}]图片已降级为占位文本,};}}}}exportconstVisionTranslatorPlugin:Pluginasync(){return{/** * 在消息即将发送给 LLM 前转换图片 → 文字。 * 相比 chat.message此处转换不影响消息的入库/显示/发送流程 * 只会让回复生成稍等分析完成即使超时也走降级路径消息绝不丢失。 */experimental.chat.messages.transform:async(_input,output){for(constmsgofoutput.messages){if(msg?.partsArray.isArray(msg.parts)){awaittranslateParts(msg.parts);}}},};};—— 完 ——
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进