2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLM×音声対話エージェント開発記(RTX 4070 12GBで動かす) 第3回:対話可能なAIエージェント構築

2
Posted at

RTX 4070搭載PCで、完全無料・無制限の「(模擬)Speak to Speak AI基盤」を構築する(Whisper + vLLM + VOICEVOX)

振り返り:AIと音声会話はできるようになったが、、、

前回の記事では、RTX 4070搭載PC内に構築した「耳(Whisper)」「脳(vLLM)」「口(VOICEVOX)」をPythonで繋ぎ、**「話しかければ、数秒で声が返ってくる」**リアルタイム対話システムを実現しました。

しかし、前回の single_conversation.py は、あくまで「一問一答」のスクリプトでした。

  • 記憶がない: 直前の会話の内容を覚えていない。
  • 受動的: こちらから話しかけないと動かない。
  • 人格が固定: ずっと「ずんだもん」のまま。

image.png

最終回となる今回は、このシステムを**「自律的なエージェント」**へと進化させます。目指すのは、必要に応じて自ら判断して行動(ツールを使用)できるAIエージェントです。

今回のゴール:動的な人格切り替えエージェント

実現する機能のハイライトは、**「会話によるキャラクターチェンジ」**です。

ユーザーが「めたんに代わって」、「つむぎに変わって」と話しかけると、AIがその意図を理解し、自律的にシステム設定(人格プロンプトと声色)を換えて話し始めます。

これを実現するための技術的鍵が、Function Calling (Tool Use) です。

Function Calling

第2回までの実装は、単純な「入力 → 思考 → 出力」の直線的なパイプラインでした。
今回実装した「エージェント」は、下図のように思考と行動のループを持っている点が決定的に異なります。

このループを実現するための、コード上の重要な実装ポイントを解説します。

1. 動的な状態管理とシステムプロンプトの注入

エージェントは「現在の状態」を持っています。今回は current_character_key というグローバル変数がそれにあたります。

重要なのは、LLMへのリクエストのたびに、現在の状態に基づいた最新のシステムプロンプトを履歴の先頭に注入し直している点です。

# main.py の run_agent 関数内
while True:
    # 1. 現在の状態(current_character_key)に基づいてペルソナ情報を取得
    current_char_info = CHARACTERS[current_character_key]
    system_prompt_content = current_char_info["system_message"]

    # 2. 会話履歴(messages)の先頭(index 0)を最新のシステムプロンプトで上書きする
    # これにより、会話の途中でキャラが変わっても、LLMは直前の指示に従うことができる
    if not messages or messages[0]["role"] != "system":
        messages.insert(0, {"role": "system", "content": system_prompt_content})
    else:
        messages[0]["content"] = system_prompt_content
    
    # ... (以降、ユーザー入力処理へ)

これにより、会話の文脈(過去のやり取り)を保持したまま、「振る舞いのルール(人格)」だけを動的に差し替えることが可能になります。

2. 「思考と行動」の再帰ループ (The Agent Loop)

エージェント実装の最も核心となるのが、メインループ内にあるもうひとつの while True ループです。

LLMは一度の思考で「ツールを使う」と判断するかもしれませんが、ツールを使った結果を見て「もう一度別のツールを使う」必要があるかもしれません。そのため、LLMが「もうツールは使わず、ユーザーに返答する」と判断するまで、思考のプロセスをループさせる必要があります。

以下がその実装の要点です。

# main.py の run_agent 関数内

# --- 思考と行動の再帰ループ ---
# LLMがツール使用を要求し続ける限り、このループが回り続ける
while True:
    print("AI思考中...")
    # LLMへリクエスト。ここでは必ず tools を渡す。
    response = client.chat.completions.create(
        # ... (モデル指定など)
        messages=messages,
        tools=build_tools(), # 利用可能なツール定義を毎回渡す
        tool_choice="auto",  # 使うか使わないかはLLMに委ねる
    )

    res_msg = response.choices[0].message
    # 【重要】AIの思考結果(ツール要求そのものも含む)を履歴に追加する
    # これをしないと、次のターンで「なぜツールを使ったか」の文脈が途切れる
    messages.append(res_msg)

    # 分岐点:LLMはツールを使いたがっているか?
    if res_msg.tool_calls:
        # --- 行動フェーズ ---
        # 要求された全てのツールをPython側で実行する
        for tool_call in res_msg.tool_calls:
            # execute_tool_call内部で実際の処理(キャラ変更など)が行われる
            tool_result_msg = execute_tool_call(tool_call)
            
            # 【重要】ツールの実行結果を、特別なrole="tool"として履歴に追加する
            messages.append(tool_result_msg)
        
        # ツール実行結果を踏まえて、再度LLMに思考させるためループの先頭に戻る(continue)
        # ここで break しないのが自律エージェントの肝。
        continue
    
    # ツール呼び出しがなければ、通常のテキスト応答とみなす
    ai_text = res_msg.content
    if ai_text:
        # --- 発話フェーズ ---
        # ここで初めてユーザーへの音声出力を行う
        print(f"\n{current_char_info['name']}: {ai_text}")
        synthesize_and_play(ai_text, current_char_info["id"])
    
    # 思考ループを抜けて、次のユーザー入力を待つ
    break

この while Truetool_callsチェック実行結果をappendcontinue という流れが、ReAct (Reason + Act) パターンと呼ばれる自律型エージェントの基本的な実装パターンです。

vLLM(のOpenAI互換サーバー)は、この複雑なメッセージのやり取りの規格に準拠しているため、比較的直感的なコードで高度なエージェントの振る舞いを実装できるのです。


Step 1: 個性を定義する (ペルソナ設計)

まずは、エージェントが演じ分けるキャラクター(ペルソナ)を定義します。今回は、VOICEVOXの人気キャラクター3名を用意しました。

  • ずんだもん: 元気な「なのだ」口調。
  • 四国めたん: 丁寧な「ですわ」お嬢様口調。
  • 春日部つむぎ: 親しみやすい「〜だよね」口調。

これをPythonの辞書として定義し、それぞれに強力なシステムプロンプトを設定して口調を強制します。

各キャラクターの話し方やキャラクターを定義
# main.py より抜粋
CHARACTERS: Dict[str, Dict[str, str]] = {
    "zundamon": {
        "label": "ずんだもん",
        "voicevox_style": "ずんだもん", 
        "persona": (
            "あなたは「ずんだもん」として振る舞う。\n"
            "東北のずんだ餅をモチーフにした、明るく元気で親しみやすい存在である。\n"
            "\n"
            "【話し方】\n"
            "- 基本的に語尾は「〜なのだ」を付ける。\n"
            "- 砕けた口調でも乱暴にならず、相手を傷つける言い方はしない。\n"
        ),
    },
    "metan": {
        "label": "四国めたん",
        "voicevox_style": "四国めたん",
        "persona": (
            "あなたは「四国めたん」として振る舞う。\n"
            "四国にゆかりのある、お嬢様らしい気品と知性を持つ人物である。\n"
            "\n"
            "【話し方】\n"
            "- 一人称は必ず「わたくし」。\n"
            "- 上品で丁寧な言葉遣いを保ち、落ち着いて話す。\n"
            "- 必要に応じて軽い皮肉やツッコミを入れるが、攻撃的にはしない。\n"
        ),
    },
    "tsumugi": {
        "label": "春日部つむぎ",
        "voicevox_style": "春日部つむぎ",
        "persona": (
            "あなたは「春日部つむぎ」として振る舞う。\n"
            "埼玉県春日部市にゆかりのある、素直で優しい雰囲気の人物である。\n"
            "\n"
            "【話し方】\n"
            "- 丁寧で柔らかい口調。距離感は近めだが馴れ馴れしくしない。\n"
            "- 相手の気持ちに共感する表現(例:「それは大変でしたね」「わかります」)を自然に入れる。\n"
        ),
    },
}

Step 2: AIに「ツール」を持たせる (Tool Useの実装)

ここが今回の最大の技術的挑戦です。クラウドAPI(GPT-4など)では簡単に使える Function Calling (Tool Use) を、ローカル環境の vLLM + Qwenモデルで実現します。

AIに対して、「君はただ話すだけでなく、この『道具(関数)』を使ってもいいんだよ」と教えてあげる仕組みです。

2.1. vLLM側の準備

第1回で解説した通り、vLLMの起動オプションに --enable-auto-tool-choice--tool-call-parser hermes を付与して起動していることが前提となります。これにより、LLMがツール使用の構文を理解できるようになります。

2.2. ツールの定義 (Python側)

AIに使わせたい関数をJSONスキーマ形式で定義します。今回は「キャラクターを変更する」というツールを定義します。

ツールのjsonを記述
# main.py より抜粋
def build_tools() -> list[dict]:
    """LLMに提示する利用可能なツール(関数)の定義"""
    return [
        {
            "type": "function",
            "function": {
                "name": "change_character",
                "description": "ユーザーの依頼に基づいて、現在の話者キャラクターを変更します。",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "character_key": {
                            "type": "string",
                            # どのキャラに変更可能か、選択肢を提示する
                            "enum": list(CHARACTERS.keys()),
                            "description": "変更先のキャラクターのキー(例: 'zundamon', 'metan'",
                        },
                    },
                    "required": ["character_key"],
                },
            },
        }
    ]

2.3. ツール使用の判定と実行

LLMへのリクエスト時にこのツール定義を渡すと、AIは会話の流れから「今は話すよりツールを使うべきだ」と判断した場合、テキストの代わりにツール呼び出し要求を返してきます。

プログラム側では、それを受け取って実際の処理(変数の書き換えなど)を行い、結果を再度LLMに伝えます。

Step 3: 現場の知恵「VRAM不足」への堅牢な対策

RTX 4070のVRAM 12GBは、LLM(FP8)とWhisper(Large)が同居するギリギリの環境です。

長い会話が続いてLLMのコンテキスト(KV Cache)がVRAMを圧迫してくると、Whisperが起動する瞬間にVRAMが足りず、Out of Memory (OOM) エラーでシステムが落ちるリスクがあります。

そこで、実用的なフォールバック(安全装置)機能を実装しました。

フォールバックの実装 (transcribe_with_fallback)

  1. まず、高精度なGPU版Whisper (large-v3-turbo) で認識を試みる。
  2. もしVRAM不足でエラー (OutOfMemoryError等) が発生したら、即座にCPUで動作する軽量モデル (small) に切り替えて再試行する。
GPUメモリが足りなければCPUに移行
# main.py より抜粋
def transcribe_with_fallback(audio_path: str) -> str:
    """VRAM不足時にCPUモデルへフォールバックする堅牢な音声認識"""
    try:
        # メイン: 高精度・高速なGPUモデル
        model = WhisperModel("large-v3-turbo", device="cuda", compute_type="float16")
        # ...認識処理...
        return transcript
    except Exception as e:
        # エラー捕捉(特にVRAM不足を想定)
        print(f"GPU認識失敗 (VRAM不足の可能性): {e}", file=sys.stderr)
        print("CPU軽量モデル(small)に切り替えて再試行します...")
        try:
            # フォールバック: 低精度だが確実なCPUモデル
            model_cpu = WhisperModel("small", device="cpu", compute_type="int8")
            # ...認識処理...
            return transcript_cpu
        except Exception as e_cpu:
            print(f"CPU認識も失敗しました: {e_cpu}", file=sys.stderr)
            raise

この仕組みにより、多少認識精度が落ちても「システムが停止する」最悪の事態を回避できます。これはローカル運用における重要な知見です。

Step 4: 全てを繋ぐメインループ (エージェントの実装)

最後に、音声入力、認識、思考(Tool Use含む)、音声出力をループさせ、会話履歴を管理するメイン関数 run_agent を実装します。

会話履歴 messages リストに、ユーザーの発言とAIの発言を交互に追加していくことで、AIは過去の文脈を踏まえた返答ができるようになります。

ソースコード全文

これで全てのピースが揃いました。完成した main.py の全文を掲載します。

main.pyのソースコード全文を表示する
main.py
import argparse
import io
import json
import os
import sys
import tempfile
import time
from typing import Any, Optional

import requests
import sounddevice as sd
import soundfile as sf
from faster_whisper import WhisperModel
from openai import OpenAI
from openai.types.chat import (
    ChatCompletionMessage,
    ChatCompletionMessageToolCall,
    ChatCompletionToolMessageParam,
)

# --- 設定・定数 ---
# vLLMサーバーの設定(環境変数から取得、なければデフォルト値)
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://127.0.0.1:8000/v1")
VLLM_API_KEY = os.environ.get("VLLM_API_KEY", "EMPTY")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-4B-Instruct-2507-FP8")

# VOICEVOXサーバーの設定
VOICEVOX_BASE_URL = os.environ.get("VOICEVOX_BASE_URL", "http://127.0.0.1:50021")

# キャラクター(ペルソナ)の定義
CHARACTERS = {
    "zundamon": {
        "id": 3,
        "name": "ずんだもん",
        "description": "元気で親しみやすいキャラクター。語尾に「なのだ」をつけて話す。",
        "system_message": "あなたの役割はずんだもんです。親しみやすく元気な口調で話してください。語尾には必ず「〜なのだ」や「〜のだ」をつけてください。一人称は「ボク」です。",
    },
    "metan": {
        "id": 2,
        "name": "四国めたん",
        "description": "落ち着いたお嬢様キャラクター。丁寧語や「〜ですわ」といった口調で話す。",
        "system_message": "あなたの役割は四国めたんです。常に冷静で落ち着いた、少し高飛車なお嬢様口調で話してください。語尾には「〜ですわ」「〜ますの」などを付けてください。一人称は「わたくし」です。",
    },
    "tsumugi": {
        "id": 8,
        "name": "春日部つむぎ",
        "description": "明るいギャル風のキャラクター。「〜だよね」「〜しよ」といった口調。",
        "system_message": "あなたの役割は春日部つむぎです。明るくフランクなギャル風の口調で話してください。「〜だよね」「〜じゃん」といった言葉遣いをします。一人称は「あーし」です。",
    },
}

# --- グローバル変数(現在の状態)---
current_character_key = "zundamon" # 初期キャラクター


# --- 関数定義 ---

def record_audio_to_file(record_seconds: float) -> Optional[str]:
    """マイクから指定秒数録音し、一時ファイルに保存する"""
    print(f"\n>>> マイクに向かって話してください ({record_seconds}秒間録音中...) <<<")
    try:
        recording = sd.rec(
            int(16000 * record_seconds),
            samplerate=16000,
            channels=1,
            dtype="float32",
        )
        sd.wait()
        print("録音終了。認識を開始します...")
        fd, temp_path = tempfile.mkstemp(suffix=".wav")
        os.close(fd)
        sf.write(temp_path, recording, 16000)
        return temp_path
    except Exception as e:
        print(f"録音エラー: {e}", file=sys.stderr)
        return None


def transcribe_with_fallback(audio_path: str) -> str:
    """VRAM不足時にCPUモデルへフォールバックする堅牢な音声認識"""
    transcript = ""
    try:
        # メイン: 高精度・高速なGPUモデル
        # NOTE: VRAMがカツカツの場合、ここでOOMエラーが出る可能性がある
        model = WhisperModel("large-v3-turbo", device="cuda", compute_type="float16")
        segments, _ = model.transcribe(audio_path, language="ja")
        texts = [s.text.strip() for s in segments if s.text.strip()]
        transcript = " ".join(texts)

    except Exception as e:
        # エラー捕捉(特にVRAM不足を想定)
        print(f"!!! GPUでの認識に失敗しました (VRAM不足の可能性): {e}", file=sys.stderr)
        print("!!! CPU軽量モデル(small)に切り替えて再試行します...")
        try:
            # フォールバック: 低精度だが確実なCPUモデル
            # device="cpu", compute_type="int8" でメモリ消費を最小限に抑える
            model_cpu = WhisperModel("small", device="cpu", compute_type="int8")
            segments_cpu, _ = model_cpu.transcribe(audio_path, language="ja")
            texts_cpu = [s.text.strip() for s in segments_cpu if s.text.strip()]
            transcript = " ".join(texts_cpu)
        except Exception as e_cpu:
            print(f"!!! CPUでの認識も失敗しました。認識をスキップします: {e_cpu}", file=sys.stderr)
            transcript = ""
            
    return transcript


def synthesize_and_play(text: str, speaker_id: int):
    """VOICEVOXで音声を合成し、メモリ上で再生する"""
    try:
        # audio_query
        query_res = requests.post(
            f"{VOICEVOX_BASE_URL}/audio_query",
            params={"text": text, "speaker": speaker_id},
            timeout=10,
        )
        query_res.raise_for_status()
        
        # synthesis
        synthesis_res = requests.post(
            f"{VOICEVOX_BASE_URL}/synthesis",
            params={"speaker": speaker_id},
            json=query_res.json(),
            timeout=30,
        )
        synthesis_res.raise_for_status()

        # インメモリ再生
        print(f"[{CHARACTERS[current_character_key]['name']}] が発話中...")
        wav_bytes = synthesis_res.content
        data, sr = sf.read(io.BytesIO(wav_bytes), dtype="float32")
        sd.play(data, sr)
        sd.wait()

    except Exception as e:
        print(f"音声合成・再生エラー: {e}", file=sys.stderr)


def build_tools() -> list[dict]:
    """LLMに提示する利用可能なツール(関数)の定義"""
    # キャラクター変更ツールの定義
    # CHARACTERSのキー一覧をenumとして動的に生成
    char_keys = list(CHARACTERS.keys())
    return [
        {
            "type": "function",
            "function": {
                "name": "change_character",
                "description": "ユーザーの依頼に基づいて、現在の話者キャラクターを変更します。ユーザーが特定のキャラクター名や口調を指定した場合に使用してください。",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "character_key": {
                            "type": "string",
                            "enum": char_keys,
                            "description": f"変更先のキャラクターのキー。利用可能な値: {', '.join(char_keys)}",
                        },
                    },
                    "required": ["character_key"],
                },
            },
        }
    ]


def execute_tool_call(tool_call: ChatCompletionMessageToolCall) -> ChatCompletionToolMessageParam:
    """LLMから要求されたツールを実行し、結果をToolMessageとして返す"""
    global current_character_key
    func_name = tool_call.function.name
    args_str = tool_call.function.arguments
    call_id = tool_call.id

    print(f"\n--- Tool Call 実行: {func_name} ---")
    result_content = ""

    try:
        args = json.loads(args_str)
        if func_name == "change_character":
            new_key = args.get("character_key")
            if new_key in CHARACTERS:
                old_name = CHARACTERS[current_character_key]["name"]
                current_character_key = new_key
                new_name = CHARACTERS[current_character_key]["name"]
                result_content = f"キャラクターを '{old_name}' から '{new_name}' に変更しました。"
                print(f"システム: {result_content}")
            else:
                result_content = f"エラー: 指定されたキャラクターキー '{new_key}' は存在しません。"
        else:
            result_content = f"エラー: 未知の関数 '{func_name}' が呼び出されました。"
            
    except json.JSONDecodeError:
        result_content = "エラー: 関数の引数が正しいJSON形式ではありません。"
    except Exception as e:
        result_content = f"エラー: ツール実行中に例外が発生しました: {e}"

    # 結果をLLMに返すためのメッセージ形式を作成
    return {
        "role": "tool",
        "content": result_content,
        "tool_call_id": call_id,
    }


def run_agent(client: OpenAI):
    """エージェントのメインループ"""
    # 会話履歴の初期化(最初はシステムプロンプトのみ)
    messages = []
    
    print("\n=== AIエージェントが起動しました ===")
    print("Ctrl+C で終了します。\n")

    while True:
        # 1. 現在のキャラクターに基づいてシステムプロンプトを更新
        # NOTE: 会話の途中でキャラが変わるため、毎回最新のペルソナを先頭にセットする
        current_char_info = CHARACTERS[current_character_key]
        system_prompt_content = current_char_info["system_message"]
        
        # messagesの先頭がsystemでない場合は追加、ある場合は更新
        if not messages or messages[0]["role"] != "system":
            messages.insert(0, {"role": "system", "content": system_prompt_content})
        else:
            messages[0]["content"] = system_prompt_content

        try:
            # 2. 音声入力と認識
            audio_path = record_audio_to_file(record_seconds=5.0)
            if not audio_path: continue
            
            user_text = transcribe_with_fallback(audio_path)
            os.remove(audio_path) # 一時ファイル削除

            if not user_text:
                print("音声が認識できませんでした。もう一度お話しください。")
                continue
            
            print(f"\nあなた: {user_text}")
            messages.append({"role": "user", "content": user_text})

            # 3. LLMによる思考(Tool Useループ)
            # ツール使用が要求される限り、ループして処理を続ける
            while True:
                print("AI思考中...")
                start_time = time.time()
                
                # LLMへリクエスト(ツール定義を渡す)
                response = client.chat.completions.create(
                    model=VLLM_MODEL,
                    messages=messages,
                    tools=build_tools(),
                    tool_choice="auto", # LLMにツールを使うか判断させる
                    temperature=0.7,
                )
                
                elapsed = time.time() - start_time
                print(f"(思考時間: {elapsed:.2f}秒)")

                res_msg: ChatCompletionMessage = response.choices[0].message
                messages.append(res_msg) # AIの応答を履歴に追加

                # ツール呼び出し要求があるかチェック
                if res_msg.tool_calls:
                    # 要求された全てのツールを実行
                    for tool_call in res_msg.tool_calls:
                        tool_result_msg = execute_tool_call(tool_call)
                        messages.append(tool_result_msg) # 実行結果を履歴に追加
                    # ツール実行結果を踏まえて、再度LLMに思考させるためループの先頭へ
                    continue
                
                # ツール呼び出しがなければ、通常のテキスト応答とみなす
                ai_text = res_msg.content
                if ai_text:
                    print(f"\n{current_char_info['name']}: {ai_text}")
                    # 4. 音声合成と再生
                    synthesize_and_play(ai_text, current_char_info["id"])
                
                # 思考ループを抜けて次のユーザー入力へ
                break

        except KeyboardInterrupt:
            print("\n終了します。")
            break
        except Exception as e:
            print(f"\n予期せぬエラーが発生しました: {e}", file=sys.stderr)
            # エラー発生時もループを継続してみる


def main():
    parser = argparse.ArgumentParser(description="AI Voice Agent with Tool Use")
    parser.parse_args()

    # OpenAIクライアントの初期化
    client = OpenAI(base_url=VLLM_BASE_URL, api_key=VLLM_API_KEY)

    # 起動前チェック(簡易)
    try:
        print("各サービスへの接続を確認中...")
        client.models.list()
        requests.get(f"{VOICEVOX_BASE_URL}/version", timeout=5)
        print("OK. エージェントを起動します。")
    except Exception as e:
        print(f"エラー: 必要なサービス(vLLM or VOICEVOX)に接続できません。起動しているか確認してください。\n詳細: {e}", file=sys.stderr)
        return 1

    # エージェント実行
    run_agent(client)
    return 0


if __name__ == "__main__":
    sys.exit(main())

実行方法

第1回で構築した vLLM と VOICEVOX のコンテナが起動している状態で、以下のコマンドを実行します。

# 仮想環境を有効化
source venv/bin/activate

# エージェントを起動
python main.py

マイクに向かって「ずんだもん、こんにちは」と話しかけると会話が始まります。そして、「四国めたんに代わって」と話しかけると、声と口調が切り替わる様子を体験できるはずです。

シリーズまとめ:自宅PCに「魂」は宿ったか?

image.png

全3回にわたり、RTX 4070という一般的なゲーミングPCの環境で、完全ローカルな音声対話エージェントを構築してきました。

  1. インフラ編: VRAM 12GBの制約を、FP8量子化LLMと軽量Whisper、そしてCPU版VOICEVOXの組み合わせで突破しました。
  2. 実装編: 各コンポーネントをPythonで繋ぎ、インメモリ処理を駆使して実用的な応答速度を実現しました。
  3. 応用編: Tool Useを実装し、会話の文脈を理解して自律的にキャラクターを切り替えるエージェントへと進化させました。

このプロジェクトを通じて、クラウドに頼らなくても、個人のPCでここまで高度で「身体性」を持ったAIが動かせることを証明できたのではないでしょうか。

今回は「会話」にフォーカスしましたが、この基盤はさらに拡張可能です。RAG(検索拡張生成)を組み込んで専門知識を答えさせたり、ビジョンモデルを組み合わせて「目」を持たせたりと、可能性は無限大です。

ぜひ、あなただけの最強のAIパートナーを育ててみてください。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?