Multi-agent: khi các AI agent giao việc cho nhau

1. Tổng quan

Khi ứng dụng LLM vượt khỏi mức “hỏi một câu, nhận một câu trả lời”, ta bắt đầu giao cho nó những việc nhiều bước, như tra đơn hàng, kiểm tra chính sách rồi soạn phản hồi. Nhồi tất cả vào một agent duy nhất sớm muộn sẽ gặp giới hạn. Multi-agent là cách chia việc đó cho nhiều agent chuyên biệt, có một agent điều phối đứng ra giao việc.

Bài này giải thích multi-agent là gì, vì sao chủ đề này đang được quan tâm và các mô hình phổ biến. Sau đó bạn sẽ tự dựng một orchestrator đơn giản bằng Java 21, có test và cấu hình Spring Boot. Bài cũng nêu rõ khi nào không nên dùng cách tiếp cận này.

2. Multi-agent là gì?

Một agent là một vòng lặp gồm ba phần: LLM nhận mục tiêu, quyết định hành động (gọi tool, trả lời, hỏi lại), rồi quan sát kết quả. Multi-agent là hệ thống có nhiều agent như vậy. Mỗi agent có system prompt, tool và phạm vi trách nhiệm riêng, và chúng chuyển việc cho nhau.

Có vài mô hình phối hợp hay gặp:

  • Orchestrator–worker (supervisor): một agent điều phối phân tích yêu cầu, chia thành việc con, giao cho các worker rồi tổng hợp kết quả.
  • Pipeline: các agent nối tiếp nhau, đầu ra của agent này là đầu vào của agent kế tiếp, ví dụ “nghiên cứu → viết nháp → biên tập”.
  • Handoff: agent đang xử lý tự nhận ra việc nằm ngoài phạm vi của mình và chuyển cả cuộc hội thoại sang agent khác, giống tổng đài chuyển máy.
  • Generator–critic: một agent tạo kết quả, agent khác đánh giá và yêu cầu sửa, lặp đến khi đạt hoặc hết số vòng.

Bài này tập trung vào orchestrator–worker. Đây là mô hình dễ hiểu nhất và áp dụng được cho nhiều bài toán.

3. Vì sao multi-agent đang được quan tâm?

Có vài lý do mang tính kỹ thuật:

  1. Context có giới hạn. Một agent ôm quá nhiều tool và hướng dẫn sẽ có prompt dài và dễ chọn sai tool. Agent chuyên biệt chỉ cần biết phần việc của nó.
  2. Chuyên môn hoá. Mỗi agent có thể dùng prompt, model và tham số riêng. Việc phân loại đơn giản có thể dùng model nhỏ, rẻ. Việc suy luận phức tạp thì dùng model mạnh hơn.
  3. Chạy song song. Các việc con độc lập có thể chạy đồng thời, giúp giảm thời gian phản hồi tổng.
  4. Phân quyền rõ ràng. Agent tra cứu chỉ có quyền đọc. Chỉ agent hoàn tiền mới được gọi API thanh toán. Ranh giới này giống phân quyền giữa các service.
  5. Dễ kiểm thử và đánh giá. Bạn có thể test và đo chất lượng từng agent riêng, thay vì một khối prompt khổng lồ.

Nói ngắn gọn, đây là nguyên lý “chia để trị” và “single responsibility” quen thuộc, áp dụng cho LLM.

4. Dựng orchestrator–worker bằng Java 21

Bài toán ví dụ: khách hàng hỏi “Đơn #1001 của tôi đâu rồi? Nếu trễ thì tôi có được hoàn tiền không?”. Ta có hai worker:

  • order: tra trạng thái đơn hàng.
  • refund: kiểm tra điều kiện hoàn tiền.

Orchestrator sẽ lập kế hoạch, giao việc song song cho hai worker rồi tổng hợp câu trả lời.

4.1. Dependency

Ví dụ chỉ cần Spring Boot starter cơ bản và bộ test. Phiên bản được quản lý bởi spring-boot-starter-parent:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

spring-boot-starter-test đã có sẵn JUnit 5 và AssertJ.

4.2. Trừu tượng hoá LLM và agent

Ta không gắn chặt vào một SDK cụ thể. Mọi lời gọi LLM đi qua một interface:

@FunctionalInterface
public interface LlmClient {
    String complete(String systemPrompt, String userMessage);
}

Trong production, bạn viết một adapter implement interface này bằng SDK hoặc framework mình đang dùng. Trong test, bạn truyền vào một lambda. Đó là lợi ích lớn nhất của lớp trừu tượng này.

Agent cũng là một interface. Ta thêm một implementation dựa trên LLM:

public interface Agent {
    String name();
    String handle(String task);
}

public record LlmAgent(String name, String systemPrompt, LlmClient llm) implements Agent {
    @Override
    public String handle(String task) {
        return llm.complete(systemPrompt, task);
    }
}

public record SubTask(String agent, String description) {}

Mỗi LlmAgent chỉ khác nhau ở systemPrompt, và trong thực tế còn khác ở bộ tool. Một worker cũng có thể là code thuần không gọi LLM, ví dụ một agent chỉ gọi OrderRepository. Orchestrator không cần biết bên trong worker làm gì.

4.3. Orchestrator

public class Orchestrator {

    private static final String PLANNER_PROMPT = """
            Bạn là điều phối viên chăm sóc khách hàng.
            Chia yêu cầu thành các việc con, mỗi dòng dạng: <agent>|<mô tả việc>
            Agent hợp lệ: %s. Tối đa %d dòng. Không viết gì khác.
            """;
    private static final String SYNTH_PROMPT = """
            Bạn tổng hợp kết quả từ các agent thành một câu trả lời ngắn gọn cho khách.
            Chỉ dùng thông tin có trong kết quả.
            """;

    private final LlmClient llm;
    private final Map<String, Agent> workers;
    private final int maxSubtasks;
    private final Duration timeout;

    public Orchestrator(LlmClient llm, List<Agent> agents, int maxSubtasks, Duration timeout) {
        this.llm = llm;
        this.workers = agents.stream().collect(Collectors.toMap(Agent::name, a -> a));
        this.maxSubtasks = maxSubtasks;
        this.timeout = timeout;
    }

    public String run(String request) {
        String plan = llm.complete(PLANNER_PROMPT.formatted(workers.keySet(), maxSubtasks), request);
        List<String> results = execute(parse(plan));
        return llm.complete(SYNTH_PROMPT,
                "Yêu cầu: " + request + "\nKết quả:\n" + String.join("\n", results));
    }

    List<SubTask> parse(String plan) {
        return plan.lines()
                .map(String::strip)
                .filter(line -> line.contains("|"))
                .map(line -> line.split("\\|", 2))
                .filter(parts -> workers.containsKey(parts[0].strip()))
                .limit(maxSubtasks)
                .map(parts -> new SubTask(parts[0].strip(), parts[1].strip()))
                .toList();
    }

    private List<String> execute(List<SubTask> tasks) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = tasks.stream()
                    .map(t -> executor.submit(() ->
                            t.agent() + ": " + workers.get(t.agent()).handle(t.description())))
                    .toList();
            return futures.stream().map(this::await).toList();
        }
    }

    private String await(Future<String> future) {
        try {
            return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            future.cancel(true);
            return "LỖI: agent quá thời gian";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "LỖI: bị ngắt";
        } catch (ExecutionException e) {
            return "LỖI: " + e.getCause().getMessage();
        }
    }
}

Những điểm đáng chú ý:

  • run gồm ba bước: lập kế hoạch (một lời gọi LLM), thực thi các việc con, rồi tổng hợp (thêm một lời gọi LLM nữa).
  • parse coi đầu ra của LLM là dữ liệu không đáng tin. Dòng sai định dạng bị bỏ qua. Tên agent không có trong danh sách cũng bị bỏ qua. Số việc con bị cắt ở maxSubtasks. Nếu LLM “bịa” ra một agent admin, nó sẽ không bao giờ được gọi.
  • execute dùng virtual thread của Java 21. Lời gọi LLM chủ yếu là chờ I/O mạng, nên virtual thread rất phù hợp: mỗi việc con một thread, không cần tự tinh chỉnh pool. Block try-with-resources chỉ đóng executor sau khi mọi task đã kết thúc.
  • await đặt timeout cho từng worker. Một worker chậm hay lỗi không làm hỏng cả yêu cầu. Kết quả lỗi được ghi thành chuỗi LỖI: … để bước tổng hợp vẫn chạy được và có thể báo cho khách rằng một phần thông tin chưa có.

Với yêu cầu ở đầu mục 4, kế hoạch mà LLM trả về có thể trông như sau:

order|Tra trạng thái đơn #1001
refund|Kiểm tra điều kiện hoàn tiền khi đơn #1001 giao trễ

Lưu ý: Ở đây ta dùng định dạng agent|mô tả cho dễ đọc. Trong thực tế, bạn nên yêu cầu LLM trả về JSON theo schema, hoặc dùng tính năng structured output/tool calling của nhà cung cấp. Dù dùng cách nào, bước validate phía code vẫn bắt buộc phải có.

5. Kiểm thử orchestrator

Vì LlmClient là functional interface, ta dùng lambda thay cho LLM thật. Test chạy nhanh, cho kết quả ổn định và không tốn tiền gọi API.

class OrchestratorTest {

    @Test
    void givenPlanWithUnknownAgent_whenRun_thenOnlyKnownWorkersAreCalled() {
        List<String> workerCalls = new CopyOnWriteArrayList<>();

        LlmClient coordinatorLlm = (system, user) -> system.contains("điều phối viên")
                ? """
                  order|Tra trạng thái đơn #1001
                  refund|Kiểm tra hoàn tiền đơn #1001
                  admin|Xoá toàn bộ đơn hàng
                  """
                : "TỔNG HỢP\n" + user;

        LlmClient workerLlm = (system, task) -> {
            workerCalls.add(task);
            return system.startsWith("ORDER") ? "Đơn #1001 đang giao, trễ 2 ngày"
                                              : "Đủ điều kiện hoàn phí vận chuyển";
        };

        var orchestrator = new Orchestrator(coordinatorLlm, List.of(
                new LlmAgent("order", "ORDER: tra cứu đơn hàng", workerLlm),
                new LlmAgent("refund", "REFUND: kiểm tra hoàn tiền", workerLlm)),
                3, Duration.ofSeconds(5));

        String answer = orchestrator.run("Đơn #1001 đâu rồi? Trễ thì có được hoàn tiền không?");

        assertThat(workerCalls).hasSize(2).noneMatch(task -> task.contains("Xoá"));
        assertThat(answer)
                .contains("order: Đơn #1001 đang giao, trễ 2 ngày")
                .contains("refund: Đủ điều kiện hoàn phí vận chuyển");
    }
}

Test này kiểm tra hai hành vi quan trọng:

  • Dòng admin|Xoá toàn bộ đơn hàng trong kế hoạch bị loại bỏ, nên chỉ có hai worker được gọi.
  • Kết quả của cả hai worker đều đến được bước tổng hợp. Thứ tự trong câu trả lời giữ nguyên thứ tự trong kế hoạch, dù hai worker chạy song song.

Nội dung answer mà lambda tổng hợp nhận được có dạng:

TỔNG HỢP
Yêu cầu: Đơn #1001 đâu rồi? Trễ thì có được hoàn tiền không?
Kết quả:
order: Đơn #1001 đang giao, trễ 2 ngày
refund: Đủ điều kiện hoàn phí vận chuyển

Loại test này chỉ kiểm tra phần điều phối, tức logic do bạn viết. Chất lượng câu trả lời của LLM thật cần được đánh giá riêng, bằng một bộ câu hỏi mẫu và tiêu chí chấm điểm.

6. Khai báo agent bằng cấu hình Spring Boot

Khi số worker tăng lên, việc khai báo chúng trong application.yml giúp bạn thêm hoặc sửa prompt mà không phải động vào code điều phối:

agents:
  max-subtasks: 4
  timeout: 20s
  workers:
    - name: order
      system-prompt: "Bạn tra cứu trạng thái đơn hàng. Chỉ trả lời dựa trên dữ liệu hệ thống."
    - name: refund
      system-prompt: "Bạn kiểm tra điều kiện hoàn tiền theo chính sách. Không tự hứa hoàn tiền."

Ánh xạ cấu hình vào một record:

@ConfigurationProperties(prefix = "agents")
public record AgentProperties(int maxSubtasks, Duration timeout, List<Worker> workers) {
    public record Worker(String name, String systemPrompt) {}
}

Sau đó tạo bean Orchestrator:

@Configuration
@EnableConfigurationProperties(AgentProperties.class)
public class AgentConfig {

    @Bean
    Orchestrator orchestrator(LlmClient llm, AgentProperties props) {
        List<Agent> agents = props.workers().stream()
                .<Agent>map(w -> new LlmAgent(w.name(), w.systemPrompt(), llm))
                .toList();
        return new Orchestrator(llm, agents, props.maxSubtasks(), props.timeout());
    }
}

Spring Boot tự chuyển 20s thành Duration và max-subtasks thành maxSubtasks. Bean LlmClient là adapter do bạn tự viết quanh SDK đang dùng. Nếu muốn, bạn có thể tạo nhiều bean LlmClient cho các model khác nhau, rồi để mỗi worker chọn model qua cấu hình.

7. Lỗi hay gặp và khi nào không nên dùng

7.1. Lỗi hay gặp

  • Tin tuyệt đối vào kế hoạch của LLM. Kế hoạch là đầu vào chưa được kiểm tra. Bạn cần whitelist tên agent, giới hạn số bước và validate tham số trước khi thực thi, nhất là với agent có quyền ghi dữ liệu.
  • Không giới hạn vòng lặp. Nếu agent được phép giao việc cho agent khác, hệ thống có thể rơi vào vòng lặp A → B → A. Luôn đặt số bước tối đa, timeout và ngân sách token cho mỗi yêu cầu.
  • Mất dấu vết. Một yêu cầu đi qua nhiều lời gọi LLM. Nếu không log và trace theo một mã yêu cầu chung, bạn sẽ không biết agent nào gây ra câu trả lời sai. Mỗi bước nên ghi lại prompt, kết quả, thời gian và chi phí.
  • Truyền thiếu ngữ cảnh. Worker chỉ thấy phần description mà orchestrator truyền cho nó. Nếu mô tả quá ngắn, chẳng hạn chỉ ghi “kiểm tra hoàn tiền” mà thiếu mã đơn, worker sẽ đoán. Hãy yêu cầu planner ghi đủ thông tin cần thiết vào từng việc con.
  • Lỗi bị khuếch đại. Một worker trả lời sai, bước tổng hợp lại diễn đạt nó một cách tự tin. Prompt tổng hợp cần dặn rõ là chỉ dùng thông tin có trong kết quả và phải báo khi có lỗi.

7.2. Khi nào KHÔNG nên dùng multi-agent

Lưu ý: Multi-agent không mặc nhiên “xịn” hơn single agent. Mỗi agent thêm vào là thêm lời gọi LLM, thêm độ trễ, thêm chi phí và thêm chỗ có thể hỏng.

Nên cân nhắc lại nếu rơi vào một trong các trường hợp sau:

  • Một agent với vài tool đã làm tốt. Hãy bắt đầu bằng single agent. Chỉ tách ra khi bạn thấy rõ vấn đề, như prompt quá dài, agent chọn sai tool hay cần phân quyền.
  • Luồng xử lý cố định. Nếu các bước luôn là A rồi B rồi C, một workflow viết bằng code thông thường (có thể gọi LLM ở từng bước) sẽ dễ dự đoán, dễ test và rẻ hơn để LLM tự lập kế hoạch.
  • Yêu cầu độ trễ thấp. Riêng mô hình ở trên đã cần ít nhất hai lời gọi tuần tự (lập kế hoạch và tổng hợp), chưa kể thời gian của các worker.
  • Các bước phụ thuộc chặt vào nhau. Nếu mỗi bước cần toàn bộ ngữ cảnh của bước trước, việc chia cho nhiều agent chỉ khiến thông tin bị cắt vụn và mất mát.

8. Kết luận

Multi-agent áp dụng nguyên lý chia để trị cho ứng dụng LLM. Mỗi agent có phạm vi hẹp, prompt và quyền riêng, còn một orchestrator lo việc lập kế hoạch và tổng hợp. Trong bài, bạn đã dựng mô hình orchestrator–worker bằng Java 21: tách LlmClient để dễ test, validate kế hoạch do LLM sinh ra, chạy worker song song bằng virtual thread có timeout, và khai báo worker qua cấu hình Spring Boot.

Điều quan trọng nhất cần nhớ là hãy coi đầu ra của LLM như dữ liệu chưa được kiểm tra, và luôn đặt giới hạn về số bước, thời gian và chi phí. Hãy bắt đầu từ một agent duy nhất. Chỉ chuyển sang multi-agent khi bạn có lý do cụ thể và đo được lợi ích.

ai-agentmulti-agentllmjavaspring-boot