Function calling / tool use qua ví dụ với Spring AI
1. Tổng quan
Một LLM chỉ biết những gì có trong dữ liệu huấn luyện. Nó không biết đơn hàng ORD-1001 của bạn đang ở đâu. Function calling (còn gọi là tool use) giải quyết đúng vấn đề này. Bạn mô tả cho model một số hàm. Model quyết định khi nào cần gọi hàm nào, với tham số gì. Ứng dụng của bạn thực thi hàm rồi trả kết quả lại cho model.
Bài này đi qua cơ chế ở mức JSON trước, sau đó xây dựng một trợ lý chăm sóc khách hàng bằng Spring AI. Trợ lý này tra cứu được trạng thái đơn và huỷ được đơn. Đọc xong, bạn sẽ khai báo được tool, truyền ngữ cảnh an toàn, xử lý lỗi và viết test mà không cần gọi model thật.
2. Function calling hoạt động thế nào
2.1. Vòng lặp hỏi – gọi – trả lời
Điểm dễ hiểu nhầm nhất: model không tự chạy code của bạn. Nó chỉ trả về một yêu cầu gọi hàm, dưới dạng JSON có cấu trúc. Một lượt hội thoại có tool diễn ra như sau:
- Ứng dụng gửi câu hỏi của người dùng, kèm danh sách tool (tên, mô tả, JSON Schema của tham số).
- Model trả về
tool_callsthay vì câu trả lời cuối. - Ứng dụng chạy hàm tương ứng rồi gửi kết quả lại dưới dạng message vai trò
tool. - Model đọc kết quả. Nó có thể gọi thêm tool khác, hoặc viết câu trả lời cuối.
2.2. Nhìn vào JSON thô
Định dạng dưới đây theo kiểu OpenAI. Các nhà cung cấp khác dùng cấu trúc tương tự, chỉ khác tên trường. Request gửi lên có phần tools:
{
"model": "gpt-4o-mini",
"messages": [
{ "role": "user", "content": "Đơn ORD-1001 của tôi tới đâu rồi?" }
],
"tools": [{
"type": "function",
"function": {
"name": "getOrderStatus",
"description": "Tra cứu trạng thái và ngày giao dự kiến của một đơn hàng",
"parameters": {
"type": "object",
"properties": { "orderId": { "type": "string" } },
"required": ["orderId"]
}
}
}]
}
Model không trả lời ngay. Nó yêu cầu gọi hàm:
{
"role": "assistant",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": { "name": "getOrderStatus", "arguments": "{\"orderId\":\"ORD-1001\"}" }
}]
}
Ứng dụng chạy hàm và gửi lại kết quả, gắn đúng tool_call_id:
{ "role": "tool", "tool_call_id": "call_abc123",
"content": "{\"orderId\":\"ORD-1001\",\"status\":\"SHIPPED\",\"eta\":\"2026-10-30\"}" }
Tới lượt này model mới viết câu trả lời cuối, chẳng hạn: “Đơn ORD-1001 đã được giao cho đơn vị vận chuyển, dự kiến tới vào 30/10.”
Hai điều cần nhớ. Thứ nhất, arguments là một chuỗi JSON do model sinh ra. Chuỗi này có thể sai kiểu hoặc thiếu trường. Thứ hai, chất lượng của description quyết định model có chọn đúng tool hay không.
Tự viết vòng lặp này không khó nhưng khá dài dòng. Spring AI làm sẵn phần đó cho bạn.
3. Cài đặt Spring AI
Thêm BOM và starter cho OpenAI. Nếu dùng nhà cung cấp khác, bạn chỉ cần đổi starter, phần code tool giữ nguyên.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
Cấu hình API key và model:
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.2
Temperature thấp giúp model chọn tool ổn định hơn. Với tác vụ tra cứu, bạn không cần sự “sáng tạo”.
4. Khai báo tool với @Tool
Ta dùng một kho đơn hàng in-memory cho gọn:
public record Order(String id, String customerId, String status, String eta) {}
public record OrderStatusReply(String orderId, String status, String eta) {}
Lớp tool là một bean bình thường. Mỗi method được đánh dấu @Tool sẽ trở thành một tool:
@Component
public class OrderTools {
private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
"ORD-1001", new Order("ORD-1001", "CUS-7", "SHIPPED", "2026-10-30"),
"ORD-1002", new Order("ORD-1002", "CUS-7", "PENDING", "2026-11-02")
));
@Tool(description = "Tra cứu trạng thái và ngày giao dự kiến của một đơn hàng")
public OrderStatusReply getOrderStatus(
@ToolParam(description = "Mã đơn hàng, dạng ORD-xxxx") String orderId,
ToolContext context) {
Order order = findOwnedOrder(orderId, context);
return new OrderStatusReply(order.id(), order.status(), order.eta());
}
@Tool(description = "Huỷ một đơn hàng chưa giao cho vận chuyển. Chỉ gọi khi khách đã xác nhận rõ ràng muốn huỷ")
public String cancelOrder(
@ToolParam(description = "Mã đơn hàng, dạng ORD-xxxx") String orderId,
@ToolParam(description = "Lý do huỷ do khách cung cấp", required = false) String reason,
ToolContext context) {
Order order = findOwnedOrder(orderId, context);
if (!"PENDING".equals(order.status())) {
throw new IllegalStateException(
"Đơn " + orderId + " đang ở trạng thái " + order.status() + ", không thể huỷ");
}
orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
return "Đã huỷ đơn " + orderId;
}
private Order findOwnedOrder(String orderId, ToolContext context) {
String customerId = (String) context.getContext().get("customerId");
Order order = orders.get(orderId);
if (order == null || !order.customerId().equals(customerId)) {
throw new IllegalArgumentException("Không tìm thấy đơn " + orderId);
}
return order;
}
}
Một số điểm đáng chú ý:
@Tool(description = …)chính làdescriptiontrong JSON ở mục 2.2. Mặc định, tên tool là tên method.@ToolParammô tả từng tham số.required = falseloại tham số khỏi danh sáchrequiredtrong schema.- Tham số kiểu
ToolContextkhông xuất hiện trong schema gửi cho model. Model không thấy và không điền được nó. Ta dùng nó để truyềncustomerIdtừ phiên đăng nhập. Nhờ vậy model không thể “bịa” ra mã khách hàng của người khác. - Kiểu trả về
OrderStatusReplyđược Spring AI serialize thành JSON trước khi gửi lại cho model. - Với đơn không thuộc khách hiện tại, ta trả “không tìm thấy” thay vì “không có quyền”. Cách này tránh lộ thông tin rằng đơn đó có tồn tại.
5. Gọi tool qua ChatClient
@Service
public class SupportAssistant {
private final ChatClient chatClient;
private final OrderTools orderTools;
public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
this.chatClient = builder
.defaultSystem("""
Bạn là trợ lý chăm sóc khách hàng của cửa hàng.
Chỉ trả lời dựa trên dữ liệu lấy từ tool, không đoán trạng thái đơn.
Trước khi huỷ đơn, hãy hỏi lại để khách xác nhận.""")
.build();
this.orderTools = orderTools;
}
public String answer(String customerId, String question) {
return chatClient.prompt()
.user(question)
.tools(orderTools)
.toolContext(Map.of("customerId", customerId))
.call()
.content();
}
}
.tools(orderTools) quét mọi method @Tool trong bean và gửi định nghĩa của chúng lên model. Khi model trả về tool_calls, Spring AI tự làm ba việc: parse arguments, gọi đúng method, rồi gửi kết quả lại. Vòng lặp này lặp tới khi model trả lời bằng văn bản. .content() chỉ trả về câu trả lời cuối cùng.
Thử với một controller nhỏ:
@RestController
@RequestMapping("/support")
public class SupportController {
private final SupportAssistant assistant;
public SupportController(SupportAssistant assistant) {
this.assistant = assistant;
}
@PostMapping
public String ask(@RequestBody String question, Principal principal) {
return assistant.answer(principal.getName(), question);
}
}
Khi khách CUS-7 hỏi “Đơn ORD-1001 của tôi tới đâu rồi?”, bạn nhận được kết quả kiểu:
Đơn ORD-1001 của bạn đã được giao cho đơn vị vận chuyển và dự kiến tới vào ngày 30/10/2026.
Câu chữ cụ thể sẽ khác nhau giữa các lần chạy. Dữ liệu thì luôn lấy từ tool.
6. Xem bên trong: ToolCallback và schema
Spring AI biến mỗi method @Tool thành một ToolCallback. Bạn có thể tự tạo ra chúng để xem model thực sự nhận được gì:
ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
for (ToolCallback cb : callbacks) {
System.out.println(cb.getToolDefinition().name());
System.out.println(cb.getToolDefinition().inputSchema());
}
Kết quả (đã rút gọn):
cancelOrder
{"type":"object","properties":{"orderId":{"type":"string","description":"Mã đơn hàng, dạng ORD-xxxx"},
"reason":{"type":"string","description":"Lý do huỷ do khách cung cấp"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"Mã đơn hàng, dạng ORD-xxxx"}},
"required":["orderId"],...}
Như đã nói ở mục 4, ToolContext không có mặt trong schema. Khi model chọn sai tool, bạn nên in schema ra như trên để kiểm tra trước tiên.
7. Kiểm thử không cần gọi model
Gọi model thật trong unit test vừa chậm, vừa tốn tiền, lại cho kết quả không ổn định. Thay vào đó, bạn test hai thứ: schema mà model nhận được và hành vi của tool khi nhận một chuỗi arguments giống hệt chuỗi model sẽ gửi.
class OrderToolsTest {
private final ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
private final ToolContext customer7 = new ToolContext(Map.of("customerId", "CUS-7"));
private ToolCallback tool(String name) {
return Arrays.stream(callbacks)
.filter(cb -> cb.getToolDefinition().name().equals(name))
.findFirst().orElseThrow();
}
@Test
void schemaShouldNotExposeToolContext() {
String schema = tool("getOrderStatus").getToolDefinition().inputSchema();
assertThat(schema).contains("\"orderId\"").doesNotContain("context");
}
@Test
void shouldReturnStatusAsJson() {
String result = tool("getOrderStatus")
.call("{\"orderId\":\"ORD-1001\"}", customer7);
assertThat(result).contains("\"status\":\"SHIPPED\"", "\"eta\":\"2026-10-30\"");
}
@Test
void shouldHideOrdersOfOtherCustomers() {
ToolContext stranger = new ToolContext(Map.of("customerId", "CUS-99"));
assertThatThrownBy(() -> tool("getOrderStatus").call("{\"orderId\":\"ORD-1001\"}", stranger))
.hasRootCauseInstanceOf(IllegalArgumentException.class)
.hasRootCauseMessage("Không tìm thấy đơn ORD-1001");
}
@Test
void shouldRefuseToCancelShippedOrder() {
assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
.isInstanceOf(ToolExecutionException.class)
.hasRootCauseMessage("Đơn ORD-1001 đang ở trạng thái SHIPPED, không thể huỷ");
}
@Test
void shouldCancelPendingOrder() {
String result = tool("cancelOrder")
.call("{\"orderId\":\"ORD-1002\",\"reason\":\"Đặt nhầm\"}", customer7);
assertThat(result).contains("Đã huỷ đơn ORD-1002");
}
}
MethodToolCallback bọc mọi exception từ method của bạn trong ToolExecutionException. Vì vậy các test trên kiểm tra root cause thay vì kiểu exception gốc. Bộ test này chạy trong vài mili-giây và kiểm tra đúng phần bạn kiểm soát được.
Hành vi của model (có chọn đúng tool không, có hỏi xác nhận trước khi huỷ không) thuộc loại kiểm thử khác. Bạn nên chạy nó bằng một bộ eval riêng với model thật, không đưa vào unit test.
8. Xử lý lỗi từ tool
Điều gì xảy ra khi cancelOrder ném exception trong lúc chạy thật? Mặc định (spring.ai.tools.throw-exception-on-error=false), Spring AI không làm vỡ request. Nó lấy message của exception và gửi lại cho model dưới dạng kết quả tool. Model đọc message và giải thích cho khách:
Rất tiếc, đơn ORD-1001 đã được giao cho đơn vị vận chuyển nên không thể huỷ nữa.
Bạn có muốn mình hướng dẫn thủ tục đổi trả khi nhận hàng không?
Hành vi này rất tiện, kéo theo hai hệ quả bạn cần lưu ý:
- Message của exception sẽ được model đọc, và thường được kể lại cho người dùng. Đừng ném exception chứa stack trace, câu SQL hay thông tin nội bộ.
- Nếu muốn lỗi hạ tầng (database sập chẳng hạn) đi thẳng lên tầng trên, hãy đặt:
spring.ai.tools.throw-exception-on-error=true
Một cách làm hợp lý: lỗi nghiệp vụ (đơn đã giao, không tìm thấy đơn) thì trả cho model, còn lỗi hạ tầng thì cho nổi lên để monitoring bắt được.
9. Lỗi hay gặp và khi nào không nên dùng
Lưu ý: Coi
argumentsdo model sinh ra như input từ người dùng không tin cậy. Prompt injection có thể khiến model gọicancelOrdervới mã đơn tuỳ ý. Mọi kiểm tra quyền phải nằm trong code tool, dựa trênToolContexthoặc security context, không dựa vào lời dặn trong system prompt.
Những lỗi thường gặp:
- Description mơ hồ. Viết “Lấy đơn hàng” thì model khó phân biệt với “Lấy lịch sử đơn hàng”. Hãy mô tả rõ tool làm gì, khi nào nên dùng và khi nào không.
- Quá nhiều tool trong một request. Mỗi định nghĩa tool tốn token, và model chọn kém hơn khi danh sách dài. Chỉ truyền những tool liên quan tới use case hiện tại.
- Tool có side effect mà không có bước xác nhận. Với thao tác huỷ đơn, hoàn tiền hay gửi email, hãy cân nhắc tách thành hai bước. Tool đầu chỉ chuẩn bị và trả về bản tóm tắt. Thao tác thật chỉ chạy sau khi người dùng bấm xác nhận trên UI, không phải chỉ sau khi model “nghĩ” rằng khách đã đồng ý.
- Thiếu idempotency. Model có thể gọi lại cùng một tool hai lần. Thiết kế
cancelOrderđể lần gọi thứ hai không gây hại. - Trả về dữ liệu quá lớn. Đổ nguyên 500 dòng lịch sử đơn hàng vào context vừa tốn token vừa làm model “lạc”. Hãy lọc và tóm gọn trong tool.
- Quên rằng mỗi tool call là thêm một round-trip tới model. Một câu hỏi cần ba tool có thể mất vài giây. Hãy đo latency trước khi đưa vào luồng cần phản hồi nhanh.
Khi nào không nên dùng function calling:
- Luồng xử lý đã cố định và biết trước (luôn tra đơn rồi mới trả lời). Khi đó hãy gọi service trực tiếp, rồi đưa kết quả vào prompt. Cách này rẻ hơn, nhanh hơn và dễ đoán hơn.
- Bạn chỉ cần model trả về dữ liệu có cấu trúc. Hãy dùng structured output (ví dụ
.call().entity(MyRecord.class)) thay vì giả lập bằng một tool. - Thao tác nhạy cảm, không đảo ngược được (chuyển tiền, xoá dữ liệu) mà không có bước xác nhận của con người.
10. Kết luận
Function calling không phải phép màu. Model chỉ trả về một yêu cầu gọi hàm bằng JSON, còn ứng dụng của bạn mới là bên thực thi. Spring AI giúp phần thực thi gọn hơn: @Tool và @ToolParam sinh ra schema, ChatClient.tools(...) lo vòng lặp gọi – trả kết quả, còn ToolContext truyền dữ liệu phiên mà model không chạm vào được.
Điều quan trọng nhất là đặt ranh giới tin cậy cho đúng. Hãy viết description rõ ràng và kiểm tra quyền ngay trong tool. Với thao tác có side effect, cần có xác nhận của người dùng. Cuối cùng, test tool qua ToolCallback với chuỗi JSON thật để biết chắc phần code của mình hoạt động đúng, bất kể model trả lời ra sao.