- Go 100%
| cmd | ||
| .gitignore | ||
| control_test.go | ||
| embedding.go | ||
| embedding_test.go | ||
| errors.go | ||
| go.mod | ||
| go.sum | ||
| history_test.go | ||
| http.go | ||
| http_test.go | ||
| LICENSE | ||
| llm.go | ||
| llm_integration_test.go | ||
| llm_test.go | ||
| README.md | ||
| stream.go | ||
| stream_test.go | ||
| structs.go | ||
| tools.go | ||
| tools_test.go | ||
| vision.go | ||
| vision_test.go | ||
llm-simple-api
Простая Go библиотека для взаимодействия с LLM (Language Model) API.
Описание
Библиотека предоставляет удобный интерфейс для работы с различными LLM API, включая поддержку Open-WebUI, OpenAI, LM Studio и других совместимых сервисов. Предоставляет функционал для отправки сообщений, управления историей чата и настройки параметров модели.
Возможности
- Поддержка различных LLM API (Open-WebUI, OpenAI, LM Studio и др.)
- Управление историей чата (чтение, модификация, очистка)
- Настройка параметров модели (температура, максимальное количество токенов)
- Поддержка авторизации через API токены
- Настройка TLS и таймаутов
- Таймаут на отдельное сообщение (
MsgTimeout) - Получение эмбеддингов (совместимо с OpenAI API)
- Система инструментов (function calling) с автогенерацией JSON Schema
- Автоматическая обработка вызовов инструментов (round-trip)
- Отправка vision-запросов (изображения)
- Сжатие истории чата при превышении бюджета токенов
- Инъекция кастомного HTTP-клиента
- Отдельный URL для сервиса эмбеддингов
- Ручное управление циклом tool calls (NotContinue / Continue)
- Параллельное выполнение инструментов (MaxParallelTools)
- Callback'и жизненного цикла (OnBeforeLLM, OnAfterLLM, OnBeforeCallTool, OnAfterCallTool)
- Reasoning Content (рассуждения моделей: DeepSeek-R1, QwQ и др.)
Установка
go get git.ymnuktech.ru/ymnuk/llm-simple-api
Использование
package main
import (
"fmt"
"git.ymnuktech.ru/ymnuk/llm-simple-api/llm"
)
func main() {
opts := &llm.LLMOpts{
BaseUrl: "http://localhost:11434/ollama",
Model: "gpt-3.5-turbo",
Token: stringPtr("your-api-token"),
Timeout: 30 * time.Second,
}
client := llm.NewLLMSimpleAPI(opts)
// Отправка сообщения
message, err := client.ChatCompletions("Привет, как дела?")
if err != nil {
panic(err)
}
fmt.Println(message.Content)
// Получение эмбеддингов
embeddings, err := client.Embedding([]string{"Привет, мир!", "Как дела?"})
if err != nil {
panic(err)
}
for _, data := range embeddings.Data {
fmt.Printf("Embedding %d: %v\n", data.Index, data.Embedding)
}
}
Структуры данных
LLMOpts
| Поле | Тип | Дефолт | Описание |
|---|---|---|---|
BaseUrl |
string |
http://localhost:11434/ollama |
Базовый URL API |
TlsVerify |
bool |
false |
Проверка TLS сертификата |
Timeout |
time.Duration |
1m |
Таймаут HTTP-клиента |
MsgTimeout |
time.Duration |
0 |
Таймаут на одно сообщение (0 = отключён) |
Token |
*string |
nil |
API токен (Bearer) |
UserAgent |
string |
llm-simple-api |
User-Agent заголовок |
SystemPrompt |
string |
Ты - ассистент... |
Системный промпт |
Model |
string |
gpt-3.5-turbo |
Модель по умолчанию |
Temperature |
float32 |
0.8 |
Температура генерации |
MaxTokens |
int |
8192 |
Максимальное количество токенов |
ModelEmbed |
string |
nomic-embed-text |
Модель для эмбеддингов |
ModelEmbedCustomPath |
string |
/embeddings |
Путь для эмбеддингов |
EmbeddingBaseUrl |
string |
"" |
Отдельный URL для эмбеддингов (пусто = BaseUrl) |
HTTPClient |
*http.Client |
nil |
Кастомный HTTP-клиент (nil = дефолтный) |
History |
*HistoryOpts |
nil |
Опции сжатия истории (nil = отключено) |
ImageType |
string |
png |
Формат изображения (png, jpeg/jpg) |
MaxWidth |
int |
1024 |
Макс. ширина изображения |
MaxHeight |
int |
1024 |
Макс. высота изображения |
NotContinue |
bool |
false |
Остановить loop после tool calls (ручное управление) |
MaxParallelTools |
int |
0 |
Параллельное выполнение инструментов (>1 = пул горутин) |
OnBeforeLLM |
func(prompt string) |
nil |
Callback перед каждым запросом к LLM |
OnAfterLLM |
func(*Message) |
nil |
Callback после каждого ответа от LLM |
OnBeforeCallTool |
func(ToolCall) |
nil |
Callback перед выполнением инструмента |
OnAfterCallTool |
func(ToolCallResponse) |
nil |
Callback после выполнения инструмента |
Message
Сообщение в чате:
Role- роль (user, assistant, system, tool)Content- содержание сообщенияToolCalls- вызовы инструментов от assistantToolCallID- ID вызова для tool-ответовReasoning- цепочка рассуждений (для reasoning-моделей)
EmbeddingResponse
Ответ с эмбеддингами (совместим с OpenAI API):
Object- тип объекта ("list")Data- массив объектов с эмбеддингами:Object- тип объекта ("embedding")Embedding- массив float32 значенийIndex- индекс текста в запросе
Model- использованная модельUsage- информация об использовании:PromptTokens- количество токенов во входных данныхCompletionTokens- количество токенов в ответеTotalTokens- общее количество токенов
Usage
Информация об использовании токенов:
PromptTokens- токены в промптеCompletionTokens- токены в ответеTotalTokens- всего токенов
HistoryOpts
Опции сжатия истории чата:
MaxTokens- бюджет токенов (0 = отключено)Threshold- порог срабатывания [0;1], дефолт 0.8OnLimit- callback, вызываемый при превышении порога
Методы
NewLLMSimpleAPI
Создание нового клиента LLM. Если передан opts.HTTPClient, используется он, иначе создаётся дефолтный с настройками из LLMOpts.
ChatCompletions
Отправка запроса в модель и получение ответа. Автоматически управляет историей чата и обрабатывает вызовы инструментов.
Embedding
Получение эмбеддингов для текста или массива текстов. Если задан EmbeddingBaseUrl, используется отдельный URL, иначе BaseUrl.
ClearHistory
Очистка истории чата. Сбрасывает накопленный usage.
GetHistory
Получение текущей истории чата (можно модифицировать).
SetHistory
Замена всей истории чата. Используется агентом для пересборки контекста (например, после сжатия или суммаризации):
h := client.GetHistory()
summary := compressHistory(h) // логика агента
client.SetHistory(append(h[:1], summary))
LastUsage
Возвращает *Usage последнего ответа, или nil если запросов ещё не было.
TotalUsage
Возвращает кумулятивный Usage за всю сессию.
SendVisionRequest
Отправка запроса с изображением в модель, поддерживающую визуальное восприятие. Функция принимает текстовый запрос и изображение в формате image.Image, преобразует изображение в base64 строку с учётом настроек формата и размера.
Управление контекстом (HistoryOpts)
Библиотека позволяет контролировать расход токенов на историю чата. После каждого успешного ответа проверяется соотношение prompt_tokens / MaxTokens. Если оно превышает Threshold — вызывается callback OnLimit, который может сжать историю.
opts := &llm.LLMOpts{
BaseUrl: "http://192.168.0.200:1234/v1",
Model: "google/gemma-4-e2b",
History: &llm.HistoryOpts{
MaxTokens: 4096,
Threshold: 0.8,
OnLimit: func(h []llm.Message, u llm.Usage) ([]llm.Message, error) {
// Оставляем system + последние 10 сообщений
return append(h[:1], h[len(h)-10:]...), nil
},
},
}
Callback получает текущую историю и usage последнего ответа. Если вернуть ошибку — история не меняется.
Таймаут на сообщение (MsgTimeout)
Позволяет задать максимальное время выполнения одного запроса, независимо от глобального Timeout HTTP-клиента:
opts := &llm.LLMOpts{
BaseUrl: "http://192.168.0.200:1234/v1",
Model: "google/gemma-4-e2b",
MsgTimeout: 30 * time.Second, // таймаут на одно сообщение
}
При превышении возвращается context.DeadlineExceeded. Если MsgTimeout = 0 (по умолчанию) — таймаут не применяется.
Кастомный HTTP-клиент
Можно передать собственный *http.Client для тонкой настройки транспорта, прокси или тестирования:
customClient := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
},
}
opts := &llm.LLMOpts{
BaseUrl: "http://192.168.0.200:1234/v1",
Model: "google/gemma-4-e2b",
HTTPClient: customClient,
}
Функциональность инструментов
Библиотека включает в себя систему инструментов, которая позволяет регистрировать пользовательские функции, вызываемые LLM. Это аналогично function calling в OpenAI.
Возможности
- Автоматическая генерация JSON-схемы: генерирует JSON-схему из Go-структур через рефлексию
- Регистрация функций: регистрируйте любую функцию с сигнатурой
func(SomeStruct) (interface{}, error) - Автоматическая отправка инструментов: зарегистрированные инструменты автоматически отправляются с запросами
- Автоматическая обработка вызовов: библиотека выполняет инструменты и возвращает результат в LLM
- Полный round-trip: при наличии tool calls библиотека делает второй запрос с результатами
- Типизированные ошибки:
ErrToolAlreadyRegistered,ErrToolNotFound,ErrInvalidToolSignature— совместимы сerrors.Is - JSON Schema improvements: поля с
omitemptyи поля-указатели не попадают вrequired;minimum/maximumпарсятся из struct-тегов
Использование
-
Определите структуру параметров с JSON-тегами:
type WeatherParams struct { Location string `json:"location" description:"Местоположение для получения погоды"` } -
Определите функцию:
func GetWeather(params WeatherParams) (interface{}, error) { return map[string]interface{}{ "temperature": 22.5, "location": params.Location, "condition": "Солнечно", }, nil } -
Зарегистрируйте инструмент:
api := llm.NewLLMSimpleAPI(nil) api.RegisterTool("get_weather", "Получить информацию о погоде", GetWeather) -
Используйте обычный
ChatCompletions:message, err := api.ChatCompletions("Какая погода в Москве?")Если LLM решит вызвать инструмент, библиотека автоматически выполнит вызов и отправит результат обратно, вернув финальный ответ.
Типизированные ошибки
Система инструментов возвращает типизированные ошибки, совместимые с errors.Is и errors.As:
| Тип | Когда возникает |
|---|---|
ErrToolAlreadyRegistered{Name} |
Регистрация инструмента с уже существующим именем |
ErrToolNotFound{Name} |
Выполнение незарегистрированного инструмента |
ErrInvalidToolSignature{Msg} |
Невалидная сигнатура функции (не функция, не те параметры, не те возвраты) |
err := api.RegisterTool("get_weather", "desc", GetWeather)
if errors.As(err, &llm.ErrToolAlreadyRegistered{}) {
fmt.Println("инструмент уже существует")
}
err = api.RegisterTool("invalid", "desc", "not a function")
var sigErr llm.ErrInvalidToolSignature
if errors.As(err, &sigErr) {
fmt.Println(sigErr.Msg) // "handler must be a function"
}
Улучшения JSON Schema
omitempty: поля сjson:"...,omitempty"не попадают вrequired— модель не обязана их указывать- Указатели: поля-указатели (
*string,*float64) не попадают вrequired, даже безomitempty minimum/maximum: числовые поля могут использовать struct-теги для ограничения диапазона:
type WeatherParams struct {
Location string `json:"location"`
Temp float64 `json:"temp" minimum:"-50" maximum:"60"`
Count int `json:"count" minimum:"1" maximum:"100"`
}
Генерируемая JSON Schema:
{
"temp": {"type": "number", "minimum": -50, "maximum": 60},
"count": {"type": "integer", "minimum": 1, "maximum": 100}
}
Стриминг (ChatCompletionsStream)
ChatCompletionsStream — стриминговая версия ChatCompletions. Ответ приходит по частям через callback. Оба раунда (первичный ответ и повторный после tool calls) стримятся.
StreamChunk
| Поле | Тип | Описание |
|---|---|---|
Content |
string |
Дельта текста с момента прошлого chunk |
Reasoning |
string |
Дельта reasoning_content (рассуждение модели) |
Finish |
string |
Причина завершения раунда: "stop", "tool_calls", "length", "" |
ToolCalls |
[]ToolCall |
Заполнено только при Finish: "tool_calls" |
Err |
error |
Ошибка парсинга SSE |
Done |
bool |
true когда ВЕСЬ стриминг завершён (все раунды) |
Использование
response, err := api.ChatCompletionsStream("Какая погода в Москве?", func(chunk llm.StreamChunk) {
if chunk.Err != nil {
fmt.Printf("Error: %v\n", chunk.Err)
return
}
if len(chunk.ToolCalls) > 0 {
fmt.Printf("\n[выполняю %d инструментов]\n", len(chunk.ToolCalls))
return
}
fmt.Print(chunk.Content) // печатаем текст по мере поступления
})
if err != nil {
log.Fatal(err)
}
Callback безопасно вызывается с nil — если callback не нужен, передайте nil.
Как работает стриминг tool calls
- LLM начинает генерировать текст — callback получает
Contentс каждым chunk - LLM решает вызвать инструмент — callback получает
Finish: "tool_calls"иToolCalls - Библиотека выполняет инструменты (синхронно)
- Начинается второй стриминговый раунд — callback снова получает
Content - Второй раунд завершается — callback получает
Done: true
IsNeedContinue / Continue / ContinueStream
NotContinue в LLMOpts переключает библиотеку в режим ручного управления циклом tool calls. По умолчанию (false) библиотека автоматически выполняет инструменты и делает повторный запрос к LLM. С NotContinue: true после tool calls library возвращает управление агенту.
opts := &llm.LLMOpts{
BaseUrl: "http://192.168.0.200:1234/v1",
Model: "google/gemma-4-e2b",
NotContinue: true,
}
IsNeedContinue() bool— возвращаетtrue, если после tool calls loop остановленContinue() (*Message, error)— продолжает без добавления нового user-сообщенияContinueStream(callback StreamCallback) (*Message, error)— стриминговая версия
Пример:
msg, err := api.ChatCompletions("Какая погода?")
// msg содержит tool_calls, needContinue = true
for api.IsNeedContinue() {
// История уже содержит tool-результаты
msg, err = api.Continue()
}
Callback'и жизненного цикла
Четыре callback'а позволяют наблюдать за внутренним циклом библиотеки:
| Callback | Когда вызывается |
|---|---|
OnBeforeLLM(prompt) |
Перед каждым HTTP-запросом к LLM (включая повторные раунды) |
OnAfterLLM(msg) |
После каждого успешного ответа от LLM |
OnBeforeCallTool(call) |
Перед выполнением каждого инструмента |
OnAfterCallTool(resp) |
После выполнения каждого инструмента |
Все callback'и опциональны (nil-safe).
opts := &llm.LLMOpts{
...
OnBeforeLLM: func(prompt string) {
log.Printf("→ запрос к LLM: %s", prompt)
},
OnAfterLLM: func(msg *llm.Message) {
log.Printf("← ответ получен (tool_calls: %d)", len(msg.ToolCalls))
},
}
MaxParallelTools
По умолчанию инструменты выполняются последовательно. MaxParallelTools > 1 включает параллельное выполнение через пул горутин:
opts := &llm.LLMOpts{
...
MaxParallelTools: 5, // до 5 инструментов одновременно
}
- Результаты возвращаются в порядке вызовов (Promise.all)
- Паника в инструменте возвращается как ошибка, остальные горутины не прерываются
- Callback'и
OnBeforeCallTool/OnAfterCallToolвызываются синхронно в главной горутине
Reasoning Content
Некоторые модели (DeepSeek-R1, QwQ, Gemma-4 и др.) возвращают цепочку рассуждений в отдельном поле reasoning_content. Библиотека парсит это поле в Message.Reasoning (non-streaming) и StreamChunk.Reasoning (streaming).
Поле Reasoning имеет json:"reasoning_content,omitempty" — при отправке истории обратно в API пустое рассуждение не попадает в messages[].
Non-streaming
msg, err := api.ChatCompletions("Сколько будет 2+2?")
fmt.Println("Ответ:", msg.Content)
fmt.Println("Рассуждение:", msg.Reasoning)
Streaming
response, err := api.ChatCompletionsStream("Сколько будет 2+2?", func(chunk llm.StreamChunk) {
if chunk.Reasoning != "" {
fmt.Printf("[рассуждает] %s\n", chunk.Reasoning)
}
if chunk.Content != "" {
fmt.Print(chunk.Content)
}
})
API не управляет рассуждением
Стандартный OpenAI-совместимый API (включая LM Studio) не имеет параметра для включения/отключения рассуждения. Это свойство модели:
- Рассуждающие модели всегда рассуждают (архитектурно)
- Модели без рассуждения никогда не возвращают
reasoning_content Message.Reasoningбудет пустым для моделей без рассуждения
В LM Studio есть UI-галочка "When applicable, separate reasoning_content and content in API responses", которая управляет только вырезанием <think>-тегов в отдельное поле. Библиотека работает в обоих режимах.
Done: true приходит только один раз — в самом конце, после финального ответа.
Usage (токены)
После каждого запроса библиотека сохраняет информацию о расходе токенов. Данные берутся из ответа API (prompt_tokens, completion_tokens, total_tokens) и передаются в HistoryOpts.OnLimit.
Тестирование
# Unit-тесты (без внешних сервисов)
go test -short ./...
# Все тесты, включая интеграционные
go test ./...
Интеграционные тесты автоматически пропускаются, если сервисы недоступны.