-
[1/8] Fill-in-the-Middle: как модель дописывает код в середине файла
-
[3/8] Context Ranking: как выбрать полезные фрагменты из репозитория
В предыдущей заметке обсуждалось как искать похожий код в репозитории. Для этого мы брали код около курсора, превращали его в поисковый запрос, находили кандидатов через BM25 и добавляли наиболее релевантные фрагменты в контекст модели.
Но код – это не просто текст. Импорт ссылается на конкретное объявление, переменная имеет определенный тип, а метод может принадлежать классу или интерфейсу. В этой заметке мы добавим в систему автодополнения источник контекста, который знает об этих связях внутри проекта.
Исторически поддержку языка программирования встраивали в конкретную IDE для того, чтобы можно было удобно подсказывать правильные методы, переходить к определению классов и функций, искать использования некоторого объекта. Для этого разработчики связывали компилятор, парсер или собственный анализатор с API выбранного редактора кода. Подобное решение, например, для Visual Studio Code нельзя было просто переиспользовать в Eclipse, Vim или Emacs.
Постепенно анализ языка начали выносить из редакторов кода в отдельные language services. У IDE появлялось разделение внутри на интерфейс взаимодействия с проектом и языковой движок, который отвечал на вопросы о типах переменных и ошибках.
27 июня 2016 года Microsoft, Red Hat и Codenvy публично объявили о совместной работе над Language Server Protocol. VS Code уже использовал JSON-протокол для общения с language servers, Codenvy добавляла его поддержку в Eclipse Che, а Red Hat работала над отдельным Java language server. Спецификацию открыли, чтобы один сервер можно было подключать к разным редакторам [5].
Вернемся к примеры из предыдущих заметок
// returns.ts
import { returnService } from "@/services/returnService";
interface ReturnRequest {
orderId: string;
reason: ReturnReason;
}
async function createReturn(request: ReturnRequest) {
ret// <CURSOR>
}
С помощью BM25 мы смогли найти файлы, в которых встречаются термины ReturnRequest и returnService. Но совпадение текста, как мы видели, ещё не означает связь между частями программы. Поиск не дает ответ на вопросы:
-
какое объявление скрывается за импортом
returnService? -
какой тип имеет эта переменная?
-
какие методы у неё доступны?
-
какие аргументы принимает нужный метод?
Для ответа нужно учитывать импорты, области видимости переменных и типы объектов. IDE уже умеет отвечать на эти вопросы. В конструкции из подсказки
returnService.submit(command);
IDE через анализатор кода (language server) видит цепочку связанных сущностей:
![[4-8] Semantic Context: как LSP помогает LLM понять код - 1 [4-8] Semantic Context: как LSP помогает LLM понять код - 1](https://www.braintools.ru/images/2026/08/20/4-8-Semantic-Context-kak-LSP-pomogaet-LLM-ponyat-kod.png)
преобразуя файлы с кодом в синтаксические деревья. В результате вместо набора строк получается граф определений, типов и вызовов. Задача системы автодополнения – запросить у IDE готовые связи и превратить ответы в контекст для модели.
Language Server Protocol, или LSP, стандартизирует общение между language client в редакторе и некоторым language server [1].
┌──────────────┐ JSON-RPC ┌─────────────────┐
│ editor / IDE │ ←─────────────────→ │ language server │
│ LSP client │ │ parser / types │
└──────────────┘ └─────────────────┘
В основе протокола лежит JSON-RPC 2.0, а запросы обычно содержат поля method, params и id, а ответ – тот же id и, либоresult , либо error. Далее вспомогательные параметры типа "jsonrpc": "2.0" и "id": 12 будет опускать.
Упрощённо, сессия между LSP клиентом и language server выглядит так:
editor language server
───────── initialize ───────────────────→
←──────── capabilities ──────────────────
───────── initialized ──────────────────→
───────── textDocument/didOpen ─────────→
───────── textDocument/didChange ───────→
←──────── publishDiagnostics ────────────
───────── textDocument/definition ──────→
←──────── location / locationLink ───────
───────── textDocument/didClose ────────→
───────── shutdown / exit ──────────────→
Во время initialize клиент и сервер обмениваются своими возможностями. Так редактор узнаёт, как синхронизировать документы и какие language features поддерживает сервер [2]. После этого didOpen и didChange поддерживают на сервере актуальную копию редактируемого файла, включая ещё не сохранённые изменения.
Далее клиент и сервер могут обмениваться запросами с разными методами. Вот некоторые из них [6]:
|
Метод |
Что возвращает |
Как использовать |
|
textDocument/hover |
Тип, сигнатуру и документацию |
Для описание |
|
textDocument/definition |
Место объявления |
Найти реальный контракт или реализацию |
|
textDocument/typeDefinition |
Объявление типа |
Вернуть позиции аргумента и результата для типа |
|
textDocument/references |
Места использования |
Найти места вызова или использования |
|
textDocument/implementation |
Реализации интерфейса |
Найти реализацию |
Сам протокол не понимает TypeScript, Go или Rust. Он задаёт формат сообщений, а качество ответа зависит от конкретного language server.
В этой заметке LSP используется как общее название доступа к семантической модели кода. Технически данные не всегда приходят через Language Server Protocol.
Плагины VS Code могут обращаться к language features самого редактора [4], а плагин IntelliJ IDEA – работать с PSI, references и индексами платформы. PSI тоже связывает использование элемента с его объявлением [3].
В LSP уже есть textDocument/completion. Если пользователь написал
returnService.
language server может вернуть допустимые элементы:
cancel
getById
submit
update
Такая подсказка отвечает на вопрос «какие символы допустимы в этой позиции?». В нашей системе автодополнения на базе LLM мы решаем более общую задачу: предсказать намерение разработчика и сгенерировать одно или несколько выражений.
Снова вернемся к нашему примеру. Разработчик написал в строке 8 (нумерация с 0) ret и ожидает от системы автодополнения подсказку. К этому моменту редактор кода уже синхронизировал returns.ts с language server. Посмотрим, какие запросы помогут собрать контекст для нашей подсказки.
Находим определение и тип
Как и раньше начнем с импортов. Посмотрим где в проекте объявляется returnService. Для этого наша система автодополнения на этапе формирования контекста отправляет в language server запрос textDocument/definition:
![[4-8] Semantic Context: как LSP помогает LLM понять код - 2 [4-8] Semantic Context: как LSP помогает LLM понять код - 2](https://www.braintools.ru/images/2026/08/20/4-8-Semantic-Context-kak-LSP-pomogaet-LLM-ponyat-kod-2.png)
Позиция (0, 12) попадает в первую строку и наш импорт, а там как раз находится returnService. Поэтому наш запрос по сути есть textDocument/definition(returnService). Языковой сервер для такого метода возвращает не исходный код, а его адрес – URI (адрес виртуального файла) и диапазон:
![[4-8] Semantic Context: как LSP помогает LLM понять код - 3 [4-8] Semantic Context: как LSP помогает LLM понять код - 3](https://www.braintools.ru/images/2026/08/20/4-8-Semantic-Context-kak-LSP-pomogaet-LLM-ponyat-kod-3.png)
LSP клиент читает файл по указанному адресу адресу и получает объявление нашего сервиса:
export const returnService: ReturnService =
new RemoteReturnService(apiClient);
Оно сообщает тип переменной, но пока еще не содержит контракты. Запрос textDocument/typeDefinition для той же позиции ведёт к интерфейсу ReturnService:
![[4-8] Semantic Context: как LSP помогает LLM понять код - 4 [4-8] Semantic Context: как LSP помогает LLM понять код - 4](https://www.braintools.ru/images/2026/08/20/4-8-Semantic-Context-kak-LSP-pomogaet-LLM-ponyat-kod-4.png)
По найденному диапазону (3, 17)-(3, 30) мы можем выделить интерфейс:
export interface ReturnService {
cancel(command: CancelReturnCommand): Promise<void>;
getById(id: string): Promise<Return | null>;
submit(command: SubmitReturnCommand): Promise<Return>;
}
Теперь модель сможет увидеть, что возврат создаётся через returnService.submit(...).
Раскрываем аргумент
В сигнатуре появился новый символ – SubmitReturnCommand:
submit(command: SubmitReturnCommand): Promise<Return>;
Метод textDocument/definition , теперь уже для позиции внутри returnService.ts, приводит к следующему объявлению:
export interface SubmitReturnCommand {
orderId: string;
reason: ReturnReason;
requestedBy: string;
}
Модель узнала точные поля аргумента:
return await returnService.submit({
orderId: request.orderId,
reason: request.reason,
requestedBy: /* unknown */,
});
Находим проектный паттерн
Но контракт отвечает только на вопрос что требует API. Он не объясняет, откуда брать requestedBy. Запросим references для метода submit. Сервер может вернуть десятки упоминаний. Среди них найдется вызов в retryReturn.ts
![[4-8] Semantic Context: как LSP помогает LLM понять код - 5 [4-8] Semantic Context: как LSP помогает LLM понять код - 5](https://www.braintools.ru/images/2026/08/20/4-8-Semantic-Context-kak-LSP-pomogaet-LLM-ponyat-kod-5.png)
Далее мы читаем файл и вырезаем функцию, внутри которой находится вызов:
async function retryReturn(failedReturn: FailedReturn): Promise<Return> {
const userId = authService.requireUserId();
return await returnService.submit({
orderId: failedReturn.orderId,
reason: failedReturn.reason,
requestedBy: userId,
});
}
Definition и type definition показали контракт. Reference показал, как этим контрактом принято пользоваться в проекте.
Собираем prompt
Теперь система автодополнения может собрать финальный FIM-запрос:
![[4-8] Semantic Context: как LSP помогает LLM понять код - 6 [4-8] Semantic Context: как LSP помогает LLM понять код - 6](https://www.braintools.ru/images/2026/08/20/4-8-Semantic-Context-kak-LSP-pomogaet-LLM-ponyat-kod-6.png)
У returnService.submit могут быть сотни других references. LSP решает именно задачу retrieval: формирует множество семантически связанных кандидатов. После этого всё равно необходимо ранжирование полученных кусков кода. Для каждого фрагмента можно учитывать:
-
текстовую похожесть на код около курсора;
-
совпадение типов и символов;
-
принадлежность к production-, test-, generated- или legacy-коду;
-
недавние изменения файлов или история просмотров;
-
размер фрагмента и его стоимость в токенах.
Итог
В первой заметке мы разобрали FIM-модель и составление промпта из PREFIX и SUFFIX. Во второй добавили дополнительный контекст. В третьей добавили retrieval и ranking с BM25. Теперь к текстовому поиску добавился еще и семантический слой.
Получилась следующая архитектура:
![[4-8] Semantic Context: как LSP помогает LLM понять код - 7 [4-8] Semantic Context: как LSP помогает LLM понять код - 7](https://www.braintools.ru/images/2026/08/20/4-8-Semantic-Context-kak-LSP-pomogaet-LLM-ponyat-kod-7.png)
На практике реальные системы автодополнения содержат еще и логику начала сбора контекста и генерации подсказок, а так же обработку ответа от модели.
Качество code completion определяет не только модель. Оно складывается из того, когда запустили генерацию, какой контекст нашли, как его ранжировали, как собрали итоговый промпт, как обработали полученный ответ и успели ли всё это сделать до следующего нажатия клавиши.
Для агента в будущем эта архитектура станет основой навигации по коду. Семантические инструменты проведут его по известным связям программы, а поиск по репозиторию поможет найти нужный код.
Полезные ссылки
-
Language Server Protocol – Microsoft. Назначение LSP, модель language client / language server и обмен сообщениями через JSON-RPC.
-
Language Server Protocol Specification 3.18 – Microsoft. Lifecycle протокола, capabilities и language features: hover, definitions, references, diagnostics и другие запросы.
-
PSI References – JetBrains. Связь между использованием элемента и его объявлением, разрешение ссылок и поиск references в IntelliJ Platform.
-
Programmatic Language Features – Visual Studio Code. Соответствие API редактора методам LSP и отдельный API для inline completions.
-
A Common Protocol for Languages – Visual Studio Code, 2016. Публичный анонс LSP совместно с Red Hat и Codenvy, предпосылки появления протокола и первые интеграции.
-
JSON-RPC 2.0 Specification – формат requests, responses, errors и notifications, который использует LSP.
Автор: denisart


