Hướng dẫn cài đặt lớp mở rộng danh sách dịch vụ AI cho EzyAI
Back to ezyaiTà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ụ AIchat**, 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ách | Dùng khi | Công sức |
|---|---|---|
Kế thừa ChatGptChatService | Nhà cung cấp có API tương thích OpenAI Chat Completions | Vài dòng |
Kế thừa AbstractAIChatService | Nhà cung cấp có API riêng nhưng hỗ trợ tool calling và SSE | Một lớp đầy đủ |
Cài trực tiếp ChatBotService của ezychat | Không cần profile, skill, tool của EzyAI, ví dụ một bot rule-based | Tự 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ụ theogetServiceName(). Mỗi lượt chat, manager gọi createInputStream để mở kếtnối tới nhà cung cấp,
createOutputStream để đọc stream, rồi chạy việc đọctrê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ủanhà 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ệt | Method cần override | Ví dụ có sẵn |
|---|---|---|
Đường dẫn chat không có /v1 | newChatUrl | GeminiChatService dùng /chat/completions |
| Đường dẫn danh sách model khác | newModelsUrl | |
| Tên trường max tokens | newRequestBody | DeepSeekChatService, GeminiChatService |
Header xác thực khác Authorization: Bearer | newRequestHeaders | ClaudeChatService 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, đọcdata[].id
Dịch vụ có API riêng
Khi API không tương thích OpenAI, kế thừa
AbstractAIChatService và càicác method sau.
ClaudeChatService là ví dụ đầy đủ nên đọc kèm.| Method | Việ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:
AbstractAIChatServicethêm"stream": truevào body quanewStreamRequestBody. Nếu provider dùng cách khác (ví dụ một URL streamriêng, hoặc cầnstream_options), overridenewStreamRequestBody. - Định dạng SSE: mỗi dòng
data:được parse thànhMaprồi truyền vàohandleStreamEvent, dòngdata: [DONE]kết thúc stream. Provider khôngdù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ớiChatBotStreamHandler.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
createInputStreamvàcreateOutputStreamđược giữ trongThreadLocal. 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ầnmộ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
- Thêm lớp dịch vụ vào
ezyai-sdk/.../service. - Thêm hằng số tên dịch vụ vào
AIChatServices, endpoint và model mặc địnhvàoEzyAIConstants, rồi khai báo trong hai mapDEFAULT_ENDPOINTS_BY_SERVICE,DEFAULT_MODELS_BY_SERVICE. Form thêmprofile sẽ điền sẵn hai giá trị này, và chúng được dùng khi admin đểtrống endpoint hoặc model. - 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ẵnsà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ôngsửa EzyAI:
- Admin, web: các plugin admin dùng chung một bean context (web cũngvậy),
ChatBotServiceManagerlấy dịch vụ bằnggetSingletonsOf(ChatBotService.class). Chỉ cần khai báo lớp con@EzySingletontrong admin/web plugin của bạn. - Socket: mỗi socket plugin có bean context riêng,
ezyai-socket-pluginchỉ đăng ký bean của chính nó. Plugin của bạn cần tự đăng ký vào appezychatkhi 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àAbstractAIChatServicetựinject (@EzyAutoBind):ObjectMapper,HttpClient,AIChatToolProvider,AISkillService,EzyAIChatProfileService,EzyAISettingService,EzyAIModelToModelConverter. Các bean này có sẵntrong context củaezyai-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ềChatBotOutputStreamnhận dữ liệuđó, gọihandler.onDelta(...)và trảChatBotAnswerModelquagetAnswer().isAvailable()trảfalsekhi 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
- Khởi động admin, vào EzyAI → Các dịch vụ AI chat, dịch vụ mới phảicó trong danh sách (sắp theo tên).
- Thêm profile cho dịch vụ, nhập api key, endpoint, model. Đặt làm profilemặc định rồi mở lại form để kiểm tra danh sách model lấy từ
newModelsUrl. - 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
donecó model, số token, stop reason:stateđược cập nhật đủ.
- Câu trả lời hiện dần theo thời gian thực:
- Gắn một kỹ năng có tool
search_knowledge_data_list, hỏi câu cần tracứu để kiểm tra vòng tool:readToolCalls,newAssistantMessage,newToolResultMessages,newToolChoiceNone. - Kiểm tra chatbot nhúng qua ezychat nếu dịch vụ được dùng ở socket.
Hiện tượng Nguyên nhân thường gặp Dịch vụ không có trong danh sách Thiếu lớp con @EzySingletonở admin plugin, hoặcgetServiceName()trùng với dịch vụ khácserviceName: invalidkhi thêm profileDịch vụ không được đăng ký trong AdminChatBotServiceManagerDanh sách model rỗng Chưa có profile mặc định, hoặc response models không có dạng data[].idCâu trả lời trống dù provider trả về handleStreamEventkhông trả về đoạn nội dung hoặc không gọistate.appendContentLỗi 400 ở vòng sau khi gọi tool newAssistantMessage/newToolResultMessagessai định dạng, thiếu id của tool callChatbot nhúng không dùng được dịch vụ Chưa đăng ký dịch vụ vào ChatBotServiceManagercủa appezychatở socket