Add tool-calling agent loop for LLM CLI

This commit is contained in:
2026-05-03 00:50:53 +09:00
parent 7e59013fa4
commit 82f98ceb07
2 changed files with 223 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
# realtime_voice_bot # realtime_voice_bot
출력장치로 재생되는 소리를 파일 저장 없이 바로 받아서 `faster-whisper`로 STT 테스트를 하고, 별도로 `Ollama` LLM CLI 테스트를 할 수 있는 최소 프로토타입입니다. 출력장치로 재생되는 소리를 파일 저장 없이 바로 받아서 `faster-whisper`로 STT 테스트를 하고, 별도로 `Ollama` LLM 에이전트 CLI 테스트를 할 수 있는 최소 프로토타입입니다.
현재 문서는 **Windows PC에서 실행하는 기준**으로 적었습니다. 현재 문서는 **Windows PC에서 실행하는 기준**으로 적었습니다.
@@ -11,7 +11,7 @@
- 메모리 버퍼 기반 간단한 저지연 발화 분리 - 메모리 버퍼 기반 간단한 저지연 발화 분리
- 미리 로드한 `faster-whisper` 워커에 PCM 직접 전달 - 미리 로드한 `faster-whisper` 워커에 PCM 직접 전달
- 디스크에 WAV 저장 없이 바로 전사 - 디스크에 WAV 저장 없이 바로 전사
- 로컬 `Ollama` LLM CLI 테스트 - 로컬 `Ollama` LLM 에이전트 CLI 테스트
## 빠른 시작 ## 빠른 시작
@@ -100,6 +100,12 @@ bun run test:llm
3. 콘솔에 직접 문장을 입력하고 답변 확인 3. 콘솔에 직접 문장을 입력하고 답변 확인
4. `/reset` 으로 문맥 초기화, `/exit` 로 종료 4. `/reset` 으로 문맥 초기화, `/exit` 로 종료
현재 `test:llm` 에이전트 도구:
- 현재 시간 조회
- 현재 런타임 설정 조회
- 주요 bun 명령 목록 조회
- 간단한 산술식 계산
## Windows용 .env 예시 ## Windows용 .env 예시
```env ```env

View File

@@ -4,16 +4,98 @@ import type { Logger } from "../logger.js";
interface OllamaChatMessage { interface OllamaChatMessage {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
content: string; content: string;
tool_calls?: OllamaToolCall[];
} }
interface OllamaChatResponse { interface OllamaChatResponse {
message?: { message?: {
content?: string; content?: string;
tool_calls?: OllamaToolCall[];
}; };
} }
interface OllamaToolCall {
type: "function";
function: {
name: string;
arguments: Record<string, unknown>;
};
}
interface OllamaToolDefinition {
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
required?: string[];
properties: Record<string, unknown>;
};
};
}
interface OllamaToolResultMessage {
role: "tool";
tool_name: string;
content: string;
}
const SYSTEM_PROMPT = 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 { export class OllamaLlmService {
private history: OllamaChatMessage[] = []; private history: OllamaChatMessage[] = [];
@@ -30,17 +112,17 @@ export class OllamaLlmService {
{ role: "user", content: "준비 상태 확인입니다. 한 단어로만 답하세요." }, { 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<string> { async generateReply(userText: string): Promise<string> {
const messages: OllamaChatMessage[] = [ const messages: Array<OllamaChatMessage | OllamaToolResultMessage> = [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: SYSTEM_PROMPT },
...this.history, ...this.history,
{ role: "user", content: userText }, { 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: "user", content: userText });
this.history.push({ role: "assistant", content: reply }); this.history.push({ role: "assistant", content: reply });
@@ -61,7 +143,42 @@ export class OllamaLlmService {
this.history = this.history.slice(-maxMessages); this.history = this.history.slice(-maxMessages);
} }
private async chat(messages: OllamaChatMessage[]): Promise<string> { private async runAgentLoop(messages: Array<OllamaChatMessage | OllamaToolResultMessage>): Promise<string> {
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<OllamaChatMessage | OllamaToolResultMessage>,
): Promise<{ content: string; toolCalls: OllamaToolCall[] }> {
const response = await fetch(`${this.config.OLLAMA_BASE_URL}/api/chat`, { const response = await fetch(`${this.config.OLLAMA_BASE_URL}/api/chat`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -70,6 +187,7 @@ export class OllamaLlmService {
body: JSON.stringify({ body: JSON.stringify({
model: this.config.OLLAMA_MODEL, model: this.config.OLLAMA_MODEL,
messages, messages,
tools: TOOL_DEFINITIONS,
stream: false, stream: false,
think: false, think: false,
keep_alive: this.config.OLLAMA_KEEP_ALIVE, keep_alive: this.config.OLLAMA_KEEP_ALIVE,
@@ -82,10 +200,98 @@ export class OllamaLlmService {
} }
const payload = (await response.json()) as OllamaChatResponse; const payload = (await response.json()) as OllamaChatResponse;
const content = payload.message?.content?.trim(); const content = payload.message?.content?.trim() ?? "";
if (!content) { const toolCalls = payload.message?.tool_calls ?? [];
throw new Error("Ollama 응답에 message.content 가 없습니다.");
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<string, unknown> {
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<string, unknown>, 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;
} }
} }