1. Tools
search_blog_post_tool 하나만 만들 때는 크게 막히는 부분이 없었다. 단일 책임도 명확했고, 구현 속도도 빠르게 나왔다.
하지만 두 번째 summarize_context_tool을 추가하고, 서로 연결하려는 순간부터 상황이 달라졌다.
1
2
Tool 간 연결은 어떻게 하지?
이 방식이 맞는 구조일까?
이 질문이 자연스럽게 나오기 시작했다.
물론 책임이나 확장성을 크게 고려하지 않는다면 단순하게 이어붙이는 것도 가능했다.
하지만 앞으로 추가될 Tool들이 많다는 것을 생각하면
1
2
지금 구조를 정리하지 않으면
나중에 더 큰 비용으로 돌아올 것
이라는 판단이 들었다.
이 시점부터는 단순 구현이 아니라 구조를 의식한 설계 단계로 넘어가게 되었다.
2. Tool의 위치에 대한 고민 ( adapter → application )
헥사고날 구조를 적용하면서 개발을 진행하다 보니 클래스 하나를 만들 때도 어디에 위치해야 하는가를 계속 고민하게 되었다.
그럼에도 불구하고, 실제로 개발을 진행하다 보면 초기 판단이 틀리는 경우가 자주 발생했다.
이번에도 마찬가지였다.
왜 inbound로 잘못 생각했는가?
초기에는 inbound adapter 가 없는 상태에서 서비스를 직접 실행시키는 구조였다.
그래서 자연스럽게 흐름을 이렇게 인식했다.
Tool → LLM
이 구조만 보면 Tool이 외부 요청을 받는 진입점처럼 보였고, 그 결과 adapter.inbound에 두는 판단을 하게 되었다.
다시 보니 무엇이 달랐는가?
Tool의 실제 역할을 다시 정의해보면 다음과 같다.
1
2
3
- 사용자 요청을 직접 받지 않는다
- 특정 기능(검색, 요약, 생성)을 수행한다
- 내부적으로 Service나 Port를 호출한다
즉 Tool은
1
2
외부 요청을 받는 계층이 아니라
애플리케이션 내부에서 사용하는 실행 단위
에 더 가깝다.
정리
이 기준으로 보면 Tool의 위치는 명확해진다.
1
2
adapter.inbound X
application.tool O
헥사고날 구조에 대한 이해가 조금 더 있었더라면 처음부터 올바르게 배치할 수 있었겠지만,
이 과정을 통해 오히려 기준이 더 명확해졌다
3. Prompt 관리에 대한 혼란
두 번째로 크게 막혔던 부분은 프롬프트를 어디에 둘 것인가였다.
기존 방식
초기 구조에서는 Service에서 prompt builder를 생성하고 LLM을 직접 호출하고 있었다.
Service → prompt 생성 → LLM 호출
이 구조는 Tool이 하나일 때는 크게 어색하지 않았다.
문제가 드러난 시점
summarize_context_tool이 추가되면서 상황이 달라졌다.
1
2
요약용 프롬프트
답변 생성 프롬프트
이 둘은 완전히 다른 책임을 가지는데
이걸 Service에서 모두 관리하려고 하니 책임이 섞이기 시작했다
여기서 생긴 판단 Service는 prompt를 알면 안 된다
이 기준을 세우면서 구조를 다시 보게 되었다.
해결 방법
프롬프트의 소유권을 Tool로 이동시켰다.
요약 프롬프트 → SummarizeContextTool
답변 프롬프트 → AnswerDraftTool
그리고 별도의 공통 prompt builder를 두기보다는
각 Tool 내부에 static method 형태로 포함 시키는 방식으로 단순화했다.
결과
구조가 다음과 같이 정리되었다.
1
2
Service → Tool 실행
Tool → prompt 생성 + LLM 호출
이로써 Service는 prompt 생성 LLM 호출 두 가지 책임에서 완전히 분리되었다.
4. 깨달은점
이번 과정을 통해 가장 크게 느낌점은
4.1 Tool 자체는 어렵지 않다
각 Tool을 만드는 것 자체는 비교적 단순했다.
- 검색
- 요약
- 답변 생성
각각은 독립적으로 구현 가능하다.
4.2 진짜 어려운건 연결이다.
문제는 Tool이 늘어나는 순간부터 시작된다.
1
2
3
- 입력/출력은 어떻게 맞출 것인가
- 어떤 순서로 실행할 것인가
- 실패하면 어떻게 처리할 것인가
즉,
Tool 구현 ««««« Tool 간 연결과 흐름 설계
4.3 이 과정에서 정리된 기준
Tool- 하나의 명확한 기능 수행
- 프롬프트 포함
LLM호출 포함
ServiceTool실행 흐름 담당- 비즈니스 시나리오 구성
PromptTool내부에 위치- 필요시 템플릿으로 분리
4.4 그리고 다음 단계
현재 구조는 정해진 순서대로 실행하는 구조를 가진다.
정해진 순서로 Tool을 실행하는 구조
이 지점에서 자연스럽게 다음 고민이 생긴다.
어떤 Tool을 사용할지 동적으로 결정을 하려면 어떻게 해야하지?
이 고민은 결국
Tool RegistryTool RouterAgent구조
로 이어지게 되며 위 항목들을 구현해 보면서 확장시킬 예정이다.
5. 마무리
Tool을 하나 만들 때는 보이지 않던 문제들이 Tool을 연결하는 순간 드러나기 시작했다.
그리고 이 과정에서 얻은 가장 중요한 인사이트는 이것이다.
1
2
문제는 Tool 자체가 아니라
Tool 사이의 관계였다
이 기준을 정리하면서 구조를 한 단계 더 발전시킬 수 있었고,
다음 단계인 Agent 구조로 자연스럽게 넘어갈 준비가 되었다.
service 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class BlogAnswerService(UserAnswerUseCase):
def __init__(
self,
search_blog_tool: SearchBlogTool,
llm: LLMPort,
prompt_builder: SearchBlogPromptBuilder,
):
self.search_blog_tool = search_blog_tool
self.llm = llm
self.prompt_builder = prompt_builder
def execute(self, text: str) -> str:
context = ToolContext(
agent_id="blog-answer-agent",
session_id="blog-answer-session",
user_id="anonymous",
trace_id=str(uuid.uuid4())
)
tool_result = self.search_blog_tool.execute(
input_data={"query": text},
context=context,
)
if not tool_result.success:
return f"블로그 게시글 조회 중 오류가 발생했습니다: {tool_result.error}"
references = tool_result.data.get("posts", [])
prompt = self.prompt_builder.build(
question=text,
references=references,
)
return self.llm.generate(prompt)
구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
\---app
| main.py
| test_main.http
|
+---adapter
| |
| +---inbound
| | | base_tool.py
| | | search_blog_tool.py
| | |
| |
| +---outbound
| | |
| | +---api
| | |
| | +---embed
| | | | ollama_embed_adapter.py
| | | |
| | |
| | +---llm
| | | | ollama_llm_adapter.py
| | | |
| | |
| | +---rag
| | |
| |
|
+---application
| |
| +---port
| | |
| | +---inbound
| | | | sync_blog_answer_usecase.py
| | | |
| | |
| | +---outbound
| | | | blog_post_chunk_query_port.py
| | | | blog_post_query_port.py
| | | | embed_port.py
| | | | llm_port.py
| | | |
| | |
| |
| +---service
| | | blog_answer_service.py
| | |
| | +---prompt
| | | | blog_answer_prompt_builder.py
| | | | prompt_builder.py
| | | |
| | |
| |
| +---usecase
| | |
| |
|
+---domain
| |
| +---model
| | | blog_post.py
| | | blog_post_chunk.py
| | |
| |
|
+---infrastructure
| | db.py
| |
| +---config
| | | settings.py
| | |
| |
| +---persistence
| | |
| | +---entity
| | | | blog_post_chunk_entity.py
| | | | blog_post_entity.py
| | | |
| | |
| | +---repository
| | | | blog_post_chunk_repository.py
| | | | blog_post_repository.py
service 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class BlogAnswerService(UserAnswerUseCase):
def __init__(
self,
search_blog_tool: SearchBlogTool,
answer_draft_tool: AnswerDraftTool,
summarize_context_tool: SummarizeContextTool,
):
self.search_blog_tool = search_blog_tool
self.answer_draft_tool = answer_draft_tool
self.summarize_context_tool = summarize_context_tool
def execute(self, text: str) -> str:
context = ToolContext(
agent_id="blog-answer-agent",
session_id="blog-answer-session",
user_id="anonymous",
trace_id=str(uuid.uuid4())
)
search_blog_tool_result = self.search_blog_tool.execute(
input_data={"query": text},
context=context,
)
blog_posts = search_blog_tool_result.data.get("posts", [])
summarize_context_tool_result = self.summarize_context_tool.execute(
input_data={"query": blog_posts},
context=context
)
summary = summarize_context_tool_result.data.get("summary")
input_data = {
"question": text,
"references": blog_posts,
"summary": summary
}
answer_draft_tool_result = self.answer_draft_tool.execute(
input_data={
"query": input_data
},
context=context
)
return answer_draft_tool_result.data.get("answer")
구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
\---app
| main.py
| test_main.http
| __init__.py
|
+---adapter
| | __init__.py
| |
| +---inbound
| | | __init__.py
| | |
| |
| +---outbound
| | | __init__.py
| | |
| | +---api
| | | __init__.py
| | |
| | +---embed
| | | | ollama_embed_adapter.py
| | | | __init__.py
| | | |
| | |
| | +---llm
| | | | ollama_llm_adapter.py
| | | | __init__.py
| | | |
| | |
| | +---rag
| | | __init__.py
| | |
| |
|
+---application
| | __init__.py
| |
| +---port
| | | __init__.py
| | |
| | +---inbound
| | | | sync_blog_answer_usecase.py
| | | | __init__.py
| | | |
| | |
| | +---outbound
| | | | blog_post_chunk_query_port.py
| | | | blog_post_query_port.py
| | | | embed_port.py
| | | | llm_port.py
| | | | __init__.py
| | | |
| | |
| |
| +---prompt
| | | __init__.py
| | |
| |
| +---service
| | | blog_answer_service.py
| | | __init__.py
| | |
| |
| +---tool
| | | answer_draft_tool.py
| | | base_tool.py
| | | search_blog_tool.py
| | | summarize_context_tool.py
| | | __init__.py
| | |
| |
| +---usecase
| | | __init__.py
| | |
| |
|
+---domain
| | __init__.py
| |
| +---model
| | | blog_post.py
| | | blog_post_chunk.py
| | | __init__.py
| | |
| |
|
+---infrastructure
| | db.py
| | __init__.py
| |
| +---config
| | | settings.py
| | | __init__.py
| | |
| |
| +---persistence
| | | __init__.py
| | |
| | +---entity
| | | | blog_post_chunk_entity.py
| | | | blog_post_entity.py
| | | | __init__.py
| | | |
| | |
| | +---repository
| | | | blog_post_chunk_repository.py
| | | | blog_post_repository.py
| | | | __init__.py