From 82f98ceb07a7d1847842ed5ab2706be734eb9056 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 3 May 2026 00:50:53 +0900 Subject: [PATCH] Add tool-calling agent loop for LLM CLI --- README.md | 10 +- src/services/ollama-llm.ts | 224 +++++++++++++++++++++++++++++++++++-- 2 files changed, 223 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3ba60fa..3b83ca2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # realtime_voice_bot -출력장치로 재생되는 소리를 파일 저장 없이 바로 받아서 `faster-whisper`로 STT 테스트를 하고, 별도로 `Ollama` LLM CLI 테스트를 할 수 있는 최소 프로토타입입니다. +출력장치로 재생되는 소리를 파일 저장 없이 바로 받아서 `faster-whisper`로 STT 테스트를 하고, 별도로 `Ollama` LLM 에이전트 CLI 테스트를 할 수 있는 최소 프로토타입입니다. 현재 문서는 **Windows PC에서 실행하는 기준**으로 적었습니다. @@ -11,7 +11,7 @@ - 메모리 버퍼 기반 간단한 저지연 발화 분리 - 미리 로드한 `faster-whisper` 워커에 PCM 직접 전달 - 디스크에 WAV 저장 없이 바로 전사 -- 로컬 `Ollama` LLM CLI 테스트 +- 로컬 `Ollama` LLM 에이전트 CLI 테스트 ## 빠른 시작 @@ -100,6 +100,12 @@ bun run test:llm 3. 콘솔에 직접 문장을 입력하고 답변 확인 4. `/reset` 으로 문맥 초기화, `/exit` 로 종료 +현재 `test:llm` 에이전트 도구: +- 현재 시간 조회 +- 현재 런타임 설정 조회 +- 주요 bun 명령 목록 조회 +- 간단한 산술식 계산 + ## Windows용 .env 예시 ```env diff --git a/src/services/ollama-llm.ts b/src/services/ollama-llm.ts index a2ac2ee..51293ed 100644 --- a/src/services/ollama-llm.ts +++ b/src/services/ollama-llm.ts @@ -4,16 +4,98 @@ import type { Logger } from "../logger.js"; interface OllamaChatMessage { role: "system" | "user" | "assistant"; content: string; + tool_calls?: OllamaToolCall[]; } interface OllamaChatResponse { message?: { content?: string; + tool_calls?: OllamaToolCall[]; }; } +interface OllamaToolCall { + type: "function"; + function: { + name: string; + arguments: Record; + }; +} + +interface OllamaToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: { + type: "object"; + required?: string[]; + properties: Record; + }; + }; +} + +interface OllamaToolResultMessage { + role: "tool"; + tool_name: string; + content: string; +} + const SYSTEM_PROMPT = - "너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 1~3문장으로 답해라."; + "너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 1~3문장으로 답해라. 정확한 시간, 설정 확인, 계산이 필요하면 도구를 우선 사용해라. 너는 도구 호출 루프 안에 있으며 필요하면 여러 번 도구를 호출할 수 있다."; + +const TOOL_DEFINITIONS: OllamaToolDefinition[] = [ + { + type: "function", + function: { + name: "get_current_time", + description: "현재 시스템 시간을 Asia/Seoul 기준 ISO 문자열과 사람이 읽기 쉬운 형식으로 반환한다.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "get_runtime_settings", + description: "현재 로컬 LLM 및 STT 실행 설정의 핵심 값만 반환한다.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "list_project_commands", + description: "현재 프로젝트에서 사용 가능한 주요 bun 스크립트 명령 목록을 반환한다.", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + { + type: "function", + function: { + name: "evaluate_math", + description: "간단한 산술식을 정확히 계산한다. 숫자, 공백, 소수점, 괄호, + - * / % 만 허용한다.", + parameters: { + type: "object", + required: ["expression"], + properties: { + expression: { + type: "string", + description: "예: (11434+12341)*412", + }, + }, + }, + }, + }, +]; export class OllamaLlmService { private history: OllamaChatMessage[] = []; @@ -30,17 +112,17 @@ export class OllamaLlmService { { role: "user", content: "준비 상태 확인입니다. 한 단어로만 답하세요." }, ], ); - this.logger.info("LLM warmup finished", { model: this.config.OLLAMA_MODEL, reply }); + this.logger.info("LLM warmup finished", { model: this.config.OLLAMA_MODEL, reply: reply.content }); } async generateReply(userText: string): Promise { - const messages: OllamaChatMessage[] = [ + const messages: Array = [ { role: "system", content: SYSTEM_PROMPT }, ...this.history, { role: "user", content: userText }, ]; - const reply = await this.chat(messages); + const reply = await this.runAgentLoop(messages); this.history.push({ role: "user", content: userText }); this.history.push({ role: "assistant", content: reply }); @@ -61,7 +143,42 @@ export class OllamaLlmService { this.history = this.history.slice(-maxMessages); } - private async chat(messages: OllamaChatMessage[]): Promise { + private async runAgentLoop(messages: Array): Promise { + for (let step = 0; step < 6; step += 1) { + const response = await this.chat(messages); + const toolCalls = response.toolCalls ?? []; + + messages.push({ + role: "assistant", + content: response.content, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); + + if (toolCalls.length === 0) { + return response.content; + } + + for (const call of toolCalls) { + const result = this.executeTool(call); + this.logger.info("LLM tool call", { + name: call.function.name, + arguments: call.function.arguments, + result, + }); + messages.push({ + role: "tool", + tool_name: call.function.name, + content: result, + }); + } + } + + throw new Error("도구 호출 루프가 제한 횟수를 넘었습니다."); + } + + private async chat( + messages: Array, + ): Promise<{ content: string; toolCalls: OllamaToolCall[] }> { const response = await fetch(`${this.config.OLLAMA_BASE_URL}/api/chat`, { method: "POST", headers: { @@ -70,6 +187,7 @@ export class OllamaLlmService { body: JSON.stringify({ model: this.config.OLLAMA_MODEL, messages, + tools: TOOL_DEFINITIONS, stream: false, think: false, keep_alive: this.config.OLLAMA_KEEP_ALIVE, @@ -82,10 +200,98 @@ export class OllamaLlmService { } const payload = (await response.json()) as OllamaChatResponse; - const content = payload.message?.content?.trim(); - if (!content) { - throw new Error("Ollama 응답에 message.content 가 없습니다."); + const content = payload.message?.content?.trim() ?? ""; + const toolCalls = payload.message?.tool_calls ?? []; + + if (!content && toolCalls.length === 0) { + throw new Error("Ollama 응답에 message.content 와 tool_calls 가 모두 없습니다."); } - return content; + + return { + content, + toolCalls, + }; + } + + private executeTool(call: OllamaToolCall): string { + switch (call.function.name) { + case "get_current_time": + return JSON.stringify(this.getCurrentTime()); + case "get_runtime_settings": + return JSON.stringify(this.getRuntimeSettings()); + case "list_project_commands": + return JSON.stringify(this.listProjectCommands()); + case "evaluate_math": + return JSON.stringify({ + expression: this.getStringArg(call.function.arguments, "expression"), + result: this.evaluateMath(this.getStringArg(call.function.arguments, "expression")), + }); + default: + return JSON.stringify({ + error: `unknown tool: ${call.function.name}`, + }); + } + } + + private getCurrentTime(): { timezone: string; iso: string; local: string } { + const now = new Date(); + return { + timezone: "Asia/Seoul", + iso: now.toISOString(), + local: new Intl.DateTimeFormat("ko-KR", { + timeZone: "Asia/Seoul", + dateStyle: "full", + timeStyle: "long", + }).format(now), + }; + } + + private getRuntimeSettings(): Record { + return { + ollama_base_url: this.config.OLLAMA_BASE_URL, + ollama_model: this.config.OLLAMA_MODEL, + ollama_keep_alive: this.config.OLLAMA_KEEP_ALIVE, + max_conversation_turns: this.config.MAX_CONVERSATION_TURNS, + whisper_model: this.config.WHISPER_MODEL, + whisper_language: this.config.WHISPER_LANGUAGE, + whisper_device: this.config.WHISPER_DEVICE, + whisper_compute_type: this.config.WHISPER_COMPUTE_TYPE, + whisper_beam_size: this.config.WHISPER_BEAM_SIZE, + audio_source: this.config.AUDIO_SOURCE ?? null, + debug: this.config.DEBUG, + }; + } + + private listProjectCommands(): { commands: string[] } { + return { + commands: [ + "bun run setup", + "bun run setup:stt", + "bun run setup:llm", + "bun run devices", + "bun run test:stt", + "bun run test:llm", + ], + }; + } + + private getStringArg(args: Record, name: string): string { + const value = args[name]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`도구 인자 ${name} 가 비어 있습니다.`); + } + return value.trim(); + } + + private evaluateMath(expression: string): number { + if (!/^[0-9+\-*/%().\s]+$/.test(expression)) { + throw new Error("허용되지 않은 문자가 포함된 산술식입니다."); + } + + const result = Function(`"use strict"; return (${expression});`)(); + if (typeof result !== "number" || !Number.isFinite(result)) { + throw new Error("산술식 계산 결과가 유효하지 않습니다."); + } + return result; } }