grep 能把字符串找出来,却解释不了这个标识符对应哪个声明、有哪些跨文件引用、类型约束是什么。智能体因此容易改错文件、改错重载。本文把 Language Server Protocol 的 definition、references、hover 收成可审计的只读工具:用 Node.js 拉起 language server、限制工作区白名单,并让每次跳转留下证据。

问题不在检索,而在「这是哪个符号」

给代码智能体接全文检索或滑窗切片,解决的是「仓库里有没有这段字符串」。它解释不了三件更关键的事:

  1. 同名符号:两个 add、两个 Client,命中的是哪一个声明。
  2. 跨文件引用:改一处签名之后,真正的调用点在哪些文件。
  3. 类型约束:当前光标处的表达式是什么类型、能不能这样改。

检索召回越高,越容易把无关重名一起塞进上下文。模型看到两份相似实现,就会在错误的文件上改。这不是提示词能稳定消掉的问题,而是工具给的信息粒度不对:字符串命中替代了符号语义。

Language Server Protocol(LSP)本来就是编辑器用来做「转到定义」「查找引用」「悬停类型」的协议。它走 JSON-RPC 2.0,方法名是稳定的:textDocument/definition、textDocument/references、textDocument/hover。智能体不需要再猜路径,只要给出文件和 0-based 行列,拿回结构化位置。

边界也要说清:LSP 依赖对应语言的 server 和项目配置(TypeScript 至少需要 tsconfig.json)。它不是通用源码搜索,也替代不了测试和 diff review。本文只把它当成只读查询层,用来降低「改错符号」的概率,而不是再把半个仓库塞进上下文。

把 language server 收成只读查询层

目标不是再包一个 IDE,而是给智能体三条不能写盘的工具:

  • gotoDefinition(file, line, character)
  • findReferences(file, line, character)
  • hover(file, line, character)

约束固定三条:

  • 只允许查询工作区白名单内的路径,拒绝 ../ 逃逸。
  • 只发 initialize、initialized、textDocument/didOpen、上述三个请求,以及 shutdown / exit。不发 workspace/applyEdit,也不把模型生成的补丁写回 server。
  • 每次请求把入参和返回值追加到 JSONL,事后能核对「当时跳到了哪」。

下面用 TypeScript 做演示,因为 typescript-language-server 是真实存在的 npm 包,协议也是标准 LSP。换成 gopls 或 rust-analyzer 时,JSON-RPC 帧格式不变,变的是启动命令和 languageId。

演示项目里的 add 有两个公开签名。grep add 会把声明和调用混在一起;hover 才能告诉你 use.ts 里第一次调用走的是数字重载。

// demo-src/math.ts
export function add(a: number, b: number): number;
export function add(a: string, b: string): string;
export function add(a: number | string, b: number | string): number | string {
  if (typeof a === "string" || typeof b === "string") {
    return String(a) + String(b);
  }
  return (a as number) + (b as number);
}
// demo-src/use.ts
import { add } from "./math";

export const n = add(1, 2);
export const s = add("hello", "world");
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "strict": true
  },
  "include": ["./**/*.ts"]
}

把上面这份 JSON 存成 demo-src/tsconfig.json。再准备 package.json:

{
  "name": "lsp-readonly-tools",
  "private": true,
  "type": "module",
  "dependencies": {
    "typescript": "^5.0.0",
    "typescript-language-server": "^4.0.0"
  }
}

最小实现:拉起进程并走 JSON-RPC

先 npm install。client 只用 Node.js 内置模块。LSP 消息是 Content-Length 头加 JSON 体,经 stdin/stdout 交换。位置全部按协议使用 0-based。Unix 上可以直接 spawn node_modules/.bin/typescript-language-server;Windows 上对应的是同名 .cmd。

// lsp-client.mjs
import { spawn } from "node:child_process";
import { existsSync, readFileSync, appendFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";

function serverCommand() {
  const bin = resolve("node_modules/.bin/typescript-language-server");
  if (process.platform === "win32") {
    return { command: `${bin}.cmd`, args: ["--stdio"] };
  }
  return { command: bin, args: ["--stdio"] };
}

export class LspClient {
  constructor({ workspaceRoot, logPath }) {
    this.workspaceRoot = resolve(workspaceRoot);
    this.logPath = logPath;
    this.nextId = 1;
    this.pending = new Map();
    this.buffer = Buffer.alloc(0);

    const { command, args } = serverCommand();
    this.proc = spawn(command, args, {
      stdio: ["pipe", "pipe", "pipe"],
      cwd: this.workspaceRoot,
    });
    this.proc.stdout.on("data", (chunk) => this.onData(chunk));
    this.proc.on("exit", (code) => {
      for (const [, { reject }] of this.pending) {
        reject(new Error(`language server exited: ${code}`));
      }
      this.pending.clear();
    });
  }

  assertInsideWorkspace(filePath) {
    const abs = resolve(this.workspaceRoot, filePath);
    const rel = relative(this.workspaceRoot, abs);
    if (rel.startsWith("..") || isAbsolute(rel)) {
      throw new Error(`path outside workspace: ${filePath}`);
    }
    return abs;
  }

  onData(chunk) {
    this.buffer = Buffer.concat([this.buffer, chunk]);
    while (true) {
      const headerEnd = this.buffer.indexOf("\r\n\r\n");
      if (headerEnd === -1) return;
      const header = this.buffer.slice(0, headerEnd).toString("utf8");
      const match = header.match(/Content-Length:\s*(\d+)/i);
      if (!match) throw new Error(`invalid LSP header: ${header}`);
      const length = Number(match[1]);
      const bodyStart = headerEnd + 4;
      if (this.buffer.length < bodyStart + length) return;
      const body = this.buffer.slice(bodyStart, bodyStart + length).toString("utf8");
      this.buffer = this.buffer.slice(bodyStart + length);
      const msg = JSON.parse(body);
      if (msg.id != null && this.pending.has(msg.id)) {
        const { resolve: ok, reject } = this.pending.get(msg.id);
        this.pending.delete(msg.id);
        if (msg.error) reject(new Error(JSON.stringify(msg.error)));
        else ok(msg.result);
      }
    }
  }

  send(message) {
    const json = JSON.stringify(message);
    const payload = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n${json}`;
    this.proc.stdin.write(payload);
  }

  request(method, params) {
    const id = this.nextId++;
    return new Promise((ok, reject) => {
      this.pending.set(id, { resolve: ok, reject });
      this.send({ jsonrpc: "2.0", id, method, params });
    });
  }

  notify(method, params) {
    this.send({ jsonrpc: "2.0", method, params });
  }

  audit(event) {
    if (!this.logPath) return;
    appendFileSync(
      this.logPath,
      `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`
    );
  }

  async initialize() {
    const rootUri = pathToFileURL(this.workspaceRoot).href;
    const result = await this.request("initialize", {
      processId: process.pid,
      rootUri,
      capabilities: {
        textDocument: {
          definition: { linkSupport: false },
          references: {},
          hover: { contentFormat: ["plaintext", "markdown"] },
        },
      },
      workspaceFolders: [{ uri: rootUri, name: "workspace" }],
    });
    this.notify("initialized", {});
    return result;
  }

  async openFile(filePath) {
    const abs = this.assertInsideWorkspace(filePath);
    if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
    const uri = pathToFileURL(abs).href;
    this.notify("textDocument/didOpen", {
      textDocument: {
        uri,
        languageId: "typescript",
        version: 1,
        text: readFileSync(abs, "utf8"),
      },
    });
    return uri;
  }

  async definition(filePath, line, character) {
    const uri = await this.openFile(filePath);
    const result = await this.request("textDocument/definition", {
      textDocument: { uri },
      position: { line, character },
    });
    this.audit({ method: "definition", filePath, line, character, result });
    return result;
  }

  async references(filePath, line, character) {
    const uri = await this.openFile(filePath);
    const result = await this.request("textDocument/references", {
      textDocument: { uri },
      position: { line, character },
      context: { includeDeclaration: true },
    });
    this.audit({ method: "references", filePath, line, character, result });
    return result;
  }

  async hover(filePath, line, character) {
    const uri = await this.openFile(filePath);
    const result = await this.request("textDocument/hover", {
      textDocument: { uri },
      position: { line, character },
    });
    this.audit({ method: "hover", filePath, line, character, result });
    return result;
  }

  async shutdown() {
    await this.request("shutdown", null);
    this.notify("exit");
    this.proc.stdin.end();
  }
}

const workspaceRoot = resolve(process.argv[2] || "./demo-src");
const client = new LspClient({
  workspaceRoot,
  logPath: resolve("lsp-audit.jsonl"),
});

const result = await client.initialize();
const hover = await client.hover("use.ts", 2, 16);
const def = await client.definition("use.ts", 2, 16);
const refs = await client.references("math.ts", 0, 16);
console.log(JSON.stringify({ capabilities: !!result.capabilities, hover, def, refs }, null, 2));
await client.shutdown();

运行:

npm install
node lsp-client.mjs ./demo-src

use.ts 第 3 行(0-based 为 2)的 add 从列 16 开始。hover 应返回数字重载的签名;definition 应落到 math.ts 的声明;references 会列出声明和两处调用。返回值可能是单个 Location、数组,或 server 选择的 LocationLink——调用方原样保留即可,不要在 client 里猜。

assertInsideWorkspace 用 path.relative 判断逃逸,这是 Node 里常见的白名单写法。智能体传入 ../secrets/id_rsa 时应直接抛错,而不是让 language server 去读。

白名单、审计,以及给智能体的约束

JSONL 审计是这条工具链里最便宜的保险。每一行都有时间戳、方法名、文件、行列和原始 result。事后对 diff 时,先看审计:如果 definition 指向 math.ts,模型却去改了另一个 add,那是模型没遵守工具结果,而不是检索没找到字符串。

给智能体暴露工具时,建议把协议收得更窄:

  • 入参只接受工作区相对路径和整数行列,不接受任意 URI。
  • 出参把 file:// 转回相对路径,避免模型在另一套路径规则里迷路。
  • hover 优先取 contents.value 或纯文本,截断到几百字;definition / references 只保留 uri + range。
  • 一次任务先 hover,再 definition,再 references;仍不够再读目标文件的局部,而不是先 grep 整个目录。
  • 只读层不负责打补丁。写文件走你现有的受审 patch 流程,和 LSP 进程隔离。

这套东西解释不了运行时行为,也处理不了没有 language server 的文件。对生成代码、多副本 vendored 库,server 的索引范围以 tsconfig.json 为准,不要假设「磁盘上能看到就一定能跳转」。这些限制写进工具描述里,比在系统提示里反复强调「请仔细搜索」有效。

总结

全文检索回答「字符串在哪」,LSP 回答「这个符号是谁、被谁用、类型是什么」。把 definition、references、hover 收成带白名单和 JSONL 证据的只读工具,智能体才能按符号跳转,而不是靠 grep 碰运气。上面的 Node.js 实现只用标准 LSP 帧和内置模块,可以直接跑在演示项目上;换语言时保留这层协议,替换启动命令即可。它降低的是改错文件、改错重载的概率,不是把代码修改变成无需审查的黑盒。