viruagent 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +26 -0
- package/README.md +23 -30
- package/package.json +1 -1
- package/docs/agent-pattern-guide.md +0 -574
- package/docs/hybrid-db-agent-guide.md +0 -484
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
id-token: write
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '20'
|
|
20
|
+
registry-url: 'https://registry.npmjs.org'
|
|
21
|
+
|
|
22
|
+
- run: npm ci
|
|
23
|
+
|
|
24
|
+
- run: npm publish --provenance --access public
|
|
25
|
+
env:
|
|
26
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
<img width="
|
|
2
|
-
|
|
1
|
+
<img width="520" height="109" alt="SCR-20260212-opqh" src="https://github.com/user-attachments/assets/b6c79a28-5619-49b9-b87c-4a3f725a193a" />
|
|
3
2
|
|
|
4
3
|
# ViruAgent
|
|
5
4
|
|
|
6
|
-
> 터미널에서 티스토리 블로그 글을
|
|
5
|
+
> 터미널에서 티스토리 블로그 글을 발행까지 한 번에!
|
|
6
|
+
> (CLI 기반 AI Agent 도구)
|
|
7
|
+
>
|
|
8
|
+
> 태균맨(tae\*virus)의 별명
|
|
9
|
+
> **Viru** + **Agent** = **ViruAgent** (바이루에이전트)
|
|
7
10
|
|
|
8
11
|
티스토리 API를 역분석해서 OpenAI와 엮었습니다.
|
|
9
12
|
글감 잡기 → 초안 생성 → 수정 → 발행까지 터미널 안에서 끝납니다.
|
|
@@ -53,8 +56,6 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
|
|
|
53
56
|
|
|
54
57
|
---
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
59
|
| 명령어 | 설명 |
|
|
59
60
|
| --------------- | -------------------------------------------- |
|
|
60
61
|
| `/write <주제>` | AI가 블로그 글 초안 생성 |
|
|
@@ -64,7 +65,7 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
|
|
|
64
65
|
| `/set` | 카테고리, 공개 설정, 모델, 말투 변경 |
|
|
65
66
|
| `/list` | 최근 발행 글 목록 |
|
|
66
67
|
| `/login` | 티스토리 세션 갱신 |
|
|
67
|
-
| `/logout`
|
|
68
|
+
| `/logout` | 티스토리 세션 삭제 |
|
|
68
69
|
|
|
69
70
|
---
|
|
70
71
|
|
|
@@ -74,25 +75,20 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
|
|
|
74
75
|
|
|
75
76
|
```
|
|
76
77
|
viruagent> AI 에이전트 주제로 글 써서 발행해줘
|
|
78
|
+
```
|
|
79
|
+

|
|
77
80
|
|
|
78
|
-
⠹
|
|
79
|
-
✓ 글 생성 완료: "AI 에이전트 완벽 가이드: 챗봇과의 7가지 차이"
|
|
80
|
-
⠧
|
|
81
|
-
✓ 발행 완료! https://tkman.tistory.com/60
|
|
82
81
|
|
|
83
|
-
AI
|
|
84
|
-
블로그 글이 성공적으로 발행되었습니다!
|
|
85
|
-
```
|
|
86
82
|
|
|
87
83
|
### 자연어 요청 예시
|
|
88
84
|
|
|
89
|
-
| 입력
|
|
90
|
-
|
|
91
|
-
| "AI 트렌드로 글 써줘"
|
|
92
|
-
| "서론을 더 흥미롭게 수정해줘" | `edit_post` 호출 → 초안 수정
|
|
93
|
-
| "발행해줘"
|
|
94
|
-
| "비공개로 발행해줘"
|
|
95
|
-
| "글 써서 바로 발행해줘"
|
|
85
|
+
| 입력 | AI가 하는 일 |
|
|
86
|
+
| ----------------------------- | -------------------------------- |
|
|
87
|
+
| "AI 트렌드로 글 써줘" | `generate_post` 호출 → 초안 생성 |
|
|
88
|
+
| "서론을 더 흥미롭게 수정해줘" | `edit_post` 호출 → 초안 수정 |
|
|
89
|
+
| "발행해줘" | `publish_post` 호출 → 즉시 발행 |
|
|
90
|
+
| "비공개로 발행해줘" | 공개설정 변경 후 발행 |
|
|
91
|
+
| "글 써서 바로 발행해줘" | 생성 → 발행 연속 실행 |
|
|
96
92
|
|
|
97
93
|
### 동작 원리
|
|
98
94
|
|
|
@@ -116,11 +112,8 @@ AI
|
|
|
116
112
|
|
|
117
113
|
<img width="1054" height="896" alt="image" src="https://github.com/user-attachments/assets/9e30e0ed-32f9-41a4-b39f-e4e6863d5d2d" />
|
|
118
114
|
|
|
119
|
-
|
|
120
|
-
|
|
121
115
|
카테고리, 공개 여부, 발행 방식을 물어본 뒤 AI가 글을 생성해서 티스토리에 발행합니다.
|
|
122
116
|
|
|
123
|
-
|
|
124
117
|
### 직접 CLI로 실행
|
|
125
118
|
|
|
126
119
|
```bash
|
|
@@ -164,13 +157,13 @@ config/
|
|
|
164
157
|
|
|
165
158
|
### 글 유형 (AI가 주제에 맞게 자율 선택)
|
|
166
159
|
|
|
167
|
-
| 유형
|
|
168
|
-
|
|
169
|
-
| 튜토리얼
|
|
170
|
-
| 비교/리뷰
|
|
171
|
-
| 리스트
|
|
172
|
-
| 정보 가이드 | 문제 → 원인 → 해결 → FAQ
|
|
173
|
-
| 인사이트
|
|
160
|
+
| 유형 | 구조 |
|
|
161
|
+
| ----------- | -------------------------------------- |
|
|
162
|
+
| 튜토리얼 | 준비물 → 단계 → 결과 확인 → 트러블슈팅 |
|
|
163
|
+
| 비교/리뷰 | 비교 테이블 + 장단점 → 추천 |
|
|
164
|
+
| 리스트 | 번호 소제목 + 항목별 팁 |
|
|
165
|
+
| 정보 가이드 | 문제 → 원인 → 해결 → FAQ |
|
|
166
|
+
| 인사이트 | 화두 → 근거 → 시사점 → 액션 |
|
|
174
167
|
|
|
175
168
|
### prompt-config.json 설정
|
|
176
169
|
|
package/package.json
CHANGED
|
@@ -1,574 +0,0 @@
|
|
|
1
|
-
# AI 에이전트 패턴 & Function Calling 학습 가이드
|
|
2
|
-
|
|
3
|
-
## 목차
|
|
4
|
-
|
|
5
|
-
1. [에이전트란 무엇인가](#1-에이전트란-무엇인가)
|
|
6
|
-
2. [기존 방식 vs 에이전트 방식](#2-기존-방식-vs-에이전트-방식)
|
|
7
|
-
3. [Function Calling 핵심 원리](#3-function-calling-핵심-원리)
|
|
8
|
-
4. [에이전트 루프 구조](#4-에이전트-루프-구조)
|
|
9
|
-
5. [Tool 정의 작성법](#5-tool-정의-작성법)
|
|
10
|
-
6. [Tool Executor 패턴](#6-tool-executor-패턴)
|
|
11
|
-
7. [실전 흐름 추적](#7-실전-흐름-추적)
|
|
12
|
-
8. [시스템 프롬프트의 역할](#8-시스템-프롬프트의-역할)
|
|
13
|
-
9. [토큰 비용 구조](#9-토큰-비용-구조)
|
|
14
|
-
10. [안전장치 설계](#10-안전장치-설계)
|
|
15
|
-
11. [확장 패턴](#11-확장-패턴)
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## 1. 에이전트란 무엇인가
|
|
20
|
-
|
|
21
|
-
### 일반 챗봇
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
사용자 → 질문 → AI → 텍스트 답변
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
AI는 **말만** 할 수 있다. 실제 행동(파일 저장, API 호출, DB 쿼리)은 불가능.
|
|
28
|
-
|
|
29
|
-
### 에이전트
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
사용자 → 요청 → AI → "이 도구를 이렇게 써야겠다" → 코드가 실행 → 결과를 AI에게 → AI가 다음 판단
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
AI가 **행동을 선택**하고, 코드가 **실행**하고, 결과를 보고 AI가 **다음 행동을 판단**한다.
|
|
36
|
-
|
|
37
|
-
### 핵심 차이
|
|
38
|
-
|
|
39
|
-
| | 챗봇 | 에이전트 |
|
|
40
|
-
| --------- | ------------- | -------------------- |
|
|
41
|
-
| AI의 역할 | 텍스트 생성 | **판단 + 도구 선택** |
|
|
42
|
-
| 실행 주체 | 없음 | 코드(Tool Executor) |
|
|
43
|
-
| 루프 | 1회 요청-응답 | **반복 루프** |
|
|
44
|
-
| 자율성 | 없음 | AI가 다음 행동 결정 |
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
## 2. 기존 방식 vs 에이전트 방식
|
|
49
|
-
|
|
50
|
-
### 기존: 사용자가 라우터
|
|
51
|
-
|
|
52
|
-
```
|
|
53
|
-
사용자가 "/write AI 트렌드" 입력
|
|
54
|
-
→ 코드가 "write" 명령어 파싱
|
|
55
|
-
→ generatePost("AI 트렌드") 직접 호출
|
|
56
|
-
→ 결과 출력
|
|
57
|
-
|
|
58
|
-
사용자가 "/edit 서론 수정" 입력
|
|
59
|
-
→ 코드가 "edit" 명령어 파싱
|
|
60
|
-
→ revisePost(content, "서론 수정") 직접 호출
|
|
61
|
-
→ 결과 출력
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
**사용자**가 어떤 함수를 쓸지 결정한다. 코드는 `if/switch`로 매핑만 한다.
|
|
65
|
-
|
|
66
|
-
### 에이전트: AI가 라우터
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
사용자가 "AI 트렌드로 글 써서 발행해줘" 입력
|
|
70
|
-
→ AI가 판단: "generate_post를 먼저 호출해야겠다"
|
|
71
|
-
→ 코드가 generatePost() 실행 → 결과를 AI에게 전달
|
|
72
|
-
→ AI가 판단: "글이 완성됐으니 발행 확인을 물어봐야겠다"
|
|
73
|
-
→ "발행할까요?" 텍스트 응답
|
|
74
|
-
→ 사용자: "응"
|
|
75
|
-
→ AI가 판단: "publish_post 호출"
|
|
76
|
-
→ 코드가 publishPost() 실행
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
**AI**가 어떤 함수를 쓸지 결정한다. 코드는 AI의 결정을 실행만 한다.
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
## 3. Function Calling 핵심 원리
|
|
84
|
-
|
|
85
|
-
OpenAI의 Function Calling은 AI에게 **"너는 이런 도구를 쓸 수 있어"**라고 알려주는 메커니즘이다.
|
|
86
|
-
|
|
87
|
-
### API 요청 구조
|
|
88
|
-
|
|
89
|
-
```js
|
|
90
|
-
const response = await openai.chat.completions.create({
|
|
91
|
-
model: 'gpt-4o-mini',
|
|
92
|
-
messages: [
|
|
93
|
-
{ role: 'system', content: '당신은 블로그 에이전트입니다.' },
|
|
94
|
-
{ role: 'user', content: 'AI 트렌드로 글 써줘' },
|
|
95
|
-
],
|
|
96
|
-
tools: [
|
|
97
|
-
// ← 여기서 도구를 알려줌
|
|
98
|
-
{
|
|
99
|
-
type: 'function',
|
|
100
|
-
function: {
|
|
101
|
-
name: 'generate_post',
|
|
102
|
-
description: '블로그 글 초안을 생성합니다.',
|
|
103
|
-
parameters: {
|
|
104
|
-
type: 'object',
|
|
105
|
-
properties: {
|
|
106
|
-
topic: { type: 'string', description: '글 주제' },
|
|
107
|
-
},
|
|
108
|
-
required: ['topic'],
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
],
|
|
113
|
-
});
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
### API 응답 — 두 가지 경우
|
|
117
|
-
|
|
118
|
-
**경우 1: 도구를 호출하겠다고 판단**
|
|
119
|
-
|
|
120
|
-
```json
|
|
121
|
-
{
|
|
122
|
-
"choices": [
|
|
123
|
-
{
|
|
124
|
-
"message": {
|
|
125
|
-
"role": "assistant",
|
|
126
|
-
"content": null,
|
|
127
|
-
"tool_calls": [
|
|
128
|
-
{
|
|
129
|
-
"id": "call_abc123",
|
|
130
|
-
"type": "function",
|
|
131
|
-
"function": {
|
|
132
|
-
"name": "generate_post",
|
|
133
|
-
"arguments": "{\"topic\": \"AI 트렌드\"}"
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
]
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
]
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
AI가 직접 함수를 실행하는 게 **아니다**. "이 함수를 이 인자로 호출해줘"라고 **요청**하는 것이다.
|
|
144
|
-
|
|
145
|
-
**경우 2: 도구 없이 텍스트로 답변**
|
|
146
|
-
|
|
147
|
-
```json
|
|
148
|
-
{
|
|
149
|
-
"choices": [
|
|
150
|
-
{
|
|
151
|
-
"message": {
|
|
152
|
-
"role": "assistant",
|
|
153
|
-
"content": "글이 생성되었습니다! 제목은 'AI 트렌드 2026'입니다."
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
]
|
|
157
|
-
}
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
`tool_calls`가 없으면 일반 텍스트 응답. 이게 루프의 종료 조건이 된다.
|
|
161
|
-
|
|
162
|
-
---
|
|
163
|
-
|
|
164
|
-
## 4. 에이전트 루프 구조
|
|
165
|
-
|
|
166
|
-
에이전트의 핵심은 **while 루프**다.
|
|
167
|
-
|
|
168
|
-
### 의사코드
|
|
169
|
-
|
|
170
|
-
```
|
|
171
|
-
messages = [시스템 프롬프트, 사용자 메시지]
|
|
172
|
-
|
|
173
|
-
반복 (최대 10회):
|
|
174
|
-
응답 = OpenAI API 호출(messages, tools)
|
|
175
|
-
|
|
176
|
-
if 응답에 tool_calls가 있으면:
|
|
177
|
-
각 tool_call에 대해:
|
|
178
|
-
함수 실행
|
|
179
|
-
결과를 messages에 추가
|
|
180
|
-
→ 루프 계속 (다시 API 호출)
|
|
181
|
-
|
|
182
|
-
else:
|
|
183
|
-
텍스트 응답을 사용자에게 표시
|
|
184
|
-
→ 루프 종료
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
### 실제 코드 (viruagent의 runAgent)
|
|
188
|
-
|
|
189
|
-
```js
|
|
190
|
-
for (let loop = 0; loop < MAX_LOOPS; loop++) {
|
|
191
|
-
// 1. API 호출
|
|
192
|
-
const res = await openai.chat.completions.create({
|
|
193
|
-
model,
|
|
194
|
-
messages,
|
|
195
|
-
tools: agentTools,
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
const msg = res.choices[0].message;
|
|
199
|
-
messages.push(msg); // AI 응답을 히스토리에 추가
|
|
200
|
-
|
|
201
|
-
// 2. 분기: tool_calls 유무
|
|
202
|
-
if (!msg.tool_calls || msg.tool_calls.length === 0) {
|
|
203
|
-
// 텍스트 응답 → 루프 종료
|
|
204
|
-
return msg.content;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// 3. 도구 실행 → 결과를 messages에 추가
|
|
208
|
-
for (const toolCall of msg.tool_calls) {
|
|
209
|
-
const result = await executeAgentTool(toolCall.function.name, args);
|
|
210
|
-
messages.push({
|
|
211
|
-
role: 'tool', // ← 특수 role
|
|
212
|
-
tool_call_id: toolCall.id, // ← 어떤 호출의 결과인지 매핑
|
|
213
|
-
content: JSON.stringify(result),
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
// → 루프 처음으로 돌아가서 다시 API 호출
|
|
217
|
-
}
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
### messages 배열의 흐름
|
|
221
|
-
|
|
222
|
-
```
|
|
223
|
-
[1] { role: "system", content: "에이전트 프롬프트" }
|
|
224
|
-
[2] { role: "user", content: "AI 트렌드로 글 써줘" }
|
|
225
|
-
↓ API 호출
|
|
226
|
-
[3] { role: "assistant", tool_calls: [generate_post] } ← AI 판단
|
|
227
|
-
[4] { role: "tool", content: '{"title":"AI 트렌드"}' } ← 실행 결과
|
|
228
|
-
↓ API 재호출 ([1]~[4] 전체 전송)
|
|
229
|
-
[5] { role: "assistant", content: "글이 완성되었습니다!" } ← 최종 응답
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
**매 루프마다 전체 messages를 다시 보낸다.** AI는 이전 맥락을 모두 보고 다음 판단을 한다.
|
|
233
|
-
|
|
234
|
-
---
|
|
235
|
-
|
|
236
|
-
## 5. Tool 정의 작성법
|
|
237
|
-
|
|
238
|
-
### 기본 구조
|
|
239
|
-
|
|
240
|
-
```js
|
|
241
|
-
{
|
|
242
|
-
type: "function",
|
|
243
|
-
function: {
|
|
244
|
-
name: "함수이름", // AI가 호출할 때 사용하는 이름
|
|
245
|
-
description: "이 도구가 뭘 하는지", // AI가 판단 근거로 사용
|
|
246
|
-
parameters: { // JSON Schema 형식
|
|
247
|
-
type: "object",
|
|
248
|
-
properties: {
|
|
249
|
-
param1: {
|
|
250
|
-
type: "string",
|
|
251
|
-
description: "파라미터 설명" // AI가 값을 채울 때 참고
|
|
252
|
-
}
|
|
253
|
-
},
|
|
254
|
-
required: ["param1"]
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
### 좋은 description 작성법
|
|
261
|
-
|
|
262
|
-
```
|
|
263
|
-
❌ "글을 만든다" → 너무 모호
|
|
264
|
-
✅ "블로그 글 초안을 생성합니다." → 명확한 행동
|
|
265
|
-
✅ "현재 초안을 블로그에 발행합니다. → 행동 + 대상
|
|
266
|
-
반드시 사용자 확인 후 호출하세요." → 조건까지 명시
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
description은 AI의 **판단 근거**가 된다. 잘 쓰면 AI가 적절한 타이밍에 적절한 도구를 선택한다.
|
|
270
|
-
|
|
271
|
-
### 파라미터 없는 도구
|
|
272
|
-
|
|
273
|
-
```js
|
|
274
|
-
{
|
|
275
|
-
name: "preview_post",
|
|
276
|
-
description: "현재 초안을 미리봅니다.",
|
|
277
|
-
parameters: { type: "object", properties: {} } // 빈 객체
|
|
278
|
-
}
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### 선택적 파라미터
|
|
282
|
-
|
|
283
|
-
```js
|
|
284
|
-
{
|
|
285
|
-
name: "generate_post",
|
|
286
|
-
parameters: {
|
|
287
|
-
type: "object",
|
|
288
|
-
properties: {
|
|
289
|
-
topic: { type: "string", description: "글 주제" },
|
|
290
|
-
tone: { type: "string", description: "글 톤 (선택)" }
|
|
291
|
-
},
|
|
292
|
-
required: ["topic"] // tone은 required에 없음 → 선택
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
---
|
|
298
|
-
|
|
299
|
-
## 6. Tool Executor 패턴
|
|
300
|
-
|
|
301
|
-
AI가 "generate_post를 호출해줘"라고 하면, 실제로 실행하는 코드가 필요하다.
|
|
302
|
-
|
|
303
|
-
### switch 패턴 (viruagent 방식)
|
|
304
|
-
|
|
305
|
-
```js
|
|
306
|
-
const executeAgentTool = async (name, args, context) => {
|
|
307
|
-
switch (name) {
|
|
308
|
-
case 'generate_post': {
|
|
309
|
-
const result = await generatePost(args.topic, { tone: args.tone });
|
|
310
|
-
context.state.draft = result; // 상태 업데이트
|
|
311
|
-
return { success: true, title: result.title };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
case 'edit_post': {
|
|
315
|
-
if (!context.state.draft) {
|
|
316
|
-
return { error: '초안이 없습니다.' }; // 에러도 JSON으로 반환
|
|
317
|
-
}
|
|
318
|
-
const result = await revisePost(context.state.draft.content, args.instruction);
|
|
319
|
-
context.state.draft = result;
|
|
320
|
-
return { success: true, title: result.title };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
case 'publish_post': {
|
|
324
|
-
const result = await context.publishPost({ ... });
|
|
325
|
-
return { success: true, url: result.entryUrl };
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
default:
|
|
329
|
-
return { error: `알 수 없는 도구: ${name}` };
|
|
330
|
-
}
|
|
331
|
-
};
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
### 핵심 원칙
|
|
335
|
-
|
|
336
|
-
1. **결과는 항상 JSON 문자열로 반환** — AI가 읽을 수 있어야 함
|
|
337
|
-
2. **에러도 throw하지 않고 `{ error: "..." }`로 반환** — AI가 에러를 보고 대안을 판단할 수 있음
|
|
338
|
-
3. **상태 변경은 여기서** — `state.draft = result` 같은 부수효과 처리
|
|
339
|
-
|
|
340
|
-
### Map 패턴 (대안)
|
|
341
|
-
|
|
342
|
-
도구가 많아지면 switch 대신 Map으로 관리할 수도 있다:
|
|
343
|
-
|
|
344
|
-
```js
|
|
345
|
-
const toolHandlers = {
|
|
346
|
-
generate_post: async (args, ctx) => { ... },
|
|
347
|
-
edit_post: async (args, ctx) => { ... },
|
|
348
|
-
publish_post: async (args, ctx) => { ... },
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
const executeAgentTool = async (name, args, ctx) => {
|
|
352
|
-
const handler = toolHandlers[name];
|
|
353
|
-
if (!handler) return { error: `알 수 없는 도구: ${name}` };
|
|
354
|
-
return handler(args, ctx);
|
|
355
|
-
};
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
---
|
|
359
|
-
|
|
360
|
-
## 7. 실전 흐름 추적
|
|
361
|
-
|
|
362
|
-
### 시나리오: "AI 트렌드로 글 써서 서론 수정하고 발행해줘"
|
|
363
|
-
|
|
364
|
-
```
|
|
365
|
-
[사용자 입력]
|
|
366
|
-
"AI 트렌드로 글 써서 서론 수정하고 발행해줘"
|
|
367
|
-
|
|
368
|
-
[루프 1] API 호출
|
|
369
|
-
→ AI 판단: 먼저 글을 생성해야 한다
|
|
370
|
-
→ tool_calls: [{ name: "generate_post", args: { topic: "AI 트렌드" } }]
|
|
371
|
-
→ 코드 실행: generatePost("AI 트렌드")
|
|
372
|
-
→ 결과: { success: true, title: "2026 AI 트렌드 총정리" }
|
|
373
|
-
|
|
374
|
-
[루프 2] API 재호출 (이전 맥락 + tool 결과 포함)
|
|
375
|
-
→ AI 판단: 이제 서론을 수정해야 한다
|
|
376
|
-
→ tool_calls: [{ name: "edit_post", args: { instruction: "서론을 더 흥미롭게" } }]
|
|
377
|
-
→ 코드 실행: revisePost(content, "서론을 더 흥미롭게")
|
|
378
|
-
→ 결과: { success: true, title: "2026 AI 트렌드 총정리" }
|
|
379
|
-
|
|
380
|
-
[루프 3] API 재호출
|
|
381
|
-
→ AI 판단: 발행 전에 사용자 확인이 필요하다 (프롬프트 규칙)
|
|
382
|
-
→ tool_calls 없음
|
|
383
|
-
→ 텍스트: "글이 준비되었습니다! 발행할까요?"
|
|
384
|
-
→ 루프 종료
|
|
385
|
-
|
|
386
|
-
[사용자 입력]
|
|
387
|
-
"응 발행해"
|
|
388
|
-
|
|
389
|
-
[루프 1] API 호출
|
|
390
|
-
→ AI 판단: 사용자가 승인했으니 발행
|
|
391
|
-
→ tool_calls: [{ name: "publish_post", args: {} }]
|
|
392
|
-
→ 코드 실행: publishPost(...)
|
|
393
|
-
→ 결과: { success: true, url: "https://blog.example.com/123" }
|
|
394
|
-
|
|
395
|
-
[루프 2] API 재호출
|
|
396
|
-
→ tool_calls 없음
|
|
397
|
-
→ 텍스트: "발행 완료! https://blog.example.com/123"
|
|
398
|
-
→ 루프 종료
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
---
|
|
402
|
-
|
|
403
|
-
## 8. 시스템 프롬프트의 역할
|
|
404
|
-
|
|
405
|
-
에이전트의 **행동 규칙**을 정하는 것이 시스템 프롬프트다.
|
|
406
|
-
|
|
407
|
-
### tool description vs 시스템 프롬프트
|
|
408
|
-
|
|
409
|
-
| | tool description | 시스템 프롬프트 |
|
|
410
|
-
| ---- | ------------------------ | --------------------------------------- |
|
|
411
|
-
| 위치 | tools 배열 안 | messages[0] |
|
|
412
|
-
| 역할 | "이 도구는 뭘 한다" | "너는 누구고, 어떻게 행동해라" |
|
|
413
|
-
| 예시 | "블로그 글을 생성합니다" | "발행 전에 반드시 사용자 확인을 구하라" |
|
|
414
|
-
|
|
415
|
-
### viruagent의 에이전트 프롬프트 구조
|
|
416
|
-
|
|
417
|
-
```markdown
|
|
418
|
-
# 역할 정의
|
|
419
|
-
|
|
420
|
-
당신은 티스토리 블로그 글쓰기를 돕는 자율형 AI 에이전트입니다.
|
|
421
|
-
|
|
422
|
-
# 도구 사용 원칙 (판단 기준)
|
|
423
|
-
|
|
424
|
-
1. 글 작성 요청 → generate_post
|
|
425
|
-
2. 수정 요청 → edit_post
|
|
426
|
-
3. 발행 요청 → 반드시 확인 먼저!
|
|
427
|
-
|
|
428
|
-
# 행동 제약
|
|
429
|
-
|
|
430
|
-
- 초안 없이 수정/발행 요청 → 안내
|
|
431
|
-
- 불필요한 도구 호출 금지
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
시스템 프롬프트가 없으면 AI는 **아무 도구나 마구 호출**할 수 있다. 프롬프트로 행동 경계를 잡아주는 것이 중요하다.
|
|
435
|
-
|
|
436
|
-
---
|
|
437
|
-
|
|
438
|
-
## 9. 토큰 비용 구조
|
|
439
|
-
|
|
440
|
-
### 매 루프마다 전송되는 토큰
|
|
441
|
-
|
|
442
|
-
```
|
|
443
|
-
시스템 프롬프트 ~300 토큰 (매번 고정)
|
|
444
|
-
tools 정의 (7개) ~800 토큰 (매번 고정)
|
|
445
|
-
대화 히스토리 누적 증가
|
|
446
|
-
tool 결과 누적 증가
|
|
447
|
-
──────────────────────────────
|
|
448
|
-
루프 1 입력: ~1,200 토큰
|
|
449
|
-
루프 2 입력: ~1,500 토큰 (이전 맥락 추가)
|
|
450
|
-
루프 3 입력: ~1,800 토큰
|
|
451
|
-
```
|
|
452
|
-
|
|
453
|
-
### 비용 최적화 전략
|
|
454
|
-
|
|
455
|
-
| 전략 | 효과 |
|
|
456
|
-
| ------------------------------- | -------------------- |
|
|
457
|
-
| 히스토리 길이 제한 (최근 N개만) | 누적 비용 억제 |
|
|
458
|
-
| tool 결과를 요약해서 반환 | 컨텍스트 절약 |
|
|
459
|
-
| 불필요한 tool 제거 | tools 정의 토큰 감소 |
|
|
460
|
-
| 작은 모델 사용 (gpt-4o-mini) | 단가 자체를 낮춤 |
|
|
461
|
-
|
|
462
|
-
### 모델별 비용 비교 (루프 3회 기준, ~5,000 입력 토큰)
|
|
463
|
-
|
|
464
|
-
| 모델 | 입력 단가 | 예상 비용 |
|
|
465
|
-
| ----------- | --------- | -------------- |
|
|
466
|
-
| gpt-4o-mini | $0.15/1M | ~$0.001 (1원) |
|
|
467
|
-
| gpt-4o | $2.5/1M | ~$0.013 (17원) |
|
|
468
|
-
| gpt-4.1 | $2.0/1M | ~$0.010 (13원) |
|
|
469
|
-
|
|
470
|
-
---
|
|
471
|
-
|
|
472
|
-
## 10. 안전장치 설계
|
|
473
|
-
|
|
474
|
-
### 무한 루프 방지
|
|
475
|
-
|
|
476
|
-
```js
|
|
477
|
-
const MAX_LOOPS = 10;
|
|
478
|
-
for (let loop = 0; loop < MAX_LOOPS; loop++) {
|
|
479
|
-
// ... 루프 초과 시 강제 종료
|
|
480
|
-
}
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
AI가 도구를 호출하고 → 결과를 보고 → 또 호출하고... 무한히 반복할 수 있으므로 **반드시 상한**을 둔다.
|
|
484
|
-
|
|
485
|
-
### 위험한 동작 보호
|
|
486
|
-
|
|
487
|
-
```markdown
|
|
488
|
-
# 시스템 프롬프트에서
|
|
489
|
-
|
|
490
|
-
발행 요청 시에는 반드시 사용자에게 먼저 확인을 구하세요.
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
```js
|
|
494
|
-
// tool description에서
|
|
495
|
-
{
|
|
496
|
-
name: "publish_post",
|
|
497
|
-
description: "현재 초안을 블로그에 발행합니다. 반드시 사용자 확인 후 호출하세요."
|
|
498
|
-
}
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
**이중 안전장치**: 시스템 프롬프트 + tool description 양쪽에서 제약.
|
|
502
|
-
|
|
503
|
-
### 에러 격리
|
|
504
|
-
|
|
505
|
-
```js
|
|
506
|
-
let result;
|
|
507
|
-
try {
|
|
508
|
-
result = await executeAgentTool(name, args, context);
|
|
509
|
-
} catch (e) {
|
|
510
|
-
result = { error: e.message }; // throw하지 않고 에러를 AI에게 전달
|
|
511
|
-
}
|
|
512
|
-
```
|
|
513
|
-
|
|
514
|
-
도구 실행 실패 시 루프가 죽지 않고, AI가 에러를 보고 대안을 판단할 수 있다.
|
|
515
|
-
|
|
516
|
-
---
|
|
517
|
-
|
|
518
|
-
## 11. 확장 패턴
|
|
519
|
-
|
|
520
|
-
### 병렬 도구 호출 (Parallel Tool Calls)
|
|
521
|
-
|
|
522
|
-
AI가 한 번에 여러 도구를 호출할 수 있다:
|
|
523
|
-
|
|
524
|
-
```json
|
|
525
|
-
{
|
|
526
|
-
"tool_calls": [{ "name": "get_blog_status" }, { "name": "generate_post", "args": { "topic": "AI" } }]
|
|
527
|
-
}
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
viruagent는 이미 `for...of`로 순차 처리하지만, `Promise.all`로 병렬 실행도 가능:
|
|
531
|
-
|
|
532
|
-
```js
|
|
533
|
-
const results = await Promise.all(msg.tool_calls.map((tc) => executeAgentTool(tc.function.name, args)));
|
|
534
|
-
```
|
|
535
|
-
|
|
536
|
-
### Structured Outputs (응답 형식 강제)
|
|
537
|
-
|
|
538
|
-
```js
|
|
539
|
-
response_format: {
|
|
540
|
-
type: 'json_object';
|
|
541
|
-
} // JSON 응답 강제
|
|
542
|
-
```
|
|
543
|
-
|
|
544
|
-
도구 결과뿐 아니라 AI의 최종 응답도 구조화할 수 있다.
|
|
545
|
-
|
|
546
|
-
### Multi-Agent (다중 에이전트)
|
|
547
|
-
|
|
548
|
-
```
|
|
549
|
-
[매니저 에이전트]
|
|
550
|
-
├── [글쓰기 에이전트] → generate_post, edit_post
|
|
551
|
-
├── [SEO 에이전트] → analyze_seo, suggest_keywords
|
|
552
|
-
└── [발행 에이전트] → publish_post, schedule_post
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
각 에이전트가 자기 전문 도구만 갖고, 매니저가 작업을 분배하는 패턴.
|
|
556
|
-
|
|
557
|
-
### 메모리 (장기 기억)
|
|
558
|
-
|
|
559
|
-
```js
|
|
560
|
-
tools: [
|
|
561
|
-
{ name: 'save_memory', description: '중요 정보를 저장합니다' },
|
|
562
|
-
{ name: 'search_memory', description: '이전에 저장한 정보를 검색합니다' },
|
|
563
|
-
];
|
|
564
|
-
```
|
|
565
|
-
|
|
566
|
-
에이전트에게 기억 저장/검색 도구를 주면 대화 세션을 넘어서 정보를 유지할 수 있다.
|
|
567
|
-
|
|
568
|
-
---
|
|
569
|
-
|
|
570
|
-
## 참고 자료
|
|
571
|
-
|
|
572
|
-
- [OpenAI Function Calling 공식 문서](https://platform.openai.com/docs/guides/function-calling)
|
|
573
|
-
- [OpenAI API Reference - Chat Completions](https://platform.openai.com/docs/api-reference/chat/create)
|
|
574
|
-
- [Building AI Agents (OpenAI Cookbook)](https://cookbook.openai.com/examples/how_to_call_functions_with_chat_models)
|
|
@@ -1,484 +0,0 @@
|
|
|
1
|
-
# 하이브리드 DB 에이전트 설계 가이드
|
|
2
|
-
|
|
3
|
-
자연어로 데이터베이스를 조회하는 AI 에이전트의 스키마 관리 전략.
|
|
4
|
-
|
|
5
|
-
**캐싱(부팅 시 스키마 로드)** + **도구(상세 조회)**를 조합하여 정확도와 성능을 모두 확보한다.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. 전체 아키텍처
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
[서버 부팅]
|
|
13
|
-
└── DB에서 스키마 캐싱 (테이블, 컬럼, FK 관계)
|
|
14
|
-
↓
|
|
15
|
-
[사용자 요청] "이번 달 매출 상위 고객 보여줘"
|
|
16
|
-
↓
|
|
17
|
-
[시스템 프롬프트]
|
|
18
|
-
├── 캐싱된 스키마 요약 (항상 포함)
|
|
19
|
-
└── "상세 정보가 필요하면 get_schema 도구를 사용하라"
|
|
20
|
-
↓
|
|
21
|
-
[에이전트 루프]
|
|
22
|
-
├── AI가 캐싱된 스키마로 충분하면 → 바로 query_database 호출
|
|
23
|
-
└── 컬럼 타입, 제약조건 등 상세 필요하면 → get_schema 먼저 호출
|
|
24
|
-
↓
|
|
25
|
-
[결과 반환] "매출 상위 고객은 ..."
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## 2. 스키마 캐싱 (서버 부팅 시)
|
|
31
|
-
|
|
32
|
-
서버가 시작될 때 한 번 DB에서 스키마 정보를 읽어 메모리에 저장한다.
|
|
33
|
-
|
|
34
|
-
### 2-1. 캐싱 쿼리
|
|
35
|
-
|
|
36
|
-
```js
|
|
37
|
-
const loadSchema = async (client) => {
|
|
38
|
-
// 테이블 + 컬럼 정보
|
|
39
|
-
const columns = await client.query(`
|
|
40
|
-
SELECT table_name, column_name, data_type, is_nullable,
|
|
41
|
-
column_default
|
|
42
|
-
FROM information_schema.columns
|
|
43
|
-
WHERE table_schema = 'public'
|
|
44
|
-
ORDER BY table_name, ordinal_position
|
|
45
|
-
`);
|
|
46
|
-
|
|
47
|
-
// FK 관계
|
|
48
|
-
const fks = await client.query(`
|
|
49
|
-
SELECT
|
|
50
|
-
tc.table_name AS from_table,
|
|
51
|
-
kcu.column_name AS from_column,
|
|
52
|
-
ccu.table_name AS to_table,
|
|
53
|
-
ccu.column_name AS to_column
|
|
54
|
-
FROM information_schema.table_constraints tc
|
|
55
|
-
JOIN information_schema.key_column_usage kcu
|
|
56
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
57
|
-
JOIN information_schema.constraint_column_usage ccu
|
|
58
|
-
ON tc.constraint_name = ccu.constraint_name
|
|
59
|
-
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
60
|
-
AND tc.table_schema = 'public'
|
|
61
|
-
`);
|
|
62
|
-
|
|
63
|
-
// 테이블별 그룹핑
|
|
64
|
-
const grouped = {};
|
|
65
|
-
for (const row of columns.rows) {
|
|
66
|
-
if (!grouped[row.table_name]) grouped[row.table_name] = [];
|
|
67
|
-
grouped[row.table_name].push(`${row.column_name} (${row.data_type})`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 텍스트로 변환
|
|
71
|
-
let schema = '## 테이블 스키마\n';
|
|
72
|
-
schema += Object.entries(grouped)
|
|
73
|
-
.map(([table, cols]) => `- ${table}: ${cols.join(', ')}`)
|
|
74
|
-
.join('\n');
|
|
75
|
-
|
|
76
|
-
if (fks.rows.length > 0) {
|
|
77
|
-
schema += '\n\n## 관계 (FK)\n';
|
|
78
|
-
schema += fks.rows
|
|
79
|
-
.map(r => `- ${r.from_table}.${r.from_column} → ${r.to_table}.${r.to_column}`)
|
|
80
|
-
.join('\n');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return schema;
|
|
84
|
-
};
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
### 2-2. 캐싱 결과 예시
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
## 테이블 스키마
|
|
91
|
-
- users: id (integer), email (varchar), name (varchar), role (varchar), created_at (timestamp)
|
|
92
|
-
- orders: id (integer), user_id (integer), total (numeric), status (varchar), created_at (timestamp)
|
|
93
|
-
- products: id (integer), name (varchar), price (numeric), category (varchar), stock (integer)
|
|
94
|
-
- order_items: id (integer), order_id (integer), product_id (integer), quantity (integer), price (numeric)
|
|
95
|
-
|
|
96
|
-
## 관계 (FK)
|
|
97
|
-
- orders.user_id → users.id
|
|
98
|
-
- order_items.order_id → orders.id
|
|
99
|
-
- order_items.product_id → products.id
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
AI는 이것만 보고도 대부분의 JOIN 쿼리를 정확하게 생성할 수 있다.
|
|
103
|
-
|
|
104
|
-
### 2-3. 캐시 갱신
|
|
105
|
-
|
|
106
|
-
```js
|
|
107
|
-
// 서버 시작 시
|
|
108
|
-
let schemaCache = await loadSchema(client);
|
|
109
|
-
|
|
110
|
-
// 주기적 갱신 (선택)
|
|
111
|
-
setInterval(async () => {
|
|
112
|
-
schemaCache = await loadSchema(client);
|
|
113
|
-
}, 1000 * 60 * 30); // 30분마다
|
|
114
|
-
|
|
115
|
-
// 수동 갱신 API (어드민용)
|
|
116
|
-
app.post('/api/admin/refresh-schema', async (req, res) => {
|
|
117
|
-
schemaCache = await loadSchema(client);
|
|
118
|
-
res.json({ success: true });
|
|
119
|
-
});
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## 3. 도구 정의
|
|
125
|
-
|
|
126
|
-
### 3-1. tools 배열
|
|
127
|
-
|
|
128
|
-
```js
|
|
129
|
-
const tools = [
|
|
130
|
-
{
|
|
131
|
-
type: 'function',
|
|
132
|
-
function: {
|
|
133
|
-
name: 'query_database',
|
|
134
|
-
description: 'PostgreSQL에 SELECT 쿼리를 실행합니다. READ ONLY. 결과는 최대 100행.',
|
|
135
|
-
parameters: {
|
|
136
|
-
type: 'object',
|
|
137
|
-
properties: {
|
|
138
|
-
sql: { type: 'string', description: 'SELECT 쿼리문' },
|
|
139
|
-
},
|
|
140
|
-
required: ['sql'],
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
type: 'function',
|
|
146
|
-
function: {
|
|
147
|
-
name: 'get_schema',
|
|
148
|
-
description: '특정 테이블의 상세 스키마를 조회합니다. 컬럼 타입, 기본값, NOT NULL, 인덱스, 코멘트 등 캐싱된 요약보다 상세한 정보가 필요할 때 사용하세요.',
|
|
149
|
-
parameters: {
|
|
150
|
-
type: 'object',
|
|
151
|
-
properties: {
|
|
152
|
-
table_name: { type: 'string', description: '조회할 테이블명' },
|
|
153
|
-
},
|
|
154
|
-
required: ['table_name'],
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
},
|
|
158
|
-
];
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
### 3-2. AI의 판단 기준
|
|
162
|
-
|
|
163
|
-
| 상황 | AI 행동 |
|
|
164
|
-
|------|---------|
|
|
165
|
-
| "주문 많은 고객 보여줘" | 캐시에 users, orders, FK 있음 → 바로 `query_database` |
|
|
166
|
-
| "users 테이블에 soft delete 있어?" | 캐시만으론 모름 → `get_schema("users")` 먼저 |
|
|
167
|
-
| "인덱스 걸린 컬럼이 뭐야?" | 캐시에 없음 → `get_schema` 호출 |
|
|
168
|
-
|
|
169
|
-
---
|
|
170
|
-
|
|
171
|
-
## 4. Tool Executor
|
|
172
|
-
|
|
173
|
-
### 4-1. query_database
|
|
174
|
-
|
|
175
|
-
```js
|
|
176
|
-
const executeQuery = async (sql, client) => {
|
|
177
|
-
// 1차: SQL 파싱 검증
|
|
178
|
-
const normalized = sql.trim().toUpperCase();
|
|
179
|
-
if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) {
|
|
180
|
-
return { error: 'SELECT / WITH 쿼리만 허용됩니다.' };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const blocked = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'TRUNCATE', 'CREATE', 'GRANT', 'REVOKE'];
|
|
184
|
-
for (const kw of blocked) {
|
|
185
|
-
// SELECT 뒤에 나오는 서브쿼리 내 키워드도 체크
|
|
186
|
-
if (normalized.includes(kw + ' ')) {
|
|
187
|
-
return { error: `${kw} 키워드가 포함된 쿼리는 실행할 수 없습니다.` };
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// 2차: read-only 트랜잭션
|
|
192
|
-
try {
|
|
193
|
-
await client.query('BEGIN READ ONLY');
|
|
194
|
-
const result = await client.query(sql);
|
|
195
|
-
await client.query('COMMIT');
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
columns: result.fields.map(f => f.name),
|
|
199
|
-
rows: result.rows.slice(0, 100),
|
|
200
|
-
totalRows: result.rowCount,
|
|
201
|
-
truncated: result.rowCount > 100,
|
|
202
|
-
};
|
|
203
|
-
} catch (e) {
|
|
204
|
-
await client.query('ROLLBACK');
|
|
205
|
-
return { error: `쿼리 실행 오류: ${e.message}` };
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
### 4-2. get_schema (상세 조회)
|
|
211
|
-
|
|
212
|
-
```js
|
|
213
|
-
const getDetailedSchema = async (tableName, client) => {
|
|
214
|
-
// 컬럼 상세
|
|
215
|
-
const columns = await client.query(`
|
|
216
|
-
SELECT
|
|
217
|
-
c.column_name,
|
|
218
|
-
c.data_type,
|
|
219
|
-
c.character_maximum_length,
|
|
220
|
-
c.is_nullable,
|
|
221
|
-
c.column_default,
|
|
222
|
-
pgd.description AS comment
|
|
223
|
-
FROM information_schema.columns c
|
|
224
|
-
LEFT JOIN pg_catalog.pg_description pgd
|
|
225
|
-
ON pgd.objsubid = c.ordinal_position
|
|
226
|
-
AND pgd.objoid = (SELECT oid FROM pg_class WHERE relname = $1)
|
|
227
|
-
WHERE c.table_name = $1 AND c.table_schema = 'public'
|
|
228
|
-
ORDER BY c.ordinal_position
|
|
229
|
-
`, [tableName]);
|
|
230
|
-
|
|
231
|
-
// 인덱스
|
|
232
|
-
const indexes = await client.query(`
|
|
233
|
-
SELECT indexname, indexdef
|
|
234
|
-
FROM pg_indexes
|
|
235
|
-
WHERE tablename = $1 AND schemaname = 'public'
|
|
236
|
-
`, [tableName]);
|
|
237
|
-
|
|
238
|
-
// FK (이 테이블에서 나가는)
|
|
239
|
-
const fksOut = await client.query(`
|
|
240
|
-
SELECT kcu.column_name, ccu.table_name AS ref_table, ccu.column_name AS ref_column
|
|
241
|
-
FROM information_schema.table_constraints tc
|
|
242
|
-
JOIN information_schema.key_column_usage kcu
|
|
243
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
244
|
-
JOIN information_schema.constraint_column_usage ccu
|
|
245
|
-
ON tc.constraint_name = ccu.constraint_name
|
|
246
|
-
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
247
|
-
AND tc.table_name = $1
|
|
248
|
-
`, [tableName]);
|
|
249
|
-
|
|
250
|
-
// FK (이 테이블로 들어오는)
|
|
251
|
-
const fksIn = await client.query(`
|
|
252
|
-
SELECT tc.table_name AS from_table, kcu.column_name AS from_column
|
|
253
|
-
FROM information_schema.table_constraints tc
|
|
254
|
-
JOIN information_schema.key_column_usage kcu
|
|
255
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
256
|
-
JOIN information_schema.constraint_column_usage ccu
|
|
257
|
-
ON tc.constraint_name = ccu.constraint_name
|
|
258
|
-
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
259
|
-
AND ccu.table_name = $1
|
|
260
|
-
`, [tableName]);
|
|
261
|
-
|
|
262
|
-
return {
|
|
263
|
-
table: tableName,
|
|
264
|
-
columns: columns.rows,
|
|
265
|
-
indexes: indexes.rows,
|
|
266
|
-
foreignKeysOut: fksOut.rows,
|
|
267
|
-
foreignKeysIn: fksIn.rows,
|
|
268
|
-
};
|
|
269
|
-
};
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
---
|
|
273
|
-
|
|
274
|
-
## 5. 시스템 프롬프트
|
|
275
|
-
|
|
276
|
-
```js
|
|
277
|
-
const buildSystemPrompt = (schemaCache) => `
|
|
278
|
-
당신은 데이터베이스 조회 어시스턴트입니다.
|
|
279
|
-
사용자의 자연어 질문을 SQL SELECT 쿼리로 변환하여 실행합니다.
|
|
280
|
-
|
|
281
|
-
## 규칙
|
|
282
|
-
- SELECT 쿼리만 생성하세요. 데이터 변경은 불가합니다.
|
|
283
|
-
- 결과가 많을 수 있으니 LIMIT을 적절히 사용하세요.
|
|
284
|
-
- 날짜 필터가 모호하면 사용자에게 확인하세요.
|
|
285
|
-
- 쿼리 결과를 사용자가 이해하기 쉽게 요약해서 설명하세요.
|
|
286
|
-
- 상세 스키마 정보(인덱스, 코멘트 등)가 필요하면 get_schema 도구를 사용하세요.
|
|
287
|
-
|
|
288
|
-
${schemaCache}
|
|
289
|
-
`;
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
---
|
|
293
|
-
|
|
294
|
-
## 6. 에이전트 루프
|
|
295
|
-
|
|
296
|
-
ViruAgent의 `runAgent`와 동일한 구조:
|
|
297
|
-
|
|
298
|
-
```js
|
|
299
|
-
const runDbAgent = async (userMessage, { schemaCache, client, chatHistory }) => {
|
|
300
|
-
chatHistory.push({ role: 'user', content: userMessage });
|
|
301
|
-
|
|
302
|
-
const messages = [
|
|
303
|
-
{ role: 'system', content: buildSystemPrompt(schemaCache) },
|
|
304
|
-
...chatHistory,
|
|
305
|
-
];
|
|
306
|
-
|
|
307
|
-
const MAX_LOOPS = 10;
|
|
308
|
-
|
|
309
|
-
for (let i = 0; i < MAX_LOOPS; i++) {
|
|
310
|
-
const res = await openai.chat.completions.create({
|
|
311
|
-
model: 'gpt-4o-mini',
|
|
312
|
-
messages,
|
|
313
|
-
tools,
|
|
314
|
-
temperature: 0, // SQL 생성은 정확도 우선
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
const msg = res.choices[0].message;
|
|
318
|
-
messages.push(msg);
|
|
319
|
-
|
|
320
|
-
if (!msg.tool_calls?.length) {
|
|
321
|
-
chatHistory.push({ role: 'assistant', content: msg.content });
|
|
322
|
-
return msg.content;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
for (const tc of msg.tool_calls) {
|
|
326
|
-
const args = JSON.parse(tc.function.arguments);
|
|
327
|
-
let result;
|
|
328
|
-
|
|
329
|
-
switch (tc.function.name) {
|
|
330
|
-
case 'query_database':
|
|
331
|
-
result = await executeQuery(args.sql, client);
|
|
332
|
-
break;
|
|
333
|
-
case 'get_schema':
|
|
334
|
-
result = await getDetailedSchema(args.table_name, client);
|
|
335
|
-
break;
|
|
336
|
-
default:
|
|
337
|
-
result = { error: `알 수 없는 도구: ${tc.function.name}` };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
messages.push({
|
|
341
|
-
role: 'tool',
|
|
342
|
-
tool_call_id: tc.id,
|
|
343
|
-
content: JSON.stringify(result),
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return '쿼리가 너무 복잡합니다. 질문을 나눠서 요청해주세요.';
|
|
349
|
-
};
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
---
|
|
353
|
-
|
|
354
|
-
## 7. 실전 흐름 추적
|
|
355
|
-
|
|
356
|
-
### 시나리오 1: 단순 조회 (캐시만으로 해결)
|
|
357
|
-
|
|
358
|
-
```
|
|
359
|
-
사용자: "이번 달 주문 건수 알려줘"
|
|
360
|
-
|
|
361
|
-
[루프 1]
|
|
362
|
-
시스템 프롬프트에 캐시된 스키마 포함
|
|
363
|
-
→ AI: orders 테이블에 created_at 있네 → 바로 SQL 생성
|
|
364
|
-
→ tool_calls: query_database
|
|
365
|
-
sql: "SELECT COUNT(*) as cnt FROM orders WHERE created_at >= '2026-02-01'"
|
|
366
|
-
→ 결과: [{ cnt: 1847 }]
|
|
367
|
-
|
|
368
|
-
[루프 2]
|
|
369
|
-
→ AI: "이번 달 주문은 총 1,847건입니다."
|
|
370
|
-
→ 루프 종료
|
|
371
|
-
|
|
372
|
-
총 루프: 2회, get_schema 호출: 0회
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
### 시나리오 2: 복잡한 조회 (상세 스키마 필요)
|
|
376
|
-
|
|
377
|
-
```
|
|
378
|
-
사용자: "soft delete된 사용자 중에 주문 있는 사람 보여줘"
|
|
379
|
-
|
|
380
|
-
[루프 1]
|
|
381
|
-
→ AI: 캐시에 users 테이블은 있지만 soft delete 컬럼이 뭔지 모르겠다
|
|
382
|
-
→ tool_calls: get_schema("users")
|
|
383
|
-
→ 결과: { columns: [..., { column_name: "deleted_at", data_type: "timestamp", is_nullable: "YES" }] }
|
|
384
|
-
|
|
385
|
-
[루프 2]
|
|
386
|
-
→ AI: deleted_at이 NOT NULL이면 soft delete구나
|
|
387
|
-
→ tool_calls: query_database
|
|
388
|
-
sql: "SELECT u.name, u.email, COUNT(o.id) as order_count
|
|
389
|
-
FROM users u
|
|
390
|
-
JOIN orders o ON o.user_id = u.id
|
|
391
|
-
WHERE u.deleted_at IS NOT NULL
|
|
392
|
-
GROUP BY u.id, u.name, u.email"
|
|
393
|
-
→ 결과: [{ name: "김철수", email: "...", order_count: 5 }, ...]
|
|
394
|
-
|
|
395
|
-
[루프 3]
|
|
396
|
-
→ AI: "soft delete된 사용자 중 주문이 있는 분은 3명입니다: ..."
|
|
397
|
-
→ 루프 종료
|
|
398
|
-
|
|
399
|
-
총 루프: 3회, get_schema 호출: 1회
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
---
|
|
403
|
-
|
|
404
|
-
## 8. 안전장치 요약
|
|
405
|
-
|
|
406
|
-
```
|
|
407
|
-
┌─────────────────────────────────────────────┐
|
|
408
|
-
│ Layer 1: 시스템 프롬프트 │
|
|
409
|
-
│ → "SELECT만 생성하라" │
|
|
410
|
-
├─────────────────────────────────────────────┤
|
|
411
|
-
│ Layer 2: Tool Executor (SQL 파싱) │
|
|
412
|
-
│ → SELECT/WITH 외 차단 │
|
|
413
|
-
│ → INSERT/UPDATE/DELETE/DROP 키워드 감지 │
|
|
414
|
-
├─────────────────────────────────────────────┤
|
|
415
|
-
│ Layer 3: DB 연결 │
|
|
416
|
-
│ → BEGIN READ ONLY 트랜잭션 │
|
|
417
|
-
│ → 또는 read replica 연결 │
|
|
418
|
-
├─────────────────────────────────────────────┤
|
|
419
|
-
│ Layer 4: 결과 제한 │
|
|
420
|
-
│ → 최대 100행 반환 │
|
|
421
|
-
│ → 타임아웃 설정 (statement_timeout) │
|
|
422
|
-
└─────────────────────────────────────────────┘
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
### DB 레벨 추가 보호 (권장)
|
|
426
|
-
|
|
427
|
-
```sql
|
|
428
|
-
-- 전용 read-only 유저 생성
|
|
429
|
-
CREATE USER db_agent_readonly WITH PASSWORD '...';
|
|
430
|
-
GRANT CONNECT ON DATABASE mydb TO db_agent_readonly;
|
|
431
|
-
GRANT USAGE ON SCHEMA public TO db_agent_readonly;
|
|
432
|
-
GRANT SELECT ON ALL TABLES IN SCHEMA public TO db_agent_readonly;
|
|
433
|
-
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
|
434
|
-
GRANT SELECT ON TABLES TO db_agent_readonly;
|
|
435
|
-
|
|
436
|
-
-- 쿼리 타임아웃 (느린 쿼리 방지)
|
|
437
|
-
ALTER USER db_agent_readonly SET statement_timeout = '10s';
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
---
|
|
441
|
-
|
|
442
|
-
## 9. 토큰 비용
|
|
443
|
-
|
|
444
|
-
| 항목 | 토큰 수 |
|
|
445
|
-
|------|---------|
|
|
446
|
-
| 시스템 프롬프트 (규칙) | ~200 |
|
|
447
|
-
| 캐싱된 스키마 (10개 테이블) | ~400 |
|
|
448
|
-
| 캐싱된 스키마 (50개 테이블) | ~2,000 |
|
|
449
|
-
| tools 정의 (2개) | ~300 |
|
|
450
|
-
| get_schema 결과 (1회) | ~500 |
|
|
451
|
-
| query_database 결과 (50행) | ~1,000 |
|
|
452
|
-
|
|
453
|
-
### 테이블이 많을 때 최적화
|
|
454
|
-
|
|
455
|
-
```js
|
|
456
|
-
// 50개 이상이면 테이블명만 캐싱, 컬럼은 get_schema로
|
|
457
|
-
const loadLightSchema = async (client) => {
|
|
458
|
-
const tables = await client.query(`
|
|
459
|
-
SELECT table_name,
|
|
460
|
-
obj_description(('"' || table_name || '"')::regclass) AS comment
|
|
461
|
-
FROM information_schema.tables
|
|
462
|
-
WHERE table_schema = 'public'
|
|
463
|
-
`);
|
|
464
|
-
|
|
465
|
-
return '## 테이블 목록\n' +
|
|
466
|
-
tables.rows.map(t =>
|
|
467
|
-
`- ${t.table_name}${t.comment ? ` (${t.comment})` : ''}`
|
|
468
|
-
).join('\n') +
|
|
469
|
-
'\n\n컬럼 정보가 필요하면 get_schema 도구를 사용하세요.';
|
|
470
|
-
};
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
---
|
|
474
|
-
|
|
475
|
-
## 10. 하이브리드 방식을 선택한 이유
|
|
476
|
-
|
|
477
|
-
| 방식 | 장점 | 단점 |
|
|
478
|
-
|------|------|------|
|
|
479
|
-
| 하드코딩 | 빠름, 정확 | 스키마 변경 시 코드 수정 |
|
|
480
|
-
| 도구만 (MCP 방식) | 항상 최신 | 매번 1~2루프 낭비 |
|
|
481
|
-
| 캐싱만 | 빠름, 자동 | 상세 정보 부족 |
|
|
482
|
-
| **하이브리드** | **빠름 + 자동 + 상세** | 구현 약간 복잡 |
|
|
483
|
-
|
|
484
|
-
하이브리드는 **90%의 쿼리는 캐시로 즉시 처리**하고, **10%의 복잡한 케이스만 도구로 상세 조회**한다. 토큰 절약과 정확도를 동시에 달성하는 최적의 전략이다.
|