DeepSeek Harness 接了 LSP:语言服务给 Agent 带来了什么、又限制了什么

2026-08-17

先说清楚我们要解决的那个具体问题:模型准备改一个函数签名,需要知道「这个符号到底在哪些地方被引用」。用 grep 搜名字,同名的局部变量、注释里提到的字符串、另一个模块里恰好重名的导出会一起冒出来,模型没法判断哪些该改。语言服务器能回答这个问题,但把一个 LSP 服务器塞进 Agent 循环里,代价是多出一个长期存活的子进程、一套坐标约定和一堆握手细节。

DeepSeek Harness 仓库把这件事切在了 packages/lsp 下的三个包里:lsp(seam 定义与 provider 注册表)、lsp-stdio(通过 stdio 说 LSP 的通用后端)、tool-lsp(模型看得见的那个 lsp 工具)。下面沿着一次查询从模型参数走到子进程再走回来,把途中每一处会咬到你的地方标出来。

需要先立一条限定:该仓库 README 自述处于开发者预览阶段,并明写「会有破坏兼容性的变更」。本文提到的字段名、默认值、配置写法都是我们从这个快照读出来的,随时可能变。

第一段:模型给的是一基坐标,seam 要的是零基

packages/lsp/tool-lsp/src/index.ts 里注册的工具参数只有四个:operationfile_pathlinecharacter。工具描述原文写明 line and character are one-based UTF-16 cursor coordinates,也就是模型按人看编辑器的习惯从 1 数。

而 seam 侧的 LspPosition 是零基 UTF-16,跟协议一致。转换发生在 packages/lsp/tool-lsp/src/render.tsparseLspArgs() 里,那一行还带着注释 The model counts from 1; the seam (and protocol) count from 0.

position: { line: line - 1, character: character - 1 },

同一个文件里的 oneBased() 要求这两个值必须是正整数,写 0 会直接抛 line must be a positive integer (one-based)。所以「差一」这类问题在这条链路上只有一个可能的发生点,不会在多层里各错一次。反过来说,如果你自己直接调 ctx.lsp.query()(比如在插件里),传的就必须是零基坐标,别照抄工具的参数语义。

另外注意 tool-lsp 往系统提示里插了一段固定文本,位置是 ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, ... }),内容里有一句值得记住的话:an off-symbol position may return no results。光标没落在符号上就是没结果,不是服务器坏了。

第二段:workspace 没有兜底

execute() 拿到参数后第一件事不是查询,而是取工作区:

const workspaceRoot = sessionCwd(exec)
if (workspaceRoot === undefined) {
  throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED')
}

packages/lsp/tool-lsp/src/session-cwd.ts 只有一行实现,读的是 exec.agent?.session.header.cwd。这个文件的模块注释自述了这里和文件工具的差别:文件类工具有 provider 兜底,LSP 没有 fallback,缺 cwd 就直接以 LSP_WORKSPACE_REQUIRED 失败,理由是本地 provider 必须先把一个真实工作区规范化出来才能启服务器。

这条对排查很有用:如果 lsp 调用报的是 LSP_WORKSPACE_REQUIRED,问题不在语言服务器,而在调用方根本没有会话工作区。

第三段:路由只看扩展名,而且是独占的

seam 的选择逻辑在 packages/lsp/lsp/src/index.tsLsp.query() 里,就一句 this.routes.get(finalExtension(request.filePath)),查不到直接抛 LSP_UNAVAILABLE

finalExtension() 的行为在同文件里写得很死:取最后一个扩展名、转小写、带前导点,Foo.TS 归一成 .tsfoo.d.ts 也归成 .tsdot <= 0 的情况——没有点,或者像 .bashrc 这种以点号开头的 dotfile——返回空串,而空串永远匹配不到任何路由。

注册侧更严格。registerProvider() 在做任何写入之前先把 id 和全部扩展名验一遍:扩展名要匹配 /^\.[^./\\]+$/,同一个 provider 里 .TS.ts 算重复(LSP_INVALID_PROVIDER),跨 provider 撞同一个扩展名报 LSP_CONFLICT。也就是说,一个扩展名在全局只能归一个语言服务器,你没法给 .ts 同时挂两个后端再让 seam 挑一个。文档 docs/subsystems/lsp.md 也写了选择是逐查询且与注册顺序无关。

第四段:进程按「provider × 规范化工作区」池化

后端在 packages/lsp/lsp-stdio/src/index.ts。配置形状是一张表:servers 下每个 key 就是一个 provider id,值里 commandextensionToLanguage 是必填。仓库里有一份可参考的写法,在 examples/acp-agent/tests/lsp.cordis.yml(这是测试用的编排,不是给你直接抄的生产配置):

- id: lsp-stdio
  name: '@deepseek-ai/dsh-lsp-stdio'
  config:
    servers:
      fixture:
        command: !!js process.execPath
        args: ['./lsp-server.mjs']
        extensionToLanguage:
          '.ts': typescript

可执行文件是在插件加载时就解析的(ctx.subprocess.resolveExecutable),而且是全部解析完才发布任何一个 provider——apply() 里的注释自述这么做是为了「一个靠后的坏命令不能把靠前的 provider 发布出去」。真正的进程则是懒启动,第一次匹配到的查询才拉起来。

池化的粒度写在 LocalLspProvider 的私有字段上:instancesMap<WorkspaceKey, LspInstance>,key 来自 canonicalizeWorkspace() 返回的 target.targetKey。同一个工作区还有一条 queues,把「读文件 → didOpen → 请求 → didClose」整条生命周期串行化;不同工作区的实例并行。这意味着两次针对同一个仓库的查询是排队的,不是并发的。

读源码这一步在 packages/lsp/lsp-stdio/src/host.tsreadHostSource():先 fs.contains(workspace.target, target) 做包含性检查,落在工作区外面直接报 source "..." resolves outside the workspace;然后流式读,超过 maxDocumentBytes 就停并报错。这几个上限的默认值都在 index.ts 顶部:

常量默认值含义(取自源码注释)
maxDocumentBytes4000000本 host 愿意打开的最大源文件字节数
maxMessageBytes16000000接受服务器单条帧消息的最大字节数
maxStderrBytes1000000保留用于诊断的 stderr 尾部字节数
shutdownTimeoutMs5000优雅 shutdown/exit 的预算
killGraceMs2000请求取消宽限,以及 SIGTERM→SIGKILL 的窗口

这些是配置默认值,不是对运行表现的承诺;validateServerConfig() 会在加载期把非正整数直接打回去,注释里点名了 maxStderrBytes 为 0 时 slice(-0) 会保留全部这种坑。

第五段:握手时被挡回来的几种情况

packages/lsp/lsp-stdio/src/instance.tsinitialize 发出去的 processId 是写死的 null,注释自述的理由是子进程 provider 可能跑在另一个 PID 命名空间或另一台机器上,传宿主 PID 会让服务器去监控一个无关进程。客户端能力 CLIENT_CAPABILITIESgeneral.positionEncodings 只有 ['utf-16']negotiatePositionEncoding() 对任何非 utf-16 的返回值直接抛错——服务器只肯说 UTF-8 坐标,这条链路就走不通。

握手过了还有两道能力检查,都在 runQuery() 里:

  • supportsOperation() 看服务器有没有声明对应的 provider 能力,没有就抛 LSP_UNSUPPORTED_OPERATION
  • supportsTransientOpen() 检查 textDocumentSync。这里有个容易踩的细节:旧的枚举形式 Full(1)/Incremental(2) 视为支持开关文档,但对象形式必须显式写 openClose: true,因为协议里省略 openClose 默认是 false。不满足同样抛 LSP_UNSUPPORTED_OPERATION,消息是 server does not support the transient textDocument/didOpen this host requires

服务器反向发过来的请求,这个 host 只答三样:workspace/configuration 用配置里那个静态值逐项回答;window/workDoneProgress/createclient/registerCapabilityclient/unregisterCapability 一律回 nullworkspace/applyEdit 直接拒绝,注释是 This host never applies edits or runs commands。其余方法一概 unsupported server request

第六段:取消和收尾

一次查询被取消时,raceAbort() 会发 $/cancelRequest,然后用 killGraceMs 起一个 deadline() 等服务器认账。宽限内还没有 settle,就走 startTeardown() 把整个实例拆掉——注释写明理由是不能让一个还在跑的请求跨到下一次查询的文档生命周期里去。拆除顺序是先试 shutdown/exit(受 shutdownTimeoutMs 约束),再 terminate() 走 SIGTERM→SIGKILL 升级,最后 await 进程与整棵进程树退出,这两个 await 是故意不设上限的(注释自述:升级已经承诺了 SIGKILL,disposal 欠调用方的后置条件是静默而不是又一个定时器)。

传输层失败还有一次透明重试:provider 的 query() catch 到错误后,若 instance.isTransportFailure(error) 为真,会 dispose 掉旧实例、从池里剔除、重建一个再查一次——注释给的依据是「查询是只读的」。只重试一次。

第七段:结果怎么回到模型,以及两个截断

导航类操作统一归一成 locationshover 归一成内容或 null,这是一个封闭联合。locations 里额外带一个 resolvedWorkspaceUri,是 provider 给出的规范工作区 file: URI。render.tsrenderUri() 就用它来做相对化,而不是拿请求里那个可能是符号链接的路径按宿主平台规则去解析。

渲染有两个默认上限,在 render.ts 顶部:DEFAULT_MAX_LOCATIONS = 100DEFAULT_MAX_RESULT_CHARS = 16_000。超过 100 条会在末尾追加一行 … N more locations omitted (limit 100).,超过字符上限则整体截断并写明 … locations truncated (limit 16000 characters).——注意 boundResult() 是把这条提示本身也算进上限里的。工具调用本身还挂了 DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000 的超时预算,交给 dsh-tool-call-timeout-policy 去执行。

对 Windows 读者有一处值得单独看:renderUri() 里有段注释说 file: URI 不携带它所属世界的操作系统信息,所以开头形如 /X: 的段会被当成 Windows 盘符;一个真正根在 /c:/... 的 POSIX 工作区会显示错。注释同时写明这只影响显示,编辑和读取用的是精确 URI。

它明确不做的那些事

docs/subsystems/lsp.md 里把边界写得比代码还直白,这几条建议直接当限制读:

  1. 只有四个操作goToDefinitionfindReferencesgoToImplementationhover。文档明说符号搜索与调用层级不在其中,理由是「它们需要不同的 schema」(文档自述)。
  2. 没有 JSON-RPC 逃生口。seam 不暴露协议类型、进程/文档控制,也没有透传任意方法的口子。想让服务器干别的,这条路不通。
  3. findReferences 永远包含声明,由 provider 内部保证,调用方连开关都没有。
  4. 没有重命名、没有编辑workspace/applyEdit 是硬拒的。
  5. 文件必须在工作区内,且不超过 maxDocumentBytes
  6. 错误码是稳定的:LSP_INVALID_PROVIDERLSP_CONFLICTLSP_UNAVAILABLELSP_DISPOSEDLSP_UNSUPPORTED_OPERATIONLSP_MALFORMED_RESPONSE,文档明确要求调用方按 code 分支,而不是去解析 message。

还有一件事需要照实说:docs/tool-catalog.md 写明这个工具需要运行时有一个已注册的 provider,否则查询返回结构化的 LSP_UNAVAILABLE。而我们在这个快照的 apps/ 目录下没有检索到任何对 lsp 的引用,能找到的完整装配都在 examples/ 与测试编排里。换句话说,要用它你得自己把 dsh-lspdsh-lsp-stdiodsh-tool-lsp 三个插件配上,并给出语言服务器的可执行命令。

顺带记一处口径不一致:docs/capability-seams.mdctx.lsp 那一行把实现方写作 lsp-local(该表由 scripts/gen-doc-graphs.ts 生成,其中第 538 行也是 implementations: ['lsp-local']),而仓库里实际存在的目录是 packages/lsp/lsp-stdio.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md 也记录了 dsh-lsp-localdsh-lsp-stdio 的改名。两处不一致,以我们实读到的目录结构为准。

什么时候值得开

系统提示里那段文本其实已经给了判断标准,原文是:先用 search/read 做普通导航,当文本匹配有歧义、或者动手改之前需要精确的定义/实现/引用时才用 lsp。这跟前面那些边界是自洽的——它不是用来替代搜索的,是用来在搜索给不出确定答案的那一步收口的。

反过来,如果你的代码库没有可用的语言服务器、或者服务器不肯声明 openClose、或者只支持 UTF-8 坐标,那这条链路会在握手或第一次查询时就明确失败,不会退化成「勉强能用」。这在排查时反而省事:报的是 LSP_UNSUPPORTED_OPERATION 还是 LSP_UNAVAILABLE,直接对应到「服务器能力不够」和「压根没有 provider 接这个扩展名」两类完全不同的处置。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

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