Your Local LLM Isn't Bad at Tool Calling, Your Pipeline Is: Fixing Tool-Call Accuracy in vLLM
Tuần trước team mình chuyển một agent nội bộ từ API thương mại sang self-host bằng vLLM với Qwen2.5-7B-Instruct. Benchmark chat thì ổn, nhưng vừa chạy production thì tool calling hỏng liên tục: model trả về JSON thiếu field, gọi tool không tồn tại, hoặc nhét tool call vào giữa đoạn text làm parser

Tuần trước team mình chuyển một agent nội bộ từ API thương mại sang self-host bằng vLLM với Qwen2.5-7B-Instruct. Benchmark chat thì ổn, nhưng vừa chạy production thì tool calling hỏng liên tục: model trả về JSON thiếu field, gọi tool không tồn tại, hoặc nhét tool call vào giữa đoạn text làm parser không nhận ra. Accuracy đo được chỉ khoảng 70%. Sau hai ngày debug, mình nhận ra phần lớn lỗi không nằm ở model mà ở cách mình cấu hình server và xử lý output. Sau khi sửa pipeline, con số lên trên 97% mà không cần đổi model. Bài này chia sẻ lại những gì mình đã làm.
Nhiều người nghĩ khi gửi tools= qua OpenAI-compatible API thì server "tự hiểu" tool calling. Thực tế, model chỉ sinh ra text. vLLM phải dùng một tool call parser để tách phần text đó thành tool_calls có cấu trúc. Mỗi họ model có format riêng: Qwen và Hermes dùng tag <tool_call>...</tool_call>, Llama 3.x dùng format JSON riêng, Mistral dùng token [TOOL_CALLS].
flowchart LR A[Client gửi messages + tools] --> B[Chat template render tools vào prompt] B --> C[Model sinh text] C --> D{tool_choice?} D -->|auto| E[Tool parser tách text bằng regex/tag] D -->|required / named| F[Guided decoding theo JSON schema] E --> G[Response tool_calls] F --> G Điểm mấu chốt nằm ở nhánh D. Với tool_choice="auto", model sinh text tự do và parser phải "đoán" phần nào là tool call. Chỉ cần model viết thêm một câu dẫn hoặc quên đóng tag là parse fail, và client nhận về một message content bình thường thay vì tool_calls. Với tool_choice="required" hoặc chỉ định tên function cụ thể, các bản vLLM gần đây (0.8+) dùng structured outputs / guided decoding: ở mỗi bước sinh token, những token vi phạm JSON schema bị mask đi. Model không thể sinh JSON sai format dù có muốn.
Lỗi đầu tiên của mình rất ngớ ngẩn: dùng sai parser. Qwen2.5 phải dùng hermes, không phải llama3_json. Dùng sai thì parser không bao giờ match, mọi tool call đều rơi vào content.
# vLLM 0.9.x, Qwen2.5-7B-Instruct vllm serve Qwen/Qwen2.5-7B-Instruct \ --enable-auto-tool-choice \ --tool-call-parser hermes \ --max-model-len 16384 \ --gpu-memory-utilization 0.90 \ --port 8000 # Llama 3.1/3.2: --tool-call-parser llama3_json # Mistral: --tool-call-parser mistral Enter fullscreen mode Exit fullscreen mode Vài lưu ý thực tế:
Đây là thay đổi tạo ra nhiều cải thiện nhất. Thay vì để auto gánh hết, mình tách thành hai bước: bước một hỏi model có cần tool không (hoặc dùng router đơn giản), bước hai nếu cần thì gọi với tool_choice="required" để guided decoding đảm bảo JSON hợp lệ. Sau đó vẫn validate lại bằng Pydantic, vì guided decoding bảo đảm cú pháp chứ không bảo đảm ngữ nghĩa (ví dụ ngày 2026-02-30 vẫn đúng kiểu string).
# pip install openai==1.54.0 pydantic==2.9.2 import json from openai import OpenAI from pydantic import BaseModel, Field, ValidationError, field_validator from datetime import date client = OpenAI ( base_url = " http://localhost:8000/v1 ", api_key = " none " ) MODEL = " Qwen/Qwen2.5-7B-Instruct " class CreateInvoice ( BaseModel ): customer_id: str = Field ( pattern = r " ^CUS-\d{6}$ " ) amount_vnd: int = Field ( gt = 0 ) due_date: date @field_validator ( " due_date " ) @classmethod def not_in_past ( cls, v: date ) -> date: if v < date. today (): raise ValueError ( " due_date không được ở quá khứ " ) return v TOOLS = [{ " type ": " function ", " function ": { " name ": " create_invoice ", " description ": " Tạo hoá đơn cho khách hàng ", " parameters ": CreateInvoice. model_json_schema (), }, }] VALIDATORS = { " create_invoice ": CreateInvoice } def call_tool ( messages: list [ dict ], max_retries: int = 2 ): for attempt in range ( max_retries + 1 ): resp = client. chat. completions. create ( model = MODEL, messages = messages, tools = TOOLS, tool_choice = " required ", # bật guided decoding temperature = 0, ) msg = resp. choices [ 0 ]. message call = msg. tool_calls [ 0 ] validator = VALIDATORS. get ( call. function. name ) if validator is None: err = f " Tool ' { call. function. name } ' không tồn tại " else: try: args = validator. model_validate_json ( call. function. arguments ) return call. function. name, args except ValidationError as e: err = e. json ( include_url = False ) # Phản hồi lỗi cụ thể cho model để nó tự sửa messages = messages + [ msg. model_dump ( exclude_none = True ), { " role ": " tool ", " tool_call_id ": call. id, " content ": f " VALIDATION_ERROR: { err }. Hãy gọi lại tool với tham số đúng. " }, ] raise RuntimeError ( " Tool call vẫn sai sau khi retry " ) Enter fullscreen mode Exit fullscreen mode Vòng retry có feedback lỗi cụ thể là thứ rẻ nhất mà hiệu quả nhất.
Model 7B rất hay sai pattern ID ở lần đầu nhưng sửa đúng ngay khi được chỉ ra field nào sai. Giới hạn 2 lần retry để không đốt GPU vô ích; quá số lần thì đẩy sang con người xử lý, đừng để agent đoán bừa.
Trước đây mình đánh giá bằng cách "thử vài câu thấy ổn". Đó là lý do lỗi lọt ra production. Giờ mình giữ một file evals.jsonl khoảng 200 case lấy từ log thật, mỗi case có input và tool call kỳ vọng, rồi chạy mỗi lần đổi model, template hay prompt.
sequenceDiagram participant E as Eval script participant V as vLLM server participant R as Report E->>V: Gửi case (messages + tools) V-->>E: tool_calls E->>E: So sánh name + args với expected E->>R: Ghi pass/fail theo loại lỗi import json from collections import Counter def run_eval ( path: str = " evals.jsonl " ): stats = Counter () for line in open ( path, encoding = " utf-8 " ): case = json. loads ( line ) try: name, args = call_tool ( case [ " messages " ]) except Exception: stats [ " hard_fail " ] += 1 continue if name!= case [ " expected_name " ]: stats [ " wrong_tool " ] += 1 elif args. model_dump ( mode = " json " )!= case [ " expected_args " ]: stats [ " wrong_args " ] += 1 else: stats [ " pass " ] += 1 total = sum ( stats. values ()) for k, v in stats. most_common (): print ( f " { k: 12 } { v: 4 } ( { v / total:. 1 % } ) " ) if __name__ == " __main__ ": run_eval () Enter fullscreen mode Exit fullscreen mode Phân loại lỗi ( wrong_tool, wrong_args, hard_fail ) quan trọng hơn con số tổng. Nếu wrong_tool cao, vấn đề nằm ở description của tool hoặc có quá nhiều tool trùng ý.
Nếu wrong_args cao, xem lại schema và ví dụ trong system prompt. Nếu hard_fail cao, gần như chắc chắn là sai parser hoặc chat template.
Kết quả của mình qua từng bước: sai parser ~70%, đúng parser + auto ~86%, chuyển sang required + Pydantic ~93%, thêm retry có feedback ~97.5%.
Tool calling với model self-host không phải "cắm vào là chạy". Trước khi kết luận model nhỏ quá kém và nâng lên 70B, hãy kiểm tra pipeline:
Làm năm việc này mất khoảng một buổi chiều, nhưng giúp bạn tiết kiệm được cả tháng tiền GPU cho một model lớn hơn mà có thể bạn chưa cần đến.