執筆者: кемо
最終更新: 2024/12/24
突然ですが,私はクリぼっちで,彼女いない歴=年齢のナメクジです.しかし,こんな私でも彼女をつくれた方法があります!!
この記事をみているみなさんに,簡単にできる彼女のつくり方をご紹介したいと思います.ちなみにクリスマスに間に合わせるためかなり駆け足で説明します.
愛ですよ.愛
必須
あったほうがいい
彼女の大まかな構成としては以下のようになっています.
独立して3Dモデルを動かしつつ,入力があればバックエンドのサーバーに送信して応答を取得.それを表示して,表情などを変えるという流れです.
ここで彼女の欠点を説明しておきます.この彼女ですがローカルでLLMを動かすために,大量のVRAMとRAMを消費します.ただし,モデルを変えればノートパソコンで動くレベルにはできます.おすすめのモデルを2つ紹介します.もちろん量子化したのを使ってくださいね.
モデルサイズはでかいですが,ローカルで動かせる中では執筆時(2024/12)で最高クラスの日本語LLMです.GPUに余裕のある方はこれでいいんじゃないでしょうか.元がLlama3なので扱いやすいのもgoodです.
だいぶ前に話題になってたRinna社の出してるモデルです.元はGoogleのgemma2なのですが,ちょっと扱いに難があります.roleにシステムがないので,ちょっと工夫が必要ですがサイズが小さいため量子化すれば十分ノートパソコンで動きます.能力も申し分ないです.
まずテキスト生成を行うための.NETアプリケーションを作りましょう。そしてこいつを導入.はい,Llama.sharpです.今回,彼女と会話するためにllmを用いますが,それを高速に動かすためのllama.cppをC#で使えるようにしたやつです.ある程度知識のある方は「llama-cpp-pythonあるだろ」と思ったかもしれませんが、製作時(2024/10くらい),このpythonラッパーは安定性に欠けていました(公式githubのissueにも指摘があるので修正されてるかもしれません).そのため急遽,llama.sharpとC#を用いた開発に切り替えました.導入方法は公式ページ見ればだいたいわかります.ただし,llama-cpp-pythonを用いたほうがよい場合もあります.これについては後述します.
とりあえずコード貼ります.公式ドキュメントとにらめっこしながら詰め込みまくったコードなので,違法建築のごとく,ごちゃごちゃになってます.
using LLama.Common;
using LLama.Sampling;
using LLama.Transformers;
using LLama;
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
public partial class Program
{
public static async Task Main(string[] args)
{
string modelPath = "model gguf pass !!";
var server = new LLMServer(modelPath);
await server.StartServer(5000);
}
}
public class LLMServerRequest
{
public string UserInput { get; set; } = string.Empty;
public string? SystemPrompt { get; set; }
public bool Stateless { get; set; } = false;
public bool discussion { get; set; } = false;
}
public class LLMServer
{
private readonly LLamaWeights _model;
private readonly ModelParams _modelParams;
// private readonly Dictionary<string, ChatSession> _sessions = new();
private readonly LLamaContext _context;
private readonly InteractiveExecutor _executor;
// private readonly StatelessExecutor _statelessExecutor;
private readonly ChatHistory _chatHistory;
private readonly ChatSession _session;
private const string DefaultSystemPrompt = "ここにシステムプロンプト";
private const string DefaultInstruction = "あなたはjson形式で返答であるmessageと今の感情であるemotionを「Joy、Trust、Fear、Surprise、Sadness、Disgust、Anger、Hope」のどれか1つを選択しなさい。";
public LLMServer(string modelPath)
{
_modelParams = new ModelParams(modelPath)
{
ContextSize = 4096,
GpuLayerCount = -1
};
_model = LLamaWeights.LoadFromFile(_modelParams);
_context = _model.CreateContext(_modelParams);
_executor = new InteractiveExecutor(_context);
_chatHistory = new ChatHistory();
_chatHistory.AddMessage(AuthorRole.System, DefaultSystemPrompt);
_chatHistory.AddMessage(AuthorRole.System, DefaultInstruction);
_session = new ChatSession(_executor, _chatHistory);
_session.WithHistoryTransform(new PromptTemplateTransformer(_model, withAssistant: true));
_session.WithOutputTransform(new LLamaTransforms.KeywordTextOutputStreamTransform([_model.Tokens.EndOfTurnToken ?? "User:", "�"],redundancyLength: 5));
}
public async Task StartServer(int port = 8000)
{
Console.WriteLine($"サーバーを起動中。ポート: {port}");
var listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{port}/");
listener.Start();
Console.WriteLine($"サーバーを起動しました。ポート: {port}");
while (true)
{
var context = await listener.GetContextAsync();
_ = ProcessRequestAsync(context);
}
}
private async Task ProcessRequestAsync(HttpListenerContext context)
{
try
{
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
var jsonInput = await reader.ReadToEndAsync();
var request = JsonSerializer.Deserialize<LLMServerRequest>(jsonInput);
if (request == null || string.IsNullOrEmpty(request.UserInput))
{
await SendResponseAsync(context, "無効なリクエストです。メッセージが必要です。", 400);
return;
}
var response = await GenerateResponseAsync(request);
await SendResponseAsync(context, response);
}
catch (Exception ex)
{
await SendResponseAsync(context, $"エラーが発生しました: {ex.Message}", 500);
}
}
private async Task<string> GenerateResponseAsync(LLMServerRequest request)
{
InferenceParams inferenceParams;
if(request.discussion)
{
var gbnf = (await File.ReadAllTextAsync("gbnf pass !!")).Trim();
inferenceParams = new InferenceParams
{
SamplingPipeline = new DefaultSamplingPipeline
{
Temperature = 0.6f,
Grammar = new(gbnf, "root"),
},
MaxTokens = -1,
AntiPrompts = [_model.Tokens.EndOfTurnToken ?? "User:"]
};
}
else
{
var emotion_gbnf = (await File.ReadAllTextAsync("gbnf pass !!")).Trim();
inferenceParams = new InferenceParams
{
SamplingPipeline = new DefaultSamplingPipeline
{
Temperature = 0.6f,
Grammar = new(emotion_gbnf, "root"),
},
MaxTokens = -1,
AntiPrompts = [_model.Tokens.EndOfTurnToken ?? "User:"]
};
}
var output = new StringBuilder();
if (request.Stateless)
{
LLamaContext statelessContext = _model.CreateContext(_modelParams);
InteractiveExecutor statelessExecutor = new InteractiveExecutor(statelessContext);
ChatHistory statelessChatHistory = new ChatHistory();
statelessChatHistory.AddMessage(AuthorRole.System, request.SystemPrompt ?? DefaultSystemPrompt);
if(request.discussion){
statelessChatHistory.AddMessage(AuthorRole.System, "あなたは次の議題に対する意見を述べてください。json形式での回答が必要です.その議題に対する賛否stanceをtrueまたはfalseで、その理由reasonを文字列で具体的に回答しなければなりません。");
}
ChatSession statelessSession = new ChatSession(statelessExecutor, statelessChatHistory);
statelessSession.WithHistoryTransform(new PromptTemplateTransformer(_model, withAssistant: true));
statelessSession.WithOutputTransform(new LLamaTransforms.KeywordTextOutputStreamTransform([_model.Tokens.EndOfTurnToken ?? "User:", "�"],redundancyLength: 5));
await foreach (string token in statelessSession.ChatAsync(
new ChatHistory.Message(AuthorRole.User, request.UserInput), inferenceParams))
{
output.Append(token);
}
}
else
{
await foreach (string token in _session.ChatAsync(
new ChatHistory.Message(AuthorRole.User, request.UserInput), inferenceParams))
{
output.Append(token);
}
}
return output.ToString();
}
private async Task SendResponseAsync(HttpListenerContext context, string responseText, int statusCode = 200)
{
var response = context.Response;
response.StatusCode = statusCode;
response.ContentType = "application/json; charset=utf-8";
var buffer = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { reply = responseText }));
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
}
絶対パスを書いてしまい大変申し訳ございません.割と適当に開発してたのと,別部分に注力してたのでこんなことになってます.discussionモードとかは試験的に作ってたものなので気にしなくていいです.なんならstatelessモードも気にしなくていいです.一応,statelessモードだけ解説すると,会話の履歴を保存しないチャットです.llama.sharpにはstatelessExcutaorというこのモードを使うためのものが用意されているのですが,こいつシステムプロンプトを受け付けません.そのため普通のexcutorでhistoryとcontextをリセットすることで実現しています.先に言っておきますが,このプログラムの最大の欠点は実質システムプロンプトがDefaultSystemPromptに書いてるやつしか使えないことです.つまり,動かしてる間システムプロンプトを変えられません.大した欠点じゃないと思ってるので直してません.そのため,実際に使うときはこのプログラムをある程度修正していただけると嬉しいです.
llama.sharpの使い方の流れを説明します.
あんまり見ないのが,コンテクストですね.これはモデルがテキスト生成の際に使用する内部状態を保持するためのインスタンスです.とにかく生成に使うものを格納してるだけだと思えば問題ないと思います.あんまり説明できません.一番いいのは公式のExampleを見ることですね.
一番苦労したのがgbnfによる出力の制限ですね.理由としては,そもそもこの概念を知らなかったからです.GPT君もまともなこと言わないし,Exampleにも1つしかないしで,よく分からないまま書いてます.emotion_gbnfの中身を貼ります.
root ::= message
message ::= "{" ws "\"emotion\"" ws ":" ws "\"" emotion "\"" ws "," ws "\"message\"" ws ":" ws string ws "}"
emotion ::= "Joy" | "Trust" | "Fear" | "Surprise" | "Sadness" | "Disgust" | "Anger" | "Hope "
value ::= array | string | number | boolean | null
boolean ::= "true" | "false"
null ::= "null"
object ::= "{" ws ( string ws ":" ws value ( ws "," ws string ws ":" ws value )* )? "}" ws
array ::= "[" ws ( value ( ws "," ws value )* )? "]" ws
string ::= "\"" ( [^"\\] | "\\" (["\\/bfnrt] | "u" [0-9a-fA-F]{4}) )* "\""
number ::= "-"? ( "0" | [1-9][0-9]* ) ( "." [0-9]+ )? ( [eE] [+-]? [0-9]+ )?
ws ::= [ \t\n\r]*
構文を指定することでJson形式に出力を制限しています.具体的にはemotionという感情を8つの中から選択し,messageに応答の本文が書かれるという形式です.これはUnityとの連携のためにしています.
音声のほうは今回は省かせてください.また今度書きます.
Unityの3Dモデル部分については,私が前に出した記事でほぼ説明しています.3Dモデルはオリジナルのが間に合わなかったので,しなのちゃん使いました.かわいいでしょう.これに適当にUI貼り付けただけです.なんでもいいので入出力できればいいのです.
ポイントなのは表情操作です.さっきLLMの出力にemotionを追加しました.これを使って表情を変えてます.正体はただのswitch文とアニメーターなので説明することないです.
こんな感じの流れになってます.
彼女創造.クリぼっち回避.
「これって...」
「ああ...,KEMOの勝ちだ」