grep 能把字符串找出来,却解释不了这个标识符对应哪个声明、有哪些跨文件引用、类型约束是什么。智能体因此容易改错文件、改错重载。本文把 Language Server Protocol 的 definition、references、hover 收成可审计的只读工具:用 Node.js 拉起 language server、限制工作区白名单,并让每次跳转留下证据。
问题不在检索,而在「这是哪个符号」
给代码智能体接全文检索或滑窗切片,解决的是「仓库里有没有这段字符串」。它解释不了三件更关键的事:
- 同名符号:两个
add、两个Client,命中的是哪一个声明。 - 跨文件引用:改一处签名之后,真正的调用点在哪些文件。
- 类型约束:当前光标处的表达式是什么类型、能不能这样改。
检索召回越高,越容易把无关重名一起塞进上下文。模型看到两份相似实现,就会在错误的文件上改。这不是提示词能稳定消掉的问题,而是工具给的信息粒度不对:字符串命中替代了符号语义。
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 帧和内置模块,可以直接跑在演示项目上;换语言时保留这层协议,替换启动命令即可。它降低的是改错文件、改错重载的概率,不是把代码修改变成无需审查的黑盒。
评论