# 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: ```java // Trong QueryAgent — chỉ load read tools của reward module List 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: ```yaml # 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: 1. **Chat history** — biết user đang nói về campaign nào 2. **Domain context** — query các thông tin liên quan (pools, tiers, counters...) 3. **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: ```java 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 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)** ```java 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 existingRules = ruleService.findByCampaignId(campaign.getId()); enrichedContext.append("Rules hiện có: ").append(existingRules.size()).append("\n"); // Auto-fetch available pools List 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ì: > 1. Deterministic — không phụ thuộc vào khả năng reasoning của model nhỏ > 2. Nhanh hơn — gọi API trực tiếp, không qua LLM → tool → LLM loop > 3. Đầ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 ```yaml # 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 ```java @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!): ```java // 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`): ```java // Context enrichment — chạy song song, mất ~500ms thay vì 3 LLM calls CompletableFuture campaignFuture = CompletableFuture.supplyAsync( () -> campaignService.findById(campaignId)); CompletableFuture> rulesFuture = CompletableFuture.supplyAsync( () -> ruleService.findByCampaignId(campaignId)); CompletableFuture> 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: ```java // 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**: ```java // 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: 1. **Phase 1**: Agent interface + ChatAgent (tắt thinking, model riêng) — cải thiện "hello" ngay 2. **Phase 2**: Tool prefix naming + QueryAgent (filtered tools) — giảm tool confusion 3. **Phase 3**: CreatorAgent + SessionContext + code-driven enrichment — nâng chất lượng tạo campaign/rule 4. **Phase 4**: Multi-Ollama config + ModelRegistry — hoàn thiện infra