(この記事は LINEDC の Advent Calendar 2025 の記事です)
はじめに
記事の内容
今年、LINE DC のイベント登壇でも扱った「ローカルLLM + LINE Bot」に関する話の記事です。
関連するスライド・アーカイブ動画
以下は、今年9月・10月の LINE DC のイベントで使ったスライドや、その時のアーカイブ動画です。
スライド(9月の分)
アーカイブ動画(9月の分)
スライド(10月の分)
アーカイブ動画(10月の分)
「ローカルLLM + LINE Bot」について
上記の登壇に関する内容
上記 2回の登壇では、どちらも「ローカルLLM + LINE Bot」に関する話をしました。
9月に話した内容
9月に話した内容に関する情報は、以下のとおりです。
LINE Bot の応答を、ローカルLLM で生成するというものでした。
10月に話した内容
10月に話した内容に関する情報は、以下のとおりです。
基本構成は 9月と同じですが、巨大なモデルを扱える PC を使うことで、ローカルLLM の部分で gpt-oss-120b を扱えるようになっています。
LINE Bot の実装に関して
上記で用いた LINE Bot の実装について書いていきます。
前提となる内容
この後に掲載したコードを用いる際の前提について、リストで書いておきます。
- ローカルLLM の処理
- LM Studio を使ってローカルサーバーを立ち上げ
- LM Studio でローカルLLM用のモデルを読み込み
- LM Studio を使ってローカルサーバーを立ち上げ
- トークンなど
- LINE関連のトークンなどは環境変数で設定
コード
コードは、以下のとおりです。コード冒頭でインポートしているパッケージを用いています。
import { Agent } from "@mastra/core/agent";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import * as line from "@line/bot-sdk";
import express from "express";
const { CHANNEL_SECRET, CHANNEL_ACCESS_TOKEN } = process.env;
if (!CHANNEL_SECRET || !CHANNEL_ACCESS_TOKEN) {
console.error(
"[env error] CHANNEL_SECRET / CHANNEL_ACCESS_TOKEN が未設定です"
);
process.exit(1);
}
// ===== LM Studio (OpenAI互換) プロバイダ =====
// LM Studio のローカルサーバーの URL は http://localhost:1234/v1
const lmstudio = createOpenAICompatible({
name: "lmstudio",
baseURL: "http://localhost:1234/v1",
apiKey: "lm-studio", // ダミー・空で OK
});
const agent = new Agent({
name: "LMStudio",
instructions: "日本語で簡潔に答えてください。",
// model: lmstudio.chatModel("gemma-3-270m-it"),
model: lmstudio.chatModel("jan-v1-4b"),
});
const config = { channelSecret: CHANNEL_SECRET };
const client = new line.messagingApi.MessagingApiClient({
channelAccessToken: CHANNEL_ACCESS_TOKEN,
});
const app = express();
// 動作確認用
app.get("/", (_req, res) => {
res.status(200).send("OK");
});
app.post("/bot", line.middleware(config), async (req, res) => {
try {
const results = await Promise.all((req.body.events || []).map(handleEvent));
res.json(results);
} catch (err) {
console.error("[callback error]", err);
res.status(500).end();
}
});
async function handleEvent(event) {
if (event.type !== "message" || event.message?.type !== "text") {
return null;
}
const userText = (event.message.text || "").trim();
const aiText = await generateAnswer(userText);
// 生成結果が空ならオウム返し
const replyText = aiText && aiText.trim() ? aiText.trim() : userText;
const capped = replyText.slice(0, 5000);
return client.replyMessage({
replyToken: event.replyToken,
messages: [{ type: "text", text: capped }],
});
}
async function generateAnswer(input) {
try {
const result = await agent.generateVNext(input);
return result?.text ?? "";
} catch (e) {
console.error("[mastra generate error]", e);
return "";
}
}
app.listen(3000, () => {
console.log("listening on 3000");
});
このコードの内容で、上で動画で掲載していたデモの内容を実現していました。




