Tài liệu này dành cho developer muốn thêm một nhà cung cấp AI mới vào EzyAI
ngoài sáu dịch vụ có sẵn (CHATGPT, CLAUDE, DEEPSEEK, GEMINI, GROK,
QWEN). Sau khi thêm, dịch vụ mới xuất hiện ở trang **Các dịch vụ AI
chat**, có profile, skill, tool, knowledge, topic guard và stream câu trả lời
giống các dịch vụ có sẵn.
Có ba mức cài đặt:
CáchDùng khiCông sức
Kế thừa ChatGptChatServiceNhà cung cấp có API tương thích OpenAI Chat CompletionsVài dòng
Kế thừa AbstractAIChatServiceNhà cung cấp có API riêng nhưng hỗ trợ tool calling và SSEMột lớp đầy đủ
Cài trực tiếp ChatBotService của ezychatKhông cần profile, skill, tool của EzyAI, ví dụ một bot rule-basedTự lo toàn bộ

Kiến trúc

classDiagram
    class ChatBotService {
        <<interface ezychat-sdk>>
        createInputStream(request) InputStream
        createOutputStream(handler) ChatBotOutputStream
        isAvailable() boolean
        getServiceName() String
    }
    class AIChatBotService {
        <<interface>>
        getModelNames() List~String~
    }
    class AbstractAIChatService {
        #newChatUrl(profile)
        #newModelsUrl(profile)
        #newRequestHeaders(profile)
        #newRequestMessages(request)
        #newRequestBody(profile, request, messages, tools)
        #readToolCalls(responseBody)
        #newAssistantMessage(responseBody)
        #newToolResultMessages(toolResults)
        #newToolChoiceNone()
        #toChatBotAnswer(profile, request, responseBody)
        #handleStreamEvent(event, state)
    }
    ChatBotService <|.. AbstractAIChatService
    AIChatBotService <|.. AbstractAIChatService
    AbstractAIChatService <|-- ChatGptChatService
    AbstractAIChatService <|-- ClaudeChatService
    ChatGptChatService <|-- DeepSeekChatService
    ChatGptChatService <|-- GeminiChatService
    ChatGptChatService <|-- MistralChatService
    note for MistralChatService "Ví dụ dịch vụ mới"
ChatBotServiceManager của ezychat quản lý các dịch vụ theo
getServiceName(). Mỗi lượt chat, manager gọi createInputStream để mở kết
nối tới nhà cung cấp, createOutputStream để đọc stream, rồi chạy việc đọc
trên thread pool riêng và trả kết quả qua ChatBotStreamHandler.
AbstractAIChatService đã lo phần chung, lớp con chỉ mô tả giao thức của
nhà cung cấp:
sequenceDiagram
    autonumber
    participant M as ChatBotServiceManager
    participant A as AbstractAIChatService
    participant P as Lớp con (provider)
    participant AI as Nhà cung cấp

    M->>A: createInputStream(request)
    A->>A: đọc profile, api key, skill, tool,<br/>ghép system message, topic guard
    A->>P: newRequestMessages(request)
    opt có tool được phép
        loop tối đa ezyai_max_allowed_tool_interactions
            A->>P: newRequestBody(profile, request, messages, tools)
            A->>AI: POST newChatUrl + newRequestHeaders
            AI-->>A: responseBody
            A->>P: toChatBotAnswer, readToolCalls
            alt không có tool call
                A-->>M: câu trả lời sẵn sàng
            else có tool call
                A->>A: gọi tool
                A->>P: newAssistantMessage, newToolResultMessages
            end
        end
    end
    A->>P: newStreamRequestBody (+ newToolChoiceNone nếu có tools)
    A->>AI: mở SSE
    M->>A: createOutputStream(handler)
    loop mỗi dòng data: của SSE
        A->>P: handleStreamEvent(event, state)
        P-->>M: đoạn nội dung mới
    end

Dịch vụ tương thích OpenAI

Nhiều nhà cung cấp (DeepSeek, Gemini, Grok, Qwen, Mistral, OpenRouter, các
server tự host như vLLM, Ollama) có API tương thích OpenAI. Chỉ cần kế thừa
ChatGptChatService, đặt tên dịch vụ và chỉnh những chỗ khác biệt.
Ví dụ thêm Mistral, dùng max_tokens thay cho max_completion_tokens:
public class MistralChatService extends ChatGptChatService {

    public static final String MISTRAL = "MISTRAL";

    private static final String FIELD_MAX_TOKENS = "max_tokens";
    private static final String FIELD_MAX_COMPLETION_TOKENS =
        "max_completion_tokens";

    @Override
    protected Map<String, Object> newRequestBody(
        AIChatProfileModel profile,
        ChatBotRequestModel request,
        List<Map<String, Object>> messages,
        List<AIChatToolModel> tools
    ) {
        Map<String, Object> body = super.newRequestBody(
            profile,
            request,
            messages,
            tools
        );
        body.put(
            FIELD_MAX_TOKENS,
            body.remove(FIELD_MAX_COMPLETION_TOKENS)
        );
        return body;
    }

    @Override
    public String getServiceName() {
        return MISTRAL;
    }
}
Những chỗ thường phải chỉnh:
Khác biệtMethod cần overrideVí dụ có sẵn
Đường dẫn chat không có /v1newChatUrlGeminiChatService dùng /chat/completions
Đường dẫn danh sách model khácnewModelsUrl
Tên trường max tokensnewRequestBodyDeepSeekChatService, GeminiChatService
Header xác thực khác Authorization: BearernewRequestHeadersClaudeChatService dùng x-api-key
Thêm tham số riêng (temperature, ...)newRequestBody
Mặc định ChatGptChatService gọi:
  • Chat: {endpoint}/v1/chat/completions
  • Danh sách model: {endpoint}/v1/models, đọc data[].id

Dịch vụ có API riêng

Khi API không tương thích OpenAI, kế thừa AbstractAIChatService và cài
các method sau. ClaudeChatService là ví dụ đầy đủ nên đọc kèm.
MethodViệc cần làm
newChatUrl(profile)URL gọi chat, dùng getBaseUrl(profile) để bỏ dấu / cuối của endpoint
newModelsUrl(profile)URL lấy danh sách model, kết quả phải có dạng {"data": [{"id": "..."}]}; nếu khác thì override getModelNames()
newRequestHeaders(profile)Header xác thực, api key lấy từ profile.getApiKey()
newRequestMessages(request)Chuyển request.getContextMessages() (và system message nếu provider để nó trong danh sách message) sang định dạng của provider. Danh sách trả về phải mutable vì vòng tool sẽ thêm message vào
newRequestBody(profile, request, messages, tools)Body của request: model, max tokens, messages, system message, tools. Tool có name, description, inputSchema (JSON Schema)
readToolCalls(responseBody)Đọc các lời gọi tool trong câu trả lời thành AIChatToolCallModel (id, name, arguments); rỗng nếu AI trả lời bằng văn bản
newAssistantMessage(responseBody)Message của assistant chứa các tool call, để gửi lại ở vòng sau
newToolResultMessages(toolResults)Message chứa kết quả tool theo định dạng của provider
newToolChoiceNone()Giá trị tool_choice cấm gọi tool, dùng ở vòng stream cuối để buộc AI trả lời bằng văn bản
toChatBotAnswer(profile, request, responseBody)Chuyển response không stream thành ChatBotAnswerModel: model, nội dung, stop reason, input/output tokens
handleStreamEvent(event, state)Đọc một event SSE (đã parse JSON), cập nhật state (model, stop reason, tokens), nối nội dung vào state.appendContent và trả về đoạn nội dung mới, null nếu event không có nội dung
getServiceName()Tên dịch vụ, viết hoa, duy nhất
Một số quy ước:
  • Stream: AbstractAIChatService thêm "stream": true vào body qua
    newStreamRequestBody. Nếu provider dùng cách khác (ví dụ một URL stream
    riêng, hoặc cần stream_options), override newStreamRequestBody.
  • Định dạng SSE: mỗi dòng data: được parse thành Map rồi truyền vào
    handleStreamEvent, dòng data: [DONE] kết thúc stream. Provider không
    dùng SSE dạng này cần tự cài stream.
  • Lỗi trong stream: khi gặp event báo lỗi, ném exception trong
    handleStreamEvent, lỗi sẽ được chuyển tới ChatBotStreamHandler.onError.
  • Thử lại: lỗi mạng tạm thời được thử lại tự động 3 lần, không cần xử
    lý trong lớp con.
  • Không thread-safe theo instance: dữ liệu giữa createInputStream và
    createOutputStream được giữ trong ThreadLocal. Không thêm field có
    trạng thái theo từng lượt chat vào lớp con.
Khung lớp:
public class AcmeChatService extends AbstractAIChatService {

    public static final String ACME = "ACME";

    @Override
    protected String newChatUrl(AIChatProfileModel profile) {
        return getBaseUrl(profile) + "/v2/chat";
    }

    @Override
    protected String newModelsUrl(AIChatProfileModel profile) {
        return getBaseUrl(profile) + "/v2/models";
    }

    @Override
    protected Map<String, String> newRequestHeaders(
        AIChatProfileModel profile
    ) {
        return Collections.singletonMap(
            "Authorization",
            "Bearer " + profile.getApiKey()
        );
    }

    @Override
    protected List<Map<String, Object>> newRequestMessages(
        ChatBotRequestModel request
    ) {
        List<Map<String, Object>> messages = new ArrayList<>();
        for (ChatBotMessageModel message : request.getContextMessages()) {
            messages.add(
                EzyMapBuilder.mapBuilder()
                    .put("role", message.getRole())
                    .put("text", message.getContent())
                    .toMap()
            );
        }
        return messages;
    }

    @Override
    protected Map<String, Object> newRequestBody(
        AIChatProfileModel profile,
        ChatBotRequestModel request,
        List<Map<String, Object>> messages,
        List<AIChatToolModel> tools
    ) {
        EzyMapBuilder builder = EzyMapBuilder.mapBuilder()
            .put("model", profile.getModel())
            .put("max_output_tokens", profile.getMaxTokens())
            .put("instructions", request.getSystemMessage())
            .put("messages", messages);
        if (!tools.isEmpty()) {
            builder.put("tools", newRequestTools(tools));
        }
        return builder.toMap();
    }

    @Override
    protected List<AIChatToolCallModel> readToolCalls(
        Map<String, Object> responseBody
    ) {
        ...
    }

    @Override
    protected Map<String, Object> newAssistantMessage(
        Map<String, Object> responseBody
    ) {
        ...
    }

    @Override
    protected List<Map<String, Object>> newToolResultMessages(
        List<AIChatToolResultModel> toolResults
    ) {
        ...
    }

    @Override
    protected Object newToolChoiceNone() {
        return "none";
    }

    @Override
    protected ChatBotAnswerModel toChatBotAnswer(
        AIChatProfileModel profile,
        ChatBotRequestModel request,
        Map<String, Object> responseBody
    ) {
        ...
    }

    @Override
    protected String handleStreamEvent(
        Map<String, Object> event,
        ChatBotStreamStateModel state
    ) {
        ...
    }

    @Override
    public String getServiceName() {
        return ACME;
    }
}

Đăng ký dịch vụ

Lớp ở ezyai-sdk không phải bean. Mỗi nơi chạy (admin, web, socket) cần
một lớp con khai báo bean để dịch vụ được tìm thấy.
flowchart TB
    SDK["MistralChatService<br/>(không phải bean)"]
    SDK --> A["AdminMistralChatService<br/>@EzySingleton"]
    SDK --> W["WebMistralChatService<br/>@EzySingleton"]
    SDK --> S["SocketMistralChatService<br/>@EzySingleton"]
    A --> AM["AdminChatBotServiceManager<br/>chat thử, danh sách dịch vụ,<br/>danh sách model"]
    W --> WM["WebChatBotServiceManager<br/>các plugin web gọi AI"]
    S -->|"SERVER_READY:<br/>addChatBotServices"| EM["ChatBotServiceManager<br/>của app ezychat<br/>(chatbot nhúng)"]

Thêm vào chính dự án EzyAI

  1. Thêm lớp dịch vụ vào ezyai-sdk/.../service.
  2. Thêm hằng số tên dịch vụ vào AIChatServices, endpoint và model mặc định
    vào EzyAIConstants, rồi khai báo trong hai map
    DEFAULT_ENDPOINTS_BY_SERVICE, DEFAULT_MODELS_BY_SERVICE. Form thêm
    profile sẽ điền sẵn hai giá trị này, và chúng được dùng khi admin để
    trống endpoint hoặc model.
  3. Thêm lớp con bean ở mỗi plugin, theo mẫu của các dịch vụ có sẵn:
    @EzySingleton
       public class AdminMistralChatService extends MistralChatService {}
    
    @EzySingleton
       public class WebMistralChatService extends MistralChatService {}
    
    @EzySingleton
       public class SocketMistralChatService extends MistralChatService {}
    
SocketPluginEzyAIServerReadyController tự đăng ký mọi bean
ChatBotService của ezyai-socket-plugin vào app ezychat khi server sẵn
sàng, không cần sửa gì thêm.

Thêm từ một plugin khác

Có thể đặt dịch vụ mới trong plugin riêng, phụ thuộc vào ezyai-sdk, không
sửa EzyAI:
  • Admin, web: các plugin admin dùng chung một bean context (web cũng
    vậy), ChatBotServiceManager lấy dịch vụ bằng
    getSingletonsOf(ChatBotService.class). Chỉ cần khai báo lớp con
    @EzySingleton trong admin/web plugin của bạn.
  • Socket: mỗi socket plugin có bean context riêng, ezyai-socket-plugin
    chỉ đăng ký bean của chính nó. Plugin của bạn cần tự đăng ký vào app
    ezychat khi server sẵn sàng:
    @EzySingleton
      @EzyEventHandler(EzyEventNames.SERVER_READY)
      public class SocketAcmeServerReadyController
          implements EzyEventController<EzyPluginContext, EzyServerReadyEvent> {
    
          @Override
          public void handle(
              EzyPluginContext ctx,
              EzyServerReadyEvent event
          ) {
              EzyAppContext chatAppContext = ctx
                  .getParent()
                  .getAppContext("ezychat");
              if (chatAppContext == null) {
                  return;
              }
              ChatBotServiceManager chatBotServiceManager = chatAppContext
                  .getProperty(EzyBeanContext.class)
                  .getBeanCast(ChatBotServiceManager.class);
              EzyBeanContext beanContext = ctx.getProperty(EzyBeanContext.class);
              chatBotServiceManager.addChatBotServices(
                  beanContext.getSingletonsOf(ChatBotService.class)
              );
          }
      }
    
    Bean dịch vụ ở socket cũng cần các bean mà AbstractAIChatService tự
    inject (@EzyAutoBind): ObjectMapper, HttpClient,
    AIChatToolProvider, AISkillService, EzyAIChatProfileService,
    EzyAISettingService, EzyAIModelToModelConverter. Các bean này có sẵn
    trong context của ezyai-socket-plugin, còn ở plugin riêng thì phải tự
    khai báo, nên với socket, đặt dịch vụ ngay trong EzyAI sẽ đơn giản hơn.
Endpoint và model mặc định nằm trong map tĩnh của AIChatServices, dịch vụ
thêm từ plugin khác sẽ không có giá trị mặc định: form profile để trống và
admin phải tự nhập endpoint, model.

Dịch vụ không cần profile của EzyAI

Nếu chỉ cần một bot đơn giản (rule-based, gọi một service nội bộ), có thể
cài thẳng ChatBotService của ezychat-sdk:
  • createInputStream(request) trả về stream dữ liệu thô.
  • createOutputStream(handler) trả về ChatBotOutputStream nhận dữ liệu
    đó, gọi handler.onDelta(...) và trả ChatBotAnswerModel qua
    getAnswer().
  • isAvailable() trả false khi chưa cấu hình xong, để ezychat không để
    bot im lặng trong kênh chat.
Cách này không có profile, skill, tool, knowledge, topic guard của EzyAI.

Kiểm tra

  1. Khởi động admin, vào EzyAI → Các dịch vụ AI chat, dịch vụ mới phải
    có trong danh sách (sắp theo tên).
  2. Thêm profile cho dịch vụ, nhập api key, endpoint, model. Đặt làm profile
    mặc định rồi mở lại form để kiểm tra danh sách model lấy từ
    newModelsUrl.
  3. Chat thử ở trang chi tiết dịch vụ:
    • Câu trả lời hiện dần theo thời gian thực: handleStreamEvent đúng.
    • Event done có model, số token, stop reason: state được cập nhật đủ.
  4. Gắn một kỹ năng có tool search_knowledge_data_list, hỏi câu cần tra
    cứu để kiểm tra vòng tool: readToolCalls, newAssistantMessage,
    newToolResultMessages, newToolChoiceNone.
  5. Kiểm tra chatbot nhúng qua ezychat nếu dịch vụ được dùng ở socket.
    Hiện tượngNguyên nhân thường gặp
    Dịch vụ không có trong danh sáchThiếu lớp con @EzySingleton ở admin plugin, hoặc getServiceName() trùng với dịch vụ khác
    serviceName: invalid khi thêm profileDịch vụ không được đăng ký trong AdminChatBotServiceManager
    Danh sách model rỗngChưa có profile mặc định, hoặc response models không có dạng data[].id
    Câu trả lời trống dù provider trả vềhandleStreamEvent không trả về đoạn nội dung hoặc không gọi state.appendContent
    Lỗi 400 ở vòng sau khi gọi toolnewAssistantMessage / newToolResultMessages sai định dạng, thiếu id của tool call
    Chatbot nhúng không dùng được dịch vụChưa đăng ký dịch vụ vào ChatBotServiceManager của app ezychat ở socket