>100 Views
June 05, 26
スライド概要
toranoana.deno#25の発表資料です。
https://yumenosora.connpass.com/event/392721/
虎の穴ラボ株式会社は、主にとらのあな関連サービスのシステム開発を専門に担う、エンジニアの会社です。
Scratch AITuber Project ~ Denoでゼロから作る、テキストAIエージェントの今 ~ toranoana.deno#25 / 2026-06 (v2) 発表者: 藤原佳顕 「フレームワークに頼らず、決定論的なコードでLLMを御す」
自己紹介 藤原 佳顕(ふじわら よしあき) — yoshiaki fujiwara / 8年目 担当: 新規事業(Fantia・Creatia)/ アーキテクトチーム(CSIRTも)/ AI推進チーム 大学: 情報系(数学より) 前職: 独立系ソフトウェア会社 — 主にGISとWeb、ライブラリ開発 言語/FW: TypeScript・Ruby on Rails・C#・C++・React・Vue・Angular 入社理由 自分がスキルアップできそうな場所に行きたい オタク系の話ができるところに行きたい 好きなモノ: シューティング / 格闘ゲーム / アトラスのゲーム・SF小説・プログラミン グ 2
本日の流れ 1. Why — なぜスクラッチで、なぜDenoなのか 2. How — アンチ・ブラックボックス と Plan-Act-Verify 3. Deno Inside — Permission / Deno.Command / Sandbox / Deno KV 4. Memory & Persona — 3層メモリ / 自己検証 5. Now & Next — 直近の進捗とこれから ゴール: YouTube配信まで自走する AITuber「ひなた」。 今日はその 土台のエージェント基盤 と Denoの効きどころ の話。 3
なぜ自作するのか? LangChain・LangGraph・Mastra…フレームワークは溢れている。 それでも スクラッチ を選んだ理由は4つ。 デバッグ可能性 — 抽象化が厚いと、本番で何が起きたか追えない 完全な制御権 — RAG・ローカルLLM・プロンプト形式まで自分で握る 学習価値 — 「12-Factor Agents」を体に染み込ませる 長期ビジョン — 配信する以上、挙動の予測可能性が必須 LLMは "知的判断" のコンポーネント。 制御フローは普通のTypeScriptで書く。 4
なぜ Deno なのか? 「LLMが暴走しても安全」を、ランタイムが標準で 担保してくれる。 Permissionモデル — --allow-read/write/run/net を粒度で付与。デフォル ト deny TypeScriptがそのまま動く — ビルド設定ゼロ、 any 禁止と相性◎ Deno KV が標準同梱 — 状態も長期記憶も外部DBなしで永続化 jsr: と npm: を混在 — OpenAI SDKは npm: 、サンドボックスは jsr: Web標準API — crypto.randomUUID() / TextDecoder / await using deno test 同梱 — テストランナーもフォーマッタも追加依存なし エージェントのセキュリティ境界を アプリ層 + Denoランタイム の二重で。 5
設計の柱:アンチ・ブラックボックス エージェントを「ブラックボックス」にしないための4原則。 1. 複雑なフレームワークの回避 — 制御フローは決定論的なコードで 2. プロンプトの所有 — プロンプトは第一級のソースコード 3. 透明性と予測可能性 — LLM出力は盲目実行せず、必ずアプリ層でバリデーション 4. 小さく特化した役割 — Planner / Executor / Verifier に分解 外側(アプリ)でループ・リトライ・形式チェックを握り、 内側(LLM)には 思考と判断だけ を委譲する。 6
制御ループ:Plan → Act → Verify ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Planner │ → │ Executor │ → │ Verifier │ └─────────┘ └──────────┘ └──────────┘ 立案者 実行者 検証者 ▲ │ └────── 失敗時は再計画 ────┘ Planner: ユーザー意図をサブタスクに分解 ( planner.ts ) Executor: ツールを呼び実行、特権操作は承認を強制 ( executor.ts ) Verifier: 失敗 / ペルソナ逸脱を検知し 再計画・再生成 ( core.ts ) runAgent() が最大50イテレーションでこの3役を回す。 7
ツール群とセキュリティ LLMが呼べるツールは OpenAI Function Calling 互換 で定義。 ツール read_file / write_file code_interpreter exec_command / fetch_url request_human_input / request_approval web_search 役割 サンドボックス内のファイル読み書き(特権) Deno Sandboxで安全にTS/JSを実行 ホワイトリスト制のシェル実行(特権) DuckDuckGo検索とURL本文抽出( Phase 2) Human-in-the-loop / 事前承認 二重の境界: アプリ層の承認フロー + Deno Permission モデル 特権ツールは実行前に LLM自身が request_approval を呼ぶ 8
LLM呼び出しの一元管理(用途駆動)
すべてのLLM呼び出しを createChatCompletion 一本に集約。用途でパラメータを決
める。
// src/agent/config.ts(抜粋)
export type LLMUseCase = "chat" | "planning" | "summary" | "verification";
export async function createChatCompletion(params: LLMRequestParams) {
let temperature = params.temperature;
if (temperature === undefined) {
// 計画・要約・検証は決定論寄り(0)、対話だけ creative(0.7)
temperature = (params.useCase === "chat") ? 0.7 : 0;
}
return await _openai.chat.completions.create({ ...params, model: params.model ?? DEFAULT_MODEL, temperature, stream: false });
}
/ planner.ts / memory.ts の呼び出しを 全部この関数経由 に統一
モデル名 ( gemma4:e2b ) もハードコード排除 → 挙動の予測可能性が上がる
core.ts
9
Deno Inside ①:Permission モデル
権限は 起動時のフラグ で粒度ごとに渡す。付け忘れれば、そもそも動かない。
// deno.json — tasks
{
"tasks": {
// 必要な権限だけを明示的に付与(デフォルトは全部 deny)
"dev": "deno run --allow-env --allow-net --allow-read \
--allow-run --allow-write --unstable-kv main.ts --no-planning",
"test": "deno test --allow-env --allow-net --allow-read \
--allow-write --allow-run --unstable-kv tests/"
}
}
LLMがどんなコードを吐こうと、権限のない操作はランタイムが拒否
将来は --allow-read=./workspace のように パス単位 まで絞れる
「アプリ側のガードを抜けても、最後にOSの手前でDenoが止める」
10
Deno Inside ②: Deno.Command × ホワイトリスト
exec_command
は 許可リスト と 標準API の二段構え。
// src/tools/shell_executor.ts(抜粋)
export async function exec_command(command: string, args: string[] = [], cwd?: string) {
// ① アプリ層:ホワイトリストにないコマンドは即拒否
if (!ALLOWED_COMMANDS.includes(command)) {
return `エラー: コマンド '${command}' は実行を許可されていません。`;
}
// ② 実行:シェルを介さず Deno.Command で直接プロセス起動(注入リスク低)
const cmd = new Deno.Command(command, {
args, cwd: cwd ?? Deno.cwd(), stdout: "piped", stderr: "piped",
});
const { code, stdout, stderr } = await cmd.output();
return `終了コード: ${code}\nSTDOUT:\n${new TextDecoder().decode(stdout)}`;
}
sh -c "..."
で文字列を渡さない=シェルインジェクションの面を減らす。
11
Deno Inside ③: @deno/sandbox で使い捨て実行
code_interpreter
は 隔離環境 でTSを実行し、終わったら自動破棄。
// src/tools/code_interpreter.ts(抜粋)
import { Sandbox } from "@deno/sandbox"; // jsr:@deno/sandbox
export async function code_interpreter(code: string): Promise<string> {
// await using → スコープを抜けると sandbox を自動 dispose(明示的リソース管理)
await using sandbox = await Sandbox.create();
await sandbox.fs.writeTextFile("main.ts", code);
await sandbox.sh`deno run main.ts > output.txt 2> error.txt`;
const stdout = await sandbox.fs.readTextFile("output.txt").catch(() => "");
return stdout || "出力はありませんでした。";
}
ホスト環境から隔離 → 生成コードが暴れてもホストに影響しない
await using は TC39 Explicit Resource Management(Denoが先行サポート)
12
Deno Inside ④:Deno KV で状態を永続化
外部DBなし。 Deno.openKv() だけで セッションを止めて再開 できる。
// src/state/agent_state.ts(抜粋)
export async function saveState(state: AgentState): Promise<void> {
const kv = await Deno.openKv();
try {
// キーは配列。["agent_sessions", sessionId] で名前空間を切る
await kv.set(["agent_sessions", state.sessionId], { ...state,
createdAt: state.createdAt.toISOString(),
updatedAt: state.updatedAt.toISOString(),
});
} finally {
kv.close();
}
}
1ターンごとに書き戻し → --session <ID> で計画・承認待ちごと復元
保存先は StateRepository 相当で抽象化(KV / RDB 差し替え可)
13
Deno Inside ⑤:同じKVが「長期記憶のベクトルストア」に
状態管理と まったく同じ Deno KV を、記憶の保存・検索に再利用。
// src/agent/memory.ts(抜粋)— KvMemoryRepository
async add(text: string, embedding: number[], metadata?: Record<string, unknown>) {
const id = crypto.randomUUID();
// Web標準のUUID生成
await this.kv.set([...this.prefix, id], { id, text, embedding, metadata });
}
async search(queryEmbedding: number[], limit: number) {
const all = await this.getAll();
// kv.list({ prefix }) で全件取得
return all
.map(e => ({ e, score: cosineSimilarity(queryEmbedding, e.embedding) }))
.sort((a, b) => b.score - a.score)
// コサイン類似度で並べ替え
.slice(0, limit).map(s => s.e);
}
Embeddingは Ollama の nomic-embed-text を OpenAI SDK 経由で生成
専用ベクトルDBを足さず、まず標準機能で動かす(後で差し替え可)
14
Deno Inside ⑥:類似度は「数式そのまま」を自前実装
ライブラリに頼らず、コサイン類似度 A・B / (|A|·|B|) を素のTSで。
// src/agent/memory.ts(抜粋)— 0.0〜1.0 を返す(1.0なら完全一致)
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) throw new Error("Vectors must have the same length");
let dotProduct = 0; // 内積 (A・B)
let normA = 0;
// |A|^2
let normB = 0;
// |B|^2
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i]; // 各要素の積の合計
normA += a[i] * a[i];
// 各要素の二乗の合計
normB += b[i] * b[i];
}
const normProduct = Math.sqrt(normA) * Math.sqrt(normB); // |A|·|B|
if (normProduct === 0) return 0;
// ゼロベクトルは類似度0扱い
return dotProduct / normProduct;
// 内積をノルムの積で割る
}
一旦自分で作ることで何が起きているか、何が必要か把握しつつ実装
15
Deno Inside ⑦:セッション終了→「事実」を抽出して記憶
Phase 2.2: 会話そのものではなく、要約した 事実 をベクトル化して保存。
// src/agent/memory.ts(抜粋)— summarizeAndStore
const response = await createChatCompletion({
useCase: "summary", // temperature=0 で決定論的に抽出
messages: [{ role: "system", content: "あなたは記憶整理のアシスタントです。" },
{ role: "user", content: prompt }], // 「好み・約束・出来事」を箇条書きで
});
const facts = response.choices[0].message.content.split("\n")
.map(l => l.replace(/^- /, "").trim()).filter(Boolean);
for (const fact of facts) {
// 事実ごとに…
const embedding = await createEmbedding(fact); // nomic-embed-text でベクトル化
await repository.add(fact, embedding, { type: "episodic" }); // KV へ永続化
}
や ワンショット実行の 終了フックで自動起動( main.ts の
handleExit )
「〜と言った」ではなく 「〜が好き」へ変換 → 再利用しやすい知識に
/quit
16
3層メモリアーキテクチャ 記憶を役割で分離し、過去への過剰適合(Sycophancy)を防ぐ。 層 短期記 憶 中核記 憶 長期記 憶 役割 実装状況 直近の会話バッファ js-tiktoken で計数 + スライディングウィンドウ 名前・感情値など絶対忘れない Core ProfileをSysPromptに注入 情報 終了時に要約→Embedding→KV保存 / 検索注入 過去セッションの要約 (recall) 短期: MAX_CONTEXT_TOKENS (4096) 等を定数管理し、古い履歴を自動切り詰め 長期: 書き込みは完成(前頁 summarizeAndStore )。残るは会話中に search() した記憶をプロンプトへ注入する配線(RAG recall) 17
ペルソナ動的注入と自己検証
キャラ設定はハードコードせず、 persona.json を起動時に注入。
{
"name": "ひなた (Hinata)",
"personality": "明るく元気で好奇心旺盛。マスターと呼ぶ。",
"rules": ["広島出身で広島弁で喋る", "明るく前向きなトーンで返答", ...]
}
出力は { thought, emotion, response } のJSON
自己検証 (Self-Correction): verifyPersona がキャラ一貫性をLLM自身でチェ
ックし、逸脱時は再生成
ユーザーの明示的な口調指定は尊重しつつ、キャラとして誠実に判断
18
Now:この数週間で進んだこと Web検索・情報収集 — web_search (DuckDuckGo) + fetch_url を実装 トークン管理の精緻化 — tool_calls含む計数の精度向上、定数管理化 長期記憶の書き込み — summarizeAndStore で終了時に事実を要約→KV保存 (テスト済み) LLM呼び出しの抽象化 — 用途駆動の createChatCompletion に一元化、モデ ル名も定数化 ペルソナ自己検証 — 機械的追従ではなく自律的に判断する振る舞いへ 初回起動バグ修正 — sessionId の取り回しを直し、初回でも記憶が保存され るように 「基盤づくり (Phase 1)」から 「記憶と外部接続 (Phase 2)」 のフェーズへ。 19
Next & まとめ Next Steps(Phase 2 → 3) [ ] 長期記憶の recall — 会話中に search() した記憶をプロンプトへ注入(RAGト リガーの軽量判定) [ ] Slack / Discord アダプター → SNSデビュー [ ] マルチモーダル(TTS / 表情制御)→ AITuberデビュー まとめ 今あるもの: Plan-Act-Verify / 3層メモリ / 承認フロー / 状態の中断再開 / Web検索 / 長期記憶の書き込み これから: 長期記憶のrecall → SNS連携 → マルチモーダル → 配信デビュー 20