DeepSeek API на Go
API DeepSeek совместим с форматом OpenAI, поэтому на Go для работы достаточно стандартной библиотеки: net/http, encoding/json, bufio. Никаких внешних зависимостей — только go.mod и код ниже.
Почему Go подходит для таких задач
Go выигрывает там, где PHP и Python начинают упираться в конкурентность и время удержания соединения:
- сервисы и микросервисы, которые принимают запрос от пользователя и сами обращаются к модели;
- прокси и шлюзы — единая точка входа к API с логированием, лимитами и подстановкой ключа;
- боты и фоновые обработчики, которые держат десятки одновременных потоковых ответов;
- утилиты командной строки и воркеры очередей, собираемые в один бинарник без интерпретатора.
Практически важны три вещи: context даёт отмену и таймаут на каждый запрос, горутины позволяют обрабатывать несколько стримов параллельно, а SSE в потоковом режиме читается построчно через bufio.Scanner без промежуточных буферов.
Go 1.22 или новее, ключ DEEPSEEK_API_KEY, адрес https://api.deepseek.com и метод POST /chat/completions. Авторизация — заголовок Authorization: Bearer ${DEEPSEEK_API_KEY}. Актуальные модели для примеров: deepseek-flash и deepseek-v4-pro. Помните, что режим рассуждений включён по умолчанию, а temperature в нём не действует.
Типы запроса и ответа
Начнём с типов. Достаточно описать только те поля, которые действительно нужны: остальные encoding/json просто проигнорирует.
package main
// Message — одно сообщение диалога. Роли: system, user, assistant, tool.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// StreamOptions включает чанк с расходом токенов.
// Работает только при Stream: true — иначе API вернёт 400.
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// Request — тело запроса к POST /chat/completions.
type Request struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
}
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
}
type Response struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage"`
}
// Chunk — один фрагмент потокового ответа (SSE).
type Chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *Usage `json:"usage"`
}
Минимальный запрос
Базовый вариант: контекст с таймаутом, сериализация тела, проверка статуса и разбор ответа декодером.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
baseURL = "https://api.deepseek.com"
model = "deepseek-flash"
)
// ask отправляет один запрос и возвращает ответ модели.
func ask(ctx context.Context, prompt string) (*Response, error) {
body, err := json.Marshal(Request{
Model: model,
Messages: []Message{
{Role: "system", Content: "Отвечай по-русски, кратко и по делу."},
{Role: "user", Content: prompt},
},
})
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("DEEPSEEK_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("deepseek: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
}
var out Response
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(out.Choices) == 0 {
return nil, fmt.Errorf("deepseek: в ответе нет choices")
}
return &out, nil
}
func main() {
resp, err := ask(context.Background(), "Что такое контекстное кэширование в API?")
if err != nil {
fmt.Fprintln(os.Stderr, "ошибка:", err)
os.Exit(1)
}
fmt.Println(resp.Choices[0].Message.Content)
if resp.Usage != nil {
fmt.Printf("токены: вход %d, выход %d\n", resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
}
}
Схема тела ошибки в документации не описана, поэтому его надёжнее прочитать целиком строкой и положить в текст ошибки — так причину видно в логах. Читайте с ограничением через io.LimitReader, чтобы не вытянуть в память большой ответ. Коды 401 и 402 повторять бессмысленно, а 429, 500 и 503 — имеет смысл.
Нужен API-ключ сегодня
Регистрация по email или Telegram занимает пару минут, оплата идёт в рублях, VPN не нужен. Один ключ работает и с DeepSeek, и с другими моделями.
Потоковая выдача
В режиме stream: true сервер отвечает в формате SSE: строки вида data: {...} и завершающая data: [DONE]. Строки без префикса data: (в том числе комментарии : keep-alive) нужно пропускать.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// stream печатает ответ по мере генерации и возвращает расход токенов.
// stream_options.include_usage требует stream: true, иначе API вернёт 400.
func stream(ctx context.Context, prompt string) (*Usage, error) {
body, err := json.Marshal(Request{
Model: model,
Messages: []Message{{Role: "user", Content: prompt}},
Stream: true,
StreamOptions: &StreamOptions{IncludeUsage: true},
})
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+os.Getenv("DEEPSEEK_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("deepseek: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // чанк может быть крупным
var usage *Usage
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
payload, ok := strings.CutPrefix(line, "data:")
if !ok {
continue // пустая строка или комментарий keep-alive
}
payload = strings.TrimSpace(payload)
if payload == "[DONE]" {
break
}
var chunk Chunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue // неполный или служебный чанк пропускаем
}
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
if chunk.Usage != nil {
usage = chunk.Usage // приходит последним чанком
}
}
if err := scanner.Err(); err != nil {
return usage, fmt.Errorf("read stream: %w", err)
}
fmt.Println()
return usage, nil
}
Альтернатива на том же теле ответа — декодер, который читает JSON-объекты подряд. Он не требует разбирать префикс data: вручную, но спотыкается на вставках вроде [DONE] и : keep-alive, поэтому такой цикл обычно комбинируют с bufio.Reader:
// Нужны импорты: bufio, encoding/json, errors, fmt, io
br := bufio.NewReader(resp.Body)
dec := json.NewDecoder(br)
for {
var chunk Chunk
if err := dec.Decode(&chunk); err != nil {
if errors.Is(err, io.EOF) {
break // поток закрыт штатно
}
return nil, fmt.Errorf("decode chunk: %w", err)
}
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
if chunk.Usage != nil {
usage = chunk.Usage
}
}
Повторы с экспоненциальной задержкой
Коды 429, 500 и 503 означают, что запрос имеет смысл повторить. Следующая функция повторяет операцию, удваивая паузу и реагируя на отмену контекста, — например, когда пользователь закрыл страницу и запрос больше не нужен.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// doWithRetry повторяет операцию при 429, 500 и 503.
// isRetryable возвращает true, если ошибку имеет смысл повторить.
// maxAttempts включает первую попытку.
func doWithRetry(
ctx context.Context,
maxAttempts int,
isRetryable func(error) bool,
op func(ctx context.Context) (*http.Response, error),
) (*http.Response, error) {
delay := time.Second
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, err := op(ctx)
if err == nil && resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 {
return resp, nil
}
retryable := err == nil || isRetryable(err)
if !retryable || attempt == maxAttempts {
if err != nil {
return nil, err
}
return resp, nil // отдаём последний ответ, разбор — на стороне вызывающего
}
if resp != nil {
resp.Body.Close() // соединение не удерживаем, тело уже не нужно
}
fmt.Printf("попытка %d не удалась, пауза %s\n", attempt, delay)
select {
case <-ctx.Done():
return nil, ctx.Err() // отмена или истёкший таймаут
case <-time.After(delay):
}
delay *= 2
}
return nil, fmt.Errorf("deepseek: исчерпаны попытки")
}
Вызов выглядит так: операция собирает запрос заново на каждой попытке, потому что тело *http.Request можно прочитать только один раз.
// Константы baseURL, model и типы Request, Message, Response — из примеров выше.
func askWithRetry(ctx context.Context, prompt string) (*Response, error) {
body, err := json.Marshal(Request{
Model: model,
Messages: []Message{{Role: "user", Content: prompt}},
})
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
op := func(ctx context.Context) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("DEEPSEEK_API_KEY"))
return http.DefaultClient.Do(req)
}
resp, err := doWithRetry(ctx, 5, func(err error) bool {
// Транспортные ошибки и таймауты тоже имеет смысл повторить
return true
}, op)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("deepseek: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
}
var out Response
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}
Расчёт стоимости по usage
В объекте usage видно, сколько входных токенов попало в контекстный кэш, а сколько нет. Ниже — расчёт для deepseek-flash по ценам вне пика, в долларах за 1M токенов.
package main
// Цены deepseek-flash вне пика, USD за 1M токенов.
const (
priceCacheHit = 0.003
priceCacheMiss = 0.15
priceOutput = 0.6
)
// costUSD считает стоимость запроса в долларах.
func costUSD(u Usage) float64 {
const million = 1_000_000.0
return (float64(u.PromptCacheHitTokens)*priceCacheHit +
float64(u.PromptCacheMissTokens)*priceCacheMiss +
float64(u.CompletionTokens)*priceOutput) / million
}
// Использование:
// if usage != nil {
// fmt.Printf("стоимость запроса: $%.6f\n", costUSD(*usage))
// }
Через агрегатор
Код не меняется: достаточно заменить базовый адрес и ключ. Идентификаторы моделей у агрегаторов другие — точное имя нужно смотреть в каталоге сервиса.
// Ключ вида sk-aitunnel-... создаётся в личном кабинете агрегатора
const (
BaseURL = "https://api.aitunnel.ru/v1/" // вместо https://api.deepseek.com
Model = "deepseek-v4.1-flash" // идентификатор из каталога сервиса
)
func askTunnel(ctx context.Context, prompt string) (string, error) {
body, err := json.Marshal(Request{
Model: Model,
Messages: []Message{{Role: "user", Content: prompt}},
})
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, BaseURL+"chat/completions", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("AITUNNEL_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", fmt.Errorf("aitunnel: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
}
var out Response
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("aitunnel: в ответе нет choices")
}
return out.Choices[0].Message.Content, nil
}
Обратите внимание на имя модели: в официальном API DeepSeek это deepseek-flash, а на AITUNNEL та же модель называется иначе — например, deepseek-v4.1-flash. Стриминг, повторы и расчёт стоимости работают через агрегатор без изменений.
Подробный разбор подключения через AITUNNEL →
Нужен API-ключ сегодня
Регистрация по email или Telegram занимает пару минут, оплата идёт в рублях, VPN не нужен. Один ключ работает и с DeepSeek, и с другими моделями.
Читайте также
- Тот же запрос через cURL Проверить ключ и увидеть «сырой» ответ API до того, как писать код на Go.
- Примеры на PHP Для проектов на WordPress, Bitrix и самописных админках.
- Модели и параметры Полный список параметров Chat Completions с типами и значениями по умолчанию.
- Стриминг и токены Формат SSE, поля usage и типичные ошибки при потоковой выдаче.