viruagent 1.0.1 → 1.2.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/README.md +53 -15
- package/config/agent-prompt.md +31 -0
- package/docs/agent-pattern-guide.md +574 -0
- package/docs/hybrid-db-agent-guide.md +484 -0
- package/package.json +1 -2
- package/src/agent.js +208 -42
- package/src/cli-post.js +11 -1
- package/src/lib/ai.js +295 -16
- package/src/lib/unsplash.js +5 -7
- package/.env.example +0 -2
|
@@ -0,0 +1,484 @@
|
|
|
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%의 복잡한 케이스만 도구로 상세 조회**한다. 토큰 절약과 정확도를 동시에 달성하는 최적의 전략이다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viruagent",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "AI 기반 티스토리 블로그 자동 발행 CLI 도구",
|
|
5
5
|
"main": "src/agent.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"type": "commonjs",
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"chalk": "^4.1.2",
|
|
33
|
-
"dotenv": "^16.4.7",
|
|
34
33
|
"oh-my-logo": "^0.4.0",
|
|
35
34
|
"openai": "^4.77.0",
|
|
36
35
|
"playwright": "^1.58.2"
|