Files
loyalty-agent-service/sub_agent_deep_dive.md

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:

  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:

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ì:

  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

# 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:

  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