26 KiB
Sub-Agent Architecture — Deep Dive (5 câu hỏi)
Dựa trên feedback và khảo sát codebase MCP server + loyalty-agent.
1. MCP Tool Design cho Loyalty Multi-Module
Hiện trạng
loyalty-mcp-server/
└── tool/
├── CampaignTools.java (7 tools: search, count, getById, getByIds, generateId, checkId, create)
└── CampaignRuleTools.java (7 tools: search, count, getById, getByIds, generateId, checkId, create)
Tổng: 14 tools, tất cả expose qua 1 MCP SSE endpoint duy nhất (/sse). Agent nhận hết 14 tool definitions → context window bị ăn ~1000+ tokens chỉ cho tool schema.
Loyalty có nhiều module hơn
Nhìn vào client/api/ đã có sẵn MemberTierApi.java, PointPoolApi.java. Tương lai sẽ có:
┌─────────────────────────────────────────────────────────┐
│ LOYALTY CORE MODULES │
├──────────────┬──────────────┬──────────────┬────────────┤
│ Reward │ Member │ Transaction │ Partner │
│ │ │ │ │
│ • Campaign │ • Profile │ • TxnCode │ • Merchant │
│ • Rule │ • Tier │ • TxnHistory │ • Store │
│ • Formula │ • Card │ • Counter │ • Offer │
│ • Schedule │ • Segment │ • Statement │ │
│ • Counter │ • Point Pool │ • Batch │ │
│ • Alert │ • Enrollment │ │ │
└──────────────┴──────────────┴──────────────┴────────────┘
Nếu mỗi module có 5-7 tools → tổng 30-50 tools. 1 agent không thể xử lý tốt.
Đề xuất: Tool Groups + Agent Scoping
Phương án A — Grouped Tools trong 1 MCP Server (Đơn giản nhất)
Giữ 1 MCP server, nhưng tools tổ chức theo package/group. Agent-side filter theo tên prefix:
loyalty-mcp-server/
└── tool/
├── reward/
│ ├── CampaignTools.java → tool names: reward_searchCampaigns, reward_getCampaignById...
│ └── CampaignRuleTools.java → tool names: reward_searchRules, reward_getRuleById...
├── member/
│ ├── MemberTools.java → tool names: member_searchMembers, member_getMemberById...
│ └── TierTools.java → tool names: member_searchTiers...
└── transaction/
└── TransactionTools.java → tool names: txn_searchTransactions...
Agent-side filtering:
// Trong QueryAgent — chỉ load read tools của reward module
List<ToolCallback> tools = toolInterceptor.getWrappedTools(sessionId, messageId)
.stream()
.filter(t -> {
String name = t.getToolDefinition().name();
return name.startsWith("reward_") && !name.contains("create") && !name.contains("generate");
})
.toList();
Tip
Ưu điểm: Không cần đổi MCP server architecture. Chỉ rename tools + filter ở agent. Nhược điểm: Tất cả tool definitions vẫn load vào memory agent, chỉ filter khi truyền cho ChatClient.
Phương án B — Multi-MCP Endpoint (Mạnh hơn, phức tạp hơn)
Mỗi domain module là 1 MCP endpoint riêng. Agent chỉ connect tới endpoint cần thiết:
# application.yml — loyalty-agent
spring:
ai:
mcp:
client:
sse:
connections:
reward:
url: http://localhost:9331/sse # Reward tools only
member:
url: http://localhost:9331/member/sse # Member tools
transaction:
url: http://localhost:9331/txn/sse # Transaction tools
Nhưng Spring AI MCP client hiện tại auto-discover tất cả connections. Để chọn lọc, cần custom ToolCallbackProvider filtering.
Note
Khuyến nghị cho bước đầu: Dùng Phương án A (1 MCP server, tool name prefix, agent-side filter). Đơn giản, hiệu quả, không cần thay đổi infrastructure.
Phương án C — MCP Server per Module (Micro-MCP)
Tách thành nhiều MCP server process:
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ reward-mcp │ │ member-mcp │ │ txn-mcp │
│ :9331/sse │ │ :9332/sse │ │ :9333/sse │
│ Campaign* │ │ Member* │ │ Transaction* │
│ Rule* │ │ Tier* │ │ Counter* │
└───────────────┘ └───────────────┘ └───────────────┘
Warning
Overkill cho giai đoạn hiện tại. Chỉ hợp lý khi mỗi module có team riêng hoặc deploy riêng.
Tóm tắt: Phương án nào?
| Phương án | Độ phức tạp | Khi nào dùng |
|---|---|---|
| A. Prefix + Filter | ⭐ Thấp | Nên bắt đầu từ đây — 5-20 tools |
| B. Multi-MCP Endpoint | ⭐⭐ Trung bình | 20-50 tools, cần isolation |
| C. Micro-MCP Server | ⭐⭐⭐ Cao | 50+ tools, multi-team |
2. Shared Memory & Context Injection cho Creator Agent
Vấn đề
User flow thực tế khi tạo campaign + rule:
User: "danh sách chiến dịch" → QueryAgent, searchCampaigns
User: "xem thể lệ Happy Birthday" → QueryAgent, searchRules
User: "tạo thêm 1 rule mới tương tự" → CreatorAgent — CẦN BIẾT context trước đó!
Cần biết: campaignId, rule template,
formula type, existing rules...
CreatorAgent cần:
- Chat history — biết user đang nói về campaign nào
- Domain context — query các thông tin liên quan (pools, tiers, counters...)
- Validation data — kiểm tra ID hợp lệ, trùng lặp...
Kiến trúc Memory
┌──────────────────────────────────────────────────────────────┐
│ SHARED MEMORY LAYER │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ InMemoryChatMemory (existing) │ │
│ │ Key: conversationId (sessionId) │ │
│ │ Shared across ALL agents │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SessionContext (NEW) │ │
│ │ Key: sessionId │ │
│ │ Stores: activeCampaignId, lastQueryResult, │ │
│ │ workflowState, userPreferences │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Chat │ │ Query │ │ Creator │
│ Agent │ │ Agent │ │ Agent │
│ │ │ writes │ │ reads │
│ │ │ context│ │ context │
└─────────┘ └─────────┘ └─────────┘
Chat history (InMemoryChatMemory) đã shared — tất cả agent dùng chung conversationId làm key. Khi user chuyển từ QueryAgent sang CreatorAgent, history vẫn giữ nguyên.
SessionContext mới — lưu structured data:
public class SessionContext {
private String activeCampaignId; // Campaign đang được nói tới
private String activeCampaignName; // Tên để inject vào prompt
private String lastToolResult; // Kết quả tool gần nhất (compressed)
private Map<String, Object> metadata; // Flexible key-value
}
Context Enrichment cho Creator Agent
Khi Creator Agent bắt đầu tạo rule, nó cần query nhiều thông tin. Có 2 cách:
Cách 1: LLM tự gọi tools (hiện tại)
Để LLM quyết định gọi tools nào để lấy thông tin. Vấn đề: model nhỏ hay quên hoặc gọi sai.
Cách 2: Code-driven Context Injection (khuyên dùng)
public class CreatorAgent implements Agent {
@Override
public void execute(ChatRequest request, String sessionId) {
SessionContext ctx = sessionContextStore.get(sessionId);
// Auto-enrich: trước khi gọi LLM, query sẵn thông tin cần thiết
StringBuilder enrichedContext = new StringBuilder();
if (ctx.getActiveCampaignId() != null) {
// Auto-fetch campaign details
Campaign campaign = campaignService.findById(ctx.getActiveCampaignId());
enrichedContext.append("Campaign hiện tại: ")
.append(campaign.getName())
.append(" (ID: ").append(campaign.getId()).append(")\n");
// Auto-fetch existing rules
List<CampaignRule> existingRules = ruleService.findByCampaignId(campaign.getId());
enrichedContext.append("Rules hiện có: ").append(existingRules.size()).append("\n");
// Auto-fetch available pools
List<PoolDefinition> pools = poolService.getAll();
enrichedContext.append("Point pools: ")
.append(pools.stream().map(PoolDefinition::getName).toList()).append("\n");
}
chatClient.prompt()
.system(CREATOR_SYSTEM_PROMPT)
.user(enrichedContext + "\n\n" + request.prompt())
...
}
}
Important
Code-driven enrichment tốt hơn "LLM tự gọi tool" vì:
- Deterministic — không phụ thuộc vào khả năng reasoning của model nhỏ
- Nhanh hơn — gọi API trực tiếp, không qua LLM → tool → LLM loop
- Đầy đủ hơn — developer biết rõ cần context gì
3. Multi-Ollama Endpoints (Thinking vs Non-Thinking)
Setup
┌──────────────────┐ ┌──────────────────┐
│ Máy A (hiện tại)│ │ Máy B (mới) │
│ 192.168.99.10 │ │ 192.168.99.XX │
│ │ │ │
│ qwen3.5:4b │ │ qwen2.5:3b │
│ (thinking ON) │ │ (non-thinking) │
│ GPU: ... │ │ GPU: ... │
│ │ │ │
│ Dùng cho: │ │ Dùng cho: │
│ - Query Agent │ │ - Chat Agent │
│ - Creator Agent │ │ - Formatter │
└──────────────────┘ └──────────────────┘
Spring AI Config — Model Registry
# application.yml
spring:
ai:
ollama:
# Default (thinking model)
base-url: http://192.168.99.10:11434
chat:
options:
model: qwen3.5:4b
temperature: 0.3
num_ctx: 32768
# Custom — non-thinking endpoint
agent:
models:
chat:
base-url: http://192.168.99.XX:11434
model: qwen2.5:3b
temperature: 0.7
query:
base-url: http://192.168.99.10:11434
model: qwen3.5:4b
temperature: 0.3
creator:
base-url: http://192.168.99.10:11434
model: qwen3.5:4b
temperature: 0.2
Implementation
@Configuration
public class MultiModelConfig {
@Bean
public OllamaApi thinkingOllamaApi() {
return OllamaApi.builder()
.baseUrl("http://192.168.99.10:11434")
.build();
}
@Bean
public OllamaApi fastOllamaApi() {
return OllamaApi.builder()
.baseUrl("http://192.168.99.XX:11434")
.build();
}
@Bean("chatAgent.chatClient")
public ChatClient chatAgentClient(
@Qualifier("fastOllamaApi") OllamaApi api,
ToolCallingManager toolCallingManager) {
var model = OllamaChatModel.builder()
.ollamaApi(api)
.defaultOptions(OllamaChatOptions.builder()
.model("qwen2.5:3b")
.temperature(0.7)
.build())
.toolCallingManager(toolCallingManager)
.build();
return ChatClient.builder(model).build();
}
@Bean("queryAgent.chatClient")
public ChatClient queryAgentClient(
@Qualifier("thinkingOllamaApi") OllamaApi api,
ToolCallingManager toolCallingManager) {
var model = OllamaChatModel.builder()
.ollamaApi(api)
.defaultOptions(OllamaChatOptions.builder()
.model("qwen3.5:4b")
.temperature(0.3)
.build())
.toolCallingManager(toolCallingManager)
.build();
return ChatClient.builder(model).build();
}
}
Tip
Thay model dễ dàng: Khi có model tốt hơn (ví dụ
qwen3:8b-no-think), chỉ cần đổi trong config YAML, không sửa code.
4. Latency Optimization — Tránh Chain Agents
Vấn đề
Chain 2 agents nối tiếp → latency gấp đôi:
BAD: Query Agent (10s) → Formatter Agent (8s) = 18s tổng
Giải pháp: KHÔNG chain — mỗi agent tự hoàn chỉnh
GOOD: Query Agent (10s) — tự format trong cùng 1 prompt = 10s tổng
Cách thực hiện: Thay vì Formatter Agent riêng, nhúng formatting instruction vào agent_instruction (đã có sẵn!):
// Trong CampaignService.java (MCP server) — ĐÃ CÓ SẴN
return Result.of(campaigns, """
Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table.
Hiển thị mỗi chiến dịch theo dạng danh sách đánh số:
**[Tên]** (`[campaignId]`) — Owner: [ownerName]...
""");
agent_instruction pattern hiện tại đã là formatter — nằm trong tool result, không tốn thêm LLM call. Rất thông minh.
Nơi nào thực sự cần parallel/async
┌─────────────────────────────────────────────────────────────┐
│ Creator Agent — Tạo Rule Flow │
│ │
│ 1. User: "tạo rule mới" │
│ │
│ 2. Context Enrichment (PARALLEL, code-driven, ~500ms): │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ getCampaign() │ │ getRules() │ │ getPools() │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ └─────────────────┼─────────────────┘ │
│ ▼ │
│ 3. LLM Call (SINGLE, ~8-12s): │
│ System: Creator prompt + enriched context │
│ User: "tạo rule mới" │
│ → LLM hỏi thêm hoặc confirm │
│ │
│ Total: ~9-13s (vs 17s+ hiện tại vì LLM phải tự gọi tool) │
└─────────────────────────────────────────────────────────────┘
Parallel context fetching (Java CompletableFuture):
// Context enrichment — chạy song song, mất ~500ms thay vì 3 LLM calls
CompletableFuture<Campaign> campaignFuture = CompletableFuture.supplyAsync(
() -> campaignService.findById(campaignId));
CompletableFuture<List<CampaignRule>> rulesFuture = CompletableFuture.supplyAsync(
() -> ruleService.findByCampaignId(campaignId));
CompletableFuture<List<PoolDefinition>> poolsFuture = CompletableFuture.supplyAsync(
() -> poolService.getAll());
// Wait all (parallel) — ~500ms
CompletableFuture.allOf(campaignFuture, rulesFuture, poolsFuture).join();
// Inject into prompt
String context = buildContext(campaignFuture.get(), rulesFuture.get(), poolsFuture.get());
Tóm tắt chiến lược latency
| Kỹ thuật | Tiết kiệm | Áp dụng |
|---|---|---|
| Tắt thinking cho Chat Agent | 17s → 2-3s | Chat Agent |
Formatter = agent_instruction |
Bỏ 1 LLM call (~8s) | Query Agent |
| Parallel context fetch | 3 API calls: 1.5s → 0.5s | Creator Agent |
| Code-driven enrichment | Bỏ 2-3 tool-call loops (~20s) | Creator Agent |
5. On-Premise Fallback Strategy (Không dùng Cloud)
Constraint: Bảo mật dữ liệu → chỉ chạy on-premise
Bỏ hoàn toàn phương án cloud. Thay vào đó:
Strategy: Multi-Tier On-Premise Models
┌─────────────────────────────────────────────────────────────┐
│ ON-PREMISE MODEL TIERS │
│ │
│ Tier 1 (Fast, Light) Tier 2 (Strong, Slow) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Máy B │ │ Máy A (GPU mạnh) │ │
│ │ qwen2.5:3b │ │ qwen3.5:4b │ │
│ │ non-thinking │ │ thinking │ │
│ │ ~2-3s/response │ │ ~8-15s/response │ │
│ │ │ │ │ │
│ │ Dùng cho: │ │ Dùng cho: │ │
│ │ - Chat Agent │ │ - Query Agent │ │
│ │ - Simple Q&A │ │ - Creator Agent │ │
│ │ - Validation │ │ - Complex reasoning│ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Tương lai: Tier 3 (nếu có GPU mạnh hơn) │
│ ┌──────────────────┐ │
│ │ Máy C (A100/H100) │ │
│ │ qwen3.5:14b │ │
│ │ cho critical tasks │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Khi nào escalate từ Tier 1 → Tier 2?
Không cần "confidence-based routing" phức tạp. Dùng Intent-based routing đơn giản:
// Router quyết định dựa trên intent, không dựa trên confidence
switch (intent) {
case CONVERSATION → chatAgent; // Tier 1 (fast model)
case CAMPAIGN_QUERY,
RULE_QUERY,
UNKNOWN → queryAgent; // Tier 2 (thinking model)
case CAMPAIGN_CREATE,
RULE_CREATE → creatorAgent; // Tier 2 (thinking model)
}
Retry Strategy (nếu model trả kết quả kém)
Thay vì fallback lên cloud, retry cùng model với prompt khác:
// Nếu tool result rỗng hoặc LLM trả "tôi không biết"
if (isLowQualityResponse(response)) {
// Retry 1: thêm few-shot example vào prompt
// Retry 2: simplify câu hỏi
// Retry 3: báo user "Không tìm thấy, vui lòng thử cách khác"
}
Important
Không retry bằng model khác — retry bằng prompt khác trên cùng model hiệu quả hơn cho small LLMs. Thêm 1 ví dụ cụ thể vào prompt thường giải quyết 80% trường hợp model trả sai.
Tổng kết — Bước tiếp theo
┌──────────────────────────────────────────────────────────────┐
│ KIẾN TRÚC ĐỀ XUẤT CUỐI │
│ │
│ Máy B (fast) Máy A (strong) │
│ ┌────────────┐ ┌────────────┐ │
│ │ qwen2.5:3b │ │ qwen3.5:4b │ │
│ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ChatAgent │ │ QueryAgent │ │
│ │(no tools)│ │ (read tools, │ │
│ │ │ │ prefix filter) │ │
│ └──────────┘ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ CreatorAgent │ │
│ │ (write tools, │ │
│ │ code-driven │ │
│ │ enrichment) │ │
│ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Shared Memory Layer │ │
│ │ InMemoryChatMemory + SessionContext │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ MCP Server (1 process) │ │
│ │ Tools: reward_*, member_*, txn_* │ │
│ │ Agent-side filtering by prefix │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Nếu anh thấy hướng này ổn, mình có thể tạo change proposal (/opsx-propose) để bắt đầu implement từng phase:
- Phase 1: Agent interface + ChatAgent (tắt thinking, model riêng) — cải thiện "hello" ngay
- Phase 2: Tool prefix naming + QueryAgent (filtered tools) — giảm tool confusion
- Phase 3: CreatorAgent + SessionContext + code-driven enrichment — nâng chất lượng tạo campaign/rule
- Phase 4: Multi-Ollama config + ModelRegistry — hoàn thiện infra