开源自托管 Agent 项目 Hermes Agent 的 LSP 集成
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
这层集成真正值钱的地方不是”多接了一个 linter”,而是它把”这次改动引入的新错误”从文件里原有的一堆报错中摘出来,只把增量交给模型。 先说清是哪个 Hermes:本文讲的是 NousResearch/hermes-agent 这个常驻自托管的开源 Agent 项目(MIT 许可证,LICENSE 署名 Nous Research),不是 Nous Research 的 Hermes 开源模型系列,也与其它同名商标、同名库无关。它在 agent/lsp/ 下自带了一套 LSP 客户端实现:agent 写完文件之后,由真实的语言服务器(pyright、gopls、typescript-language-server 等)回答”这行到底错在哪”,而不是让模型自己盯着文本猜。
站内已有的 给 Agent 设计工具:描述比实现更重要 讲工具接口该怎么向模型呈现,模型给的参数别照单全收 讲入口层怎么拦住坏参数,开源编程 Agent pi 的文件编辑 讲的是另一个项目在写入侧的防线——那三篇是通用方法论或别的代码库;这一篇只做一件事:把 hermes-agent 这个具体项目的 LSP 层拆开,告诉你它落在哪几个文件、哪几个常量上。
一、模型硬读文本时漏掉的是什么
写入链路上有两条彼此独立的通道。tools/file_operations.py 里的写入结果对象上同时挂着 lint 和 lsp_diagnostics 两个字段:前者是进程内的语法检查(Python 走 ast.parse、JSON 走 json.loads 这类微秒级解析),后者才是语言服务器给出的语义诊断。语法过关不等于语义正确——类型不匹配、未定义的名字、少写的 import,这些必须有项目上下文才能判断,纯文本层面看起来毫无破绽。两个字段独立存在的意思是:模型可能看到 lint 报告干净、lsp_diagnostics 却塞满错误,这正是最容易被漏掉的那一类问题。
第二层价值是”只给增量”。如果直接把语言服务器对这个文件的全部诊断塞给模型,它看到的是一份混着历史欠债的清单:要么跑去修不该它修的东西,要么误判自己刚才写坏了整个文件。agent/lsp/manager.py 用一个基线映射解决这件事——写之前调 snapshot_baseline(path) 把当前诊断存成基线,写完调 get_diagnostics_sync(path, delta=True),用 _diag_key 做集合差。这个 key 把 severity、code、source、message 和起止行列拼成一个字符串当身份,所以”同一类错误在另一处又犯了一次”不会因为长得像旧账而被过滤掉。
行号漂移被单独处理了。删几行或插几行,编辑点以下的旧诊断会整体挪位置,不做处理它们全都长得像”这次改出来的”。file_operations 在拿到编辑前后文本时会用 range_shift 里的 build_line_shift 造一个行映射,manager 收到这个 line_shift 后先用 shift_baseline 把基线搬到编辑后的坐标系,再做集合差。少了这一步,一次删行就会让整个下半个文件的旧报错涌进模型视野。
二、四块主件加四块外围,各管什么
选题里点的是管理器、协议层、注册表、上报这四块主件,但真要排查问题,紧邻的四块外围也躲不开,所以一起列在下面。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
服务管理器 LSPService | 后台事件循环、按(服务器 id、项目根)复用客户端、懒启动、失败拉黑、基线增量、空闲回收、状态快照 | agent/lsp/manager.py | 调时间预算、查为什么”后来就不报了” |
| 协议分帧层 | Content-Length 分帧 + JSON-RPC 2.0 信封的编解码与分类,只管传输不管语义 | agent/lsp/protocol.py | 日志里出现帧错、非 UTF-8、body 截断这类报错时 |
| 服务器注册表 | 27 条服务器定义:按扩展名匹配、找项目根、拼启动命令、标记服务器怪癖 | agent/lsp/servers.py | 加语言、改二进制路径、排”为什么这个文件没人管” |
| 诊断上报 | 把诊断压成 <diagnostics file="..."> 块,按严重级过滤、按条数与字符数截断、对字段做转义 | agent/lsp/reporter.py | 觉得模型看到的报错太少或太长时 |
协议客户端 LSPClient | 一个服务器进程的完整生命周期:spawn、initialize、didOpen、等诊断、shutdown | agent/lsp/client.py | 想看握手与新鲜度判定细节时 |
| 工作区判定 | git 工作树探测与各语言标记文件的向上查找 | agent/lsp/workspace.py | 明明装了服务器却整层不跑时 |
| 结构化事件日志 | 稳态静默、状态转换只喊一次的日志分级 | agent/lsp/eventlog.py | 想 grep “这次编辑到底跑没跑” 时 |
| 安装与 CLI | 二进制探测、按 npm/go 配方安装,以及 hermes lsp 子命令 | agent/lsp/install.py、agent/lsp/cli.py | 第一次上手体检的时候 |
管理器这块的取舍值得单独看。它只起一个后台守护线程跑一个 asyncio 事件循环(线程名 hermes-lsp-loop),同步调用方把协程丢进去阻塞等结果——这样文件操作层不用整体改成异步。客户端按(服务器 id、项目根)做键懒启动,同一个键上的并发请求共用一个进行中的 spawn future,不会一次编辑起两个 pyright。空闲客户端有回收:默认 600 秒无活动被扫掉,配置值低于 30 秒会被夹到 30,注释写明了理由——回收周期低于单次等待预算,会把飞行中的客户端收掉,外层一超时又把这对拉黑,等于自己给自己制造故障。
协议层刻意做得很薄:Content-Length 头加空行加 UTF-8 JSON body,编码时用紧凑分隔符保证长度计数精确,读取时干净 EOF 返回 None、坏帧抛 LSPProtocolError。两个防御上限值得记:头块累计超过 8 KiB 直接判违规,body 长度超过 64 MiB 判不合理。它还把两类错误分开——协议本身坏了是 LSPProtocolError,服务器按规范回了一个错误响应是 LSPRequestError,后者带着 JSON-RPC 的 code,ERROR_CONTENT_MODIFIED 这类码被单独拎出来命名,因为它的含义是”内容变了,重试即可”而不是”出故障了”。
注册表把服务器定义当数据而不是分支来写。每条定义包含四件事:匹配哪些扩展名、怎么找项目根、怎么拼启动命令、以及这个服务器有什么怪癖需要标记。匹配规则是取扩展名,扩展名为空时退回文件名本身——所以 Dockerfile 这种没后缀的文件也能匹配上。项目根各语言各认自己的标记文件:Python 认 pyproject.toml、setup.py 这一串,Rust 认 Cargo.toml,Go 认 go.work/go.mod。命令细节也都是数据:clangd 带 --background-index --clang-tidy 启动,intelephense 的初始化选项里默认关掉遥测,pyright 启动前会先在项目里找虚拟环境解释器(依次看 VIRTUAL_ENV、.venv、venv)填进初始化选项,因为它默认会用 PATH 上的 python,那基本不是你项目的那个。
SERVERS: List[ServerDef] = [
ServerDef(
server_id="pyright",
extensions=(".py", ".pyi"),
resolve_root=_root_python,
build_spawn=_spawn_pyright,
description="Python — Microsoft pyright",
),
# ……其余 26 条同构
]
上报这块有一个细节最值得抄走:诊断文本被当成不可信输入处理。语言服务器刚刚解析过仓库里的源码,一个恶意仓库完全可以把指令样的文本塞进标识符名、类型别名或 import 路径里,诊断信息就会把这段文本原样回显进模型要读的那个块。所以 message、code、source 三个字段在拼进输出前会被折叠换行、丢掉控制字符、按字段截断(消息 300 字符)、并转义尖括号与 &;文件名放进 file="..." 属性时连引号一起转义,防止构造出来的文件名提前闭合属性再伪造新标签。这条思路和 提示注入怎么防 里讲的一样:凡是从外部流进模型上下文的文本,都算攻击面,工具返回值也不例外。
三、一次写入在这条链路上怎么走
顺着代码走一遍,你会发现真正复杂的不是调用 LSP,而是判断”这次结果算不算数”。
- 先看后端是不是本地的。远程或沙箱后端(Docker、Modal、SSH 之类)直接跳过——文件在沙箱里,主机上的语言服务器看不见它。
- 再看有没有服务器认领这个扩展名。这一步只查静态注册表,很便宜,用来决定要不要为行映射保留一份编辑前的内容。
- 写之前拍基线快照。这一步是尽力而为的:任何失败都被咽掉,还会顺手把超时的(服务器、项目根)拉黑,因为语言服务器不该有资格搞坏一次写入。
- 执行写入。
- 写完拿增量诊断。客户端侧的动作是:发 didOpen(languageId 由扩展名映射表决定,少数服务器会拒绝 ID 不对的文件)、发保存通知、在预算内等诊断、只取新鲜的那一批。
- 这里有一个必须区分的三态:拿到诊断列表、拿到空列表、拿不到结论。空列表意味着”服务器看过新内容了,干净”;拿不到结论意味着”在预算内它没给出针对新内容的答复”。混淆这两者的后果代码注释里直接点了名——会把上一次编辑的错误当成这次的报出来。所以后者返回空、并且不拉黑服务器:慢不等于死。
- 有诊断才格式化成块交给模型,并做总长截断。
新鲜度是这条链路的地基。客户端为每个打开的文档记一个版本号,didOpen 是 0、每次变更加一,服务器推来的诊断被打上它所描述的版本;只有版本追平当前版本的结果才算新鲜。这套设计的好处是变更一发生,所有旧结果自动作废,不需要清缓存也不需要比时间戳。
四、这比让模型硬读文本强在哪,代价在哪一侧
强的地方有五条,都很具体:判断来自真实工具链而不是模型的印象;定位精确到行列,模型不用二次搜索;只给这次改动引入的部分,不让历史欠债干扰当前任务;输出量被硬性裁到 20 条、4000 字符,不会挤掉上下文预算;外部文本被当注入面处理,不是原样拼进提示词。
代价也不含糊。你的机器上会多出一个进程家族——pyright、gopls、rust-analyzer 各自都是常驻子进程,吃内存、吃文件句柄。冷启动有等待,仓库文档给的量级是多数服务器 1 到 3 秒,rust-analyzer 在冷项目上可能 10 秒以上。时间预算需要人调:wait_mode 在 document 和 full 之间选,对应的默认等待是 5 秒和 10 秒,大项目上偏小。最需要提前想清楚的是失败模式变多了:spawn 失败、握手超时、服务器不吐诊断、二进制不在 PATH,每一条的处理都是静默降级回语法检查。这是双刃的——写入永远不会被这层搞坏,但你也很难注意到它其实一直没在工作。这时候能依赖的只有日志与状态命令,也就是 agent 的可观察日志 那篇讲的事:稳态静默、状态转换喊一次,这层的事件日志正是按这个原则分级的——干净结果走 DEBUG,客户端首次启动、诊断到达走 INFO,二进制找不到、超时、spawn 失败走 WARNING,且大多做了”每个键只喊一次”的去重。
五、边界与代价:它明确不管什么
只在 git 工作树里跑。 工作区判定会从当前目录和被编辑文件各走一遍向上查找 .git,两条都不在 git 工作树里,整层不启动。这不是疏漏而是有意的:常驻在聊天网关上的 agent,当前目录往往就是用户家目录,没有项目可诊断,起一堆守护进程纯属浪费。
只在本地后端跑。 远程与沙箱后端整条路径直接跳过。你要是把 agent 跑在容器里、指望主机上的语言服务器给出诊断,那是拿不到的。
拉黑是进程级的、不自动恢复。 某个(服务器、项目根)一旦 spawn 或初始化失败,本进程剩余生命周期内都不会再试,为的是不重复支付超时成本。副作用是你修好了二进制也得手动重启这层才生效。
默认只把 ERROR 交给模型。 严重级过滤的默认值是只放行 severity 1,warning、info、hint 全不进模型。上报函数留了一个严重级参数,但写入链路的调用点没有传它——所以现在这是一个模块默认值,不是一个可配置项。想让模型看见警告,得改代码。
只看被编辑的那个文件。 交给模型的诊断块是按被编辑文件组织的,改 A 文件把 B 文件的调用点弄坏这类跨文件回归,不在这层的职责里。
它不修代码。 这层的产出就是一段文本,改不改、怎么改是模型的事;它也不把跳转、悬停、补全这些语言服务器能力端给模型,向写入链路暴露的只有诊断这一件。
有一种”假绿”它只能提醒不能解决。 bash-language-server 把诊断委托给外部的 shellcheck,后者不在 PATH 上时服务器照样启动、照样接受请求、永远报 0 个问题。项目为此加了一次性 WARNING 和状态命令里的一节专门警告,但如果你不看,看到的就是”一切正常”。
自动安装会真的联网写盘。 安装策略为 auto 时,缺失的二进制会按 npm 或 go 的配方拉下来,装进 Hermes 自己的家目录下(<HERMES_HOME>/lsp/bin/),注释里写明这样做是为了不污染用户的全局环境。这件事的代价要认:它会走公共包仓库下载并在磁盘上落文件,等于把一批第三方二进制的供应链风险接进了你的机器,而触发时机是你某次编辑了对应语言的文件,不是你主动敲了安装命令。不想让它动网络和磁盘,就把策略设成 manual,只用 PATH 上已有的二进制,缺哪个自己按发行版的渠道装。
清理只挂在正常退出上。 服务单例首次创建时注册了退出钩子,正常退出会回收语言服务器;被信号强杀不走这条路。仓库注释认为这可接受——语言服务器是无状态子进程,随父进程被内核回收即可。
六、上手与避坑清单
不在 git 仓库里,整层静默不跑。 会踩是因为它不报错也不提示,你只会觉得”没生效”。避法:确认工作目录在 git 工作树内,新项目先 git init,再用 hermes lsp status 看服务是不是 enabled、有没有活跃客户端。
以为容器里也能用。 会踩是因为文档上”内置 LSP”看起来是全局能力,实际它要求文件在本机可见。避法:把这层当本地后端的专属增强,远程后端的质量门用别的手段兜(测试、CI)。
二进制没装,只在日志里喊过一次。 会踩是因为这层为了不刷屏做了去重:同一个(服务器、二进制)只 WARNING 一次,之后降级成 DEBUG,你回头翻日志很可能翻不到。避法:上手第一件事跑 hermes lsp status 看清单里每个服务器的安装状态,hermes lsp which <id> 确认解析到哪个路径,hermes lsp install <id> 或 hermes lsp install-all 补齐。
shell 脚本永远零报错。 会踩是因为服务器装了但 shellcheck 没装,表现是”能用但从不发现问题”。避法:看状态输出里有没有 Backend warnings 一节,按提示用系统包管理器装上 shellcheck。
大项目上一次超时之后就再也不报了。 会踩是因为超时会把这个(服务器、项目根)拉黑到进程结束,而拉黑本身只有一行 WARNING。避法:先把等待预算调够(lsp.wait_timeout 调大,或 lsp.wait_mode 设成 full),已经拉黑的用 hermes lsp restart 清掉,下次编辑会重新启动。
把空闲回收时间设得很小以求省内存。 会踩是因为小于 30 秒的值会被夹到 30,你以为设成了 5 秒,实际不是;真要按你的想法生效反而会造出”回收撞上飞行中请求”的故障。避法:要么留默认,要么设 0 彻底关掉回收、用内存换索引常热。
Deno 项目里等不到 TypeScript 诊断。 会踩是因为 TypeScript 的项目根查找带排除标记:向上走的时候先遇到 deno.json 或 deno.jsonc,这个服务器就对该文件整体让位了。避法:知道这是设计而非故障,Deno 项目按自己的工具链做检查。
Python 报一堆”找不到模块”。 会踩是因为解释器探测只看环境变量和项目下的 .venv、venv 两个位置,你的虚拟环境放在别处它就找不到,pyright 会拿系统 python 去解析依赖。避法:在按服务器配置里用初始化选项显式指定解释器路径,或用 command 直接钉死二进制。
指望它替代 lint 与风格检查。 会踩是因为默认只放行 ERROR 级,风格、可疑写法、未使用变量这些多数是 WARNING 级,根本不会进模型视野。避法:把这层定位成”语义错误捕获器”,代码规范交给独立的 lint 步骤,并在给 agent 的验收标准里写清哪一步管哪一类问题。
排查时不知道从哪看。 这层用了一个独立的日志名(hermes.lint.lsp),每行带 lsp[服务器 id] 前缀,文档给的看法是 grep 这个前缀;语言服务器自己的 stderr 和协议错误落在客户端模块的日志里。
结尾:三个自检问题和下一站
装完之后按顺序问自己三句话,能省掉大半排查时间:状态命令里服务是不是 enabled、我在编的这个文件的语言在不在清单里且状态是已安装;改一个明显的类型错误再写一次,写入结果里的诊断字段是不是真的有内容;如果没有,是被 git 门、本地后端门、拉黑集合还是等待预算挡住的。这三问对应的正是前面拆的那几层判断。这套做法本质上是给 agent 加一个外部裁判,而不是让它自己回头看一眼——为什么外部裁判比自省可靠,agent 自查了一轮还是错 那篇有更一般的讨论。
接着往下读的顺序建议是:agent/lsp/client.py 看握手与新鲜度判定的完整实现,这是整层最厚的一块;agent/lsp/range_shift.py 看行映射是怎么算的;agent/lsp/workspace.py 看向上查找的边界处理(层数上限、权限错误、跨盘符);agent/lsp/install.py 和 agent/lsp/cli.py 看安装配方与体检命令。想验证自己有没有读偏,直接照着 agent/lsp/ 下的文件对一遍——这些判断都写在注释里,作者把理由留在了代码旁边。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 自托管开源项目 Hermes Agent 的 ACP 适配层 和 开源自托管 Agent 项目 Hermes Agent 的可观测四块拼图。