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
|
# 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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user