终端编码 Agent opencode 的语言服务器集成:拉起、诊断回灌与失效表现
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
这层的产出物只有一个:编辑类工具返回文本里那段 <diagnostics file="...">。 它不是给你看的,是给模型看的。理解 opencode 这个开源终端编码 Agent 的语言服务器集成,最省事的切入点就是倒着看——先认准这段文本长什么样、由谁拼出来、被谁读走,再回头看前面那一长串进程拉起和协议握手是为了把什么东西送到这里。搞反顺序,你会陷进 LSP 协议细节里出不来,却仍然答不上来”为什么我的 Agent 改坏了代码却一声不吭”。
顺带说清本篇在站内的位置:Hermes Agent 的 LSP 集成那篇讲的是另一个项目里同类能力的搭法,可以对照看设计取向的差别;工具返回值设计讲的是”往模型嘴里塞什么格式的结果”这个通用命题;Agent 工具调错的排查讲的是通用排查方法。本篇不重复这三件事,只钉死一个具体仓库里这一层的真实实现和它的失效面。
一、这层要解决的问题:改完之后怎么知道改坏了
一个跑在终端里的编码 Agent,最容易翻车的动作是”改了一处,坏了另一处”。模型自己不知道,你也要等到下一次 build 才知道。语言服务器天生就是干这个的:它常驻、有全项目索引、文件一变就能吐出错误位置。
opencode 的做法是把语言服务器接进工具的返回路径。你可以在 packages/opencode/src/tool/edit.ts 里看到这个动作的全貌,编辑落盘之后紧接着就是三行:
yield* lsp.touchFile(filePath, "document")
const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = FSUtil.normalizePath(filePath)
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
模型收到的不是”编辑成功”四个字,而是”编辑成功 + 你刚踩出来的错误清单 + 一句 please fix”。这就是整层的目的。
有一点值得先摆在前面:这个能力默认是关的。仓库文档 packages/web/src/content/docs/lsp.mdx 里写得很直接——LSP 默认禁用,要用得在配置里写 lsp。同一份文档的 Best Practices 一节还给了一段少见的自我设限,大意是语言服务器会失同步、吃内存、版本因项目而异、拖慢 Agent 流程,很多项目里更划算的做法是让 Agent 直接去跑 lint、typecheck 这类命令行工具,把命令写进 AGENTS.md 或技能文件里让 Agent 知道该跑什么。维护者自己都不认为这是无条件的净收益,你在决定要不要开之前,先把这段读进去。
二、按语言怎么拉起:扩展名、根目录、熔断
调度全在 packages/opencode/src/lsp/lsp.ts 里,核心是一个叫 getClients 的函数。它拿到一个文件路径,走这么几步。
先做边界判断,用 containsPath 确认文件在当前实例目录内,不在就直接返回空数组——这一步是防越界,Agent 想拿工作区外的文件去唤起语言服务器是唤不起来的。
然后取扩展名,代码是 path.parse(file).ext || file,没有扩展名时退回拿整个文件路径去匹配。拿到扩展名后遍历所有启用的 server 定义,每个定义在 packages/opencode/src/lsp/server.ts 里都是一个对象,四个关键字段:id、extensions、root、spawn。扩展名对不上就跳过。
root 是个函数,不是字符串。这点很关键:同一个 monorepo 里,不同文件会算出不同的根目录,也就会各起一份进程。server.ts 里给了两个通用实现 NearestRoot 和 StrictNearestRoot,都是从文件所在目录往上找标志文件、找到就取它的目录。两者的差别在找不到时——NearestRoot 兜底返回实例目录,StrictNearestRoot 返回 undefined,也就是干脆不起。typescript 的定义就用了带排除项的形式:
id: "typescript",
root: NearestRoot(
["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"],
["deno.json", "deno.jsonc"],
),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
排除项写着 deno.json,意思是往上找的路上撞见 Deno 项目标志就返回 undefined,把这块地方让给 deno 那份定义。rust 那份更绕一层:先用 NearestRoot 找到 Cargo.toml 或 Cargo.lock,再沿目录往上读每一层的 Cargo.toml,看到 [workspace] 就把根改到工作区级别,同时用 ctx.worktree 前缀做上界,防止一路爬到文件系统根。
算出根目录之后是三层去重和一层熔断,都靠状态里那几个字段:
s.clients:已经建好的连接,按root + serverID匹配,命中就直接复用。s.spawning:正在启动中的 Promise,键同样是root + server.id。并发的读写操作打到同一个 server 上时,后来者 await 前一个的 Promise,不会重复拉进程。s.broken:启动失败过的root + server.id集合,命中就continue,这一轮直接跳过。
熔断条件比较宽:spawn 抛异常算失败,spawn 正常返回但值是 undefined 也算失败。而 undefined 恰恰是”环境里没这个工具”的正常返回路径——rust 那份定义里 which("rust-analyzer") 找不到就 return,typescript 那份要求 Module.resolve("typescript/lib/tsserver.js", ctx.directory) 能在项目里解析到 typescript、并且 Npm.which("typescript-language-server") 能拿到可执行文件,缺一样就返回空。这条路径不报错、不打红字,只是往 broken 里塞一个键。
配置层的覆盖也在这个文件里。cfg.lsp 是 true 时启用全部内置定义;是对象时逐条合并,disabled 为真就把这个 id 从表里删掉,否则用配置里的 command 数组去替换 spawn,extensions、env、initialization 按需覆盖,root 没有既有定义时兜底成”实例目录”。仓库文档里的自定义示例长这样:
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"custom-lsp": {
"command": ["custom-lsp-server", "--stdio"],
"extensions": [".custom"]
}
}
}
真正把进程拉起来的是 packages/opencode/src/lsp/launch.ts,二十来行,把 stdin、stdout、stderr 三条都设成 pipe,任何一条拿不到就抛。语言服务器全部走 stdio 通信,这是后面所有协议交互的物理基础。
另外有一个实验开关:filterExperimentalServers 会读运行时标志,开启时把 pyright 从表里删掉、留下 ty,未开启时反过来删掉 ty。对应的环境变量名是 OPENCODE_EXPERIMENTAL_LSP_TY。还有一个 OPENCODE_DISABLE_LSP_DOWNLOAD,用来关掉语言服务器的自动下载——好几个内置定义会在缺少可执行文件时自行去装,这个开关是给不希望 Agent 往你机器上装东西的场景准备的。
三、诊断怎么回到会话里:两条来源、一次合并、一段等待
packages/opencode/src/lsp/client.ts 是这层最厚的一块,六百多行几乎全在处理一件事——怎么确保”我改完了”和”错误列表刷新了”这两件事的先后关系。
先看数据结构。客户端里并排放着两个 Map:pushDiagnostics 和 pullDiagnostics。前者接的是服务器主动发来的 textDocument/publishDiagnostics 通知,后者存的是主动发 textDocument/diagnostic 或 workspace/diagnostic 请求拉回来的结果。LSP 生态里这两种模式都有服务器在用,opencode 的选择是两条都收,取用时合并去重——dedupeDiagnostics 把 code、severity、message、source、range 五个字段序列化成键来判重。
握手阶段声明的能力值得看一眼。initialize 请求里带的客户端能力包括 workspace.configuration、didChangeWatchedFiles.dynamicRegistration、textDocument.synchronization 的 didOpen/didChange、以及 textDocument.diagnostic 的 dynamicRegistration 与 relatedDocumentSupport。有两处显式关掉了:workspace.diagnostics.refreshSupport 是 false,publishDiagnostics.versionSupport 也是 false。握手有 45 秒上限,超时就抛 InitializeError,这个错会一路冒到调度层被 catch 掉,然后写进 broken。
文件同步在 notify.open 里。同一个路径第一次进来走 textDocument/didOpen,版本号 0;第二次及以后走 textDocument/didChange,版本号加一。两种情况之前都先发一条 workspace/didChangeWatchedFiles,区别只是 created 还是 changed。增量同步这里有个细节:服务器声明的同步方式是增量时,opencode 并不真的算差异,而是构造一个从 (0,0) 到旧文本末尾位置的 range,整块覆盖。代码注释里还留了一条经验——didChange 时不清空已有诊断,因为像 clangd 这类服务器只在内容真变了才重新推送,提前清空会让空改动的 touch 把错误弄丢。
等待逻辑是这层的技术核心。touchFile 的第二个参数决定模式,"document" 走文档级、"full" 走全量,不传就只同步不等。几个时间常数写死在文件顶部:文档级等待上限 5 秒,全量等待上限 10 秒,单次拉取请求超时 3 秒,推送去抖 150 毫秒。
等待过程是一个循环,每轮做三件事:发一轮拉取请求;没拿到当前文件的结果就在”收到新推送”和”能力注册发生变化”之间赛跑;只有赢家是”注册变化”才继续下一轮,否则直接返回。为什么要盯着注册变化?因为不少服务器是在 initialize 之后才通过 client/registerCapability 动态注册 textDocument/diagnostic 的,注册里的 registerOptions.identifier 决定要带什么标识去拉,registerOptions.workspaceDiagnostics 决定这条注册算文档级还是工作区级。注册没到之前拉是白拉,所以要等它。
拉取这块还有一条被显式标注为延迟敏感的路径。多个 identifier 的拉取是并行发出去的,只要其中一批已经产出了当前文件的诊断就立刻解除阻塞,剩下的慢请求继续在后台合并进 Map,不做逐个串行、也不加匹配后的额外沉降延时。
推送这一侧有个针对性补丁:shouldSeedDiagnosticsOnFirstPush 只对 typescript 返回真。原因写在注释里——TypeScript 的内置服务在首次打开时推得很猛,所以第一次推送只用来给缓存播种、不触发监听器,避免等待逻辑在还没稳定的结果上提前返回。
最后是渲染。packages/opencode/src/lsp/diagnostic.ts 只有二十几行,但它决定了模型到底看见什么:
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
MAX_PER_FILE 是 20。pretty 把每条渲染成 ERROR [行:列] 消息 的形式,行列都从 0 基转成 1 基。严重度映射表里 WARN、INFO、HINT 三档都在,但 report 一进门就把非 1 的全滤掉了——警告和提示进不了模型的视野。
不同工具的回灌口径也不一样。edit.ts 只报当前文件;write.ts 会额外报别的文件里的错误,上限是 5 个文件;apply_patch.ts 也是按目标文件报;而 read.ts 调 touchFile 时不传诊断参数、并且 fork 到后台忽略失败,纯粹是趁读文件的时候把语言服务器预热起来。
四、这层由哪几块拼起来
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| server 定义表 | 每个语言服务器的 id、extensions、root 解析、spawn 方式 | packages/opencode/src/lsp/server.ts | 某个语言死活起不来时 |
| 调度层 | 按文件挑 server,复用连接、并发去重、失败熔断 | packages/opencode/src/lsp/lsp.ts | 大仓里进程数量不符预期时 |
| 单连接客户端 | initialize 握手、文档同步、push/pull 诊断合并与等待 | packages/opencode/src/lsp/client.ts | 诊断慢半拍或压根不来时 |
| 进程拉起 | 以 stdio 三管道方式 spawn 出子进程 | packages/opencode/src/lsp/launch.ts | 自定义 command 写错时 |
| 诊断渲染 | 过滤严重度、按条截断、包成 XML 块 | packages/opencode/src/lsp/diagnostic.ts | 想确认模型究竟看见了什么时 |
| lsp 工具 | 把只读查询操作暴露给模型 | packages/opencode/src/tool/lsp.ts 与同名 .txt | 想让 Agent 主动查定义/引用时 |
| 写类工具的回灌点 | 编辑后 touchFile 再拼诊断进 output | packages/opencode/src/tool/edit.ts、write.ts、apply_patch.ts | 输出里出现 LSP errors detected 时 |
| 扩展名到 languageId | didOpen 时告诉服务器这是什么语言 | packages/opencode/src/lsp/language.ts | 自定义扩展名被当成 plaintext 时 |
packages/ 下 32 个包里,这层的实现集中在 packages/opencode 一个包内;工具目录里有 25 个 .ts 与 15 个 .txt,两者数量并不相等——只有一部分工具会把说明文本单独拆成 .txt,lsp.ts 和 lsp.txt 恰好是配齐的一对:前者是执行逻辑,后者是给模型读的工具说明。
tool/lsp.ts 暴露给模型的操作固定为九个:goToDefinition、findReferences、hover、documentSymbol、workspaceSymbol、goToImplementation、prepareCallHierarchy、incomingCalls、outgoingCalls。参数里的 line 和 character 都要求 1 基(跟编辑器显示一致),进入实现时统一减一转成协议的 0 基。执行前先 hasClients 探一下有没有可用服务器,没有就直接抛 “No LSP server available for this file type.”;权限侧走一个名为 lsp 的权限项,仓库权限文档里对它的标注是目前非细粒度。
五、边界与代价:它明确不管什么
它不做写操作。 九个操作全是只读查询,没有重命名、没有 code action、没有自动修复、没有格式化。语言服务器能力里最诱人的那部分被整个放弃了,模型拿到的是”哪里错了”,改还是得自己改。
它只让 ERROR 通过。 你的 lint 规则如果配成 warning 级,Agent 完全看不到。指望靠 LSP 层管住代码风格的话,得先把关心的规则提到 error。
它有硬截断。 单文件 20 条封顶,write 工具报到第 5 个其他文件为止。大范围重构时被截掉的部分不会有任何补偿机制。
它的失败是静默的。 这一条最需要记住。启动失败进 broken 集合、等待超时直接返回、拉取请求 catch 成 null——整条链路上几乎没有一处会把失败冒到用户面前。而 status() 的实现里循环只会 push "connected" 这一种状态,尽管状态类型定义里同时有 connected 和 error 两个字面量:启动失败的服务器根本不在 clients 列表里,也就不会出现在状态输出中。于是失效的表现就是——什么都不说,编辑工具照常返回”成功”,只是那段 <diagnostics> 永远不出现。Agent 因此变得像在没有编译器的环境里写代码:它不会报警,它只是变笨了。
熔断不自愈。 broken 是进程内的一个 Set,没有过期、没有重试计划。你在会话中途装好 rust-analyzer 或补上 typescript 依赖,当前实例不会重新尝试。
代价是真实的进程和真实的等待。 每个 root 加每个 server 就是一个常驻子进程,占内存、占启动时间;编辑类工具默认走文档级等待,最坏情况给单次编辑加上数秒。仓库文档自己给的建议是:很多项目直接让 Agent 跑命令行 typecheck 更划算。
安全面必须讲清。 配置里的 command 是一个会在你机器上被执行的数组,env 会与 process.env 合并后传给子进程。项目级配置文件如果跟着别人的合并请求进了你的仓库,这就是一条本地任意命令执行的路径——审配置要和审代码一样认真。另一头,诊断消息里往往带着符号名、类型签名甚至源码片段,它们会随工具输出一起进入上下文、发给模型服务商。私有代码库开这层之前,先想清楚这部分外泄面能不能接受。各家服务商的数据处理规则不同且会调整,以官方最新说明为准。权限侧还有一层需要注意:这类终端 Agent 本身就能跑命令、能直接改你的文件,权限放太松的后果是误删误改直接落盘。相关取舍可以参考最小权限设计那篇。
六、上手与避坑清单
装了语言服务器却毫无反应。 会踩是因为直觉上”装了就该用上”,而这里默认是关的。避法:先在配置里把 lsp 打开,最省事是设成 true 启用全部内置定义,想做细调就写成对象。
TypeScript 项目起不来 typescript。 会踩是因为你全局装了语言服务器,但实现要求的是项目内能解析到 typescript 这个依赖。避法:把 typescript 装进项目依赖,而不是只装在全局。
Deno 项目里 typescript 不生效。 这不是 bug,是 typescript 定义里显式排除了 deno.json 和 deno.jsonc。避法:确认 deno 可执行文件在 PATH 里,让 deno 那份定义接管。
中途修好环境却依然没诊断。 会踩是因为熔断只记不清。避法:修完环境重启实例,别在同一个会话里反复试。
大仓里进程数量失控。 会踩是因为 root 是按文件算的,子包各有各的标志文件就会各起一份。避法:动手前先按你的目录结构推演一遍每类文件会算出什么根,必要时用配置里的 disabled 关掉不需要的定义。
自定义扩展名的诊断行为怪异。 会踩是因为 didOpen 时的 languageId 来自扩展名映射表,映射不到就填 plaintext,服务器可能因此不按预期工作。避法:先在 language.ts 里确认扩展名有没有对应项。
分不清是这层坏了还是模型没理会。 会踩是因为两种情况在会话里长得一模一样——都是”没提到任何错误”。避法:绕开 Agent 直接验证,仓库提供了调试子命令,用 debug lsp diagnostics <file> 走全量模式打印原始诊断 JSON,另外还有 symbols 和 document-symbols 两个子命令可以单独验证查询链路。拿到 JSON 说明这层是活的,问题在提示或模型侧;拿到空的说明问题在这层。
把 LSP 当成质量闸门。 会踩是因为它看起来很像。避法:记住它只报 ERROR、有条数上限、失败静默。真正的闸门应该是能失败退出的命令——把 typecheck 和 lint 命令写进指令文件里让 Agent 去跑,让非零退出码说话,比依赖这层可靠得多。
收束
把这层当成”给模型的一次廉价体检”来用,不要当成”保证不出错的护栏”。它的价值在编辑之后那几百毫秒里多给了模型一次自我纠正的机会;它的风险在于失效时长得跟正常一模一样。
上手自检可以按这个顺序走:配置里 lsp 是否已开 → 目标语言的可执行文件与项目依赖是否满足 → 用调试子命令直接取一次诊断确认这层活着 → 手动改坏一个文件让 Agent 编辑一次,看返回里有没有出现 <diagnostics 这个字符串 → 确认你关心的规则确实是 error 级别。
想继续往下读,路径也很清楚:先 packages/opencode/src/lsp/diagnostic.ts,二十几行就能建立”模型看见什么”的直觉;再 packages/opencode/src/tool/edit.ts 的结尾几行,看回灌点在哪;然后 packages/opencode/src/lsp/lsp.ts 的 getClients,理解拉起与熔断;最后才啃 client.ts 的等待逻辑——那里的时间常数和赛跑条件,是这层所有”时快时慢""时有时无”的来源。想对比另一种实现路子,回头看Hermes Agent 的 LSP 集成,两边在”诊断怎么进上下文”这个问题上的取向差别,比协议细节更值得琢磨。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 的 code mode 支线:让模型写程序串工具的代价 和 opencode 扩展指南:插件钩子与自定义工具,两条路怎么选。