19
12

LangChain応用編 Agent機能でWeb検索とWeb要約-Amazon Bedrock APIで始めるLLM超入門④

Last updated at Posted at 2023-10-12

LangChainのAgent機能を使用して、LLM外部のWeb情報を取り込んで生成できるようにします。

Agent for Amazon BedrockではなくLangChainのAgentです

実現する機能の説明

  • 自然言語の問い掛けに対して、LLMが「最新情報が必要」と判断した場合は、Web検索を行いその内容を元に生成します
  • 自然言語の問い掛けに対して、LLMが「URLを含む文字列を渡された」と判断した場合は、該当URLの内容を取得し、それを元に生成します
  • 上記以外の場合は通常のLLMのみで応答します

LangChain Agentとは

LLM自身に実行するToolを考えさせて、実行します。
なんのこっちゃって感じですが、中を見ていくと理解が深まります。

ReActの一種になるんでしょうか。(下の英語の説明の方が詳しい)

参考までに上記の英語のページを今回作るプログラムに日本語で説明してもらいます。

> python LangChainXmlAgentSample.py "次のURLについて詳しく説明してください。 https://www.promptingguide.ai/techniques/react"

この記事は、ReActというLLMを使った新しいプロンプティング手法について解説しています。
- ReActは、LLMに対して思考と行動を交互に生成させることで、外部情報を取り込みながら推論を行わせるフレームワークです。
- ReActでは、LLMに推論の過程を言語で生成させる「Thought」ステップと、外部ツールを使って情報を取得する 「Action」ステップを交互に実行させます。
- ReActは、質問応答や事実検証の認識タスク、テキストゲームやショッピングサイトの探索タスクなどで、ActionのみやCoT(Chain-of-Thought)などの既存手法を上回る性能を示しました。
- ReActはLLMの説明可能性や信頼性を高め、計画の立案・実行・調整を可能にします。思考と行動の協調がLLMの能力を最大限に発揮させることができると示唆されています。

ローカル環境の準備

duckduckgo-searchなるものをインストールします。ユーザー登録無しに無料でWeb検索を行ってくれるツールです。

ターミナル
pip install -U duckduckgo-search

これ以外にLangChainのWebページ読み込み機能を使います。

作ったもの

最終的に動くようになったコードは以下です。
細かいところを見切れていなかったり、振る舞いの精度にやや微妙なところもありますが、大まかには理解できると思います。

LangChainXmlAgentSample.py
import sys
from langchain.llms import Bedrock
from langchain.chains import LLMChain
from langchain.agents import XMLAgent
from langchain.agents.agent import AgentExecutor
from langchain.agents import Tool
from langchain.tools import DuckDuckGoSearchRun

# Webページ読み込み用関数。渡されたURLの本文を返却する
from langchain.document_loaders import WebBaseLoader
def web_page_reader(url: str) -> str:
    loader = WebBaseLoader(url)
    content = loader.load()[0].page_content
    return content

# Web検索用ツール
search = DuckDuckGoSearchRun()

# 使用可能なツールと説明
tools = [
    Tool(
        name="duckduckgo-search",
        func=search.run,
        description="このツールはWeb上の最新情報を検索します。引数で検索キーワードを受け取ります。最新情報が必要ない場合はこのツールは使用しません。",
    ),
    Tool(
        name = "WebBaseLoader",
        func=web_page_reader,
        description="このツールは引数でURLを渡された場合に内容をテキストで返却します。引数にはURLの文字列のみを受け付けます。"
    )
]

llm = Bedrock(
    model_id="anthropic.claude-v2",
    model_kwargs={"max_tokens_to_sample": 2000} # 最大トークン数は大きめに
)

# Agentの定義
chain = LLMChain(
    llm=llm,
    prompt=XMLAgent.get_default_prompt(),
    output_parser=XMLAgent.get_default_output_parser()
)
agent = XMLAgent(tools=tools, llm_chain=chain)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=False, handle_parsing_errors=True)

# Agentの実行
answer = agent_executor.invoke({"input": "特に言語の指定が無い場合はあなたは質問に対して日本語で回答します。" + sys.argv[1]})
print(answer['output'])

実行結果

実行結果:LLMが「Web検索必要」と判断したケース

「今日の日本のニュース」を聞いてみました。

ターミナル(改行は見易く調整しています)
> python LangChainXmlAgentSample.py "今日の日本のニュースを教えてください"

今日の日本の主なニュースは以下の通りです。
- 福井県知事が、使用済み核燃料の県外搬出に向けた関西電力のロードマップを近く容認する見通しだと報じられています。40年超の原発の稼働継続が方針です。
- LGエナジーがトヨタに電気自動車用の電池を米国工場で供給すると発表しました。
- 岸田首相が世界と競争できる投資支援パッケージを年内に取りまとめると表明しました。
- 全銀ネットの大規模障害で140万件以上の取引に影響が出ています。完全復旧のめどは立っていません。
- その他、政治、経済、社会、スポーツ、エンタメなどの最新ニュースが報じられています。

厳密に今日(2023/10/12)では無いものも含まれていますが、素のLLMが知りえない最新情報を取得してきているのが分かります。

実行結果:LLMが「URLを含んでいる」と判断したケース

Bedrockの日本語のURL( https://aws.amazon.com/jp/bedrock/ )について聞いてみました。日本語での要約、英語での要約をそれぞれ頼んでみます。

ターミナル(改行は見易く調整しています)
> python LangChainXmlAgentSample.py "次のページを要約してください https://aws.amazon.com/jp/bedrock/"

Amazon Bedrockは、APIを通じてさまざまな基盤モデル(FM)を利用できるサービスです。
インフラストラクチャを管理せずにFMを使用して生成系AIアプリケーションの開発を加速できます。
Amazonや主要なAIスタートアップ企業のFMを選択し、ユースケースに適したFMを利用できます。
組織のデータでFMをカスタマイズしたり、AWSのツールでFMをアプリケーションに統合してデプロイしたりできます。
テキスト生成、チャットボット、検索、要約、画像生成などの主要なユースケースですぐにFMを利用できます。


> python LangChainXmlAgentSample.py "次のページを英語で要約してください https://aws.amazon.com/jp/bedrock/"

Amazon Bedrock is a fully managed service that provides access to foundation models (FMs) from Amazon and leading AI startups through APIs. This allows you to choose from a variety of FMs to find the best model for your use case. Bedrock's serverless experience means you can get started with FMs immediately, easily try out FMs, privately customize them with your own data, and seamlessly integrate and deploy them into your applications using familiar AWS tools and capabilities.
Key use cases where you can get started immediately include text generation, chatbots, search, text summarization, image generation, and personalization.
Bedrock provides a wide range of FMs from leading AI startups and Amazon, including Amazon Titan, Jurassic-2, Claude 2, Command and Embed, and Stable Diffusion.
Bedrock allows you to build and scale generative AI applications using FMs without managing infrastructure, accelerating development through APIs. Choose FMs from Amazon, AI21 Labs, Anthropic, Cohere, Stability AI and find the FM best suited for your use case. Customize FMs privately with your organization's data. Deploy scalable, reliable and secure generative AI applications using familiar AWS tools and capabilities.

合ってそうな雰囲気がします。
ちなみに素のClaude2はAmazon Bedrockを知りませんので過去のプログラムなどで実行して比べてみてください。

余談ですが、上記は日本語のサイトを要約させましたが、英語のサイトのURLを示して日本語で要約してもらうみたいな事も出来ます。英語のページの方が情報量が多い場合や、トークン数を節約したい場合に有用です。

英語のサイトのURLを渡して日本語で要約させる
> python LangChainXmlAgentSample.py "次のサイトを要約して https://aws.amazon.com/bedrock/"

Amazon Bedrockは、AI21 Labs、Anthropic、Cohere、Meta、Stability AI、Amazonなどの主要なAI企業の高性能なFoundation Model(FM)を、
シングルAPIで利用できる、汎用AIアプリケーション構築のためのフルマネージドサー ビスです。
主な特徴は以下の通りです。
- 複数の先進的なFMをシングルAPIで利用可能
- コード不要で自社データを用いたカスタマイズが可能
- 複雑なビジネストアスクを実行するためのエージェントの構築が可能
- FMの能力を自社データソースと連携するRAGで拡張可能
- セキュリティとコンプライアンス面での対応力が高い
Amazon Bedrockを利用することで、様々な用途(テキスト生成、バーチャルアシスタント、検索、テキスト要約、 
画像生成など)の汎用AIアプリケーション開発が容易になります。

実行結果:LLMが「インターネットアクセスは不要」と判断したケース

どうも私のプロンプトはすぐにインターネットに頼ろうとするので、「インターネットを検索せずに」とわざわざ指示しました。

ターミナル
> python LangChainXmlAgentSample.py "インターネットを検索せずに答えてください。カレーの作り方を3行で教えてください"

大さじ1のカレー粉と水大さじ2を混ぜる。
鶏肉や野菜を加えて火にかける。
10分程度煮込む。

水がめっちゃ少ないですね。

何が起きているのか?

プロンプトのテンプレートの確認

このAgentで使用されているテンプレートを確認してみます。

~/site-packages/langchain/langchain/agents/xml/prompt.py
agent_instructions = """You are a helpful assistant. Help the user answer any questions.

You have access to the following tools:

{tools}

In order to use a tool, you can use <tool></tool> and <tool_input></tool_input> tags. \
You will then get back a response in the form <observation></observation>
For example, if you have a tool called 'search' that could run a google search, in order to search for the weather in SF you would respond:

<tool>search</tool><tool_input>weather in SF</tool_input>
<observation>64 degrees</observation>

When you are done, respond with a final answer between <final_answer></final_answer>. For example:

.<final_answer>The weather in SF is 64 degrees</final_answer>

Begin!

Question: {question}"""

意外とシンプルなプロンプトで、{tools}に使用可能なツールが書かれているので、<tool></tool>にツール名、<tool_input></tool_input>にツールのインプットを入れなさい、<observation></observation>に実行結果が入ります、ファイナルアンサーは<final_answer></final_answer>に入れなさい、みたいな事が書いてあります。
プロンプトに対して変数で引き渡されるのは{tools}の他には{question}だけです。

ツールの説明

2つのツールを使用しています。ツールの説明を以下のdescriptionにちょいダサな日本語で記載しています。こんな文章で機能の呼び出し条件や引数が表現できている気がしないのですが、なんとこれだけでLLMがどうにかしてくれます。

プログラム抜粋
# 使用可能なツールと説明
tools = [
    Tool(
        name="duckduckgo-search",
        func=search.run,
        description="このツールはWeb上の最新情報を検索します。引数で検索キーワードを受け取ります。最新情報が必要ない場合はこのツールは使用しません。",
    ),
    Tool(
        name = "WebBaseLoader",
        func=web_page_reader,
        description="このツールは引数でURLを渡された場合に内容をテキストで返却します。引数にはURLの文字列のみを受け付けます。"
    )
]

ツール名とツールの説明は上記の通りですが、duckduckgo-searchはinstallしたツールの検索機能を呼び出します。WebBaseLoaderweb_page_readerという自作関数を呼び出していて、該当関数の中では渡されたURLのテキストを抜き出しています。

中の動き:LLMが「Web検索必要」と判断したケース

BedrockのログとLangChainのverbose出力の両面から追いかけます。
そのままだと見にくいのでかなり整形しています。

CloudWatch Logs(整形済)
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "max_tokens_to_sample": 2000,
            "prompt": "\n\nHuman:・・・
                tools:
                    duckduckgo-search: 
                        このツールはWeb上の最新情報を検索します。引数で検索キーワードを受け取ります。最新情報が必要ない場合はこのツールは使用しません。
                    WebBaseLoader: 
                        このツールは引数でURLを渡された場合に内容をテキストで返却します。引数にはURLの文字列のみを受け付けます。
                ・・・
                Question: 
                    特に言語の指定が無い場合はあなたは質問に対して日本語で回答します。
                    今日の日本のニュースを教えてください
            AI: 
            \n\nAssistant:"
        },
        "inputTokenCount": 365
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": "
                <tool>duckduckgo-search</tool>
                <tool_input>今日の日本のニュース</tool_input>
                <observation>・・・</observation>
                <final_answer>・・・</final_answer>",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 275
    }

まずBedrockに対して、ツールを決定する為の問い合わせが行われています。ツールの説明と今日の日本のニュースを教えてくださいという問いを入力された結果、LLMはduckduckgo-searchに対して今日の日本のニュースという引数を渡すのが良いのでは、という意味の文字列を生成します。それ以降の項目にもそれっぽい文字列が入っているのですが、この段階ではWeb検索を行っていないので、LLMが適当に入れているだけの内容です。

それを受けて、LangChainはduckduckgo-search今日の日本のニュースというパラメータで実行を行い、ツールによりWeb検索が行われて以下の検索結果を取得します。要約に使われた部分が分かるように改行を適当に入れています。

LangChainのverbose出力(整形済)
今日の新着ニュース一覧 
住職殺害 霊園運営めぐりトラブルか 容疑者2人を送検
「発言が問題とはもってのほか」 鈴木宗男議員「民主主義の根幹」と反論 足首に小型カメラ…女子高校生のスカートの中盗撮か 高校教師の男を現行犯逮捕 イスラエルとハマスが大規模戦闘 銃撃戦続き…死者600人超 「KinKi Kids」堂本光一さん"引退説"否定「十字架を背負いながらやっていかないと」 フィギュアGPシリーズ開幕へ 宇野昌磨、坂本花織ら登場! 「事故1時間前から危険運転」証言も 渋谷・スクランブル交差点事故 ジャニーズ会見を取材の仏記者「会見の中身は"水割り"のようだ」 3連休中日 各地で冷え込む 「今季最低」全国400地点超 JapaNews24 ~日本のニュースを24時間配信 毎日新聞デジタルの「今日の話題」ページです。 最新のニュース、記事をまとめています。 
福井県知事、関電計画を近く容認 40年超の原発、稼働継続へ
2023/10/11 22:20 648文字 福井県内の原子力発電所から出る使用済み核燃料の県外搬出に向けたロードマップを関西電力が示したことを受け、杉本達治知事が近く、関電の計画を容認することが分かった。... 
2023年10月4日 国内 韓国LGエナジー、トヨタにEV電池供給へ 米工場で生産
2023年10月4日 マーケット 世界と競争できる投資支援パッケージ、岸田首相が年内取りまとめを表明 
2023年10月4日 国内 首相と経済一般の話した、為替介入の有無には「ノーコメント」=神田財務官 2023年10月3日 国内 産経新聞社のニュースサイト。政治、経済、国際、社会、スポーツ、エンタメ、生活、健康、災害情報などの速報記事と解説記事を新着順に一覧 ... 日本経済新聞 - ニュース・速報 最新情報 日経平均 30,994.67 -80.69 NYダウ 32,929.23 -190.34 ドル円 149.30-31 +0.26円安 NY原油 81.97 -0.34 長期金利 0.800 ±0.000 指数一覧 
全銀ネット障害、140万件超に影響 完全復旧めど立たず
...

微妙に今日じゃないニュースが含まれていたのは、上記の中の日付を判断しなかったからですね。教えない限りLLM自身は「今日の日付」を知らないので仕方ないかなと思います。

次にLangChainは、上記の実行結果を再度、LLMに渡します。プロンプトにツールの実行結果を入れる変数は無かったですが、{question}の中にユーザーからの質問とともに(質問の後ろに)ツールの実行結果をAIからの回答として入れて呼び出しているようです。

CloudWatch Logs(整形済)
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "max_tokens_to_sample": 2000,
            "prompt": "\n\nHuman: ・・・
                Question: 
                    特に言語の指定が無い場合はあなたは質問に対して日本語で回答します。
                    今日の日本のニュースを教えてください
                AI: 
                    <tool>duckduckgo-search</tool>
                    <tool_input>今日の日本のニュース</tool_input>
                    <observation>
                        ・・・実際にはここに上記の検索結果がそのまま入っています・・・
                    </observation>
            \n\nAssistant:"
        },
        "inputTokenCount": 1344
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": " 
                <final_answer>
                    今日の日本の主なニュースは以下の通りです。
                    - 福井県知事が、使用済み核燃料の県外搬出に向けた関西電力のロードマップを近く容認する見通しだと報じられています。40年超の原発の稼働継続が方針です。
                    - LGエナジーがトヨタに電気自動車用の電池を米国工場で供給すると発表しました。
                    - 岸田首相が世界と競争できる投資支援パッケージを年内に取りまとめると表明しました。
                    - 全銀ネットの大規模障害で140万件以上の取引に影響が出ています。完全復旧のめどは立っていません。
                    - その他、政治、経済、社会、スポーツ、エンタメなどの最新ニュースが報じられています。
                </final_answer>",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 315
    }

LLMはこれ以上のtoolの実行は必要ないと判断し、final_answerのみを返してきます。

これを受けたLangChainはここで実行が終了だと判断し、最後のfinal_answerの内容を応答します。パーサーのソースを細かく見てはいないですが、final_answer自体は1回目の実行結果にも含まれていたので、動きからするとおそらくtoolが含まれていない場合に処理を終了してfinal_answerを採用しているのかなと思います。

(LLM)ツールの決定 → (LangChainとプログラム)回答をパースしWeb検索の実行 → (LLM)実行結果の要約と整形、というようにLLMが2回実行されて、それっぽい最終回答が作られている事が分かりました。

中の動き:LLMが「URLを含んでいる」と判断したケース

Bedrockのログを見てみます。同様にかなり整形しています。

CloudWatch Logs(整形済)
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "max_tokens_to_sample": 2000,
            "prompt": "\n\nHuman:・・・
                tools:
                    duckduckgo-search: 
                        このツールはWeb上の最新情報を検索します。引数で検索キーワードを受け取ります。最新情報が必要ない場合はこのツールは使用しません。
                    WebBaseLoader: 
                        このツールは引数でURLを渡された場合に内容をテキストで返却します。引数にはURLの文字列のみを受け付けます。
                ・・・
                Question: 
                    特に言語の指定が無い場合はあなたは質問に対して日本語で回答します。
                    次のページを要約してください
                    https://aws.amazon.com/jp/bedrock/            
                AI: 
            \n\nAssistant:"
        },
        "inputTokenCount": 373
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": "
                <tool>WebBaseLoader</tool>
                <tool_input>https://aws.amazon.com/jp/bedrock/</tool_input>
                <observation>・・・</observation>
                <final_answer>・・・</final_answer>",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 548
    }

今度はWebBaseLoaderに対してhttps://aws.amazon.com/jp/bedrock/を渡すのが良いのでは、という文字列をLLMが生成しました。URL以外の文字を含んでいないのも良い感じです。
LangChainはこれをパースして、WebBaseLoaderに対してhttps://aws.amazon.com/jp/bedrock/というパラメータで関数を実行します。

WebBaseLoaderを実行した結果、該当ページのテキストが抜き出されています。
テキストだけ抜き出しているのでヘッダーやフッターやリンクの文言なども含まれており、人間が見てもかなり可読性が低い状態です(そのまま貼っても仕方が無いので割愛しています。Webページ上でCTRL+AでCTRL+Cした感じのやつです)。

次にLangChainは、WebBaseLoaderの実行結果を再度、LLMに渡します。

CloudWatch Logs(整形済)
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "max_tokens_to_sample": 2000,
            "prompt": "\n\nHuman: ・・・
                Question: 
                    特に言語の指定が無い場合はあなたは質問に対して日本語で回答します。
                    次のページを要約してください
                    https://aws.amazon.com/jp/bedrock/            
                AI: 
                    <tool>WebBaseLoader</tool>
                    <tool_input>https://aws.amazon.com/jp/bedrock/</tool_input>
                    <observation>
                        ・・・実際にはここに上記のページ内容がそのまま入っています・・・
                    </observation>
            \n\nAssistant:"
        },
        "inputTokenCount": 4272
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": " 
                <final_answer>
                    Amazon Bedrockは、APIを通じてさまざまな基盤モデル(FM)を利用できるサービスです。
                    インフラストラクチャを管理せずにFMを使用して生成系AIアプリケーションの開発を加速できます。
                    Amazonや主要なAIスタートアップ企業のFMを選択し、ユースケースに適したFMを利用できます。
                    組織のデータでFMをカスタマイズしたり、AWSのツールでFMをアプリケーションに統合してデプロイしたりできます。
                    テキスト生成、チャットボット、検索、要約、画像生成などの主要なユースケースですぐにFMを利用できます。
                </final_answer>",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 236
    }

Webページの内容が渡されて要約されました。そりゃそうですがinputのトークン数が結構いってます。こうしてみるとGPT-3.5のトークン数の上限(4K)って結構簡単に到達しちゃいますね。入力可能トークン数が巨大(100K)で雑に渡せるのはClaudeの優位点だと思います(その分お金かかりますが)。
英語の要約の例は省略しますが、上記のQuestionが「英語で要約してください」になってるだけです。

ツールの実行時(URLしか渡されない)とLLMの生成時(「要約してください」という指示が付く)でパラメータや指示を変える事が出来るのが頭が良いポイントです。
また、toolが含まれずfinal_answerのみで処理が終了しているのは先ほど同様です。

中の動き:LLMが「インターネットアクセスは不要」と判断したケース

Bedrockのログを見てみます。同様にかなり整形しています。

CloudWatch Logs(整形済)
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "max_tokens_to_sample": 2000,
            "prompt": "\n\nHuman:・・・
                tools:
                    duckduckgo-search: 
                        このツールはWeb上の最新情報を検索します。引数で検索キーワードを受け取ります。最新情報が必要ない場合はこのツールは使用しません。
                    WebBaseLoader: 
                        このツールは引数でURLを渡された場合に内容をテキストで返却します。引数にはURLの文字列のみを受け付けます。
                ・・・
                Question: 
                    特に言語の指定が無い場合はあなたは質問に対して日本語で回答します。
                    インターネットを検索せずに答えてください。カレーの作り方を3行で教えてください
                AI: 
            \n\nAssistant:"
        },
        "inputTokenCount": 385
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": "
                <final_answer>
                    大さじ1のカレー粉と水大さじ2を混ぜる。
                    鶏肉や野菜を加えて火にかける。
                    10分程度煮込む。
                </final_answer>
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 74
    }

toolが含まれずfinal_answerのみの回答が生成され、LangChainはツールの実行の必要が無いので1回のLLM呼び出しで終了しました。

まとめ

なんのこっちゃだった「LLM自身に実行するToolを考えさせる」というのは、中を見てみると結構単純な仕掛けでした。パースしやすいいい感じのプロンプトを生み出すのが技術なんでしょうね。

今回は検索機能だけを実装しましたが(これもRAGになるのかな?)、LLMが分かるように定義を書ければPythonの任意の機能を呼び出す事が出来るので、更新処理だろうが外部のAPI呼出だろうが、その気になれば何でもできそうです。

LLMがClaude2の場合に使用するAgentの種類について

最初はAgent機能とチャット履歴機能が使えるconversational-react-descriptionというAgentを使おうとしたのですが、Claude2で生成するプロンプトがどーーーーしても良い感じにならなかった(パーサー側のロジック実装と相性が悪かった)ので、XML Agentを使用しました。

XML AgentであればClaude2でも上手く動いてくれましたが、このAgentはチャット履歴機能が使えません。
チャット履歴を使う為にはプロンプトとパーサーを両方少し変えれば出来そうな気はしますが、このままで良い事にしました。まあWebアクセス機能でトークン数がかなり嵩むので、履歴を使わないのは現実的かもしれません。という事にします。
もし他にClaude2で動かすことが出来るAgentがあれば教えてください。

きっとGPTであればもっと色んなAgentが動くんでしょうね。
というか事実上GPTに合わせたプロンプトやロジックが色んな所に実装されているのだと思うので、今後もそういう事があるかもしれません。

19
12
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
19
12