opencode 的诊断与格式化两条线:语言服务器怎么报错、格式化器何时跑

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

**这两条线在开源终端编码 Agent 项目 opencode 里根本不是并行的,所以也谈不上”打架”——格式化命令必须先跑完、退出,语言服务器才会被叫醒去重新读那个文件。**理解了这个顺序,你就能解释绝大多数”它明明改错了却说没问题""行号对不上”的现象;反过来,如果你以为诊断是边写边出的,配置怎么调都会调歪。

opencode 是一个跑在终端里的开源编码 Agent,采用 MIT 许可证(LICENSE,Copyright 2025 opencode)。它的仓库里 packages/ 下有 32 个包,英文文档 packages/web/src/content/docs/ 有 36 份 mdx。本篇只盯其中两份文档和它们对应的实现:lsp.mdxformatters.mdx,以及 packages/opencode/src/format/packages/opencode/src/lsp/packages/opencode/src/tool/ 这三处代码。

站内已有几篇相邻的文章:AI 代码评审工具怎么选 讲的是评审这一层的工具选型,Agent 交付的验收标准 讲你凭什么判断一次任务算做完,让 AI 写测试 讲的是补测试这条反馈线;本篇往下钻一层,只看 opencode 在”一次写文件”这几百毫秒里究竟拿到了哪些信号、又主动丢掉了哪些信号。

一、先把顺序钉死:写完一个文件之后发生了什么

packages/opencode/src/tool/write.ts 里这一段是全篇的地基,它是顺序的唯一权威:

yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom))
if (yield* format.file(filepath)) {
  yield* Bom.syncFile(fs, filepath, desiredBom)
}
yield* events.publish(FileSystem.Event.Edited, { file: filepath })
// ...
let output = "Wrote file successfully."
yield* lsp.touchFile(filepath, "document")
const diagnostics = yield* lsp.diagnostics()

读法是这样的:内容先落盘,然后 format.fileyield* 等待——它不是发射后不管,而是等格式化子进程退出。格式化如果真的跑了(返回 true),紧接着补一次 BOM 同步,因为外部命令可能把字节顺序标记吃掉。之后才是 lsp.touchFile,再取 lsp.diagnostics()

touchFile 的实现在 packages/opencode/src/lsp/lsp.ts,它调 client.notify.open,而 packages/opencode/src/lsp/client.ts 里的 open 会重新从磁盘读一遍文本再发给语言服务器。这一点很关键:语言服务器看到的是格式化之后的文件,不是 Agent 刚生成的那份。所以诊断里的行号、列号,跟你 git diff 看到的最终产物是对得上的。假如顺序反过来先出诊断再格式化,你就会拿到一堆偏移了若干行的错误位置,那才是真正的打架。

edit.tsapply_patch.ts 走的是同一套顺序,只是入口不同。另有一个例外:packages/opencode/src/tool/read.tslsp.touchFile(filepath) 没有传第二个参数,而且是 fork 出去的——读文件的时候只是顺手把语言服务器叫起来预热,不等诊断。

组成部分它负责什么对应仓库位置你什么时候会碰到它
格式化器清单每个格式化器的扩展名列表,以及 enabled() 探测后返回的命令数组packages/opencode/src/format/formatter.ts想知道”我的项目到底会不会触发 prettier”
格式化调度按扩展名挑出所有匹配项、替换 $FILE、逐个跑子进程packages/opencode/src/format/index.ts两个格式化器互相覆盖对方结果时
LSP 服务层touchFilediagnostics、客户端按根目录复用与失败标记packages/opencode/src/lsp/lsp.ts语言服务器起不来、或反复重启
LSP 客户端didOpen / didChange 通知、等待诊断、各类超时常数packages/opencode/src/lsp/client.ts大仓里诊断”总是空的”
诊断渲染过滤严重级别、截断条数、拼成回灌给模型的文本块packages/opencode/src/lsp/diagnostic.ts想知道警告为什么从来没被 Agent 看见
写入类工具把上面两条线按固定顺序串起来packages/opencode/src/tool/write.tsedit.tsapply_patch.ts排查”改完之后它凭什么说没错”
两份说明文档内置清单、配置键、官方给的取舍建议packages/web/src/content/docs/lsp.mdxformatters.mdx配置之前先对一遍键名

二、诊断这条线:它只告诉模型很少的一部分

很多人默认”接了 LSP,Agent 就什么都知道了”。实际回灌进模型上下文的信息,被 packages/opencode/src/lsp/diagnostic.ts 砍过两刀:

const MAX_PER_FILE = 20

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>`
}

第一刀:severity === 1,也就是只留 ERROR。同一个文件里的 WARN、INFO、HINT 一条都不会进上下文——pretty 函数里那张 severityMap 虽然四个级别都写了,但 report 在调用它之前就已经把非 ERROR 的过滤掉,实际能被渲染出来的只有 ERROR 这一档。你在编辑器里看到的”未使用的变量""建议改用某写法”这类提示,Agent 是看不见的。想让它管这些,得靠别的路子。

第二刀:每个文件最多 20 条,多出来的折成一句 ... and N more。一次大改动引发几百个错误时,模型只能看到头 20 条加一个数字。

范围上还有第三层差别:write.ts 里除了当前文件,还会带上其他文件的诊断,但用 MAX_PROJECT_DIAGNOSTICS_FILES = 5 限住最多 5 个文件;而 edit.ts 只报当前文件这一个。也就是说,用 write 整文件重写比用 edit 局部替换,能让 Agent 多看见一点连带破坏。

至于”等多久”,常数写在 client.ts 顶部:文档模式等诊断的上限是 DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000,全量模式是 DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000,单次拉取请求是 DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000,初始化握手是 INITIALIZE_TIMEOUT_MS = 45_000。写入类工具传的都是 "document",只有 packages/opencode/src/cli/cmd/debug/lsp.ts"full"。超时不会报错,只是拿不到东西就往下走——所以在一个冷启动的大仓里,头几次编辑的诊断很可能是空的,不是因为代码没问题。

client.ts 里还留了一段注释,解释为什么重复 touch 同一个文件时不清空已有诊断:有些服务器只在内容真的变了才重新推送,清掉就等于把错误弄丢了。这类细节决定了你不能把”诊断为空”当成”编译通过”。

三、格式化这条线:命令是探测出来的,不是写死的

formatter.ts 里每个格式化器都是同一个形状——名字、扩展名列表,加一个 enabled()。它返回 false 表示这台机器上用不了,返回数组则是要执行的命令:

export const gofmt: Info = {
  name: "gofmt",
  extensions: [".go"],
  async enabled() {
    const match = which("gofmt")
    if (!match) return false
    return [match, "-w", "$FILE"]
  },
}

探测条件因语言而异,且比”命令存在”要严:prettier 要 findUp 找到 package.jsondependenciesdevDependencies 里有 prettier;biome 要找到 biome.jsonbiome.jsonc;clang-format 要有 .clang-format;ruff 要么在 pyproject.toml 里有 [tool.ruff] 段,要么在 ruff.toml.ruff.tomlrequirements.txtPipfile 里能查到;air 更狠,会真的跑一次 --help 去看首行是不是包含 R language 和 formatter 字样,防止撞名。这些判断都发生在你的机器上,用的是你的项目文件。

调度侧在 format/index.ts。按扩展名匹配出来的是一个数组,命中几个就依次跑几个,命令里的 $FILE 换成真实路径。探测结果会缓存,但缓存逻辑是 if (cmd === false || cmd === undefined) 才重新探——意味着”当时不可用”的结论不会被记住,你中途装上格式化器它能自己恢复;而一旦探到了可用命令,就一直用那一条。

冲突处理只做了一处硬编码:ruff 与 uv 被视作同一个后端,禁用其中任何一个,两个都会从表里删掉,代码旁边留着一条 TODO 说要把共享后端的联动禁用做成通用机制。uvformat 自己也让路——if (await ruff.enabled(context)) return false。除此之外,prettier 和 biome 的扩展名列表几乎完全重合,只要你的仓库同时满足两边的探测条件,一次写入就会先后跑两个格式化器,后跑的那个说了算,而顺序取决于 Object.values(Formatter) 的导出次序,不是你能配的。

失败是静默的。子进程的 stdoutstderr 全设成 ignore,spawn 失败走 Effect.logError 记一条 “failed to format file”,退出码非零也只记一条 “failed”。这些都进日志,不进模型上下文,formatFile 照样返回 true。所以格式化器崩了,Agent 那边什么都感觉不到。

四、两条线各自的配置:键名别记串了

两块都默认关闭。文档写得很明确:lsp 省略则全部 LSP 关闭,formatter 省略则全部格式化器关闭。设成 true 开全部内置项,设成 {} 表示保留内置项的同时做局部覆盖,设成 false 用来推翻另一份配置里的开启。

差异在细节上,配错了不会报错,只会不生效:

  • LSP 服务器条目用的环境变量键是 env;格式化器条目用的是 environment。这两个不通用,formatter.ts 里 prettier、biome、oxfmt 三个内置项声明的都是 environment,而 lsp.ts 读的是 item.env
  • 两边都有 disabledcommandextensions。LSP 条目多一个 initialization,用来发 initialize 请求时带服务器专属选项;格式化器没有这个。
  • LSP 条目除非只是用来禁用,否则必须给 command;格式化器的 command 对内置项是可选的,对自定义项是必需的。

关掉某一个的写法,两边形状一致,只是段名不同。这是 lsp.mdx 里的原文示例:

{
  "$schema": "https://opencode.ai/config.json",
  "lsp": {
    "typescript": {
      "disabled": true
    }
  }
}

自定义格式化器则是 formatters.mdx 里的这段:

{
  "$schema": "https://opencode.ai/config.json",
  "formatter": {
    "custom-markdown-formatter": {
      "command": ["deno", "fmt", "$FILE"],
      "extensions": [".md"]
    }
  }
}

有个覆盖行为值得单独记住:在 format/index.ts 里,给一个内置格式化器写了 command 之后,enabled 会被换成一个直接返回该命令的函数,原本那套探测逻辑就不跑了。好处是你能强制指定二进制;代价是原来”项目里没装就不跑”的保护没了,命令会在每次匹配到扩展名时无条件执行。LSP 那边同理,配置项里的 command 会整个替换掉内置条目的 spawn,内置的自动安装路径随之失效。

五、边界与代价:它明确不管的那些事

这套设计换来的是简单和可预测,放弃的东西也很具体。

它不替你做类型检查和 lint 的全局判断。 诊断只在你刚写的那个文件(write 场景下外加最多 5 个连带文件)上取,且只取 ERROR。跨文件的类型破坏、只在 CI 里才暴露的规则违例,这条线不负责。lsp.mdx 的 Best Practices 一节自己也这么说:语言服务器会失同步、吃内存、随版本和项目而异、拖慢 Agent 流程,很多项目更适合让 Agent 直接跑 lint、typecheck 之类的命令行工具,把错误喂回循环,并把这些命令写进 AGENTS.md 或技能文件里让 Agent 知道该跑什么。这是项目方给出的取向,不是本文的发挥。

它不保证语言服务器一定起得来。 lsp.ts 里有个 broken 集合,某个根目录加服务器 ID 的组合一旦 spawn 失败或客户端创建失败,就被记进去,同一次会话里不再重试。你把缺的依赖装好,也得重开会话。另外 containsPath 会挡住实例目录之外的文件,跨目录编辑拿不到诊断。

它不给你格式化的结果反馈。 上一节说过,输出全丢、失败只进日志。你不会在会话里看到”格式化失败”这句话。

它在你的机器上执行命令,而且这一步不单独问你。 写文件本身要过 ctx.askedit 权限,但格式化命令的执行没有独立的确认环节——命令来自配置文件,工作目录是实例目录,extendEnv: true 意味着继承你当前的环境变量。把这条和”command 会绕过探测”合起来看,风险就很清楚了:克隆一个陌生仓库、里面带着一份定义了自定义格式化器的 opencode.json,只要你开着格式化功能并让 Agent 写了一个匹配扩展名的文件,那条命令就会带着你的环境变量跑起来。同理,LSP 那边文档提到可以用 OPENCODE_DISABLE_LSP_DOWNLOAD 环境变量设为 true 来关掉语言服务器的自动下载——默认是会下载的。

诊断文本会进上下文,也就会发给模型服务商。 报错信息里常常带着符号名、路径片段,有时还有变量值。私有代码的外泄面不是只有你贴进去的那段。各家服务商的数据处理规则不同且会调整,以官方最新说明为准。

它不做回滚。 格式化器是带 -w--write-i--autocorrect--fix 这类原地改写参数的,直接覆盖磁盘上的文件。Agent 改错加上格式化器再改一道,脏工作区里想区分”谁改的”会很费劲。开工前把工作区清干净、小步提交,是这套机制下唯一的兜底。

六、上手与避坑清单

先只开一边,别两边一起开。 会踩是因为两块都默认关闭,很多人照着文档一次性都设成 true,出问题时分不清是格式化器改乱了还是语言服务器没起来。避法:先开 formatter,跑几次写入确认产物符合项目风格,再开 lsp。这两块的失败表现完全不同,分开开才能定位。

别在同一个项目里同时满足 prettier 和 biome 的探测条件。 会踩是因为两者扩展名列表几乎重合,探测条件互不感知,两个都会跑,执行顺序不由你定。避法:确定用哪个,另一个在 formatter 段里显式 disabled: true,别指望”我只装了配置文件没装依赖”能挡住——prettier 看的是 package.json 依赖,biome 看的是配置文件,两条判据不一样。

Python 项目注意 ruff 和 uv 是联动的。 会踩是因为它们被当成同一个后端,禁其中一个会把两个都删掉。避法:想保留其中一个就别去禁另一个,uvformat 在 ruff 可用时本来就会自己返回 false

环境变量键别写混。 会踩是因为 LSP 用 env、格式化器用 environment,写错的那个键会被静默忽略,你只会看到”环境变量没生效”。避法:改完配置先用 $schema 那行让编辑器校验,或者回 formatters.mdxlsp.mdx 的属性表对一眼。

给内置格式化器写 command 之前想清楚。 会踩是因为一旦写了 command,探测被跳过,命令变成无条件执行;在没装该工具的机器上就是每次写文件都 spawn 失败——而失败是静默的,你只会觉得”格式化偶尔不生效”。避法:只在需要锁定具体二进制或加参数时覆盖,并且用 extensions 把作用范围收窄。

别把”没有诊断”读成”编译通过”。 会踩是因为超时、服务器还没握手完、broken 标记、非 ERROR 级别这四种情况都会让诊断为空。避法:把真正的判据放在你自己的命令上——按官方文档的建议,把 typecheck、lint、测试命令写进 AGENTS.md,让 Agent 显式去跑,诊断只当作即时的辅助信号。这也是 工具返回结果怎么设计 里那套思路:回灌给模型的文本,决定了它认为自己做到了什么。

处理陌生仓库时先看它的 opencode.json 会踩是因为配置里的格式化命令会在你的机器上执行,且不单独征求同意。避法:clone 之后、启动 Agent 之前,把这个文件读一遍,尤其是 formatter 段里的 commandlsp 段里的 command

收束

要判断你这套配置到底在生效还是在裸奔,按顺序自查三件事就够:一,opencode.jsonlspformatter 是不是真的存在(省略就等于全关);二,你项目里同一批扩展名有没有被两个以上的格式化器同时命中;三,你依赖的错误反馈,是来自那 20 条 ERROR,还是来自你自己定义的命令。

想继续往下读的话,路线是明确的:先看 packages/opencode/src/tool/write.ts,它是顺序的权威;再看 packages/opencode/src/format/index.tsformatFile 弄清命令怎么被拼出来;最后看 packages/opencode/src/lsp/client.ts 顶部那几个超时常数,你就知道诊断为什么时有时无。这个项目还在快速改,读代码永远比读转述可靠。至于要不要把它换成你现在的主力工具,那是另一个问题,可以对照 开源终端 Agent 怎么选 里的判据自己算一笔账。

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 把开源编码 Agent opencode 挂到代码托管平台:权限与边界开源终端编码 Agent opencode 跑不动:按模型、认证、Windows、代理证书的顺序排查

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。