golang

Когда нейросети общаются сами: эксперимент с диалогом двух LLM и графическая утилита на Go

  • понедельник, 23 марта 2026 г. в 00:00:36
https://habr.com/ru/articles/1013178/

Всем привет! Что будет, если задать двум LLM моделям одну тему и позволить вести диалог без участия человека? Я написал небольшую программу на Go, которая делает это автоматически. Рассказываю как она устроена и почему она может пригодиться каждому, кто работает с Ollama.

Один интерфейс для двух моделей

Программа представляет собой графическое приложение на Fyne. В верхней левой части окна настройка диалога. Выбираем две модели из списка, который программа получает командой ollama list. Можно задать каждой модели свою роль (необязательно).

Дальше пишем тему диалога. Это может быть любой вопрос или утверждение, которое станет отправной точкой. Указываем количество раундов и таймаут в минутах. Таймаут нужен, чтобы модель не зависла, если ответ затягивается. По нажатию кнопки «Начать диалог» программа запускает обмен репликами.

Как это выглядит в работе

Диалог отображается в правой части окна. Каждое сообщение сопровождается временем и именем модели. Модели выделяются разными цветами, чтобы их было легко различать. Сначала система отправляет тему и роли, затем модели по очереди отвечают друг другу.

Если нужно прервать диалог, есть кнопка «Сброс». Она останавливает общение, очищает историю и возвращает настройки к исходным значениям. Это удобно, когда эксперимент пошёл не по плану или модели начали повторяться.

Технические нюансы

Программа использует локальный сервер Ollama, который должен быть запущен на порту 11434. Все запросы через HTTP API. Для каждой модели хранится свой контекст: системное сообщение с ролью и история последних 20 сообщений. Это позволяет нейросетям «помнить» ход разговора, но не уходить в слишком длинную историю.

Когда модель отвечает, её ответ добавляется в историю обеих моделей. Таким образом, вторая модель видит реплику первой и может на неё реагировать. Таймаут контролируется через контекст Go, что предотвращает зависания. Для интерфейса используется Fyne, он прост в работе и позволяет быстро собрать рабочий прототип.

Зачем это может пригодиться

Можно сравнивать поведение разных моделей на одних и тех же темах. Например, задать философский вопрос и посмотреть, как ответит Gemma, а как Mistral. Можно проверять, как роли влияют на стиль ответов. Или просто развлекаться, наблюдая за спором двух нейросетей.

Весь код проекта
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os/exec"
	"strconv"
	"strings"
	"sync"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"fyne.io/fyne/v2/data/validation"
	"fyne.io/fyne/v2/dialog"
	"fyne.io/fyne/v2/theme"
	"fyne.io/fyne/v2/widget"
)

// ---------- Структуры для работы с Ollama ----------
type Message struct {
	Role      string `json:"role"`
	Content   string `json:"content"`
	Model     string `json:"model,omitempty"`
	Timestamp string `json:"timestamp"`
	IsBot     bool   `json:"is_bot"`
	Round     int    `json:"round,omitempty"`
}

type ChatRequest struct {
	Model    string    `json:"model"`
	Messages []Message `json:"messages"`
	Stream   bool      `json:"stream"`
	Options  struct {
		NumPredict int `json:"num_predict,omitempty"`
		Seed       int `json:"seed,omitempty"`
	} `json:"options,omitempty"`
}

type ChatResponse struct {
	Model     string  `json:"model"`
	Message   Message `json:"message"`
	Done      bool    `json:"done"`
	CreatedAt string  `json:"created_at"`
}

// ModelContext хранит системное сообщение и историю для каждой модели
type ModelContext struct {
	Role          string
	SystemMessage Message
	DialogHistory []Message
}

// ConversationState управляет состоянием диалога
type ConversationState struct {
	Model1        string
	Model2        string
	MaxRounds     int
	TimeoutMin    int
	InitialPrompt string
	History       []Message
	CurrentRound  int
	IsActive      bool
	Model1Context *ModelContext
	Model2Context *ModelContext
}

// guiApp объединяет все элементы GUI и состояние
type guiApp struct {
	window       fyne.Window
	status       *widget.Label
	model1Select *widget.Select
	model2Select *widget.Select
	role1Entry   *widget.Entry
	role2Entry   *widget.Entry
	topicEntry   *widget.Entry
	roundsEntry  *widget.Entry
	timeoutEntry *widget.Entry
	startBtn     *widget.Button
	resetBtn     *widget.Button
	chatRichText *widget.RichText
	roundLabel   *widget.Label

	state ConversationState
	mu    sync.Mutex // защита state и chatRichText.Segments
	wg    sync.WaitGroup
}

// callOllamaAPI отправляет запрос к локальному Ollama и возвращает ответ
func callOllamaAPI(ctx context.Context, model string, messages []Message) (string, error) {
	cleanMessages := []Message{}
	for _, msg := range messages {
		if msg.Content != "" && strings.TrimSpace(msg.Content) != "" {
			cleanMessages = append(cleanMessages, msg)
		}
	}
	if len(cleanMessages) == 0 {
		return "", fmt.Errorf("нет валидных сообщений для отправки в модель")
	}

	requestBody := ChatRequest{
		Model:    model,
		Messages: cleanMessages,
		Stream:   false,
	}
	requestBody.Options.NumPredict = 1024

	jsonData, err := json.Marshal(requestBody)
	if err != nil {
		return "", fmt.Errorf("ошибка маршалинга запроса: %v", err)
	}

	req, err := http.NewRequestWithContext(ctx, "POST",
		"http://localhost:11434/api/chat",
		bytes.NewBuffer(jsonData))
	if err != nil {
		return "", fmt.Errorf("ошибка создания запроса: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{
		Timeout: 0,
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("ошибка HTTP запроса: %v", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("ошибка чтения ответа: %v", err)
	}

	var response ChatResponse
	if err := json.Unmarshal(body, &response); err != nil {
		return "", fmt.Errorf("ошибка парсинга JSON: %v", err)
	}
	if strings.TrimSpace(response.Message.Content) == "" {
		return "", fmt.Errorf("модель вернула пустой ответ")
	}
	return response.Message.Content, nil
}

// updateDialogContext добавляет сообщение в историю контекста модели
func updateDialogContext(context *ModelContext, message Message, isOwnMessage bool) {
	if strings.TrimSpace(message.Content) == "" {
		return
	}
	msgToAdd := message
	if isOwnMessage {
		msgToAdd.Role = "assistant"
	} else {
		msgToAdd.Role = "user"
	}
	context.DialogHistory = append(context.DialogHistory, msgToAdd)
	if len(context.DialogHistory) > 20 {
		context.DialogHistory = context.DialogHistory[len(context.DialogHistory)-20:]
	}
}

// buildContext формирует слайс сообщений для отправки в модель
func buildContext(context *ModelContext) []Message {
	messages := make([]Message, 0)
	if context.SystemMessage.Content != "" {
		messages = append(messages, context.SystemMessage)
	}
	messages = append(messages, context.DialogHistory...)
	return messages
}

// appendMessage добавляет сообщение в RichText
func (g *guiApp) appendMessage(msg Message) {
	g.mu.Lock()
	defer g.mu.Unlock()

	timestamp := msg.Timestamp
	if timestamp == "" {
		timestamp = time.Now().Format("15:04:05")
	}
	sender := ""
	if msg.IsBot {
		sender = msg.Model
	} else if msg.Role == "error" {
		sender = "Ошибка"
	} else {
		sender = "Система"
	}
	header := fmt.Sprintf("[%s] %s", timestamp, sender)

	var colorName fyne.ThemeColorName
	if msg.IsBot {
		if msg.Model == g.model1Select.Selected {
			colorName = theme.ColorNameWarning
		} else if msg.Model == g.model2Select.Selected {
			colorName = theme.ColorNameSuccess
		} else {
			colorName = theme.ColorNameForeground
		}
	} else if msg.Role == "error" {
		colorName = theme.ColorNameError
	} else {
		colorName = theme.ColorNameForeground
	}

	headerSegment := &widget.TextSegment{
		Text: header + "\n",
		Style: widget.RichTextStyle{
			TextStyle: fyne.TextStyle{Bold: true},
			ColorName: colorName,
		},
	}
	msgSegment := &widget.TextSegment{
		Text: msg.Content + "\n\n",
		Style: widget.RichTextStyle{
			ColorName: colorName,
		},
	}

	g.chatRichText.Segments = append(g.chatRichText.Segments, headerSegment, msgSegment)
	g.chatRichText.Refresh()
}

// loadModels получает список моделей через ollama list и обновляет селекты
func (g *guiApp) loadModels() {
	cmd := exec.Command("ollama", "list")
	output, err := cmd.Output()
	if err != nil {
		log.Printf("Ошибка выполнения ollama list: %v", err)
		fyne.Do(func() {
			dialog.ShowInformation("Предупреждение",
				"Не удалось получить список моделей от Ollama.\n"+
					"Убедитесь, что Ollama установлен и запущен.\n", g.window)
		})
		defaultModels := []string{
			"Модели Ollama не найдены.",
		}
		fyne.Do(func() {
			g.model1Select.Options = defaultModels
			g.model2Select.Options = defaultModels
			g.model1Select.Refresh()
			g.model2Select.Refresh()
			g.status.SetText("Ollama не запущен")
		})
		return
	}

	lines := strings.Split(string(output), "\n")
	models := []string{}
	for i, line := range lines {
		if i == 0 || strings.TrimSpace(line) == "" {
			continue
		}
		columns := strings.Fields(line)
		if len(columns) > 0 {
			modelName := columns[0]
			if modelName != "" {
				models = append(models, modelName)
			}
		}
	}
	if len(models) == 0 {
		log.Println("Модели Ollama не найдены.")
		models = []string{
			"Модели Ollama не найдены.",
		}
		fyne.Do(func() {
			g.model1Select.Options = models
			g.model2Select.Options = models
			g.model1Select.Refresh()
			g.model2Select.Refresh()
			g.status.SetText("Нет моделей Ollama")
		})
	} else {
		fyne.Do(func() {
			g.model1Select.Options = models
			g.model2Select.Options = models
			g.model1Select.Refresh()
			g.model2Select.Refresh()
			g.status.SetText("Готов")
		})
	}
	log.Printf("Загружено %d моделей", len(models))
}

// runConversation выполняет диалог в отдельной горутине
func (g *guiApp) runConversation(params struct {
	Model1, Model2, Role1, Role2, InitialPrompt string
	MaxRounds, TimeoutMin                       int
}) {
	defer g.wg.Done()

	g.mu.Lock()
	if g.state.IsActive {
		g.mu.Unlock()
		fyne.Do(func() {
			g.appendMessage(Message{
				Role:      "system",
				Content:   "Диалог уже запущен, дождитесь завершения",
				Timestamp: time.Now().Format("15:04:05"),
				IsBot:     false,
			})
		})
		return
	}
	g.state = ConversationState{
		Model1:        params.Model1,
		Model2:        params.Model2,
		MaxRounds:     params.MaxRounds,
		TimeoutMin:    params.TimeoutMin,
		InitialPrompt: params.InitialPrompt,
		History:       make([]Message, 0),
		CurrentRound:  0,
		IsActive:      true,
		Model1Context: &ModelContext{
			Role:          params.Role1,
			DialogHistory: make([]Message, 0),
		},
		Model2Context: &ModelContext{
			Role:          params.Role2,
			DialogHistory: make([]Message, 0),
		},
	}
	systemMsg1 := params.InitialPrompt
	if params.Role1 != "" {
		systemMsg1 = fmt.Sprintf("Ты играешь роль: %s. %s", params.Role1, params.InitialPrompt)
	}
	systemMsg2 := params.InitialPrompt
	if params.Role2 != "" {
		systemMsg2 = fmt.Sprintf("Ты играешь роль: %s. %s", params.Role2, params.InitialPrompt)
	}
	g.state.Model1Context.SystemMessage = Message{
		Role:    "system",
		Content: systemMsg1,
		Model:   params.Model1,
	}
	g.state.Model2Context.SystemMessage = Message{
		Role:    "system",
		Content: systemMsg2,
		Model:   params.Model2,
	}
	g.mu.Unlock()

	fyne.Do(func() {
		g.appendMessage(Message{
			Role:      "system",
			Content:   fmt.Sprintf("Диалог начался: %s vs %s", params.Model1, params.Model2),
			Timestamp: time.Now().Format("15:04:05"),
			IsBot:     false,
		})
		g.appendMessage(Message{
			Role:      "user",
			Content:   "Давайте начнем наш диалог. " + params.InitialPrompt,
			Timestamp: time.Now().Format("15:04:05"),
			IsBot:     false,
		})
	})

	for round := 1; round <= params.MaxRounds; round++ {
		g.mu.Lock()
		if !g.state.IsActive {
			g.mu.Unlock()
			break
		}
		g.state.CurrentRound = round
		g.mu.Unlock()

		fyne.Do(func() {
			g.mu.Lock()
			roundNum := g.state.CurrentRound
			maxRounds := g.state.MaxRounds
			g.mu.Unlock()
			g.roundLabel.SetText(fmt.Sprintf("Раунд: %d/%d", roundNum, maxRounds))
		})

		log.Printf("--- Раунд %d ---", round)

		// Ход первой модели
		g.mu.Lock()
		messages1 := buildContext(g.state.Model1Context)
		g.mu.Unlock()

		ctx1, cancel1 := context.WithTimeout(context.Background(), time.Duration(params.TimeoutMin)*time.Minute)
		response1, err := callOllamaAPI(ctx1, params.Model1, messages1)
		cancel1()
		if err != nil {
			fyne.Do(func() {
				g.appendMessage(Message{
					Role:      "error",
					Content:   fmt.Sprintf("Ошибка от %s: %v", params.Model1, err),
					Timestamp: time.Now().Format("15:04:05"),
					IsBot:     false,
				})
			})
			break
		}
		msg1 := Message{
			Role:      "assistant",
			Content:   response1,
			Model:     params.Model1,
			Timestamp: time.Now().Format("15:04:05"),
			IsBot:     true,
			Round:     round,
		}

		g.mu.Lock()
		g.state.History = append(g.state.History, msg1)
		updateDialogContext(g.state.Model1Context, msg1, true)
		updateDialogContext(g.state.Model2Context, Message{
			Role:    "user",
			Content: response1,
			Model:   params.Model1,
		}, false)
		g.mu.Unlock()

		fyne.Do(func() {
			g.appendMessage(msg1)
		})

		g.mu.Lock()
		active := g.state.IsActive
		g.mu.Unlock()
		if !active {
			break
		}

		// Ход второй модели
		g.mu.Lock()
		messages2 := buildContext(g.state.Model2Context)
		g.mu.Unlock()

		ctx2, cancel2 := context.WithTimeout(context.Background(), time.Duration(params.TimeoutMin)*time.Minute)
		response2, err := callOllamaAPI(ctx2, params.Model2, messages2)
		cancel2()
		if err != nil {
			fyne.Do(func() {
				g.appendMessage(Message{
					Role:      "error",
					Content:   fmt.Sprintf("Ошибка от %s: %v", params.Model2, err),
					Timestamp: time.Now().Format("15:04:05"),
					IsBot:     false,
				})
			})
			break
		}
		msg2 := Message{
			Role:      "assistant",
			Content:   response2,
			Model:     params.Model2,
			Timestamp: time.Now().Format("15:04:05"),
			IsBot:     true,
			Round:     round,
		}

		g.mu.Lock()
		g.state.History = append(g.state.History, msg2)
		updateDialogContext(g.state.Model2Context, msg2, true)
		updateDialogContext(g.state.Model1Context, Message{
			Role:    "user",
			Content: response2,
			Model:   params.Model2,
		}, false)
		g.mu.Unlock()

		fyne.Do(func() {
			g.appendMessage(msg2)
		})

		time.Sleep(1 * time.Second)
	}

	g.mu.Lock()
	g.state.IsActive = false
	finalRound := g.state.CurrentRound
	g.mu.Unlock()

	fyne.Do(func() {
		g.appendMessage(Message{
			Role:      "system",
			Content:   fmt.Sprintf("Диалог завершен. Всего раундов: %d", finalRound),
			Timestamp: time.Now().Format("15:04:05"),
			IsBot:     false,
		})
		g.startBtn.Enable()
	})
	log.Println("Диалог завершен")
}

// startConversation вызывается по нажатию кнопки "Начать диалог"
func (g *guiApp) startConversation() {
	g.mu.Lock()
	if g.state.IsActive {
		g.mu.Unlock()
		dialog.ShowInformation("Ошибка", "Диалог уже запущен", g.window)
		return
	}
	g.mu.Unlock()

	if g.model1Select.Selected == "" || g.model2Select.Selected == "" {
		dialog.ShowInformation("Ошибка", "Выберите обе модели", g.window)
		return
	}
	if g.model1Select.Selected == g.model2Select.Selected {
		dialog.ShowInformation("Ошибка", "Модели должны быть разными", g.window)
		return
	}
	topic := g.topicEntry.Text
	if topic == "" {
		dialog.ShowInformation("Ошибка", "Введите тему диалога", g.window)
		return
	}
	rounds, err := strconv.Atoi(g.roundsEntry.Text)
	if err != nil || rounds < 1 {
		dialog.ShowInformation("Ошибка", "Некорректное количество раундов", g.window)
		return
	}
	timeout, err := strconv.Atoi(g.timeoutEntry.Text)
	if err != nil || timeout < 1 {
		dialog.ShowInformation("Ошибка", "Некорректный таймаут", g.window)
		return
	}

	params := struct {
		Model1, Model2, Role1, Role2, InitialPrompt string
		MaxRounds, TimeoutMin                       int
	}{
		Model1:        g.model1Select.Selected,
		Model2:        g.model2Select.Selected,
		Role1:         g.role1Entry.Text,
		Role2:         g.role2Entry.Text,
		InitialPrompt: topic,
		MaxRounds:     rounds,
		TimeoutMin:    timeout,
	}

	g.startBtn.Disable()
	g.wg.Add(1)
	go g.runConversation(params)
}

// resetConversation выполняет экстренную остановку и возврат к исходным настройкам
func (g *guiApp) resetConversation() {
	// Блокируем кнопки на время сброса
	g.startBtn.Disable()
	g.resetBtn.Disable()

	// Показываем диалог ожидания
	loadingDialog := dialog.NewInformation("Сброс", "Остановка диалога...", g.window)
	loadingDialog.Show()

	go func() {
		// Останавливаем диалог
		g.mu.Lock()
		wasActive := g.state.IsActive
		if wasActive {
			g.state.IsActive = false
		}
		g.mu.Unlock()

		if wasActive {
			g.wg.Wait() // ждем полной остановки
		}

		// Очищаем состояние и историю
		g.mu.Lock()
		g.state = ConversationState{
			History:       make([]Message, 0),
			CurrentRound:  0,
			IsActive:      false,
			Model1Context: nil,
			Model2Context: nil,
		}
		g.mu.Unlock()

		// Сбрасываем все поля ввода к исходным значениям
		fyne.Do(func() {
			// Очищаем выбор моделей
			g.model1Select.ClearSelected()
			g.model2Select.ClearSelected()
			// Очищаем поля ролей и темы
			g.role1Entry.SetText("")
			g.role2Entry.SetText("")
			g.topicEntry.SetText("")
			// Восстанавливаем значения по умолчанию
			g.roundsEntry.SetText("2")
			g.timeoutEntry.SetText("10")
			// Очищаем чат
			g.chatRichText.Segments = nil
			g.chatRichText.Refresh()
			// Сбрасываем индикатор раунда
			g.roundLabel.SetText("Раунд: 0/0")
			// Разблокируем кнопки
			g.startBtn.Enable()
			g.resetBtn.Enable()
			// Закрываем диалог ожидания
			loadingDialog.Hide()
			// Показываем сообщение о сбросе
			g.appendMessage(Message{
				Role:      "system",
				Content:   "Состояние сброшено. Настройки восстановлены по умолчанию.",
				Timestamp: time.Now().Format("15:04:05"),
				IsBot:     false,
			})
		})
	}()
}

// buildGUI создаёт и размещает все элементы интерфейса
func (g *guiApp) buildGUI() {
	g.status = widget.NewLabel("Готов")
	g.status.TextStyle = fyne.TextStyle{Bold: true}

	g.model1Select = widget.NewSelect([]string{}, func(s string) {})
	g.model1Select.PlaceHolder = "Выберите модель 1"
	g.model2Select = widget.NewSelect([]string{}, func(s string) {})
	g.model2Select.PlaceHolder = "Выберите модель 2"
	g.role1Entry = widget.NewEntry()
	g.role1Entry.SetPlaceHolder("Роль модели 1 (необязательно)")
	g.role2Entry = widget.NewEntry()
	g.role2Entry.SetPlaceHolder("Роль модели 2 (необязательно)")
	g.topicEntry = widget.NewMultiLineEntry()
	g.topicEntry.SetPlaceHolder("Тема диалога")
	g.topicEntry.Wrapping = fyne.TextWrapWord
	g.roundsEntry = widget.NewEntry()
	g.roundsEntry.SetText("2")
	g.roundsEntry.Validator = validation.NewRegexp(`^[0-9]+$`, "только число")
	g.timeoutEntry = widget.NewEntry()
	g.timeoutEntry.SetText("10")
	g.timeoutEntry.Validator = validation.NewRegexp(`^[0-9]+$`, "только число")

	g.startBtn = widget.NewButtonWithIcon("Начать диалог", theme.MediaPlayIcon(), g.startConversation)
	g.resetBtn = widget.NewButtonWithIcon("Сброс", theme.ViewRefreshIcon(), g.resetConversation)

	settingsForm := widget.NewForm(
		widget.NewFormItem("Модель 1", g.model1Select),
		widget.NewFormItem("Роль 1", g.role1Entry),
		widget.NewFormItem("Модель 2", g.model2Select),
		widget.NewFormItem("Роль 2", g.role2Entry),
		widget.NewFormItem("Тема диалога", g.topicEntry),
		widget.NewFormItem("Количество раундов", g.roundsEntry),
		widget.NewFormItem("Таймаут (мин)", g.timeoutEntry),
	)
	buttons := container.NewGridWithColumns(2, g.startBtn, g.resetBtn)
	settingsPanel := container.NewBorder(
		container.NewVBox(widget.NewLabelWithStyle("Настройки диалога", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})),
		buttons, nil, nil,
		settingsForm,
	)

	g.chatRichText = widget.NewRichText()
	g.chatRichText.Wrapping = fyne.TextWrapWord
	chatScroll := container.NewScroll(g.chatRichText)
	g.roundLabel = widget.NewLabel("Раунд: 0/0")
	chatHeader := container.NewBorder(nil, nil, nil, g.roundLabel, widget.NewLabelWithStyle("Диалог моделей", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}))
	chatPanel := container.NewBorder(chatHeader, nil, nil, nil, chatScroll)

	split := container.NewHSplit(settingsPanel, chatPanel)
	split.SetOffset(0.35)

	statusBox := container.NewHBox(widget.NewLabel("Статус:"), g.status)
	topBar := container.NewBorder(nil, nil, nil, nil,
		container.NewVBox(statusBox, widget.NewSeparator()))
	content := container.NewBorder(topBar, nil, nil, nil, split)
	g.window.SetContent(content)

	// Загружаем модели в фоне
	go func() {
		g.loadModels()
	}()
}

func main() {
	a := app.New()
	w := a.NewWindow("Диалог LLM моделей Ollama")
	w.Resize(fyne.NewSize(1000, 700))

	gui := &guiApp{
		window: w,
	}
	gui.buildGUI()

	w.ShowAndRun()
}

А вы пробовали сталкивать нейросети друг с другом?