Add tool-calling agent loop for LLM CLI
This commit is contained in:
10
README.md
10
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
|
||||
|
||||
@@ -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<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 =
|
||||
"너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 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<string> {
|
||||
const messages: OllamaChatMessage[] = [
|
||||
const messages: Array<OllamaChatMessage | OllamaToolResultMessage> = [
|
||||
{ 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<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`, {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user