Локальный AI-ассистент в VS Code: Запускаем gpt-oss 20B (MoE) с плагином Kilo Code на 32 ГБ ОЗУ

—

от автора

Многие разработчики рано или поздно задумываются о том, чтобы перенести AI-ассистента к себе на локальную машину. Причины разные: корпоративные политики безопасности, нежелание «сливать» свой код в облако или просто спортивный интерес.

Если вы еще ни разу не пробовали развернуть оффлайн-референс, сейчас — самое время. Спойлер: вам не нужен суперкомпьютер. Если у вас есть 32 ГБ оперативной памяти и относительно свежее железо, этого более чем достаточно для комфортной работы.

В этой статье мы возьмем модель класса 20B (например, gpt-oss:20b). Звучит тяжело, но благодаря архитектуре MoE (Mixture of Experts) модель активирует только часть своих параметров для каждого токена, поэтому скорость генерации вас приятно удивит даже без топовой видеокарты.

А в качестве интерфейса мы будем использовать один из самых популярных и продвинутых плагинов для VS Code — Kilo Code App. Это мощный форк известного OpenCode (Continue), который включает в себя значительно больше изменений, новшеств и заточен под серьезную работу с агентами и тулами (tools).

🛑 Проблема: Когда ИИ сходит с ума

Итак, вы скачали модель, установили Kilo Code App, настроили подключение к локальному серверу Ollama, нажимаете «Сгенерировать»… и вместо помощи получаете бесконечный поток шизофрении в логах.

Выглядит это примерно так:

Plaintext

work with this python code and repo and find issuesWe need to explain what to do? maybe user needs guidance on something. They didn't ask question explicitly yet.We must respond? Maybe answer expected to be helpful. Let's see. Probably they want assistant to act as J.A.N. help? Could use skill "kilo-config". Provide explanation of config and usage.Let's read AGENTS.md for context. Search.Model tried to call unavailable tool 'repo_browser.search'. Available tools: agent_manager, agent_manager_models, background_process, bash, chart, edit, glob, grep, kilo_local_recall, open_plan, question, read, skill, suggest, task, todowrite, webfetch, write.Need search. Use grep.The bash tool was called with invalid arguments: ["command"]: is missing and is required. Please rewrite the input so it satisfies the expected schema.We need to respond to user request. They tried a bash tool but error. The system probably expects us to help them? Let's view conversation. There's no previous message from user (except we are ChatGPT). Wait: The system says “You are a large language model …” and the user said nothing else. But then we got an invocation: "The bash tool was called with invalid arguments: ["command"]: is missing and is required." Actually, that's our own error in calling bash. No user query exists.Wait maybe the conversation has not started - but user might be trying something? The system likely expects me to respond or no? It's ambiguous.We may need to provide some guidance about using bash tool correctly? But I suspect this is a bug: The assistant attempted to call the bash function but incorrectly, producing error. There's no input from user specifying command. Maybe we should ask for clarification? According to instruction: respond in commentary for updates while working; final channel with answer. Since there's no actual user instruction, maybe we should explain that we can't do anything until they give a valid command.I'll output a final message asking what they'd like me to do and how to use bash correctly.It looks like the last attempt to run the bash tool was made without specifying a command. The function requires a string argument named command, so an empty list caused the “missing required field” error.When you want me to execute shell commands, please provide them as a single string in the command: key of the request, e.g.:{  "name": "bash",  "arguments": {    "command": "ls -la"  }}If you have any specific task or command you'd like me to run—or if you need help formatting a tool invocation—just let me know!3.1 t/s

Что здесь произошло? Модель попыталась вызвать инструмент (tool) bash, но забыла передать аргументы. Затем она запаниковала, начала анализировать собственную ошибку, запуталась в каналах связи (внутренние мысли vs ответ пользователю) и в итоге просто вывела инструкцию о том, как нужно пользоваться башем, хотя мы её об этом не просили.

🛠 Решение: Лезем под капот (Modelfile)

Проблема кроется не в самой модели, а в её Modelfile — конфигурационном файле Ollama, который определяет, как формируется промпт, как работают шаблоны (templates) и какие системные параметры применяются. Дефолтный темплейт просто не умеет правильно «готовить» тулы для сложных плагинов вроде Kilo Code.

Чтобы это исправить, нам нужно создать свой собственный Modelfile. Вот готовый, вылизанный до идеала конфиг, который заставит всё работать как часы:

FROM gpt-oss:20b# 40K контекста: вмещает твои промпты по 33.5К токенов и экономит 70% памяти KV-кэшаPARAMETER num_ctx 40960# Батч 512: чекпоинты кэша сохраняются каждые ~35 сек вместо 3.5 минутPARAMETER num_batch 512PARAMETER num_thread 4# Разумный предел генерации для CPU (хватит на мысли + большой diff)PARAMETER num_predict 8192# Оптимальная температура для кода и строгого JSON в тулахPARAMETER temperature 0.6PARAMETER top_p 0.95TEMPLATE """<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.Knowledge cutoff: 2024-06Current date: 2026-10Reasoning: low{{- $hasNonBuiltinTools := false }}{{- if .Tools -}}{{- $hasBrowserSearch := false }}{{- $hasBrowserOpen := false }}{{- $hasBrowserFind := false }}{{- $hasPython := false }}  {{- range .Tools }}    {{- if eq .Function.Name "browser.search" -}}{{- $hasBrowserSearch = true -}}    {{- else if eq .Function.Name "browser.open" -}}{{- $hasBrowserOpen = true -}}    {{- else if eq .Function.Name "browser.find" -}}{{- $hasBrowserFind = true -}}    {{- else if eq .Function.Name "python" -}}{{- $hasPython = true -}}    {{- else }}{{ $hasNonBuiltinTools = true -}}    {{- end }}  {{- end }}{{- if or $hasBrowserSearch $hasBrowserOpen $hasBrowserFind $hasPython }}# Tools{{- if or $hasBrowserSearch $hasBrowserOpen $hasBrowserFind }}## browser// Tool for browsing.// The `cursor` appears in brackets before each browsing display: `[{cursor}]`.// Cite information from the tool using the following format:// `【{cursor}†L{line_start}(-L{line_end})?】`, for example: `【6†L9-L11】` or `【8†L3】`.// Do not quote more than 10 words directly from the tool output.// sources=web (default: web)namespace browser {{{- if $hasBrowserSearch }}// Searches for information related to `query` and displays `topn` results.type search = (_: {query: string,topn?: number, // default: 10source?: string,}) => any;{{- end }}{{- if $hasBrowserOpen }}// Opens the link `id` from the page indicated by `cursor` starting at line number `loc`, showing `num_lines` lines.// Valid link ids are displayed with the formatting: `【{id}†.*】`.// If `cursor` is not provided, the most recent page is implied.// If `id` is a string, it is treated as a fully qualified URL associated with `source`.// If `loc` is not provided, the viewport will be positioned at the beginning of the document or centered on the most relevant passage, if available.// Use this function without `id` to scroll to a new location of an opened page.type open = (_: {id?: number | string, // default: -1cursor?: number, // default: -1loc?: number, // default: -1num_lines?: number, // default: -1view_source?: boolean, // default: falsesource?: string,}) => any;{{- end }}{{- if $hasBrowserFind }}// Finds exact matches of `pattern` in the current page, or the page given by `cursor`.type find = (_: {pattern: string,cursor?: number, // default: -1}) => any;{{- end }}} // namespace browser{{- end }}{{/* end if has browser tools */}}{{- if $hasPython }}## pythonUse this tool to execute Python code in your chain of thought. The code will not be shown to the user. This tool should be used for internal reasoning, but not for code that is intended to be visible to the user (e.g. when creating plots, tables, or files).When you send a message containing Python code to python, it will be executed in a stateful Jupyter notebook environment. python will respond with the output of the execution or time out after 120.0 seconds. The drive at '/mnt/data' can be used to save and persist user files. Internet access for this session is UNKNOWN. Depends on the cluster.{{- end }}{{/* end if hasPython */}}{{- end }}{{/* end if has any built-in tools */}}{{- end }}{{/* end if .Tools */}}# Valid channels: analysis, commentary, final. Channel must be included for every message.{{ if $hasNonBuiltinTools }}Calls to these tools must go to the commentary channel: 'functions'.{{- end -}}<|end|>{{/* end of system */ -}}{{- if or $hasNonBuiltinTools .System -}}<|start|>developer<|message|>{{- if $hasNonBuiltinTools }}# Tools## functionsnamespace functions {{{- range .Tools }}{{- if not (or (eq .Function.Name "browser.search") (eq .Function.Name "browser.open") (eq .Function.Name "browser.find") (eq .Function.Name "python")) }}{{if .Function.Description }}// {{ .Function.Description }}{{- end }}{{- if and .Function.Parameters.Properties (gt (len .Function.Parameters.Properties) 0) }}type {{ .Function.Name }} = (_: {{{- range $name, $prop := .Function.Parameters.Properties }}{{- if $prop.Description }}  // {{ $prop.Description }}{{- end }}  {{ $name }}: {{ $prop | toTypeScriptType }},{{- end }}}) => any;{{- else }}type {{ .Function.Name }} = () => any;{{- end }}{{- end }}{{/* end if not browser tool */}}{{- end }}{{/* end of range .Tools */}}} // namespace functions// Tool calling rules:// 1. Invoke only ONE tool per turn using: <|channel|>commentary to=functions.<tool_name> <|constrain|>json<|message|>{...}<|call|>// 2. Arguments MUST be strictly valid JSON with all newlines and quotes inside strings properly escaped.// 3. After receiving a tool result, continue reasoning in the `analysis` channel and either call the next tool or provide the final response in the `final` channel.{{- end }}{{/* end if hasNonBuiltinTools */}}{{- if .System }}# Instructions{{ .System }}{{- end -}}<|end|>{{- end -}}{{- /* Find the index of the last user message */ -}}{{- $lastUserIdx := -1 }}{{- $prefillingContent := false }}{{- $prefillingThinkingOnly := false }}{{- range $i, $msg := .Messages }}  {{- $last := eq (len (slice $.Messages $i)) 1 -}}  {{- if eq $msg.Role "user" }}    {{- $lastUserIdx = $i }}  {{- end -}}  {{- if and $last (eq $msg.Role "assistant") (gt (len $msg.Content) 0) }}    {{- $prefillingContent = true }}  {{- else if and $last (eq $msg.Role "assistant") (gt (len $msg.Thinking) 0) }}    {{- $prefillingThinkingOnly = true }}  {{- end }}{{- end -}}{{- /* Now render messages */ -}}{{- range $i, $msg := .Messages }}  {{- $last := eq (len (slice $.Messages $i)) 1 -}}  {{- if (ne $msg.Role "system") -}}    {{- if eq $msg.Role "tool" -}}      {{- if or (eq $msg.ToolName "python") (eq $msg.ToolName "browser.search") (eq $msg.ToolName "browser.open") (eq $msg.ToolName "browser.find") -}}        <|start|>{{ $msg.ToolName }} to=assistant<|message|>{{ $msg.Content }}<|end|>      {{- else if $msg.ToolName -}}        <|start|>functions.{{ $msg.ToolName }} to=assistant<|message|>{{ $msg.Content }}<|end|>      {{- else -}}        <|start|>functions to=assistant<|message|>{{ $msg.Content }}<|end|>      {{- end -}}    {{- else if eq $msg.Role "assistant" -}}      {{- if and $msg.Thinking (gt $i $lastUserIdx) -}}{{- /* Show thinking only after last user message */ -}}      <|start|>assistant<|channel|>analysis<|message|>{{ $msg.Thinking }}{{- if not $prefillingThinkingOnly -}}<|end|>{{- end -}}      {{- end -}}      {{- if gt (len $msg.Content) 0 -}}      <|start|>assistant<|channel|>{{ if gt (len $msg.ToolCalls) 0 }}commentary{{ else }}final{{ end }}<|message|>{{ $msg.Content }}{{- if not $prefillingContent -}}<|end|>{{- end -}}      {{- end -}}      {{- if gt (len $msg.ToolCalls) 0 -}}        {{- range $j, $toolCall := $msg.ToolCalls -}}          {{- $isBuiltin := or (eq $toolCall.Function.Name "python") (eq $toolCall.Function.Name "browser.search") (eq $toolCall.Function.Name "browser.open") (eq $toolCall.Function.Name "browser.find") -}}          <|start|>assistant<|channel|>{{ if $isBuiltin }}analysis{{ else }}commentary{{ end }} to={{ if not $isBuiltin }}functions.{{ end }}{{ $toolCall.Function.Name }} <|constrain|>json<|message|>{{ $toolCall.Function.Arguments }}<|call|>        {{- end -}}      {{- end -}}    {{- else if eq $msg.Role "user" -}}      <|start|>{{ $msg.Role }}<|message|>{{ $msg.Content }}<|end|>    {{- end }}  {{- end }}{{- end -}}{{- if not (or $prefillingContent $prefillingThinkingOnly) -}}<|start|>assistant{{- end -}}"""

🧠 Разбор полетов: Что мы изменили и почему это сработало?

Разница между старым нерабочим профилем и нашим новым заключается в нескольких критических изменениях:

1. Правильная маршрутизация каналов (Channel Routing)

Архитектура общения с такими моделями требует строгого разделения на каналы. Если ИИ рассуждает, это канал analysis. Если он вызывает инструмент — commentary. Если отвечает юзеру — final. В дефолтном шаблоне весь текст ассистента принудительно отправлялся в канал final. Из-за этого парсер ломался, модель пыталась вызвать tool текстом, адресованным пользователю, и всё падало. Мы добавили логику:<|start|>assistant<|channel|>{{ if gt (len $msg.ToolCalls) 0 }}commentary{{ else }}final{{ end }}<|message|> Теперь, если модель генерирует вызов инструмента, текст легитимно уходит во внутренний канал commentary.

2. Защита от галлюцинаций тулов (Tool Calling Rules)

Open-source модели порой «забывают», как правильно вызывать функции, генерируя невалидный JSON или пытаясь вызвать три тула одновременно. Мы добавили строгие правила прямо в системный промпт:

  • Invoke only ONE tool per turn… (Один тул за раз).

  • Arguments MUST be strictly valid JSON… (Строгий JSON).

3. Инъекция ответов от плагина

Когда Kilo Code выполняет команду (например, парсит репозиторий через bash), он возвращает результат. Старый темплейт не знал, как отобразить этот результат модели, если имя инструмента не совпадало с хардкодом. Мы добавили конструкцию {{- else if $msg.ToolName -}} и универсальный fallback {{- else -}} <|start|>functions to=assistant.... Теперь ИИ реально «видит» вывод консоли.

4. Тюнинг параметров

  • temperature 0.6: Снизили с 1.0. Высокая креативность — враг строгого JSON. На 0.6 модель генерирует код и аргументы гораздо детерминированнее.

  • num_predict 8192: Вызов тулов и Chain-of-Thought (мыслительный процесс) съедают кучу токенов. Дефолтных значений не хватает, и генерация может оборваться на середине JSON’а.

  • num_ctx 40960: Раздули контекст до 40K, чтобы туда влезали и огромные промпты плагина (до 33.5К), и история контекста, при этом экономя память KV-кэша.

💻 Как применить этот патч

  1. Сохраните код выше в файл с именем Modelfile (без расширения) на вашем ПК.

  2. Откройте терминал и соберите новую модель через Ollama:

    Bash

    ollama create gpt-oss-kilo -f Modelfile
  3. Зайдите в настройки VS Code, найдите конфигурацию Kilo Code (обычно config.json в ~/.continue/ или ~/.kilo/) и укажите использование вашей новой модели:

    JSON

    "models": [     {       "title": "Claude 3.5 Sonnet",       "provider": "anthropic",       "model": "claude-3-5-sonnet-latest"     },+    {+      "title": "Local GPT-OSS (MoE)",+      "provider": "ollama",+      "model": "gpt-oss-kilo"+    }   ],

Заключение

Всего пара десятков строк в конфигурации шаблона — и мы заставили Kilo Code App идеально работать с локальной моделью прямо в VS Code. Больше никаких паник при вызове bash-команд или потерянного контекста. Вы получаете автономного, бесплатного и приватного ассистента, который шустро бегает на ваших 32 ГБ памяти благодаря магии MoE.

Удачного кодинга! Если вы тоже экспериментируете с локальными агентами, делитесь в комментариях своими сборками Modelfile.

ссылка на оригинал статьи https://habr.com/ru/articles/1100390/